/** * Deploy posture — fast vs. safe — for `celilo module upgrade` (ISS-0138, * openspec/changes/build-bus-poll-cd/proposal.md). * * CD is CI-driven, so no operator is present to pass `--no-backup`. The posture * (fast = skip the pre-deploy backup; safe = back up first) is DERIVED, with * this precedence: * 1. per-module `upgrade_policy: always-safe` — a hard floor (always safe). * 2. per-release `deploy_posture` stamped in the .netapp release metadata. * 3. per-module `upgrade_policy: always-fast`. * 4. default: the semver delta of installed → next (revision/patch = fast; * minor/major = safe). * * Posture gates the BACKUP ONLY. Both paths run the same post-deploy verify * (module-upgrade.ts calls runModuleHealthCheck either way), and neither * gates the deploy: verify is the last step, so a failing health check reports * "upgraded but verify failed" on an upgrade that has already landed — there * is no rollback and no auto-restore. This comment previously claimed safe * posture ran a "full verify" against fast's "extended verify"; no such * distinction has ever existed in the code, and believing it makes * `auto_upgrade` sound far more guarded than it is. * * `upgrade_policy` (like `auto_upgrade`) is OPERATOR CONFIG, not a manifest * key — `celilo module config set upgrade_policy always-safe`. The * manifest schema is `.strict()` and declares neither. * * celilo reads version NUMBERS, never changesets — the default needs only the * versions. All functions here are pure (Rule 10). */ export type DeployPosture = 'fast' | 'safe'; export type VersionDelta = 'none' | 'revision' | 'patch' | 'minor' | 'major'; export type UpgradePolicy = 'by-semver' | 'always-safe' | 'always-fast'; export interface ParsedVersion { major: number; minor: number; patch: number; /** prerelease tag, e.g. "alpha.2"; '' when absent. */ prerelease: string; /** build metadata — celilo's `+N` registry revision; '' when absent. */ build: string; } /** Parse a celilo module version: `major.minor.patch[-prerelease][+build]`. */ export function parseVersion(version: string): ParsedVersion { const [coreAndPre = '', build = ''] = version.split('+'); const [core = '', prerelease = ''] = coreAndPre.split('-'); const parts = core.split('.'); const num = (i: number) => Number.parseInt(parts[i] ?? '', 10) || 0; return { major: num(0), minor: num(1), patch: num(2), prerelease, build }; } /** * Classify the delta between an installed version and the next one by the * highest-order component that changed. Assumes `next` >= `installed` (the poll * only upgrades). A prerelease-only change counts as `patch` (still fast); a * build-metadata-only change (`+N`) is `revision`. */ export function classifyVersionDelta(installed: string, next: string): VersionDelta { const a = parseVersion(installed); const b = parseVersion(next); if (a.major !== b.major) return 'major'; if (a.minor !== b.minor) return 'minor'; if (a.patch !== b.patch) return 'patch'; if (a.prerelease !== b.prerelease) return 'patch'; if (a.build !== b.build) return 'revision'; return 'none'; } /** * Resolve the deploy posture for an upgrade. Precedence: `always-safe` floor → * per-release override → `always-fast` → semver-delta default. Returns the * posture plus a short reason for operator-visible logging. */ export function resolveDeployPosture(opts: { installed: string; next: string; releasePosture?: DeployPosture | null; modulePolicy?: UpgradePolicy; }): { posture: DeployPosture; reason: string } { const policy = opts.modulePolicy ?? 'by-semver'; if (policy === 'always-safe') { return { posture: 'safe', reason: 'module upgrade_policy=always-safe' }; } if (opts.releasePosture) { return { posture: opts.releasePosture, reason: `release deploy_posture=${opts.releasePosture}`, }; } if (policy === 'always-fast') { return { posture: 'fast', reason: 'module upgrade_policy=always-fast' }; } const delta = classifyVersionDelta(opts.installed, opts.next); const posture: DeployPosture = delta === 'minor' || delta === 'major' ? 'safe' : 'fast'; return { posture, reason: `semver delta=${delta}` }; }