/* RFC-1297: live-channel drift detection — shared comparison between the recorded channel state (systems-cache/<id>/system-state.yaml lastPropagated) and the release actually served by each channel's build-identity.json. Owns the factored fetch helper (previously private to leitstand-commands.ts), the PIN auth-header builder, and the LIVE-01/LIVE-02 diagnostic projection consumed by sternsystem.validate, leitstand.ship preflight, and leitstand.rollback. Not a contract source of truth — callers depend on the module's exported API, not this file's internals. RFC-1297: initial — fetchChannelBuildIdentity/buildAuthHeader moved here from leitstand/deploy-execution.ts + leitstand-commands.ts; readLiveChannelStates + liveDriftDiagnostics added. RFC-1297 RFC-1297: keep the worker bundle free of live-drift and engine kernel imports */ import type { Diagnostic, WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel"; import { readSystemConfigSmart, readSystemStateSmart } from "./registry-io.ts"; import { BUILD_IDENTITY_PATH } from "./build-identity-path.ts"; export { BUILD_IDENTITY_PATH }; export type LiveChannelName = "dev" | "alt" | "main"; const LIVE_CHANNELS: readonly LiveChannelName[] = ["dev", "alt", "main"]; /** RFC-0925: Basic-auth header for PIN-protected channel endpoints. */ export function buildAuthHeader(pin: string | null | undefined): Record { if (!pin) return {}; const credentials = btoa(`warp:${pin}`); return { Authorization: `Basic ${credentials}` }; } export interface ChannelBuildIdentity { distTreeHash: string | null; releaseId: string | null; missionId: string | null; buildTimestamp: string | null; } export interface FetchBuildIdentityResult { reachable: boolean; httpStatus: number | null; buildIdentity: ChannelBuildIdentity | null; error: string | null; } /** * Fetch a channel's build-identity.json (cache-busted). Factored out of * runLeitstandVerify (RFC-0930) so sternsystem.validate, ship preflight, and * rollback can probe the same live truth through one code path. */ export async function fetchChannelBuildIdentity( channel: LiveChannelName, url: string, authHeaders: Record, timeoutMs: number, fetchImpl: typeof fetch = fetch, ): Promise { const base = url.replace(/\/$/, ""); // RFC-1297: cache-buster defeats stale CDN copies of the identity right after // a deploy or out-of-band rollback. const fetchUrl = `${base}${BUILD_IDENTITY_PATH}?verify=${Date.now()}`; const controller = new AbortController(); let timer: ReturnType | undefined; try { timer = setTimeout(() => controller.abort(), timeoutMs); const response = await fetchImpl(fetchUrl, { headers: authHeaders, redirect: "follow", signal: controller.signal, }); if (!response.ok) { return { reachable: response.status < 500, httpStatus: response.status, buildIdentity: null, error: response.status === 404 ? "build-identity.json not found" : `HTTP ${response.status}`, }; } const body = (await response.json()) as { distTreeHash?: string; releaseId?: string; missionId?: string; buildTimestamp?: string; }; return { reachable: true, httpStatus: response.status, buildIdentity: { distTreeHash: body.distTreeHash ?? null, releaseId: body.releaseId ?? null, missionId: body.missionId ?? null, buildTimestamp: body.buildTimestamp ?? null, }, error: null, }; } catch (err) { const isAbort = err instanceof Error && err.name === "AbortError"; return { reachable: false, httpStatus: null, buildIdentity: null, error: isAbort ? `timeout after ${timeoutMs}ms` : err instanceof Error ? err.message : String(err), }; } finally { if (timer) clearTimeout(timer); } } export interface ChannelLiveState { channel: LiveChannelName; /** releaseId recorded in system-state.yaml lastPropagated — the believed-live release. */ recordedReleaseId: string | null; /** releaseId served by the channel's live build-identity.json — null when unreachable or identity missing. */ liveReleaseId: string | null; /** true when the channel answered with an HTTP status (even 4xx). */ reachable: boolean; error: string | null; } /** * Read every channel that has a recorded lastPropagated entry and compare the * recorded releaseId against the live build-identity.json releaseId. Channels * with no lastPropagated entry are skipped — never deployed, nothing to drift * from. */ export async function readLiveChannelStates(ctx: { workspaceRoot: string; systemId: string; io: WorkspaceIO; fetchImpl?: typeof fetch; timeoutMs?: number; }): Promise { const { workspaceRoot, systemId, io } = ctx; const timeoutMs = ctx.timeoutMs ?? 10_000; const fetchImpl = ctx.fetchImpl ?? fetch; const systemConfig = await readSystemConfigSmart(workspaceRoot, systemId, io); const channels = systemConfig.deployment?.channels; if (!channels) return []; const systemState = await readSystemStateSmart(workspaceRoot, systemId, io); const authHeaders = buildAuthHeader(systemState.accessPin); const states: ChannelLiveState[] = []; for (const channel of LIVE_CHANNELS) { const recorded = systemState.lastPropagated?.[channel]; if (!recorded) continue; const url = channels[channel]?.url; if (!url) continue; const result = await fetchChannelBuildIdentity(channel, url, authHeaders, timeoutMs, fetchImpl); states.push({ channel, recordedReleaseId: recorded.releaseId ?? null, liveReleaseId: result.buildIdentity?.releaseId ?? null, reachable: result.reachable, error: result.error, }); } return states; } /** * LIVE-01 (error): live releaseId differs from the recorded one — an * out-of-band deploy or rollback happened. * LIVE-02 (warning): the channel is unreachable or returns no identity — the * probe is inconclusive, never a violation. */ export function liveDriftDiagnostics(states: ChannelLiveState[], systemId: string): Diagnostic[] { const diagnostics: Diagnostic[] = []; for (const state of states) { if (state.liveReleaseId === null) { diagnostics.push({ ruleId: "LIVE-02", severity: "warning", message: `${state.channel}: live release probe inconclusive for ${systemId} — ${state.error ?? "no build-identity"}`, }); continue; } if (state.recordedReleaseId !== null && state.liveReleaseId !== state.recordedReleaseId) { diagnostics.push({ ruleId: "LIVE-01", severity: "error", message: `${state.channel}: live release ${state.liveReleaseId} ≠ recorded ${state.recordedReleaseId} — an out-of-band deploy or rollback happened`, fixHint: `Inspect with leitstand.verify --site ${systemId}; then either redeploy the recorded release via the pipeline, or record reality with leitstand.state.reconcile --site ${systemId} --channel ${state.channel} --from-live --reason "…" (operator approval).`, }); } } return diagnostics; }