feat: add service-scoped Compose update and restore (#1648)

* feat: add service-scoped Compose update and restore

Allow updating or rebuilding one declared Compose service on multi-service
stacks without recreating siblings, with recovery snapshots, health-gate
observation, and prune holds for rollback images. Full-stack update paths
and single-service UX stay unchanged.

* fix: sanitize service-scoped update log messages for CodeQL

* fix: address service-scoped update audit findings B-01 through B-07

* fix: complete service-scoped update audit metadata and surfaces

* test: wrap Updates readiness tests for deploy-feedback context

* fix: keep service recovery reachable without Deploy Progress

Make failed service-gate recovery discoverable when Deploy Progress is
disabled or dismissed, suppress stale image-scan notification side
effects, normalize ComposeService line endings, and add focused
regression coverage.

* fix: resurface ContainersHealth density and expand on multi-service stacks

Service grouping hid the summary strip and Compact/Detailed/Expand controls that still applied to multi-container stacks.
This commit is contained in:
Anso
2026-07-19 02:42:29 -04:00
committed by GitHub
parent 31d4e4669b
commit 63213c0960
89 changed files with 7608 additions and 331 deletions
+1 -1
View File
@@ -338,7 +338,7 @@ The entire host threshold evaluation can be silenced per node from **Settings ·
### Image update availability
`info`/`image_update_available`: `Stack "<name>" has image updates available.` By default, checks every two hours in interval mode, with a two-minute startup delay. The cadence can be changed or replaced with a cron schedule. Manual refresh carries a two-minute cooldown. Notifies on **state transition only**; the first run also emits catch-up notifications for stacks already known to have updates.
`info`/`image_update_available`: `Stack "<name>" has image updates available.` On multi-service stacks the message can name the services that have updates. By default, checks every two hours in interval mode, with a two-minute startup delay. The cadence can be changed or replaced with a cron schedule. Manual refresh carries a two-minute cooldown. Notifies on **state transition only**; the first run also emits catch-up notifications for stacks already known to have updates.
### Auto-update execution
+2
View File
@@ -71,6 +71,8 @@ Once the cause is resolved, the next check (on the interval, or via **Recheck**)
2. Skim the card grid. The badge tells you the risk at a glance: `Safe · patch` is green, `Review · minor` is amber, `Blocked · major` is red, and a digest-only rebuild on a non-semver tag shows the gray `Digest rebuild` badge.
3. For a safe update, click **Apply now** on the card to pull and recreate the stack immediately.
4. For a major bump, review the changelog preview and the upstream release notes. **Apply now** is disabled on the readiness board for blocked cards; to apply a major bump after review, use the stack's lifecycle **Update** action (right-click the stack in the sidebar, or open the kebab menu and choose **Update**, or click **Deploy** in the stack editor).
On multi-service stacks, the Updates view can also apply a single service when that service has a confirmed image update. Scheduled auto-update, webhook pull, and bulk update always refresh the full stack.
5. Use **Recheck** in the hero to force an immediate registry poll across every reachable node. A 2-minute per-node cooldown applies, and the toast tells you how many nodes were triggered, rate-limited, or failed.
## Risk badges
+1 -1
View File
@@ -62,7 +62,7 @@ A mono table of every stack discovered in the active node's `COMPOSE_DIR`, sorte
| Column | Description |
|--------|-------------|
| **Status dot** | Green when the stack is running and its 10-minute peak CPU is under 80%, amber when peak CPU is at or above 80%, rose when any container has exited or peak CPU is at or above 90% |
| **STACK** | Stack name with an orange "Update available" badge when a newer image has been detected. The badge appears regardless of the sidebar indicator setting. |
| **STACK** | Stack name with an orange update badge when a newer image has been detected. When per-service status is known, the badge names the outdated service (or a count for several). The badge appears regardless of the sidebar indicator setting. |
| **HOST** | Active node this stack belongs to |
| **UP** | How long the oldest running container has been up, in compact units (`s` / `m` / `h` / `d`); a stopped or never-started stack reads `--` |
| **CPU** | Latest aggregate CPU across the stack's containers |
+6
View File
@@ -34,6 +34,10 @@ Admins also see whether a [fleet snapshot](/features/fleet-backups) covers this
Every verdict keeps the **Update now** button enabled. The dialog informs the decision; it does not make it for you.
On stacks with more than one Compose service, you can also open readiness for a single service from that service's header. The dialog still checks stack-wide guardrails (Compose Doctor, disk, backup, Docker), but container health, pending change, and recovery signals focus on the selected service. Sibling and dependency issues stay advisory. The toolbar **Update** action and the Updates view's stack **Apply** always update the full stack.
When the selected service is build-backed and has no registry update, the proceed button reads **Rebuild** instead of **Update**. A per-service update badge appears only when a registry update is confirmed for that service.
<Frame>
<img src="/images/health-gated-updates/readiness-dialog.png" alt="Update readiness dialog showing the verdict chip, signal rows for Compose Doctor, Drift, Current containers, Healthcheck coverage, Pending update, Rollback backup and Node disk, the fleet snapshot row with its checkbox, and the Cancel and Update now buttons" />
</Frame>
@@ -50,6 +54,8 @@ During the window, the gate checks every container that came up with the stack:
A clear failure ends the observation immediately. Passing requires the full window, and a healthcheck still in its start period when the window ends records the verdict as unknown rather than claiming success.
After a **service-scoped** update or restore, the gate still watches the whole project, but it attributes failures: a problem on the updated service is a primary failure; a previously healthy sibling that regresses during the window is a collateral failure. Siblings that were already unhealthy before the update do not fail the gate on their own.
The deploy progress modal treats the gate verdict as the real result: while the gate observes, the status reads **Verifying health** rather than claiming success, and the auto-close waits. A passed gate shows the success state; a failed or unknown gate becomes the headline result with its reason. Closing the modal never stops the observation; the gate runs server-side and its result appears on the stack timeline either way, as **health gate passed** or **health gate failed** events alongside the **update started** marker.
The live verifying and recovery view is part of the deploy progress panel. If you have turned that panel off, the in-browser gate view does not appear, but the gate still runs on the node and records its verdict on the stack timeline.
+1
View File
@@ -79,6 +79,7 @@ Every Sencho release ships with a static list of capabilities. The current list
| `compose-doctor` | Compose Doctor preflight |
| `compose-networking` | Stack Networking tab and node Networking overview |
| `guided-external-network-preflight` | Guided missing-external-network check before deploy |
| `service-scoped-update` | Per-service update, rebuild, and restore on multi-service stacks |
<Note>
`vulnerability-scanning` is advertised only when the Trivy binary is present on the node. If a node does not have Trivy installed, it omits this capability and the scanning UI is replaced by a lock card.
+1 -1
View File
@@ -68,7 +68,7 @@ Each row gives you everything you need to read the stack at a glance, in a fixed
- **Stack name** in mono type, truncated with an ellipsis when the row gets tight.
- **Label dots** to the right of the name. Up to three colored dots represent the stack's labels. If a stack carries more than three labels, a **+N** counter appears for the extras.
- **Trailing indicator** in its own fixed-width slot at the far right of the name area, showing the highest-priority signal present:
- Pulsing orange dot: an image update is available. Hover for the "Update available" tooltip.
- Pulsing orange dot: an image update is available. Hover for the update tooltip; when per-service status is known, the tooltip names the outdated service(s).
- Muted circle-alert icon: the last registry check failed (registry unreachable, missing credentials, or rate-limited). Hover to read the failure reason.
- Git branch icon: a Git source update is pending and no image update is available.
- **Hover kebab** on the right edge. On desktop, hover the row to reveal a vertical three-dot menu that opens the same actions as right-clicking. On touch viewports the kebab is always visible.
+9 -1
View File
@@ -220,7 +220,15 @@ Each row includes:
- **Meta line.** Uptime (`up 12 hours`) and the primary port mapping (`8989 → 8989/tcp`).
- **Open link.** When the container publishes a port, the mapping itself is a link (`8989 → 8989/tcp ↗`) that opens the service in a new tab, with a **Copy URL** button beside it. The address uses the active node's host and switches to `https` for port 443. Recognised multi-port apps open their web path automatically (for example, Plex opens `/web`).
- **Live stat tiles.** Three tiles show CPU, memory, and network I/O with a rolling sparkline. The sparkline uses the cyan data color and refreshes roughly every 1.5 seconds.
- **Action icons.** The image source links button (see above), plus shortcuts to **View logs**, open a bash shell, and the per-service kebab.
- **Action icons.** The image source links button (see above), plus shortcuts to **View logs**, open a bash shell, and (on single-service stacks) the per-container Start / Stop / Restart kebab.
### Multi-service stacks
When a stack declares more than one Compose service, each service gets its own header above its container cards. That header owns **Update service** or **Rebuild service** (when eligible), the update badge when a registry update is confirmed for that service, and **Start / Stop / Restart** for every replica of the service. Child container cards keep logs, shell, ports, and metrics only.
Build-backed services without a registry update show **Rebuild** wording and no update badge. The editor toolbar **Update** action still updates the entire stack.
Single-service stacks keep the existing flat container layout; service headers do not appear.
## Logs viewer
+270 -2
View File
@@ -1772,10 +1772,21 @@ paths:
now, from the stored preflight result, open drift findings, live
container health, the pending image change, the rollback backup slot,
and node disk headroom. Advisory only: no verdict blocks an update.
Requires `stack:read` permission.
Requires `stack:read` permission. Pass `service` to scope the verdict
to one Compose service on a multi-service stack.
parameters:
- $ref: "#/components/parameters/stackName"
- $ref: "#/components/parameters/nodeId"
- name: service
in: query
required: false
schema:
type: string
description: |
When set, readiness is scoped to this Compose service (multi-service
stacks only). Stack-wide guardrails still apply; sibling and
dependency signals become advisory. Single-service stacks reject
this parameter with 400.
responses:
"200":
description: Readiness report.
@@ -1810,6 +1821,12 @@ paths:
type: string
affectsVerdict:
type: boolean
"400":
description: Invalid service scope.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
@@ -1817,6 +1834,238 @@ paths:
"500":
$ref: "#/components/responses/InternalError"
/api/stacks/{stackName}/effective-services:
get:
operationId: getStackEffectiveServices
tags: [Stacks]
summary: List effective Compose services
description: |
Returns the fail-closed effective service model for the stack (declared
image, build presence, expected replicas, depends_on, healthcheck).
Requires `stack:read` permission.
parameters:
- $ref: "#/components/parameters/stackName"
- $ref: "#/components/parameters/nodeId"
responses:
"200":
description: Effective model result.
content:
application/json:
schema:
type: object
required: [renderable]
properties:
renderable:
type: boolean
services:
type: array
items:
type: object
required: [name, declaredImage, hasBuild, expectedReplicas, dependsOn, hasHealthcheck]
properties:
name:
type: string
declaredImage:
type: string
nullable: true
hasBuild:
type: boolean
expectedReplicas:
type: integer
dependsOn:
type: array
items:
type: string
hasHealthcheck:
type: boolean
code:
type: string
error:
type: string
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"500":
$ref: "#/components/responses/InternalError"
/api/stacks/{stackName}/services/{serviceName}/update:
post:
operationId: updateStackService
tags: [Stacks]
summary: Update or rebuild one Compose service
description: |
Manually pulls or builds one declared service, then recreates only that
service with `--no-deps --force-recreate`. Multi-service stacks only.
Requires `stack:deploy`. Automation paths continue to update the full stack.
parameters:
- $ref: "#/components/parameters/stackName"
- name: serviceName
in: path
required: true
schema:
type: string
- $ref: "#/components/parameters/nodeId"
responses:
"200":
description: Service update completed.
content:
application/json:
schema:
type: object
required: [serviceName, healthGateId, observing, recoveryAvailable]
properties:
serviceName:
type: string
healthGateId:
type: string
nullable: true
observing:
type: boolean
recoveryId:
type: string
nullable: true
recoveryAvailable:
type: boolean
recheckWarning:
type: string
"400":
description: Service update rejected (single-service stack, missing service, or unavailable capability).
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"403":
$ref: "#/components/responses/Forbidden"
"500":
description: Service update failed.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/api/stacks/{stackName}/services/{serviceName}/recovery:
get:
operationId: getStackServiceRecovery
tags: [Stacks]
summary: Latest active service recovery snapshot
description: |
Returns the newest active, unexpired recovery row for one service, or
`recovery: null` when none is available. Used so Restore stays
discoverable when Deploy Progress is disabled or dismissed.
Requires `stack:deploy`.
parameters:
- $ref: "#/components/parameters/stackName"
- name: serviceName
in: path
required: true
schema:
type: string
- $ref: "#/components/parameters/nodeId"
responses:
"200":
description: Recovery lookup completed.
content:
application/json:
schema:
type: object
required: [recovery]
properties:
recovery:
oneOf:
- type: "null"
- type: object
required: [id, status, expiresAt, createdAt]
properties:
id:
type: string
status:
type: string
healthGateId:
type: string
nullable: true
expiresAt:
type: integer
createdAt:
type: integer
majorityImageId:
type: string
declaredImageRef:
type: string
"400":
description: Capability unavailable.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
/api/stacks/{stackName}/services/{serviceName}/restore:
post:
operationId: restoreStackService
tags: [Stacks]
summary: Restore one Compose service from a recovery snapshot
description: |
Retags the captured majority image onto the declared tag and recreates
only that service. Body must include `recoveryId` from a prior service
update. Requires `stack:deploy`.
parameters:
- $ref: "#/components/parameters/stackName"
- name: serviceName
in: path
required: true
schema:
type: string
- $ref: "#/components/parameters/nodeId"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [recoveryId]
properties:
recoveryId:
type: string
responses:
"200":
description: Service restore completed.
content:
application/json:
schema:
type: object
required: [serviceName, healthGateId, observing, recoveryAvailable]
properties:
serviceName:
type: string
healthGateId:
type: string
nullable: true
observing:
type: boolean
recoveryId:
type: string
nullable: true
recoveryAvailable:
type: boolean
"400":
description: Restore rejected (missing recoveryId or invalid binding).
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"403":
$ref: "#/components/responses/Forbidden"
"500":
description: Service restore failed.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/api/stacks/{stackName}/rollback-readiness:
get:
operationId: getStackRollbackReadiness
@@ -1908,7 +2157,7 @@ paths:
trigger:
type: string
nullable: true
enum: [update, deploy, null]
enum: [update, deploy, service_update, service_restore, null]
reason:
type: string
nullable: true
@@ -1937,6 +2186,25 @@ paths:
nullable: true
restarts:
type: integer
service:
type: string
nullable: true
role:
type: string
nullable: true
enum: [primary, collateral, null]
targetScope:
type: string
enum: [stack, service]
description: Defaults to stack for legacy runs.
serviceName:
type: string
nullable: true
failureSource:
type: string
nullable: true
enum: [primary, collateral, null]
description: Set on failed service-scoped runs; null for stack and successful runs.
"401":
$ref: "#/components/responses/Unauthorized"
"403":