fix: reconcile sticky update indicators with Anatomy preview (#1698)

* fix: reconcile sticky update indicators with Anatomy preview

Sidebar, Updates filter, and Fleet treated retained partial/failed
scanner has_update as confirmed. Keep raw state for retention/notifications,
project confirmed-only to APIs, show distinct incomplete indicators, and
clear sticky rows only after an authoritative-negative preview.

Closes #1685

* test: align sidebar truncate E2E with failed-over-retained precedence

Purple update indicators are confirmed-only; hasUpdate with a failed
check correctly shows the failed trailing icon.

* fix: clear confirmed update rows on authoritative-negative preview

Address audit SF-1/SF-2/SF-3: observation-watermark clears for older
ok+has_update rows (DB + memory gens), Fleet checkability parity with
backend not_checkable, and Updates chip confirmed-only regressions.

* fix: tombstone equal-generation writers on preview clear

Advance the per-stack write generation when clearing at the observation
watermark so a scanner reserved before preview cannot recreate the row
after an authoritative-negative reconcile.

* fix: clear sticky updates with digest and tag preview parity

Share detection across scanner and preview, keep GET read-only with POST reconcile, gate Apply to digest and rebuild updates, and invalidate the hub fleet cache on clear.

* test: set digestUpdate on auto-update checkImage mocks

Scheduler and execute routes now gate Compose on digest drift; fixtures that expect an apply need digestUpdate so they exercise the update path.

* fix: clear unused lint errors on sticky update branch

Drop unused partial helper and fleet invalidate import; keep the CacheService inflight self-ref as let with an eslint exception so tsc stays green.

* fix: use inflight holder for CacheService prefer-const

Keep generation-aware ownership without a let self-reference that fights ESLint and tsc.
This commit is contained in:
Anso
2026-07-25 15:42:19 -04:00
committed by GitHub
parent 8b5407fcff
commit 0daddfde00
43 changed files with 2529 additions and 390 deletions
+5 -3
View File
@@ -68,8 +68,8 @@ Once the cause is resolved, the next check (on the interval, or via **Recheck**)
## Workflow
1. Open **Update** from the top nav strip.
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.
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, a digest-only rebuild on a non-semver tag shows the gray `Digest rebuild` badge, `Check uncertain` appears when the last preview check was incomplete or failed, and `Newer tag · edit Compose` marks a higher tag that requires editing the Compose pin (Compose pull cannot rewrite pins).
3. For a safe, Compose-actionable update (digest drift or local rebuild), click **Apply now** on the card to pull and recreate the stack immediately. **Apply now** stays disabled for tag-only advisories and for uncertain checks.
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.
@@ -85,6 +85,8 @@ On multi-service stacks, the Updates view can also apply a single service when t
| `Review · minor` | Amber (AlertTriangle icon) | Minor semver bump (e.g. `1.2.3` to `1.3.0`) |
| `Blocked · major` | Red (ShieldAlert icon) | Major semver bump (e.g. `1.2.3` to `2.0.0`). **Apply now** is disabled; the card surfaces the reason "Major version jumps require human review before applying." |
| `Digest rebuild` | Gray | Non-semver tag (e.g. `main`, `stable`) with an updated digest (tag name unchanged; content changed; see [Tags vs digests](/features/vulnerability-scanning#tags-vs-digests)) |
| `Check uncertain` | Amber | Preview check incomplete or failed; **Apply now** stays disabled until a full successful check |
| `Newer tag · edit Compose` | Amber | A higher tag exists than the Compose pin; edit Compose (do not use **Apply now**) |
A separate inline `Rebuild available` label replaces the version diff when only the digest changed (same tag, new image). The risk badge on those cards still reflects the underlying semver classification reported by the registry.
@@ -133,7 +135,7 @@ For each stack with a pending image update, Sencho computes a preview by:
1. Parsing the compose file to enumerate every service that pulls a registry image (a service declaring only `build:`, with no `image:` key, has no registry reference to check and is excluded from this preview).
2. Calling the registry with your configured credentials to fetch the current tag list and remote digest.
3. Picking the highest semver tag greater than the current tag (keeping the same prefix and suffix). If the highest available tag matches the current one but the remote digest has changed, the card surfaces as a `Rebuild available` update. Sencho checks both a newer semver tag and a digest change behind the same tag; see [Tags vs digests](/features/vulnerability-scanning#tags-vs-digests).
3. Picking the highest semver tag greater than the current tag (keeping the same prefix and suffix). If a higher tag exists, the card surfaces as a tag-only advisory (`Newer tag · edit Compose`); scheduled auto-update and **Apply now** do not rewrite Compose pins. If the highest available tag matches the current one but the remote digest has changed, the card surfaces as a Compose-actionable `Rebuild available` / digest update. Sencho checks both a newer semver tag and a digest change behind the same tag; see [Tags vs digests](/features/vulnerability-scanning#tags-vs-digests).
4. Scoring the overall stack by the most severe image bump. Any major bump marks the stack as blocked.
5. Normalizing Docker Hub library paths so credentials and changelog lookups resolve correctly.
+11 -4
View File
@@ -144,8 +144,9 @@ Each row leads with a two-character status indicator that summarizes the stack's
Additional indicators appear to the right of the stack name:
- A pulsing fuchsia dot flags that an image update is available.
- A muted alert icon replaces the dot when the update check ran but failed to reach the registry or errored.
- A pulsing fuchsia dot flags that an image update is available and the latest check completed successfully. That signal covers Compose-actionable digest drift (same tag, new registry content) as well as a newer tag that requires editing the Compose pin.
- A muted warning icon appears when the last check was incomplete. When an update was also detected or previously recorded, the tooltip notes that the full stack could not be verified. When no update is recorded, the tooltip notes that update status could not be fully verified. Incomplete results are not counted under **Updates**.
- A muted alert icon appears when the update check failed (and, when an earlier update was recorded, notes that the previous status was retained).
- A branch icon signals that the Git source's upstream branch has moved ahead of the working copy.
### Search and filter chips
@@ -159,7 +160,7 @@ The search box above the list filters stacks by name. The chip row below it filt
- **All**: every stack discovered in `COMPOSE_DIR`.
- **Up**: stacks where every container is running.
- **Down**: stacks with at least one stopped or exited container.
- **Updates**: stacks with a pending image update.
- **Updates**: stacks with a confirmed pending image update (latest check completed successfully). Incomplete or failed checks are excluded from this chip.
Each chip carries a live count. Click **Hide filters** in the top-right of the row to collapse the chips when you want a denser list. Search and chips combine: filtering by **Updates** and typing a few characters returns the intersection.
@@ -281,7 +282,13 @@ Each row maps one compose concept to the value it resolves to right now:
A footer card under the rows surfaces the first published port as a clickable **EXPOSED** link, so you can jump straight to the running app.
If an image update is available, or the stack declares one or more services with a local `build:` section, an inline banner appears below the rows. Registry updates name each image with a pending update and show its version transition (for example `27.1.4 -> 27.1.5`); the detail line below reads `patch · safe to apply` (green), `minor · review recommended` (amber), or `major · breaking changes possible` (rose), followed by an **apply** button. Build-only stacks show **Rebuild available** instead of a version bump, with a **Rebuild & Update** button. A stack that mixes registry images and local builds still shows a single banner, with the rebuild note folded into the same detail line. Major bumps use the rose styling and are worth reviewing before applying.
If an image update is available, or the stack declares one or more services with a local `build:` section, an inline banner appears below the rows.
- **Digest rebuild / rebuild**: same-tag registry content changed, or a local `build:` service needs rebuilding. The banner shows the version context and an **apply** (or **Rebuild & Update**) button when the check completed successfully.
- **Newer tag**: a higher semver tag exists than the pin in Compose. The detail line reads `newer tag · edit Compose pin`. There is no **apply** button; Compose pull does not rewrite image pins. Edit the Compose file (or use your usual pin-change workflow), then deploy.
- **Incomplete or failed check** with no confirmed update: a separate amber banner explains that status is uncertain until a full check succeeds.
For actionable registry updates, the detail line also reads `patch · safe to apply` (green), `minor · review recommended` (amber), or `major · breaking changes possible` (rose). Build-only stacks show **Rebuild available** instead of a version bump, with a **Rebuild & Update** button. A stack that mixes registry images and local builds still shows a single banner, with the rebuild note folded into the same detail line. Major bumps use the rose styling and are worth reviewing before applying.
Rebuilds can take longer than a registry pull and depend on the local Dockerfile context, network access, and base-image availability. Atomic rollback restores compose and env files only; previously built image layers are not rolled back automatically.
+125
View File
@@ -149,6 +149,69 @@ components:
type: boolean
example: true
UpdatePreviewImage:
type: object
required:
[service, image, current_tag, next_tag, has_update, digest_update, tag_update, semver_bump, check_status]
properties:
service: { type: string }
image: { type: string }
current_tag: { type: string }
next_tag: { type: ["string", "null"] }
has_update: { type: boolean }
digest_update:
type: boolean
description: Same-tag registry content drift; Compose pull can apply without changing the pin.
tag_update:
type: boolean
description: A higher pinned semver exists; advisory until Compose is edited.
semver_bump:
type: string
enum: [none, unknown, patch, minor, major]
check_status:
type: string
enum: [ok, partial, failed, not_checkable]
UpdatePreviewSummary:
type: object
required:
[has_update, primary_image, current_tag, next_tag, semver_bump, update_kind, blocked, blocked_reason, has_build_services, rebuild_available, check_status]
properties:
has_update: { type: boolean }
primary_image: { type: ["string", "null"] }
current_tag: { type: ["string", "null"] }
next_tag: { type: ["string", "null"] }
semver_bump:
type: string
enum: [none, unknown, patch, minor, major]
update_kind:
type: string
enum: [tag, digest, none]
blocked: { type: boolean }
blocked_reason: { type: ["string", "null"] }
has_build_services: { type: boolean }
rebuild_available: { type: boolean }
check_status:
type: string
enum: [ok, partial, failed]
UpdatePreview:
type: object
required: [stack_name, images, build_services, summary, rollback_target, changelog]
properties:
stack_name: { type: string }
images:
type: array
items:
$ref: "#/components/schemas/UpdatePreviewImage"
build_services:
type: array
items: { type: string }
summary:
$ref: "#/components/schemas/UpdatePreviewSummary"
rollback_target: { type: ["string", "null"] }
changelog: { type: ["string", "null"] }
LabelSource:
type: string
description: Provenance of a label. `unknown` when a container or image could not be inspected.
@@ -1788,6 +1851,68 @@ paths:
"500":
$ref: "#/components/responses/InternalError"
/api/stacks/{stackName}/update-preview:
get:
operationId: getStackUpdatePreview
tags: [Stacks]
summary: Compute image update preview (read-only)
description: |
Computes the current registry update preview for the stack without
mutating persisted scanner state. Use POST when the client should
reconcile sticky update indicators after an authoritative-negative
result. Requires `stack:read` permission.
parameters:
- $ref: "#/components/parameters/stackName"
- $ref: "#/components/parameters/nodeId"
responses:
"200":
description: Update preview.
content:
application/json:
schema:
$ref: "#/components/schemas/UpdatePreview"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"500":
$ref: "#/components/responses/InternalError"
post:
operationId: reconcileStackUpdatePreview
tags: [Stacks]
summary: Compute update preview and reconcile sticky state
description: |
Computes the same preview as GET. When every image reports
`check_status: ok` and `has_update` is false (mixed `ok` +
`not_checkable` does not clear), clears sticky confirmed update rows
for the stack and sets `reconciled: true`. Requires `stack:read`
permission.
parameters:
- $ref: "#/components/parameters/stackName"
- $ref: "#/components/parameters/nodeId"
responses:
"200":
description: Update preview, with reconcile outcome.
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/UpdatePreview"
- type: object
required: [reconciled]
properties:
reconciled:
type: boolean
description: |
True when sticky update rows were cleared after an
authoritative-negative preview.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"500":
$ref: "#/components/responses/InternalError"
/api/stacks/{stackName}/update-readiness:
get:
operationId: getStackUpdateReadiness