diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index aadd78e..a9018ce 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -35,7 +35,6 @@ jobs: # taken on the machine itself. Known occupants of that host: # 8000, 9443 Portainer (the Edge tunnel and its UI) # 18080/18081 the production dtf-cloud stack (docker-compose.yml defaults) - # 8080/8081 deploy/stack.yaml defaults # 9000/9001 MinIO defaults elsewhere # This block avoids all of them. Ephemeral ports are not an option: the # published port is baked into PUBLIC_ORIGIN, ALLOWED_ORIGINS and the CSP diff --git a/ROADMAP.md b/ROADMAP.md index b37872e..0f86bf2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -222,38 +222,30 @@ refactor: `'This runtime only supports APP_ENV=local'`, - Replace marker matching with behavioural assertions (import the module, assert the adapter classes in use). -### `[ ]` 2.12 — Production credentials are environment variables, and the docs name the wrong file +### `[x]` 2.12 — Two divergent stack definitions; the docs named the wrong one -Found 2026-09-21, by asking which file Portainer deploys. +Found 2026-09-21 by asking which file Portainer deploys. `PORTAINER.md` called +`deploy/stack.yaml` "the production stack"; the deployed file is the repository's +`docker-compose.yml`. `deploy/stack.yaml` came from the first commit and was never +deployed — it supplied credentials as Docker secrets where the deployed file uses +plain environment variables. -`PORTAINER.md` called `deploy/stack.yaml` "the production stack". The deployed -file is the repository's `docker-compose.yml`. They are not equivalent: +**Decision (2026-09-21): keep `docker-compose.yml`, delete `deploy/stack.yaml`.** -| File | Credentials | Deployed | -|---|---|---| -| `deploy/stack.yaml` | 11 Docker secret files | no | -| `docker-compose.yml` | 8 plain environment variables | yes | +The gain from Docker secrets here is narrower than it sounds. It keeps values out +of `docker inspect` and the Portainer UI, but `local/secrets.py` loads them into +the process environment anyway, and anyone who can read `docker inspect` is +already root or in the docker group and could read the secret files directly. The +operator is the only Portainer user, so the main benefit — limiting what a +lower-privileged console user can see — does not apply. Maintaining two +definitions that drift was the larger real cost. -So `DATABASE_PASSWORD`, `AWS_SECRET_ACCESS_KEY` and `OPERATOR_PASSWORD` sit in the -container environment, readable through `docker inspect`, `docker service inspect`, -the Portainer stack editor, and `/proc//environ` for anything in that -container. The R2 secret key is the worst of them: it grants read and write over -every customer's artwork. +`local/secrets.py` stays. It is inert against the deployed file and costs nothing, +and it means a stack can switch to Docker secrets later without a code change. -The `*_FILE` loading from 2.3 makes the hardened file bootable, so the work is -done — what remains is a decision, because changing how production receives its -credentials is not a change to make quietly: - -- **Migrate to `deploy/stack.yaml`:** create the 11 secrets in Portainer, point the - stack at that file. Best posture, most operational steps, and it also switches the - published ports (8080/8081 vs 18080/18081) so the reverse proxy needs updating. -- **Add Docker secrets to `docker-compose.yml`:** smaller change, keeps ports and - the current stack definition, still removes the values from the environment. -- **Accept it explicitly** and delete `deploy/stack.yaml` so two divergent - definitions stop drifting. - -Rotate the R2 key and operator password whichever is chosen, since the current -values have been readable from the stack environment. +Still open: **rotate the R2 secret key.** Not because of Portainer, but because it +grants read and write over every customer's artwork and has been readable from the +stack environment for some time. The operator password is worth rotating with it. ### `[x]` 2.6 — Base images are not pinned `(F10)` diff --git a/deploy/README.md b/deploy/README.md index d7c9f59..6605917 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -4,7 +4,7 @@ The DTF application is one Portainer-owned Docker Swarm stack. Gitea builds, tests, scans, and publishes the two application images, then calls the stack's Portainer webhook. Start with the short operator guide in `../PORTAINER.md`. -- `stack.yaml` — the single Portainer stack. +- The deployed stack is the repository's `docker-compose.yml`, not a file here. - `Dockerfile.api` and `Dockerfile.web` — prebuilt registry images. - `portainer.env.example` — non-secret Portainer variables. - `production_preflight.py` — fail-closed application/configuration validator. diff --git a/deploy/stack.yaml b/deploy/stack.yaml deleted file mode 100644 index 6c07ddf..0000000 --- a/deploy/stack.yaml +++ /dev/null @@ -1,310 +0,0 @@ -version: "3.8" - -x-app-environment: &app-environment - APP_ENV: production - DATABASE_URL_FILE: /run/secrets/database_url - S3_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT} - S3_PUBLIC_ENDPOINT: ${R2_PUBLIC_ENDPOINT:?set R2_PUBLIC_ENDPOINT} - S3_BUCKET: ${R2_BUCKET:?set R2_BUCKET} - AWS_ACCESS_KEY_ID_FILE: /run/secrets/r2_access_key_id - AWS_SECRET_ACCESS_KEY_FILE: /run/secrets/r2_secret_access_key - AWS_DEFAULT_REGION: auto - # The Kanban authenticates by email; the runtime reads OPERATOR_EMAIL. - OPERATOR_EMAIL: ${OPERATOR_EMAIL:?set OPERATOR_EMAIL} - OPERATOR_PASSWORD_FILE: /run/secrets/operator_password - PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:?set PAYMENT_ADAPTER} - FREIGHT_ADAPTER: ${FREIGHT_ADAPTER:?set FREIGHT_ADAPTER} - TINY_ADAPTER: ${TINY_ADAPTER:?set TINY_ADAPTER} - WHATSAPP_ADAPTER: ${WHATSAPP_ADAPTER:?set WHATSAPP_ADAPTER} - STORAGE_ADAPTER: s3-r2 - PAYMENT_TOKEN_FILE: /run/secrets/payment_token - PAYMENT_WEBHOOK_SECRET_FILE: /run/secrets/payment_webhook_secret - TINY_TOKEN_FILE: /run/secrets/tiny_token - WHATSAPP_TOKEN_FILE: /run/secrets/whatsapp_token - PUBLIC_ORIGIN: ${PUBLIC_ORIGIN:?set PUBLIC_ORIGIN} - PUBLIC_HOST: ${PUBLIC_HOST:?set PUBLIC_HOST} - ALLOWED_HOSTS: ${PUBLIC_HOST:?set PUBLIC_HOST},${KANBAN_HOST:?set KANBAN_HOST} - ALLOWED_ORIGINS: ${PUBLIC_ORIGIN:?set PUBLIC_ORIGIN},https://${KANBAN_HOST:?set KANBAN_HOST} - COOKIE_SECURE: "true" - MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-5368709120} - UPLOAD_PART_BYTES: ${UPLOAD_PART_BYTES:-8388608} - STORAGE_QUOTA_BYTES: ${STORAGE_QUOTA_BYTES:?set STORAGE_QUOTA_BYTES} - OWNER_UPLOAD_QUOTA_BYTES: ${OWNER_UPLOAD_QUOTA_BYTES:?set OWNER_UPLOAD_QUOTA_BYTES} - MAX_PENDING_UPLOADS: ${MAX_PENDING_UPLOADS:-10} - SCAN_MAX_BYTES: ${SCAN_MAX_BYTES:-134217728} - -x-app-secrets: &app-secrets - - database_url - - r2_access_key_id - - r2_secret_access_key - - operator_password - - payment_token - - payment_webhook_secret - - tiny_token - - whatsapp_token - -x-rolling: &rolling - update_config: - parallelism: 1 - delay: 10s - order: start-first - failure_action: rollback - monitor: 45s - rollback_config: - parallelism: 1 - delay: 5s - order: start-first - failure_action: pause - monitor: 45s - restart_policy: - condition: on-failure - delay: 5s - max_attempts: 5 - window: 60s - -services: - db: - image: ${POSTGRES_IMAGE:?set POSTGRES_IMAGE} - environment: - POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB} - POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER} - POSTGRES_PASSWORD_FILE: /run/secrets/db_admin_password - secrets: [db_admin_password] - volumes: - - postgres-data:/var/lib/postgresql/data - networks: [backend] - healthcheck: - test: [CMD-SHELL, 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"'] - interval: 10s - timeout: 5s - retries: 12 - start_period: 20s - stop_grace_period: 60s - deploy: - replicas: 1 - placement: - constraints: [node.labels.dtf_database == true] - update_config: - parallelism: 1 - order: stop-first - failure_action: rollback - monitor: 60s - rollback_config: - parallelism: 1 - order: stop-first - failure_action: pause - monitor: 60s - restart_policy: - condition: on-failure - delay: 10s - max_attempts: 5 - window: 120s - resources: - limits: {cpus: "2.0", memory: 4G} - reservations: {cpus: "0.5", memory: 1G} - - db-init: - image: ${API_IMAGE:?set API_IMAGE}:${IMAGE_TAG:-latest} - command: python -m local.bootstrap - environment: - APP_ENV: production - DATABASE_ADMIN_URL_FILE: /run/secrets/database_admin_url - APP_DB_USER: ${APP_DB_USER:?set APP_DB_USER} - APP_DB_PASSWORD_FILE: /run/secrets/app_db_password - secrets: [database_admin_url, app_db_password] - networks: [backend] - deploy: - replicas: 1 - restart_policy: {condition: none} - placement: - constraints: [node.platform.os == linux] - resources: - limits: {cpus: "0.5", memory: 512M} - - scanner: - image: ${CLAMAV_IMAGE:?set CLAMAV_IMAGE} - user: "100:101" - entrypoint: [clamd, --foreground=true, --config-file=/etc/clamav/clamd.conf] - configs: - - source: clamd_config - target: /etc/clamav/clamd.conf - mode: 0444 - networks: [backend] - read_only: true - cap_drop: [ALL] - security_opt: [no-new-privileges:true] - tmpfs: - - /tmp:uid=100,gid=101,mode=0750 - - /run/clamav:uid=100,gid=101,mode=0750 - - /var/log/clamav:uid=100,gid=101,mode=0750 - healthcheck: - test: [CMD, clamdscan, --config-file=/etc/clamav/clamd.conf, --ping, "3"] - interval: 15s - timeout: 5s - retries: 20 - start_period: 90s - deploy: - replicas: 1 - restart_policy: {condition: on-failure, delay: 10s} - resources: - limits: {cpus: "2.0", memory: 3G} - reservations: {cpus: "0.5", memory: 1G} - - api: - image: ${API_IMAGE:?set API_IMAGE}:${IMAGE_TAG:-latest} - environment: *app-environment - secrets: *app-secrets - networks: [backend, egress] - read_only: true - tmpfs: [/tmp] - init: true - cap_drop: [ALL] - security_opt: [no-new-privileges:true] - healthcheck: - test: - - CMD-SHELL - - >- - python -c "import os,urllib.request; r=urllib.request.Request('http://localhost:8000/health',headers={'Host':os.environ['PUBLIC_HOST']}); urllib.request.urlopen(r,timeout=3)" - interval: 10s - timeout: 5s - retries: 12 - start_period: 30s - stop_grace_period: 30s - deploy: - <<: *rolling - replicas: 2 - resources: - limits: {cpus: "1.0", memory: 1G} - reservations: {cpus: "0.25", memory: 256M} - - worker: - image: ${API_IMAGE:?set API_IMAGE}:${IMAGE_TAG:-latest} - command: python -m local.worker - environment: - <<: *app-environment - CLAMD_HOST: scanner - secrets: *app-secrets - networks: [backend, egress] - read_only: true - tmpfs: [/tmp] - init: true - cap_drop: [ALL] - security_opt: [no-new-privileges:true] - healthcheck: - test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://localhost:8002/health',timeout=3)"] - interval: 15s - timeout: 5s - retries: 12 - start_period: 90s - stop_grace_period: 60s - deploy: - <<: *rolling - replicas: 1 - update_config: - parallelism: 1 - order: stop-first - failure_action: rollback - monitor: 60s - rollback_config: - parallelism: 1 - order: stop-first - failure_action: pause - monitor: 60s - resources: - limits: {cpus: "1.5", memory: 2G} - reservations: {cpus: "0.25", memory: 512M} - - site: - image: ${WEB_IMAGE:?set WEB_IMAGE}:${IMAGE_TAG:-latest} - environment: - WEB_INDEX: index.html - PUBLIC_HOST: ${PUBLIC_HOST:?set PUBLIC_HOST} - S3_PUBLIC_ENDPOINT: ${R2_PUBLIC_ENDPOINT:?set R2_PUBLIC_ENDPOINT} - networks: [backend] - ports: - - target: 8080 - published: ${SITE_PORT:-8080} - protocol: tcp - mode: ingress - read_only: true - tmpfs: - - /tmp:uid=101,gid=101,mode=0750 - - /var/cache/nginx:uid=101,gid=101,mode=0750 - - /var/run:uid=101,gid=101,mode=0750 - - /etc/nginx/conf.d:uid=101,gid=101,mode=0750 - cap_drop: [ALL] - security_opt: [no-new-privileges:true] - healthcheck: - test: [CMD-SHELL, 'wget -q --header="Host: $$PUBLIC_HOST" -O /dev/null http://127.0.0.1:8080/health'] - interval: 10s - timeout: 5s - retries: 12 - start_period: 15s - deploy: - <<: *rolling - replicas: 2 - resources: - limits: {cpus: "0.5", memory: 256M} - reservations: {cpus: "0.1", memory: 64M} - - kanban: - image: ${WEB_IMAGE:?set WEB_IMAGE}:${IMAGE_TAG:-latest} - environment: - WEB_INDEX: kanban.html - PUBLIC_HOST: ${KANBAN_HOST:?set KANBAN_HOST} - S3_PUBLIC_ENDPOINT: ${R2_PUBLIC_ENDPOINT:?set R2_PUBLIC_ENDPOINT} - networks: [backend] - ports: - - target: 8080 - published: ${KANBAN_PORT:-8081} - protocol: tcp - mode: ingress - read_only: true - tmpfs: - - /tmp:uid=101,gid=101,mode=0750 - - /var/cache/nginx:uid=101,gid=101,mode=0750 - - /var/run:uid=101,gid=101,mode=0750 - - /etc/nginx/conf.d:uid=101,gid=101,mode=0750 - cap_drop: [ALL] - security_opt: [no-new-privileges:true] - healthcheck: - test: [CMD-SHELL, 'wget -q --header="Host: $$PUBLIC_HOST" -O /dev/null http://127.0.0.1:8080/health'] - interval: 10s - timeout: 5s - retries: 12 - start_period: 15s - deploy: - <<: *rolling - replicas: 1 - resources: - limits: {cpus: "0.5", memory: 256M} - reservations: {cpus: "0.1", memory: 64M} - -configs: - clamd_config: - file: ../local/clamd.conf - -secrets: - database_url: {external: true, name: "${DATABASE_URL_SECRET:?set DATABASE_URL_SECRET}"} - database_admin_url: {external: true, name: "${DATABASE_ADMIN_URL_SECRET:?set DATABASE_ADMIN_URL_SECRET}"} - db_admin_password: {external: true, name: "${DB_ADMIN_PASSWORD_SECRET:?set DB_ADMIN_PASSWORD_SECRET}"} - app_db_password: {external: true, name: "${APP_DB_PASSWORD_SECRET:?set APP_DB_PASSWORD_SECRET}"} - r2_access_key_id: {external: true, name: "${R2_ACCESS_KEY_ID_SECRET:?set R2_ACCESS_KEY_ID_SECRET}"} - r2_secret_access_key: {external: true, name: "${R2_SECRET_ACCESS_KEY_SECRET:?set R2_SECRET_ACCESS_KEY_SECRET}"} - operator_password: {external: true, name: "${OPERATOR_PASSWORD_SECRET:?set OPERATOR_PASSWORD_SECRET}"} - payment_token: {external: true, name: "${PAYMENT_TOKEN_SECRET:?set PAYMENT_TOKEN_SECRET}"} - payment_webhook_secret: {external: true, name: "${PAYMENT_WEBHOOK_SECRET:?set PAYMENT_WEBHOOK_SECRET}"} - tiny_token: {external: true, name: "${TINY_TOKEN_SECRET:?set TINY_TOKEN_SECRET}"} - whatsapp_token: {external: true, name: "${WHATSAPP_TOKEN_SECRET:?set WHATSAPP_TOKEN_SECRET}"} - -volumes: - postgres-data: - external: true - name: ${POSTGRES_VOLUME:?set POSTGRES_VOLUME} - -networks: - backend: - driver: overlay - internal: true - egress: - driver: overlay diff --git a/local/secrets.py b/local/secrets.py index 8cb5031..ffdcb91 100644 --- a/local/secrets.py +++ b/local/secrets.py @@ -1,9 +1,9 @@ """Resolve Docker secret files into the environment before configuration is read. Swarm mounts each secret as a file and the stack passes its path as `_FILE`. -Nothing read `_FILE` settings, so `deploy/stack.yaml` could not boot: the runtime -looked for `DATABASE_URL`, `AWS_ACCESS_KEY_ID` and `OPERATOR_PASSWORD` while the -stack supplied only the `_FILE` form. +The deployed `docker-compose.yml` passes credentials as plain environment +variables, so this module is inert there. It exists so a stack can supply them as +Docker secrets instead without any code change; see `ROADMAP.md` 2.12. Call `load()` in every entrypoint before any configuration is read. @@ -12,9 +12,9 @@ secrets` elsewhere in the package to the standard library, not to this file. """ import os -# The settings production supplies as secret files. Any other `*_FILE` variable is +# The settings a stack may supply as secret files. Any other `*_FILE` variable is # resolved the same way; this list documents the contract and is what the release -# gate checks against, so keep it in step with `deploy/stack.yaml`. +# gate checks against. SECRET_FILE_SETTINGS = ( 'DATABASE_URL', 'DATABASE_ADMIN_URL',