Files
sencho/backend/src/services/updateGuard/readiness.ts
T
Anso 6688da97b1 fix(image-updates): match any local RepoDigest against the remote tag (#1695)
* fix(image-updates): match any local RepoDigest against the remote tag

Docker can list a stale multi-arch index digest ahead of the current one
on the same image. Selecting only the first RepoDigest caused false
same-tag rebuilds (for example redis:8.8.0) even when another digest
equaled the registry primary. Compare every matching candidate and keep
fail-closed behavior for empty, unknown-platform, and classification errors.

Fixes #1684

* fix(image-updates): surface digest verification failures to operators

Carry comparator errors into update-preview as check_error / verification_failed,
prefer failed checks over sticky has_update in Fleet and the sidebar, and keep
Update Guard from claiming no pending update when verification failed.

* fix(e2e): align sidebar truncation spec with check-failed precedence

StackRow now shows the check-failed icon over a stale update dot, but
this spec still asserted the old precedence and failed deterministically
in CI on every attempt.

* fix(fleet): treat verification-only previews as non-actionable

Fresh update-preview wins over sticky fleet booleans: disable Apply, exclude
from ready counts, and move verification-only stacks into the check-failures
advisory (including remote-labeled names).

* fix(fleet): move preview actionability helpers out of the view

Exporting non-components from AutoUpdateReadinessView tripped react-refresh
lint in CI. Keep the helpers in a shared lib module and drop an unused mock arg.

* fix(fleet): drop sticky cards when fresh preview clears the update

A successful no-update preview now removes the pending Fleet card instead of
leaving Apply enabled. Verification-only stacks still go to the advisory, and
empty-state copy no longer claims all-clear while checks remain unresolved.

* fix(image-updates): hold full-stack apply for review when another image fails verification

A confirmed update or rebuild on one image previously left the whole stack
fully actionable even when a different image in the same stack failed digest
verification: Anatomy claimed "safe to apply", Update Guard reported ready,
and Fleet's full-stack Apply stayed enabled, all while showing the
verification-failure text right next to those claims.

isActionableUpdatePreview now requires no verification failure anywhere in
the stack; a new isReviewRequiredUpdatePreview flags the mixed state so Fleet
still surfaces the card (not silently cleared) with Apply now disabled and a
"Review · unverified" badge. Anatomy's banner says "review required" instead
of a bump-based safety claim and withholds its Apply button. Update Guard's
pending-update signal downgrades from ok to attention. Per-service apply
(Fleet's per-image row) is deliberately left enabled since a service-scoped
update to the confirmed image does not touch the unverified one.

* fix(image-updates): treat rebuild_available symmetrically with has_update in Update Guard

updatePreviewSignal only downgraded to 'attention' inside the has_update
branch, so a rebuild-only stack (has_update false, rebuild_available true)
with a sibling verification failure fell through to the plain
verification-only 'unknown' branch and never mentioned the pending rebuild,
inconsistent with isReviewRequiredUpdatePreview on the frontend which treats
has_update and rebuild_available the same way.

Also adds desktop-card coverage for the mixed state (previously only the
mobile card was exercised) and locks in blocked/major-bump precedence over
the new review-required badge/banner in both Fleet and Anatomy.

* fix(image-updates): derive the mixed-verification review-hold from per-image detail, not the stack aggregate

has_update and check_error are independent per image: a tag-based update can
be confirmed via the registry's tag list even when that same image's own
digest comparison against the current tag errored (already covered by an
existing update-preview-service test). The stack-level verification_failed
and has_update flags can therefore both be true for the SAME single image,
which the previous review-hold treated identically to a genuinely different
image failing verification: Update Guard said "another image failed digest
verification" and Fleet told the user to "apply the confirmed service
individually" on a single-service stack where no such affordance exists.

isReviewRequiredUpdatePreview (and isActionableUpdatePreview) now walk the
preview's images to require a pure failure image (check_error, no has_update
of its own) alongside a genuinely different confirmed image or rebuild,
falling back to the old aggregate-only judgment when per-image detail is
unavailable. StackAnatomyPanel now imports the shared helper instead of
hand-rolling the same predicate, so Fleet and Anatomy cannot drift apart.
Backend updatePreviewSignal gets the same per-image treatment via a new
optional images parameter, threaded through from UpdateGuardService.

* fix(image-updates): fail closed on platform-unavailable indexes and legacy previews, allow anonymous tag listing

Four independent gaps from the same QA pass, all in the digest/tag
verification path this PR introduced or touches:

- compareLocalToRemoteTag now distinguishes a remote index with no descriptor
  at all for the local platform (including an empty or fully-filtered index)
  from a genuine mismatch: the former returns an error instead of reporting a
  speculative update. A node cannot pull a platform the index does not offer.
- selectLocalRepoDigests no longer falls back to a sole unrelated-repository
  RepoDigest when nothing matches the configured repo; comparing against a
  registry state that has nothing to do with the declared image risks a
  false update. Returns unresolved instead of guessing.
- isClearedUpdatePreview no longer treats a preview with verification_failed
  missing entirely (not merely false) as proof the stack is clean. The
  current backend always includes this field, so its absence identifies an
  older remote node's response, which cannot vouch for a clean result the
  way an explicit false can.
- listRegistryTags (and the underlying listRegistryTagsResult) no longer
  short-circuits to an empty list whenever no registry credentials are
  configured. getAuthToken already resolves anonymous tokens for public
  repositories; skipping it meant tag-based update detection silently never
  fired for any public image without a stored credential.

* fix(image-updates): correct platform-check overreach, add cache and advisory for prior fixes

Addresses code-review findings on the previous commit:

- The platform-unavailable check fired too eagerly: an index whose runnable
  descriptors legally omit platform (OCI-permitted, routed to exactDigests)
  has real pullable content, so it must not be confused with a genuinely
  empty or fully-filtered index. Now only errors when both platform-labeled
  descriptors and exactDigests are empty.
- listRegistryTags is now cached (15 min TTL): anonymous listing has no other
  rate limiting, and Fleet fans this out across every image on every reload.
- A legacy preview (kept rather than cleared) now also pushes a check-failure
  advisory entry explaining why, instead of rendering as an unexplained
  pending card.
- Corrected docstrings that described the old sole-unmatched-digest fallback
  and inverted how anonymous registry auth actually resolves.

Adds coverage for: nested-index and attestation-only-filtered platform
unavailability, a platform-less-but-populated index staying a match, the
unrelated-repo digest rejection wired through the real preview-computation
path (not just the registry-api unit), and legacy-preview interaction with
an actionable has_update:true.

* fix(image-updates): fail closed on mixed platform indexes, stop caching tag-list failures

Addresses a second review round on the previous commit, including an
empirically-verified regression:

- The exactDigests fallback was unconditional: an index mixing a
  platform-labeled descriptor for a DIFFERENT platform with an unlabeled leaf
  let that leaf stand in as this platform's content, reporting a speculative
  update for a genuinely incompatible platform. Now an unlabeled leaf is only
  trusted when it is the ONLY kind of descriptor in the index (nothing else
  claims a different platform); a mixed index errors instead.
- listRegistryTags was caching failed lookups for the full 15-minute TTL
  (a 429, an unreachable registry, or credentials not yet configured all
  looked identical to a real empty tag list). The fetcher now throws on
  failure so only a success is ever cached; CacheService's existing
  stale-on-error fallback still serves the last good list when one exists.
- The manual "Recheck" action now also drops the tag-list cache, so a newly
  published tag is visible immediately instead of waiting out the TTL.
- The legacy-preview advisory no longer fires when the same preview is
  already actionable on its own terms (a remote's own confirmed
  has_update/rebuild_available): pairing a "could not be checked" banner
  with an enabled Apply button next to it contradicted itself.
- Corrected docstrings and a self-contradictory inline comment left over
  from the prior fix.

New coverage: the exact mixed-index regression this round found and fixed,
cache hit/no-repeat-fetch and failure-not-cached behavior, and the
legacy-preview-plus-already-actionable non-contradiction.

* fix(image-updates): add digest_error unmasked field, reorder RiskBadge, fix actionability gates

Add digest_error to UpdatePreviewImage as an always-populated field that
is independent of check_status masking: a confirmed tag-based update on
the same image resolves check_status to 'ok' and nulls check_error, but
digest_error stays set since the image's current tag content was never
verified. Switch hasUnverifiedOtherImage to read digest_error.

Reorder RiskBadge precedence so reviewRequired is checked before
uncertain (both derive from check_error, so uncertain was unreachable).

Fix self-contradiction in test fixtures (digest_update:true +
check_error). Add masked-tag regression test with two-image fixture.
Simplify hasUnverifiedOtherImage per code review feedback.
2026-07-25 21:57:30 -04:00

409 lines
22 KiB
TypeScript

import type { PreflightStatus } from '../preflight/types';
import type { UpdatePreviewImage, UpdatePreviewSummary } from '../UpdatePreviewService';
import type {
ContainerProbe,
ReadinessSignal,
ReadinessVerdict,
RollbackOverall,
RollbackReadinessItem,
SignalStatus,
} from './types';
/**
* Pure readiness scoring. UpdateGuardService gathers the inputs (each of which
* degrades independently to the 'error' sentinel) and these functions map them
* to signals and a verdict, so every grading rule is synchronously testable.
*/
/** Sentinel for an input whose collection failed. */
export type Errored = 'error';
const formatAge = (timestamp: number, now: number): string => {
const minutes = Math.max(0, Math.round((now - timestamp) / 60_000));
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 48) return `${hours}h ago`;
return `${Math.round(hours / 24)}d ago`;
};
export function preflightSignal(
input: { activeStatus: PreflightStatus } | Errored,
): ReadinessSignal {
const base = { id: 'preflight' as const, title: 'Compose Doctor' };
if (input === 'error') {
return { ...base, status: 'unknown', affectsVerdict: false, detail: 'The stored preflight report could not be read.' };
}
switch (input.activeStatus) {
case 'never-run':
return { ...base, status: 'unknown', affectsVerdict: false, detail: 'Compose Doctor has not been run for this stack yet. Run it for a deeper pre-update check.' };
case 'blocker':
return { ...base, status: 'blocked', affectsVerdict: true, detail: 'The last preflight found a blocker. Resolve it before updating.' };
case 'unrenderable':
return { ...base, status: 'attention', affectsVerdict: true, detail: 'The compose file did not render in the last preflight; the update is likely to fail the same way.' };
case 'high':
return { ...base, status: 'attention', affectsVerdict: true, detail: 'The last preflight found high-risk findings. Review them before updating.' };
case 'warning':
return { ...base, status: 'warning', affectsVerdict: true, detail: 'The last preflight found warnings.' };
case 'pass':
case 'info':
return { ...base, status: 'ok', affectsVerdict: true, detail: 'The last preflight passed.' };
}
}
export function driftSignal(input: number | Errored): ReadinessSignal {
const base = { id: 'drift' as const, title: 'Drift' };
if (input === 'error') {
return { ...base, status: 'unknown', affectsVerdict: false, detail: 'Drift findings could not be read.' };
}
if (input > 0) {
const plural = input === 1 ? 'finding' : 'findings';
return {
...base,
status: 'warning',
affectsVerdict: true,
detail: `${input} open drift ${plural}: the running state has diverged from the compose file, so the rollback target may not match what is running.`,
};
}
return { ...base, status: 'ok', affectsVerdict: true, detail: 'No open drift findings.' };
}
/** A container already unhealthy, restarting, or crash-exited before any update runs. */
export function isTroubledContainer(c: ContainerProbe): boolean {
return c.health === 'unhealthy' || c.state === 'restarting' || (c.state === 'exited' && (c.exitCode ?? 0) !== 0);
}
export function containersSignal(input: ContainerProbe[] | Errored): ReadinessSignal {
const base = { id: 'containers' as const, title: 'Current containers' };
if (input === 'error') {
return { ...base, status: 'unknown', affectsVerdict: true, detail: 'Container state could not be read from Docker.' };
}
if (input.length === 0) {
return { ...base, status: 'warning', affectsVerdict: true, detail: 'The stack is not running; updating will start it.' };
}
const troubled = input.filter(isTroubledContainer);
if (troubled.length > 0) {
const names = troubled.map(c => c.name).join(', ');
return {
...base,
status: 'attention',
affectsVerdict: true,
detail: `Already unhealthy before the update: ${names}. An update on top of a failing stack is hard to evaluate; consider fixing or stopping it first.`,
};
}
return { ...base, status: 'ok', affectsVerdict: true, detail: `${input.length} container${input.length === 1 ? '' : 's'} running normally.` };
}
export function healthchecksSignal(input: ContainerProbe[] | Errored): ReadinessSignal {
const base = { id: 'healthchecks' as const, title: 'Healthcheck coverage', status: 'ok' as SignalStatus, affectsVerdict: false };
if (input === 'error' || input.length === 0) {
return { ...base, detail: 'Coverage is unknown until the stack runs. Containers without healthchecks are verified by run state only after an update.' };
}
const withCheck = input.filter(c => c.hasHealthcheck).length;
const withoutRestart = input.filter(c => !c.restartPolicy || c.restartPolicy === 'no').length;
const parts = [
`${withCheck} of ${input.length} container${input.length === 1 ? '' : 's'} define a healthcheck; the rest are verified by run state only after an update.`,
];
if (withoutRestart > 0) {
parts.push(`${withoutRestart} ha${withoutRestart === 1 ? 's' : 've'} no restart policy.`);
}
return { ...base, detail: parts.join(' ') };
}
/**
* True when a full-stack apply would pull/recreate an image whose digest
* verification failed as collateral of applying a DIFFERENT image's confirmed
* update or a local rebuild. `has_update` and `check_error` are independent
* per image (a tag-based update can be confirmed via the registry's tag list
* even when that same image's own digest comparison errored), so this
* excludes the case where the only verification failure belongs to the very
* image whose update is confirmed: that image's own stale-digest check does
* not block moving it to a newer tag. Falls back to the aggregate summary
* flags when per-image detail is unavailable.
*/
function hasUnverifiedOtherImage(summary: UpdatePreviewSummary, images: UpdatePreviewImage[] | undefined): boolean {
if (!images || images.length === 0) {
return summary.verification_failed && (summary.has_update || summary.rebuild_available);
}
const hasPureFailureImage = images.some((i) => Boolean(i.check_error) && !i.has_update);
if (!hasPureFailureImage) return false;
return images.some((i) => i.has_update) || summary.rebuild_available;
}
export function updatePreviewSignal(input: UpdatePreviewSummary | Errored, images?: UpdatePreviewImage[]): ReadinessSignal {
const base = { id: 'update_preview' as const, title: 'Pending update' };
if (input === 'error') {
return { ...base, status: 'unknown', affectsVerdict: false, detail: 'The update preview is unavailable.' };
}
if (input.blocked) {
return {
...base,
status: 'blocked',
affectsVerdict: true,
detail: input.blocked_reason ?? 'A scan policy blocks this update.',
};
}
if (input.has_update && input.semver_bump === 'major') {
const change = input.current_tag && input.next_tag ? ` (${input.current_tag} to ${input.next_tag})` : '';
return { ...base, status: 'attention', affectsVerdict: true, detail: `A major version bump is pending${change}. Review the upstream changelog for breaking changes.` };
}
if (input.has_update && input.semver_bump === 'unknown') {
return { ...base, status: 'warning', affectsVerdict: true, detail: 'An image update is pending but the version change could not be classified.' };
}
// A DIFFERENT image in the stack failing digest verification holds any
// pending action (a tag/digest update or a local-build rebuild) for review,
// since a full-stack apply would pull/recreate the unverified image as
// collateral. has_update and rebuild_available are handled symmetrically
// here to match isReviewRequiredUpdatePreview on the frontend.
const reviewRequired = hasUnverifiedOtherImage(input, images);
if (input.has_update) {
const kind = input.update_kind === 'digest' ? 'a same-tag image refresh' : `a ${input.semver_bump} update`;
const buildNote = input.has_build_services
? ' Local build services will also be rebuilt from source.'
: '';
if (reviewRequired) {
const verifyNote = input.verification_error ? `: ${input.verification_error}` : '.';
return {
...base,
status: 'attention',
affectsVerdict: true,
detail: `Pending: ${kind}.${buildNote} Another image failed digest verification${verifyNote} Review before a full-stack update.`,
};
}
return { ...base, status: 'ok', affectsVerdict: true, detail: `Pending: ${kind}.${buildNote}` };
}
if (input.rebuild_available) {
const n = input.has_build_services ? 'Local build service(s)' : 'Build';
const rebuildNote = `${n} require a rebuild from source; the update rebuilds images and recreates containers.`;
if (reviewRequired) {
const verifyNote = input.verification_error ? `: ${input.verification_error}` : '.';
return {
...base,
status: 'attention',
affectsVerdict: true,
detail: `${rebuildNote} Another image failed digest verification${verifyNote} Review before a full-stack update.`,
};
}
return { ...base, status: 'warning', affectsVerdict: true, detail: rebuildNote };
}
if (input.verification_failed) {
return {
...base,
status: 'unknown',
affectsVerdict: true,
detail: input.verification_error
? `Digest verification failed: ${input.verification_error}`
: 'Digest verification failed; Sencho is not claiming a rebuild.',
};
}
return { ...base, status: 'ok', affectsVerdict: true, detail: 'No pending image update detected; the update re-pulls and recreates with current tags.' };
}
export function buildServicesSignal(buildServices: string[] | Errored): ReadinessSignal {
const base = { id: 'build_services' as const, title: 'Local build services', affectsVerdict: false };
if (buildServices === 'error') {
return { ...base, status: 'unknown', detail: 'Build services could not be detected from the compose model.' };
}
if (buildServices.length === 0) {
return { ...base, status: 'ok', detail: 'No services declare a local build; the update pulls registry images only.' };
}
const plural = buildServices.length === 1 ? 'service' : 'services';
const names = buildServices.join(', ');
return {
...base,
status: 'warning',
detail: `${buildServices.length} ${plural} (${names}) rebuild from source. This may take longer and depends on the local Dockerfile context, network access, and base-image availability.`,
};
}
/** Model-membership facts for the selected service, gathered from EffectiveServiceModel. */
export type ServiceMembership =
| { found: false }
| { found: true; hasBuild: boolean; declaredImage: string | null; expectedReplicas: number };
/** Matches `ServiceUpdateRecoveryService`'s digest-pin check (a recovery snapshot is never eligible for a digest-pinned declared ref). */
const DIGEST_PIN_RE = /@sha256:[a-f0-9]{64}$/i;
/**
* Selected-service signal for a service-scoped readiness check: model
* membership and whether the update would leave a rollback recovery snapshot
* behind. Render failure or a missing service fails closed (blocked), never
* unknown, so a service-scoped check cannot silently proceed against a stale
* or absent model.
*/
export function serviceSignal(input: ServiceMembership | Errored): ReadinessSignal {
const base = { id: 'service' as const, title: 'Selected service' };
if (input === 'error') {
return { ...base, status: 'blocked', affectsVerdict: true, detail: 'The compose model could not be rendered, so the selected service cannot be validated. Resolve the compose error before updating.' };
}
if (!input.found) {
return { ...base, status: 'blocked', affectsVerdict: true, detail: 'The selected service is not declared in this stack\u2019s compose model.' };
}
if (!input.hasBuild && !input.declaredImage) {
return { ...base, status: 'blocked', affectsVerdict: true, detail: 'The selected service has neither an image nor a build, so it cannot be updated.' };
}
const digestPinned = input.declaredImage !== null && DIGEST_PIN_RE.test(input.declaredImage);
if (!input.declaredImage || digestPinned) {
const reason = !input.declaredImage ? 'it builds from source with no declared image' : 'its declared image is pinned to a digest';
return {
...base,
status: 'warning',
affectsVerdict: true,
detail: `Declared with ${input.expectedReplicas} expected replica${input.expectedReplicas === 1 ? '' : 's'}. No rollback recovery snapshot will be available for this update because ${reason}.`,
};
}
return {
...base,
status: 'ok',
affectsVerdict: true,
detail: `Declared with ${input.expectedReplicas} expected replica${input.expectedReplicas === 1 ? '' : 's'}; a rollback recovery snapshot will be captured before the update.`,
};
}
export function backupSlotSignal(
input: { exists: boolean; timestamp: number | null } | Errored,
now: number,
): ReadinessSignal {
const base = { id: 'backup_slot' as const, title: 'Rollback backup' };
if (input === 'error') {
return { ...base, status: 'unknown', affectsVerdict: false, detail: 'The backup slot could not be read.' };
}
if (!input.exists) {
return { ...base, status: 'warning', affectsVerdict: true, detail: 'No rollback backup exists yet; one is created automatically when the update starts.' };
}
const age = input.timestamp ? ` (from ${formatAge(input.timestamp, now)})` : '';
return { ...base, status: 'ok', affectsVerdict: true, detail: `A compose and env file backup exists${age} and is refreshed when the update starts.` };
}
export function diskSignal(
input: { usePercent: number; limitPercent: number } | null | Errored,
): ReadinessSignal {
const base = { id: 'disk' as const, title: 'Node disk' };
if (input === 'error' || input === null) {
return { ...base, status: 'unknown', affectsVerdict: false, detail: 'Disk usage could not be read.' };
}
const use = Math.round(input.usePercent);
if (input.usePercent >= input.limitPercent) {
return { ...base, status: 'attention', affectsVerdict: true, detail: `Disk usage is at ${use}%, at or above the ${input.limitPercent}% alert threshold. Image pulls may fail; free space first.` };
}
if (input.usePercent >= input.limitPercent - 5) {
return { ...base, status: 'warning', affectsVerdict: true, detail: `Disk usage is at ${use}%, close to the ${input.limitPercent}% alert threshold.` };
}
return { ...base, status: 'ok', affectsVerdict: true, detail: `Disk usage is at ${use}%.` };
}
/**
* Severity precedence: blocked > attention (review required) > verdict-affecting
* unknown > warning > ready. Informational unknowns never affect the verdict.
*/
export function aggregateVerdict(signals: ReadinessSignal[]): ReadinessVerdict {
const affecting = signals.filter(s => s.affectsVerdict);
if (affecting.some(s => s.status === 'blocked')) return 'blocked';
if (affecting.some(s => s.status === 'attention')) return 'review_required';
if (affecting.some(s => s.status === 'unknown')) return 'unknown';
if (affecting.some(s => s.status === 'warning')) return 'ready_with_warnings';
return 'ready';
}
// ── Rollback readiness ───────────────────────────────────────────────────────
export interface RollbackInputs {
backup: { exists: boolean; timestamp: number | null } | Errored;
envSummary: { exists: boolean; envPresent: boolean; keys: string[] } | Errored;
/** Whether the stack currently has an env file (distinguishes "no env to cover"). */
stackHasEnv: boolean | Errored;
/**
* UpdatePreview.rollback_target wrapped in an object so the Errored sentinel
* cannot be absorbed into the string domain (an image literally named
* "error" must not read as a failed preview). `moving` is true when any image
* in the stack uses a moving tag (latest, a branch, an unpinned major/minor),
* in which case restoring files does not revert the image because the local
* tag already resolves to the newer digest.
*/
rollbackTarget: { target: string | null; moving: boolean } | Errored;
/** Timestamp of the most recent deploy_success activity event, if any. */
lastDeployAt: number | null | Errored;
containers: ContainerProbe[] | Errored;
}
export function buildRollbackItems(inputs: RollbackInputs, now: number): RollbackReadinessItem[] {
const items: RollbackReadinessItem[] = [];
const backupExists = inputs.backup !== 'error' && inputs.backup.exists;
if (inputs.backup === 'error') {
items.push({ id: 'compose_source', state: 'unknown', label: 'Previous compose file', detail: 'The backup slot could not be read.' });
} else if (backupExists) {
const age = inputs.backup.timestamp ? ` from ${formatAge(inputs.backup.timestamp, now)}` : '';
items.push({ id: 'compose_source', state: 'ready', label: 'Previous compose file', detail: `A backup${age} is available to restore.` });
} else {
items.push({ id: 'compose_source', state: 'missing', label: 'Previous compose file', detail: 'No backup exists yet. One is created automatically by the next update or deploy.' });
}
if (inputs.envSummary === 'error') {
items.push({ id: 'env_keys', state: 'unknown', label: 'Previous env file', detail: 'The backed-up env file could not be read.' });
} else if (inputs.envSummary.envPresent) {
const n = inputs.envSummary.keys.length;
items.push({ id: 'env_keys', state: 'ready', label: 'Previous env file', detail: `${n} variable name${n === 1 ? '' : 's'} captured in the backup (values are restored with the file, never shown here).` });
} else if (inputs.stackHasEnv === true && backupExists) {
items.push({ id: 'env_keys', state: 'missing', label: 'Previous env file', detail: 'The stack has an env file but the backup does not contain one; a rollback would not restore env changes.' });
} else if (!backupExists) {
items.push({ id: 'env_keys', state: 'missing', label: 'Previous env file', detail: 'No backup exists yet.' });
} else {
items.push({ id: 'env_keys', state: 'ready', label: 'Previous env file', detail: 'The stack uses no env file, so there is nothing to restore.' });
}
if (inputs.rollbackTarget === 'error') {
items.push({ id: 'previous_images', state: 'unknown', label: 'Previous image tag', detail: 'The update preview is unavailable.' });
} else if (inputs.rollbackTarget.target && inputs.rollbackTarget.moving) {
items.push({ id: 'previous_images', state: 'ready', label: 'Previous image tag', detail: `Rollback target ${inputs.rollbackTarget.target}. This stack uses a moving image tag. Full-stack updates capture the running image ID before pull/build and retain it through the recovery window so an immediate restore can retarget the exact prior image, not only the tag string.` });
} else if (inputs.rollbackTarget.target) {
items.push({ id: 'previous_images', state: 'ready', label: 'Previous image tag', detail: `Known rollback target: ${inputs.rollbackTarget.target}. The compose file pins an immutable tag, so restoring files also restores the image.` });
} else {
items.push({ id: 'previous_images', state: 'unknown', label: 'Previous image tag', detail: 'The previous image tag could not be determined. When no prior image is recorded, a restore may fall back to compose and env files alone; a moving tag can still resolve to a newer digest.' });
}
if (inputs.lastDeployAt === 'error') {
items.push({ id: 'last_deploy', state: 'unknown', label: 'Last successful deploy', detail: 'The activity history could not be read.' });
} else if (inputs.lastDeployAt) {
items.push({ id: 'last_deploy', state: 'ready', label: 'Last successful deploy', detail: `Recorded ${formatAge(inputs.lastDeployAt, now)}; the backup reflects a configuration that deployed successfully.` });
} else {
items.push({ id: 'last_deploy', state: 'missing', label: 'Last successful deploy', detail: 'No successful deploy is recorded in the recent activity history.' });
}
if (inputs.containers === 'error') {
items.push({ id: 'healthchecks', state: 'unknown', label: 'Healthchecks', detail: 'Container state could not be read from Docker.' });
} else if (inputs.containers.some(c => c.hasHealthcheck)) {
items.push({ id: 'healthchecks', state: 'ready', label: 'Healthchecks', detail: 'At least one service defines a healthcheck, so a rollback can be verified beyond run state.' });
} else {
items.push({ id: 'healthchecks', state: 'missing', label: 'Healthchecks', detail: 'No service defines a healthcheck; rollback verification relies on run state only.' });
}
const mounts = inputs.containers === 'error'
? []
: [...new Set(inputs.containers.flatMap(c => c.mounts))];
const mountDetail = mounts.length > 0 ? ` This stack mounts: ${mounts.join(', ')}.` : '';
items.push({
id: 'volume_data',
state: 'not_covered',
label: 'Application data',
detail: `Named volumes and bind-mounted data are not included in file backups. Rolling back restores compose and env files only; application data keeps its current state.${mountDetail}`,
});
return items;
}
/**
* compose_source gates not_ready; ready additionally requires env coverage and
* a known previous image tag. volume_data, healthchecks, and last_deploy are
* disclosures and never gate the overall state.
*/
export function aggregateRollbackOverall(items: RollbackReadinessItem[]): RollbackOverall {
const byId = new Map(items.map(i => [i.id, i.state]));
if (byId.get('compose_source') !== 'ready') {
return byId.get('compose_source') === 'unknown' ? 'partial' : 'not_ready';
}
if (byId.get('env_keys') === 'ready' && byId.get('previous_images') === 'ready') {
return 'ready';
}
return 'partial';
}