Files
sencho/backend/src/helpers/selfUpdateCompose.ts
T
Anso 1ad20da31f Surface runtime build identity across About, shell, and Admiral Account (#1899)
* feat: surface runtime build identity across About, shell, and Admiral Account

Add one canonical source for the control instance's runtime build identity so
a development image is visibly identified even when its packaged semver still
matches the previous stable release.

SelfIdentityService now retains the running image reference and assembles a
BuildInfo (version, channel, imageRef, imageId, revision) via a bounded,
failure-isolated image-inspect step during initialization. getBuildInfo() is a
cached read that never triggers Docker. classifyBuildChannel() labels the
running reference stable, dev, preview, or unknown.

A new proxy-exempt GET /api/build-info returns that identity to a signed-in
human session, redacting hardened image references to non-admins with a
restricted flag so the UI shows Restricted rather than Unknown. Public
/api/meta gains a bounded buildChannel enum without leaking the image
reference.

A BuildInfoProvider context shares one fetch across the About section, the
sidebar DEV/PREVIEW chip, the mobile tab bar, and the Admiral Account Channel
and Current image rows, keeping the control-instance identity separate from
remote nodes.

* fix: move chipDetail helper out of the component file to satisfy fast refresh

SidebarBrand.tsx exported both the SidebarBrand component and the chipDetail
helper, which trips react-refresh/only-export-components and fails the lint
gate. The helper now lives in its own chipDetail.ts module so the component
file exports only the component; the direct unit test keeps its coverage by
importing from the new module.

* fix(build-info): resolve revision-enrichment race and harden identity rendering

- Await the detached revision enrichment before serving /api/build-info so a
  successful response never freezes a transient null revision.
- Lay the mobile DEV/PREVIEW pill in-flow as a non-overlapping flex sibling
  instead of an absolutely positioned overlay.
- Wrap long image and revision tokens with break-all so they do not overflow
  the About panel.
- Surface a toast when copying the image id fails instead of leaving an
  unhandled rejection.
2026-09-07 14:20:47 -04:00

210 lines
8.7 KiB
TypeScript

import { parse, parseDocument } from 'yaml';
import semver from 'semver';
import { normalizeImageRepository } from './imageChannel';
/**
* 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);
}
/**
* True for any reference to the `studio-saelix/sencho-dev` repository on
* `ghcr.io`, including digest-pinned references and the immutable `dev-<sha>`
* tag. Normalizes the image reference to the bare repository string and
* compares against the canonical Sencho dev repository identifier.
*/
export function isSenchoDevRepository(imageRef: string): boolean {
const normalized = normalizeImageRepository(imageRef);
return normalized === 'ghcr.io/studio-saelix/sencho-dev';
}
/**
* True only when the image reference points to the Sencho dev repository with
* the floating `dev` tag, not digest-pinned. This predicate identifies the
* canonical floating tag that users configure for auto-update detection.
*/
export function isSenchoDevFloatingTag(imageRef: string): boolean {
if (!isSenchoDevRepository(imageRef)) return false;
const ref = imageRef?.trim();
if (!ref) return false;
// A digest pin disqualifies the reference (e.g., `@sha256:...`)
if (ref.includes('@sha256:') || ref.startsWith('sha256:')) return false;
const tag = extractTagFromRef(ref);
return tag === 'dev';
}
/**
* Build channel of a running or declared image, derived from the image
* reference alone. This is the canonical build identity classifier used by
* `/api/build-info` and the shell/About surfaces: it answers "is this a dev,
* preview, or stable build" from the ref, independent of the packaged semver
* (a dev image carries the last released version, so version alone cannot
* identify it).
*
* - dev repo (`ghcr.io/studio-saelix/sencho-dev`): always `'dev'`. An
* arbitrary `:dev-<sha>` tag still reads `dev` (a deliberate operator
* choice); the immutable revision is surfaced separately from the digest.
* - stable repos (`ghcr.io/studio-saelix/sencho-hardened`, `saelix/sencho`,
* `ghcr.io/studio-saelix/sencho`): `pr-<n>` and `preview-<sha>` tags are
* `'preview'` (CI builds on the stable repo that are not releases);
* everything else is `'stable'`.
* - any other repository: `'unknown'`.
*/
export type BuildChannel = 'stable' | 'dev' | 'preview' | 'unknown';
export function classifyBuildChannel(imageRef: string): BuildChannel {
const repository = normalizeImageRepository(imageRef);
if (repository === 'ghcr.io/studio-saelix/sencho-dev') return 'dev';
if (
repository === 'ghcr.io/studio-saelix/sencho-hardened' ||
repository === 'saelix/sencho' ||
repository === 'ghcr.io/studio-saelix/sencho'
) {
const tag = extractTagFromRef(imageRef.trim());
if (tag && (/^pr-\d+$/.test(tag) || /^preview-[0-9a-f]{7,40}$/.test(tag))) return 'preview';
return 'stable';
}
return 'unknown';
}
/** Extract the tag portion of an image ref (`.../repo:tag`), or '' when absent. */
function extractTagFromRef(ref: string): string {
const lastSlash = ref.lastIndexOf('/');
const lastColon = ref.lastIndexOf(':');
// A colon after the last slash is a tag separator; before it is a registry port.
return lastColon > lastSlash ? ref.slice(lastColon + 1) : '';
}
/**
* 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);
}