mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 22:17:50 +00:00
b0b423b234
* feat(fleet): reapply Compose configuration without a version update Add a distinct Fleet Reapply configuration path so Compose-managed nodes can recreate Sencho from the current on-disk project when already up to date, without pulling or rewriting the image reference. * fix(fleet): confirm remote reapply and close concurrent tracker race Require confirmation for remote compose reapply, and lock dispatch before the remote POST so a second request cannot overwrite a successful in-flight tracker. * fix(ui): icon-only Reapply control so Up to date badge can breathe Collapse the Node updates Reapply label into a tooltip so the status pill no longer wraps in the Status column. * feat(editor): Save & Reapply self-stack via fleet compose reapply (#1726) * feat(editor): Save & Reapply self-stack via fleet compose reapply Eligible admins can apply on-disk Compose edits to Sencho's own stack from the editor using the same confirm, dispatch, and reconnect path as Fleet Node Updates. * fix(editor): gate Save & Reapply label to self-stack only Ordinary stacks were labeled Save & Reapply whenever the node was reapply-eligible. Require the selected file to be the self-stack for the toolbar label and diff confirm CTA. * fix(ui): move compose diff action label helper out of dialog module Keep ComposeDiffPreviewDialog component-only so react-refresh Fast Refresh lint passes after the Save and reapply stacked merge.
661 lines
28 KiB
TypeScript
661 lines
28 KiB
TypeScript
import { execFile } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import * as fs from 'fs';
|
|
import DockerController from './DockerController';
|
|
import { DatabaseService } from './DatabaseService';
|
|
import { disableCapability } from './CapabilityRegistry';
|
|
import { isDebugEnabled } from '../utils/debug';
|
|
import {
|
|
buildTargetImageRef,
|
|
isRepinBlocked,
|
|
isValidImageRef,
|
|
patchComposeServiceImage,
|
|
resolveServiceImageFromContents,
|
|
type ImagePinKind,
|
|
type ResolvedComposeImage,
|
|
} from '../helpers/selfUpdateCompose';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
// Error file written by the helper container on a failed compose recreate.
|
|
// Must live under /app/data so both the helper (via host-path bind mount) and
|
|
// the NEW gateway process (which always mounts /app/data) can reach it.
|
|
const UPDATE_ERROR_FILE = '/app/data/.sencho-update-error';
|
|
|
|
// Rewritten compose file the main process stages under /app/data for the helper
|
|
// to copy onto the host compose file. Uses the same /app/data handoff as the
|
|
// error file because the main process cannot reach the compose working dir.
|
|
const STAGED_PATCH_FILE = '/app/data/.sencho-compose-patch';
|
|
|
|
// Cap how long a cached pin classification is served to status/meta callers.
|
|
// Update DECISIONS always read fresh (fresh: true), so this only bounds how
|
|
// stale a displayed pin kind can be after a live compose edit.
|
|
const PIN_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
|
|
// Operator-facing block reasons. Deliberately generic: no host paths, compose
|
|
// contents, or registry/repository names (this text can reach the UI).
|
|
const UPDATE_BLOCKED_REASON =
|
|
'This install pins the Sencho image to a digest or a value Fleet cannot resolve, so it cannot repin automatically. Change the image tag in your compose file, then update again.';
|
|
const UPDATE_READ_FAILED_REASON =
|
|
'Could not read the Sencho compose file to determine how its image is pinned. Confirm the update helper can reach the compose directory, then try again.';
|
|
|
|
interface HostMount {
|
|
source: string;
|
|
destination: string;
|
|
}
|
|
|
|
/** Narrow projection of the Dockerode mount entry we actually consume. */
|
|
export type DockerMount = {
|
|
Type: 'bind' | 'volume' | 'tmpfs' | 'npipe' | 'cluster' | 'image';
|
|
Source: string;
|
|
Destination: string;
|
|
};
|
|
|
|
/** Pin classification exposed to callers (status/meta), without file contents. */
|
|
export interface PinInfo {
|
|
pinKind: ImagePinKind;
|
|
composeImageRef: string;
|
|
filePath: string;
|
|
}
|
|
|
|
/** Outcome of the pre-update check the route layer runs before responding. */
|
|
export type SelfUpdatePreflight = { ok: true } | { ok: false; reason: string };
|
|
|
|
/** A staged compose rewrite the helper copies onto the host before recreate. */
|
|
export interface ComposeCopy {
|
|
stagedPath: string;
|
|
targetPath: string;
|
|
}
|
|
|
|
/**
|
|
* Find the host-side path Docker resolved for /app/data, regardless of whether
|
|
* the operator declared a bind or a named volume. The helper container uses
|
|
* this path to mount /app/data:rw so it can write UPDATE_ERROR_FILE when a
|
|
* compose recreate fails before the gateway can persist the error itself.
|
|
*/
|
|
export function findDataDirHost(mounts: ReadonlyArray<DockerMount>): string | null {
|
|
const match = mounts.find(m =>
|
|
m.Destination === '/app/data' &&
|
|
(m.Type === 'bind' || m.Type === 'volume') &&
|
|
!!m.Source,
|
|
);
|
|
return match?.Source ?? null;
|
|
}
|
|
|
|
// POSIX single-quote escaping. serviceName and the compose config paths in
|
|
// fFlags come from Docker Compose labels on Sencho's own container, so a label
|
|
// carrying shell metacharacters must not be able to break out of the command
|
|
// sequence (the exit-code capture, error-file write, and prune guard).
|
|
export function shQuote(value: string): string {
|
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
}
|
|
|
|
/**
|
|
* Build the argv for a one-shot helper that reads a single compose config file
|
|
* from the host. The main process cannot open the compose working dir, so it
|
|
* mounts it read-only into a throwaway container and cats the file out. Pure and
|
|
* exported for unit testing. The path is a discrete argv element handed to
|
|
* execFile (no shell), so a path with shell metacharacters stays inert.
|
|
*/
|
|
export function buildComposeReadArgs(workingDir: string, imageName: string, configFilePath: string): string[] {
|
|
return [
|
|
'run', '--rm',
|
|
'--user', 'root',
|
|
'--entrypoint', 'cat',
|
|
'-v', `${workingDir}:${workingDir}:ro`,
|
|
'-w', workingDir,
|
|
imageName,
|
|
configFilePath,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Build the shell command the helper container runs to recreate Sencho. Kept as
|
|
* a pure, exported function so the prune-on-update and repin-copy branches are
|
|
* unit-testable.
|
|
*
|
|
* Label-derived inputs (serviceName, the fFlags config paths) and the repin copy
|
|
* paths are shell-quoted so they cannot alter the command structure. When a
|
|
* composeCopy is present the rewritten compose file is copied onto the host
|
|
* BEFORE the recreate; a failed copy aborts before recreate so a half-applied
|
|
* update never runs. The recreate writes the error file only on failure; the
|
|
* optional dangling prune runs only on success, so the branches never overlap.
|
|
*/
|
|
export function buildSelfUpdateComposeCmd(
|
|
fFlags: string[],
|
|
serviceName: string,
|
|
stderrTmp: string,
|
|
errorFile: string,
|
|
pruneOnUpdate: boolean,
|
|
composeCopy?: ComposeCopy,
|
|
successMarkerFile?: string,
|
|
successMarkerContent = '{"ok":true}',
|
|
): string {
|
|
const recreate = ['docker compose', ...fFlags.map(shQuote), 'up -d --force-recreate', shQuote(serviceName), `2>${stderrTmp}`].join(' ');
|
|
const copyStep = composeCopy
|
|
? [`cp ${shQuote(composeCopy.stagedPath)} ${shQuote(composeCopy.targetPath)} 2>${stderrTmp} || { { echo "exit=1"; echo "Failed to write the updated compose file"; cat ${stderrTmp}; } > ${errorFile} 2>/dev/null; exit 1; }`]
|
|
: [];
|
|
return [
|
|
'sleep 3',
|
|
...copyStep,
|
|
recreate,
|
|
'ec=$?',
|
|
`if [ $ec -ne 0 ]; then { echo "exit=$ec"; cat ${stderrTmp}; } > ${errorFile} 2>/dev/null; fi`,
|
|
...(pruneOnUpdate
|
|
? [`if [ $ec -eq 0 ]; then docker image prune -f >/dev/null 2>&1 || true; fi`]
|
|
: []),
|
|
...(successMarkerFile
|
|
? [`if [ $ec -eq 0 ]; then printf %s ${shQuote(successMarkerContent)} > ${shQuote(successMarkerFile)}; fi`]
|
|
: []),
|
|
`cat ${stderrTmp} >&2 2>/dev/null`,
|
|
'exit $ec',
|
|
].join('; ');
|
|
}
|
|
|
|
interface ComposeContext {
|
|
workingDir: string;
|
|
configFiles: string;
|
|
serviceName: string;
|
|
imageName: string;
|
|
dataDirHost: string | null;
|
|
hostBindMounts: HostMount[];
|
|
}
|
|
|
|
/**
|
|
* Build the `docker run` argv for the self-update helper container. Pure and
|
|
* exported so the mount construction is unit-testable alongside the compose
|
|
* command it carries.
|
|
*
|
|
* workingDir, dataDirHost, and the host bind-mount sources are operator-set
|
|
* paths (compose labels / container mounts). They are placed as discrete argv
|
|
* elements and handed to execFile, which spawns docker with no shell, so a path
|
|
* carrying shell metacharacters stays inert data and never reaches a shell.
|
|
*
|
|
* workingDir is mounted read-only unless `repinWritable` is set, which happens
|
|
* only for a semver repin that must copy the rewritten compose file onto the
|
|
* host. Limiting :rw to that case keeps the helper's host write scope minimal.
|
|
*/
|
|
export function buildSelfUpdateRunArgs(
|
|
ctx: Pick<ComposeContext, 'workingDir' | 'imageName' | 'dataDirHost' | 'hostBindMounts'> & { repinWritable?: boolean },
|
|
composeCmd: string,
|
|
): string[] {
|
|
const { workingDir, imageName, dataDirHost, hostBindMounts, repinWritable } = ctx;
|
|
const mountArgs: string[] = [
|
|
'-v', '/var/run/docker.sock:/var/run/docker.sock',
|
|
'-v', `${workingDir}:${workingDir}:${repinWritable ? 'rw' : 'ro'}`,
|
|
];
|
|
if (dataDirHost) {
|
|
mountArgs.push('-v', `${dataDirHost}:/app/data:rw`);
|
|
}
|
|
const alreadyMounted = new Set([
|
|
'/var/run/docker.sock', workingDir, ...(dataDirHost ? [dataDirHost] : []),
|
|
]);
|
|
for (const { source } of hostBindMounts) {
|
|
if (alreadyMounted.has(source) || source.startsWith(workingDir + '/')) continue;
|
|
mountArgs.push('-v', `${source}:${source}:ro`);
|
|
}
|
|
return [
|
|
'run', '--rm',
|
|
'--user', 'root',
|
|
'--entrypoint', 'sh',
|
|
...mountArgs,
|
|
'-w', workingDir,
|
|
imageName,
|
|
'-c', composeCmd,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Build the argv for a throwaway helper that runs `docker compose … config`
|
|
* against the host compose project. Reuses the recreate helper's mount layout
|
|
* (socket + working dir + host binds) without mounting /app/data, since
|
|
* validation is read-only. Pure and exported for unit testing.
|
|
*/
|
|
export function buildComposeConfigValidateArgs(
|
|
ctx: Pick<ComposeContext, 'workingDir' | 'imageName' | 'hostBindMounts'> & { configFiles: string },
|
|
): string[] {
|
|
const { workingDir, imageName, hostBindMounts, configFiles } = ctx;
|
|
const fFlags = configFiles.split(',').flatMap(f => {
|
|
const trimmed = f.trim();
|
|
return trimmed ? ['-f', trimmed] : [];
|
|
});
|
|
const composeCmd = ['docker compose', ...fFlags.map(shQuote), 'config'].join(' ');
|
|
return buildSelfUpdateRunArgs(
|
|
{ workingDir, imageName, dataDirHost: null, hostBindMounts },
|
|
composeCmd,
|
|
);
|
|
}
|
|
|
|
class SelfUpdateService {
|
|
private static instance: SelfUpdateService;
|
|
private canSelfUpdate = false;
|
|
private composeContext: ComposeContext | null = null;
|
|
private lastUpdateError: string | null = null;
|
|
/** Stashed when the helper exits before any onceHelperExit listener is registered. */
|
|
private pendingHelperExitError: string | undefined = undefined;
|
|
private helperExitListeners: Array<(error: string | null) => void> = [];
|
|
private pinCache: { info: ResolvedComposeImage | null; at: number } | null = null;
|
|
|
|
public static getInstance(): SelfUpdateService {
|
|
if (!SelfUpdateService.instance) {
|
|
SelfUpdateService.instance = new SelfUpdateService();
|
|
}
|
|
return SelfUpdateService.instance;
|
|
}
|
|
|
|
async initialize(): Promise<void> {
|
|
const hostname = process.env.HOSTNAME;
|
|
if (!hostname) {
|
|
console.log('[SelfUpdate] HOSTNAME not set - self-update unavailable (not running in Docker?)');
|
|
disableCapability('self-update');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const docker = DockerController.getInstance().getDocker();
|
|
const container = docker.getContainer(hostname);
|
|
const info = await container.inspect();
|
|
const labels = info.Config?.Labels ?? {};
|
|
|
|
const workingDir = labels['com.docker.compose.project.working_dir'];
|
|
const configFiles = labels['com.docker.compose.project.config_files'];
|
|
const serviceName = labels['com.docker.compose.service'];
|
|
|
|
if (!workingDir || !configFiles || !serviceName) {
|
|
console.log('[SelfUpdate] Container lacks Docker Compose labels - self-update unavailable');
|
|
disableCapability('self-update');
|
|
return;
|
|
}
|
|
|
|
// Verify docker compose CLI is available inside the container.
|
|
// execFileAsync (not Sync) so the parallel boot-task block in
|
|
// bootstrap/startup.ts can actually run TrivyService and DockerEventManager
|
|
// concurrently instead of waiting on a 5s blocking spawn here.
|
|
try {
|
|
await execFileAsync('docker', ['compose', 'version'], { timeout: 5000 });
|
|
} catch {
|
|
console.log('[SelfUpdate] docker compose CLI not available in container');
|
|
disableCapability('self-update');
|
|
return;
|
|
}
|
|
|
|
// Read the container's own image name for the legacy (no target) pull and
|
|
// as a fallback ref. It is captured once here and can go stale if the
|
|
// compose file is edited later, so update decisions resolve the compose
|
|
// image fresh instead of trusting this value.
|
|
const imageName = info.Config?.Image;
|
|
if (!imageName) {
|
|
console.log('[SelfUpdate] Could not determine container image name');
|
|
disableCapability('self-update');
|
|
return;
|
|
}
|
|
|
|
// Collect all host bind mounts so the helper container can forward them.
|
|
// This lets docker compose resolve env_file, configs, secrets, and any
|
|
// other host-path references that live outside the compose working dir.
|
|
const rawMounts = (info.Mounts ?? []) as DockerMount[];
|
|
const hostBindMounts: HostMount[] = rawMounts
|
|
.filter(m => m.Type === 'bind' && m.Source && m.Destination)
|
|
.map(m => ({ source: m.Source, destination: m.Destination }));
|
|
|
|
const dataDirHost = findDataDirHost(rawMounts);
|
|
if (!dataDirHost) {
|
|
console.log('[SelfUpdate] /app/data mount not found - update error recovery and compose repin will be unavailable');
|
|
}
|
|
|
|
this.composeContext = { workingDir, configFiles, serviceName, imageName, dataDirHost, hostBindMounts };
|
|
this.canSelfUpdate = true;
|
|
console.log(`[SelfUpdate] Ready - service="${serviceName}" image="${imageName}" in ${workingDir}`);
|
|
|
|
// Warm the pin-info cache in the background for diagnostics and the first
|
|
// status/meta read. This is never a source of truth for an update
|
|
// decision, which always resolves fresh.
|
|
void this.resolveComposeImage(true)
|
|
.then(resolved => {
|
|
if (resolved) console.log(`[SelfUpdate] Compose image pin: ${resolved.imageRef} (${resolved.pinKind})`);
|
|
})
|
|
.catch(() => { /* diagnostic only */ });
|
|
|
|
// Surface any error from a previous failed update attempt (persisted by
|
|
// the helper container) so the new process can report it to the user.
|
|
this.recoverPreviousError();
|
|
} catch (error) {
|
|
console.log('[SelfUpdate] Could not inspect own container - self-update unavailable:', (error as Error).message);
|
|
disableCapability('self-update');
|
|
}
|
|
}
|
|
|
|
isAvailable(): boolean {
|
|
return this.canSelfUpdate;
|
|
}
|
|
|
|
/** Returns the error message from the last failed update attempt, or null. */
|
|
getLastError(): string | null {
|
|
return this.lastUpdateError;
|
|
}
|
|
|
|
/** Register a one-shot listener for the helper container's execFile callback. */
|
|
onceHelperExit(listener: (error: string | null) => void): void {
|
|
if (this.pendingHelperExitError !== undefined) {
|
|
const error = this.pendingHelperExitError;
|
|
this.pendingHelperExitError = undefined;
|
|
queueMicrotask(() => {
|
|
try {
|
|
listener(error);
|
|
} catch (listenerError) {
|
|
console.error('[SelfUpdate] Helper exit listener failed:', listenerError);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
this.helperExitListeners.push(listener);
|
|
}
|
|
|
|
/** Clears the stored update error (call after reading it). */
|
|
clearLastError(): void {
|
|
this.lastUpdateError = null;
|
|
}
|
|
|
|
/**
|
|
* Classify how this instance's compose image is pinned. Serves a short-lived
|
|
* cache to frequent status/meta callers; pass `{ fresh: true }` for update
|
|
* decisions so a live compose edit is always reflected. Returns null when
|
|
* self-update is unavailable or the compose file could not be read.
|
|
*/
|
|
async getPinInfo(opts?: { fresh?: boolean; cacheOnly?: boolean }): Promise<PinInfo | null> {
|
|
const resolved = await this.resolveComposeImage(opts?.fresh ?? false, opts?.cacheOnly ?? false);
|
|
if (!resolved) return null;
|
|
return { pinKind: resolved.pinKind, composeImageRef: resolved.imageRef, filePath: resolved.filePath };
|
|
}
|
|
|
|
/** Fresh compose image resolution for guarded image-channel operations. */
|
|
async getResolvedComposeImageForUpdate(): Promise<ResolvedComposeImage | null> {
|
|
return this.resolveComposeImage(true);
|
|
}
|
|
|
|
getComposeServiceName(): string | null {
|
|
return this.composeContext?.serviceName ?? null;
|
|
}
|
|
|
|
/**
|
|
* Preflight the route layer runs before responding, so a blocked update fails
|
|
* fast with a 409 instead of returning 202 and stalling the reconnect overlay.
|
|
* A missing target version is the legacy pull-current path and makes no repin
|
|
* decision. A digest/unknown pin (or an unreadable compose file) is blocked.
|
|
*/
|
|
async canSelfUpdateTarget(targetVersion?: string): Promise<SelfUpdatePreflight> {
|
|
if (!this.canSelfUpdate) {
|
|
return { ok: false, reason: 'Self-update is unavailable on this instance.' };
|
|
}
|
|
if (!targetVersion) return { ok: true };
|
|
const info = await this.getPinInfo({ fresh: true });
|
|
if (!info) return { ok: false, reason: UPDATE_READ_FAILED_REASON };
|
|
if (isRepinBlocked(info.pinKind)) {
|
|
return { ok: false, reason: UPDATE_BLOCKED_REASON };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
/** Resolve the compose-declared service image fresh from the host, or from a
|
|
* short-lived cache. Reads each config file via a throwaway cat container. */
|
|
private async resolveComposeImage(fresh: boolean, cacheOnly = false): Promise<ResolvedComposeImage | null> {
|
|
if (!this.composeContext) return null;
|
|
const now = Date.now();
|
|
if (!fresh && this.pinCache && now - this.pinCache.at < PIN_CACHE_TTL_MS) {
|
|
return this.pinCache.info;
|
|
}
|
|
if (cacheOnly) return null;
|
|
const { workingDir, configFiles, serviceName, imageName } = this.composeContext;
|
|
const env = this.buildEnv();
|
|
const debug = isDebugEnabled();
|
|
const files: { filePath: string; content: string }[] = [];
|
|
for (const raw of configFiles.split(',')) {
|
|
const filePath = raw.trim();
|
|
if (!filePath) continue;
|
|
try {
|
|
const { stdout } = await execFileAsync('docker', buildComposeReadArgs(workingDir, imageName, filePath), {
|
|
env,
|
|
timeout: 30_000,
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
});
|
|
files.push({ filePath, content: stdout });
|
|
} catch (error) {
|
|
if (debug) console.debug('[SelfUpdate:debug] Could not read compose file', filePath, (error as Error).message);
|
|
}
|
|
}
|
|
const info = resolveServiceImageFromContents(files, serviceName);
|
|
this.pinCache = { info, at: now };
|
|
return info;
|
|
}
|
|
|
|
private buildEnv(): NodeJS.ProcessEnv {
|
|
return { ...process.env, PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' };
|
|
}
|
|
|
|
/** Surfaces any error the helper container persisted before the previous
|
|
* gateway process died, then deletes the file. */
|
|
private recoverPreviousError(): void {
|
|
try {
|
|
const content = fs.readFileSync(UPDATE_ERROR_FILE, 'utf8').trim();
|
|
if (content) {
|
|
this.lastUpdateError = content;
|
|
console.error('[SelfUpdate] Recovered error from previous update attempt:', content);
|
|
}
|
|
fs.unlinkSync(UPDATE_ERROR_FILE);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
console.error('[SelfUpdate] Failed to recover previous update error:', (error as Error).message);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pull the target image, then (for a semver pin) rewrite the compose tag and
|
|
* recreate. Pull happens BEFORE any compose mutation, so a failed pull leaves
|
|
* the compose file untouched. `targetVersion` comes from the Fleet compare
|
|
* target; when omitted this keeps the legacy behavior of pulling the running
|
|
* image and recreating from the on-disk compose.
|
|
*/
|
|
async triggerUpdate(options?: {
|
|
targetVersion?: string;
|
|
targetImageRef?: string;
|
|
dockerConfigPath?: string;
|
|
successMarkerFile?: string;
|
|
successMarkerContent?: string;
|
|
}): Promise<void> {
|
|
if (!this.composeContext) return;
|
|
const env = {
|
|
...this.buildEnv(),
|
|
...(options?.dockerConfigPath ? { DOCKER_CONFIG: options.dockerConfigPath } : {}),
|
|
};
|
|
this.lastUpdateError = null;
|
|
this.pendingHelperExitError = undefined;
|
|
|
|
try { fs.unlinkSync(UPDATE_ERROR_FILE); } catch { /* absent is the steady state */ }
|
|
try { fs.unlinkSync(STAGED_PATCH_FILE); } catch { /* absent is the steady state */ }
|
|
|
|
const { imageName, serviceName, dataDirHost } = this.composeContext;
|
|
const targetVersion = options?.targetVersion;
|
|
const targetImageRef = options?.targetImageRef;
|
|
|
|
let pullRef = imageName;
|
|
let repin: { resolved: ResolvedComposeImage; ref: string } | null = null;
|
|
|
|
if (targetVersion || targetImageRef) {
|
|
const resolved = await this.resolveComposeImage(true);
|
|
const pinKind = resolved?.pinKind ?? 'unknown';
|
|
// Defense in depth: the route preflight already rejected these, but the
|
|
// compose file could change between preflight and this last-breath call.
|
|
if (!resolved || (!targetImageRef && isRepinBlocked(pinKind))) {
|
|
this.lastUpdateError = resolved ? UPDATE_BLOCKED_REASON : UPDATE_READ_FAILED_REASON;
|
|
console.error('[SelfUpdate] Update blocked:', this.lastUpdateError);
|
|
return;
|
|
}
|
|
pullRef = targetImageRef ?? (pinKind === 'semver'
|
|
? buildTargetImageRef(resolved.imageRef, targetVersion!)
|
|
: resolved.imageRef);
|
|
if (!isValidImageRef(pullRef)) {
|
|
this.lastUpdateError = 'Aborting update: the computed image reference is invalid.';
|
|
console.error('[SelfUpdate] Update blocked:', this.lastUpdateError, pullRef);
|
|
return;
|
|
}
|
|
if (pinKind === 'semver' || targetImageRef) {
|
|
if (!dataDirHost) {
|
|
this.lastUpdateError =
|
|
'Cannot rewrite the pinned compose image: the data directory needed for the update handoff is not mounted. Change the image tag manually and update again.';
|
|
console.error('[SelfUpdate] Repin blocked:', this.lastUpdateError);
|
|
return;
|
|
}
|
|
repin = { resolved, ref: pullRef };
|
|
}
|
|
}
|
|
|
|
// Pull FIRST so the compose file is never mutated for an image we could not
|
|
// fetch. A sync execFileSync would block the event loop and let the overlay
|
|
// see a false "online" between the pull finishing and the restart.
|
|
const debug = isDebugEnabled();
|
|
const pullStart = Date.now();
|
|
console.log(`[SelfUpdate] Pulling image: ${pullRef}...`);
|
|
if (debug) console.debug('[SelfUpdate:debug] Pull context:', { targetVersion: targetVersion ?? null, repin: !!repin });
|
|
try {
|
|
await execFileAsync('docker', ['pull', pullRef], { env, timeout: 300_000 });
|
|
if (debug) console.debug('[SelfUpdate:debug] Pull completed in', Math.round((Date.now() - pullStart) / 1000) + 's');
|
|
} catch (error) {
|
|
const stderr = (error as { stderr?: Buffer | string })?.stderr?.toString().trim();
|
|
this.lastUpdateError = stderr || (error as Error).message;
|
|
console.error('[SelfUpdate] Pull failed:', this.lastUpdateError);
|
|
return;
|
|
}
|
|
|
|
// Rewrite the pinned tag only after a confirmed pull (semver only). The
|
|
// rewrite is staged under /app/data; the helper copies it onto the host
|
|
// compose file just before recreate.
|
|
let composeCopy: ComposeCopy | undefined;
|
|
if (repin) {
|
|
try {
|
|
const patched = patchComposeServiceImage(repin.resolved.fileContent, serviceName, repin.ref);
|
|
fs.writeFileSync(STAGED_PATCH_FILE, patched, 'utf8');
|
|
composeCopy = { stagedPath: STAGED_PATCH_FILE, targetPath: repin.resolved.filePath };
|
|
console.log(`[SelfUpdate] Repinning "${serviceName}" image to ${repin.ref}`);
|
|
} catch (error) {
|
|
this.lastUpdateError =
|
|
`Pulled ${repin.ref} but could not rewrite the compose image tag (${(error as Error).message}). Change the tag manually and update again.`;
|
|
console.error('[SelfUpdate] Repin failed:', this.lastUpdateError);
|
|
return;
|
|
}
|
|
}
|
|
|
|
this.spawnHelper(env, composeCopy, options?.successMarkerFile, options?.successMarkerContent);
|
|
}
|
|
|
|
/**
|
|
* Recreate the Sencho service from the exact current on-disk Compose project
|
|
* without pulling or rewriting the image reference. Used by Fleet "Reapply
|
|
* configuration". Validates the authored compose via a throwaway helper
|
|
* before the last-breath recreate so invalid config fails before shutdown.
|
|
*/
|
|
async triggerComposeReapply(options?: {
|
|
successMarkerFile?: string;
|
|
successMarkerContent?: string;
|
|
}): Promise<void> {
|
|
if (!this.composeContext) return;
|
|
const env = this.buildEnv();
|
|
this.lastUpdateError = null;
|
|
this.pendingHelperExitError = undefined;
|
|
|
|
try { fs.unlinkSync(UPDATE_ERROR_FILE); } catch { /* absent is the steady state */ }
|
|
try { fs.unlinkSync(STAGED_PATCH_FILE); } catch { /* absent is the steady state */ }
|
|
|
|
const { workingDir, configFiles, imageName, hostBindMounts } = this.composeContext;
|
|
console.log('[SelfUpdate] Validating compose configuration before reapply...');
|
|
try {
|
|
await execFileAsync(
|
|
'docker',
|
|
buildComposeConfigValidateArgs({ workingDir, imageName, hostBindMounts, configFiles }),
|
|
{ env, timeout: 60_000, maxBuffer: 10 * 1024 * 1024 },
|
|
);
|
|
} catch (error) {
|
|
const stderr = (error as { stderr?: Buffer | string })?.stderr?.toString().trim();
|
|
const stdout = (error as { stdout?: Buffer | string })?.stdout?.toString().trim();
|
|
this.lastUpdateError =
|
|
stderr || stdout || (error as Error).message || 'Compose configuration validation failed.';
|
|
console.error('[SelfUpdate] Compose reapply validation failed:', this.lastUpdateError);
|
|
return;
|
|
}
|
|
|
|
// No pull and no compose rewrite: the authored image ref is authoritative.
|
|
// Skip dangling-image prune (nothing was pulled).
|
|
this.spawnHelper(env, undefined, options?.successMarkerFile, options?.successMarkerContent, false);
|
|
}
|
|
|
|
/**
|
|
* Spawn the "last breath" helper container that recreates Sencho (and, when a
|
|
* repin is staged, copies the rewritten compose file onto the host first).
|
|
* Runs attached (no -d): if the recreate fails before it kills us, execFile's
|
|
* callback receives the helper's exit code and stderr directly.
|
|
*/
|
|
private spawnHelper(
|
|
env: NodeJS.ProcessEnv,
|
|
composeCopy?: ComposeCopy,
|
|
successMarkerFile?: string,
|
|
successMarkerContent?: string,
|
|
pruneOnUpdateOverride?: boolean,
|
|
): void {
|
|
if (!this.composeContext) return;
|
|
const { workingDir, configFiles, serviceName, imageName, dataDirHost, hostBindMounts } = this.composeContext;
|
|
|
|
console.log('[SelfUpdate] Spawning updater container... (last breath)');
|
|
if (isDebugEnabled()) console.debug('[SelfUpdate:debug] Helper context:', { workingDir, serviceName, dataDirHost, repin: !!composeCopy, mountCount: hostBindMounts.length });
|
|
const fFlags = configFiles.split(',').flatMap(f => ['-f', f.trim()]);
|
|
|
|
// On failure, persist exit code + stderr to UPDATE_ERROR_FILE (host-mounted)
|
|
// so the NEW gateway can read it after restart if we die mid-execution.
|
|
// Opt-out (default ON): after a clean recreate, prune the dangling image
|
|
// layers the pull orphaned. Read fresh so this node honors its own setting.
|
|
const stderrTmp = '/tmp/_sencho_err';
|
|
const pruneOnUpdate = pruneOnUpdateOverride ?? (
|
|
DatabaseService.getInstance().getGlobalSettings()['prune_on_update'] === '1'
|
|
);
|
|
const composeCmd = buildSelfUpdateComposeCmd(
|
|
fFlags,
|
|
serviceName,
|
|
stderrTmp,
|
|
UPDATE_ERROR_FILE,
|
|
pruneOnUpdate,
|
|
composeCopy,
|
|
successMarkerFile,
|
|
successMarkerContent,
|
|
);
|
|
const args = buildSelfUpdateRunArgs({ workingDir, imageName, dataDirHost, hostBindMounts, repinWritable: !!composeCopy }, composeCmd);
|
|
|
|
// Callback may never fire on success (we die mid-call during recreate);
|
|
// that is fine because the restart itself is the success signal.
|
|
// Surviving a clean helper exit means recreate did not take over this process.
|
|
execFile('docker', args, { env }, (err, _stdout, stderr) => {
|
|
if (err) {
|
|
const stderrText = stderr?.toString().trim();
|
|
this.lastUpdateError = stderrText || err.message || 'Helper container failed';
|
|
console.error('[SelfUpdate] Helper container failed:', this.lastUpdateError);
|
|
} else if (!this.lastUpdateError) {
|
|
this.lastUpdateError = 'Helper container exited without restarting Sencho';
|
|
console.error('[SelfUpdate] Helper container exited cleanly without restarting Sencho');
|
|
}
|
|
const listeners = this.helperExitListeners.splice(0);
|
|
if (listeners.length === 0) {
|
|
this.pendingHelperExitError = this.lastUpdateError!;
|
|
return;
|
|
}
|
|
for (const listener of listeners) {
|
|
try {
|
|
listener(this.lastUpdateError);
|
|
} catch (listenerError) {
|
|
console.error('[SelfUpdate] Helper exit listener failed:', listenerError);
|
|
}
|
|
}
|
|
});
|
|
// No code after this point is guaranteed to run: the helper recreates this container.
|
|
}
|
|
}
|
|
|
|
export default SelfUpdateService;
|