diff --git a/.gitignore b/.gitignore index e6dfe4ba7..27a9265d3 100644 --- a/.gitignore +++ b/.gitignore @@ -347,3 +347,4 @@ x86/ dashboard/.azure/ dashboard/dist/ dashboard/**/dist/ + diff --git a/plugin/skills/deploy-to-aks/SKILL.md b/plugin/skills/deploy-to-aks/SKILL.md new file mode 100644 index 000000000..48cca9d29 --- /dev/null +++ b/plugin/skills/deploy-to-aks/SKILL.md @@ -0,0 +1,115 @@ +--- +name: deploy-to-aks +license: MIT +metadata: + author: Microsoft + version: "1.0.0" +description: "Use when deploying a web application or API to an existing Azure Kubernetes Service cluster. Detects framework, generates Dockerfile and Kubernetes manifests, validates against AKS Deployment Safeguards, and deploys with verification. WHEN: deploy to AKS, deploy app to Kubernetes, containerize for AKS, deploy to existing AKS cluster, generate K8s manifests for Azure, set up CI/CD for AKS, migrate app to AKS, deploy container to Azure, I have a Django/Express/Spring Boot app and want to run it on AKS, my AKS deployment is failing safeguard checks." +--- + +# Deploy to AKS + +Deploy applications to an existing AKS cluster with production-grade artifacts. Detects the framework, generates Dockerfile + K8s manifests, validates against Deployment Safeguards, and deploys — with minimal questions. + +## When to Use This Skill + +**Use this skill when:** +- You want to deploy an existing web application or API to an AKS cluster +- You need to containerize an app for Kubernetes and generate deployment manifests +- Your AKS deployment is failing Deployment Safeguard checks (DS001–DS013) and you need guidance +- You want to set up or improve CI/CD pipelines for AKS deployments +- You're migrating an application from another platform to AKS + +**Do NOT use this skill for:** +- Provisioning or creating a new AKS cluster (use a separate provisioning skill) +- Deploying to non-AKS compute targets (Web Apps, Container Apps, etc.) +- Managing cluster infrastructure, scaling policies, or node pools +- Performing Kubernetes cluster administration tasks (RBAC, networking policies, etc.) + +## MCP Tools + +| Tool | Purpose | Required | +|------|---------|----------| +| `azure-documentation` | Fetch Azure documentation and configuration references | Yes | +| Terminal commands | Execute `kubectl`, `az`, `docker`, `gh` CLI commands | Yes | + +## Error Handling + +| Error | Likely Cause | Recovery | +|-------|--------------|----------| +| Safeguard validation failure (DS001–DS013) | Manifest violates deployment best practices (missing resource limits, security policies, etc.) | Review the safeguard checklist in `references/safeguards.md`, apply recommended fixes, re-validate | +| Image push fails to ACR | ACR not attached to cluster or authentication token expired | Run `az acr login --name `, verify ACR attachment with `az aks check-acr`, retry push | +| `kubectl apply` fails | Manifest syntax error or unsupported API version | Check manifest YAML syntax, verify API version compatibility with cluster Kubernetes version using `kubectl api-resources` | +| Pod CrashLoopBackOff | Application fails to start (missing env vars, config, port mismatch) | Check logs with `kubectl logs `, verify health endpoint config, ensure all required environment variables are set in ConfigMap/Secrets | +| Workload Identity auth failure | OIDC not configured or service account not mapped | Follow `references/workload-identity.md` to set up federated identity credentials and service account annotations | +| Deployment rollout stuck | Resource quota exceeded or image pull failure | Check `kubectl describe deployment`, verify resource requests fit quota, ensure image pull secrets are configured, check node readiness | + +## Prerequisites + +- An existing AKS cluster +- Azure CLI authenticated (`az login`) +- `kubectl` configured for the target cluster + +## Workflow + +Follow the quick deploy workflow in `phases/quick-deploy.md`. The workflow has 5 sections: + +1. **Detection** — scan project for framework/port/health endpoints; detect AKS cluster, ACR, routing mode +2. **File Generation** — generate Dockerfile + K8s manifests from templates +3. **Safeguards Validation** — validate manifests against AKS Deployment Safeguards DS001-DS013 +4. **Deploy** — build image, push to ACR, apply manifests +5. **Verify** — confirm pods running, external IP available, health check passing + +## Quick Reference + +| Property | Value | +|----------|-------| +| Best for | Deploying apps to an existing AKS cluster | +| MCP Tools | `azure-documentation` | +| CLI | `az acr build`, `kubectl apply`, `kubectl rollout status` | +| Related skills | azure-kubernetes (cluster provisioning), azure-diagnostics (troubleshooting) | + +## Workflow Quick Reference + +| Step | Read | Also load | +|------|------|-----------| +| Quick Deploy | `phases/quick-deploy.md` | `references/detection.md`, `knowledge-packs/frameworks/.md` (if exists), `references/safeguards.md`, `references/workload-identity.md`, `references/rollback.md` (on failure) | + +## References + +Load these on-demand based on workflow phase: + +- [detection.md](./references/detection.md) — framework, port, and health endpoint detection tables +- [safeguards.md](./references/safeguards.md) — AKS Deployment Safeguards DS001-DS013 checklist +- [workload-identity.md](./references/workload-identity.md) — Azure Workload Identity setup for AKS pods +- [rollback.md](./references/rollback.md) — recovery procedures for deployment failures + +## Knowledge Packs + +After detecting the framework, load the matching pack from `knowledge-packs/frameworks/` if available. Packs provide framework-specific Dockerfile patterns, health endpoints, database config, and writable path requirements. + +Available: `spring-boot`, `express`, `nextjs`, `fastapi`, `django`, `nestjs`, `aspnet-core`, `go`, `flask` + +## Templates + +Templates are starting points — replace `` placeholders with detected values. + +| Category | Directory | Files | +|----------|-----------|-------| +| Dockerfiles | `templates/dockerfiles/` | node, python, java, go, dotnet, rust (+ matching `.dockerignore` per language) | +| K8s manifests | `templates/k8s/` | namespace, deployment, service, ingress, gateway, httproute, hpa, pdb, serviceaccount, configmap, networkpolicy | +| CI/CD | `templates/github-actions/` | deploy.yml | +| Diagrams | `templates/mermaid/` | architecture-diagram, summary-dashboard | + +## Execution Model + +- **Generate artifacts automatically** — Dockerfiles, manifests, workflows +- **Execute CLI commands only with confirmation** — `az`, `docker`, `kubectl`, `gh` +- **Detect before create** — check for existing Dockerfiles, manifests, CI/CD +- **Validate before replace** — improve what exists rather than overwriting + +## Key Principles + +- ONE concept per turn — never overload the developer +- Sensible defaults — Ingress (Web App Routing), Workload Identity, 2 replicas +- Teach while fixing — when auto-fixing Safeguard violations, explain why diff --git a/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/aspnet-core.md b/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/aspnet-core.md new file mode 100644 index 000000000..e3f577062 --- /dev/null +++ b/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/aspnet-core.md @@ -0,0 +1,238 @@ +# ASP.NET Core Knowledge Pack + +> **Applies to:** Projects detected with `*.csproj` containing `Microsoft.NET.Sdk.Web` or referencing `Microsoft.AspNetCore.*` packages + +--- + +## Dockerfile Patterns + +### Multi-stage build with project-file-first NuGet restore + +Copying only `*.csproj` and restoring before copying source ensures NuGet restore is cached unless dependencies change: + +```dockerfile +# Build stage +FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build +WORKDIR /app +COPY *.csproj ./ +RUN dotnet restore +COPY . . +RUN dotnet publish -c Release -o /app/publish + +# Runtime stage +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime +WORKDIR /app +ENV DOTNET_EnableDiagnostics=0 \ + DOTNET_RUNNING_IN_CONTAINER=true +COPY --from=build /app/publish . +USER app +EXPOSE 8080 +ENTRYPOINT ["dotnet", ".dll"] +``` + +### Key points + +- **Base image:** `mcr.microsoft.com/dotnet/aspnet` is the official Microsoft runtime image — minimal and supported +- **Alpine variant** reduces image size by ~60% compared to the Debian-based tag +- **Project-file-first copy** (`COPY *.csproj`) means `dotnet restore` layer is cached until dependencies change +- **Non-root user** (`app`, uid 1654) is built into .NET 8+ images — no need to create one manually; satisfies DS004 +- **`DOTNET_EnableDiagnostics=0`** disables diagnostic pipes that require writable paths not available in read-only filesystems +- **`DOTNET_RUNNING_IN_CONTAINER=true`** signals the runtime to optimize for container environments (GC, thread pool) + + + +--- + +## Health Endpoints + +ASP.NET Core has built-in health check middleware via `Microsoft.Extensions.Diagnostics.HealthChecks`: + +| Endpoint | Purpose | Probe Type | +|----------|---------|-----------| +| `/healthz` | Overall health | `livenessProbe` | +| `/ready` | Dependency readiness | `readinessProbe` | + +### Required configuration + +In `Program.cs`: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +// Register health checks +builder.Services.AddHealthChecks() + .AddNpgSql(builder.Configuration.GetConnectionString("DefaultConnection")!, + name: "postgresql", + tags: new[] { "ready" }); + +var app = builder.Build(); + +// Map health endpoints +app.MapHealthChecks("/healthz", new HealthCheckOptions +{ + Predicate = _ => false // No dependency checks for liveness +}); + +app.MapHealthChecks("/ready", new HealthCheckOptions +{ + Predicate = check => check.Tags.Contains("ready") +}); +``` + +The `AspNetCore.HealthChecks.NpgSql` NuGet package provides the PostgreSQL health check. Install with: + +```bash +dotnet add package AspNetCore.HealthChecks.NpgSql +``` + +### Probe configuration in Deployment manifest + +```yaml +livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 +readinessProbe: + httpGet: + path: /ready + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 +``` + +**Note:** ASP.NET Core apps start significantly faster than JVM-based frameworks — `initialDelaySeconds: 5` is typically sufficient. + +--- + +## Database Profiles + +ASP.NET Core uses configuration providers and Entity Framework Core for database access: + +| Configuration Source | Activation | Typical Usage | +|---------------------|------------|---------------| +| `appsettings.json` | Default | Local dev with SQLite or LocalDB | +| `appsettings.Production.json` | `ASPNETCORE_ENVIRONMENT=Production` | Production connection strings | +| Environment variables | Always override file config | AKS deployments | + +### Environment variables for PostgreSQL on AKS + +```yaml +env: + - name: ASPNETCORE_ENVIRONMENT + value: Production + - name: ConnectionStrings__DefaultConnection + value: "Host={{PG_SERVER_NAME}}.postgres.database.azure.com;Database={{DB_NAME}};Username={{IDENTITY_NAME}};Ssl Mode=Require" +``` + +The double-underscore (`__`) in `ConnectionStrings__DefaultConnection` maps to the `:` separator in .NET configuration — `ConnectionStrings:DefaultConnection`. + +### Workload Identity with Azure.Identity + +See `references/workload-identity.md` for connection patterns. Requires `Azure.Identity` and `Npgsql.EntityFrameworkCore.PostgreSQL` packages. + +### ConfigMap pattern + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{APP_NAME}}-config +data: + ASPNETCORE_ENVIRONMENT: "Production" + ConnectionStrings__DefaultConnection: "Host={{PG_SERVER_NAME}}.postgres.database.azure.com;Database={{DB_NAME}};Ssl Mode=Require" + DOTNET_EnableDiagnostics: "0" + DOTNET_RUNNING_IN_CONTAINER: "true" +``` + +--- + +## Writable Paths (DS012 Compliance) + +When `readOnlyRootFilesystem: true` is set, ASP.NET Core needs `/tmp` writable: + +- **Data Protection keys** are written to a local directory by default for key persistence +- **Temporary files** from multipart uploads and response buffering use `/tmp` +- **Entity Framework** compiled models may write to temp directories + +### Required volume mount + +```yaml +volumes: + - name: tmp + emptyDir: {} +containers: + - name: app + volumeMounts: + - name: tmp + mountPath: /tmp +``` + +### Data Protection key persistence + +By default, ASP.NET Core Data Protection stores encryption keys in-memory when no persistent path is available, meaning keys are lost on pod restart. This breaks authentication cookies and anti-forgery tokens across pod restarts or in multi-replica deployments. + +For production, persist keys to Azure Blob Storage: + +```csharp +builder.Services.AddDataProtection() + .PersistKeysToAzureBlobStorage("", "", "") + .ProtectKeysWithAzureKeyVault(new Uri(""), new DefaultAzureCredential()); +``` + +Alternatively, mount a PVC at a known path and configure: + +```csharp +builder.Services.AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo("/keys")); +``` + +--- + +## Resource Sizing + +ASP.NET Core on the .NET runtime is efficient but needs moderate memory for the CLR. + +| Resource | Request | Limit | +|----------|---------|-------| +| CPU | 200m | 500m | +| Memory | 256Mi | 512Mi | + +--- + +## Port Configuration + +- **Default port:** 8080 (since .NET 8; previously 80 in .NET 7 and earlier) +- **Env var override:** `ASPNETCORE_URLS=http://+:8080` or `ASPNETCORE_HTTP_PORTS=8080` +- **Code override:** `builder.WebHost.UseUrls("http://+:8080")` in `Program.cs` + +The port change from 80 to 8080 in .NET 8 aligns with non-root container best practices — port 80 requires elevated privileges. + +--- + +## Build Commands + +| Variant | Build Command | Output | +|---------|---------------|--------| +| Framework-dependent | `dotnet publish -c Release -o ./publish` | `./publish/.dll` — requires .NET runtime on target | +| Self-contained | `dotnet publish -c Release --self-contained -o ./publish` | `./publish/` — includes .NET runtime | +| Single-file | `dotnet publish -c Release --self-contained -p:PublishSingleFile=true -o ./publish` | Single executable binary | + +The `-c Release` flag enables compiler optimizations and disables debug symbols — always use it for production builds. + +--- + +## Common Issues on AKS + +| Issue | Symptom | Fix | +|-------|---------|-----| +| Kestrel bound to port 80 | `CrashLoopBackOff` — permission denied binding to port 80 as non-root | Set `ASPNETCORE_HTTP_PORTS=8080` or upgrade to .NET 8+ which defaults to 8080 | +| Data Protection keys lost on restart | Users logged out after pod restart, anti-forgery token validation failures | Persist keys to Azure Blob Storage or a PVC — do not rely on in-memory default | +| EF Core migrations not applied | `NpgsqlException: relation "..." does not exist` | Run `dotnet ef database update` as an init container or at startup with `Database.Migrate()` | +| Image too large (>500MB) | Slow pulls, high ACR storage | Use self-contained + trimmed publish with `runtime-deps` Alpine base image | +| HTTPS redirect loop behind gateway | Infinite 307/308 redirects, `ERR_TOO_MANY_REDIRECTS` | Disable HTTPS redirection in `Program.cs` when behind a TLS-terminating gateway — configure `ForwardedHeaders` middleware instead | diff --git a/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/django.md b/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/django.md new file mode 100644 index 000000000..a5ce4fe62 --- /dev/null +++ b/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/django.md @@ -0,0 +1,239 @@ +# Django Knowledge Pack + +> **Applies to:** Projects detected with `requirements.txt`, `pyproject.toml`, or `Pipfile` containing `django`, or presence of `manage.py` + +--- + +## Dockerfile Patterns + +### Multi-stage build with virtual environment and collectstatic + +Django requires a build stage that installs dependencies **and** collects static assets before the runtime stage: + +```dockerfile +# Build stage +FROM python:3.12-slim AS build +WORKDIR /app +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +RUN SECRET_KEY=build-placeholder python manage.py collectstatic --noinput +``` + +The `SECRET_KEY=build-placeholder` is necessary because `collectstatic` imports Django settings, which require a `SECRET_KEY` — but the real secret is never baked into the image. + +```dockerfile +# Runtime stage +FROM python:3.12-slim AS runtime +WORKDIR /app +RUN addgroup --system app && adduser --system --ingroup app app +COPY --from=build /opt/venv /opt/venv +COPY --from=build /app . +ENV PATH="/opt/venv/bin:$PATH" \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 +USER app:app +EXPOSE 8000 +# HEALTHCHECK is omitted — Kubernetes liveness/readiness probes handle +# health checks in AKS. See deployment.yaml for probe configuration. +ENTRYPOINT ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"] +``` + +### Key points + +- **Base image:** `python:3.12-slim` over Alpine — Alpine uses musl libc which causes build failures with many Python C extensions (psycopg2, Pillow, cryptography) +- **`PYTHONDONTWRITEBYTECODE=1`** prevents `.pyc` files from bloating the image +- **`PYTHONUNBUFFERED=1`** ensures logs appear immediately in `kubectl logs` without buffering +- **`--no-cache-dir`** for pip avoids caching wheel files in the image layer +- **Non-root user** (`app`) satisfies DS004 +- **`gunicorn`** is the production WSGI server — never use `manage.py runserver` in production (it is single-threaded, unoptimized, and not designed for production traffic) +- **Workers formula:** `2 * CPU_CORES + 1` — for a 1-vCPU container, use `--workers 3` +- **WSGI module path** varies by project scaffold: `config.wsgi:application`, `myproject.wsgi:application`, or `app.wsgi:application` — check `wsgi.py` location + +--- + +## Health Endpoints + +Django does not provide health endpoints out of the box. Use the `django-health-check` package: + +### Installation + +```bash +pip install django-health-check +``` + +### Configuration in `settings.py` + +```python +INSTALLED_APPS = [ + # ...existing apps... + "health_check", + "health_check.db", + "health_check.cache", + "health_check.storage", + "health_check.contrib.migrations", +] +``` + +### URL configuration in `urls.py` + +```python +from django.urls import include, path + +urlpatterns = [ + # ...existing urls... + path("health/", include("health_check.urls")), +] +``` + +The `/health/` endpoint returns HTTP 200 when all checks pass and HTTP 500 with details when any check fails. + +### Probe configuration in Deployment manifest + +```yaml +livenessProbe: + httpGet: + path: /health/ + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 +readinessProbe: + httpGet: + path: /health/ + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 +``` + +**Note:** `initialDelaySeconds: 10` is sufficient for most Django apps. + +--- + +## Database Profiles + +Django does not have a built-in profile system like Spring Boot. Database configuration is driven by `settings.py` with environment variables: + +| Pattern | How it works | +|---------|-------------| +| `dj-database-url` | Parse `DATABASE_URL` env var (recommended for 12-factor apps) + +### Environment variables for PostgreSQL on AKS + +```yaml +env: + - name: DATABASE_URL + value: "postgres://{{IDENTITY_NAME}}@{{PG_SERVER_NAME}}.postgres.database.azure.com:5432/{{DB_NAME}}?sslmode=require" + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: {{APP_NAME}}-secrets + key: secret-key +``` + +**Important:** `SECRET_KEY` must never be in a ConfigMap or hardcoded. Always store it in a Kubernetes Secret (or Key Vault via Workload Identity). + +### ConfigMap pattern + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{APP_NAME}}-config +data: + DJANGO_SETTINGS_MODULE: "config.settings.production" + DJANGO_ALLOWED_HOSTS: "{{INGRESS_HOSTNAME}}" + DATABASE_URL: "postgres://{{IDENTITY_NAME}}@{{PG_SERVER_NAME}}.postgres.database.azure.com:5432/{{DB_NAME}}?sslmode=require" +``` + +--- + +## Writable Paths (DS012 Compliance) + +When `readOnlyRootFilesystem: true` is set, Django apps need `/tmp` writable and optionally `/app/staticfiles`: + +- **`/tmp`** — required for file uploads (`FILE_UPLOAD_TEMP_DIR` defaults to `/tmp`), session data when using file-based sessions, and temporary processing +- **`/app/staticfiles`** — optional, only needed if serving collected static files at runtime from the local filesystem (when not using WhiteNoise or a CDN) + +### Volume mount configuration + +```yaml +volumes: + - name: tmp + emptyDir: {} + - name: staticfiles + emptyDir: {} +containers: + - name: app + volumeMounts: + - name: tmp + mountPath: /tmp + - name: staticfiles + mountPath: /app/staticfiles +``` + +If static files are baked into the image at build time via `collectstatic` and served by WhiteNoise, the `staticfiles` volume can be omitted — only `/tmp` is required. + +--- + +## Resource Sizing + +Django with Gunicorn runs multiple worker processes. Size for the number of workers (default: 2-4). + +| Resource | Request | Limit | +|----------|---------|-------| +| CPU | 200m | 500m | +| Memory | 256Mi | 512Mi | + +--- + +## Port Configuration + +- **Default port:** 8000 +- **CLI flag:** `--bind 0.0.0.0:8000` passed to `gunicorn` +- **Env var override:** `PORT` (read via `gunicorn --bind 0.0.0.0:$PORT` or `int(os.environ.get("PORT", 8000))`) + +Gunicorn logs the port on startup: `Listening at: http://0.0.0.0:8000` + +--- + +## Build Commands + +| Command | Purpose | When to run | +|---------|---------|-------------| +| `python manage.py collectstatic --noinput` | Gathers static files into `STATIC_ROOT` | In Dockerfile build stage (with `SECRET_KEY=build-placeholder`) | +| `python manage.py migrate --noinput` | Applies database migrations | As a Kubernetes init container — **never in the Dockerfile** | + +**Important:** Database migrations must run as an init container, not during the Docker build. The build stage has no access to the production database, and running migrations in the entrypoint creates race conditions when multiple replicas start simultaneously. + +### Init container for migrations + +```yaml +initContainers: + - name: migrate + image: {{ACR_NAME}}.azurecr.io/{{APP_NAME}}:{{TAG}} + command: ["python", "manage.py", "migrate", "--noinput"] + envFrom: + - configMapRef: + name: {{APP_NAME}}-config + - secretRef: + name: {{APP_NAME}}-secrets +``` + +--- + +## Common Issues on AKS + +| Issue | Symptom | Fix | +|-------|---------|-----| +| `collectstatic` not run | Static files 404 | Run `python manage.py collectstatic --noinput` in Dockerfile build stage | +| `ALLOWED_HOSTS` not set | `DisallowedHost` error | Set `DJANGO_ALLOWED_HOSTS` env var | +| Dev server in production | Single-threaded, no security | Use `gunicorn` in ENTRYPOINT | +| Migrations not applied | `relation "..." does not exist` | Run `manage.py migrate` as init container | +| `SECRET_KEY` not set | `ImproperlyConfigured` error | Store in Kubernetes Secret | +| Static files 404 in production | CSS/JS/images not loading | Use WhiteNoise or CDN for static files diff --git a/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/express.md b/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/express.md new file mode 100644 index 000000000..aeecf5c7a --- /dev/null +++ b/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/express.md @@ -0,0 +1,232 @@ +# Express / Fastify Knowledge Pack + +> **Applies to:** Projects detected with `package.json` containing `express` or `fastify` as a dependency + +--- + +## Dockerfile Patterns + +### Multi-stage build with dumb-init for signal handling + +Node.js does not handle `SIGTERM` correctly when running as PID 1. Use `dumb-init` as the entrypoint to forward signals properly: + +```dockerfile +# Build stage +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build --if-present + +# Runtime stage +FROM node:22-alpine AS runtime +RUN apk add --no-cache dumb-init +WORKDIR /app +COPY --from=build /app/package.json /app/package-lock.json ./ +RUN npm ci --omit=dev +COPY --from=build /app/dist ./dist +COPY --from=build /app/src ./src +USER node +EXPOSE 3000 +# HEALTHCHECK is omitted — Kubernetes liveness/readiness probes handle +# health checks in AKS. See deployment.yaml for probe configuration. +ENTRYPOINT ["dumb-init", "node", "dist/index.js"] +``` + +### Key points + +- **Base image:** Official `node:22-alpine` — minimal footprint, receives LTS security patches +- **Alpine variant** reduces image size by ~70% compared to Debian-based `node:22` +- **`dumb-init`** ensures `SIGTERM` from Kubernetes is forwarded to the Node process so graceful shutdown works +- **`npm ci --omit=dev`** strips dev dependencies from the runtime image, cutting image size and attack surface +- **`USER node`** — the official Node Alpine image ships with a built-in `node` user (uid 1000), satisfying DS004 without creating a custom user + +### Fastify listen caveat + +Fastify defaults to listening on `127.0.0.1`, which is unreachable from outside the container. Bind to `0.0.0.0` explicitly: + +```js +await fastify.listen({ port: 3000, host: '0.0.0.0' }); +``` + +If the pod starts but health probes fail with `connection refused`, this is almost always the cause. + +### Package manager variants + +| Package Manager | Install (all) | Install (prod only) | Lock File | +|----------------|---------------|---------------------|-----------| +| npm | `npm ci` | `npm ci --omit=dev` | `package-lock.json` | +| yarn | `yarn install --frozen-lockfile` | `yarn install --frozen-lockfile --production` | `yarn.lock` | +| pnpm | `pnpm install --frozen-lockfile` | `pnpm install --frozen-lockfile --prod` | `pnpm-lock.yaml` | + +Copy the correct lock file in the Dockerfile `COPY` step to match the project's package manager. + +--- + +## Health Endpoints + +Node.js frameworks do not provide health endpoints out of the box. Add a `/healthz` route manually. + +### Express + +```js +app.get('/healthz', (req, res) => { + res.status(200).json({ status: 'UP' }); +}); +``` + +### Fastify + +```js +fastify.get('/healthz', async () => { + return { status: 'UP' }; +}); +``` + +For richer checks (database connectivity, downstream services), extend the handler to verify dependencies and return `503` when unhealthy. + +### Probe configuration in Deployment manifest + +```yaml +livenessProbe: + httpGet: + path: /healthz + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 +readinessProbe: + httpGet: + path: /healthz + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 +``` + +**Note:** Node.js apps start in under a second, so `initialDelaySeconds: 5` is sufficient. No `startupProbe` is needed unless the app performs heavy initialization (e.g., loading ML models). + +--- + +## Database Profiles + +Node.js projects use a variety of database libraries. The standard pattern is a `DATABASE_URL` connection string injected via environment variable: + +| Library | Connection Pattern | Config Property | +|---------|-------------------|-----------------| +| `pg` (node-postgres) | `new Pool({ connectionString: process.env.DATABASE_URL })` | `DATABASE_URL` | +| Prisma | `datasource db { url = env("DATABASE_URL") }` in `schema.prisma` | `DATABASE_URL` | +| Sequelize | `new Sequelize(process.env.DATABASE_URL)` | `DATABASE_URL` | +| Knex | `connection: process.env.DATABASE_URL` in `knexfile.js` | `DATABASE_URL` | + +### Environment variables for PostgreSQL on AKS + +```yaml +env: + - name: DATABASE_URL + value: "postgresql://{{IDENTITY_NAME}}@{{PG_SERVER_NAME}}.postgres.database.azure.com:5432/{{DB_NAME}}?sslmode=require" + - name: PGHOST + value: "{{PG_SERVER_NAME}}.postgres.database.azure.com" + - name: PGDATABASE + value: "{{DB_NAME}}" + - name: PGUSER + value: "{{IDENTITY_NAME}}" + - name: PGPORT + value: "5432" + - name: PGSSLMODE + value: "require" +``` + +For Workload Identity with passwordless authentication, use the `@azure/identity` package with `pg` to obtain Azure AD tokens instead of passwords. + +### ConfigMap pattern + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{APP_NAME}}-config +data: + NODE_ENV: "production" + PGHOST: "{{PG_SERVER_NAME}}.postgres.database.azure.com" + PGDATABASE: "{{DB_NAME}}" + PGPORT: "5432" + PGSSLMODE: "require" +``` + +--- + +## Writable Paths (DS012 Compliance) + +When `readOnlyRootFilesystem: true` is set, Node.js apps need only `/tmp` writable: + +- **Multipart uploads** (e.g., `multer`, `@fastify/multipart`) stage files to `/tmp` +- **Logging libraries** that buffer to disk use `/tmp` +- **No other writable paths** are typically needed — `node_modules` is read-only at runtime + +### Required volume mount + +```yaml +volumes: + - name: tmp + emptyDir: {} +containers: + - name: app + volumeMounts: + - name: tmp + mountPath: /tmp +``` + +No other writable paths are typically needed for production Node.js apps. + +--- + +## Resource Sizing + +Node.js is single-threaded and relatively lightweight. These are starting-point defaults — tune based on observed usage. + +| Resource | Request | Limit | +|----------|---------|-------| +| CPU | 100m | 500m | +| Memory | 128Mi | 256Mi | + +For memory-intensive workloads (large payloads, SSR), increase the memory limit and set `--max-old-space-size` to ~75% of the limit. + +--- + +## Port Configuration + +- **Default port:** 3000 +- **Env var override:** `PORT=3000` +- **Code pattern:** `app.listen(process.env.PORT || 3000)` + +Express binds to `0.0.0.0` by default, so it is reachable from outside the container without additional configuration. + +Fastify binds to `127.0.0.1` by default — **you must pass `host: '0.0.0.0'`** in the `listen()` call or the pod will start but all probes and traffic will fail with `connection refused`. + +--- + +## Build Commands + +| Scenario | Build Command | Output | Entrypoint | +|----------|---------------|--------|------------| +| TypeScript | `npm run build` (invokes `tsc`) | `dist/` | `node dist/index.js` | +| JavaScript (no build) | None | `src/` | `node src/index.js` | +| Bundler (esbuild/webpack) | `npm run build` | `dist/bundle.js` | `node dist/bundle.js` | + +For TypeScript projects, ensure `tsconfig.json` has `"outDir": "dist"` and the Dockerfile copies the `dist/` folder to the runtime stage. Do **not** install `typescript` or `ts-node` in the production image. + +--- + +## Common Issues on AKS + +| Issue | Symptom | Fix | +|-------|---------|-----| +| No SIGTERM handling | Pod takes 30s to terminate (killed by `SIGKILL` after grace period) | Use `dumb-init` as entrypoint, or add explicit `process.on('SIGTERM', ...)` handler to close the server gracefully | +| ECONNRESET on PostgreSQL | `Error: Connection terminated unexpectedly` | Configure pool `idleTimeoutMillis` and `connectionTimeoutMillis`; Azure PG Flexible Server closes idle connections after ~5 min | +| Fastify localhost binding | Health probes fail with `connection refused` despite app running | Pass `host: '0.0.0.0'` to `fastify.listen()` — Fastify defaults to `127.0.0.1` | +| node_modules bloat | Image > 500MB, slow pulls from ACR | Run `npm ci --omit=dev` in a separate stage; consider esbuild bundling for single-file output | +| Memory leak under load | Pod `OOMKilled` after hours of traffic | Set `--max-old-space-size` to ~75% of container memory limit (e.g., `--max-old-space-size=384` for 512Mi limit); profile with `--inspect` locally | diff --git a/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/fastapi.md b/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/fastapi.md new file mode 100644 index 000000000..33e315e61 --- /dev/null +++ b/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/fastapi.md @@ -0,0 +1,214 @@ +# FastAPI Knowledge Pack + +> **Applies to:** Projects detected with `requirements.txt`, `pyproject.toml`, or `Pipfile` containing `fastapi` + +--- + +## Dockerfile Patterns + +### Multi-stage build with virtual environment + +Using a virtual environment in a multi-stage build keeps the final image lean by copying only installed packages: + +```dockerfile +# Build stage +FROM python:3.12-slim AS build +WORKDIR /app +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . + +# Runtime stage +FROM python:3.12-slim AS runtime +WORKDIR /app +RUN addgroup --system app && adduser --system --ingroup app app +COPY --from=build /opt/venv /opt/venv +COPY --from=build /app . +ENV PATH="/opt/venv/bin:$PATH" \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 +USER app:app +EXPOSE 8000 +# HEALTHCHECK is omitted — Kubernetes liveness/readiness probes handle +# health checks in AKS. See deployment.yaml for probe configuration. +ENTRYPOINT ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +### Key points + +- **Base image:** `python:3.12-slim` over Alpine — Alpine uses musl libc which causes build failures with many Python C extensions (numpy, psycopg2, cryptography) +- **`PYTHONDONTWRITEBYTECODE=1`** prevents `.pyc` files from bloating the image +- **`PYTHONUNBUFFERED=1`** ensures logs appear immediately in `kubectl logs` without buffering +- **`--no-cache-dir`** for pip avoids caching wheel files in the image layer +- **Non-root user** (`app`) satisfies DS004 +- **Virtual environment copy** (`/opt/venv`) cleanly separates dependencies from build tools + +--- + +## Health Endpoints + +FastAPI health endpoints must be defined explicitly in application code: + +### Minimal health route + +```python +from fastapi import FastAPI + +app = FastAPI() + +@app.get("/health") +async def health(): + return {"status": "ok"} +``` + +### Readiness route with database check + +```python +from fastapi import FastAPI, status +from fastapi.responses import JSONResponse +from sqlalchemy.ext.asyncio import AsyncSession + +@app.get("/ready") +async def ready(db: AsyncSession = Depends(get_db)): + try: + await db.execute(text("SELECT 1")) + return {"status": "ready"} + except Exception: + return JSONResponse( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + content={"status": "not ready"}, + ) +``` + +### Probe configuration in Deployment manifest + +```yaml +livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 +readinessProbe: + httpGet: + path: /ready + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 +``` + +**Note:** FastAPI apps start quickly (typically <2s), so `initialDelaySeconds: 5` is sufficient — much lower than JVM-based frameworks. + +--- + +## Database Profiles + +FastAPI does not have a built-in profile system. Database configuration is typically driven by environment variables: + +| ORM / Driver | Package(s) | Connection String Format | +|-------------|-----------|--------------------------| +| SQLAlchemy async + asyncpg | `sqlalchemy[asyncio]`, `asyncpg` | `postgresql+asyncpg://user:pass@host:5432/db` | +| Tortoise ORM | `tortoise-orm`, `asyncpg` | `postgres://user:pass@host:5432/db` | +| SQLModel | `sqlmodel`, `asyncpg` | `postgresql+asyncpg://user:pass@host:5432/db` | +| asyncpg direct | `asyncpg` | `postgresql://user:pass@host:5432/db` | + +**Important:** SQLAlchemy async requires the `+asyncpg` suffix in the connection URL scheme (`postgresql+asyncpg://`). Omitting it will default to the synchronous `psycopg2` driver, which blocks the event loop. + +### Environment variables for PostgreSQL on AKS + +```yaml +env: + - name: DATABASE_URL + value: "postgresql+asyncpg://{{IDENTITY_NAME}}@{{PG_SERVER_NAME}}.postgres.database.azure.com:5432/{{DB_NAME}}?sslmode=require" +``` + +### ConfigMap pattern + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{APP_NAME}}-config +data: + DATABASE_URL: "postgresql+asyncpg://{{IDENTITY_NAME}}@{{PG_SERVER_NAME}}.postgres.database.azure.com:5432/{{DB_NAME}}?sslmode=require" + UVICORN_WORKERS: "1" +``` + +### Workload Identity with azure-identity + +See `references/workload-identity.md` for connection patterns. Requires `azure-identity` package. + +--- + +## Writable Paths (DS012 Compliance) + +When `readOnlyRootFilesystem: true` is set, FastAPI apps typically only need `/tmp` writable: + +- **Uploaded files** use `/tmp` as the default staging directory for `UploadFile` +- **Temporary processing** may write intermediate results to `/tmp` + +### Required volume mount + +```yaml +volumes: + - name: tmp + emptyDir: {} +containers: + - name: app + volumeMounts: + - name: tmp + mountPath: /tmp +``` + +No other writable paths are typically needed for production FastAPI apps. + +--- + +## Resource Sizing + +FastAPI with Uvicorn is async and lightweight. Size for workload concurrency. + +| Resource | Request | Limit | +|----------|---------|-------| +| CPU | 100m | 500m | +| Memory | 128Mi | 256Mi | + +--- + +## Port Configuration + +- **Default port:** 8000 +- **CLI flag:** `--port 8000` passed to `uvicorn` +- **Env var override:** `PORT` (read via `uvicorn --port $PORT` or `int(os.environ.get("PORT", 8000))`) + +Uvicorn logs the port on startup: `Uvicorn running on http://0.0.0.0:8000` + +--- + +## Build Commands + +| Tool | Install Command | Output | +|------|----------------|--------| +| pip | `pip install --no-cache-dir -r requirements.txt` | Packages in site-packages | +| Poetry | `poetry install --only main --no-interaction` | Packages in virtualenv | +| uv | `uv sync --frozen --no-dev` | Packages in virtualenv | + +The `--no-cache-dir` flag (pip) and `--no-interaction` flag (Poetry) suppress interactive prompts — important for CI/CD and Docker builds. + +--- + +## Common Issues on AKS + +| Issue | Symptom | Fix | +|-------|---------|-----| +| Uvicorn workers misconfigured | High latency under load, single-core CPU usage | Set `--workers` to `2 * CPU_CORES + 1` for sync code, or `1` when using async handlers (async code uses a single event loop) | +| Async DB pool exhaustion | `asyncpg.exceptions.TooManyConnectionsError` | Configure pool size with `create_async_engine(pool_size=5, max_overflow=10)` and match PostgreSQL `max_connections` | +| Alpine build fails | `gcc` errors installing `cryptography`, `psycopg2`, `numpy` | Use `python:3.12-slim` (Debian-based) instead of `python:3.12-alpine` | +| Uvicorn binds to localhost | Connection refused from Kubernetes probes | Set `--host 0.0.0.0` — uvicorn defaults to `127.0.0.1` which is unreachable from outside the container | +| Missing uvicorn in production | `ModuleNotFoundError: No module named 'uvicorn'` | Ensure `uvicorn[standard]` is in `requirements.txt` — it is often only in dev dependencies | diff --git a/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/flask.md b/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/flask.md new file mode 100644 index 000000000..c403538cf --- /dev/null +++ b/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/flask.md @@ -0,0 +1,247 @@ +# Flask Knowledge Pack + +> **Applies to:** Projects detected with `requirements.txt`, `pyproject.toml`, or `Pipfile` containing `flask` + +--- + +## Dockerfile Patterns + +### Multi-stage build with virtual environment + +Flask requires a production WSGI server — never use `flask run` or `app.run()` in production. Gunicorn is the standard choice: + +```dockerfile +# Build stage +FROM python:3.12-slim AS build +WORKDIR /app +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . + +# Runtime stage +FROM python:3.12-slim AS runtime +WORKDIR /app +RUN addgroup --system app && adduser --system --ingroup app app +COPY --from=build /opt/venv /opt/venv +COPY --from=build /app . +ENV PATH="/opt/venv/bin:$PATH" \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 +USER app:app +EXPOSE 8000 +# HEALTHCHECK is omitted — Kubernetes liveness/readiness probes handle +# health checks in AKS. See deployment.yaml for probe configuration. +ENTRYPOINT ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "app:app"] +``` + +### Key points + +- **Base image:** `python:3.12-slim` over Alpine — Alpine uses musl libc which causes build failures with many Python C extensions (psycopg2, cryptography) +- **`PYTHONDONTWRITEBYTECODE=1`** prevents `.pyc` files from bloating the image +- **`PYTHONUNBUFFERED=1`** ensures logs appear immediately in `kubectl logs` without buffering +- **`--no-cache-dir`** for pip avoids caching wheel files in the image layer +- **Non-root user** (`app`) satisfies DS004 +- **Virtual environment copy** (`/opt/venv`) cleanly separates dependencies from build tools +- **Never use `flask run` or `app.run()` in production** — these start the Werkzeug development server which is single-threaded, not hardened, and not suitable for production traffic + +### Entry point variants + +- **Module-level `app` object:** `gunicorn "app:app"` — the most common pattern where `app = Flask(__name__)` is defined at module level +- **Application factory pattern:** `gunicorn "myapp:create_app()"` — when the app is created via a factory function like `def create_app(): app = Flask(__name__); return app` + +### Workers formula + +Set gunicorn workers to `2 * CPU_CORES + 1`. For a container with a 1-core CPU limit, use `--workers 3`. Adjust via the `WEB_CONCURRENCY` env var at runtime: + +```dockerfile +ENTRYPOINT ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"] +``` + +```yaml +env: + - name: WEB_CONCURRENCY + value: "3" +``` + +--- + +## Health Endpoints + +Flask does not include health check endpoints — they must be defined explicitly in application code: + +### Minimal health route + +```python +from flask import Flask, jsonify + +app = Flask(__name__) + +@app.route("/health") +def health(): + return jsonify(status="ok"), 200 +``` + +### Readiness route with database check + +```python +from flask import jsonify +from sqlalchemy import text + +@app.route("/ready") +def ready(): + try: + db.session.execute(text("SELECT 1")) + return jsonify(status="ready"), 200 + except Exception: + return jsonify(status="not ready"), 503 +``` + +### Probe configuration in Deployment manifest + +```yaml +livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 +readinessProbe: + httpGet: + path: /ready + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 +``` + +**Note:** Flask apps behind gunicorn start quickly (typically <3s), so `initialDelaySeconds: 5` is sufficient — much lower than JVM-based frameworks. + +--- + +## Database Profiles + +Flask does not have a built-in profile system. Database configuration is typically driven by environment variables: + +| ORM / Driver | Package(s) | Connection String Env Var | +|-------------|-----------|--------------------------| +| Flask-SQLAlchemy | `flask-sqlalchemy`, `psycopg2-binary` | `SQLALCHEMY_DATABASE_URI` | +| SQLAlchemy direct | `sqlalchemy`, `psycopg2-binary` | `DATABASE_URL` | +| psycopg2 direct | `psycopg2-binary` | `DATABASE_URL` | + +**Important:** Flask-SQLAlchemy reads the connection string from `app.config["SQLALCHEMY_DATABASE_URI"]`, which is typically set via `os.environ.get("SQLALCHEMY_DATABASE_URI")` or `os.environ.get("DATABASE_URL")`. Ensure the env var name matches what the app expects. + +### Environment variables for PostgreSQL on AKS + +```yaml +env: + - name: SQLALCHEMY_DATABASE_URI + value: "postgresql://{{IDENTITY_NAME}}@{{PG_SERVER_NAME}}.postgres.database.azure.com:5432/{{DB_NAME}}?sslmode=require" + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: {{APP_NAME}}-secrets + key: secret-key +``` + +### Secret for SECRET_KEY + +Flask requires `SECRET_KEY` for session signing, CSRF tokens, and any use of `flask.session`. Never hardcode it — store it in a Kubernetes Secret: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: {{APP_NAME}}-secrets +type: Opaque +stringData: + secret-key: "" +``` + +### ConfigMap pattern + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{APP_NAME}}-config +data: + SQLALCHEMY_DATABASE_URI: "postgresql://{{IDENTITY_NAME}}@{{PG_SERVER_NAME}}.postgres.database.azure.com:5432/{{DB_NAME}}?sslmode=require" +``` + +### Workload Identity with azure-identity + +See `references/workload-identity.md` for connection patterns. Requires `azure-identity` package. + +--- + +## Writable Paths (DS012 Compliance) + +When `readOnlyRootFilesystem: true` is set, Flask apps typically only need `/tmp` writable: + +- **Uploaded files** use `/tmp` as the default staging directory for `request.files` +- **Temporary processing** may write intermediate results to `/tmp` + +### Required volume mount + +```yaml +volumes: + - name: tmp + emptyDir: {} +containers: + - name: app + volumeMounts: + - name: tmp + mountPath: /tmp +``` + +No other writable paths are typically needed for production Flask apps. + +--- + +## Resource Sizing + +Flask with Gunicorn runs multiple worker processes. Size for the number of workers (default: 2-4). + +| Resource | Request | Limit | +|----------|---------|-------| +| CPU | 150m | 500m | +| Memory | 128Mi | 256Mi | + +--- + +## Port Configuration + +- **Development port:** 5000 (`flask run` default — do not use in production) +- **Production port:** 8000 (gunicorn convention) +- **CLI flag:** `--bind 0.0.0.0:8000` passed to `gunicorn` +- **Env var override:** `PORT` (read via `gunicorn --bind 0.0.0.0:$PORT` or in app code `int(os.environ.get("PORT", 8000))`) + +Gunicorn logs the port on startup: `Listening at: http://0.0.0.0:8000` + +--- + +## Build Commands + +| Tool | Install Command | +|------|----------------| +| pip | `pip install --no-cache-dir -r requirements.txt` | +| Poetry | `poetry install --only main --no-interaction` | + +Ensure `gunicorn` is listed in `requirements.txt` or `pyproject.toml` production dependencies. + +--- + +## Common Issues on AKS + +| Issue | Symptom | Fix | +|-------|---------|-----| +| Running dev server in production | Single-threaded, poor performance, `WARNING: This is a development server` in logs | Use `gunicorn` as the ENTRYPOINT — never use `flask run` or `app.run()` in production containers | +| `SECRET_KEY` not set | `RuntimeError: The session is unavailable because no secret key was set`, CSRF failures | Set `SECRET_KEY` via a Kubernetes Secret and reference it as an env var in the Deployment manifest | +| Flask binds to localhost | Connection refused from Kubernetes probes | Pass `--bind 0.0.0.0:8000` to gunicorn — the Flask dev server defaults to `127.0.0.1` which is unreachable from outside the container | +| Gunicorn not installed | `ModuleNotFoundError: No module named 'gunicorn'` | Ensure `gunicorn` is in `requirements.txt` or `pyproject.toml` main dependencies — it is often only in dev dependencies or missing entirely | +| DB connections not closed | `sqlalchemy.exc.TimeoutError: QueuePool limit`, PostgreSQL `max_connections` exhaustion | Configure pool size with `SQLALCHEMY_ENGINE_OPTIONS = {"pool_size": 5, "max_overflow": 10, "pool_recycle": 300}` and match PostgreSQL `max_connections` | diff --git a/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/go.md b/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/go.md new file mode 100644 index 000000000..ed436e4ee --- /dev/null +++ b/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/go.md @@ -0,0 +1,218 @@ +# Go Knowledge Pack + +> **Applies to:** Projects detected with `go.mod` containing `github.com/gin-gonic/gin`, `github.com/labstack/echo`, `github.com/gofiber/fiber`, or any Go project using the standard library `net/http` for HTTP serving + +--- + +## Dockerfile Patterns + +### Static binary with distroless runtime + +Go compiles to a single static binary, producing some of the smallest production images possible: + +```dockerfile +# Build stage +FROM golang:1.23-alpine AS build +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /app/server ./cmd/server + +# Runtime stage +FROM gcr.io/distroless/static-debian12 AS runtime +COPY --from=build /app/server /server +USER 65534 +EXPOSE 8080 +ENTRYPOINT ["/server"] +``` + +### Key points + +- **`CGO_ENABLED=0`** produces a fully static binary with no libc dependency — required for `distroless/static` +- **`-ldflags="-s -w"`** strips debug symbols and DWARF info, reducing binary size by ~30% +- **`distroless/static-debian12`** is ~2MB — no shell, no package manager, minimal attack surface +- **`USER 65534`** is the `nobody` user in distroless, satisfying DS004 + +--- + +## Health Endpoints + +Go does not provide health check endpoints out of the box — you must implement them manually. Example using standard library: + +```go +http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok"}`)) +}) +http.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) { + if err := db.Ping(); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte(`{"status":"not ready"}`)) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ready"}`)) +}) +``` + +### Probe configuration in Deployment manifest + +```yaml +livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 3 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 +readinessProbe: + httpGet: + path: /ready + port: 8080 + initialDelaySeconds: 3 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 +``` + +**Note:** Go binaries start in milliseconds — `initialDelaySeconds: 3` is generous. No JVM warmup or interpreter startup to wait for. + +--- + +## Database Profiles + +Go does not have a built-in profile system. Database configuration is typically driven by environment variables: + +| Library | Driver | Connection Env Var | +|---------|--------|--------------------| +| `database/sql` + `pgx` | `github.com/jackc/pgx/v5/stdlib` | `DATABASE_URL` | +| GORM | `gorm.io/driver/postgres` | `DATABASE_URL` | +| sqlx | `github.com/jmoiron/sqlx` + `pgx` | `DATABASE_URL` | +| pgx direct | `github.com/jackc/pgx/v5` | `DATABASE_URL` | + +### Environment variables for PostgreSQL on AKS + +```yaml +env: + - name: DATABASE_URL + value: "host={{PG_SERVER_NAME}}.postgres.database.azure.com port=5432 dbname={{DB_NAME}} user={{IDENTITY_NAME}} sslmode=require" +``` + +### Workload Identity with pgx + +Use `azidentity` to obtain Azure AD tokens and inject them via pgx's `BeforeConnect` hook — no password stored: + +```go +import ( + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/jackc/pgx/v5" +) + +cred, _ := azidentity.NewDefaultAzureCredential(nil) + +config, _ := pgx.ParseConfig(os.Getenv("DATABASE_URL")) +config.BeforeConnect = func(ctx context.Context, cfg *pgx.ConnConfig) error { + token, err := cred.GetToken(ctx, policy.TokenRequestOptions{ + Scopes: []string{"https://ossrdbms-aad.database.windows.net/.default"}, + }) + if err != nil { + return err + } + cfg.Password = token.Token + return nil +} +``` + +### ConfigMap pattern + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{APP_NAME}}-config +data: + DATABASE_URL: "host={{PG_SERVER_NAME}}.postgres.database.azure.com port=5432 dbname={{DB_NAME}} user={{IDENTITY_NAME}} sslmode=require" +``` + +--- + +## Writable Paths (DS012 Compliance) + +When `readOnlyRootFilesystem: true` is set, Go apps typically need **no writable paths**: + +- Go compiles to a static binary — no temp files, no interpreted bytecode, no session storage +- The `distroless/static` base image has no shell or package manager that writes to disk + +### Optional `/tmp` mount + +If your application explicitly writes temporary files (e.g., file uploads, report generation): + +```yaml +volumes: + - name: tmp + emptyDir: {} +containers: + - name: app + volumeMounts: + - name: tmp + mountPath: /tmp +``` + +Most Go web APIs do not need this. + +--- + +## Resource Sizing + +Go compiles to a static binary with no runtime — it is the most resource-efficient option. + +| Resource | Request | Limit | +|----------|---------|-------| +| CPU | 50m | 200m | +| Memory | 64Mi | 128Mi | + +--- + +## Port Configuration + +- **Default port:** 8080 (Go convention, not enforced by any framework) +- **Env var override:** `PORT` (commonly used pattern) + +### Code pattern + +```go +port := os.Getenv("PORT") +if port == "" { + port = "8080" +} +log.Printf("Listening on :%s", port) +log.Fatal(http.ListenAndServe(":"+port, router)) +``` + +All major Go frameworks (Gin, Echo, Fiber) accept the listen address as a string — no special configuration property needed. + +--- + +## Build Commands + +| Variant | Command | Notes | +|---------|---------|-------| +| Standard | `CGO_ENABLED=0 go build -ldflags="-s -w" -o server ./cmd/server` | Production binary, stripped | +| Race detector (test only) | `go build -race -o server ./cmd/server` | Do **not** use in production — 10x overhead | +| Multiple binaries | `CGO_ENABLED=0 go build -ldflags="-s -w" -o migrate ./cmd/migrate` | Build each binary target separately | + +The `./cmd/server` path is conventional for Go projects using the [Standard Go Project Layout](https://github.com/golang-standards/project-layout). Adjust to match the actual `main` package location. + +--- + +## Common Issues on AKS + +| Issue | Symptom | Fix | +|-------|---------|-----| +| Binary not statically linked | `exec format error` or `not found` in distroless | Ensure `CGO_ENABLED=0` is set during build; if CGO is required, use `distroless/cc` instead of `distroless/static` | +| DNS resolution issues with Alpine | `dial tcp: lookup ... no such host` during build | Use `golang:1.23-alpine` with `RUN apk add --no-cache ca-certificates` or switch to `golang:1.23` (Debian-based) for the build stage | +| Graceful shutdown not implemented | Connections dropped during rolling update, 502 errors | Implement `signal.NotifyContext` with `srv.Shutdown(ctx)` — give in-flight requests time to complete before exit | +| Binary name mismatch | `exec /server: no such file or directory` | Verify the `-o` flag in `go build` matches the `ENTRYPOINT` path in the Dockerfile | +| Port < 1024 with non-root user | `bind: permission denied` | Use port 8080 (or any port >= 1024); never bind to 80 or 443 inside the container | diff --git a/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/nestjs.md b/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/nestjs.md new file mode 100644 index 000000000..4475b9add --- /dev/null +++ b/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/nestjs.md @@ -0,0 +1,218 @@ +# NestJS Knowledge Pack + +> **Applies to:** Projects detected with `package.json` containing `@nestjs/core` as a dependency + +--- + +## Dockerfile Patterns + +### Multi-stage build with TypeScript compilation + +NestJS compiles TypeScript to JavaScript via `nest build`, outputting to `dist/`. Use `dumb-init` for proper signal handling: + +```dockerfile +# Build stage +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# Runtime stage +FROM node:22-alpine AS runtime +RUN apk add --no-cache dumb-init +WORKDIR /app +COPY --from=build /app/package.json /app/package-lock.json ./ +RUN npm ci --omit=dev +COPY --from=build /app/dist ./dist +USER node +EXPOSE 3000 +# HEALTHCHECK is omitted — Kubernetes liveness/readiness probes handle +# health checks in AKS. See deployment.yaml for probe configuration. +ENTRYPOINT ["dumb-init", "node", "dist/main.js"] +``` + +### Key points + +- **Base image:** Official `node:22-alpine` — minimal footprint, receives LTS security patches +- **Alpine variant** reduces image size by ~70% compared to Debian-based `node:22` +- **`dumb-init`** ensures `SIGTERM` from Kubernetes is forwarded to the Node process so graceful shutdown works +- **`npm ci --omit=dev`** strips dev dependencies (including `typescript`, `@nestjs/cli`, `@nestjs/schematics`) from the runtime image +- **`USER node`** — the official Node Alpine image ships with a built-in `node` user (uid 1000), satisfying DS004 without creating a custom user +- **`dist/main.js`** is the default entrypoint — NestJS compiles `src/main.ts` to `dist/main.js` + +### Monorepo projects + +For NestJS monorepos, compile specific apps with `npx nest build ` and adjust `ENTRYPOINT` to `node dist/apps//main.js`. + +### Package manager variants + +| Package Manager | Install (all) | Install (prod only) | +|----------------|---------------|---------------------| +| npm | `npm ci` | `npm ci --omit=dev` | +| yarn | `yarn install --frozen-lockfile` | `yarn install --frozen-lockfile --production` | +| pnpm | `pnpm install --frozen-lockfile` | `pnpm install --frozen-lockfile --prod` | + +--- + +## Health Endpoints + +NestJS provides health checks via the `@nestjs/terminus` package. + +### Installation + +```bash +npm install @nestjs/terminus +``` + +### HealthModule + +```typescript +import { Module } from '@nestjs/common'; +import { TerminusModule } from '@nestjs/terminus'; +import { HealthController } from './health.controller'; + +@Module({ + imports: [TerminusModule], + controllers: [HealthController], +}) +export class HealthModule {} +``` + +Register `HealthModule` in `AppModule` imports. + +### HealthController with database check + +```typescript +import { Controller, Get } from '@nestjs/common'; +import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator } from '@nestjs/terminus'; + +@Controller('health') +export class HealthController { + constructor(private health: HealthCheckService, private db: TypeOrmHealthIndicator) {} + @Get() + @HealthCheck() + check() { + return this.health.check([() => this.db.pingCheck('database')]); + } +} +``` + +For Prisma, use `PrismaHealthIndicator`; for MikroORM, use `MikroOrmHealthIndicator`. If no database is used, omit the indicator and return a simple status check. + +### Probe configuration in Deployment manifest + +```yaml +livenessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 +readinessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 +``` + +**Note:** NestJS apps start quickly (typically under 2 seconds), so `initialDelaySeconds: 5` is sufficient. If the app performs heavy initialization (e.g., loading large config, running migrations), increase to 10-15s or add a `startupProbe`. + +--- + +## Database Profiles + +NestJS supports multiple ORM libraries. The standard pattern is a connection string or individual env vars injected via environment variables: + +| ORM | Connection Pattern | Config Property | +|-----|-------------------|-----------------| +| TypeORM | `TypeOrmModule.forRoot({ url: process.env.DATABASE_URL })` | `DATABASE_URL` | +| Prisma | `datasource db { url = env("DATABASE_URL") }` in `schema.prisma` | `DATABASE_URL` | +| MikroORM | `MikroOrmModule.forRoot({ clientUrl: process.env.DATABASE_URL })` | `DATABASE_URL` | +| Sequelize | `SequelizeModule.forRoot({ uri: process.env.DATABASE_URL })` | `DATABASE_URL` | + +### Environment variables for PostgreSQL on AKS + +```yaml +env: + - name: DATABASE_URL + value: "postgresql://{{IDENTITY_NAME}}@{{PG_SERVER_NAME}}.postgres.database.azure.com:5432/{{DB_NAME}}?sslmode=require" +``` + +For Workload Identity, see `references/workload-identity.md`. + +--- + +## Writable Paths (DS012 Compliance) + +When `readOnlyRootFilesystem: true` is set, NestJS apps need only `/tmp` writable: + +- **Multipart uploads** (e.g., `@nestjs/platform-express` with `multer`) stage files to `/tmp` +- **Logging libraries** that buffer to disk use `/tmp` +- **No other writable paths** are typically needed — `node_modules` and `dist/` are read-only at runtime + +### Required volume mount + +```yaml +volumes: + - name: tmp + emptyDir: {} +containers: + - name: app + volumeMounts: + - name: tmp + mountPath: /tmp +``` + +No other writable paths are typically needed for production NestJS apps. + +--- + +## Resource Sizing + +NestJS is Node.js-based and single-threaded. Similar to Express/Fastify. + +| Resource | Request | Limit | +|----------|---------|-------| +| CPU | 100m | 500m | +| Memory | 128Mi | 256Mi | + +--- + +## Port Configuration + +- **Default port:** 3000 +- **Env var override:** `PORT=3000` +- **Code pattern:** `await app.listen(process.env.PORT || 3000)` in `main.ts` + +NestJS (via Express adapter) binds to `0.0.0.0` by default. For Fastify adapter, pass `'0.0.0.0'` explicitly: `await app.listen(process.env.PORT || 3000, '0.0.0.0')`. + +--- + +## Build Commands + +| Scenario | Build Command | Output | Entrypoint | +|----------|---------------|--------|------------| +| Standard | `npm run build` (invokes `nest build`) | `dist/` | `node dist/main.js` | +| Monorepo | `npx nest build ` | `dist/apps//` | `node dist/apps//main.js` | +| SWC compiler | `nest build --builder swc` | `dist/` | `node dist/main.js` | + +The **SWC compiler** is ~20x faster than the default TypeScript compiler for large projects. Enable it by installing `@swc/cli @swc/core` and passing `--builder swc` or setting `"builder": "swc"` in `nest-cli.json`. SWC does not perform type checking — run `tsc --noEmit` separately in CI if type safety is required. + +--- + +## Common Issues on AKS + +| Issue | Symptom | Fix | +|-------|---------|-----| +| SIGTERM not handled | Pod takes 30s to terminate (killed by `SIGKILL` after grace period) | Call `app.enableShutdownHooks()` in `main.ts` so NestJS lifecycle events (`OnModuleDestroy`, `BeforeApplicationShutdown`) fire on `SIGTERM`; also use `dumb-init` as the container entrypoint | +| TypeORM connection pool exhaustion | `Error: Connection pool exhausted` or `ETIMEDOUT` under load | Set `extra: { max: 10 }` in TypeORM config to limit pool size; Azure PG Flexible Server has a connection limit based on SKU — monitor with `pg_stat_activity` | +| Circular dependency | `Error: Nest cannot create the ... instance` at startup | Use `forwardRef(() => Module)` in module imports; refactor shared logic into a dedicated module to break the cycle | +| dist/ not included in image | `Error: Cannot find module '/app/dist/main.js'` at container start | Ensure `COPY --from=build /app/dist ./dist` is present in the Dockerfile runtime stage; verify `nest build` runs successfully in the build stage | +| Global prefix breaks probes | Health probes return `404` after setting `app.setGlobalPrefix('api')` | The health endpoint moves to `/api/health` — update probe paths in the Deployment manifest, or exclude the health controller from the global prefix using `app.setGlobalPrefix('api', { exclude: ['health'] })` | diff --git a/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/nextjs.md b/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/nextjs.md new file mode 100644 index 000000000..cefe7bf67 --- /dev/null +++ b/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/nextjs.md @@ -0,0 +1,228 @@ +# Next.js Knowledge Pack + +> **Applies to:** Projects detected with `package.json` containing `next` as a dependency + +--- + +## Dockerfile Patterns + +### Multi-stage build with standalone output + +Next.js standalone output mode is critical for containerized deployments — it reduces the image from ~1GB to ~100MB by bundling only the files needed to run the server: + +```dockerfile +# Build stage +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +# Runtime stage +FROM node:22-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup -S nextjs && adduser -S nextjs -G nextjs + +COPY --from=build /app/public ./public +COPY --from=build --chown=nextjs:nextjs /app/.next/standalone ./ +COPY --from=build --chown=nextjs:nextjs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" +CMD ["node", "server.js"] +``` + +### Key points + +- **Standalone output** requires `output: 'standalone'` in `next.config.js` — without this, the build copies all of `node_modules` into the image +- **No `dumb-init` needed** — the standalone `server.js` handles `SIGTERM` signals correctly as PID 1 +- **`NEXT_TELEMETRY_DISABLED=1`** prevents Next.js from sending anonymous telemetry from the build and runtime containers +- **Three COPY steps** are required: `public/` for static assets, `.next/standalone` for the server, `.next/static` for client-side JS/CSS bundles +- **`USER nextjs`** satisfies DS004 — create a dedicated non-root user since the official Node Alpine `node` user also works +- **`HOSTNAME="0.0.0.0"`** is required in Next.js 14+ (replaces the older `-H 0.0.0.0` CLI flag) to listen on all interfaces + +### Enabling standalone output + +In `next.config.js` (or `next.config.mjs` / `next.config.ts`): + +```js +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', +}; + +module.exports = nextConfig; +``` + +### sharp for next/image optimization + +If the app uses `next/image`, install `sharp` explicitly: `RUN npm install sharp`. Without it, Next.js falls back to the slower `squoosh` library. + +### Package manager variants + +| Package Manager | Install Command | Lock File | +|----------------|-----------------|-----------| +| npm | `npm ci` | `package-lock.json` | +| yarn | `yarn install --frozen-lockfile` | `yarn.lock` | +| pnpm | `pnpm install --frozen-lockfile` | `pnpm-lock.yaml` | + +--- + +## Health Endpoints + +Next.js does not provide health endpoints out of the box. Add a custom API route — the implementation depends on whether the project uses App Router or Pages Router. + +### App Router (Next.js 13.4+) + +Create `app/api/health/route.ts`: + +```ts +import { NextResponse } from 'next/server'; + +export async function GET() { + return NextResponse.json({ status: 'UP' }); +} + +export const dynamic = 'force-dynamic'; +``` + +The `force-dynamic` export prevents Next.js from statically caching the health response at build time. + +### Pages Router + +Create `pages/api/health.ts`: + +```ts +import type { NextApiRequest, NextApiResponse } from 'next'; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + res.status(200).json({ status: 'UP' }); +} +``` + +### Probe configuration in Deployment manifest + +```yaml +livenessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 +readinessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 +``` + +**Note:** Next.js standalone server starts in 1–3 seconds, but `initialDelaySeconds: 10` provides a safe margin for cold starts and environment variable resolution. No `startupProbe` is needed unless the app performs heavy server-side initialization. + +--- + +## Database Profiles + +Next.js apps commonly use Prisma, Drizzle, or `pg` (node-postgres) for database access. All follow the `DATABASE_URL` connection string pattern: + +| Library | Connection Pattern | Config Property | +|---------|-------------------|-----------------| +| Prisma | `datasource db { url = env("DATABASE_URL") }` in `schema.prisma` | `DATABASE_URL` | +| Drizzle | `postgres(process.env.DATABASE_URL!)` or `drizzle(process.env.DATABASE_URL!)` | `DATABASE_URL` | +| `pg` (node-postgres) | `new Pool({ connectionString: process.env.DATABASE_URL })` | `DATABASE_URL` | + +### Environment variables for PostgreSQL on AKS + +```yaml +env: + - name: DATABASE_URL + value: "postgresql://{{IDENTITY_NAME}}@{{PG_SERVER_NAME}}.postgres.database.azure.com:5432/{{DB_NAME}}?sslmode=require" +``` + +For Workload Identity, see `references/workload-identity.md`. + +--- + +## Writable Paths (DS012 Compliance) + +When `readOnlyRootFilesystem: true` is set, Next.js needs **two** writable paths: + +- **`/tmp`** — general-purpose temporary file storage +- **`/app/.next/cache`** — ISR (Incremental Static Regeneration) page cache and `next/image` optimization cache; without this, ISR and image optimization fail with `EROFS: read-only file system` errors + +### Required volume mounts + +```yaml +volumes: + - name: tmp + emptyDir: {} + - name: next-cache + emptyDir: {} +containers: + - name: app + volumeMounts: + - name: tmp + mountPath: /tmp + - name: next-cache + mountPath: /app/.next/cache +``` + +Both mounts are required. Missing the cache mount is the most common cause of ISR failures on AKS. + +--- + +## Resource Sizing + +Next.js SSR needs more memory than a plain API due to React rendering. Static-only exports can use lower limits. + +| Resource | Request | Limit | +|----------|---------|-------| +| CPU | 200m | 1000m | +| Memory | 256Mi | 512Mi | + +--- + +## Port Configuration + +- **Default port:** 3000 +- **Env var override:** `PORT=3000` +- **Hostname binding:** `HOSTNAME="0.0.0.0"` (Next.js 14+) or `-H 0.0.0.0` CLI flag (Next.js 13) + +The standalone `server.js` reads the `PORT` and `HOSTNAME` environment variables automatically. No code changes are needed to customize the port. + +--- + +## Build Commands + +| Scenario | Build Command | Output | Entrypoint | +|----------|---------------|--------|------------| +| Standard build | `npm run build` | `.next/` (full) | `next start` | +| Standalone build (recommended) | `npm run build` with `output: 'standalone'` | `.next/standalone/server.js` | `node server.js` | + +The `output: 'standalone'` setting in `next.config.js` changes what `npm run build` produces — no separate command is needed. The standalone `server.js` includes a built-in HTTP server and does not require the `next` CLI at runtime. + +**Important:** Always use the standalone build for container deployments. The standard build requires the full `node_modules` directory at runtime, resulting in images 5-10x larger. + +--- + +## Common Issues on AKS + +| Issue | Symptom | Fix | +|-------|---------|-----| +| Image too large without standalone | Image > 1GB, slow pulls from ACR | Set `output: 'standalone'` in `next.config.js` — reduces image to ~100MB | +| Static assets 404 | CSS/JS files return 404 after deployment | Ensure `.next/static` is copied to `.next/static` in the runtime stage (not into `standalone/.next/static`) | +| ISR fails with read-only filesystem | `EROFS: read-only file system` when revalidating pages | Mount `emptyDir` volume at `/app/.next/cache` — ISR writes regenerated pages to the cache directory | +| next/image optimization fails | Images return 500 or timeout under load | Install `sharp` explicitly (`npm install sharp`); the standalone build may not include it automatically | +| Env vars undefined (`NEXT_PUBLIC_` prefix) | Client-side code sees `undefined` for environment variables | `NEXT_PUBLIC_` vars are inlined at **build time**, not runtime; set them as build args in the Dockerfile or use runtime config via `publicRuntimeConfig` | +| Telemetry calls from container | Unexpected outbound network requests to `telemetry.nextjs.org` | Set `NEXT_TELEMETRY_DISABLED=1` in both the build stage and runtime stage of the Dockerfile | diff --git a/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/spring-boot.md b/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/spring-boot.md new file mode 100644 index 000000000..50c86362e --- /dev/null +++ b/plugin/skills/deploy-to-aks/knowledge-packs/frameworks/spring-boot.md @@ -0,0 +1,225 @@ +# Spring Boot Knowledge Pack + +> **Applies to:** Projects detected with `pom.xml` containing `spring-boot-starter-web` or `build.gradle`/`build.gradle.kts` containing `org.springframework.boot` + +--- + +## Dockerfile Patterns + +### Multi-stage build with layered JAR extraction + +Spring Boot 2.3+ supports layered JARs for optimized Docker layer caching: + +```dockerfile +# Build stage +FROM eclipse-temurin:21-jdk-alpine AS build +WORKDIR /app +COPY pom.xml mvnw ./ +COPY .mvn .mvn +RUN ./mvnw dependency:go-offline -B +COPY src src +RUN ./mvnw package -DskipTests -B + +# Extract layers for caching +FROM eclipse-temurin:21-jdk-alpine AS extract +WORKDIR /app +COPY --from=build /app/target/*.jar app.jar +RUN java -Djarmode=layertools -jar app.jar extract + +# Runtime stage +FROM eclipse-temurin:21-jre-alpine AS runtime +WORKDIR /app +RUN addgroup -S spring && adduser -S spring -G spring +COPY --from=extract /app/dependencies/ ./ +COPY --from=extract /app/spring-boot-loader/ ./ +COPY --from=extract /app/snapshot-dependencies/ ./ +COPY --from=extract /app/application/ ./ +USER spring:spring +EXPOSE 8080 +# HEALTHCHECK is omitted — Kubernetes liveness/readiness probes handle +# health checks in AKS. See deployment.yaml for probe configuration. +ENTRYPOINT ["java", "org.springframework.boot.loader.launch.LaunchedClassPathJarLauncher"] +``` + +### Key points + +- **Base image:** Eclipse Temurin (Adoptium) is the recommended OpenJDK distribution for production +- **Alpine variant** reduces image size by ~60% compared to Debian-based +- **Layered JAR extraction** means only changed layers are rebuilt/pushed — dependencies rarely change +- **Non-root user** (`spring`) satisfies DS004 + +### ACR-compatible flattening + +If the layered extraction fails (older Spring Boot versions), flatten the layers: + +```dockerfile +COPY --from=extract /app/dependencies/ ./ +COPY --from=extract /app/spring-boot-loader/ ./ +COPY --from=extract /app/snapshot-dependencies/ ./ +COPY --from=extract /app/application/ ./ +``` + +This is compatible with ACR's layer deduplication. + +--- + +## Health Endpoints + +Spring Boot Actuator provides health endpoints out of the box: + +| Endpoint | Purpose | Probe Type | +|----------|---------|-----------| +| `/actuator/health` | Overall health | General | +| `/actuator/health/liveness` | Liveness group | `livenessProbe` | +| `/actuator/health/readiness` | Readiness group | `readinessProbe` | + +### Required configuration + +In `application.properties` or `application.yml`: + +```properties +management.endpoints.web.exposure.include=health +management.endpoint.health.probes.enabled=true +management.endpoint.health.show-details=always +``` + +The probes are automatically enabled when running in Kubernetes (detected via the `KUBERNETES_SERVICE_HOST` env var), but it's best practice to enable them explicitly. + +### Probe configuration in Deployment manifest + +```yaml +startupProbe: + httpGet: + path: /actuator/health/liveness + port: 8080 + periodSeconds: 10 + failureThreshold: 30 # allows up to 300s for JVM warmup + Spring context init +livenessProbe: + httpGet: + path: /actuator/health/liveness + port: 8080 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 +readinessProbe: + httpGet: + path: /actuator/health/readiness + port: 8080 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 +``` + +**Important:** Spring Boot apps need a `startupProbe` because JVM warmup and Spring context initialization typically take 15-60 seconds. Without it, the liveness probe may kill the pod before it finishes starting. The startup probe gives the app up to 300 seconds to become healthy before the liveness probe takes over. Uncomment the `startupProbe` section in the deployment template. + +--- + +## Database Profiles + +Spring Boot uses Spring Profiles to switch database configurations: + +| Profile | Activation | Typical Config File | +|---------|------------|-------------------| +| `default` | No profile set | `application.properties` — usually H2 in-memory | +| `mysql` | `SPRING_PROFILES_ACTIVE=mysql` | `application-mysql.properties` | +| `postgres` | `SPRING_PROFILES_ACTIVE=postgres` | `application-postgres.properties` | + +### Environment variables for PostgreSQL on AKS + +```yaml +env: + - name: SPRING_PROFILES_ACTIVE + value: postgres + - name: POSTGRES_URL + value: "jdbc:postgresql://{{PG_SERVER_NAME}}.postgres.database.azure.com:5432/{{DB_NAME}}" + - name: POSTGRES_USER + value: "{{IDENTITY_NAME}}" + - name: SPRING_DATASOURCE_AZURE_PASSWORDLESS_ENABLED + value: "true" +``` + +With Workload Identity and the `spring-cloud-azure-starter-jdbc-postgresql` dependency, Spring Boot can authenticate to PostgreSQL without a password using Azure AD tokens. + +### ConfigMap pattern + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{APP_NAME}}-config +data: + SPRING_PROFILES_ACTIVE: "postgres" + MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: "health" + MANAGEMENT_ENDPOINT_HEALTH_PROBES_ENABLED: "true" +``` + +--- + +## Writable Paths (DS012 Compliance) + +When `readOnlyRootFilesystem: true` is set, Spring Boot needs `/tmp` writable: + +- **Tomcat** writes session data and compiled JSPs to `/tmp` +- **Multipart file uploads** use `/tmp` as the staging directory +- **Spring Boot DevTools** (if accidentally included) writes to `/tmp` + +### Required volume mount + +```yaml +volumes: + - name: tmp + emptyDir: {} +containers: + - name: app + volumeMounts: + - name: tmp + mountPath: /tmp +``` + +No other writable paths are typically needed for production Spring Boot apps. + +--- + +## Resource Sizing + +Spring Boot apps running on the JVM need more memory than interpreted languages. These are starting-point defaults — tune based on observed usage. + +| Resource | Request | Limit | +|----------|---------|-------| +| CPU | 250m | 1000m | +| Memory | 512Mi | 1Gi | + +Set `-XX:MaxRAMPercentage=75.0` in `JAVA_OPTS` so the JVM uses at most 75% of the container's memory limit, leaving headroom for the OS and non-heap memory. + +--- + +## Port Configuration + +- **Default port:** 8080 +- **Config property:** `server.port` in `application.properties` +- **Env var override:** `SERVER_PORT=8080` + +Spring Boot always logs the port on startup: `Tomcat started on port(s): 8080 (http)` + +--- + +## Build Commands + +| Build Tool | Build Command | Output | +|-----------|---------------|--------| +| Maven | `./mvnw package -DskipTests -B` | `target/*.jar` | +| Gradle | `./gradlew bootJar` | `build/libs/*.jar` | + +The `-B` flag (batch mode) suppresses interactive Maven output — important for CI/CD and Docker builds. + +--- + +## Common Issues on AKS + +| Issue | Symptom | Fix | +|-------|---------|-----| +| JVM OOM in container | `OOMKilled` pod status | Set `-XX:MaxRAMPercentage=75.0` in `JAVA_OPTS` and ensure memory limit >= 256Mi | +| Slow startup | Readiness probe fails, pod restarted | Increase `initialDelaySeconds` to 45-60s, or add a `startupProbe` with higher `failureThreshold` | +| H2 in-memory on AKS | Data lost on pod restart | Switch to PostgreSQL profile — H2 is for local dev only | +| Connection refused to PostgreSQL | `PSQLException: Connection refused` | Verify firewall rules on PostgreSQL Flexible Server allow AKS subnet | +| Image too large (>500MB) | Slow pulls, high ACR storage | Use Alpine base image + layered JAR extraction | diff --git a/plugin/skills/deploy-to-aks/phases/quick-deploy.md b/plugin/skills/deploy-to-aks/phases/quick-deploy.md new file mode 100644 index 000000000..c34e93cbd --- /dev/null +++ b/plugin/skills/deploy-to-aks/phases/quick-deploy.md @@ -0,0 +1,230 @@ +# Quick Deploy + +Deploy an application to an existing AKS cluster with production-grade artifacts. + +## Goal + +Detect the application framework and Azure infrastructure, generate production-ready deployment artifacts, validate against AKS Deployment Safeguards, deploy, and verify — with minimal questions. + +--- + +## Section 1: Detection + +Scan the project and Azure environment. Ask at most one clarifying question (only if genuinely ambiguous: multiple Dockerfiles, multiple ACRs, multiple identities). + +### Framework Detection + +Follow the framework detection table in `references/detection.md`. Scan for signal files at the project root (and one level deep for monorepos). + +### Port Detection + +Follow the port detection table in `references/detection.md` (first match wins). + +### Health Endpoint Detection + +Follow the health endpoint detection table in `references/detection.md`. If none found, use `/health` as default in probes. + +### Existing Artifact Detection + +Check for existing `Dockerfile` and `k8s/` (or `manifests/`, `deploy/`) directories. + +### Azure Infrastructure Detection + +```bash +kubectl config current-context +az aks show -g -n -o json +``` + +Extract from cluster details: +- **AKS flavor**: `nodeProvisioningProfile.mode` — `"Auto"` = AKS Automatic, otherwise = AKS Standard +- **OIDC issuer**: `oidcIssuerProfile.issuerUrl` +- **Azure RBAC**: `aadProfile.enableAzureRBAC` + +### Routing Detection + +Determine whether the cluster uses Gateway API or Ingress — this applies to **both** AKS Automatic and Standard: + +```bash +az aks show -g -n --query '{webAppRoutingEnabled: ingressProfile.webAppRouting.enabled, istioMode: serviceMeshProfile.istio.mode}' -o json +``` + +- If `webAppRoutingEnabled` is not `true`, stop with error and provide the enable command: `az aks approuting enable -g -n ` +- If `istioMode` is `"Enabled"` → use **Gateway API** (`gateway.yaml` + `httproute.yaml`, `gatewayClassName: istio`) +- Otherwise → use **Ingress** (`ingress.yaml`, `ingressClassName: webapprouting.kubernetes.azure.com`) + +> **Note:** AKS Automatic defaults to NGINX/Ingress (same as Standard). Gateway API via Istio is an optional mode on both flavors. + +```bash +az acr list -g -o json +az identity list -g -o json +``` + +### ACR-AKS Integration + +Verify the AKS kubelet identity can pull images from the detected ACR: + +```bash +az aks check-acr --resource-group --name --acr .azurecr.io +``` + +If the check fails, attach the ACR to the cluster (requires confirmation): + +```bash +az aks update -g -n --attach-acr +``` + +**RBAC check** — if Azure RBAC is enabled: + +```bash +kubectl auth can-i create namespaces +``` + +If `no`, stop with error. Offer alternatives: have admin create the namespace, or deploy to an existing namespace. + +If any Azure CLI or kubectl command fails during detection, stop with the error and suggest common fixes: `az login`, `az account set -s `, `az aks get-credentials -g -n `. + +### Knowledge Pack + +After framework detection, load the matching pack from `knowledge-packs/frameworks/` if available: + +`spring-boot`, `express`, `nextjs`, `fastapi`, `django`, `nestjs`, `aspnet-core`, `go`, `flask` + +Knowledge packs influence Dockerfile optimization, probe configuration, and writable path requirements. + +--- + +## Section 2: File Generation + +Write all files in a single response turn (batch file writes). + +### Dockerfile + +**If existing Dockerfile:** Validate against best practices (multi-stage build, non-root USER, pinned base tags, layer caching, .dockerignore). Apply targeted fixes for failures — do not regenerate the file. + +**If no Dockerfile:** Generate from the appropriate template: + +| Language | Template | +|----------|----------| +| Node.js | `templates/dockerfiles/node.Dockerfile` | +| Python | `templates/dockerfiles/python.Dockerfile` | +| Java | `templates/dockerfiles/java.Dockerfile` | +| Go | `templates/dockerfiles/go.Dockerfile` | +| .NET | `templates/dockerfiles/dotnet.Dockerfile` | +| Rust | `templates/dockerfiles/rust.Dockerfile` | + +Generate `.dockerignore` if missing — use the matching template from `templates/dockerfiles/.dockerignore`. + +### Kubernetes Manifests + +**If existing manifests found** (in `k8s/`, `manifests/`, or `deploy/` directories): Validate them against AKS Deployment Safeguards (Section 3) and apply targeted fixes. Do not regenerate files that already exist — improve them in place. + +**If no manifests found:** Generate from `templates/k8s/` templates. Replace `` placeholders with detected values. + +| Manifest | Template | Notes | +|----------|----------|-------| +| `k8s/namespace.yaml` | `templates/k8s/namespace.yaml` | | +| `k8s/serviceaccount.yaml` | `templates/k8s/serviceaccount.yaml` | Workload Identity annotation | +| `k8s/deployment.yaml` | `templates/k8s/deployment.yaml` | Image placeholder resolved at deploy time | +| `k8s/service.yaml` | `templates/k8s/service.yaml` | | +| `k8s/gateway.yaml` | `templates/k8s/gateway.yaml` | Only if Istio Gateway API detected | +| `k8s/httproute.yaml` | `templates/k8s/httproute.yaml` | Only if Istio Gateway API detected | +| `k8s/ingress.yaml` | `templates/k8s/ingress.yaml` | Only if using Ingress (default for both flavors) | +| `k8s/hpa.yaml` | `templates/k8s/hpa.yaml` | min: 2, max: 10 | +| `k8s/pdb.yaml` | `templates/k8s/pdb.yaml` | minAvailable: 1 | +| `k8s/configmap.yaml` | `templates/k8s/configmap.yaml` | Only if app needs environment-specific config | +| `k8s/networkpolicy.yaml` | `templates/k8s/networkpolicy.yaml` | Restricts ingress to the ingress controller namespace | + +### Hostname + +For initial deployments without a custom domain, **omit the `host` field** from the Ingress `rules` (or Gateway `listeners`) so traffic routes to the external IP directly. Once the user has a domain, they can add `host` and TLS configuration later. + +### Resource Sizing + +Use the framework-specific defaults from the knowledge pack's "Resource Sizing" section. If no knowledge pack is loaded, use these general defaults: + +| Resource | Request | Limit | +|----------|---------|-------| +| CPU | 100m | 500m | +| Memory | 128Mi | 256Mi | + +### Startup Probe + +For slow-start frameworks (Java/Spring Boot, .NET with heavy DI), uncomment the `startupProbe` in the deployment template. This prevents the liveness probe from killing the pod before initialization completes. + +--- + +## Section 3: Safeguards Validation + +Before deploying, validate all generated manifests against AKS Deployment Safeguards DS001-DS013. Reference `references/safeguards.md` for the full checklist. + +- 12 of 13 rules are auto-fixable. DS009 (no `:latest` tag) is resolved by tagging with git SHA. +- Apply framework-specific writable path requirements from the knowledge pack (e.g., Spring Boot needs `/tmp`, Next.js needs `/app/.next/cache`). +- Reference `references/workload-identity.md` for Workload Identity configuration. + +**AKS Automatic:** Safeguards are always enforced — all violations must be fixed. + +**AKS Standard:** Check `safeguardsProfile.level`: +```bash +az aks show -g -n --query 'safeguardsProfile.level' -o tsv +``` +- `Enforcement`: fix all violations +- `Warning` or `Off`: mention issues as warnings, don't block + +--- + +## Section 4: Deploy + +### Ensure kubectl context + +```bash +az aks get-credentials -g -n --overwrite-existing +``` + +### Verify Gateway API CRDs (only if Istio Gateway API detected) + +```bash +kubectl get crd gateways.gateway.networking.k8s.io httproutes.gateway.networking.k8s.io 2>/dev/null +``` + +If missing: `kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.0.0/standard-install.yaml` + +### Build and push + +```bash +IMAGE_TAG=$(git rev-parse --short HEAD) # fallback: date +%Y%m%d%H%M%S +az acr build --registry --image :$IMAGE_TAG --file Dockerfile . +``` + +**Monorepo:** If the app is not at the repository root, adjust the build context and Dockerfile path: + +```bash +az acr build --registry --image :$IMAGE_TAG --file apps/myapp/Dockerfile apps/myapp/ +``` + +### Deploy to cluster + +```bash +# 1. Create namespace (must succeed before proceeding) +kubectl apply -f k8s/namespace.yaml +kubectl get namespace -o name # verify + +# 2. Apply remaining manifests +kubectl apply -f k8s/ --recursive + +# 3. Wait for rollout +kubectl rollout status deployment/ -n --timeout=300s +``` + +If any step fails, show the error and stop. Reference `references/rollback.md` for recovery procedures. + +--- + +## Section 5: Verify + +```bash +kubectl get pods -n -l app= +kubectl get gateway -n -o jsonpath='{.items[0].status.addresses[0].value}' # if Gateway API +kubectl get ingress -n -o jsonpath='{.items[0].status.loadBalancer.ingress[0].ip}' # if Ingress +``` + +Wait up to 3 minutes for external IP. Once available, curl the health endpoint. diff --git a/plugin/skills/deploy-to-aks/references/detection.md b/plugin/skills/deploy-to-aks/references/detection.md new file mode 100644 index 000000000..56d8a7109 --- /dev/null +++ b/plugin/skills/deploy-to-aks/references/detection.md @@ -0,0 +1,52 @@ +# Detection Reference + +Shared detection logic used by Section 1 (Detection) in Quick Deploy. + +## Framework Detection + +Scan for signal files at the project root (and one level deep for monorepos). Map each signal to a framework and, where possible, a sub-framework: + +| Signal File | Framework | Sub-framework Detection | +|---|---|---| +| `package.json` | Node.js | Inspect `dependencies` for: **Express** (`express`), **Fastify** (`fastify`), **NestJS** (`@nestjs/core`), **Next.js** (`next`), **Remix** (`@remix-run/node`), **Hono** (`hono`), **Koa** (`koa`) | +| `requirements.txt` | Python | Scan for: **FastAPI** (`fastapi`), **Django** (`django`), **Flask** (`flask`), **Starlette** (`starlette`), **Gunicorn** (`gunicorn`) | +| `pyproject.toml` | Python | Parse `[project.dependencies]` or `[tool.poetry.dependencies]` for the same libraries as above | +| `Pipfile` | Python | Parse `[packages]` section for the same libraries as above | +| `pom.xml` | Java | Search for `spring-boot-starter-web` → **Spring Boot**; `quarkus-resteasy` → **Quarkus**; `micronaut-http-server-netty` → **Micronaut** | +| `build.gradle` / `build.gradle.kts` | Java / Kotlin | Search for `org.springframework.boot` → **Spring Boot**; `io.quarkus` → **Quarkus**; `io.micronaut` → **Micronaut** | +| `go.mod` | Go | Parse `require` block for: `github.com/gin-gonic/gin` → **Gin**; `github.com/labstack/echo` → **Echo**; `github.com/gofiber/fiber` → **Fiber**. For `net/http` (stdlib): search `.go` source files for `"net/http"` import — stdlib packages never appear in the `require` block | +| `*.csproj` | .NET | Search for `` for version (e.g. `net8.0`) | +| `Cargo.toml` | Rust | Parse `[dependencies]` for: `actix-web` → **Actix**; `axum` → **Axum**; `rocket` → **Rocket**; `warp` → **Warp** | + +**If multiple signal files are found** (e.g. both `package.json` and `requirements.txt`), record all of them — this may indicate a monorepo or polyglot project. Flag for clarification. + +## Port Detection + +Check these sources in priority order (first match wins): + +| Source | What to Look For | Example | +|---|---|---| +| `Dockerfile` | `EXPOSE ` directive | `EXPOSE 3000` | +| `.env` / `.env.example` | `PORT=` | `PORT=8080` | +| `package.json` (`scripts.start`) | `--port ` or `-p ` | `next start --port 3000` | +| Source code | `app.listen()`, `.listen()`, `server.port=` | `app.listen(3000)` | +| `application.properties` / `application.yml` (Java) | `server.port=` | `server.port=8080` | +| `appsettings.json` (.NET) | `"Urls": "http://*:"` | `"Urls": "http://*:8080"` | +| Framework defaults | Use known defaults if nothing explicit found | Express: 3000, FastAPI: 8000, Spring Boot: 8080, ASP.NET: 8080, Gin: 8080 | + +## Health Endpoint Detection + +Grep the source tree for route registrations matching these patterns: + +| Pattern | Endpoint Type | +|---|---| +| `/health` | Generic health check | +| `/healthz` | Kubernetes-style health check | +| `/ready`, `/readiness` | Readiness probe | +| `/liveness` | Liveness probe | +| `/startup` | Startup probe | +| `/ping` | Simple ping (sometimes used as health) | +| `/status` | Status endpoint | +| `/api/health`, `/api/healthz` | Prefixed health check | + +Record the **HTTP method** (GET/HEAD) and **expected response code** (200) for each detected endpoint. If no health endpoints are found, flag it — probes will use `/health` as default. diff --git a/plugin/skills/deploy-to-aks/references/rollback.md b/plugin/skills/deploy-to-aks/references/rollback.md new file mode 100644 index 000000000..940ac0dee --- /dev/null +++ b/plugin/skills/deploy-to-aks/references/rollback.md @@ -0,0 +1,66 @@ +# Rollback Guidance + +Recovery procedures for deployment failures. Referenced from Section 4 (Deploy). + +--- + +## Image Build Failed + +```bash +# No cloud resources were persisted — nothing to roll back. +# Fix the issue and retry: + +# Common fixes: +# - Dockerfile syntax error → edit Dockerfile +# - Missing file in build context → check .dockerignore +# - Dependency install failure → fix package.json / requirements.txt / go.mod + +# Retry: +az acr build --registry --image : . +``` + +## kubectl apply Failed (Section 4 — Deploy to Cluster) + +```bash +# Remove the partially applied resources: +kubectl delete -f k8s/ + +# Common fixes: +# - YAML syntax error → validate with: kubectl apply -f k8s/ --dry-run=client +# - Invalid resource field → check API version matches cluster version +# - Image pull error → verify ACR name in deployment.yaml matches actual ACR +# - Namespace doesn't exist → create it first or remove namespace from manifests + +# Fix and retry: +kubectl apply -f k8s/ +``` + +## Pods Not Starting (Section 5 — Verify) + +```bash +# Diagnose: +kubectl get pods -l app=myapp +kubectl describe pod -l app=myapp +kubectl logs -l app=myapp --tail=50 + +# Common error patterns: + +# CrashLoopBackOff — app crashes on startup +# → Check logs for the crash reason +# → Usually: missing env var, bad database connection string, port mismatch + +# ImagePullBackOff — can't pull the container image +# → Verify image name: kubectl get deployment myapp -o jsonpath='{.spec.template.spec.containers[0].image}' +# → Verify ACR access: az aks check-acr --resource-group --name --acr .azurecr.io + +# Pending — pod can't be scheduled +# → Check node status: kubectl get nodes +# → Check resource requests vs available capacity: kubectl describe nodes + +# OOMKilled — app exceeded memory limit +# → Increase memory limit in k8s/deployment.yaml and re-apply + +# After fixing, re-apply: +kubectl apply -f k8s/ +kubectl rollout status deployment/myapp --timeout=300s +``` diff --git a/plugin/skills/deploy-to-aks/references/safeguards.md b/plugin/skills/deploy-to-aks/references/safeguards.md new file mode 100644 index 000000000..0a737d57b --- /dev/null +++ b/plugin/skills/deploy-to-aks/references/safeguards.md @@ -0,0 +1,148 @@ +# AKS Deployment Safeguards Reference + +> **Last updated:** 2026-04-02 + +AKS Deployment Safeguards enforce best practices on Kubernetes manifests at admission time. This reference covers every rule the skill validates **before** deployment. + +--- + +## DS001 — Resource Limits Required (Error) + +Every container needs `resources.requests` AND `resources.limits` for both `cpu` and `memory`. + +```yaml +resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "500m" + memory: "256Mi" +``` + +## DS002 — Liveness Probe Required (Warning) + +Every container needs a `livenessProbe`. Use `httpGet`, `tcpSocket`, or `exec`: + +```yaml +livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 15 +``` + +## DS003 — Readiness Probe Required (Warning) + +Every container needs a `readinessProbe`: + +```yaml +readinessProbe: + httpGet: + path: /ready + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 +``` + +## DS004 — runAsNonRoot Required (Error) + +Set at **both** pod and container level: + +```yaml +# Pod level +spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + +# Container level +securityContext: + runAsNonRoot: true +``` + +## DS005 — No hostNetwork (Error) + +Remove `hostNetwork: true` or set to `false`. + +## DS006 — No hostPID (Error) + +Remove `hostPID: true` or set to `false`. + +## DS007 — No hostIPC (Error) + +Remove `hostIPC: true` or set to `false`. + +## DS008 — No Privileged Containers (Error) + +Remove `securityContext.privileged: true` or set to `false`. + +## DS009 — No :latest Image Tag (Error, NOT auto-fixable) + +Use a semantic version, git SHA, or digest — never `:latest` or omit the tag. + +## DS010 — Minimum 2 Replicas (Warning) + +Set `spec.replicas: 2` or higher. Pair with a PodDisruptionBudget. + +## DS011 — allowPrivilegeEscalation: false (Error) + +Every container must have: + +```yaml +securityContext: + allowPrivilegeEscalation: false +``` + +## DS012 — readOnlyRootFilesystem: true (Warning) + +```yaml +securityContext: + readOnlyRootFilesystem: true +``` + +If the app writes to specific paths, mount `emptyDir` volumes: + +```yaml +volumes: + - name: tmp + emptyDir: {} +containers: + - volumeMounts: + - name: tmp + mountPath: /tmp +``` + +Common writable paths: Spring Boot `/tmp`, ASP.NET `/tmp`, Django `/tmp`, Express `/tmp`, Go `/tmp`. + +## DS013 — automountServiceAccountToken: false (Warning) + +```yaml +spec: + automountServiceAccountToken: false +``` + +Set to `true` only if the app genuinely calls the K8s API (scope with RBAC). + +--- + +## Quick Reference + +| Rule | What | Severity | Auto-Fix | +|------|------|----------|----------| +| DS001 | Resource limits | Error | Yes | +| DS002 | Liveness probe | Warning | Yes | +| DS003 | Readiness probe | Warning | Yes | +| DS004 | runAsNonRoot | Error | Yes | +| DS005 | No hostNetwork | Error | Yes | +| DS006 | No hostPID | Error | Yes | +| DS007 | No hostIPC | Error | Yes | +| DS008 | No privileged | Error | Yes | +| DS009 | No :latest tag | Error | No | +| DS010 | Min 2 replicas | Warning | Yes | +| DS011 | No privilege escalation | Error | Yes | +| DS012 | Read-only root FS | Warning | Yes | +| DS013 | No SA token mount | Warning | Yes | diff --git a/plugin/skills/deploy-to-aks/references/workload-identity.md b/plugin/skills/deploy-to-aks/references/workload-identity.md new file mode 100644 index 000000000..3a3569d2a --- /dev/null +++ b/plugin/skills/deploy-to-aks/references/workload-identity.md @@ -0,0 +1,260 @@ +# Azure Workload Identity for AKS + +> **Last updated:** 2026-04-02 + +## What Is Workload Identity? + +Workload Identity lets pods in AKS authenticate to Azure services (Key Vault, Storage, +PostgreSQL, etc.) without storing any secrets. Instead of injecting connection strings or +passwords, your pod proves its identity through a short-lived token issued by the +cluster's OIDC provider, which Microsoft Entra ID trusts because you've set up a federation +between the cluster and a Managed Identity. The pod gets a token automatically — your +app code just uses the standard Azure SDK credential chain. + +--- + +## Three Components + +### 1. User-Assigned Managed Identity + +A Managed Identity in Azure that has RBAC role assignments on the target resources +(e.g., `Key Vault Secrets User`, `Storage Blob Data Contributor`). + +``` +Managed Identity + ├── Client ID: + ├── Tenant ID: + └── Role assignments: + ├── Key Vault Secrets User → /subscriptions/.../vaults/my-kv + ├── Storage Blob Data Contributor → /subscriptions/.../storageAccounts/my-sa + └── ... +``` + +### 2. Federated Identity Credential + +A trust relationship that says: "When the AKS cluster's OIDC issuer presents a token +for ServiceAccount `/`, treat it as this Managed Identity." + +``` +Federated Credential + ├── Issuer: https://oidc.prod-aks.azure.com// + ├── Subject: system:serviceaccount:: + └── Audience: api://AzureADTokenExchange +``` + +### 3. Kubernetes ServiceAccount + +A standard K8s ServiceAccount annotated with the Managed Identity's client ID. + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: + namespace: + annotations: + azure.workload.identity/client-id: "" +``` + +--- + +## How They Link Together + +``` +Pod (with label azure.workload.identity/use: "true") + │ + ├── References ServiceAccount (annotated with client-id) + │ + ▼ +AKS OIDC Issuer issues a projected service account token + │ + ├── Issuer URL matches the Federated Credential's issuer + ├── Subject (system:serviceaccount:ns:sa) matches the Federated Credential's subject + │ + ▼ +Microsoft Entra ID validates the federation and issues a token + │ + ▼ +Azure SDK (DefaultAzureCredential) uses the token to access Azure resources +``` + +The Workload Identity webhook in AKS automatically: +- Projects the service account token into the pod at a well-known path +- Sets the `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and `AZURE_FEDERATED_TOKEN_FILE` + environment variables in the container + +Your app code does **not** need to know about any of this — `DefaultAzureCredential` +picks it up automatically. + +--- + +## Per-Service Patterns + +### PostgreSQL (Flexible Server with Microsoft Entra ID Auth) + +The Managed Identity needs the ` Admin` or a custom PostgreSQL role. + +```python +# Python — psycopg2 + DefaultAzureCredential +import psycopg2 +from azure.identity import DefaultAzureCredential + +credential = DefaultAzureCredential() +token = credential.get_token("https://ossrdbms-aad.database.windows.net/.default") + +conn = psycopg2.connect( + host=".postgres.database.azure.com", + dbname="", + user="", + password=token.token, + sslmode="require", +) +``` + +```csharp +// C# — Npgsql + Azure.Identity +var credential = new DefaultAzureCredential(); +var token = await credential.GetTokenAsync( + new TokenRequestContext(new[] { "https://ossrdbms-aad.database.windows.net/.default" })); + +var connString = $"Host=.postgres.database.azure.com;Database=;" + + $"Username=;Password={token.Token};SSL Mode=Require"; +await using var conn = new NpgsqlConnection(connString); +``` + +**Required env vars** (injected by Workload Identity webhook): +- `AZURE_CLIENT_ID` — used by `DefaultAzureCredential` + +### Key Vault + +Role assignment: `Key Vault Secrets User` (or `Key Vault Crypto User` for keys). + +```python +# Python +from azure.identity import DefaultAzureCredential +from azure.keyvault.secrets import SecretClient + +credential = DefaultAzureCredential() +client = SecretClient(vault_url="https://.vault.azure.net", credential=credential) +secret = client.get_secret("my-secret") +``` + +```csharp +// C# +var credential = new DefaultAzureCredential(); +var client = new SecretClient(new Uri("https://.vault.azure.net"), credential); +KeyVaultSecret secret = await client.GetSecretAsync("my-secret"); +``` + +### Azure Blob Storage + +Role assignment: `Storage Blob Data Contributor` (or `Reader` for read-only). + +```python +# Python +from azure.identity import DefaultAzureCredential +from azure.storage.blob import BlobServiceClient + +credential = DefaultAzureCredential() +client = BlobServiceClient( + account_url="https://.blob.core.windows.net", + credential=credential, +) +``` + +```csharp +// C# +var credential = new DefaultAzureCredential(); +var client = new BlobServiceClient( + new Uri("https://.blob.core.windows.net"), credential); +``` + +### Azure Cache for Redis (Microsoft Entra ID Token Auth) + +Role assignment: `Redis Cache Contributor` or custom data-plane role. + +```python +# Python — redis-py with Microsoft Entra ID token +import os +from azure.identity import DefaultAzureCredential +import redis + +credential = DefaultAzureCredential() +token = credential.get_token("https://redis.azure.com/.default") + +r = redis.Redis( + host=".redis.cache.windows.net", + port=6380, + ssl=True, + username=os.environ["AZURE_CLIENT_ID"], + password=token.token, +) +``` + +```csharp +// C# +var credential = new DefaultAzureCredential(); +var token = await credential.GetTokenAsync( + new TokenRequestContext(new[] { "https://redis.azure.com/.default" })); + +var muxer = await ConnectionMultiplexer.ConnectAsync(new ConfigurationOptions +{ + EndPoints = { ".redis.cache.windows.net:6380" }, + Ssl = true, + User = Environment.GetEnvironmentVariable("AZURE_CLIENT_ID"), + Password = token.Token, +}); +``` + +--- + +## Required Pod Labels and ServiceAccount Annotations + +### Pod Label (on the Deployment's `spec.template.metadata.labels`) + +```yaml +labels: + azure.workload.identity/use: "true" +``` + +This label tells the Workload Identity webhook to inject the projected token volume +and environment variables into the pod. + +### ServiceAccount Annotation + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: + annotations: + azure.workload.identity/client-id: "" +``` + +This annotation tells the webhook which Managed Identity to federate with. + +### Complete Deployment Snippet + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: +spec: + template: + metadata: + labels: + app: + azure.workload.identity/use: "true" # ← required label + spec: + serviceAccountName: # ← references annotated SA + automountServiceAccountToken: false # ← DS013 (Workload Identity uses projected volume, not SA token) + containers: + - name: + # AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_FEDERATED_TOKEN_FILE + # are injected automatically by the webhook +``` + +> **Note:** `automountServiceAccountToken: false` disables the *default* SA token mount. +> Workload Identity uses a separate projected volume that the webhook manages independently, +> so both can coexist without conflict. diff --git a/plugin/skills/deploy-to-aks/templates/dockerfiles/dotnet.Dockerfile b/plugin/skills/deploy-to-aks/templates/dockerfiles/dotnet.Dockerfile new file mode 100644 index 000000000..ddc23d025 --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/dockerfiles/dotnet.Dockerfile @@ -0,0 +1,63 @@ +# ============================================================================= +# .NET (ASP.NET Core) Production Dockerfile +# ============================================================================= +# Customize the following before use: +# - PROJECT_NAME: Replace "MyApp" with your .csproj name (without extension) +# - PORT: Change EXPOSE port if not 8080 +# - ASSEMBLY: Adjust the DLL name in ENTRYPOINT if it differs from the project +# +# Notes: +# - .NET 8+ defaults to port 8080 (ASPNETCORE_HTTP_PORTS), not 80 +# - The "app" user is built into the aspnet runtime image since .NET 8 +# - For self-contained deployment, add --self-contained to dotnet publish +# and switch the runtime image to mcr.microsoft.com/dotnet/runtime-deps:9.0 +# ============================================================================= + +# --------------------------------------------------------------------------- +# Stage 1: Build +# --------------------------------------------------------------------------- +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build + +WORKDIR /src + +# Layer caching: restore NuGet packages before copying the full source. +# Copy only project files first so the restore layer is cached independently. +COPY *.sln ./ +COPY src/MyApp/*.csproj src/MyApp/ + +RUN dotnet restore src/MyApp/MyApp.csproj + +# Copy everything and publish a Release build +COPY . . + +RUN dotnet publish src/MyApp/MyApp.csproj \ + --configuration Release \ + --no-restore \ + --output /app/publish + +# --------------------------------------------------------------------------- +# Stage 2: Runtime +# --------------------------------------------------------------------------- +FROM mcr.microsoft.com/dotnet/aspnet:9.0 + +WORKDIR /app + +# Copy published output from the build stage +COPY --from=build /app/publish ./ + +# AKS Deployment Safeguards DS004: run as non-root. +# The "app" user is built into the aspnet image since .NET 8. +USER app + +EXPOSE 8080 + +ENV ASPNETCORE_URLS="http://+:8080" \ + DOTNET_RUNNING_IN_CONTAINER=true \ + DOTNET_EnableDiagnostics=0 + +# No HEALTHCHECK: the aspnet runtime image does not include curl or wget. +# Kubernetes liveness/readiness probes (configured in deployment.yaml) handle +# health checking in AKS. For local Docker usage, install wget or add +# app.MapHealthChecks("/healthz") and use a custom health check binary. + +ENTRYPOINT ["dotnet", "MyApp.dll"] diff --git a/plugin/skills/deploy-to-aks/templates/dockerfiles/dotnet.dockerignore b/plugin/skills/deploy-to-aks/templates/dockerfiles/dotnet.dockerignore new file mode 100644 index 000000000..8df105361 --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/dockerfiles/dotnet.dockerignore @@ -0,0 +1,16 @@ +**/bin +**/obj +**/out +*.user +*.suo +.vs +.env +.env.* +.git +.gitignore +.dockerignore +Dockerfile +*.md +.vscode +.idea +**/TestResults diff --git a/plugin/skills/deploy-to-aks/templates/dockerfiles/go.Dockerfile b/plugin/skills/deploy-to-aks/templates/dockerfiles/go.Dockerfile new file mode 100644 index 000000000..54049e11c --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/dockerfiles/go.Dockerfile @@ -0,0 +1,54 @@ +# ============================================================================= +# Go Production Dockerfile +# ============================================================================= +# Customize the following before use: +# - APP_NAME: Replace "app" in the binary name and CMD +# - PORT: Change EXPOSE port if not 8080 +# - MODULE_PATH: Ensure go.mod module path matches your project +# +# Notes: +# - CGO_ENABLED=0 produces a fully static binary that runs on distroless +# - The distroless runtime has no shell — use the exec form for CMD +# - To debug, swap the runtime to gcr.io/distroless/static-debian12:debug +# which includes busybox +# ============================================================================= + +# --------------------------------------------------------------------------- +# Stage 1: Build +# --------------------------------------------------------------------------- +FROM golang:-alpine AS build + +WORKDIR /src + +# Layer caching: download module dependencies before copying source. +# This layer is only rebuilt when go.mod or go.sum changes. +COPY go.mod go.sum ./ + +RUN go mod download && go mod verify + +# Copy source and compile a static binary +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux \ + go build -ldflags="-s -w" -o /bin/app ./cmd/app + +# --------------------------------------------------------------------------- +# Stage 2: Runtime +# --------------------------------------------------------------------------- +FROM gcr.io/distroless/static-debian12 + +# Copy the compiled binary from the build stage +COPY --from=build /bin/app /app + +# AKS Deployment Safeguards DS004: run as non-root. +# 65534 is the "nobody" user in distroless images. +USER 65534 + +EXPOSE 8080 + +# Distroless has no shell, curl, or wget. Kubernetes liveness/readiness probes +# (configured in deployment.yaml) handle health checking in AKS. +# For local Docker usage, consider adding a /healthz handler and using a +# statically-compiled health check binary, or swap to the :debug variant. + +ENTRYPOINT ["/app"] diff --git a/plugin/skills/deploy-to-aks/templates/dockerfiles/go.dockerignore b/plugin/skills/deploy-to-aks/templates/dockerfiles/go.dockerignore new file mode 100644 index 000000000..6b1cc2cac --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/dockerfiles/go.dockerignore @@ -0,0 +1,18 @@ +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +vendor +.env +.env.* +.git +.gitignore +.dockerignore +Dockerfile +*.md +.vscode +.idea +tmp diff --git a/plugin/skills/deploy-to-aks/templates/dockerfiles/java.Dockerfile b/plugin/skills/deploy-to-aks/templates/dockerfiles/java.Dockerfile new file mode 100644 index 000000000..835ac0472 --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/dockerfiles/java.Dockerfile @@ -0,0 +1,77 @@ +# ============================================================================= +# Java (Spring Boot / Maven) Production Dockerfile +# ============================================================================= +# Customize the following before use: +# - JAR_FILE: Adjust the glob pattern if your build output differs +# - PORT: Change EXPOSE port if not 8080 +# - JVM_OPTS: Tune -Xmx, -Xms, GC flags, etc. via JAVA_OPTS env var +# +# Gradle users: +# Replace the Maven wrapper commands in the build stage with: +# COPY gradlew build.gradle.kts settings.gradle.kts ./ +# COPY gradle ./gradle +# RUN ./gradlew dependencies --no-daemon +# COPY . . +# RUN ./gradlew bootJar --no-daemon +# And adjust the JAR_FILE path to "build/libs/*.jar" +# ============================================================================= + +# --------------------------------------------------------------------------- +# Stage 1: Build +# --------------------------------------------------------------------------- +FROM eclipse-temurin:21-jdk-alpine AS build + +WORKDIR /app + +# Layer caching: copy Maven wrapper and POM first so dependency resolution is +# cached independently of source changes. +COPY mvnw pom.xml ./ +COPY .mvn .mvn + +# Download dependencies (offline-friendly layer) +RUN chmod +x mvnw \ + && ./mvnw dependency:go-offline -B + +# Copy source and build the fat JAR +COPY src ./src + +# -Dspring-boot.repackage.finalName=app ensures a single predictably named fat JAR, +# avoiding glob ambiguity when Maven produces both thin and fat JARs. +RUN ./mvnw package spring-boot:repackage -DskipTests -B \ + -Dspring-boot.repackage.finalName=app \ + && mv target/app.jar app.jar + +# --------------------------------------------------------------------------- +# Stage 2: Runtime +# --------------------------------------------------------------------------- +FROM eclipse-temurin:21-jre-alpine + +WORKDIR /app + +# AKS Deployment Safeguards DS004: create and switch to a non-root user +RUN addgroup -S appuser && adduser -S appuser -G appuser + +# Copy only the built JAR from the build stage +COPY --from=build --chown=appuser:appuser /app/app.jar ./app.jar + +# Spring Boot Layered JARs: if using layered JARs, replace the COPY above +# with the extract + copy approach for even better layer caching: +# RUN java -Djarmode=layertools -jar app.jar extract +# COPY --from=build /app/dependencies/ ./ +# COPY --from=build /app/spring-boot-loader/ ./ +# COPY --from=build /app/snapshot-dependencies/ ./ +# COPY --from=build /app/application/ ./ + +USER appuser + +EXPOSE 8080 + +# MaxRAMPercentage caps heap relative to the container memory limit +# (container-aware by default in JDK 21). +ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC" + +# No HEALTHCHECK: the JRE Alpine image does not include wget or curl. +# Kubernetes liveness/readiness probes (configured in deployment.yaml) handle +# health checking in AKS. + +ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"] diff --git a/plugin/skills/deploy-to-aks/templates/dockerfiles/java.dockerignore b/plugin/skills/deploy-to-aks/templates/dockerfiles/java.dockerignore new file mode 100644 index 000000000..c5f60d24b --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/dockerfiles/java.dockerignore @@ -0,0 +1,20 @@ +target +build +.gradle +*.class +*.jar +*.war +!*.jar +.env +.env.* +.git +.gitignore +.dockerignore +Dockerfile +*.md +.vscode +.idea +*.iml +.settings +.project +.classpath diff --git a/plugin/skills/deploy-to-aks/templates/dockerfiles/node.Dockerfile b/plugin/skills/deploy-to-aks/templates/dockerfiles/node.Dockerfile new file mode 100644 index 000000000..0a0b57e7b --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/dockerfiles/node.Dockerfile @@ -0,0 +1,70 @@ +# ============================================================================= +# Node.js Production Dockerfile +# ============================================================================= +# Customize the following before use: +# - APP_NAME: Replace in comments as needed +# - PORT: Change EXPOSE port if not 3000 +# - ENTRY_POINT: Change the final CMD to your main file (e.g. dist/main.js) +# - BUILD_CMD: Adjust "npm run build --if-present" if your build script differs +# +# Package manager support: +# - npm: This file is configured for npm by default +# - yarn: Replace "npm ci" with "yarn install --frozen-lockfile" +# Replace "package-lock.json" with "yarn.lock" +# - pnpm: Replace "npm ci" with "corepack enable && pnpm install --frozen-lockfile" +# Replace "package-lock.json" with "pnpm-lock.yaml" +# ============================================================================= + +# --------------------------------------------------------------------------- +# Stage 1: Build +# --------------------------------------------------------------------------- +FROM node:22-alpine AS build + +WORKDIR /app + +# Layer caching: copy dependency manifests first so the install layer is +# only rebuilt when dependencies change, not on every source edit. +COPY package.json package-lock.json ./ + +RUN npm ci + +# Copy the rest of the source and build +COPY . . + +RUN npm run build --if-present + +# Guard: verify build output exists at expected location +RUN test -d /app/dist || (echo "ERROR: Build output directory '/app/dist' not found." && echo "Your build script did not produce output in the 'dist/' directory." && echo "Update the 'COPY --from=build /app/dist ./dist' line in the runtime stage" && echo "to match your build script's output directory (e.g., 'build/', 'out/', 'public/')." && exit 1) + +# Remove dev dependencies to slim down the production node_modules +RUN npm prune --omit=dev + +# --------------------------------------------------------------------------- +# Stage 2: Runtime +# --------------------------------------------------------------------------- +FROM node:22-alpine + +# Security: install dumb-init so Node runs as PID > 1 and signals propagate +# correctly — avoids zombie processes inside the container. +RUN apk add --no-cache dumb-init + +WORKDIR /app + +# Copy only production artifacts from the build stage. +# If your build script outputs to a different directory (e.g. build/ or out/), +# update the /app/dist path below to match. +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY --from=build /app/package.json ./ + +# AKS Deployment Safeguards DS004: never run as root. +# The "node" user (uid 1000) is built into the node-alpine image. +USER node + +EXPOSE 3000 + +# HEALTHCHECK is omitted — Kubernetes liveness/readiness probes handle health +# checks in AKS. See deployment.yaml for probe configuration. + +ENTRYPOINT ["dumb-init", "--"] +CMD ["node", "dist/main.js"] diff --git a/plugin/skills/deploy-to-aks/templates/dockerfiles/node.dockerignore b/plugin/skills/deploy-to-aks/templates/dockerfiles/node.dockerignore new file mode 100644 index 000000000..414950c2a --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/dockerfiles/node.dockerignore @@ -0,0 +1,20 @@ +node_modules +npm-debug.log* +.npm +.env +.env.* +dist +build +.git +.gitignore +.dockerignore +Dockerfile +*.md +.vscode +.idea +coverage +.nyc_output +tests +__tests__ +*.test.js +*.spec.js diff --git a/plugin/skills/deploy-to-aks/templates/dockerfiles/python.Dockerfile b/plugin/skills/deploy-to-aks/templates/dockerfiles/python.Dockerfile new file mode 100644 index 000000000..cca706387 --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/dockerfiles/python.Dockerfile @@ -0,0 +1,63 @@ +# ============================================================================= +# Python Production Dockerfile +# ============================================================================= +# Customize the following before use: +# - APP_MODULE: Change the uvicorn target (e.g. "app.main:app" for FastAPI, +# "myproject.wsgi:application" for Django with gunicorn) +# - PORT: Change EXPOSE port if not 8000 +# - DEPS FILE: If using Poetry, replace requirements.txt steps with +# "poetry export -f requirements.txt" in the build stage +# - ENTRY_POINT: Adjust the final CMD for your framework (gunicorn, uvicorn, +# flask run, etc.) +# ============================================================================= + +# --------------------------------------------------------------------------- +# Stage 1: Build +# --------------------------------------------------------------------------- +FROM python:3.12-slim AS build + +WORKDIR /app + +# Create a virtual environment so we can copy it cleanly to the runtime stage +RUN python -m venv /app/venv +ENV PATH="/app/venv/bin:$PATH" + +# Layer caching: install dependencies before copying source +COPY requirements.txt ./ + +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt + +# Copy application source +COPY . . + +# If you have a build step (e.g. Django collectstatic), run it here: +# RUN python manage.py collectstatic --noinput + +# --------------------------------------------------------------------------- +# Stage 2: Runtime +# --------------------------------------------------------------------------- +FROM python:3.12-slim + +WORKDIR /app + +# AKS Deployment Safeguards DS004: create and switch to a non-root user +RUN groupadd --gid 1000 appuser \ + && useradd --uid 1000 --gid appuser --shell /bin/sh --create-home appuser + +# Copy the virtual environment and application source from the build stage +COPY --from=build --chown=appuser:appuser /app /app + +ENV PATH="/app/venv/bin:$PATH" \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +USER appuser + +EXPOSE 8000 + +# HEALTHCHECK is omitted — Kubernetes liveness/readiness probes handle health +# checks in AKS. Adding a Dockerfile HEALTHCHECK would require installing curl +# in the runtime image, increasing size and attack surface. + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/plugin/skills/deploy-to-aks/templates/dockerfiles/python.dockerignore b/plugin/skills/deploy-to-aks/templates/dockerfiles/python.dockerignore new file mode 100644 index 000000000..fccf85366 --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/dockerfiles/python.dockerignore @@ -0,0 +1,25 @@ +__pycache__ +*.pyc +*.pyo +*.egg-info +dist +build +.eggs +.env +.env.* +.venv +venv +env +.git +.gitignore +.dockerignore +Dockerfile +*.md +.vscode +.idea +.pytest_cache +.mypy_cache +.ruff_cache +htmlcov +.coverage +tests diff --git a/plugin/skills/deploy-to-aks/templates/dockerfiles/rust.Dockerfile b/plugin/skills/deploy-to-aks/templates/dockerfiles/rust.Dockerfile new file mode 100644 index 000000000..1daef49ca --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/dockerfiles/rust.Dockerfile @@ -0,0 +1,72 @@ +# ============================================================================= +# Rust Production Dockerfile +# ============================================================================= +# Customize the following before use: +# - APP_NAME: Replace "app" with your binary name from Cargo.toml +# - PORT: Change EXPOSE port if not 8080 +# +# Notes: +# - The dependency-caching trick creates a dummy main.rs, builds +# dependencies, then replaces it with real source — this avoids +# rebuilding all deps on every source change +# - The final image uses distroless/cc which includes libgcc/libstdc++ +# needed by the default Rust allocator; if you use musl +# (--target x86_64-unknown-linux-musl) switch to distroless/static +# - For workspace builds, copy the whole workspace in one shot and adjust +# the binary path in the final COPY +# ============================================================================= + +# --------------------------------------------------------------------------- +# Stage 1: Build +# --------------------------------------------------------------------------- +FROM rust:1.83-slim AS build + +WORKDIR /app + +# Install build dependencies (if any native libs are needed, add them here) +RUN apt-get update \ + && apt-get install -y --no-install-recommends pkg-config libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +# Layer caching: build dependencies separately from application code. +# 1. Copy only the manifests and create a dummy main to compile deps. +COPY Cargo.toml Cargo.lock ./ + +RUN mkdir src \ + && echo 'fn main() { println!("placeholder"); }' > src/main.rs \ + && cargo build --release \ + && echo "IMPORTANT: Update 'app' below to match your [[bin]] name in Cargo.toml." \ + && echo "If the name doesn't match, this cache trick will silently fail." \ + && rm -rf src target/release/deps/app* target/release/app* + +# 2. Copy real source and build the actual binary. +COPY src ./src + +RUN cargo build --release + +# Verify binary exists with expected name +RUN test -f /app/target/release/app || (echo "ERROR: Binary 'app' not found at /app/target/release/app"; echo "The binary name in Cargo.toml must be 'app'."; echo "Update [[bin]] section in Cargo.toml to set name = \"app\""; echo "Also verify the COPY step above uses the correct binary name."; exit 1) + +# --------------------------------------------------------------------------- +# Stage 2: Runtime +# --------------------------------------------------------------------------- +FROM gcr.io/distroless/cc-debian12 + +WORKDIR /app + +# Update source path if your Cargo.toml binary name differs from "app" +COPY --from=build /app/target/release/app /app/app + +# AKS Deployment Safeguards DS004: run as non-root. +# 65534 is the "nobody" user in distroless images. +USER 65534 + +EXPOSE 8080 + +# Distroless has no shell, curl, or wget. Kubernetes liveness/readiness probes +# (configured in deployment.yaml) handle health checking in AKS. +# For local Docker usage, consider adding a /healthz handler and using a +# statically-compiled health check binary. + +# Update "/app/app" if your binary name differs +ENTRYPOINT ["/app/app"] diff --git a/plugin/skills/deploy-to-aks/templates/dockerfiles/rust.dockerignore b/plugin/skills/deploy-to-aks/templates/dockerfiles/rust.dockerignore new file mode 100644 index 000000000..04876f729 --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/dockerfiles/rust.dockerignore @@ -0,0 +1,11 @@ +target +*.rs.bk +.env +.env.* +.git +.gitignore +.dockerignore +Dockerfile +*.md +.vscode +.idea diff --git a/plugin/skills/deploy-to-aks/templates/github-actions/deploy.yml b/plugin/skills/deploy-to-aks/templates/github-actions/deploy.yml new file mode 100644 index 000000000..051dd8925 --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/github-actions/deploy.yml @@ -0,0 +1,192 @@ +# GitHub Actions workflow: Deploy to AKS +# +# This workflow builds a container image, pushes it to Azure Container Registry, +# and deploys it to an Azure Kubernetes Service cluster. +# +# Authentication uses OIDC federation (workload identity) — no stored passwords. +# Required GitHub secrets: AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID +# +# Placeholders to replace (uses __DOUBLE_UNDERSCORE__ style; K8s templates use angle-bracket style): +# __ACR_NAME__ — Azure Container Registry name (e.g. myappacr) +# __AKS_CLUSTER__ — AKS cluster name (e.g. myapp-aks) +# __RG_NAME__ — Azure resource group containing ACR and AKS +# __APP_NAME__ — Application / deployment name in Kubernetes +# __NAMESPACE__ — Kubernetes namespace to deploy into + +name: Deploy to AKS + +on: + # Trigger on push to main branch (app code changes only) + push: + branches: + - main + paths-ignore: + - 'docs/**' + - '*.md' + - '.github/**' + - '.vscode/**' + + # Allow manual trigger from the Actions tab + workflow_dispatch: + +# OIDC federation requires these permissions so GitHub can issue +# an ID token that Microsoft Entra ID will accept. +permissions: + id-token: write # Required for requesting the JWT + contents: read # Required for actions/checkout + +# Prevent parallel deployments on the same branch. +# Uses workflow + ref so staging and production runs can proceed independently. +# cancel-in-progress: false ensures the running deploy finishes +# before the queued deploy starts (avoids mid-rollout conflicts). +# Note: env context is not available here — use github or vars contexts only. +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }} + cancel-in-progress: false + +env: + ACR_NAME: __ACR_NAME__ + AKS_CLUSTER: __AKS_CLUSTER__ + RESOURCE_GROUP: __RG_NAME__ + APP_NAME: __APP_NAME__ + NAMESPACE: __NAMESPACE__ + +defaults: + run: + shell: bash + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # ----------------------------------------------------------- + # Validate that no placeholders remain unreplaced + # + # Checks for __PLACEHOLDER__ and patterns in + # env vars and k8s/ directory. Fails fast with clear error + # if any found. + # ----------------------------------------------------------- + - name: Validate — no unreplaced placeholders + run: | + PLACEHOLDERS_FOUND=0 + + # Check env variables + for VAR in ACR_NAME AKS_CLUSTER RESOURCE_GROUP APP_NAME NAMESPACE; do + VALUE="${!VAR}" + if [[ "$VALUE" =~ __[A-Z_]+__ ]]; then + echo "❌ Placeholder found in \$${VAR}: ${VALUE}" + PLACEHOLDERS_FOUND=1 + fi + done + + # Check k8s/ directory if it exists + if [ -d k8s ]; then + # __PLACEHOLDER__ style (env var style used in this workflow) + if grep -rq '__[A-Z_]\+__' k8s/; then + echo "❌ Placeholders found in k8s/ manifests:" + grep -rn '__[A-Z_]\+__' k8s/ || true + PLACEHOLDERS_FOUND=1 + fi + # style (angle-bracket style used in K8s manifest templates) + if grep -rqP '<[a-z][a-z0-9-]*>' k8s/; then + echo "❌ Angle-bracket placeholders found in k8s/ manifests:" + grep -rnP '<[a-z][a-z0-9-]*>' k8s/ || true + PLACEHOLDERS_FOUND=1 + fi + fi + + if [ $PLACEHOLDERS_FOUND -eq 1 ]; then + echo "" + echo "⚠️ Workflow failed: unreplaced placeholders detected." + echo "Replace the following in your deploy.yml:" + echo " - __ACR_NAME__ → Your Container Registry name" + echo " - __AKS_CLUSTER__ → Your AKS cluster name" + echo " - __RG_NAME__ → Your resource group name" + echo " - __APP_NAME__ → Your application name" + echo " - __NAMESPACE__ → Your Kubernetes namespace" + exit 1 + fi + + echo "✓ All placeholders replaced" + + # ----------------------------------------------------------- + # Authenticate to Azure using OIDC (workload identity) + # + # This exchanges the GitHub-issued OIDC token for an Azure + # access token — no client secret required. + # ----------------------------------------------------------- + - name: Azure Login (OIDC) + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + # ----------------------------------------------------------- + # Build container image and push to ACR + # + # `az acr build` runs the Docker build remotely on ACR, + # so no local Docker daemon is needed. The image is tagged + # with the commit SHA for traceability. + # ----------------------------------------------------------- + - name: Build and push image to ACR + run: | + az acr build \ + --registry ${{ env.ACR_NAME }} \ + --image ${{ env.APP_NAME }}:${{ github.sha }} \ + . + + - name: Set AKS context + uses: azure/aks-set-context@v4 + with: + resource-group: ${{ env.RESOURCE_GROUP }} + cluster-name: ${{ env.AKS_CLUSTER }} + + # ----------------------------------------------------------- + # Deploy to AKS + # + # Applies all K8s resources (with substituted image tag), + # then waits for the rollout to complete successfully. + # Sets a step output flag used to gate the rollback step. + # ----------------------------------------------------------- + - name: Substitute image tag in manifests + env: + IMAGE: ${{ env.ACR_NAME }}.azurecr.io/${{ env.APP_NAME }}:${{ github.sha }} + run: | + if [ ! -d k8s ]; then + echo "❌ k8s/ directory not found — cannot deploy without manifests" + exit 1 + fi + # Use xargs to preserve sed exit codes (find|while swallows them) + find k8s -name "*.yaml" -o -name "*.yml" \ + | xargs -I{} sed -i "s||${IMAGE}|g" "{}" + echo "✓ Image tag substituted in all manifests" + + - name: Deploy to AKS + id: deploy + run: | + # Ensure the namespace exists before applying manifests + kubectl create namespace ${{ env.NAMESPACE }} --dry-run=client -o yaml \ + | kubectl apply -f - + kubectl apply -f k8s/ --namespace ${{ env.NAMESPACE }} + + kubectl rollout status deployment/${{ env.APP_NAME }} \ + --namespace ${{ env.NAMESPACE }} \ + --timeout=300s + + # Signal that the deployment was applied — used to gate rollback + echo "deployed=true" >> "$GITHUB_OUTPUT" + + - name: Rollback on failure + if: failure() && steps.deploy.outputs.deployed == 'true' + run: | + kubectl rollout undo deployment/${{ env.APP_NAME }} \ + --namespace ${{ env.NAMESPACE }} + kubectl rollout status deployment/${{ env.APP_NAME }} \ + --namespace ${{ env.NAMESPACE }} \ + --timeout=120s + echo "⚠️ Rolled back to previous revision" diff --git a/plugin/skills/deploy-to-aks/templates/k8s/configmap.yaml b/plugin/skills/deploy-to-aks/templates/k8s/configmap.yaml new file mode 100644 index 000000000..9e12a2026 --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/k8s/configmap.yaml @@ -0,0 +1,23 @@ +# ============================================================================= +# Kubernetes ConfigMap Template — AKS Deploy Skill +# ============================================================================= +# Stores non-sensitive configuration data as key-value pairs. Values are +# injected into pods as environment variables via envFrom or env/valueFrom. +# +# Do NOT store secrets here — use Azure Key Vault + Workload Identity instead. +# +# REPLACE: — your application name (e.g., order-api) +# REPLACE: — target namespace (e.g., production) +# ============================================================================= +apiVersion: v1 +kind: ConfigMap +metadata: + name: -config + namespace: + labels: + app: +data: + # Add application configuration as key-value pairs. Example: + # LOG_LEVEL: "info" + # ASPNETCORE_ENVIRONMENT: "Production" + # SPRING_PROFILES_ACTIVE: "prod" diff --git a/plugin/skills/deploy-to-aks/templates/k8s/deployment.yaml b/plugin/skills/deploy-to-aks/templates/k8s/deployment.yaml new file mode 100644 index 000000000..f521493b6 --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/k8s/deployment.yaml @@ -0,0 +1,112 @@ +# Kubernetes Deployment Template — AKS Deploy Skill +# Satisfies Deployment Safeguard rules DS001–DS013. Replace values before applying. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: + namespace: + labels: + app: +spec: + # DS010: Minimum 2 replicas for high availability. + # If HPA is enabled, remove this field or set it to the HPA minReplicas value + # to prevent kubectl apply from resetting the replica count on each deploy. + replicas: 2 + selector: + matchLabels: + app: + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + app: + # Workload Identity: enables the mutating webhook to inject + # AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_FEDERATED_TOKEN_FILE + azure.workload.identity/use: "true" + spec: + serviceAccountName: + + # DS013: Do not auto-mount the default ServiceAccount token. + # Workload Identity uses a separate projected volume managed by its webhook. + automountServiceAccountToken: false + + # DS004 (pod-level): Run as non-root + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + + containers: + - name: + # DS009: Always use an explicit tag — never :latest or bare image + image: + ports: + - name: http + containerPort: + protocol: TCP + + # DS001: Resource requests AND limits for cpu and memory + resources: + requests: + cpu: "" + memory: "" + limits: + cpu: "" + memory: "" + + # DS002: Liveness probe + livenessProbe: + httpGet: + path: + port: + initialDelaySeconds: 10 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 + + # DS003: Readiness probe + readinessProbe: + httpGet: + path: + port: + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + + # Startup probe — uncomment for slow-start frameworks (Java/Spring Boot, + # .NET with heavy DI). Prevents the liveness probe from killing the pod + # before it finishes initializing. The pod has up to 30 * 10s = 300s to start. + # startupProbe: + # httpGet: + # path: + # port: + # periodSeconds: 10 + # failureThreshold: 30 + + # DS004, DS008, DS011, DS012 + securityContext: + runAsNonRoot: true + privileged: false + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + + # If the app needs to write to specific paths (logs, tmp, cache), + # mount emptyDir volumes below instead of disabling readOnlyRootFilesystem. + volumeMounts: + - name: tmp + mountPath: /tmp + + volumes: + - name: tmp + emptyDir: {} diff --git a/plugin/skills/deploy-to-aks/templates/k8s/gateway.yaml b/plugin/skills/deploy-to-aks/templates/k8s/gateway.yaml new file mode 100644 index 000000000..1a0e3f168 --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/k8s/gateway.yaml @@ -0,0 +1,43 @@ +# ============================================================================= +# Gateway API — Gateway Resource Template — AKS Deploy Skill +# ============================================================================= +# Use this template for AKS clusters with Istio Gateway API enabled +# (appRoutingIstio.mode: Enabled). This applies to both AKS Automatic and Standard. +# +# For clusters using the default Web App Routing add-on, use ingress.yaml instead. +# +# REPLACE: — name for the gateway (e.g., app-gateway) +# REPLACE: — target namespace (e.g., production) +# REPLACE: — FQDN for the listener (e.g., api.example.com) +# ============================================================================= +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: + namespace: + labels: + app: +spec: + # Istio gateway controller — available on both AKS Automatic and Standard when enabled + gatewayClassName: istio + listeners: + - name: http + protocol: HTTP + port: 80 + hostname: "" + allowedRoutes: + namespaces: + from: Same + # Uncomment for TLS — requires a Secret with the certificate + # - name: https + # protocol: HTTPS + # port: 443 + # hostname: "" + # tls: + # mode: Terminate + # certificateRefs: + # - kind: Secret + # name: + # allowedRoutes: + # namespaces: + # from: Same diff --git a/plugin/skills/deploy-to-aks/templates/k8s/hpa.yaml b/plugin/skills/deploy-to-aks/templates/k8s/hpa.yaml new file mode 100644 index 000000000..f0f80800e --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/k8s/hpa.yaml @@ -0,0 +1,45 @@ +# ============================================================================= +# HorizontalPodAutoscaler Template — AKS Deploy Skill +# ============================================================================= +# Scales the Deployment between min and max replicas based on CPU utilization. +# Minimum of 2 replicas ensures HA even at low load (aligns with DS010). +# +# REPLACE: — your application name (e.g., order-api) +# REPLACE: — target namespace (e.g., production) +# REPLACE: — minimum replicas (default: 2, must be >= 2 for DS010) +# REPLACE: — maximum replicas (e.g., 10) +# ============================================================================= +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: + namespace: + labels: + app: +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: + minReplicas: + maxReplicas: + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + behavior: + scaleDown: + stabilizationWindowSeconds: 300 + policies: + - type: Pods + value: 1 + periodSeconds: 60 + scaleUp: + stabilizationWindowSeconds: 0 + policies: + - type: Pods + value: 2 + periodSeconds: 60 diff --git a/plugin/skills/deploy-to-aks/templates/k8s/httproute.yaml b/plugin/skills/deploy-to-aks/templates/k8s/httproute.yaml new file mode 100644 index 000000000..ce8cac6f6 --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/k8s/httproute.yaml @@ -0,0 +1,35 @@ +# ============================================================================= +# Gateway API — HTTPRoute Template — AKS Deploy Skill +# ============================================================================= +# Routes HTTP traffic from a Gateway to a backend Service. +# Use this together with gateway.yaml on clusters with Istio Gateway API enabled. +# +# REPLACE: — your application name (e.g., order-api) +# REPLACE: — target namespace (e.g., production) +# REPLACE: — name of the Gateway resource (e.g., app-gateway) +# REPLACE: — FQDN matching the Gateway listener (e.g., api.example.com) +# REPLACE: — URL path prefix to match (e.g., /) +# REPLACE: — port on the backend Service (e.g., 80) +# ============================================================================= +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: + namespace: + labels: + app: +spec: + parentRefs: + - name: + namespace: + hostnames: + - "" + rules: + - matches: + - path: + type: PathPrefix + value: "" + backendRefs: + - name: + port: + kind: Service diff --git a/plugin/skills/deploy-to-aks/templates/k8s/ingress.yaml b/plugin/skills/deploy-to-aks/templates/k8s/ingress.yaml new file mode 100644 index 000000000..1d3130eef --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/k8s/ingress.yaml @@ -0,0 +1,50 @@ +# ============================================================================= +# Kubernetes Ingress Template — AKS Deploy Skill +# ============================================================================= +# Use this template for AKS clusters with the Web App Routing add-on +# This is the default for both AKS Automatic and AKS Standard. +# For clusters with Istio Gateway API enabled, use gateway.yaml + httproute.yaml instead. +# +# REPLACE: — your application name (e.g., order-api) +# REPLACE: — target namespace (e.g., production) +# REPLACE: — URL path (e.g., /) +# REPLACE: — port on the backend Service (e.g., 80) +# NOTE: is in the commented host-based rule — fill it in when DNS is configured +# ============================================================================= +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: + namespace: + labels: + app: +spec: + ingressClassName: webapprouting.kubernetes.azure.com + # Uncomment for TLS — requires a Secret with the certificate + # tls: + # - hosts: + # - + # secretName: + rules: + # Initial deploy (no custom domain) — traffic routes to the external IP directly. + # Once DNS is configured, replace this rule with the host-based variant below. + - http: + paths: + - path: "" + pathType: Prefix + backend: + service: + name: + port: + number: + # Host-based rule — uncomment and replace the rule above once DNS is configured: + # - host: "" + # http: + # paths: + # - path: "" + # pathType: Prefix + # backend: + # service: + # name: + # port: + # number: diff --git a/plugin/skills/deploy-to-aks/templates/k8s/namespace.yaml b/plugin/skills/deploy-to-aks/templates/k8s/namespace.yaml new file mode 100644 index 000000000..3996281db --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/k8s/namespace.yaml @@ -0,0 +1,15 @@ +# ============================================================================= +# Kubernetes Namespace Template — AKS Deploy Skill +# ============================================================================= +# Creates an isolated namespace for the application workload. Using a dedicated +# namespace (rather than "default") improves resource organization, access +# control, and makes cleanup easier (delete the namespace to remove everything). +# +# REPLACE: — target namespace (e.g., myapp, production) +# ============================================================================= +apiVersion: v1 +kind: Namespace +metadata: + name: + labels: + app.kubernetes.io/managed-by: deploy-to-aks-skill diff --git a/plugin/skills/deploy-to-aks/templates/k8s/networkpolicy.yaml b/plugin/skills/deploy-to-aks/templates/k8s/networkpolicy.yaml new file mode 100644 index 000000000..2e6a325c6 --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/k8s/networkpolicy.yaml @@ -0,0 +1,43 @@ +# ============================================================================= +# Kubernetes NetworkPolicy Template — AKS Deploy Skill +# ============================================================================= +# Restricts ingress to the application pod so only the ingress controller +# (or gateway) namespace can reach it. Denies all other inbound traffic. +# +# REPLACE: — your application name (e.g., order-api) +# REPLACE: — target namespace (e.g., production) +# REPLACE: — namespace of the ingress controller +# AKS Web App Routing: app-routing-system +# Istio Gateway: aks-istio-ingress +# ============================================================================= +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: -allow-ingress + namespace: + labels: + app: +spec: + podSelector: + matchLabels: + app: + policyTypes: + - Ingress + # Uncomment to also restrict egress (recommended for production): + # - Egress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: + # Uncomment and customize to restrict egress (e.g., allow only DNS + database): + # egress: + # - ports: + # - port: 53 + # protocol: UDP + # - port: 53 + # protocol: TCP + # - to: + # - namespaceSelector: + # matchLabels: + # kubernetes.io/metadata.name: diff --git a/plugin/skills/deploy-to-aks/templates/k8s/pdb.yaml b/plugin/skills/deploy-to-aks/templates/k8s/pdb.yaml new file mode 100644 index 000000000..dad4b37a5 --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/k8s/pdb.yaml @@ -0,0 +1,21 @@ +# ============================================================================= +# PodDisruptionBudget Template — AKS Deploy Skill +# ============================================================================= +# Ensures at least one pod remains available during voluntary disruptions +# (node drains, cluster upgrades, spot evictions). +# +# REPLACE: — your application name (e.g., order-api) +# REPLACE: — target namespace (e.g., production) +# ============================================================================= +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: + namespace: + labels: + app: +spec: + minAvailable: 1 + selector: + matchLabels: + app: diff --git a/plugin/skills/deploy-to-aks/templates/k8s/service.yaml b/plugin/skills/deploy-to-aks/templates/k8s/service.yaml new file mode 100644 index 000000000..642d8832e --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/k8s/service.yaml @@ -0,0 +1,26 @@ +# ============================================================================= +# Kubernetes Service Template — AKS Deploy Skill +# ============================================================================= +# ClusterIP Service that routes traffic to application pods. +# +# REPLACE: — your application name (e.g., order-api) +# REPLACE: — target namespace (e.g., production) +# REPLACE: — service port (e.g., 80) +# REPLACE: — container port (e.g., 8080) +# ============================================================================= +apiVersion: v1 +kind: Service +metadata: + name: + namespace: + labels: + app: +spec: + type: ClusterIP + selector: + app: + ports: + - name: http + port: + targetPort: + protocol: TCP diff --git a/plugin/skills/deploy-to-aks/templates/k8s/serviceaccount.yaml b/plugin/skills/deploy-to-aks/templates/k8s/serviceaccount.yaml new file mode 100644 index 000000000..e28321d86 --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/k8s/serviceaccount.yaml @@ -0,0 +1,31 @@ +# ============================================================================= +# ServiceAccount Template — AKS Deploy Skill +# ============================================================================= +# Kubernetes ServiceAccount with Azure Workload Identity annotation. +# The annotation links this SA to an Azure Managed Identity via OIDC federation. +# +# Prerequisites: +# 1. A User-Assigned Managed Identity exists in Azure +# 2. A Federated Identity Credential is configured with: +# - Issuer: +# - Subject: system:serviceaccount:: +# - Audience: api://AzureADTokenExchange +# +# REPLACE: — your application name (e.g., order-api) +# REPLACE: — target namespace (e.g., production) +# REPLACE: — client ID of the Managed Identity +# ============================================================================= +apiVersion: v1 +kind: ServiceAccount +metadata: + name: + namespace: + labels: + app: + annotations: + # Workload Identity: maps this ServiceAccount to an Azure Managed Identity. + # The Workload Identity webhook reads this annotation and injects + # AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_FEDERATED_TOKEN_FILE + # into any pod that references this ServiceAccount AND has the label + # azure.workload.identity/use: "true". + azure.workload.identity/client-id: "" diff --git a/plugin/skills/deploy-to-aks/templates/mermaid/architecture-diagram.md b/plugin/skills/deploy-to-aks/templates/mermaid/architecture-diagram.md new file mode 100644 index 000000000..28f6ea386 --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/mermaid/architecture-diagram.md @@ -0,0 +1,41 @@ +# Architecture Diagram Template + +Render this mermaid diagram in the terminal, replacing all `{{PLACEHOLDER}}` tokens with detected values from Section 1 (Detection) and the chosen backing services. + +## Diagram + +~~~mermaid +flowchart LR + Users([Users]) -->|HTTPS| GW + + subgraph AKS["AKS Cluster: {{AKS_CLUSTER_NAME}}"] + direction LR + GW[{{INGRESS_TYPE}}] --> SVC[Service\n{{APP_NAME}}:{{PORT}}] + SVC --> DEP[Deployment\n{{REPLICA_COUNT}} replicas] + end + + DEP -.->|Workload Identity| MI[Managed Identity\n{{IDENTITY_NAME}}] + ACR[ACR\n{{ACR_NAME}}.azurecr.io] -->|pull| AKS + CICD[GitHub Actions] -->|push| ACR + + %% Backing services — include only those in the architecture contract + %% Delete lines for services not selected + DEP -.->|Workload Identity| PG[(PostgreSQL\n{{PG_SERVER_NAME}})] + DEP -.->|Workload Identity| REDIS[(Redis\n{{REDIS_NAME}})] + DEP -.->|Workload Identity| KV[Key Vault\n{{KV_NAME}}] + + MON[Log Analytics\n{{LAW_NAME}}] -..- AKS + + style AKS fill:#e8f5e9,stroke:#107C10,stroke-width:2px + style ACR fill:#e3f2fd,stroke:#0078D4 + style PG fill:#fff3e0,stroke:#f57c00 + style REDIS fill:#fce4ec,stroke:#c62828 + style KV fill:#f3e5f5,stroke:#7b1fa2 + style MON fill:#f5f5f5,stroke:#757575 +~~~ + +## Rendering instructions + +Output this diagram as a fenced mermaid code block in the terminal. The developer will see it rendered if their terminal/tool supports mermaid, or as readable text if not. + +After the diagram, output a cost estimate table listing each Azure resource with its SKU/tier and approximate monthly cost. Use your knowledge of Azure pricing to provide estimates. diff --git a/plugin/skills/deploy-to-aks/templates/mermaid/summary-dashboard.md b/plugin/skills/deploy-to-aks/templates/mermaid/summary-dashboard.md new file mode 100644 index 000000000..9db77906e --- /dev/null +++ b/plugin/skills/deploy-to-aks/templates/mermaid/summary-dashboard.md @@ -0,0 +1,40 @@ +# Deployment Summary Template + +After successful deployment, render this summary in the terminal. + +## Template + +``` +╔══════════════════════════════════════════════════════╗ +║ DEPLOYMENT SUCCESSFUL ║ +║ {{APP_NAME}} is live at {{APP_URL}} ║ +║ Deployed: {{DEPLOY_TIMESTAMP}} ║ +╚══════════════════════════════════════════════════════╝ +``` + +### Azure Resources + +| Resource | Type | Name | Portal Link | +|----------|------|------|-------------| +| Resource Group | resourceGroups | {{RG_NAME}} | `https://portal.azure.com/...` | +| AKS Cluster | managedClusters | {{AKS_NAME}} | `https://portal.azure.com/...` | +| Container Registry | registries | {{ACR_NAME}} | `https://portal.azure.com/...` | +| {{BACKING_SERVICE}} | {{TYPE}} | {{NAME}} | `https://portal.azure.com/...` | + +Replace each portal link with the full URL using the subscription ID, resource group, and resource name. + +### Files Created / Modified + +List all files generated during the workflow with `+` for created and `~` for modified. + +### Monthly Cost Estimate + +List each Azure resource with its SKU/tier and approximate monthly cost. + +### Next Steps + +1. **Custom Domain** — Point DNS to external IP, update Gateway/Ingress +2. **TLS Certificate** — Enable HTTPS via cert-manager or Azure-managed TLS +3. **Monitoring Dashboard** — Set up Azure Monitor / Prometheus + Grafana +4. **Scaling** — Tune HPA min/max replicas and resource requests/limits +5. **CI/CD Trigger** — Push to default branch to trigger pipeline diff --git a/tests/deploy-to-aks/README.md b/tests/deploy-to-aks/README.md new file mode 100644 index 000000000..81488bcc4 --- /dev/null +++ b/tests/deploy-to-aks/README.md @@ -0,0 +1,56 @@ +# deploy-to-aks — Skill Tests + +Tests for the `deploy-to-aks` skill, which guides users through containerizing +and deploying applications to an existing Azure Kubernetes Service cluster. + +## Quick Start + +```bash +# Run all tests for this skill (integration auto-skips if SDK unavailable) +cd tests +npm test -- --testPathPatterns=deploy-to-aks + +# Unit and trigger tests only (always fast) +SKIP_INTEGRATION_TESTS=true npm test -- --testPathPatterns=deploy-to-aks + +# Update snapshots after intentional trigger keyword changes +npm run update:snapshots -- --testPathPatterns=deploy-to-aks +``` + +## File Structure + +``` +tests/deploy-to-aks/ +├── unit.test.ts # Skill metadata and content validation +├── triggers.test.ts # Skill activation on deployment-related prompts +├── integration.test.ts # Real agent session tests (requires Copilot CLI auth) +└── __snapshots__/ # Auto-generated by Jest + └── triggers.test.ts.snap +``` + +## Test Coverage + +### Unit Tests (`unit.test.ts`) +- Validates SKILL.md metadata (name, description, required fields) +- Checks description length and trigger phrase presence +- Validates frontmatter format (no tabs, supported keys, WHEN clause) + +### Trigger Tests (`triggers.test.ts`) +- **Should trigger:** AKS deploy, containerize for Kubernetes, generate K8s manifests, + set up CI/CD for AKS, migrate app to AKS, and more +- **Should NOT trigger:** Azure App Service, EKS/GKE, Docker-only workflows, + cluster provisioning (separate `azure-kubernetes` skill) +- Edge cases: mixed-case input, empty string + +### Integration Tests (`integration.test.ts`) +- Invocation rate test: measures how reliably the skill triggers on realistic prompts +- Response quality: checks for expected keywords (`kubectl`, deployment commands) +- Workspace context: verifies skill triggers when a Node.js `package.json` is present + +## Prerequisites for Integration Tests + +1. Install Copilot CLI: `npm install -g @github/copilot-cli` +2. Authenticate: Run `copilot` and follow prompts +3. Ensure `GITHUB_TOKEN` is set in the environment + +See `/tests/AGENTS.md` for complete testing patterns and guidelines. diff --git a/tests/deploy-to-aks/__snapshots__/triggers.test.ts.snap b/tests/deploy-to-aks/__snapshots__/triggers.test.ts.snap new file mode 100644 index 000000000..167cf5bc7 --- /dev/null +++ b/tests/deploy-to-aks/__snapshots__/triggers.test.ts.snap @@ -0,0 +1,103 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`deploy-to-aks - Trigger Tests Trigger Keywords Snapshot skill description triggers match snapshot 1`] = ` +{ + "description": "Use when deploying a web application or API to an existing Azure Kubernetes Service cluster. Detects framework, generates Dockerfile and Kubernetes manifests, validates against AKS Deployment Safeguards, and deploys with verification. WHEN: deploy to AKS, deploy app to Kubernetes, containerize for AKS, deploy to existing AKS cluster, generate K8s manifests for Azure, set up CI/CD for AKS, migrate app to AKS, deploy container to Azure, I have a Django/Express/Spring Boot app and want to run it on AKS, my AKS deployment is failing safeguard checks.", + "extractedKeywords": [ + "against", + "aks", + "application", + "authentication", + "azure", + "boot", + "checks", + "cli", + "cluster", + "container", + "containerize", + "deploy", + "deploying", + "deployment", + "deploys", + "detects", + "diagnostic", + "django", + "dockerfile", + "existing", + "express", + "failing", + "framework", + "generate", + "generates", + "have", + "identity", + "kubernetes", + "manifests", + "mcp", + "migrate", + "networking", + "rbac", + "safeguard", + "safeguards", + "security", + "service", + "spring", + "validates", + "validation", + "verification", + "want", + "when", + "with", + ], + "name": "deploy-to-aks", +} +`; + +exports[`deploy-to-aks - Trigger Tests Trigger Keywords Snapshot skill keywords match snapshot 1`] = ` +[ + "against", + "aks", + "application", + "authentication", + "azure", + "boot", + "checks", + "cli", + "cluster", + "container", + "containerize", + "deploy", + "deploying", + "deployment", + "deploys", + "detects", + "diagnostic", + "django", + "dockerfile", + "existing", + "express", + "failing", + "framework", + "generate", + "generates", + "have", + "identity", + "kubernetes", + "manifests", + "mcp", + "migrate", + "networking", + "rbac", + "safeguard", + "safeguards", + "security", + "service", + "spring", + "validates", + "validation", + "verification", + "want", + "when", + "with", +] +`; diff --git a/tests/deploy-to-aks/integration.test.ts b/tests/deploy-to-aks/integration.test.ts new file mode 100644 index 000000000..5ad8c1fb8 --- /dev/null +++ b/tests/deploy-to-aks/integration.test.ts @@ -0,0 +1,106 @@ +/** + * Integration Tests for deploy-to-aks + * + * Tests skill behavior with a real Copilot agent session. + * Requires Copilot CLI to be installed and authenticated. + * + * IMPORTANT: All test cases MUST be wrapped with `withTestResult` so that + * pass/fail results and skill invocation rates are automatically recorded + * to testResults.json after each test run. + * + * Prerequisites: + * 1. npm install -g @github/copilot-cli + * 2. Run `copilot` and authenticate + * + * Run with: npm run test:integration -- --testPathPatterns=deploy-to-aks + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { + useAgentRunner, + areToolCallsSuccess, + doesAssistantMessageIncludeKeyword, + shouldSkipIntegrationTests +} from "../utils/agent-runner"; +import { isSkillInvoked, softCheckSkill, shouldEarlyTerminateForSkillInvocation, withTestResult } from "../utils/evaluate"; + +const SKILL_NAME = "deploy-to-aks"; +const RUNS_PER_PROMPT = 5; +const invocationRateThreshold = 0.8; + +const describeIntegration = shouldSkipIntegrationTests() ? describe.skip : describe; + +describeIntegration(`${SKILL_NAME}_ - Integration Tests`, () => { + const agent = useAgentRunner(); + + describe("skill-invocation", () => { + test("invokes skill for relevant prompt", async () => { + await withTestResult(async ({ setSkillInvocationRate }) => { + let invocationCount = 0; + for (let i = 0; i < RUNS_PER_PROMPT; i++) { + const agentMetadata = await agent.run({ + prompt: "deploy my Node.js app to my existing AKS cluster", + shouldEarlyTerminate: (metadata) => shouldEarlyTerminateForSkillInvocation(metadata, SKILL_NAME) + }); + + softCheckSkill(agentMetadata, SKILL_NAME); + if (isSkillInvoked(agentMetadata, SKILL_NAME)) { + invocationCount++; + } + } + const rate = invocationCount / RUNS_PER_PROMPT; + setSkillInvocationRate(rate); + expect(rate).toBeGreaterThanOrEqual(invocationRateThreshold); + }); + }); + }); + + describe("response-quality", () => { + test("response contains expected keywords", async () => { + await withTestResult(async () => { + const agentMetadata = await agent.run({ + prompt: "deploy my Express.js API to AKS" + }); + + const hasExpectedContent = doesAssistantMessageIncludeKeyword( + agentMetadata, + "kubectl" + ); + expect(hasExpectedContent).toBe(true); + }); + }); + + test("MCP tool calls are successful", async () => { + await withTestResult(async () => { + const agentMetadata = await agent.run({ + prompt: "deploy my app to AKS and check the deployment status" + }); + + const toolsSucceeded = areToolCallsSuccess(agentMetadata, "azure-documentation"); + expect(toolsSucceeded).toBe(true); + }); + }); + + test("works with project files", async () => { + await withTestResult(async () => { + const agentMetadata = await agent.run({ + setup: async (workspace: string) => { + fs.writeFileSync( + path.join(workspace, "package.json"), + JSON.stringify({ + name: "my-api", + version: "1.0.0", + scripts: { start: "node dist/index.js", build: "tsc" }, + dependencies: { express: "^4.18.0" } + }, null, 2) + ); + }, + prompt: "containerize and deploy this Node.js app to my AKS cluster" + }); + + expect(isSkillInvoked(agentMetadata, SKILL_NAME)).toBe(true); + }); + }); + }); +}); diff --git a/tests/deploy-to-aks/triggers.test.ts b/tests/deploy-to-aks/triggers.test.ts new file mode 100644 index 000000000..241524698 --- /dev/null +++ b/tests/deploy-to-aks/triggers.test.ts @@ -0,0 +1,129 @@ +/** + * Trigger Tests for deploy-to-aks + * + * Tests that verify the skill triggers on appropriate prompts + * and does NOT trigger on unrelated prompts. + */ + +import { TriggerMatcher } from "../utils/trigger-matcher"; +import { loadSkill, LoadedSkill } from "../utils/skill-loader"; + +const SKILL_NAME = "deploy-to-aks"; + +describe(`${SKILL_NAME} - Trigger Tests`, () => { + let triggerMatcher: TriggerMatcher; + let skill: LoadedSkill; + + beforeAll(async () => { + skill = await loadSkill(SKILL_NAME); + triggerMatcher = new TriggerMatcher(skill); + }); + + describe("Should Trigger", () => { + // Common customer prompts for deploying apps to existing AKS clusters + const shouldTriggerPrompts: string[] = [ + // Core deployment scenarios + "deploy my app to AKS", + "deploy to Azure Kubernetes Service", + "deploy my Node.js application to an existing AKS cluster", + "I need to deploy my API to my Kubernetes cluster on Azure", + "help me deploy a container to AKS", + + // Containerization + "containerize my application for AKS deployment", + "generate a Dockerfile for deploying to Azure Kubernetes", + + // Manifest generation + "generate Kubernetes manifests for my Azure deployment", + "create deployment YAML for my AKS cluster", + + // CI/CD + "set up CI/CD pipeline for AKS deployment", + + // Migration + "migrate my app to Azure Kubernetes Service", + ]; + + test.each(shouldTriggerPrompts)( + 'triggers on: "%s"', + (prompt) => { + const result = triggerMatcher.shouldTrigger(prompt); + expect(result.triggered).toBe(true); + } + ); + }); + + describe("Should NOT Trigger", () => { + // Near-miss: Azure but not AKS deployment + const azureNonAksPrompts: string[] = [ + "set up Azure Functions for my API", + "configure Azure Front Door for my web app", + "create a storage account in Azure", + "monitor my App Service logs", + "configure Azure DevOps pipelines", + ]; + + // Near-miss: other cloud providers + const otherCloudPrompts: string[] = [ + "deploy my app to EKS on AWS", + "deploy to GKE using Cloud Build", + "configure Istio service mesh on minikube", + "create a Kubernetes operator in Go", + "set up EC2 instances", + ]; + + // Generic infrastructure (no Azure/AKS keywords) + const genericInfraPrompts: string[] = [ + "write a Dockerfile for local development", + "set up Docker Compose for my microservices", + "push an image to Docker Hub", + "debug a failing Docker build", + "configure SSL certificates", + ]; + + const shouldNotTriggerPrompts = [ + ...azureNonAksPrompts, + ...otherCloudPrompts, + ...genericInfraPrompts, + ]; + + test.each(shouldNotTriggerPrompts)( + 'does not trigger on: "%s"', + (prompt) => { + const result = triggerMatcher.shouldTrigger(prompt); + expect(result.triggered).toBe(false); + } + ); + }); + + describe("Trigger Keywords Snapshot", () => { + test("skill keywords match snapshot", () => { + expect(triggerMatcher.getKeywords()).toMatchSnapshot(); + }); + + test("skill description triggers match snapshot", () => { + expect({ + name: skill.metadata.name, + description: skill.metadata.description, + extractedKeywords: triggerMatcher.getKeywords() + }).toMatchSnapshot(); + }); + }); + + describe("Edge Cases", () => { + test("handles mixed case input", () => { + const result = triggerMatcher.shouldTrigger("DEPLOY MY APP TO AKS"); + expect(result.triggered).toBe(true); + }); + + test("handles multi-keyword match in conversational prompt", () => { + const result = triggerMatcher.shouldTrigger("kubernetes deploy on azure"); + expect(result.triggered).toBe(true); + }); + + test("handles empty prompt", () => { + const result = triggerMatcher.shouldTrigger(""); + expect(result.triggered).toBe(false); + }); + }); +}); diff --git a/tests/deploy-to-aks/unit.test.ts b/tests/deploy-to-aks/unit.test.ts new file mode 100644 index 000000000..7bd618600 --- /dev/null +++ b/tests/deploy-to-aks/unit.test.ts @@ -0,0 +1,153 @@ +/** + * Unit Tests for deploy-to-aks + * + * Tests isolated skill logic and validation rules. + */ + +import { readFileSync } from "node:fs"; +import { loadSkill, LoadedSkill } from "../utils/skill-loader"; + +const SKILL_NAME = "deploy-to-aks"; + +describe(`${SKILL_NAME} - Unit Tests`, () => { + let skill: LoadedSkill; + + beforeAll(async () => { + skill = await loadSkill(SKILL_NAME); + }); + + describe("Skill Metadata", () => { + test("has valid SKILL.md with required fields", () => { + expect(skill.metadata).toBeDefined(); + expect(skill.metadata.name).toBe(SKILL_NAME); + expect(skill.metadata.description).toBeDefined(); + expect(skill.metadata.description.length).toBeGreaterThan(10); + }); + + test("description is concise and actionable", () => { + expect(skill.metadata.description.length).toBeGreaterThan(150); + expect(skill.metadata.description.length).toBeLessThanOrEqual(1024); + }); + + test("description contains trigger phrases", () => { + const description = skill.metadata.description.toLowerCase(); + const hasTriggerPhrases = + description.includes("use this") || + description.includes("use when") || + description.includes("helps") || + description.includes("activate") || + description.includes("trigger"); + expect(hasTriggerPhrases).toBe(true); + }); + + test("description mentions key AKS concepts", () => { + const desc = skill.metadata.description.toLowerCase(); + expect(desc).toMatch(/aks|kubernetes/); + expect(desc).toMatch(/deploy|container|manifest/); + }); + }); + + describe("Skill Content", () => { + test("has substantive content", () => { + expect(skill.content).toBeDefined(); + expect(skill.content.length).toBeGreaterThan(100); + }); + + test("contains the standard skill sections", () => { + expect(skill.content).toContain("## Workflow"); + expect(skill.content).toContain("## Quick Reference"); + expect(skill.content).toContain("## Templates"); + expect(skill.content).toContain("## References"); + }); + }); + + describe("Deployment Workflow", () => { + test("references the quick-deploy phase file", () => { + expect(skill.content).toMatch(/quick-deploy\.md/i); + }); + + test("lists all 5 workflow phases", () => { + expect(skill.content).toMatch(/detection/i); + expect(skill.content).toMatch(/file generation/i); + expect(skill.content).toMatch(/safeguards validation/i); + expect(skill.content).toMatch(/deploy/i); + expect(skill.content).toMatch(/verify/i); + }); + }); + + describe("Deployment Safeguards", () => { + test("references the safeguards document", () => { + expect(skill.content).toMatch(/safeguards\.md/i); + }); + + test("covers DS001-DS013 safeguard range", () => { + expect(skill.content).toMatch(/DS001/i); + expect(skill.content).toMatch(/DS013/i); + }); + }); + + describe("Templates Coverage", () => { + test("covers Dockerfile templates", () => { + expect(skill.content).toMatch(/dockerfile/i); + }); + + test("covers K8s manifest templates", () => { + expect(skill.content).toMatch(/templates\/k8s/i); + }); + + test("covers GitHub Actions template", () => { + expect(skill.content).toMatch(/github-actions/i); + }); + }); + + describe("Security Guidance", () => { + test("references Workload Identity", () => { + expect(skill.content).toMatch(/workload.?identity/i); + }); + + test("references ACR for container images", () => { + expect(skill.content).toMatch(/acr|azure container registry/i); + }); + }); + + describe("Knowledge Packs", () => { + test("references framework knowledge packs", () => { + expect(skill.content).toMatch(/knowledge.?pack/i); + }); + + test("lists supported frameworks", () => { + const content = skill.content.toLowerCase(); + expect(content).toMatch(/express|django|spring.?boot|fastapi|nextjs/); + }); + }); + + describe("Frontmatter Formatting", () => { + let frontmatter: string; + + beforeAll(() => { + const raw = readFileSync(skill.filePath, "utf-8"); + frontmatter = raw.split("---")[1]; + }); + + test("frontmatter has no tabs", () => { + expect(frontmatter).not.toMatch(/\t/); + }); + + test("frontmatter keys are only supported attributes", () => { + const supported = ["name", "description", "compatibility", "license", "metadata", + "argument-hint", "disable-model-invocation", "user-invokable"]; + const keys = frontmatter.split("\n") + .filter((l) => /^[a-z][\w-]*\s*:/.test(l)) + .map((l) => l.split(":")[0].trim()); + for (const key of keys) { + expect(supported).toContain(key); + } + }); + + test("WHEN clause is inside description", () => { + // WHEN: must be embedded in the description string, not parsed as a YAML key + const description = skill.metadata.description; + expect(description).toContain("WHEN:"); + }); + }); +}); diff --git a/tests/skills.json b/tests/skills.json index 78054791d..5de9a3230 100644 --- a/tests/skills.json +++ b/tests/skills.json @@ -22,12 +22,13 @@ "azure-storage", "azure-upgrade", "azure-validate", + "deploy-to-aks", "entra-app-registration", "microsoft-foundry" ], "integrationTestSchedule": { "0 5 * * 2-6": "microsoft-foundry", "0 8 * * 2-6": "azure-deploy", - "0 12 * * 2-6": "appinsights-instrumentation,azure-ai,azure-aigateway,azure-cloud-migrate,azure-compliance,azure-compute,azure-cost,azure-diagnostics,azure-enterprise-infra-planner,azure-hosted-copilot-sdk,azure-kubernetes,azure-kusto,azure-messaging,azure-prepare,azure-quotas,azure-rbac,azure-resource-lookup,azure-resource-visualizer,azure-storage,azure-upgrade,azure-validate,entra-app-registration" + "0 12 * * 2-6": "appinsights-instrumentation,azure-ai,azure-aigateway,azure-cloud-migrate,azure-compliance,azure-compute,azure-cost,azure-diagnostics,azure-enterprise-infra-planner,azure-hosted-copilot-sdk,azure-kubernetes,azure-kusto,azure-messaging,azure-prepare,azure-quotas,azure-rbac,azure-resource-lookup,azure-resource-visualizer,azure-storage,azure-upgrade,azure-validate,deploy-to-aks,entra-app-registration" } } \ No newline at end of file