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
+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":