mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 06:46:23 +00:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user