feat: add Admiral Hardened Build channel and business assurance surfaces (#1629)

* feat: add Admiral Hardened Build channel and business assurance surfaces

Introduce Studio Saelix entitlement-backed Hardened Build switching, a
single-flight image operation coordinator, Recovery Vault naming, Admiral
Account settings, and typed Fleet update failures while preserving Community
custom-repo and targetless pull-current updates.

* fix: harden image-op paths and clear CI CodeQL/pilot flake

Validate operation IDs before filesystem use, use hostname checks in Fleet
fetch mocks, sanitize registry probe logs, and swallow expected TCP teardown
errors in the pilot reverse-route post-handshake test.

* fix: sanitize image-op docker config write and probe logs

Allowlist-copy registry host keys and base64 auth before writing the
temp DOCKER_CONFIG, and log registry probe failures with a fixed message
so CodeQL no longer flags network-to-file and log-injection mediums.

* fix: address Admiral Hardened Build audit blockers

Expose imageChannel so hardened Fleet peers still POST for typed rejection, claim community updates before 202, terminalize helper failures, gate Hardened on paid, and align support/docs/e2e wording.

* fix: terminalize image ops on helper survival and aborted claims

* fix: prevent recreating persist from overwriting helper-exit failure

* test: assert helper-exit failure lands before recreating persist

* fix: keep current pointer when acknowledging a stale image operation
This commit is contained in:
Anso
2026-07-14 10:47:54 -04:00
committed by GitHub
parent 8ca8ebaa24
commit 381ed2a91f
54 changed files with 2302 additions and 125 deletions
+86 -9
View File
@@ -128,6 +128,8 @@ export function buildSelfUpdateComposeCmd(
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
@@ -142,6 +144,9 @@ export function buildSelfUpdateComposeCmd(
...(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('; ');
@@ -205,6 +210,9 @@ class 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 {
@@ -305,6 +313,23 @@ class SelfUpdateService {
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;
@@ -322,6 +347,15 @@ class SelfUpdateService {
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.
@@ -401,37 +435,50 @@ class SelfUpdateService {
* 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 }): Promise<void> {
async triggerUpdate(options?: {
targetVersion?: string;
targetImageRef?: string;
dockerConfigPath?: string;
successMarkerFile?: string;
successMarkerContent?: string;
}): Promise<void> {
if (!this.composeContext) return;
const env = this.buildEnv();
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) {
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 || isRepinBlocked(pinKind)) {
if (!resolved || (!targetImageRef && isRepinBlocked(pinKind))) {
this.lastUpdateError = resolved ? UPDATE_BLOCKED_REASON : UPDATE_READ_FAILED_REASON;
console.error('[SelfUpdate] Update blocked:', this.lastUpdateError);
return;
}
pullRef = pinKind === 'semver' ? buildTargetImageRef(resolved.imageRef, targetVersion) : resolved.imageRef;
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') {
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.';
@@ -477,7 +524,7 @@ class SelfUpdateService {
}
}
this.spawnHelper(env, composeCopy);
this.spawnHelper(env, composeCopy, options?.successMarkerFile, options?.successMarkerContent);
}
/**
@@ -486,7 +533,12 @@ class SelfUpdateService {
* 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): void {
private spawnHelper(
env: NodeJS.ProcessEnv,
composeCopy?: ComposeCopy,
successMarkerFile?: string,
successMarkerContent?: string,
): void {
if (!this.composeContext) return;
const { workingDir, configFiles, serviceName, imageName, dataDirHost, hostBindMounts } = this.composeContext;
@@ -501,16 +553,41 @@ class SelfUpdateService {
const stderrTmp = '/tmp/_sencho_err';
const pruneOnUpdate =
DatabaseService.getInstance().getGlobalSettings()['prune_on_update'] === '1';
const composeCmd = buildSelfUpdateComposeCmd(fFlags, serviceName, stderrTmp, UPDATE_ERROR_FILE, pruneOnUpdate, composeCopy);
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.