# Azaion Admin API — Containerization **Date**: 2026-05-13 · **Cycle**: 1 · **Status**: planning artifact (no code changes; Dockerfile updates land in Step 7). ## 1. Container Inventory The system has only **one runtime container**. The four library components are linked into the API at build time, not shipped separately. | # | Container | Built from | Purpose | Lifetime | |---|-----------|------------|---------|----------| | 1 | `admin-api` | `Dockerfile` (root) | Single ASP.NET Core 10 service exposing all 17 endpoints | Long-running | | 2 | `e2e-runner` | `e2e/Dockerfile` | Black-box test consumer used by CI and local `docker-compose.test.yml` | One-shot (run-and-exit) | | 3 | `test-db` | `postgres:16-alpine` (no custom Dockerfile) | Isolated Postgres for tests | One-shot (per CI run) | > `docker.test/Dockerfile` is a leftover placeholder (`FROM alpine:latest; CMD echo hello`) and is unused. **Drift G** — recommend deletion in Step 7 (scripts) cleanup. ## 2. Component → Container Mapping | Component | Ships in container? | Notes | |-----------|--------------------:|-------| | 01 Data Layer | no | Class library `Azaion.Common`, linked into `admin-api` | | 02 User Management | no | Class library `Azaion.Services` | | 03 Auth & Security | no | Class library `Azaion.Services` | | 04 Resource Management | no | Class library `Azaion.Services` | | 05 Admin API | **yes** | Hosts the Minimal API process (`Azaion.AdminApi`) | ## 3. `admin-api` — Dockerfile Specification | Property | Current value | Planned value (Step 7) | Rationale | |----------|---------------|------------------------|-----------| | Build base image | `mcr.microsoft.com/dotnet/sdk:10.0` (`--platform=$BUILDPLATFORM`) | unchanged | Matches restriction (.NET 10.0); cross-platform build supported | | Runtime base image | `mcr.microsoft.com/dotnet/aspnet:10.0` | **pin by digest** in production (`@sha256:…`) | Restrictions forbid moving off `aspnet:10.0`; digest pin protects against silent base-image churn | | Stages | `base` → `build` → `publish` → `final` | unchanged structure; non-root user added in `final` | Existing layout already follows multi-stage best practice | | Working dir | `/app` | unchanged | Matches `start-container.sh` mounts | | Exposed port | `8080` | unchanged | Bound by Kestrel via `ASPNETCORE_URLS=http://+:8080` | | Container user | **root** (current) | `USER app` (UID 1654, GID 1654) | Closes security audit F-6 / AZ-518 (Drift C). Non-existing UID; matches the convention in `mcr.microsoft.com/dotnet/aspnet:8.0+` images | | Mount points needing write | `/app/Content`, `/app/logs` | `chown app:app` both directories in the `final` stage | The new non-root user must own the dirs that are bind-mounted from the host | | Build arg | `CI_COMMIT_SHA=unknown` | unchanged; populated by Woodpecker | Already wired; surfaces as `AZAION_REVISION` env var inside the container | | OCI labels | none on the Dockerfile (CI adds three: `revision`, `created`, `source`) | move the three labels into the Dockerfile so local builds also carry them | Single source of truth; consistent labeling regardless of build origin | | Health check | none | `HEALTHCHECK CMD curl -fsS http://localhost:8080/health \|\| exit 1` | Wires into the `/health` endpoint planned in Step 5 (Observability). Until that endpoint exists, fall back to the TCP probe already used in `docker-compose.test.yml`. | | Entrypoint | `["dotnet", "Azaion.AdminApi.dll"]` | unchanged | Smallest-possible entrypoint; PID 1 is the .NET process | ### Sketch (planning artifact — actual edits land in Step 7) ``` FROM mcr.microsoft.com/dotnet/aspnet:10.0@sha256: AS base WORKDIR /app EXPOSE 8080 RUN groupadd -g 1654 app && useradd -u 1654 -g 1654 -m -d /home/app -s /sbin/nologin app \ && mkdir -p /app/Content /app/logs && chown -R app:app /app FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG TARGETARCH WORKDIR /app COPY . . RUN dotnet restore WORKDIR /app/Azaion.AdminApi RUN dotnet build "Azaion.AdminApi.csproj" -c Release -o /app/build FROM build AS publish RUN arch=$([ "$TARGETARCH" = "amd64" ] && echo "x64" || echo "$TARGETARCH") && \ dotnet publish "Azaion.AdminApi.csproj" -c Release -o /app/publish /p:UseAppHost=false --os linux --arch $arch FROM base AS final ARG CI_COMMIT_SHA=unknown ARG BUILD_DATE=unknown ENV AZAION_REVISION=$CI_COMMIT_SHA LABEL org.opencontainers.image.revision="$CI_COMMIT_SHA" LABEL org.opencontainers.image.created="$BUILD_DATE" LABEL org.opencontainers.image.source="https://git.azaion.com/azaion/admin" COPY --from=publish --chown=app:app /app/publish /app/ USER app HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ CMD curl -fsS http://localhost:8080/health || exit 1 ENTRYPOINT ["dotnet", "Azaion.AdminApi.dll"] ``` ## 4. `e2e-runner` — Dockerfile Specification Existing `e2e/Dockerfile` is sufficient for cycle 1; no changes proposed. | Property | Value | Notes | |----------|-------|-------| | Base image | `mcr.microsoft.com/dotnet/sdk:10.0` (build + run) | SDK is required because the runner invokes `dotnet test` | | Stages | `build` → run | Multi-stage to discard sources from the final image | | Working dir | `/test` | Matches `docker-compose.test.yml` | | Output dir | `/test-results` | Bind-mounted to `./e2e/test-results` on the host | | User | root (acceptable — short-lived, no network exposure, no persistence beyond `/test-results`) | Non-root not required for one-shot CI containers | | Loggers | `console`, `trx`, `xunit` | Last one feeds Woodpecker's parser | | Entrypoint | `dotnet test Azaion.E2E.dll …` | Already present | ## 5. Local Development — `docker-compose.yml` > Currently the project does **not** ship a local-dev compose file. Local devs run the API via `dotnet run` against a host Postgres on port 4312. We add `docker-compose.yml` in Step 7 (scripts) so newcomers get a one-command bring-up. ```yaml # docker-compose.yml — planning artifact for Step 7 services: api: build: context: . dockerfile: Dockerfile args: CI_COMMIT_SHA: dev image: azaion/admin:dev-local env_file: .env depends_on: db: condition: service_healthy ports: - "8080:8080" volumes: - ./.dev/content:/app/Content - ./.dev/logs:/app/logs healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] interval: 15s timeout: 5s retries: 5 start_period: 30s networks: [azaion-net] db: image: postgres:16-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: postgres volumes: - ./e2e/db-init/00_run_all.sh:/docker-entrypoint-initdb.d/00_run_all.sh:ro - ./env/db:/docker-entrypoint-initdb.d/sql:ro - dev-db:/var/lib/postgresql/data ports: - "4312:5432" # match local-dev convention; non-standard host port healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] interval: 5s timeout: 5s retries: 10 start_period: 10s networks: [azaion-net] volumes: dev-db: networks: azaion-net: driver: bridge ``` Notes: - The DB schema and roles are bootstrapped from the same SQL files that the test-compose uses (`env/db/*.sql`), so `docker-compose.yml` and `docker-compose.test.yml` produce DB images with identical structure. - `.dev/` is added to `.gitignore` and `.dockerignore` in Step 7. - `db.ports` exposes `4312:5432` so a developer running the API outside Docker can still hit the same connection string defined in `.env`. ## 6. Blackbox Test — `docker-compose.test.yml` (existing) The current file is already aligned with the Step 2 contract (`docker compose -f docker-compose.test.yml up --abort-on-container-exit --exit-code-from e2e-consumer`). Only one drift to log: | Drift | Description | Resolved In | |-------|-------------|-------------| | Drift H | `system-under-test.healthcheck` uses a raw bash TCP probe (`exec 3<>/dev/tcp/127.0.0.1/8080`). Once `/health` exists (Step 5), switch to the curl-based probe to actually test the application layer. | Step 5 + Step 7 | No structural change in cycle 1 — the file already brings up Postgres + SUT + e2e-runner on a private network and tears down on test exit. ## 7. Image Tagging Strategy | Context | Tag format | Example | Notes | |---------|------------|---------|-------| | CI build (per push) | `$REGISTRY_HOST/$REGISTRY_IMAGE:${CI_COMMIT_BRANCH}-${TAG_SUFFIX}` | `docker.azaion.com/azaion/admin:dev-arm` | Existing convention from `.woodpecker/02-build-push.yml` | | CI build (per push) — additional immutable tag | `$REGISTRY_HOST/$REGISTRY_IMAGE:${CI_COMMIT_SHA:0:12}-${TAG_SUFFIX}` | `docker.azaion.com/azaion/admin:a1b2c3d4e5f6-arm` | **NEW (Drift A resolution)** — gives every CI build an immutable tag the host scripts can pin | | Production deploy | the SHA tag from above; never `latest` | `docker.azaion.com/azaion/admin:a1b2c3d4e5f6-arm` | Eliminates the host-pulls-`:latest` / CI-never-pushes-`:latest` mismatch | | Local dev | `azaion/admin:dev-local` | — | Built by `docker-compose.yml`; never pushed | | Multi-arch (future) | `:-amd` and `:-arm` (already matrix-prepared) | — | The Woodpecker matrix is wired; uncomment the `amd64` row when an amd agent is online | > Drift A resolution depends on a CI change (Step 3) and a script change (Step 7). The tag format itself is decided here. ## 8. `.dockerignore` Existing `.dockerignore` is sufficient; no changes proposed in cycle 1. It already excludes `bin/`, `obj/`, `.env`, `.git`, IDE folders, `Dockerfile*`, and compose files. The only addition required by the new local-dev compose is `.dev/` — added in Step 7. ``` .dev **/.dockerignore **/.env **/.git **/.gitignore **/.project **/.settings **/.toolstarget **/.vs **/.vscode **/.idea **/*.*proj.user **/*.dbmdl **/*.jfm **/azds.yaml **/bin **/charts **/docker-compose* **/Dockerfile* **/node_modules **/npm-debug.log **/obj **/secrets.dev.yaml **/values.dev.yaml LICENSE README.md ``` ## 9. Self-verification - [x] Every component has a Dockerfile specification (only Admin API ships; libraries explicitly excluded with rationale). - [x] Multi-stage builds specified for every production image. - [x] Non-root user planned for `admin-api` (Drift C closed in spec; code change in Step 7). - [x] Health check defined for every long-running service (real `/health` planned in Step 5; TCP fallback documented for the interim). - [x] `docker-compose.yml` covers all components + Postgres dependency. - [x] `docker-compose.test.yml` already enables black-box testing; one observation logged (Drift H). - [x] `.dockerignore` defined and reviewed (one addition planned: `.dev/`). ## 10. Drifts Logged Here (carried forward) | ID | Severity | Description | Resolved In | |----|----------|-------------|-------------| | C | Medium | `Dockerfile` final stage runs as root → add `USER app` (UID 1654) | Step 7 | | G | Low | Unused `docker.test/Dockerfile` placeholder | Step 7 (delete) | | H | Low | `docker-compose.test.yml` health check is TCP-only; upgrade to `/health` once available | Step 5 + Step 7 |