diff --git a/AGENTS.md b/AGENTS.md index 595edff..c0e8075 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,10 +10,11 @@ This app manages Git-backed **source/configuration** for Compose stacks. It is * ## Current phase -**PR #5 — Deployment routing** is the next active product phase (DeploymentProvider, Portainer/generic webhooks, -forge webhook receiver, relevance rules, selective deploy). PR #2 (application foundation), PR #3 (source workspace) -and PR #4 (Git workflow: commit, safe push, conflict protection, stack history, Changes UI; released in v0.5.0) are -done. Do not build secrets providers, runtime watch or PWA work ahead of their phases. +**PR #5 — Deployment routing** (DeploymentProvider, Portainer/generic webhooks, forge webhook receiver, relevance +rules, selective deploy) is implemented on `main` for v0.6.0 and is in its release gate (#57; release PR #41). +PR #2 (application foundation), PR #3 (source workspace) and PR #4 (Git workflow: commit, safe push, conflict +protection, stack history, Changes UI; released in v0.5.0) are done. The maintainer picks the next phase from the +roadmap. Do not build secrets providers, runtime watch or PWA work ahead of their phases. See [docs/public/plans/MVP_PLAN.md](docs/public/plans/MVP_PLAN.md) and the code layout in [docs/public/ARCHITECTURE.md](docs/public/ARCHITECTURE.md#code-layout-implemented-in-pr-2-extended-through-v050). diff --git a/docs/internal/INDEX.md b/docs/internal/INDEX.md index 297f229..759d7c5 100644 --- a/docs/internal/INDEX.md +++ b/docs/internal/INDEX.md @@ -56,7 +56,9 @@ are listed in [manifest.json](../manifest.json) (see [docs-sync](../../.github/d | [AUTH_AND_CREDENTIALS.md](../public/AUTH_AND_CREDENTIALS.md) | Auth, encryption at rest | | [SECURITY.md](../public/SECURITY.md) | Threat model and controls | | [UX.md](UX.md) | Desktop / mobile / PWA | -| [BACKUP_RESTORE.md](../public/BACKUP_RESTORE.md) | Backup and restore | +| [BACKUP_RESTORE.md](../public/BACKUP_RESTORE.md) | Backup, restore and rollback | +| [UPGRADING_0_6.md](../public/UPGRADING_0_6.md) | v0.6.0 upgrade notes and operator acceptance checklist | +| [releases/v0.6.0-readiness.md](releases/v0.6.0-readiness.md) | v0.6.0 release audit: verification matrix, findings, risks, Go/No-Go | | [TESTING.md](../public/TESTING.md) | Testing strategy | ## Architecture decision records diff --git a/docs/internal/releases/v0.6.0-readiness.md b/docs/internal/releases/v0.6.0-readiness.md new file mode 100644 index 0000000..27a40c3 --- /dev/null +++ b/docs/internal/releases/v0.6.0-readiness.md @@ -0,0 +1,103 @@ +# v0.6.0 release readiness (#57) + +Independent release audit of Stack Manager v0.6.0, performed 2026-10-22 for +[#57](https://github.com/uniskela/stack-manager/issues/57) (parent +[#51](https://github.com/uniskela/stack-manager/issues/51)). Nothing was merged, tagged, released, published +or deployed. No real deployment webhook or production data was used. + +## Release summary + +| Item | Value | +| --- | --- | +| Audited `main` | `8333dd88d87834890dfcd58e7745e4c8d9994f20` (`feat(deployment): add selective Git change routing and idempotency (#66)`) | +| Target version | 0.6.0 (Release Please PR [#41](https://github.com/uniskela/stack-manager/pull/41), head `d7ced04`) | +| Previous release | v0.5.0 (migrations `0000`–`0003`) | +| New migrations | `0004_sync-follow-up`, `0005_sync-timestamps`, `0006_repository_auto_fetch_policy`, `0007_git_webhook_deliveries`, `0008_git_webhook_body_replay`, `0009_flashy_turbo` (deployment bindings/attempts), `0010_large_salo` (routing). All additive. | +| Features | Scheduled and post-push fetch (#59, #62), security hardening (#61), dashboard (#63), signed GitHub/Gitea/Forgejo push webhooks (#64), per-stack Portainer/generic deployment webhooks and manual Deploy now (#65), selective routing and idempotency (#66). | +| Hardening PR | Branch `uniskela/adhd-v0.6.0-release-verification-and-documentati` ([PR #67](https://github.com/uniskela/stack-manager/pull/67)). | +| Open issues | #51 parent, #57 this gate. #52 and #56 verified on `main` (see below). | + +## Verification matrix + +"Local" results ran on the audited SHA (or that SHA plus this PR's test/doc changes) on the development VPS. + +| Category | Result | Evidence | +| --- | --- | --- | +| Git synchronization | **PASS** | `pnpm check` 656/656 on `main`; scheduler unit tests incl. new worker/server-disabled scan test; per-repository settings Playwright (desktop/mobile) and the server-disabled scenario run separately with `STACK_MANAGER_AUTO_FETCH_ENABLED=false` (passed). Fetch only updates `refs/remotes/origin/*`; drafts (DB) and unpushed commits (`refs/stack-manager/heads/*`) survive failure and upgrade. New audit test: one repository's failing fetch does not block another's routing. | +| Inbound webhooks | **PASS** (automated) | Code review of `providers/git/webhooks.ts`, `git-webhook-service.ts`, `http/route.ts`: raw-byte HMAC-SHA256 with constant-time compare before JSON parse, native headers only, SHA-1/empty/alternate formats rejected, repository/branch identity checked against the stored remote, delivery-ID and body-fingerprint ledger committed in the same transaction as the fetch job, credential/branch/remote fenced inside that transaction. Route-coverage test allowlists the single `signed-webhook` route; all other mutations keep same-origin checks. Existing 3-provider unit and integration suites pass. New test: cross-workspace repository id is rejected. Live forge delivery: **not verified** (operator). | +| Deployment bindings | **PASS** (automated) | Encrypted URL/token, fixed mask, workspace-scoped credential checks, revision CAS, managed-credential triggers, per-stack active-attempt unique index, intent before send, `begin`/`finish` lease fences, interrupted sends → unknown. Outbound: HTTPS only, no embedded credentials, DNS answers all checked, socket pinned to the validated address with original hostname for TLS, no redirects, 5 s connect / 15 s total deadlines, response bodies discarded. Local TLS receiver tests (invalid cert, hostname mismatch, rebinding resolver, redirect, timeout) pass. New tests: credential rotation and disabling reject queued automatic work without sending. | +| Selective routing | **PASS** (automated) | Scenario table independently re-run through real Git/SQLite/worker in `tests/integration/release-audit-routing.test.ts` plus existing suite: stack-only compose change, shared dependency to two stacks, README-only skip, unrelated stack skip, unsafe/rewritten history blocked, duplicate delivery no resend, automation off no deploy, unknown outcome holds until manual Deploy now. Unicode, spaces, `#`, `(`, `)` and a `apps/wiki café` vs `apps/wiki` prefix trap route correctly. | +| Recovery/idempotency | **PASS** with documented risk R1 | Restart before/after fetch and after routing, lease expiry, late-enqueue rollback, signing rotation between accept and route (blocked, cursor kept, next push covers it). Verified fetch exhaustion leaves the push unrouted (fail-closed; see R1). | +| Database migration | **PASS** | Seeded a database with the **actual v0.5.0 code** (admin, workspace, Gitea token credential, repo clone, 2 stacks, draft, unpushed commit, pending job, audit), upgraded with the candidate: 11 migrations, every pre-existing row byte-identical across 9 tables, token decrypts and drives an authenticated fetch, v0.5.0's pending job runs, new defaults correct (auto-fetch on/inherit, webhooks off, no bindings). Reopen is a no-op; upgraded schema equals a fresh schema; `pnpm db:generate` reports no drift. | +| Backup/restore | **PASS** (disposable data) | Online `sqlite3 .backup` (integrity `ok`) + clone archive; stopped-volume `tar` round trip byte-identical with uid 1000 preserved; clean stop leaves no WAL. Pre-upgrade backup restored and run with v0.5.0: 4 migrations, data intact, token decrypts, fetch works. Wrong key: starts, decryption fails, signed webhook rejected. Missing/placeholder key: refuses to start (exit 1). v0.5.0 on an upgraded DB starts but leaves v0.6.0 queued work dormant → documented as unsupported. | +| Security | **PASS** | Gitleaks over `main` history (70 commits): no leaks. Prod `pnpm audit`: no known vulnerabilities. Code-scanning: 20 open Sonar alerts, all previously dispositioned in `docs/internal/security/2026-10-20-sonar-dispositions.md` (manual Sonar status change pending). Secret-scanning alerts: 0. Dependabot alerts are disabled for the repository (R12). Two remaining unresolved Kody threads fixed in this PR. No credential exposure, SSRF, workspace isolation, CSRF or replay regression found. | +| Accessibility | **PASS** | Full Playwright + axe on the audited production build: 43 passed, 13 intentional skips (duplicate-viewport shared mutations; worker/server-disabled scenarios). Server-disabled auto-fetch scenario run separately: passed. Modified webhook spec re-run: passed. New dashboard keyboard check (Tab to **View all activity**, Enter opens Settings → Activity) passed on desktop and mobile. | +| Container and CI | **PASS** (CI) / local image **not run** | [CI on `8333dd8`](https://github.com/uniskela/stack-manager/actions/runs/39720529649): actionlint/shellcheck, check, build, smoke, Playwright, audit, Docker build, healthy start, same-volume restart, missing-key refusal. [Container security on `main`](https://github.com/uniskela/stack-manager/actions/runs/39722448520) (dispatched by this audit, scan-only): passed. [Secret scan](https://github.com/uniskela/stack-manager/actions/runs/39720529616): passed. Locally: actionlint, shellcheck, standalone production smoke, same-data restart, missing-key refusal passed. A local Docker image build was **not run**: the root disk had ~450 MB free and broad Docker cleanup was out of scope. | +| Live operator acceptance | **UNVERIFIED** | Checklist in [Upgrading to v0.6.0](../../public/UPGRADING_0_6.md#acceptance-checklist-before-enabling-automatic-deployment). Requires the real forge, reverse proxy, TLS chain and a disposable receiver; not authorized in this audit. | + +### Commands run + +```text +pnpm install --frozen-lockfile --ignore-scripts && pnpm rebuild better-sqlite3 esbuild # ok +pnpm check # main: 656 passed; with this PR: see PR checks +pnpm build # ok (audited SHA, built on tmpfs) +pnpm test:e2e (E2E_GIT_REMOTE=https://github.com/docker/awesome-compose.git E2E_STACK_ROOT=nginx-golang) + # 43 passed, 13 skipped +STACK_MANAGER_AUTO_FETCH_ENABLED=false playwright test repository-auto-fetch.spec.ts # passed +scripts/smoke.sh against .next/standalone/server.js; restart on same data; empty key # ok / ok / exit 1 +pnpm audit --prod --audit-level high # no vulnerabilities +pnpm db:generate # no schema changes +actionlint 1.7.12, shellcheck scripts/*.sh scripts/ci/*.sh # clean +gitleaks 8.30.1 git (origin/main) # no leaks +v0.5.0 → candidate upgrade, backup, restore and wrong/missing-key checks (scratch harness) # passed +``` + +The upgrade/restore harness ran from disposable scratch copies of `v0.5.0` and `8333dd8`; it is not part of the +repository because it needs the old release tree. Its assertions are summarised in the matrix above. + +## Findings and dispositions + +| # | Finding | Risk | Blocker? | Disposition | +| --- | --- | --- | --- | --- | +| F1 | All 13 deployment audit actions had no human label; Dashboard and Settings → Activity showed raw ids such as `deployment.unknown`. | Operators misread the audit trail during an incident. | No | **Fixed**: labels that state HTTP acceptance, not health; typed completeness test fails if a future action lacks a label. | +| F2 | Kody [r4325951812](https://github.com/uniskela/stack-manager/pull/63#discussion_r4325951812): repository-scope test could pass with an out-of-scope row. | Weak regression test. | No | **Fixed**: exact set asserted, r3 explicitly excluded. | +| F3 | Kody [r4332700230](https://github.com/uniskela/stack-manager/pull/64#discussion_r4332700230): e2e cleanup assertion in `finally` can mask the original failure. | Misleading CI diagnosis. | No | **Fixed**: cleanup is asserted only after a passing body and never throws over an earlier failure. | +| F4 | Verified push whose fetch exhausts retries is never routed (R1). | Missed automatic deployment; fail-closed. | No | **Documented** in Deployment/Git webhook troubleshooting; regression test pins the safe behaviour. Follow-up recommended. | +| F5 | Backup guide described backups as "planned" and lacked WAL, permissions, key and rollback guidance; release doc said ingress never deploys. | Unsafe upgrade/rollback practice. | Yes (docs) | **Fixed**: tested procedures, failure-mode table, rollback and replay-ledger guidance, v0.6.0 release gate, upgrade page and operator checklist. | +| F6 | Routing patterns reject `{ } [ ] ( ) ! :` even as literals; undocumented. | Confusing configuration. | No | **Documented** with a verified `?` workaround. | +| F7 | Changelog in #41 repeats "explain automatic fetch and manual fallback" four times and lists #59's six intermediate `feat(sync)` commits separately. | Cosmetic, noisy release notes. | No | **Recommendation** only (Release Please owns the file): consolidate in the GitHub Release text after publication, and add the upgrade/backup note. | +| F8 | Roadmap (`MVP_PLAN.md`) and `AGENTS.md` still called deployment routing the next active phase. | Misleading project status for contributors and agents. | No | **Fixed**: both say it is implemented on `main` for v0.6.0 and in its release gate. | + +Kody's PR #66 items were re-checked against the code: the repository-lookup failure still propagates for job +retry without advancing shared checkpoints (correct: skipping would move cursors past unrouted sources), shared +reads are hoisted while per-stack guarded enqueue stays inside one immediate transaction (no unsafe batching), +and the SQLite smoke helper closes its handle on failure paths. Those dispositions stand. + +## Remaining risks + +| ID | Risk | Severity | Likelihood | Impact | Mitigation | +| --- | --- | --- | --- | --- | --- | +| R1 | A verified push is not routed if its fetch fails all 5 retries (~75 s of backoff, e.g. forge restarting). A later polled fetch does not route it. | Medium | Low–medium | Missed automatic deployment; no unsafe deploy. | Next verified push includes the changes (baseline unchanged); Deploy now; documented. Follow-up [#68](https://github.com/uniskela/stack-manager/issues/68). | +| R2 | Exactly-once delivery is impossible across HTTP; Portainer ignores `Idempotency-Key`. | Medium | Low | Possible duplicate redeploy after a crash/timeout. | Intent before send, unknown outcome, persistent automation hold, operator inspection. | +| R3 | Live forge versions, reverse proxies and receivers may differ from fixtures (byte rewriting, redirects, TLS chains). | Medium | Medium | 401/415 ingress or unknown/rejected sends. | Operator checklist steps 2–3, 5–12 before production opt-in. | +| R4 | Git remote host check happens before `git` resolves DNS (rebinding TOCTOU), already listed in SECURITY.md. | Medium | Low | SSRF to a private address via a hostile DNS name. | HTTPS only, no redirects, private networks denied by default. Deployment webhooks are pinned; Git is not. | +| R5 | In-memory ingress rate limit resets on restart and is skipped without a trustworthy client IP. | Low | Medium | Weak flood protection. | Proxy-level limits (documented). | +| R6 | Restoring an older backup restores an older replay/routing ledger. | Medium | Low | Redeliveries can fetch/route again; sent requests are not undone. | Documented: keep automation off until receivers are inspected. | +| R7 | Delivery, decision and attempt history grows until repository/stack removal. | Low | Medium | Database growth on busy repositories. | Monitor size; retention is future work. | +| R8 | 20 Sonar alerts await manual status changes; 25 reviewed advisories remain from #66. | Low | n/a | Noise in security dashboards. | Dispositions recorded; apply in Sonar. | +| R9 | Dependabot alerts disabled. | Low | Low | Slower notice of new advisories. | `pnpm audit` and Trivy run in CI; consider enabling alerts. | +| R10 | Local Docker build not exercised on the audit host. | Low | Low | — | CI Docker job and container-security pass on `main`. | +| R11 | After R1 occurs, every later successful fetch queues a no-op routing job for that repository. | Low | Low | Negligible worker load. | Covered by the R1 follow-up. | + +## Go/No-Go + +**CONDITIONAL GO.** The code on `8333dd8` plus this hardening PR is ready: all automated gates pass, no +critical or high defect was found, and the remaining risks fail closed or are documented. Before merging +release PR #41 (and in any case before enabling automatic deployment in production), the operator must: + +1. Merge this hardening PR after review so its fixes and docs ship in v0.6.0, then let Release Please update #41. +2. Approve the pending workflow runs on #41 (they show `action_required`) and confirm they pass on its head. +3. Complete the [acceptance checklist](../../public/UPGRADING_0_6.md#acceptance-checklist-before-enabling-automatic-deployment) + on a disposable stack, recording results on #57. Steps 2, 3, 6, 9–12 and 14 are the minimum. + +If any checklist step fails, treat it as a release blocker and record it on #57. diff --git a/docs/manifest.json b/docs/manifest.json index 626c5a3..d976906 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -62,6 +62,13 @@ "group": "Deploy & operate", "kind": "doc" }, + { + "source": "docs/public/UPGRADING_0_6.md", + "slug": "upgrading-0-6", + "title": "Upgrading to v0.6.0", + "group": "Deploy & operate", + "kind": "doc" + }, { "source": "docs/public/AUTH_AND_CREDENTIALS.md", "slug": "auth-and-credentials", diff --git a/docs/public/BACKUP_RESTORE.md b/docs/public/BACKUP_RESTORE.md index 3f1db62..3777686 100644 --- a/docs/public/BACKUP_RESTORE.md +++ b/docs/public/BACKUP_RESTORE.md @@ -1,53 +1,122 @@ # Backup and restore -## What to back up +A complete backup has two parts that you store **separately**: -Application data directory (indicative layout): +1. **The data volume** (`/data` in the container): the SQLite database plus local Git clones. +2. **The configuration secrets**: `STACK_MANAGER_ENCRYPTION_KEY` (and its `STACK_MANAGER_ENCRYPTION_KEY_VERSION`) + and `STACK_MANAGER_SESSION_SECRET`, kept in your host's secret management, never inside the data backup. + +Without the matching encryption key, a restored database starts but cannot decrypt any stored Git token, +Git webhook signing secret or deployment webhook URL. Without the session secret, everyone is signed out. + +## What is in the data volume ```text -data/ - stack-manager.sqlite # includes credential ciphertext, session digests, jobs, audit - stack-manager.sqlite-wal # WAL mode — back up together with the main file (or checkpoint first) +/data/ + stack-manager.sqlite # users, session digests, workspaces, credential ciphertext, jobs, audit, + # webhook delivery ledger, deployment bindings, attempts and routing decisions + stack-manager.sqlite-wal # write-ahead log while the app runs (absent after a clean stop) stack-manager.sqlite-shm - repos/ # local clones (re-creatable from the forge) - git-home/ # isolated git HOME (re-creatable) + repos// # local clones, including unpushed commits under refs/stack-manager/heads/* + git-home/ # isolated HOME for git (re-created automatically) ``` -See [SELF_HOSTING.md](SELF_HOSTING.md#data-directory) for ownership and permissions. +Drafts live in the database. Commits made in the app but not yet pushed live only in `repos/`, so back up +the whole volume, not just the database file. + +## Take a consistent backup + +The database runs in WAL mode. Copying `stack-manager.sqlite` while the app runs, without its WAL, can +produce an inconsistent or out-of-date backup. Use one of these methods. + +### Stop, then archive the volume (recommended) + +A clean stop checkpoints the WAL into the main database file. + +```sh +docker compose stop stack-manager +docker run --rm -v _stack-manager-data:/data:ro -v "$PWD":/backup alpine \ + tar -C /data -czf /backup/stack-manager-data-$(date +%F).tgz . +chmod 600 stack-manager-data-*.tgz +docker compose start stack-manager +``` + +Replace `_stack-manager-data` with the volume name shown by `docker volume ls`. Snapshots of the +underlying volume or filesystem taken while the app is stopped are equally good. + +### Online backup of the database (bind mounts) + +If the data directory is a host bind mount and the `sqlite3` CLI is installed on the host, its backup command +is consistent while the app runs: -Also back up **configuration env** separately (encryption key, session secret) via host secret management — **not** inside the DB dump alone. +```sh +sqlite3 /srv/stack-manager/data/stack-manager.sqlite ".backup '/backups/stack-manager.sqlite'" +sqlite3 /backups/stack-manager.sqlite "PRAGMA integrity_check;" # expect: ok +tar -C /srv/stack-manager/data -czf /backups/stack-manager-repos.tgz repos +``` -## Backup methods (planned) +The `repos/` archive taken this way can be slightly newer or older than the database. Prefer the stopped +method before an upgrade. -1. **Volume snapshot** of Docker named volume (preferred operationally) -2. **In-app export** (PR #8): consistent SQLite checkpoint + manifest of version + keyVersion (not the key itself) -3. Document `sqlite` online backup API usage for consistency +Backups contain credential ciphertext and session digests. Keep them owner-readable only (`chmod 600`) and +treat them as sensitive even though they are encrypted. ## Restore -1. Stop stack-manager -2. Restore volume / files -3. Ensure same `STACK_MANAGER_ENCRYPTION_KEY` -4. Start and run migrations if needed -5. Verify Git remotes fetch; re-test provider connections +1. Stop Stack Manager: `docker compose stop stack-manager`. +2. Restore into an **empty** volume (or move the old one aside): + + ```sh + docker volume create _stack-manager-data + docker run --rm -v _stack-manager-data:/data -v "$PWD":/backup:ro alpine \ + tar -C /data -xzpf /backup/stack-manager-data-YYYY-MM-DD.tgz --numeric-owner + ``` + + Files must stay owned by uid/gid 1000 (`node`). `-p --numeric-owner` preserves that. +3. Set the **same** `STACK_MANAGER_ENCRYPTION_KEY` and key version that were in use when the backup was taken. +4. Run the **application version that created the backup** (set `STACK_MANAGER_VERSION`), then start it. + A newer version migrates the restored database forward on start; an older version must not be used on a + database that a newer version has already migrated (see [Rolling back an upgrade](#rolling-back-an-upgrade)). +5. Sign in, open each repository and choose **Fetch now**; check that clone credentials still work. ## Failure modes | Situation | Result | | --- | --- | -| DB restored, wrong encryption key | App starts but credentials unusable — show clear error | -| Key restored, empty DB | Fresh setup; old credentials gone | -| Clone dirs missing | Re-clone from forge on demand | +| Encryption key missing, empty or a placeholder | The server refuses to start (`invalid configuration; refusing to start`). | +| Database restored with a different key | The server starts, but each credential fails to decrypt: fetches fail, signed Git webhooks return 401 and deployment requests are rejected before anything is sent. Restore the correct key. | +| Key restored, database empty or missing | A fresh first-run setup; old credentials are gone. | +| `repos/` missing | Clones are re-created on the next fetch, but unpushed in-app commits are lost. | -## What not to back up into Git +## Rolling back an upgrade -- Entire `data/` directory -- Encryption keys -- Session secrets -- Decrypted credential material +Database migrations only move forward and there are no down migrations. To roll back: + +1. Stop Stack Manager. +2. If automatic deployment was enabled, inspect your deployment receivers (Portainer, generic endpoints) for + requests that were sent after the backup. A restore cannot undo a request that was already sent. +3. Restore the **pre-upgrade** backup, keep the same encryption key, set `STACK_MANAGER_VERSION` back to the + previous release and start it. + +Do not run an older version against a database that a newer version has migrated, even if it appears to +start: newer tables and queued jobs remain in the database without the code that manages them. For example, +deployment requests queued by v0.6.0 stay dormant under v0.5.0 and can be sent if you later upgrade again. -## Tests (PR #8) +### Restoring an older replay and routing ledger -- Export/import round-trip on fixture workspace -- Wrong-key detection -- Redaction: export archive must not contain plaintext tokens +v0.6.0 stores which Git webhook deliveries it has accepted, which pushes were routed to which stacks and +which deployment requests were sent. Restoring an older backup restores that older ledger: + +- Forge redeliveries of pushes received after the backup are no longer recognised as duplicates and can be + fetched and, for opted-in stacks, routed again. +- Deployment requests sent after the backup are not undone and no longer appear in history. + +After restoring a backup older than your last deployments, keep automatic deployment **off** (or keep forge +webhooks disabled) until you have inspected the receivers. Then re-enable it deliberately; saving the +automatic settings sets a new baseline at the current fetched commit. + +## What not to put in Git + +- The data volume or any part of it +- Encryption keys and session secrets +- Decrypted credential material diff --git a/docs/public/DEPLOYMENT_WEBHOOKS.md b/docs/public/DEPLOYMENT_WEBHOOKS.md index 80bd461..cefa254 100644 --- a/docs/public/DEPLOYMENT_WEBHOOKS.md +++ b/docs/public/DEPLOYMENT_WEBHOOKS.md @@ -118,7 +118,9 @@ The initial form includes standard Compose filenames, the selected Compose file, an explicit dependency such as `shared/proxy/**`. Exclude a shared file with its repository-relative path. Supported wildcards are `*` (within one path segment), `?` (one character) and whole-segment `**` -(zero or more segments). Brace expansion, character classes, negation and extended globs are unsupported. +(zero or more segments). Brace expansion, character classes, negation and extended globs are unsupported, +so patterns cannot contain `{ } [ ] ( ) ! :` even as literal characters. Match such a character with `?` +(for example `config/?prod?/**` for files under `config/(prod)/`). Unicode and spaces in paths are matched exactly. Paths must be relative POSIX paths without traversal, `.git`, backslashes or control characters. Each list accepts at most **32 patterns**, each at most **256 characters**. Comparisons accept at most **1,000 commits** and **10,000 changed paths**, with bounded Git execution, output and wildcard matching work (50 million estimated steps per stack, 100 million per source). @@ -175,6 +177,14 @@ concurrent saves must reload rather than silently overwrite a newer binding. - **Unknown:** check the receiver before retrying. A failure response does not prove a deployment did not start. A new Deploy now confirmation is required for another attempt. - **Settings changed:** reload the latest settings, review the provider and replace configuration again. +- **No routing decision after a push:** a verified push is routed only after its own fetch succeeds. If that + fetch fails on every retry (the forge is unreachable for a minute or more), the push is not routed, and a + later scheduled or manual fetch does not route it either. Nothing is deployed and the binding's baseline does + not move, so the next verified push includes those changes. Check the fetch error in Overview and use + **Deploy now** if the change should not wait. Redelivering the same push from the forge does not help: + an identical body is recognised as a duplicate. +- **Blocked: history unavailable:** the repository was not ready when routing ran, for example because a + later fetch failed. Fetch, inspect, then use **Deploy now** if the change needs deploying. ## Migration, backups and rollback @@ -191,4 +201,7 @@ that backup: restoring encrypted webhook URLs requires the same key material. Te relying on it. Migrations run on startup; do not edit previous migrations or apply hand-written down migrations. To roll back, stop all workers, inspect destinations for any in-flight requests, restore the pre-upgrade data backup and matching encryption configuration, then run the prior pinned application -version. Restoring a database cannot undo a deployment request already sent to another system. +version. Restoring a database cannot undo a deployment request already sent to another system. Do not run +the prior version on the migrated database instead of restoring: queued deployment jobs stay dormant there +and can be sent after a later re-upgrade. See [Backup and restore](BACKUP_RESTORE.md#rolling-back-an-upgrade), +including what restoring an older replay ledger means for automation. diff --git a/docs/public/GIT_WEBHOOKS.md b/docs/public/GIT_WEBHOOKS.md index 39ebc54..814c0d5 100644 --- a/docs/public/GIT_WEBHOOKS.md +++ b/docs/public/GIT_WEBHOOKS.md @@ -98,6 +98,8 @@ Regular Stack Manager mutations still require a browser session and same-origin Last accepted time appears in Overview. Failed unauthenticated deliveries are not stored as delivery records or reflected in the UI: inspect the forge's HTTP delivery history. Fetch failures appear in Overview and Settings → Activity. Never paste signing secrets or raw webhook bodies into support logs. +A 202 means the fetch was queued, not that it succeeded. If that fetch then fails on every retry, the push +is not routed to deployments; see [Deployment webhooks troubleshooting](DEPLOYMENT_WEBHOOKS.md#troubleshooting). Delivery IDs are persisted uniquely per repository/provider across process restarts. Recording an accepted delivery and inserting/coalescing its fetch are one SQLite transaction; a queue error rolls diff --git a/docs/public/RELEASING.md b/docs/public/RELEASING.md index fbf818b..ff07b2c 100644 --- a/docs/public/RELEASING.md +++ b/docs/public/RELEASING.md @@ -47,9 +47,14 @@ All third-party actions are pinned to commit SHAs; Dependabot proposes weekly up - Images are linux/amd64. arm64 (e.g. Raspberry Pi) needs emulated or arm64 runners and can be added to `publish-image.yml` later. -## v0.6.0 inbound webhook validation +## v0.6.0 release gate -Issue #53 adds signed GitHub/Gitea/Forgejo ingress and additive delivery-ledger and accepted-body-fingerprint migrations (0007/0008). -Before release, verify the provider/security/replay/queue/restart tests, production UI and rootless -Docker smoke checks, plus live forge delivery from supported operator versions. No deployment is -triggered by ingress. Follow [Git push webhooks](GIT_WEBHOOKS.md) for backup and rollback. +v0.6.0 adds scheduled and post-push fetching, signed GitHub/Gitea/Forgejo push webhooks, per-stack Portainer and +generic deployment webhooks with manual **Deploy now**, and opt-in selective deployment routing. Migrations +`0004`–`0010` are additive. Verified pushes can trigger deployments only for stacks that explicitly enable +automatic deployment. + +Before merging the release PR, the maintained CI checks above must pass on the release head and the operator +acceptance checklist in [Upgrading to v0.6.0](UPGRADING_0_6.md) must be completed on a disposable stack with +the real forge, reverse proxy and deployment receiver. Back up first and follow +[Backup and restore](BACKUP_RESTORE.md) for rollback. diff --git a/docs/public/SECURITY.md b/docs/public/SECURITY.md index c22d312..18f78fd 100644 --- a/docs/public/SECURITY.md +++ b/docs/public/SECURITY.md @@ -58,7 +58,7 @@ Assume: operator places stack-manager on a trusted homelab network behind auth a - [x] Encryption key required at boot (PR #2 — process exits on missing/malformed key) - [x] Secret redaction tests (PR #2 — logger, audit, job payloads, API responses, data dir scan) -- [ ] Webhook signature tests +- [x] Webhook signature tests (v0.6.0 — GitHub/Gitea/Forgejo native HMAC-SHA256, SHA-1 and header substitution rejected) - [x] SSRF unit tests (PR #2 — Git remote host policy; deploy webhook URLs in PR #5) - [ ] Log viewer XSS tests - [x] Dependency vulnerability scanning in CI (PR #2 — `pnpm audit --prod`; Trivy on `pnpm-lock.yaml`) diff --git a/docs/public/SELF_HOSTING.md b/docs/public/SELF_HOSTING.md index 2b12a17..d39b9ed 100644 --- a/docs/public/SELF_HOSTING.md +++ b/docs/public/SELF_HOSTING.md @@ -104,8 +104,10 @@ once; afterwards `/setup` redirects to sign-in. ## Upgrades -Back up the data volume, change `STACK_MANAGER_VERSION`, then `docker compose up -d`. Migrations run automatically. Back up the data volume first -(see [BACKUP_RESTORE.md](BACKUP_RESTORE.md)). +Stop the app and back up the data volume (see [BACKUP_RESTORE.md](BACKUP_RESTORE.md)), change +`STACK_MANAGER_VERSION`, then `docker compose up -d`. Migrations run automatically and only move forward: to roll +back, restore the pre-upgrade backup and run the previous version. See [Upgrading to v0.6.0](UPGRADING_0_6.md) +for the v0.6.0 notes and acceptance checklist. ## Scaling notes diff --git a/docs/public/UPGRADING_0_6.md b/docs/public/UPGRADING_0_6.md new file mode 100644 index 0000000..b0530f3 --- /dev/null +++ b/docs/public/UPGRADING_0_6.md @@ -0,0 +1,56 @@ +# Upgrading to v0.6.0 + +v0.6.0 adds automatic Git fetching, signed GitHub/Gitea/Forgejo push webhooks, per-stack Portainer and +generic deployment webhooks, and opt-in selective deployment after verified pushes. Nothing deploys +automatically after the upgrade: every stack starts with no deployment binding and automatic deployment off. + +## Before you upgrade + +1. Note your current `STACK_MANAGER_VERSION` (you need it to roll back). +2. Stop Stack Manager and back up the data volume; keep the encryption key and session secret separately. + Follow [Backup and restore](BACKUP_RESTORE.md#take-a-consistent-backup). +3. Read the [release notes](https://github.com/uniskela/stack-manager/releases). + +## Upgrade + +1. Set `STACK_MANAGER_VERSION=0.6.0` in `.env` and run `docker compose up -d`. +2. Migrations `0004`–`0010` run on start. They only add tables and columns; existing users, workspaces, + repositories, credentials, stacks, drafts, unpushed commits, jobs and audit history are kept unchanged. +3. Background fetching starts every 15 minutes by default. Change it with + `STACK_MANAGER_AUTO_FETCH_INTERVAL_MINUTES` (5–1440) or `STACK_MANAGER_AUTO_FETCH_ENABLED=false`, or per + repository in **Overview → Automatic fetching**. +4. Optional features stay off until you configure them: [Git push webhooks](GIT_WEBHOOKS.md) and + [Deployment webhooks](DEPLOYMENT_WEBHOOKS.md). + +To roll back, follow [Rolling back an upgrade](BACKUP_RESTORE.md#rolling-back-an-upgrade): restore the +pre-upgrade backup and run the previous version. Do not run the previous version on the upgraded database. + +## Acceptance checklist before enabling automatic deployment + +Automated tests cover these behaviours with mock forges and receivers. They cannot prove that your forge +version, reverse proxy, TLS chain and deployment receiver behave the same way. Run this checklist once on a +**disposable** stack before opting real stacks into automatic deployment. Record each result. + +Use a throwaway repository folder and a throwaway receiver stack (for example a Portainer stack running a +`traefik/whoami` container, or a generic HTTPS endpoint you control that logs requests). Never use a +production stack for steps 6–12. + +| # | Check | How | Expected result | +| --- | --- | --- | --- | +| 1 | Backup | Stop, archive the volume, start again ([guide](BACKUP_RESTORE.md#take-a-consistent-backup)). Store keys separately. | Archive exists, `chmod 600`, keys stored elsewhere. | +| 2 | Webhook ingress through the reverse proxy | Configure the repository's **Git webhooks** signing secret and the forge hook (POST, JSON, push only). Push a commit to the tracked branch. | Forge delivery history shows HTTP 202 `accepted`; Overview shows **Last accepted delivery**. GitHub's initial ping returns 200 `ignored`, which is expected. Any 401, 413, 415 or redirect means the secret, proxy or hook settings need fixing. | +| 3 | Provider signature format | In the forge delivery details, check the headers. | GitHub: `X-Hub-Signature-256: sha256=…`. Gitea: `X-Gitea-Signature`. Forgejo: `X-Forgejo-Signature`. A wrong secret returns 401. | +| 4 | Automatic fetch | Set the repository's **Check interval** to 5 minutes, disable the forge hook, push a commit from outside Stack Manager and wait. | The new commit appears without **Fetch now**; last-checked time updates. Re-enable the hook. | +| 5 | Outbound webhook configuration | On the disposable stack's **Deployment** tab, save the Portainer or generic HTTPS URL, then choose **Validate destination**. | Settings show only a mask. Validation passes without sending a request. Private addresses need `STACK_MANAGER_ALLOW_PRIVATE_NETWORKS=true`. | +| 6 | Manual deployment | Choose **Deploy now** and confirm. | **Recent deployments** shows **Accepted** with an HTTP status, and the receiver shows exactly one request. Accepted means the receiver answered 2xx, not that containers are healthy: check them in Portainer. | +| 7 | Routing rules and preview | Review the include/exclude/dependency patterns. Enter `/compose.yaml` and `/README.md` as sample paths and choose **Preview routing**. | Compose change: deploy; README-only: no deployment. No request is sent. | +| 8 | Opt in | Select **Deploy automatically after verified Git pushes** and save. | Saved. No request is sent on save; the baseline is the current fetched commit. | +| 9 | Relevant push | Change the disposable stack's Compose file and push to the tracked branch. | **Recent routing decisions** shows **deploy** for this stack only; one new **Automatic** attempt; the receiver shows one request. Other opted-in stacks show skip. | +| 10 | Irrelevant push | Change only a Markdown file in the stack folder and push. | Decision **skip** (`no_relevant_changes`); no new attempt; no receiver request. | +| 11 | Repeated delivery | In the forge, **Redeliver** the push from step 9. | Forge shows HTTP 200 with `duplicate`; no new decision or attempt; no receiver request. | +| 12 | Unknown outcome recovery | Make the receiver slow or unreachable so a request times out (for example a generic endpoint that waits longer than 15 seconds, or stop the receiver's proxy route), then push a relevant change. | Attempt shows **Unknown**; automation is on hold; a further relevant push is **blocked** (`outcome_unknown`). Check whether the receiver acted, restore it, then use **Deploy now**: the hold clears and the next relevant push deploys. | +| 13 | Audit and history | Open **Settings → Activity** and the stack's **Deployment** tab. | Entries for webhook configured, destination checked, manual and automatic requests, accepted, unknown and stack not deployed. No URLs, tokens or secrets appear anywhere. | +| 14 | Restore on a separate instance | Restore the step 1 archive into a new volume. Start it with the same keys on another port and `STACK_MANAGER_WORKER_ENABLED=false`, so it cannot send deployment requests. Disable every deployment binding on the copy, then restart it with the worker enabled and choose **Fetch now**. | Sign-in works, repositories, stacks and drafts are present, and the fetch succeeds (credentials decrypt). The copy sends no deployment requests. Remove it afterwards. | + +When all checks pass, opt real stacks in one at a time. Keep Git polling enabled as a fallback for missed +webhooks, and keep regular backups. diff --git a/docs/public/installation.md b/docs/public/installation.md index f8f4a34..ee10155 100644 --- a/docs/public/installation.md +++ b/docs/public/installation.md @@ -93,7 +93,8 @@ provenance. 3. Set the new `STACK_MANAGER_VERSION` in `.env` and run `docker compose up -d`. Database migrations run automatically on start. Because the version is pinned in `.env`, nothing upgrades until you -change it. +change it. Upgrading from v0.5.x? Follow [Upgrading to v0.6.0](UPGRADING_0_6.md), including its acceptance +checklist before you enable automatic deployment. ## Building from source diff --git a/docs/public/plans/MVP_PLAN.md b/docs/public/plans/MVP_PLAN.md index fe85ffb..f0ce209 100644 --- a/docs/public/plans/MVP_PLAN.md +++ b/docs/public/plans/MVP_PLAN.md @@ -1,7 +1,8 @@ # Phased MVP implementation plan PR #1 (documentation), PR #2 (application foundation), PR #3 (source workspace) and PR #4 (Git workflow, released as -v0.5.0) are complete. **PR #5 (deployment routing) is the next active phase.** Subsequent PRs implement the product. Boundaries may shift for strong technical reasons; avoid giant cross-cutting PRs. +v0.5.0) are complete. **PR #5 (deployment routing) is implemented on `main` for v0.6.0** and is in its release gate +([#57](https://github.com/uniskela/stack-manager/issues/57)). Subsequent PRs implement the product. Boundaries may shift for strong technical reasons; avoid giant cross-cutting PRs. ## PR sequence @@ -84,11 +85,12 @@ Shipped across repository PRs #29, #31, #32, #34, #35, #36 and #37. User guide a **Safe defaults:** automatic Git fetch enabled at 15-minute fallback; auto-deployment off for all stacks until operator opt-in; manual fetch retained; no draft deletion, merge/rebase or force-push. Release version is controlled by Release Please, not this plan. Keep PR #6 environment/secrets and PR #7 read-only runtime observation in their existing later sequence. -## PR #5 (next): deployment routing +## PR #5: deployment routing (implemented for v0.6.0) -Next active phase. Scope as in the table above: `DeploymentProvider` with Portainer and generic webhooks, a forge -webhook receiver, relevance rules, selective deploy, idempotency and audit. Do not expand it into Portainer management -UI. +Delivered in sub-PRs #59, #62, #64, #65 and #66 (issues #52–#56): `DeploymentProvider` with Portainer and generic +webhooks, a signed forge webhook receiver, relevance rules, opt-in selective deploy, idempotency and audit. Release +verification and the operator acceptance checklist are tracked in [#57](https://github.com/uniskela/stack-manager/issues/57) +and [Upgrading to v0.6.0](../UPGRADING_0_6.md). Do not expand it into Portainer management UI. ## Dependency graph diff --git a/src/shared/audit/labels.ts b/src/shared/audit/labels.ts index 8d35c64..cd14833 100644 --- a/src/shared/audit/labels.ts +++ b/src/shared/audit/labels.ts @@ -28,6 +28,20 @@ const LABELS: Record = { 'git.push': 'Git commit pushed', 'git.push_rejected': 'Git push blocked', 'git.commit_validation_overridden': 'Commit warnings acknowledged', + // HTTP acceptance is provider acceptance, never verified container health. + 'deployment.binding_created': 'Deployment webhook configured', + 'deployment.binding_updated': 'Deployment webhook updated', + 'deployment.binding_disabled': 'Deployment webhook disabled', + 'deployment.binding_removed': 'Deployment webhook removed', + 'deployment.secret_rotated': 'Deployment webhook secret replaced', + 'deployment.destination_validated': 'Deployment destination checked (no request sent)', + 'deployment.requested': 'Manual deployment requested', + 'deployment.automatic_requested': 'Automatic deployment requested', + 'deployment.accepted': 'Deployment request accepted by receiver', + 'deployment.rejected': 'Deployment request rejected', + 'deployment.unknown': 'Deployment outcome unknown', + 'deployment.routing_evaluated': 'Git push routing evaluated', + 'deployment.stack_skipped': 'Stack not deployed for Git push', }; export function auditActionLabel(action: string): string { diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts index 1cb5134..b664c33 100644 --- a/tests/e2e/dashboard.spec.ts +++ b/tests/e2e/dashboard.spec.ts @@ -29,4 +29,15 @@ test('dashboard stays focused, accessible and responsive with a connected reposi expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); await page.screenshot({ path: `test-results/dashboard-${info.project.name}.png`, fullPage: true }); + + // Keyboard: the activity link is reachable with Tab and opens the full activity log with Enter. + const activity = page.getByRole('link', { name: 'View all activity' }); + let reached = false; + for (let i = 0; i < 60 && !reached; i++) { + await page.keyboard.press('Tab'); + reached = await activity.evaluate((el) => el === document.activeElement); + } + expect(reached).toBe(true); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(new RegExp(`/w/${workspaceId}/settings/activity$`)); }); diff --git a/tests/e2e/git-webhooks.spec.ts b/tests/e2e/git-webhooks.spec.ts index 6220f78..fce4bb6 100644 --- a/tests/e2e/git-webhooks.spec.ts +++ b/tests/e2e/git-webhooks.spec.ts @@ -34,6 +34,7 @@ test('signing secret saves, rotates with confirmation and disables', async ({ pa test.skip(configured, 'Existing signing secrets cannot be restored; leave configuration untouched.'); await page.goto(`/w/${workspaceId}/repositories/${repositoryId}`); const region = page.getByRole('region', { name: 'Git webhooks', exact: true }); + let bodyPassed = false; try { await region .getByLabel('Signing secret', { exact: true }) @@ -89,13 +90,24 @@ test('signing secret saves, rotates with confirmation and disables', async ({ pa await region.getByRole('button', { name: 'Disable webhook…' }).click(); await region.getByRole('button', { name: 'Confirm disable' }).click(); await expect(region.getByText('Disabled', { exact: true })).toBeVisible(); + bodyPassed = true; } finally { - const cleanup = await page.request.delete( - `/api/workspaces/${workspaceId}/repositories/${repositoryId}/webhook`, - { - headers: { origin: new URL(page.url()).origin }, - }, - ); - expect(cleanup.ok()).toBe(true); + // Never let cleanup mask the original failure: assert it after a passing body, otherwise report it + // alongside the original error (a leftover signing secret makes later runs skip this test). + let cleanupProblem: string | null = null; + try { + const cleanup = await page.request.delete( + `/api/workspaces/${workspaceId}/repositories/${repositoryId}/webhook`, + { headers: { origin: new URL(page.url()).origin } }, + ); + if (!cleanup.ok()) cleanupProblem = `webhook cleanup returned HTTP ${cleanup.status()}`; + } catch (error) { + cleanupProblem = `webhook cleanup failed: ${error instanceof Error ? error.message : String(error)}`; + } + if (bodyPassed) expect(cleanupProblem).toBeNull(); + else if (cleanupProblem) { + info.annotations.push({ type: 'cleanup-failed', description: cleanupProblem }); + console.warn(`${cleanupProblem}; the sample repository may keep a signing secret.`); + } } }); diff --git a/tests/integration/dashboard-draft-counts.test.ts b/tests/integration/dashboard-draft-counts.test.ts index b649a6c..95c0a16 100644 --- a/tests/integration/dashboard-draft-counts.test.ts +++ b/tests/integration/dashboard-draft-counts.test.ts @@ -74,13 +74,16 @@ describe('dashboard draft metadata query', () => { } const query = await h.container.source.listDraftPaths(['r1', 'r2']); + // Pin the exact set: three in-scope rows and never the r3 draft. expect(query).toEqual( expect.arrayContaining([ { repositoryId: 'r1', path: 'apps/wiki/compose.yaml' }, + { repositoryId: 'r1', path: 'apps/wiki-extra/compose.yaml' }, { repositoryId: 'r2', path: 'apps/web/compose.yaml' }, ]), ); expect(query).toHaveLength(3); + expect(query.some((row) => row.repositoryId === 'r3')).toBe(false); expect(JSON.stringify(query)).not.toContain('sensitive content'); const counts = await loadDashboardDraftCounts( diff --git a/tests/integration/deployment-routing.test.ts b/tests/integration/deployment-routing.test.ts index d197a61..3e1f719 100644 --- a/tests/integration/deployment-routing.test.ts +++ b/tests/integration/deployment-routing.test.ts @@ -5,10 +5,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as setup from '@/app/api/setup/route'; import * as preview from '@/app/api/workspaces/[workspaceId]/stacks/[stackId]/deployment/preview/route'; import { DEFAULT_ROUTING_POLICY } from '@/shared/deployment-routing'; -import { DeploymentService } from '@/server/application/deployment-service'; -import { openSqlite } from '@/server/persistence/sqlite/database'; import { GitCli } from '@/server/providers/git/git-cli'; import { createHarness, type TestHarness } from '../support/container'; +import { mockDeploymentSend, sqlRows } from '../support/routing'; import { startGitServer, gitServerOverrides, type GitServer } from '../support/git-server'; import { call, cookieFrom } from '../support/http'; @@ -20,17 +19,8 @@ const trigger = vi.fn(); const policy = { ...DEFAULT_ROUTING_POLICY, dependencies: ['shared/proxy/**'] }; const params = { workspaceId, stackId: 'wiki' }; -function sql>(statement: string, ...values: unknown[]): T[] { - const handle = openSqlite(h.config.databasePath); - try { - const prepared = handle.db.$client.prepare(statement); - if (prepared.reader) return prepared.all(...values) as T[]; - prepared.run(...values); - return []; - } finally { - handle.close(); - } -} +const sql = >(statement: string, ...values: unknown[]) => + sqlRows(h, statement, ...values); async function drain() { for (let i = 0; i < 30 && (await h.container.worker.runOnce()); i++) { /* bounded test drain */ @@ -76,24 +66,7 @@ async function restart() { h = await createHarness({ dataDir, config, overrides: gitServerOverrides(server) }); mockSend(); } -function mockSend() { - const provider = { - type: 'generic-webhook' as const, - validateConfiguration: (v: unknown) => v as { url: string }, - validateDestination: async () => {}, - trigger, - }; - const service = new DeploymentService( - h.container.repos, - h.container.credentials, - new Map([['generic-webhook', provider]]), - h.container.jobs, - h.container.audit, - h.clock, - randomUUID, - ); - vi.spyOn(h.container.deployments, 'execute').mockImplementation((job, ctx) => service.execute(job, ctx)); -} +const mockSend = () => mockDeploymentSend(h, trigger); beforeEach(async () => { server = await startGitServer(); server.createRepo('infra.git'); diff --git a/tests/integration/release-audit-routing.test.ts b/tests/integration/release-audit-routing.test.ts new file mode 100644 index 0000000..1006512 --- /dev/null +++ b/tests/integration/release-audit-routing.test.ts @@ -0,0 +1,349 @@ +/** + * v0.6.0 release audit (#57): independent end-to-end checks of selective routing through real Git, + * SQLite and the durable worker. These cover combinations not exercised by deployment-routing.test.ts. + */ +import { createHmac, randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as setup from '@/app/api/setup/route'; +import { DEFAULT_ROUTING_POLICY } from '@/shared/deployment-routing'; +import { createHarness, type TestHarness } from '../support/container'; +import { mockDeploymentSend, sqlRows } from '../support/routing'; +import { startGitServer, gitServerOverrides, type GitServer } from '../support/git-server'; +import { call } from '../support/http'; + +let h: TestHarness, server: GitServer, actor: string; +const workspaceId = 'home'; +// Random per run: no credential-shaped literals in the repository. +const secrets: Record = {}; +const trigger = vi.fn(); +const policy = { ...DEFAULT_ROUTING_POLICY, dependencies: ['shared/proxy/**', 'shared/ünï cødé/**'] }; + +const sql = >(statement: string, ...values: unknown[]) => + sqlRows(h, statement, ...values); +async function drain() { + for (let i = 0; i < 40 && (await h.container.worker.runOnce()); i++) { + /* bounded test drain */ + } +} +async function optIn(stackId: string, deployOnPush = true, enabled = true) { + const b = (await h.container.deployments.settings(workspaceId, stackId)).binding; + return h.container.deployments.configure( + workspaceId, + stackId, + { + providerType: 'generic-webhook', + enabled, + expectedRevision: b?.revision ?? null, + ...(b ? {} : { configuration: { url: `https://hooks.example.test/deploy/${stackId}` } }), + deployOnPush, + routingPolicy: policy, + }, + actor, + ); +} +async function deliver(repositoryId: string, sha: string, id = randomUUID()) { + const body = JSON.stringify({ + ref: 'refs/heads/main', + after: sha, + deleted: false, + repository: { clone_url: `https://git.test/${repositoryId}.git` }, + }); + const headers = new Headers({ + 'x-github-event': 'push', + 'x-github-delivery': id, + 'x-hub-signature-256': `sha256=${createHmac('sha256', secrets[repositoryId]!).update(body).digest('hex')}`, + }); + return h.container.gitWebhooks.receive(workspaceId, repositoryId, headers, Buffer.from(body)); +} +const change = (repo: string, files: Record) => + server.commitFiles(`${repo}.git`, 'main', files); +const decisions = (stackId: string) => h.container.repos.routing.history(workspaceId, stackId); +const attempts = async (stackId: string) => + (await h.container.deployments.history(workspaceId, stackId)).attempts; +const binding = async (stackId: string) => + (await h.container.deployments.settings(workspaceId, stackId)).binding!; + +const mockSend = () => mockDeploymentSend(h, trigger); + +async function connect(repositoryId: string) { + const now = h.clock.now(); + await h.container.repos.gitRepositories.insert({ + id: repositoryId, + workspaceId, + name: repositoryId, + gitProviderType: 'github', + remoteUrl: `https://git.test/${repositoryId}.git`, + defaultBranch: 'main', + credentialId: null, + webhookCredentialId: null, + localClonePath: `repos/${repositoryId}`, + syncStatus: 'pending', + headSha: null, + lastSyncError: null, + lastFetchedAt: null, + lastSyncAttemptAt: null, + lastSyncFailedAt: null, + lastHeadChangedAt: null, + autoAddStacks: false, + autoFetchEnabled: false, + autoFetchIntervalMinutes: null, + createdAt: now, + updatedAt: now, + }); + await h.container.repositories.sync(repositoryId); + await h.container.gitWebhooks.configure( + workspaceId, + repositoryId, + { secret: secrets[repositoryId]! }, + actor, + ); +} +async function stack(id: string, repositoryId: string, root: string) { + const now = h.clock.now(); + await h.container.repos.stacks.insert({ + id, + workspaceId, + repositoryId, + name: id, + slug: id, + rootPath: root, + composePath: `${root}/compose.yaml`, + createdAt: now, + updatedAt: now, + }); +} + +beforeEach(async () => { + secrets.infra = `audit-${randomUUID()}`; + secrets.media = `audit-${randomUUID()}`; + server = await startGitServer(); + server.createRepo('infra.git'); + server.createRepo('media.git'); + change('infra', { + 'apps/wiki/compose.yaml': 'services: {}', + 'apps/notes/compose.yaml': 'services: {}', + 'apps/wiki café/compose.yaml': 'services: {}', + }); + change('media', { 'photos/compose.yaml': 'services: {}' }); + h = await createHarness({ overrides: gitServerOverrides(server) }); + const user = await call(setup.POST, { + method: 'POST', + body: { username: 'admin', password: 'correct horse battery staple' }, + }); + actor = user.json.user.id; + const now = h.clock.now(); + await h.container.repos.workspaces.insert({ + id: workspaceId, + name: 'Home', + slug: 'audit-home', + createdAt: now, + updatedAt: now, + }); + await connect('infra'); + await connect('media'); + await stack('wiki', 'infra', 'apps/wiki'); + await stack('notes', 'infra', 'apps/notes'); + await stack('cafe', 'infra', 'apps/wiki café'); + await stack('photos', 'media', 'photos'); + trigger.mockReset().mockResolvedValue({ status: 'accepted', httpStatus: 204 }); + mockSend(); +}); +afterEach(async () => { + vi.restoreAllMocks(); + const dir = h.dataDir; + await h.cleanup(); + fs.rmSync(dir, { recursive: true, force: true }); + await server.close(); +}); + +describe('release audit: selective routing matrix', () => { + it('routes Unicode, spaces and unusual characters through real Git without prefix confusion', async () => { + await optIn('wiki'); + await optIn('cafe'); + await optIn('notes'); + const sha = change('infra', { + 'apps/wiki café/config/日本 #1 (ü).yml': 'x', + 'shared/ünï cødé/proxy.conf': 'y', + }); + expect(await deliver('infra', sha)).toBe('accepted'); + await drain(); + // `apps/wiki café` must not match the `apps/wiki` root; the shared dependency matches all three. + expect((await decisions('cafe'))[0]).toMatchObject({ + decision: 'deploy', + matchedPaths: ['apps/wiki café/config/日本 #1 (ü).yml', 'shared/ünï cødé/proxy.conf'], + }); + expect((await decisions('wiki'))[0]!.matchedPaths).toEqual(['shared/ünï cødé/proxy.conf']); + expect(trigger).toHaveBeenCalledTimes(3); + }); + + it('isolates repositories: a failing fetch in one repository does not block another', async () => { + await optIn('wiki'); + await optIn('photos'); + const real = h.container.repositories.sync.bind(h.container.repositories); + vi.spyOn(h.container.repositories, 'sync').mockImplementation(async (id, signal) => { + if (id === 'infra') throw new Error('simulated network failure'); + return real(id, signal); + }); + await deliver('infra', change('infra', { 'apps/wiki/config/a': 'a' })); + await deliver('media', change('media', { 'photos/config/b': 'b' })); + await drain(); + expect(await attempts('wiki')).toEqual([]); + expect((await attempts('photos'))[0]).toMatchObject({ status: 'accepted', triggerType: 'automatic' }); + expect(trigger).toHaveBeenCalledTimes(1); + }); + + it('never deploys when a verified delivery’s fetch exhausts retries, even after a later polled fetch', async () => { + await optIn('wiki'); + const baseline = (await binding('wiki')).routingHeadSha; + const real = h.container.repositories.sync.bind(h.container.repositories); + const sync = vi + .spyOn(h.container.repositories, 'sync') + .mockRejectedValue(new Error('simulated forge outage')); + const first = change('infra', { 'apps/wiki/config/a': 'a' }); + await deliver('infra', first); + for (let i = 0; i < 8; i++) { + await drain(); + h.clock.advance(16 * 60_000); + } + expect(sql<{ status: string }>("SELECT status FROM jobs WHERE type = 'repository_sync'")).toEqual([ + { status: 'dead' }, + ]); + sync.mockImplementation((id, signal) => real(id, signal)); + // Polling/manual fetch alone grants no deployment authority, so the orphaned source stays unrouted. + await h.container.repositories.requestSync(workspaceId, 'infra', actor); + await drain(); + expect(trigger).not.toHaveBeenCalled(); + expect(await decisions('wiki')).toEqual([]); + expect((await binding('wiki')).routingHeadSha).toBe(baseline); + // The next verified push covers the earlier change because the cursor never advanced. + h.clock.advance(1000); + await deliver('infra', change('infra', { 'apps/wiki/config/b': 'b' })); + await drain(); + expect((await decisions('wiki'))[0]).toMatchObject({ + decision: 'deploy', + matchedPaths: ['apps/wiki/config/a', 'apps/wiki/config/b'], + }); + expect(trigger).toHaveBeenCalledTimes(1); + }); + + it('blocks a source signed before rotation without advancing the cursor; the next push covers it', async () => { + await optIn('wiki'); + const before = (await binding('wiki')).routingHeadSha; + await deliver('infra', change('infra', { 'apps/wiki/config/a': 'a' })); + await h.container.gitWebhooks.configure( + workspaceId, + 'infra', + { secret: (secrets.infra = `audit-${randomUUID()}`) }, + actor, + ); + await drain(); + expect((await decisions('wiki'))[0]).toMatchObject({ + decision: 'blocked', + reasonCode: 'signing_changed', + }); + expect((await binding('wiki')).routingHeadSha).toBe(before); + expect(trigger).not.toHaveBeenCalled(); + h.clock.advance(1000); + await deliver('infra', change('infra', { 'apps/wiki/config/b': 'b' })); + await drain(); + expect((await decisions('wiki'))[0]).toMatchObject({ + decision: 'deploy', + matchedPaths: ['apps/wiki/config/a', 'apps/wiki/config/b'], + }); + expect(trigger).toHaveBeenCalledTimes(1); + }); + + it('rejects queued automatic work after deployment credential rotation or disabling, without sending', async () => { + await optIn('wiki'); + await optIn('notes'); + await deliver('infra', change('infra', { 'apps/wiki/config/a': 'a', 'apps/notes/config/a': 'a' })); + await h.container.worker.runOnce(); // fetch + await h.container.worker.runOnce(); // route: both attempts queued + expect((await attempts('wiki'))[0]!.status).toBe('queued'); + const wiki = await binding('wiki'); + await h.container.deployments.configure( + workspaceId, + 'wiki', + { + providerType: 'generic-webhook', + enabled: true, + expectedRevision: wiki.revision, + configuration: { url: 'https://hooks.example.test/deploy/rotated' }, + deployOnPush: true, + routingPolicy: policy, + }, + actor, + ); + await optIn('notes', false, false); + await drain(); + expect(trigger).not.toHaveBeenCalled(); + expect((await attempts('wiki'))[0]!.status).toBe('rejected'); + expect((await attempts('notes'))[0]!.status).toBe('rejected'); + }); + + it('turning automatic deployment off stops later pushes; turning it on re-baselines without replay', async () => { + await optIn('wiki'); + await optIn('wiki', false); + await deliver('infra', change('infra', { 'apps/wiki/config/a': 'a' })); + await drain(); + expect((await decisions('wiki'))[0]!.reasonCode).toBe('deploy_on_push_disabled'); + h.clock.advance(1000); + await optIn('wiki'); + expect((await binding('wiki')).routingHeadSha).toBe( + (await h.container.repos.gitRepositories.findById(workspaceId, 'infra'))!.headSha, + ); + await drain(); + expect(trigger).not.toHaveBeenCalled(); + }); + + it('holds automation after an unknown provider outcome until a deliberate manual request', async () => { + await optIn('wiki'); + trigger.mockResolvedValueOnce({ status: 'unknown', reason: 'request_failed' }); + await deliver('infra', change('infra', { 'apps/wiki/config/a': 'a' })); + await drain(); + expect((await attempts('wiki'))[0]!.status).toBe('unknown'); + expect((await binding('wiki')).automationHold).toBe(true); + h.clock.advance(1000); + await deliver('infra', change('infra', { 'apps/wiki/config/b': 'b' })); + await drain(); + expect((await decisions('wiki'))[0]).toMatchObject({ + decision: 'blocked', + reasonCode: 'outcome_unknown', + }); + expect(trigger).toHaveBeenCalledTimes(1); + // Re-saving settings does not clear the hold. + await optIn('wiki'); + expect((await binding('wiki')).automationHold).toBe(true); + await h.container.deployments.trigger(workspaceId, 'wiki', actor); + await drain(); + expect((await binding('wiki')).automationHold).toBe(false); + h.clock.advance(1000); + await deliver('infra', change('infra', { 'apps/wiki/config/c': 'c' })); + await drain(); + expect((await decisions('wiki'))[0]!.decision).toBe('deploy'); + expect(trigger).toHaveBeenCalledTimes(3); + expect(sql("SELECT count(*) AS n FROM deployment_attempts WHERE status = 'unknown'")).toEqual([{ n: 1 }]); + }); + + it('never routes a verified push for another workspace’s repository identifier', async () => { + await optIn('wiki'); + const sha = change('infra', { 'apps/wiki/config/a': 'a' }); + const body = JSON.stringify({ + ref: 'refs/heads/main', + after: sha, + deleted: false, + repository: { clone_url: 'https://git.test/infra.git' }, + }); + const headers = new Headers({ + 'x-github-event': 'push', + 'x-github-delivery': randomUUID(), + 'x-hub-signature-256': `sha256=${createHmac('sha256', secrets.infra!).update(body).digest('hex')}`, + }); + await expect( + h.container.gitWebhooks.receive('other-workspace', 'infra', headers, Buffer.from(body)), + ).rejects.toThrow('Webhook authentication failed.'); + await drain(); + expect(trigger).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/support/routing.ts b/tests/support/routing.ts new file mode 100644 index 0000000..e67cc65 --- /dev/null +++ b/tests/support/routing.ts @@ -0,0 +1,42 @@ +import { randomUUID } from 'node:crypto'; +import { vi, type Mock } from 'vitest'; +import { DeploymentService } from '@/server/application/deployment-service'; +import { openSqlite } from '@/server/persistence/sqlite/database'; +import type { TestHarness } from './container'; + +/** Runs one statement on an independent SQLite connection, returning rows for readers. */ +export function sqlRows>( + h: TestHarness, + statement: string, + ...values: unknown[] +): T[] { + const handle = openSqlite(h.config.databasePath); + try { + const prepared = handle.db.$client.prepare(statement); + if (prepared.reader) return prepared.all(...values) as T[]; + prepared.run(...values); + return []; + } finally { + handle.close(); + } +} + +/** Routes deployment jobs through a real DeploymentService whose only provider is the `trigger` mock. */ +export function mockDeploymentSend(h: TestHarness, trigger: Mock) { + const provider = { + type: 'generic-webhook' as const, + validateConfiguration: (v: unknown) => v as { url: string }, + validateDestination: async () => {}, + trigger, + }; + const service = new DeploymentService( + h.container.repos, + h.container.credentials, + new Map([['generic-webhook', provider]]), + h.container.jobs, + h.container.audit, + h.clock, + randomUUID, + ); + vi.spyOn(h.container.deployments, 'execute').mockImplementation((job, ctx) => service.execute(job, ctx)); +} diff --git a/tests/unit/audit-labels.test.ts b/tests/unit/audit-labels.test.ts index 3566048..977143d 100644 --- a/tests/unit/audit-labels.test.ts +++ b/tests/unit/audit-labels.test.ts @@ -1,6 +1,52 @@ import { describe, expect, it } from 'vitest'; import { auditActionLabel } from '@/shared/audit/labels'; import { auditEntityHref } from '@/shared/audit/links'; +import type { AuditAction } from '@/server/domain/audit'; + +// Typed as a complete record so adding an AuditAction without updating this list fails typecheck. +const ALL_ACTIONS: Record = { + 'deployment.routing_evaluated': true, + 'deployment.stack_skipped': true, + 'deployment.automatic_requested': true, + 'deployment.binding_created': true, + 'deployment.binding_updated': true, + 'deployment.destination_validated': true, + 'deployment.binding_disabled': true, + 'deployment.binding_removed': true, + 'deployment.secret_rotated': true, + 'deployment.requested': true, + 'deployment.accepted': true, + 'deployment.rejected': true, + 'deployment.unknown': true, + 'auth.setup': true, + 'auth.login': true, + 'auth.login_failed': true, + 'auth.logout': true, + 'auth.password_changed': true, + 'auth.password_change_failed': true, + 'auth.git_identity_updated': true, + 'workspace.create': true, + 'workspace.update': true, + 'credential.create': true, + 'credential.update': true, + 'credential.replace_secret': true, + 'credential.delete': true, + 'repository.create': true, + 'repository.update': true, + 'repository.test': true, + 'repository.sync_requested': true, + 'repository.sync': true, + 'repository.delete': true, + 'stack.create': true, + 'stack.update': true, + 'stack.delete': true, + 'draft.save': true, + 'draft.discard': true, + 'git.commit': true, + 'git.push': true, + 'git.push_rejected': true, + 'git.commit_validation_overridden': true, +}; describe('auditActionLabel', () => { it('maps known audit actions to human labels', () => { @@ -12,6 +58,12 @@ describe('auditActionLabel', () => { expect(auditActionLabel('stack.create')).toBe('Stack added'); }); + it('labels every audit action, including v0.6.0 deployment events', () => { + for (const action of Object.keys(ALL_ACTIONS)) expect(auditActionLabel(action), action).not.toBe(action); + expect(auditActionLabel('deployment.accepted')).toBe('Deployment request accepted by receiver'); + expect(auditActionLabel('deployment.destination_validated')).toContain('no request sent'); + }); + it('falls back to the raw action for unknown values', () => { expect(auditActionLabel('future.unknown_action')).toBe('future.unknown_action'); }); diff --git a/tests/unit/docs-layout.test.ts b/tests/unit/docs-layout.test.ts index fd38fe7..ef1c76d 100644 --- a/tests/unit/docs-layout.test.ts +++ b/tests/unit/docs-layout.test.ts @@ -15,6 +15,7 @@ const PUBLIC_SLUGS = [ 'deployment-webhooks', 'self-hosting', 'backup-restore', + 'upgrading-0-6', 'auth-and-credentials', 'security', 'product', diff --git a/tests/unit/repository-auto-fetch.test.ts b/tests/unit/repository-auto-fetch.test.ts index bf6f54a..8448f6f 100644 --- a/tests/unit/repository-auto-fetch.test.ts +++ b/tests/unit/repository-auto-fetch.test.ts @@ -60,7 +60,10 @@ describe('isRepositoryAutoFetchDue', () => { }); describe('RepositoryAutoFetch', () => { - function make(overrides: Partial = {}) { + function make( + overrides: Partial = {}, + policy = { workerEnabled: true, autoFetchEnabled: true, autoFetchIntervalMinutes: 15 }, + ) { const listWorkspaces = vi.fn(async () => [{ id: 'w1' } as Workspace]); const listRepositories = vi.fn(async () => [repository(overrides)]); const enqueue = vi.fn(async () => ({ created: true, job: {} as Job })); @@ -72,7 +75,7 @@ describe('RepositoryAutoFetch', () => { { enqueue }, { now: () => now }, { warn, debug }, - { workerEnabled: true, autoFetchEnabled: true, autoFetchIntervalMinutes: 15 }, + policy, ); return { scheduler, enqueue, warn, listRepositories, listWorkspaces }; } @@ -87,6 +90,19 @@ describe('RepositoryAutoFetch', () => { ); }); + it.each([ + ['worker disabled', { workerEnabled: false, autoFetchEnabled: true, autoFetchIntervalMinutes: 15 }], + [ + 'server auto-fetch disabled', + { workerEnabled: true, autoFetchEnabled: false, autoFetchIntervalMinutes: 15 }, + ], + ])('never scans or enqueues when the %s, even for an overdue repository', async (_label, policy) => { + const { scheduler, enqueue, listWorkspaces } = make({ lastFetchedAt: minutesAgo(600) }, policy); + expect(await scheduler.runOnce()).toBe(0); + expect(listWorkspaces).not.toHaveBeenCalled(); + expect(enqueue).not.toHaveBeenCalled(); + }); + it('skips repositories that are not due', async () => { const { scheduler, enqueue } = make({ lastFetchedAt: minutesAgo(1) }); expect(await scheduler.runOnce()).toBe(0);