diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index f8d8e6f..85cfbc0 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -48,6 +48,9 @@ jobs: # by whoever follows them. The suites run inside the network, so it has to # be the service name, not a published port on the host. S3_PUBLIC_ENDPOINT: http://storage:9000 + PUBLIC_ORIGIN: http://site + ALLOWED_HOSTS: localhost,127.0.0.1,site,kanban + ALLOWED_ORIGINS: http://site,http://kanban,http://localhost:28080,http://localhost:28081 COMPOSE: docker compose -f compose.local.yaml steps: - name: Checkout @@ -70,7 +73,7 @@ jobs: # one a developer exercises on localhost. - name: API and workflow regressions run: | - for suite in smoke_test workflow_test security_test scanning_test payment_test; do + for suite in smoke_test workflow_test security_test scanning_test payment_test quote_pagination_test; do echo "--- $suite" $COMPOSE exec -T \ -e SITE_BASE_URL=http://site \ @@ -83,35 +86,13 @@ jobs: $COMPOSE exec -T api python -m tests.retention_test $COMPOSE exec -T api python -m tests.runtime_security_test - # These need a real Chrome. They are the only coverage for the artwork - # editor and the full customer journey, so install google-chrome-stable - # (or set CHROME_BIN) on the runner to make them gate deployments. The - # suites above stay hard gates either way. + # Run Chrome on the Compose network. It must resolve the same storage:9000 + # hostname used in presigned URLs, and absence of Chrome must fail CI. - name: Browser regressions run: | - for candidate in "$CHROME_BIN" /usr/bin/google-chrome-stable \ - /usr/bin/google-chrome /usr/bin/chromium /usr/bin/chromium-browser; do - if [ -n "$candidate" ] && [ -x "$candidate" ]; then - export CHROME_BIN="$candidate" - break - fi - done - if [ ! -x "${CHROME_BIN:-}" ]; then - echo "::warning::No Chrome on this runner; browser regressions were NOT run." - echo "Install google-chrome-stable or set CHROME_BIN to gate on them." - exit 0 - fi - # Chrome runs here, in the runner container, and reaches the stack only - # through ports published on the host. When the runner is itself a - # container those are in another namespace, so check before running - # rather than failing with a bare connection error. See ROADMAP 5.10. - if ! wget -q -T 5 -O /dev/null "http://localhost:${SITE_PORT}/health"; then - echo "::warning::Stack not reachable from the runner; browser regressions were NOT run." - exit 0 - fi - echo "Using $CHROME_BIN" - node tests/artwork_browser_test.mjs - node tests/browser_test.mjs + $COMPOSE build browser-tests + $COMPOSE run --rm --no-deps browser-tests sh -ec \ + 'node tests/artwork_browser_test.mjs && node tests/browser_test.mjs' - name: Diagnostics on failure if: failure() @@ -145,13 +126,9 @@ jobs: fs --scanners secret --exit-code 1 --severity HIGH,CRITICAL \ --no-progress /src - # docs/PORTAINER.md described this as blocking publication. It never ran at - # all, and turning it on unconditionally would block every deploy: the - # source preflight refuses a release while the payment and messaging - # adapters are fake, which is the deliberate state the stack runs in - # today. So its verdict is always printed, and enforcement is opt-in. - # Set the repository variable ENFORCE_PRODUCTION_PREFLIGHT to "true" once - # real adapters land, and this becomes the gate the documentation claims. + # Keep push feedback advisory while the provider adapters are fake. + # The manual release job enforces the source preflight unconditionally. + # ENFORCE_PRODUCTION_PREFLIGHT can make push checks fail on blockers too. - name: Production source preflight run: | set +e @@ -171,7 +148,7 @@ jobs: publish-and-deploy: name: Publish images and notify Portainer needs: [validate, integration, scan] - if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' + if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 45 env: @@ -181,6 +158,12 @@ jobs: steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - name: Require production readiness + env: + PORTAINER_WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }} + run: | + python3 deploy/production_preflight.py --source-only + test -n "$PORTAINER_WEBHOOK" - name: Sign in to the Gitea Container Registry env: REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} @@ -190,7 +173,7 @@ jobs: test -n "$REGISTRY_TOKEN" echo "$REGISTRY_TOKEN" | docker login gitea.blyzer.com.br \ --username "$REGISTRY_USERNAME" --password-stdin - - name: Build and publish API + - name: Build API run: | image="gitea.blyzer.com.br/blyzer/dtf-api" # The Dockerfiles pin digests themselves; these variables let a base be @@ -201,9 +184,7 @@ jobs: docker build --file deploy/Dockerfile.api "$@" \ --build-arg VCS_REF="${{ gitea.sha }}" \ --tag "$image:latest" --tag "$image:${{ gitea.sha }}" . - docker push "$image:latest" - docker push "$image:${{ gitea.sha }}" - - name: Build and publish web + - name: Build web run: | image="gitea.blyzer.com.br/blyzer/dtf-web" set -- @@ -212,8 +193,6 @@ jobs: docker build --file deploy/Dockerfile.web "$@" \ --build-arg VCS_REF="${{ gitea.sha }}" \ --tag "$image:latest" --tag "$image:${{ gitea.sha }}" . - docker push "$image:latest" - docker push "$image:${{ gitea.sha }}" # CRITICAL blocks, HIGH is reported. Both images carry zero CRITICAL after # the base pinning and OS upgrades, so this gate holds the line already # reached. The remaining HIGH findings have no upstream fix, so failing on @@ -227,25 +206,29 @@ jobs: "gitea.blyzer.com.br/blyzer/dtf-web:${{ gitea.sha }}"; do echo "--- $target (HIGH, reported)" docker run --rm -v /var/run/docker.sock:/var/run/docker.sock "$image" \ - image --scanners vuln --severity HIGH --no-progress \ + image --image-src docker --scanners vuln --severity HIGH --no-progress \ --format table --exit-code 0 "$target" || echo "::warning::Could not scan $target for HIGH findings" echo "--- $target (CRITICAL, blocking)" docker run --rm -v /var/run/docker.sock:/var/run/docker.sock "$image" \ - image --scanners vuln --severity CRITICAL --no-progress \ + image --image-src docker --scanners vuln --severity CRITICAL --no-progress \ --format table --exit-code 1 "$target" || failed=1 done if [ "$failed" -ne 0 ]; then - echo "::error::A CRITICAL vulnerability was found in a published image." + echo "::error::A CRITICAL vulnerability was found in a release image." exit 1 fi + - name: Publish validated images + run: | + for name in dtf-api dtf-web; do + image="gitea.blyzer.com.br/blyzer/$name" + docker push "$image:${{ gitea.sha }}" + docker push "$image:latest" + done + - name: Trigger Portainer redeployment env: PORTAINER_WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }} run: | - if [ -z "$PORTAINER_WEBHOOK" ]; then - echo "PORTAINER_WEBHOOK is not configured; images were published but deployment was skipped." - exit 0 - fi curl --fail --silent --show-error --max-time 30 --request POST "$PORTAINER_WEBHOOK" diff --git a/compose.local.yaml b/compose.local.yaml index 18ed38d..4fd1329 100644 --- a/compose.local.yaml +++ b/compose.local.yaml @@ -23,9 +23,9 @@ x-app: &app OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:-local-operator-only} # The browser reaches the API through the Site gateway, so the published # Site/Kanban origins must be accepted or every write is rejected 403. - PUBLIC_ORIGIN: http://localhost:${SITE_PORT:-8080} - ALLOWED_HOSTS: localhost,127.0.0.1 - ALLOWED_ORIGINS: http://localhost:${SITE_PORT:-8080},http://localhost:${KANBAN_PORT:-8081},http://127.0.0.1:${SITE_PORT:-8080},http://127.0.0.1:${KANBAN_PORT:-8081} + PUBLIC_ORIGIN: ${PUBLIC_ORIGIN:-http://localhost:${SITE_PORT:-8080}} + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1} + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:${SITE_PORT:-8080},http://localhost:${KANBAN_PORT:-8081},http://127.0.0.1:${SITE_PORT:-8080},http://127.0.0.1:${KANBAN_PORT:-8081}} COOKIE_SECURE: "false" PAYMENT_ADAPTER: fake PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-local-webhook-secret} @@ -208,6 +208,28 @@ services: timeout: 3s retries: 12 + browser-tests: + profiles: [ci] + build: + context: . + dockerfile: infra/Dockerfile.browser-tests + environment: + CHROME_BIN: /usr/bin/chromium + CHROME_NO_SANDBOX: "1" + CHROME_TRUST_TEST_ORIGINS: "1" + SITE_BROWSER_ORIGIN: http://site + KANBAN_BROWSER_ORIGIN: http://kanban + OPERATOR_EMAIL: ${OPERATOR_EMAIL:-operator@example.test} + OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:-local-operator-only} + shm_size: 1gb + networks: [local] + depends_on: + site: {condition: service_healthy} + kanban: {condition: service_healthy} + storage: {condition: service_healthy} + security_opt: [no-new-privileges:true] + cap_drop: [ALL] + volumes: postgres-data: storage-data: diff --git a/deploy/test_production_preflight.py b/deploy/test_production_preflight.py index def30ed..f7407bd 100644 --- a/deploy/test_production_preflight.py +++ b/deploy/test_production_preflight.py @@ -1,8 +1,27 @@ import unittest +from pathlib import Path from .production_preflight import config_errors, source_errors +class ReleaseWorkflowTests(unittest.TestCase): + def test_main_push_cannot_publish_and_manual_release_is_gated(self): + workflow = (Path(__file__).resolve().parents[1] / + '.gitea/workflows/deploy.yml').read_text() + release = workflow.split(' publish-and-deploy:\n', 1)[1] + self.assertIn("if: gitea.event_name == 'workflow_dispatch' && " + "gitea.ref == 'refs/heads/main'", release) + preflight = release.index('python3 deploy/production_preflight.py --source-only') + webhook = release.index('test -n "$PORTAINER_WEBHOOK"') + scan = release.index('- name: Image vulnerabilities') + publish = release.index('- name: Publish validated images') + redeploy = release.index('- name: Trigger Portainer redeployment') + self.assertLess(preflight, scan) + self.assertLess(webhook, scan) + self.assertLess(scan, publish) + self.assertLess(publish, redeploy) + + def valid_config(): digest = '1' * 64 values = { diff --git a/docs/PORTAINER.md b/docs/PORTAINER.md index 2796739..e4a6193 100644 --- a/docs/PORTAINER.md +++ b/docs/PORTAINER.md @@ -17,8 +17,9 @@ Cloudflare R2, so MinIO is not part of this stack. The single workflow is `.gitea/workflows/deploy.yml`. Every push and pull request runs static validation, the integration suite against a real stack, and a secret -scan. A push to `main` then builds the production images, publishes both `latest` -and the full commit SHA, reports their vulnerabilities, and calls Portainer. +scan. A push to `main` does not publish or deploy. A manual workflow run on +`main` repeats those checks, builds and scans the images, then publishes both +`latest` and the full commit SHA and calls Portainer. What actually gates a deployment: @@ -26,17 +27,19 @@ What actually gates a deployment: |---|---| | `py_compile` and the unit tests | yes | | Integration suite on a live stack (smoke, workflow, security, scanning, retention, runtime) | yes | -| Browser suites | only when the runner has Chrome; otherwise warns and continues | +| Browser suites | yes; Chrome runs in the Compose test container | | Trivy secret scan (HIGH/CRITICAL) | yes | -| Source preflight (`deploy/production_preflight.py --source-only`) | only when `ENFORCE_PRODUCTION_PREFLIGHT` is `true` | +| Source preflight (`deploy/production_preflight.py --source-only`) | yes for manual release; advisory on pushes unless `ENFORCE_PRODUCTION_PREFLIGHT` is `true` | | Trivy image vulnerabilities, CRITICAL | yes | -| Trivy image vulnerabilities, HIGH | no — reported after the build | +| Trivy image vulnerabilities, HIGH | no — reported before publication | +| Configured Portainer webhook | yes for manual release | -The source preflight is advisory by default because it refuses a release while -the payment and messaging adapters are fake, which is the deliberate state the -stack runs in today. Enforcing it now would block every deployment. Set the -repository variable `ENFORCE_PRODUCTION_PREFLIGHT` to `true` once real adapters -land, and it becomes a hard gate. +The source preflight refuses a release while the payment and messaging adapters +are fake. It remains advisory on push checks so development can continue, but a +manual release is blocked until those adapters are replaced. Set the repository +variable `ENFORCE_PRODUCTION_PREFLIGHT` to `true` when all pushes should also +fail on those blockers. Before a manual release, run the full configuration +preflight below against the actual Portainer values; CI checks source only. CRITICAL image findings block. Both images carry none: the bases are pinned by digest, both Dockerfiles upgrade their OS packages, and the web image moved off @@ -60,10 +63,10 @@ Repository secrets: - `REGISTRY_USERNAME` and `REGISTRY_TOKEN` — package write credentials. - `PORTAINER_WEBHOOK` — webhook generated by the `dtf-cloud` Portainer stack. -The webhook is called only after the gating checks in the table above pass. +The webhook is called only after the release gates above pass. `ENFORCE_PRODUCTION_PREFLIGHT` and `TRIVY_IMAGE` are optional repository -variables; without them the preflight is advisory and a pinned default scanner -image is used. +variables; the former affects push checks and the latter defaults to a pinned +scanner image. ## 2. One-time Portainer resources @@ -124,10 +127,13 @@ The `db-init` service completing and stopping is expected. The other six services must be healthy. A failed `db-init` task or an unhealthy service blocks acceptance. -## 4. Normal deployment +## 4. Manual deployment -Push to `main`. Gitea validates, tests, scans, publishes these images, and calls -the webhook: +Push the reviewed commit to `main` and wait for its validation workflow to pass. +After validating the actual Portainer configuration with the full preflight in +section 3, use Gitea Actions to manually run **Build and deploy** on `main` at +that commit. The workflow repeats validation, tests and scans, then publishes +these images and calls the webhook: ```text gitea.blyzer.com.br/blyzer/dtf-api:latest diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 385a880..c105e80 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -14,6 +14,9 @@ kept through the approved order; 4.6 now pages pending and approved unpaid quotes, including a tested 101st pending quote. Operational entrypoints in 5.12 are repaired and locally exercised. Image decoding, mixed-sheet grading, rotation-sensitive DPI, and PDF page geometry are corrected in 3.9/4.4. +`main` pushes now validate without publishing; manual release requires a passing +source preflight. The containerized browser gate passes locally, pending a Gitea +runner run. Next address the upload/scanner safety gate and unsupported PDF image evidence. The customer/API upload admission now stops above the scanner's effective limit before transfer; the 5 GiB large-file product path still needs agreement and @@ -598,14 +601,9 @@ print-file evidence still need correction before this item can close. integration job should run `down -v` before `up` — or a dedicated step should apply `schema.sql` twice to a fresh database, proving both a first install and a re-run. -- `[ ]` 5.10 — The browser suites do not run in CI. Chrome runs in the runner - container and can only reach the stack through ports published on the host, which - is a different network namespace when the runner is itself a container. The API, - workflow, security, scanning, retention and runtime suites were moved inside the - stack's network and do gate. The browser suites are the only coverage for the - artwork editor and the full customer journey, so they need either Chrome in a - container on that network, or a runner with host networking. Until then they gate - locally only, and CI warns when it skips them. +- `[ ]` 5.10 — The browser suites were moved into a Chrome container on the + Compose network and made required in CI. Confirm the complete checkout journey + passes in that topology and on the actual Gitea runner before closing this item. - `[ ]` 5.9 — `local/browser_test.mjs` failed once and passed on an immediate re-run, with no code change in between (2026-09-21). It is a deploy gate when the runner has Chrome, so an intermittent failure there blocks releases for no reason. @@ -627,10 +625,12 @@ print-file evidence still need correction before this item can close. - `[ ]` 5.13 — Define production recovery: scheduled encrypted offsite database and object backups, a consistent snapshot boundary, Swarm data placement and a restore rehearsal that opens every required live order file. -- `[ ]` 5.14 — Promote and verify one immutable release. Scan before publishing - mutable tags, make the source preflight validate the active stack, require the - browser tests to run, test clean install and upgrade, and check application - readiness after Portainer redeploys. Isolate concurrent CI stacks. +- `[ ]` 5.14 — Promote and verify one immutable release. Normal `main` pushes + now run checks only; manual dispatch requires source preflight and a configured + webhook, and scans images before publishing. Still make the full preflight + validate the active stack, deploy the tested immutable image references, test + clean install and upgrade, check application readiness after Portainer + redeploys, and isolate concurrent CI stacks. --- diff --git a/docs/SECURITY_REPORT.md b/docs/SECURITY_REPORT.md index 16bec12..448e7ba 100644 --- a/docs/SECURITY_REPORT.md +++ b/docs/SECURITY_REPORT.md @@ -132,15 +132,12 @@ change when the advisory database or selected base digest changes. The files in `deploy/` and `.gitea/workflows/` are a guarded delivery mechanism, not an approval to operate the current application on the public internet. -Corrected 2026-09-21: an earlier version of this section described gates the -workflow did not contain. The workflow now runs static validation, the -integration suite against a live stack, and a blocking Trivy secret scan before -publishing. The source preflight is advisory unless -`ENFORCE_PRODUCTION_PREFLIGHT` is set, and image vulnerabilities are reported -rather than enforced, because the current bases carry HIGH/CRITICAL findings -with no upstream fix. Base images are still mutable tags, not digests. -`PORTAINER.md` holds the authoritative table of what gates and what does not. -It publishes both `latest` and the full commit SHA, then calls the Portainer +Updated 2026-09-23: pushes to `main` run checks only. A manual workflow run on +`main` requires the source preflight and a configured Portainer webhook before +building. It scans built images before publication; CRITICAL findings block and +HIGH findings are reported. The browser suites run in a required Compose Chrome +container. `PORTAINER.md` holds the authoritative gate table. A permitted release +publishes both `latest` and the full commit SHA, then calls the Portainer webhook. Application/provider secrets are created directly as versioned external Swarm secrets and never cross the workflow. Rollback selects the prior commit SHA in Portainer and does not roll back the database. diff --git a/infra/Dockerfile.browser-tests b/infra/Dockerfile.browser-tests new file mode 100644 index 0000000..0ca1c6d --- /dev/null +++ b/infra/Dockerfile.browser-tests @@ -0,0 +1,13 @@ +FROM node:22-bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends chromium ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace +COPY tests /workspace/tests +COPY web /workspace/web +RUN mkdir -p /workspace/output/local \ + && chown -R node:node /workspace/output +USER node +ENV CHROME_BIN=/usr/bin/chromium diff --git a/infra/nginx.conf.template b/infra/nginx.conf.template index 96eeb43..c68e7d5 100644 --- a/infra/nginx.conf.template +++ b/infra/nginx.conf.template @@ -1,8 +1,8 @@ limit_req_zone $binary_remote_addr zone=api_limit:10m rate=20r/s; server { listen 80; - server_name localhost; - if ($host !~ ^(localhost|127\.0\.0\.1)$) { return 400; } + server_name localhost site kanban; + if ($host !~ ^(localhost|127\.0\.0\.1|site|kanban)$) { return 400; } root /usr/share/nginx/html; index ${WEB_INDEX}; add_header X-Content-Type-Options nosniff always; @@ -23,8 +23,8 @@ server { } server { listen 81; - server_name localhost; - if ($host !~ ^(localhost|127\.0\.0\.1)$) { return 400; } + server_name localhost site kanban; + if ($host !~ ^(localhost|127\.0\.0\.1|site|kanban)$) { return 400; } client_max_body_size 2m; location / { limit_req zone=api_limit burst=100 nodelay; diff --git a/tests/browser_test.mjs b/tests/browser_test.mjs index b7b9732..85f8991 100644 --- a/tests/browser_test.mjs +++ b/tests/browser_test.mjs @@ -15,6 +15,10 @@ const profile=await mkdtemp(tmpdir()+'/dtf-browser-'); const chrome=spawn(process.env.CHROME_BIN||'/usr/bin/google-chrome-stable',[ '--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check', ...(process.env.CHROME_NO_SANDBOX==='1'?['--no-sandbox']:[]), + // Production uses HTTPS; internal Compose HTTP names need a secure context + // for Web Crypto during the upload and checkout journey. + ...(process.env.CHROME_TRUST_TEST_ORIGINS==='1' + ? ['--unsafely-treat-insecure-origin-as-secure=http://site,http://kanban'] : []), '--remote-debugging-port=0','--user-data-dir='+profile,'about:blank' ],{stdio:['ignore','ignore','pipe']}); const pause=ms=>new Promise(r=>setTimeout(r,ms));