* feat(recovery): make rollback-recovery image lifecycle visible and controllable
GitHub discussion #1751 asked why Sencho creates sencho-rb/<id>/<service>:hold
images during automatic updates and how to clean them up. That surfaced a real
safety bug alongside the missing visibility: the manual single-image delete
route did not consult the held-image predicate every other deletion path
already honors, so a user could delete a rollback-protected image straight
through the Images tab and silently break automatic recovery for that update.
A short/truncated id also bypassed the predicate's full-id lookup.
Fixes:
- POST /images/delete now resolves the submitted id to its canonical form and
checks the unified held-image predicate before deleting, returning 409
IMAGE_HELD_FOR_ROLLBACK for a protected image.
- The Images tab no longer mislabels a protected image as plain "Unused"; a
fully-synthetic hold image is kept out of the generic inventory entirely and
surfaced instead in a new Resources -> Rollback tab, with an additive
"Rollback protected" badge for images that still carry a normal tag too.
New capability:
- Two settings (Deploy Guardrails): superseded-generation retention (days,
replaces a hardcoded 7) and a cap on retained generations per stack.
- A new Resources -> Rollback tab lists every generation (stack, short id,
state, retention) with an admin-gated manual release action, including
releasing the current generation with an explicit warning that automatic
rollback becomes unavailable until the next successful update. Release is
a single atomic, server-revalidated transition so a stale UI read can never
release a row that has since become ineligible.
Also consolidated three near-duplicate implementations of the held-image
predicate (two of which relied on a require() of a sibling .ts file that
silently failed to resolve under the test runner and was never actually
exercised by a real test before this change) into one shared module.
Known follow-up, not fixed here: an orphaned sencho-rb tag whose recovery row
no longer exists (DB restore, node re-add) is invisible in both the Images
and Rollback tabs with no UI path to reclaim it.
* fix(audit): add summary mapping for rollback generation release
* fix(security): sanitize prune target in log sinks and cover release RBAC
Closes two open js/log-injection findings on the system prune route by
applying the same inline sanitizeForLog barrier the rest of the file
already uses. The prune target is validated against an enum by
parsePruneTargets before reaching these sinks, so the findings were false
positives, but the barrier is cheap and removes the standing alerts on a
file this change already touches. Also wraps the generation id in the
release log line for consistency with the stack name beside it.
Adds coverage for gaps a QA pass identified:
- Release endpoint refuses a viewer and a deployer (Admin-only), leaving
the generation and its artifacts untouched.
- Viewer can still read the generations list, matching the sibling
Resources routes.
- The predicate the prune routes build reports full-stack rollback holds,
not just service-scoped ones, and re-reads per call so a hold taken
between plan and delete still gates the delete.
- After releasing the current generation, no rollback point is claimed
for the stack through any consumer of the current-generation lookup.
* fix: keep running containers until stack pull/build succeeds
Acquire images before reconcile, capture a recovery generation for
compensation, and only remove classified orphans after handoff.
* fix: address recovery audit blockers for safe stack updates
Retire abandoned and expired recovery artifacts, probe compensated
runtimes before reporting rollback success, preserve local Docker when
deleting a node, validate the exact Compose invocation before capture,
and repair updateStack return-contract fixtures.
* fix: resolve ESLint errors blocking CI on this branch
Unused-import and unused-variable errors left over from the stack
deletion refactor: MeshService in stacks.ts (its opt-out cascade moved
into DeployedStackDeletionService), a redundant pruneVolumes
destructure in deleteDeployedStack (the real one is re-derived from
the same input object inside runDeletionBody), and an unused beforeAll
import in a Docker-integration test stub. Also scopes the webhook
pull-action case body in a block to satisfy no-case-declarations;
purely syntactic, no behavior change.
* fix: harden recovery probe, cleanup retry, and failed-pull Docker test
Reject absent or unhealthy expected replicas before reporting rollback
success, keep cleanup records until artifacts are actually removed, fail
closed when a mesh override cannot be generated, and assert a real
failed pull leaves the original container running.
* fix: verify recovery probe image identity and stack-scoped override paths
Reject recovered runtimes that use the wrong image or leave scale-zero
services running, and confine tombstone override deletion to the intent
stack directory so forged cross-stack paths cannot be swept.
* test: batch notification cap fixtures in a SQLite transaction
Unbatched 1200-row inserts were timing out at the default 30s under
CI load even though the same assertions pass in under 2s when green.