Files
sencho/backend/src/helpers/selfUpdateCompose.ts
T
Anso e12602091a fix: repin semver compose tags during fleet self-update (#1587)
* fix: repin semver compose tags during fleet self-update

Fleet updates failed when docker-compose.yml pinned a semver tag because recreate reused the on-disk pin. Pull the target image first, rewrite semver pins via the update helper, and block digest or unresolved pins with fast 409s.

* fix: update OFFLINE_META shape in capability and node-registry meta tests
2026-07-07 14:40:50 -04:00

138 lines
5.7 KiB
TypeScript

import { parse, parseDocument } from 'yaml';
import semver from 'semver';
/**
* How the Sencho service's compose `image:` is pinned. Drives the Fleet
* self-update behavior:
* - `semver` : rewrite the tag to the target release, then recreate.
* - `floating` : pull the compose-declared ref (e.g. `:latest`), no rewrite.
* - `digest` : `@sha256:...` pin; blocked (needs a manual digest change).
* - `unknown` : interpolated (`${VAR}`), unparseable, or the service/image is
* absent; blocked, because the tag cannot be resolved safely.
*/
export type ImagePinKind = 'floating' | 'semver' | 'digest' | 'unknown';
/** True when Fleet cannot repin or resolve the compose image automatically. */
export function isRepinBlocked(pinKind: ImagePinKind): boolean {
return pinKind === 'digest' || pinKind === 'unknown';
}
export interface ResolvedComposeImage {
/** Absolute path of the highest-precedence compose file that sets the image. */
filePath: string;
/** The verbatim compose-declared image reference. */
imageRef: string;
/** The full contents of that file, so the caller can patch it in place. */
fileContent: string;
pinKind: ImagePinKind;
}
/**
* Classify how an image reference is pinned. Aligned with the preflight
* `usesLatestTag` rule: a digest wins, an interpolated value is unknown, a tag
* that parses as semver is a pin, and everything else (implicit latest and
* non-semver moving tags) is floating.
*/
export function classifyImagePin(imageRef: string): ImagePinKind {
const ref = imageRef?.trim();
if (!ref) return 'unknown';
// Compose variable interpolation: the real tag is only known at deploy time.
if (ref.includes('${') || ref.includes('$(')) return 'unknown';
if (ref.includes('@sha256:') || ref.startsWith('sha256:')) return 'digest';
const lastSlash = ref.lastIndexOf('/');
const lastColon = ref.lastIndexOf(':');
// A colon after the last slash is a tag separator; before it is a registry port.
const tag = lastColon > lastSlash ? ref.slice(lastColon + 1) : '';
if (!tag) return 'floating'; // no explicit tag -> implicit latest
if (tag === 'latest') return 'floating';
if (semver.valid(tag.replace(/^v/, ''))) return 'semver';
return 'floating'; // other moving tag (dev, stable, edge, ...)
}
/**
* Build the target image reference for a semver-pinned install: keep the
* registry and repository (including any `-dev` variant), swap only the tag to
* the target version. Any digest suffix on the current ref is dropped. A `v`
* prefix on the current tag is preserved so `:v1.2.3` stays `:v...`.
*/
export function buildTargetImageRef(currentRef: string, targetVersion: string): string {
const ref = currentRef.split('@')[0];
const lastSlash = ref.lastIndexOf('/');
const lastColon = ref.lastIndexOf(':');
const hasTag = lastColon > lastSlash;
const currentTag = hasTag ? ref.slice(lastColon + 1) : '';
const base = hasTag ? ref.slice(0, lastColon) : ref;
const prefix = /^v\d/.test(currentTag) ? 'v' : '';
return `${base}:${prefix}${targetVersion}`;
}
/**
* Conservative image-reference validation before `docker pull`. execFile runs
* docker with no shell, so this is defense in depth, not the only guard: it
* fails closed on whitespace, control characters, or an implausibly long value.
*/
export function isValidImageRef(ref: string): boolean {
if (!ref || ref.length > 512) return false;
if (/\s/.test(ref)) return false;
return /^[a-zA-Z0-9][a-zA-Z0-9._\-/:@]*$/.test(ref);
}
/**
* Resolve the Sencho service's image from already-read compose file contents.
* Files are given in compose `-f` order; they are scanned in REVERSE so the
* highest-precedence override that explicitly sets the service image wins,
* matching how Docker Compose merges later `-f` files over earlier ones.
* Returns null when no file declares a string image for the service.
*/
export function resolveServiceImageFromContents(
files: ReadonlyArray<{ filePath: string; content: string }>,
serviceName: string,
): ResolvedComposeImage | null {
for (let i = files.length - 1; i >= 0; i--) {
const { filePath, content } = files[i];
let parsed: unknown;
try {
parsed = parse(content);
} catch {
continue; // a malformed override cannot own the image; try the next file
}
const image = extractServiceImage(parsed, serviceName);
if (image) {
return { filePath, imageRef: image, fileContent: content, pinKind: classifyImagePin(image) };
}
}
return null;
}
/** Read `services.<name>.image` as a trimmed string, or null if absent/non-string. */
function extractServiceImage(parsed: unknown, serviceName: string): string | null {
if (!isRecord(parsed)) return null;
const services = parsed.services;
if (!isRecord(services)) return null;
const service = services[serviceName];
if (!isRecord(service)) return null;
const image = service.image;
if (typeof image !== 'string') return null;
const trimmed = image.trim();
return trimmed || null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Rewrite only the target service's `image:` value, preserving comments,
* formatting, and every other node via a document round-trip. Throws when the
* service has no image to patch, so a caller never silently no-ops.
*/
export function patchComposeServiceImage(content: string, serviceName: string, newImageRef: string): string {
const doc = parseDocument(content);
if (!doc.hasIn(['services', serviceName, 'image'])) {
throw new Error(`Service "${serviceName}" has no image to patch`);
}
doc.setIn(['services', serviceName, 'image'], newImageRef);
return String(doc);
}