diff --git a/cli/src/commands/upgrade.ts b/cli/src/commands/upgrade.ts index 670e9cf7..acf62fa1 100644 --- a/cli/src/commands/upgrade.ts +++ b/cli/src/commands/upgrade.ts @@ -24,6 +24,10 @@ import { releaseImagePlan, compareVersions, fetchLatestReleaseTag, + fetchRecentReleases, + releasesBetween, + fetchTagMessage, + ghcrManifestDigests, } from "../lib/release.js"; const NS = "kars-system"; @@ -140,6 +144,24 @@ Examples: } stepper.done(`Connected — kars release at revision ${karsRel.revision}`); + // ── Pre-flight: cluster must be able to run the upgrade ──────── + // Fail fast on a degraded/stopped cluster (e.g. all nodes NotReady) + // BEFORE the 5-minute image import + 8-minute Helm wait that would only + // time out and roll back. This is the failsafe: no work is done until we + // know the cluster can actually schedule the new pods. + if (!options.rollback) { + const health = await assertClusterUpgradeable(execa); + if (!health.ok) { + stepper.stop(); + console.error(chalk.red(`\n ✗ Cluster is not in a state to upgrade — no changes made.`)); + console.error(chalk.red(` ${health.reason}\n`)); + for (const hint of health.hints) console.error(chalk.dim(` ${hint}`)); + console.error(); + process.exit(1); + } + for (const hint of health.hints) stepper.detail("info", hint); + } + // ── Rollback path ───────────────────────────────────────────── if (options.rollback) { stepper.step("Rolling back to the previous Helm revision..."); @@ -190,6 +212,8 @@ Examples: const images = releaseImagePlan(target, { includeRuntimes: !options.skipRuntimeImages }); if (options.dryRun) { stepper.stop(); + await printChangelog(current, target); + await printImpactTable(execa); section("Upgrade plan (dry-run — no changes made)"); kvLine("Cluster", ctx.aksCluster); kvLine("ACR", ctx.acrLoginServer); @@ -203,6 +227,30 @@ Examples: process.exit(0); } + // ── Changelog summary + confirmation ───────────────────────── + // Show what's about to change, then confirm before any write. The + // dry-run above already exited; this only runs for a real upgrade. + stepper.stop(); + await printChangelog(current, target); + await printImpactTable(execa); + + const interactive = !options.yes && process.stdin.isTTY === true; + if (interactive) { + const { default: inquirer } = await import("inquirer"); + const { proceed } = await inquirer.prompt([{ + type: "confirm", + name: "proceed", + message: `Upgrade ${ctx.aksCluster} from ${current || "unknown"} to ${target}?`, + default: true, + }]); + if (!proceed) { + console.log(chalk.dim("\n Upgrade cancelled — no changes made.\n")); + process.exit(0); + } + } else { + console.log(chalk.dim(` Non-interactive — proceeding with upgrade to ${target}.\n`)); + } + // ── Step 3: Import target release images into ACR ───────────── stepper.step(`Importing ${target} images into ${acrName}...`); let requiredFailures = 0; @@ -275,10 +323,170 @@ Examples: type Execa = typeof import("execa").execa; -/** Determine the deployed kars release. Prefers the `karsRelease` value stamped - * into Helm by a prior `kars upgrade` (reliable), falling back to the chart's - * static appVersion (only accurate right after a same-version install). */ +/** Read the cluster and print a table of every kars workload the upgrade would + * restart (controller + sandboxes), with namespace, readiness, and the running + * image — the blast radius, shown before the confirm. Best-effort: a read + * failure prints a note rather than aborting. */ +async function printImpactTable(execa: Execa): Promise { + section("Impact — workloads that will be restarted"); + + interface Row { component: string; namespace: string; name: string; ready: string; image: string } + const rows: Row[] = []; + + const shortImage = (img: string): string => { + if (!img) return "—"; + // ".../openclaw-sandbox:latest" → "openclaw-sandbox:latest"; strip digest. + const noDigest = img.split("@")[0]; + const parts = noDigest.split("/"); + return parts[parts.length - 1] || noDigest; + }; + + const readyOf = (d: { status?: { readyReplicas?: number; replicas?: number }; spec?: { replicas?: number } }): string => { + const ready = d.status?.readyReplicas ?? 0; + const desired = d.spec?.replicas ?? d.status?.replicas ?? 0; + return `${ready}/${desired}`; + }; + + interface DeployJson { + metadata?: { name?: string; namespace?: string }; + spec?: { replicas?: number; template?: { spec?: { containers?: Array<{ name?: string; image?: string }> } } }; + status?: { readyReplicas?: number; replicas?: number }; + } + const firstImage = (d: DeployJson, prefer?: string): string => { + const cs = d.spec?.template?.spec?.containers ?? []; + const pick = prefer ? cs.find((c) => c.name?.includes(prefer)) : undefined; + return shortImage((pick ?? cs[0])?.image ?? ""); + }; + + try { + // Controller. + const { stdout: ctrlJson } = await execa("kubectl", [ + "get", "deployment", "kars-controller", "-n", NS, "-o", "json", + ], { stdio: "pipe" }).catch(() => ({ stdout: "" })); + if (ctrlJson.trim()) { + const d = JSON.parse(ctrlJson) as DeployJson; + rows.push({ component: "controller", namespace: NS, name: "kars-controller", ready: readyOf(d), image: firstImage(d, "controller") }); + } + + // Sandboxes across all namespaces (the inference-router rides inside these). + const { stdout: sbJson } = await execa("kubectl", [ + "get", "deployment", "-A", "-l", "kars.azure.com/component=sandbox", "-o", "json", + ], { stdio: "pipe" }).catch(() => ({ stdout: "" })); + if (sbJson.trim()) { + const list = JSON.parse(sbJson) as { items?: DeployJson[] }; + for (const d of list.items ?? []) { + rows.push({ + component: "sandbox", + namespace: d.metadata?.namespace ?? "?", + name: d.metadata?.name ?? "?", + ready: readyOf(d), + image: firstImage(d, "openclaw"), + }); + } + } + } catch { + console.log(chalk.dim("\n (could not read cluster workloads — continuing)\n")); + return; + } + + if (rows.length === 0) { + console.log(chalk.dim("\n (no kars workloads found)\n")); + return; + } + + // Render a simple aligned table. + const headers = { component: "TYPE", namespace: "NAMESPACE", name: "NAME", ready: "READY", image: "IMAGE" }; + const w = { + component: Math.max(headers.component.length, ...rows.map((r) => r.component.length)), + namespace: Math.max(headers.namespace.length, ...rows.map((r) => r.namespace.length)), + name: Math.max(headers.name.length, ...rows.map((r) => r.name.length)), + ready: Math.max(headers.ready.length, ...rows.map((r) => r.ready.length)), + image: Math.max(headers.image.length, ...rows.map((r) => r.image.length)), + }; + const pad = (s: string, n: number) => s.padEnd(n); + console.log(); + console.log( + " " + chalk.dim( + `${pad(headers.component, w.component)} ${pad(headers.namespace, w.namespace)} ${pad(headers.name, w.name)} ${pad(headers.ready, w.ready)} ${headers.image}`, + ), + ); + for (const r of rows) { + const notReady = (() => { + const [a, b] = r.ready.split("/").map((n) => parseInt(n, 10)); + return !(b > 0 && a === b); + })(); + const readyCell = notReady ? chalk.yellow(pad(r.ready, w.ready)) : chalk.green(pad(r.ready, w.ready)); + console.log( + ` ${pad(r.component, w.component)} ${pad(r.namespace, w.namespace)} ${pad(r.name, w.name)} ${readyCell} ${chalk.dim(r.image)}`, + ); + } + const sandboxCount = rows.filter((r) => r.component === "sandbox").length; + console.log(chalk.dim(`\n ${rows.length} workload(s) will be rolling-restarted (1 controller + ${sandboxCount} sandbox(es)).`)); + console.log(chalk.dim(` Each sandbox restarts its agent pod; in-flight agent work is interrupted briefly.\n`)); +} + +/** Print a concise changelog of the releases between current and target. */ +async function printChangelog(current: string, target: string): Promise { + section("What's changing"); + kvLine("From", current || "unknown"); + kvLine("To", target); + + const releases = await fetchRecentReleases(20); + const between = current + ? releasesBetween(releases, current, target) + : releases.filter((r) => compareVersions(r.tag, target) <= 0).slice(0, 1); + if (between.length === 0) { + console.log(chalk.dim(`\n (no release notes found between ${current || "?"} and ${target})\n`)); + return; + } + console.log(); + // Newest first reads best in a terminal. Prefer the annotated tag message + // (real changelog) over the auto-generated release body (boilerplate). + for (const r of [...between].reverse()) { + const tagMsg = await fetchTagMessage(r.tag); + console.log(` ${chalk.bold(r.tag)}${r.name && r.name !== r.tag ? chalk.dim(` — ${r.name}`) : ""}`); + for (const line of summarizeChangelog(tagMsg || r.body)) { + console.log(chalk.dim(` ${line}`)); + } + } + console.log(); +} + +/** Pull human-meaningful lines (bullets, or the first prose lines) from an + * annotated tag message or release body, skipping install/verification + * boilerplate and the leading "kars vX.Y.Z" title line. */ +function summarizeChangelog(text: string, maxLines = 8): string[] { + const lines = text.split("\n").map((l) => l.trim()); + const bullets: string[] = []; + const prose: string[] = []; + for (const l of lines) { + if (!l) continue; + if (/^#+\s*(container images|runtime adapter|verification|integrity|install)/i.test(l)) break; + if (l.startsWith("```")) continue; + if (/^kars v\d/i.test(l)) continue; // title line + if (/^[-*]\s+/.test(l)) { + bullets.push("• " + l.replace(/^[-*]\s+/, "").slice(0, 100)); + } else if (/^#+\s+/.test(l)) { + bullets.push(l.replace(/^#+\s+/, "").slice(0, 100)); + } else { + prose.push(l.slice(0, 100)); + } + if (bullets.length >= maxLines) { bullets.push("…"); break; } + } + // Prefer bullets; if none, fall back to the first couple of prose lines. + if (bullets.length > 0) return bullets; + return prose.slice(0, 3); +} + +/** Determine the deployed kars release, most-reliable signal first: + * 1. the `karsRelease` value stamped into Helm by a prior `kars upgrade`; + * 2. **image-digest match** — the controller's running image digest matched + * against published release digests (works even for clusters deployed before + * the stamp existed, since `az acr import` preserves content-addressed + * digests). This is what makes "Current:" accurate on an old cluster; + * 3. the chart's static appVersion (last resort; often `v0.1.0`). */ async function detectCurrentVersion(execa: Execa, appVersion?: string): Promise { + // 1. Stamped Helm value (set by a prior `kars upgrade`). const { stdout } = await execa("helm", [ "get", "values", "kars", "-n", NS, "-o", "json", ], { stdio: "pipe" }).catch(() => ({ stdout: "" })); @@ -286,9 +494,44 @@ async function detectCurrentVersion(execa: Execa, appVersion?: string): Promise< const vals = JSON.parse(stdout || "{}") as { karsRelease?: string }; if (vals.karsRelease) return vals.karsRelease; } catch { /* ignore */ } + + // 2. Match the running controller image digest against published releases. + const byDigest = await detectVersionByImageDigest(execa).catch(() => undefined); + if (byDigest) return byDigest; + + // 3. Static chart appVersion. return appVersion ? `v${appVersion.replace(/^v/, "")}` : ""; } +/** Resolve the deployed version by matching the controller pod's running image + * digest to the digests of recent published `kars-controller` release tags. */ +async function detectVersionByImageDigest(execa: Execa): Promise { + // Scan all kars-controller container statuses for a running image digest + // (`imageID` is like `…/kars-controller@sha256:`). Skips Pending pods + // (empty imageID) and tolerates rollouts with multiple replicas. + const { stdout: ids } = await execa("kubectl", [ + "get", "pods", "-n", NS, "-l", "app.kubernetes.io/name=kars", + "-o", "jsonpath={range .items[*]}{range .status.containerStatuses[*]}{.image}{\"|\"}{.imageID}{\"\\n\"}{end}{end}", + ], { stdio: "pipe" }).catch(() => ({ stdout: "" })); + + // Prefer the controller container's digest; accept any kars-controller image. + let runningDigest: string | undefined; + for (const line of ids.split("\n")) { + if (!line.includes("kars-controller")) continue; + const m = line.match(/@(sha256:[a-f0-9]{64})/); + if (m) { runningDigest = m[1]; break; } + } + if (!runningDigest) return undefined; + + // Compare against recent release tags (newest first → report the newest match). + const releases = await fetchRecentReleases(20); + for (const r of releases) { + const digests = await ghcrManifestDigests("azure/kars-controller", r.tag); + if (digests.has(runningDigest)) return r.tag; + } + return undefined; +} + /** `az acr import --force` one image. Returns true on success. */ async function acrImport(execa: Execa, acrName: string, src: string, target: string): Promise { return execa("az", [ @@ -315,3 +558,42 @@ async function verifyHealth(execa: Execa): Promise { ], { stdio: "pipe" }).catch(() => ({ stdout: "" })); return ctrl.trim() === "True"; } + +/** Pre-flight: can this cluster actually accept an upgrade right now? The upgrade + * reimports images and runs `helm upgrade --wait`, which needs schedulable, + * Ready nodes. A stopped/degraded cluster (all nodes NotReady — e.g. an AKS + * cluster whose VMSS was deallocated, or a broken CNI) would burn ~13 minutes + * and then time out + roll back. Detect it up front. */ +async function assertClusterUpgradeable( + execa: Execa, +): Promise<{ ok: boolean; reason: string; hints: string[] }> { + const { stdout } = await execa("kubectl", [ + "get", "nodes", + "-o", "jsonpath={range .items[*]}{.metadata.name}{\"|\"}{range .status.conditions[?(@.type=='Ready')]}{.status}{end}{\"\\n\"}{end}", + ], { stdio: "pipe" }).catch(() => ({ stdout: "" })); + + const lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean); + if (lines.length === 0) { + // Couldn't read nodes — don't hard-block on an unexpected API shape, but the + // later `helm --wait` still guards correctness. + return { ok: true, reason: "", hints: [] }; + } + const total = lines.length; + const ready = lines.filter((l) => l.endsWith("|True")).length; + + if (ready === 0) { + return { + ok: false, + reason: `All ${total} cluster node(s) are NotReady — the upgrade can't schedule new pods and would time out.`, + hints: [ + "Check node health: kubectl get nodes", + "If the AKS cluster is stopped, start it: az aks start -g -n ", + "If nodes are stuck (CNI/kubelet), check: kubectl describe nodes", + "Re-run `kars upgrade` once nodes are Ready.", + ], + }; + } + // Some-but-not-all Ready is allowed (the upgrade can still proceed), but worth + // surfacing — the controller wants 2 replicas and `helm --wait` needs them. + return { ok: true, reason: "", hints: ready < total ? [`Note: ${ready}/${total} nodes Ready.`] : [] }; +} diff --git a/cli/src/lib/release.test.ts b/cli/src/lib/release.test.ts index f41b107b..07cc0827 100644 --- a/cli/src/lib/release.test.ts +++ b/cli/src/lib/release.test.ts @@ -6,6 +6,8 @@ import { parseVersionTag, compareVersions, releaseImagePlan, + releasesBetween, + type ReleaseNote, } from "./release.js"; import { buildHelmUpgradeArgs } from "../commands/upgrade.js"; @@ -85,3 +87,27 @@ describe("buildHelmUpgradeArgs", () => { expect(args.join(" ")).not.toContain("inferenceRouter.azure.openai.endpoint"); }); }); + +describe("releasesBetween", () => { + const rels: ReleaseNote[] = [ + { tag: "v0.1.18", name: "v0.1.18", body: "" }, + { tag: "v0.1.17", name: "v0.1.17", body: "" }, + { tag: "v0.1.16", name: "v0.1.16", body: "" }, + { tag: "v0.1.15", name: "v0.1.15", body: "" }, + { tag: "v0.1.14", name: "v0.1.14", body: "" }, + ]; + it("returns releases newer than current up to target, oldest→newest", () => { + expect(releasesBetween(rels, "v0.1.15", "v0.1.18").map((r) => r.tag)) + .toEqual(["v0.1.16", "v0.1.17", "v0.1.18"]); + }); + it("excludes the current version and anything above target", () => { + const got = releasesBetween(rels, "v0.1.16", "v0.1.17").map((r) => r.tag); + expect(got).toEqual(["v0.1.17"]); + expect(got).not.toContain("v0.1.16"); + expect(got).not.toContain("v0.1.18"); + }); + it("with no known current, includes everything up to target", () => { + expect(releasesBetween(rels, "", "v0.1.16").map((r) => r.tag)) + .toEqual(["v0.1.14", "v0.1.15", "v0.1.16"]); + }); +}); diff --git a/cli/src/lib/release.ts b/cli/src/lib/release.ts index 0243c1b2..c2bc7136 100644 --- a/cli/src/lib/release.ts +++ b/cli/src/lib/release.ts @@ -128,3 +128,133 @@ export async function fetchLatestReleaseTag( return null; } } + +export interface ReleaseNote { + tag: string; + name: string; + /** Raw release body (markdown). */ + body: string; +} + +/** + * Fetch recent published releases (newest first). Used for current-version + * digest matching and for the changelog summary. Never throws. + */ +export async function fetchRecentReleases( + limit = 20, + fetchImpl: typeof fetch = fetch, +): Promise { + try { + const res = await fetchImpl( + `https://api.github.com/repos/Azure/kars/releases?per_page=${limit}`, + { headers: { Accept: "application/vnd.github+json", "User-Agent": "kars-cli" } }, + ); + if (!res.ok) return []; + const body = (await res.json()) as Array<{ tag_name?: string; name?: string; body?: string }>; + return body + .filter((r) => r.tag_name) + .map((r) => ({ tag: r.tag_name as string, name: r.name || (r.tag_name as string), body: r.body || "" })); + } catch { + return []; + } +} + +/** + * The set of releases strictly newer than `current` and up to (and including) + * `target`, oldest→newest — i.e. exactly what an upgrade would apply. Used for + * the changelog summary. + */ +export function releasesBetween( + releases: ReleaseNote[], + current: string, + target: string, +): ReleaseNote[] { + return releases + .filter((r) => { + const gtCurrent = current ? compareVersions(r.tag, current) > 0 : true; + const leTarget = compareVersions(r.tag, target) <= 0; + return gtCurrent && leTarget; + }) + .sort((a, b) => compareVersions(a.tag, b.tag)); +} + +/** + * Fetch the annotated tag message for a release tag — this carries the real, + * human-written changelog (feature bullets) for kars releases, unlike the + * auto-generated release body. Returns null when the tag is lightweight / + * unreachable. Never throws. + */ +export async function fetchTagMessage( + tag: string, + fetchImpl: typeof fetch = fetch, +): Promise { + try { + const refRes = await fetchImpl( + `https://api.github.com/repos/Azure/kars/git/refs/tags/${tag}`, + { headers: { Accept: "application/vnd.github+json", "User-Agent": "kars-cli" } }, + ); + if (!refRes.ok) return null; + const ref = (await refRes.json()) as { object?: { sha?: string; type?: string } }; + // Lightweight tags point straight at a commit (no annotation message). + if (ref.object?.type !== "tag" || !ref.object.sha) return null; + const tagRes = await fetchImpl( + `https://api.github.com/repos/Azure/kars/git/tags/${ref.object.sha}`, + { headers: { Accept: "application/vnd.github+json", "User-Agent": "kars-cli" } }, + ); + if (!tagRes.ok) return null; + return ((await tagRes.json()) as { message?: string }).message ?? null; + } catch { + return null; + } +} + +/** Anonymous GHCR pull token for a public repo (e.g. "azure/kars-controller"). */ +async function ghcrToken(repo: string, fetchImpl: typeof fetch): Promise { + try { + const res = await fetchImpl(`https://ghcr.io/token?scope=repository:${repo}:pull`, { + headers: { "User-Agent": "kars-cli" }, + }); + if (!res.ok) return null; + return ((await res.json()) as { token?: string }).token ?? null; + } catch { + return null; + } +} + +/** + * Collect every manifest digest (the multi-arch index digest plus each per-arch + * sub-manifest digest) for `ghcr.io/azure/:`. A running pod's + * `imageID` is a per-arch digest, while `:latest` resolves to the index digest — + * gathering both lets a caller match either. Digests are content-addressed, so + * GHCR and an `az acr import`-copied ACR share identical values. Never throws. + */ +export async function ghcrManifestDigests( + repo: string, + tag: string, + fetchImpl: typeof fetch = fetch, +): Promise> { + const out = new Set(); + const token = await ghcrToken(repo, fetchImpl); + if (!token) return out; + const accept = [ + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", + ].join(", "); + try { + const res = await fetchImpl(`https://ghcr.io/v2/${repo}/manifests/${tag}`, { + headers: { Authorization: `Bearer ${token}`, Accept: accept, "User-Agent": "kars-cli" }, + }); + if (!res.ok) return out; + const indexDigest = res.headers.get("docker-content-digest"); + if (indexDigest) out.add(indexDigest); + const body = (await res.json()) as { manifests?: Array<{ digest?: string }> }; + for (const m of body.manifests ?? []) { + if (m.digest) out.add(m.digest); + } + } catch { + /* ignore — best-effort */ + } + return out; +} diff --git a/docs/internal/security-audits/2026-06-26-upgrade-version-changelog-impact.md b/docs/internal/security-audits/2026-06-26-upgrade-version-changelog-impact.md new file mode 100644 index 00000000..1663f4b6 --- /dev/null +++ b/docs/internal/security-audits/2026-06-26-upgrade-version-changelog-impact.md @@ -0,0 +1,83 @@ +# Security Audit — `kars upgrade` pre-flight UX: accurate version detection, changelog, impact table, confirm + +Date: 2026-06-26 +Scope: +- `cli/src/commands/upgrade.ts` — image-digest version detection, changelog summary, + cluster impact table, Y/N confirmation. +- `cli/src/lib/release.ts` (+ `release.test.ts`) — `fetchRecentReleases`, + `releasesBetween`, `fetchTagMessage`, `ghcrManifestDigests`. + +Gated path (CI `security-audit-required`): `cli/src/commands/upgrade.ts`. + +## Summary + +UX hardening for `kars upgrade`, all **read-only** until the existing confirmed +write path. No new privileges or mutations are introduced. + +1. **Accurate current-version detection.** Previously fell back to the chart's + static `appVersion` (showed `v0.1.0` on any older cluster). Now resolves the + real version, most-reliable first: (a) the `karsRelease` Helm value stamped by a + prior upgrade; (b) **image-digest match** — read the controller pod's running + image digest (`kubectl get pods … imageID`) and match it against the digests of + recent published `kars-controller` release tags via the **public** GHCR registry + (`az acr import` preserves content-addressed digests, so ACR==GHCR); (c) the + static appVersion as last resort. + +2. **Changelog summary.** Before the confirm, fetches recent releases (public + GitHub API) and prints the annotated **tag messages** (the real feature + changelog) for the versions between current and target. + +3. **Impact table.** Reads the live cluster (controller + all sandbox Deployments + across namespaces) and prints what will be rolling-restarted, with namespace, + readiness, and running image — the blast radius — before the confirm. + +4. **Y/N confirmation.** Interactive prompt before any write; auto-proceeds under + `--yes` or a non-TTY (CI). `--dry-run` still previews and exits. + +5. **Fail-fast cluster health gate.** Before any work (image import / Helm), the + upgrade checks node readiness via `kubectl get nodes`. If **all** nodes are + NotReady (a stopped/degraded cluster — e.g. a deallocated VMSS or broken CNI), + it aborts in ~2s with actionable guidance instead of burning ~13 minutes on an + image import + `helm --wait` that can only time out and roll back. + +## T1: New capability / attack surface? (NO) +- All additions are reads: `kubectl get` (pods/deployments), unauthenticated GETs + to `api.github.com` (public releases/tags) and `ghcr.io` (public image + manifests via an anonymous pull token). No new write, no new credential, no new + cluster permission. The only network egress targets are public Microsoft/GitHub + endpoints already used by `kars up --release`. +- The confirmation gate **reduces** capability (a write now requires explicit + consent in interactive mode). + +## T2: Security-control change? (NEUTRAL / IMPROVED) +- The actual upgrade write path (image import → `helm upgrade --atomic` → rolling + restart → verify, with `--rollback`) is unchanged from the merged `kars upgrade`. +- Adds a human confirmation step before mutation — a net safety improvement. +- GHCR/GitHub responses are only parsed for digests/tag strings; nothing from them + is executed or used to construct privileged operations. Digest comparison is an + exact `sha256:…` string match. + +## T3: Availability / fail-open risk? (REDUCED) +- Every new read is best-effort with graceful fallback: digest detection failing → + fall through to appVersion; GitHub/GHCR unreachable → empty changelog note; + cluster read failing → "could not read workloads" note. None abort the upgrade + or block on the network. +- Accurate version + visible blast radius + confirm make a production upgrade + safer and less surprising. + +## Verification +- CLI `tsc --noEmit` clean, oxlint 0 errors, **843 tests pass** (incl. new + `releasesBetween` cases; version compare/image-plan unchanged). +- Validated live against `kars-aks`: digest helpers collect 5 manifest digests per + tag; `releasesBetween("v0.1.15","v0.1.18")` → v0.1.16/17/18; changelog renders + real bullets from tag messages; impact table renders controller + sandbox with + namespace/readiness/image; `--dry-run` shows changelog + impact + plan with no + changes. + +## Verdict +Accept. Read-only pre-flight UX (accurate version, changelog, impact table) plus a +confirmation gate in front of the existing, unchanged upgrade write path. No new +attack surface; net safety improvement. + +Signed-off-by: Pal Lakatos-Toth +Signed-off-by: Copilot <223556219+Copilot@users.noreply.github.com>