feat(fleet): reapply Compose configuration without a version update (#1716)

* 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.
This commit is contained in:
Anso
2026-07-28 14:26:46 -04:00
committed by GitHub
parent 543e4ef256
commit b0b423b234
42 changed files with 1770 additions and 168 deletions
@@ -1,3 +1,5 @@
export type FleetOperationKind = 'update' | 'reapply_configuration';
export interface UpdateTracker {
status: 'updating' | 'completed' | 'timeout' | 'failed';
startedAt: number;
@@ -11,6 +13,8 @@ export interface UpdateTracker {
wasOffline: boolean;
/** Timestamp when the tracker transitioned to a terminal state (completed/failed/timeout). */
resolvedAt?: number;
/** Distinguishes version updates from compose reapply so poll heuristics stay correct. */
operationKind: FleetOperationKind;
}
export type TerminalStatus = 'completed' | 'failed' | 'timeout';
@@ -61,13 +65,15 @@ export class FleetUpdateTrackerService {
return this.trackers.size;
}
/** Create a new tracker with `startedAt=now` and resolvedAt set if terminal. */
/** Create a new tracker with `startedAt=now` and resolvedAt set if terminal.
* `operationKind` defaults to `'update'` so existing call sites stay unchanged. */
public create(
status: UpdateTracker['status'],
previousVersion: string | null,
previousProcessStart: number | null,
error?: string,
code?: string,
operationKind: FleetOperationKind = 'update',
): UpdateTracker {
const now = Date.now();
return {
@@ -78,6 +84,7 @@ export class FleetUpdateTrackerService {
wasOffline: false,
error,
code,
operationKind,
resolvedAt: status !== 'updating' ? now : undefined,
};
}
+67 -26
View File
@@ -8,7 +8,7 @@ import type { ImagePinKind } from '../helpers/selfUpdateCompose';
import type { LocalRegistryAccess } from './hardenedEntitlementTypes';
import { getAuthToken, httpRequest } from './registry-api';
export type ImageOperationKind = 'switch' | 'update' | 'community_update';
export type ImageOperationKind = 'switch' | 'update' | 'community_update' | 'compose_reapply';
export type ImageOperationState = 'pending_pull' | 'pulling' | 'patching' | 'recreating' | 'succeeded' | 'failed';
type FailureCode = 'self_update_unavailable' | 'entitlement_denied' | 'preflight_mismatch' | 'compose_unavailable' | 'registry_access_unavailable' | 'update_failed' | 'interrupted_by_restart';
@@ -146,32 +146,12 @@ export class ImageOperationService {
public async claimCommunityUpdate(options?: { targetVersion?: string }): Promise<
{ ok: true } | { ok: false; failureCode: 'IMAGE_OPERATION_IN_FLIGHT' }
> {
const selfUpdate = SelfUpdateService.getInstance();
const resolved = await selfUpdate.getResolvedComposeImageForUpdate();
const operation = this.newOperation(
'community_update',
resolved?.imageRef ?? null,
options?.targetVersion ?? null,
resolved?.filePath ?? null,
selfUpdate.getComposeServiceName(),
);
if (!await this.tryClaim(operation)) {
return { ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' };
}
// Disk non-terminal state is the concurrency lock; clear the in-memory mutex
// so a later claim can observe the persisted pending operation.
this.claimed = false;
return { ok: true };
return this.claimComposeOperation('community_update', options?.targetVersion ?? null);
}
public async executeClaimedCommunityUpdate(options?: { targetVersion?: string }): Promise<{ ok: boolean; failureCode?: string }> {
const operation = await this.getCurrentOperation();
if (!operation || operation.kind !== 'community_update') {
return { ok: false, failureCode: 'update_failed' };
}
if (!['pending_pull', 'pulling', 'patching', 'recreating'].includes(operation.state)) {
return { ok: false, failureCode: 'update_failed' };
}
const operation = await this.getActiveClaimedOperation('community_update');
if (!operation) return { ok: false, failureCode: 'update_failed' };
const selfUpdate = SelfUpdateService.getInstance();
try {
operation.state = 'pulling';
@@ -207,6 +187,37 @@ export class ImageOperationService {
return this.executeClaimedCommunityUpdate(options);
}
public async claimComposeReapply(): Promise<
{ ok: true } | { ok: false; failureCode: 'IMAGE_OPERATION_IN_FLIGHT' }
> {
return this.claimComposeOperation('compose_reapply', null);
}
public async executeClaimedComposeReapply(): Promise<{ ok: boolean; failureCode?: string }> {
const operation = await this.getActiveClaimedOperation('compose_reapply');
if (!operation) return { ok: false, failureCode: 'update_failed' };
const selfUpdate = SelfUpdateService.getInstance();
try {
// No pull/patch for reapply: jump straight to recreating.
operation.state = 'recreating';
await this.persist(operation);
this.watchHelperExit(operation);
await selfUpdate.triggerComposeReapply({
successMarkerFile: this.successMarkerFile(operation),
successMarkerContent: JSON.stringify({ ok: true, operationId: operation.operationId }),
});
if (selfUpdate.getLastError()) {
await this.fail(operation, 'update_failed');
return { ok: false, failureCode: 'update_failed' };
}
return { ok: true };
} catch (error) {
console.error('[ImageOperation] Compose reapply failed:', error);
await this.fail(operation, 'update_failed');
return { ok: false, failureCode: 'update_failed' };
}
}
public async getOperation(operationId: string): Promise<ImageOperation | null> {
const filePath = this.operationFile(operationId);
if (!filePath) return null;
@@ -243,8 +254,9 @@ export class ImageOperationService {
const markerPath = this.successMarkerFile(operation);
for (let elapsed = 0; elapsed < 30_000; elapsed += 1_000) {
const markerOk = await this.isSuccessMarkerForOperation(markerPath, operation.operationId);
if (operation.kind === 'community_update') {
// Community success is the marker alone; floating tags may not equal targetImageRef.
if (operation.kind === 'community_update' || operation.kind === 'compose_reapply') {
// Marker-only success: community updates may leave floating tags that do
// not equal targetImageRef, and reapply never sets a target image at all.
if (markerOk) {
operation.state = 'succeeded';
operation.resolvedAt = new Date().toISOString();
@@ -284,6 +296,35 @@ export class ImageOperationService {
});
}
private async claimComposeOperation(
kind: 'community_update' | 'compose_reapply',
targetImageRef: string | null,
): Promise<{ ok: true } | { ok: false; failureCode: 'IMAGE_OPERATION_IN_FLIGHT' }> {
const selfUpdate = SelfUpdateService.getInstance();
const resolved = await selfUpdate.getResolvedComposeImageForUpdate();
const operation = this.newOperation(
kind,
resolved?.imageRef ?? null,
targetImageRef,
resolved?.filePath ?? null,
selfUpdate.getComposeServiceName(),
);
if (!await this.tryClaim(operation)) {
return { ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' };
}
// Disk non-terminal state is the concurrency lock; clear the in-memory mutex
// so a later claim can observe the persisted pending operation.
this.claimed = false;
return { ok: true };
}
private async getActiveClaimedOperation(kind: ImageOperationKind): Promise<ImageOperation | null> {
const operation = await this.getCurrentOperation();
if (!operation || operation.kind !== kind) return null;
if (!['pending_pull', 'pulling', 'patching', 'recreating'].includes(operation.state)) return null;
return operation;
}
private newOperation(kind: ImageOperationKind, previousImageRef: string | null, targetImageRef: string | null, composeFilePath: string | null, serviceName: string | null, preflightFingerprint?: string): ImageOperation {
return {
schemaVersion: 1,
+65 -2
View File
@@ -205,6 +205,27 @@ export function buildSelfUpdateRunArgs(
];
}
/**
* 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;
@@ -527,6 +548,46 @@ class SelfUpdateService {
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).
@@ -538,6 +599,7 @@ class SelfUpdateService {
composeCopy?: ComposeCopy,
successMarkerFile?: string,
successMarkerContent?: string,
pruneOnUpdateOverride?: boolean,
): void {
if (!this.composeContext) return;
const { workingDir, configFiles, serviceName, imageName, dataDirHost, hostBindMounts } = this.composeContext;
@@ -551,8 +613,9 @@ class SelfUpdateService {
// 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 =
DatabaseService.getInstance().getGlobalSettings()['prune_on_update'] === '1';
const pruneOnUpdate = pruneOnUpdateOverride ?? (
DatabaseService.getInstance().getGlobalSettings()['prune_on_update'] === '1'
);
const composeCmd = buildSelfUpdateComposeCmd(
fFlags,
serviceName,