Files
admin/_docs/04_deploy/containerization.md
T
Oleksandr Bezdieniezhnykh c7b297de83
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status
refactor: remove deploy.cmd and update Dockerfile for health checks
- Deleted the deploy.cmd script as it was no longer needed.
- Updated Dockerfile to include curl for health checks and added a non-root user for improved security.
- Modified health check command to use curl for better reliability.
- Adjusted docker-compose.test.yml to reflect changes in health check configuration.
- Cleaned up appsettings.json and removed unused configuration properties.
- Removed Resource entity and related requests from the codebase as part of the architectural shift.
- Updated documentation to reflect the removal of hardware binding and related endpoints.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 08:47:21 +03:00

229 lines
11 KiB
Markdown

# 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:<pinned> 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) | `<image>:<branch>-amd` and `<image>:<branch>-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 |