mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +00:00
e12602091a
* 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
138 lines
5.7 KiB
TypeScript
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);
|
|
}
|