Files
sencho/backend
Anso 45844b92ca fix(atomic-deploy): harden rollback locking, restore fidelity, and tier gating (#1247)
* fix(atomic-deploy): harden rollback locking, restore fidelity, and tier gating

Hardens the Atomic Deployments feature found during a full audit:

- Rollback now holds the per-stack lifecycle lock (deploy/update already do),
  so a rollback can no longer race a concurrent deploy on the same compose
  files. Adds a 'rollback' lifecycle action and releases the lock in finally.
- restoreStackFiles is now a faithful revert: it removes managed compose/.env
  files added after the backup before copying, so a rollback no longer leaves a
  hybrid of old and new configuration. Scope is the protected file set only;
  user data is untouched. Aborts (rather than reporting success) if a stale
  managed file cannot be removed.
- The scheduled image-update path derives the atomic flag from the licence tier
  instead of hardcoding it on, keeping the paid capability explicit at the call
  site (the scheduler is already paid-gated; this prevents silent drift).
- The backup-metadata read (GET /stacks/:name/backup) now requires a paid
  licence, matching the rollback flow that is the only caller.
- Manual rollback dispatches a success/failure notification, alongside the
  existing audit-log entry.

Adds route integration tests (lock acquisition/release, tier 403, notifications,
no-backup 404), filesystem tests for the faithful restore (orphan removal,
variant switch, abort path, non-managed files preserved), a community-tier
scheduler test, and a developer-mode logging matrix. Documents the restore
semantics and reconciles the scheduled-update wording in the feature guide.

* fix(atomic-deploy): assert restore target stays within the compose dir before unlink

The orphan-removal step in restoreStackFiles joins the stack directory with a
managed filename and unlinks it. The stack directory is already validated and
contained by resolveStackDir (allowlist stack name + within-base assertion), but
the containment guard was not reapplied to the joined target at the delete sink,
so static analysis flagged the path as derived from user input. Reassert
containment on the final path before unlinking, matching the barrier the other
write/read helpers in this service already apply. No behavior change for valid
stacks; defense-in-depth at the sink.

* fix(atomic-deploy): inline the path-containment barrier at the restore unlink sink

The wrapped within-base assertion was not recognized as a sanitizer by the
static path-injection analysis, which still traced the stack name to the unlink
sink. Replace it with the inline path.resolve + startsWith containment check the
other write helpers in this service already use (the recognized barrier), kept
in the same scope as the sink. Behavior is unchanged for valid stack names.

* fix(atomic-deploy): clear stale managed files from the backup slot before writing

The backup directory is reused across runs and was only ever added to, never
cleared. A managed file removed from the stack since the last backup (e.g. a
deleted .env or a switched compose variant) lingered in the slot, so a later
rollback restored a file that did not exist immediately before the failed run,
contradicting the faithful-revert guarantee. Clear the protected file set from
the slot before copying the current files, with the same inline containment
barrier the restore path uses. A clear failure is logged, not fatal, since it
only risks a stale future rollback and should not block a valid deploy.
2026-05-29 00:12:39 -04:00
..