mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 06:46:23 +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.
316 lines
15 KiB
TypeScript
316 lines
15 KiB
TypeScript
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
import { cleanupTestDb, setupTestDb } from './helpers/setupTestDb';
|
|
|
|
let tmpDir: string;
|
|
let ImageOperationService: typeof import('../services/ImageOperationService').ImageOperationService;
|
|
let SelfUpdateService: typeof import('../services/SelfUpdateService').default;
|
|
let HardenedEntitlementService: typeof import('../services/HardenedEntitlementService').HardenedEntitlementService;
|
|
let RegistryService: typeof import('../services/RegistryService').RegistryService;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ ImageOperationService } = await import('../services/ImageOperationService'));
|
|
SelfUpdateService = (await import('../services/SelfUpdateService')).default;
|
|
({ HardenedEntitlementService } = await import('../services/HardenedEntitlementService'));
|
|
({ RegistryService } = await import('../services/RegistryService'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
const selfUpdate = SelfUpdateService.getInstance() as unknown as {
|
|
pendingHelperExitError: string | undefined;
|
|
helperExitListeners: Array<(error: string | null) => void>;
|
|
};
|
|
selfUpdate.pendingHelperExitError = undefined;
|
|
selfUpdate.helperExitListeners = [];
|
|
await fs.rm(path.join(tmpDir, 'image-operation-current.json'), { force: true });
|
|
await fs.rm(path.join(tmpDir, 'image-operations'), { recursive: true, force: true });
|
|
});
|
|
|
|
afterAll(() => cleanupTestDb(tmpDir));
|
|
|
|
describe('ImageOperationService', () => {
|
|
it('rejects a second update while the first operation is claimed', async () => {
|
|
let releaseUpdate: (() => void) | undefined;
|
|
let markTriggered: (() => void) | undefined;
|
|
const triggered = new Promise<void>(resolve => { markTriggered = resolve; });
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue(null);
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockImplementation(async () => {
|
|
markTriggered!();
|
|
await new Promise<void>(resolve => { releaseUpdate = resolve; });
|
|
});
|
|
|
|
const service = ImageOperationService.getInstance();
|
|
const first = service.runCommunityUpdate();
|
|
await triggered;
|
|
const second = await service.runCommunityUpdate();
|
|
|
|
expect(second).toEqual({ ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' });
|
|
releaseUpdate!();
|
|
await expect(first).resolves.toEqual({ ok: true });
|
|
});
|
|
|
|
it('claims synchronously before checking existing operations', async () => {
|
|
let releaseLookup: (() => void) | undefined;
|
|
let signalLookupStarted: (() => void) | undefined;
|
|
const lookupStarted = new Promise<void>(resolve => { signalLookupStarted = resolve; });
|
|
let lookups = 0;
|
|
const service = ImageOperationService.getInstance();
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
|
|
vi.spyOn(service, 'getCurrentOperation').mockImplementation(async () => {
|
|
lookups += 1;
|
|
if (lookups === 1) {
|
|
signalLookupStarted!();
|
|
await new Promise<void>(resolve => { releaseLookup = resolve; });
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const first = service.claimCommunityUpdate();
|
|
await lookupStarted;
|
|
const second = await service.claimCommunityUpdate();
|
|
releaseLookup!();
|
|
|
|
await expect(first).resolves.toEqual({ ok: true });
|
|
expect(second).toEqual({ ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' });
|
|
});
|
|
|
|
it('persists a terminal failure when the update helper reports an error', async () => {
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockResolvedValue(undefined);
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue('pull failed');
|
|
|
|
const result = await ImageOperationService.getInstance().runCommunityUpdate();
|
|
const current = await ImageOperationService.getInstance().getCurrentOperation();
|
|
|
|
expect(result).toEqual({ ok: false, failureCode: 'update_failed' });
|
|
expect(current?.state).toBe('failed');
|
|
expect(current?.failureCode).toBe('update_failed');
|
|
});
|
|
|
|
it('fails a hardened update when the helper reports an error and binds its marker to the operation', async () => {
|
|
const entitlement = {
|
|
success: true as const,
|
|
entitlement: {
|
|
hardened_build_access: true,
|
|
channel: 'hardened' as const,
|
|
allowed_image_ref: 'ghcr.io/studio-saelix/sencho-hardened:latest',
|
|
pin_recommendation: 'ghcr.io/studio-saelix/sencho-hardened:latest',
|
|
checked_at: '2026-07-13T00:00:00.000Z',
|
|
registry_requirement: {
|
|
registry_host: 'ghcr.io',
|
|
package_scope: 'studio-saelix/sencho-hardened',
|
|
credential_instructions: 'Use a pull token.',
|
|
supports_pull_token: true,
|
|
},
|
|
},
|
|
};
|
|
const resolved = {
|
|
filePath: '/compose.yml',
|
|
imageRef: 'ghcr.io/studio-saelix/sencho-hardened:latest',
|
|
pinKind: 'semver' as const,
|
|
fileContent: 'services: {}',
|
|
};
|
|
let markerFile = '';
|
|
let markerContent = '';
|
|
vi.spyOn(HardenedEntitlementService.getInstance(), 'getEntitlement').mockResolvedValue(entitlement);
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(resolved);
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
|
|
vi.spyOn(RegistryService.getInstance(), 'resolveDockerConfigForHost').mockResolvedValue({
|
|
config: { auths: { 'ghcr.io': { auth: 'credential' } } },
|
|
warnings: [],
|
|
});
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockImplementation(async options => {
|
|
markerFile = options?.successMarkerFile ?? '';
|
|
markerContent = options?.successMarkerContent ?? '';
|
|
});
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue('pull failed');
|
|
|
|
const service = ImageOperationService.getInstance();
|
|
const fingerprint = service.computePreflightFingerprint(
|
|
resolved.filePath,
|
|
resolved.imageRef,
|
|
resolved.pinKind,
|
|
entitlement.entitlement.allowed_image_ref,
|
|
);
|
|
const result = await service.switchToHardened(fingerprint);
|
|
const current = await service.getCurrentOperation();
|
|
|
|
expect(result).toEqual({ ok: false, code: 'update_failed' });
|
|
expect(current?.state).toBe('failed');
|
|
expect(markerFile).toBe(path.join(tmpDir, `image-op-success-${current?.operationId}.json`));
|
|
expect(markerContent).toBe(JSON.stringify({ ok: true, operationId: current?.operationId }));
|
|
});
|
|
|
|
|
|
it('fails a recreating community op when the helper exits after handoff', async () => {
|
|
let helperExit: ((error: string | null) => void) | undefined;
|
|
const callOrder: string[] = [];
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'onceHelperExit').mockImplementation((listener) => {
|
|
callOrder.push('watch');
|
|
helperExit = listener;
|
|
});
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockImplementation(async () => {
|
|
callOrder.push('trigger');
|
|
});
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue(null);
|
|
|
|
const service = ImageOperationService.getInstance();
|
|
const result = await service.runCommunityUpdate();
|
|
const mid = await service.getCurrentOperation();
|
|
|
|
expect(result).toEqual({ ok: true });
|
|
expect(mid?.state).toBe('recreating');
|
|
expect(callOrder).toEqual(['watch', 'trigger']);
|
|
expect(helperExit).toBeTypeOf('function');
|
|
|
|
helperExit!('Helper container exited without restarting Sencho');
|
|
await vi.waitFor(async () => {
|
|
const current = await service.getCurrentOperation();
|
|
expect(current?.state).toBe('failed');
|
|
expect(current?.failureCode).toBe('update_failed');
|
|
});
|
|
});
|
|
|
|
it('does not let recreating persist overwrite a concurrent helper-exit failure', async () => {
|
|
let helperExit: ((error: string | null) => void) | undefined;
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'onceHelperExit').mockImplementation((listener) => {
|
|
helperExit = listener;
|
|
});
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockResolvedValue(undefined);
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue(null);
|
|
|
|
const service = ImageOperationService.getInstance();
|
|
const proto = Object.getPrototypeOf(service) as {
|
|
persist: (operation: unknown) => Promise<void>;
|
|
};
|
|
const realPersist = proto.persist.bind(service);
|
|
vi.spyOn(service as unknown as { persist: (operation: { state: string }) => Promise<void> }, 'persist')
|
|
.mockImplementation(async (operation) => {
|
|
if (operation.state === 'recreating' && helperExit) {
|
|
const exit = helperExit;
|
|
helperExit = undefined;
|
|
// Fail must land on disk before the recreating write runs. That is the
|
|
// overwrite order without CAS: helper-exit failed, then late recreating.
|
|
exit('Helper container exited without restarting Sencho');
|
|
await vi.waitFor(async () => {
|
|
const current = await service.getCurrentOperation();
|
|
expect(current?.state).toBe('failed');
|
|
expect(current?.failureCode).toBe('update_failed');
|
|
});
|
|
await realPersist(operation);
|
|
return;
|
|
}
|
|
return realPersist(operation);
|
|
});
|
|
|
|
await service.runCommunityUpdate();
|
|
const current = await service.getCurrentOperation();
|
|
expect(current?.state).toBe('failed');
|
|
expect(current?.failureCode).toBe('update_failed');
|
|
});
|
|
|
|
it('does not let acknowledging a stale failure replace an active current operation', async () => {
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockResolvedValue(undefined);
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue('pull failed');
|
|
|
|
const service = ImageOperationService.getInstance();
|
|
await service.runCommunityUpdate();
|
|
const failedA = await service.getCurrentOperation();
|
|
expect(failedA?.state).toBe('failed');
|
|
const operationAId = failedA!.operationId;
|
|
|
|
const claimB = await service.claimCommunityUpdate();
|
|
expect(claimB).toEqual({ ok: true });
|
|
const currentB = await service.getCurrentOperation();
|
|
expect(currentB?.state).toBe('pending_pull');
|
|
expect(currentB?.operationId).not.toBe(operationAId);
|
|
|
|
expect(await service.acknowledge(operationAId)).toBe(true);
|
|
const stillB = await service.getCurrentOperation();
|
|
expect(stillB?.operationId).toBe(currentB!.operationId);
|
|
expect(stillB?.state).toBe('pending_pull');
|
|
|
|
const ackedA = await service.getOperation(operationAId);
|
|
expect(ackedA?.acknowledgedAt).toBeTruthy();
|
|
|
|
const claimC = await service.claimCommunityUpdate();
|
|
expect(claimC).toEqual({ ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' });
|
|
});
|
|
|
|
it('replays a pending helper exit to a late onceHelperExit listener', async () => {
|
|
const svc = SelfUpdateService.getInstance();
|
|
const internal = svc as unknown as { pendingHelperExitError: string | undefined };
|
|
internal.pendingHelperExitError = 'Helper container exited without restarting Sencho';
|
|
|
|
const seen = new Promise<string | null>((resolve) => {
|
|
svc.onceHelperExit(resolve);
|
|
});
|
|
|
|
await expect(seen).resolves.toBe('Helper container exited without restarting Sencho');
|
|
expect(internal.pendingHelperExitError).toBeUndefined();
|
|
});
|
|
|
|
it('changes the fingerprint when a preflight value changes', () => {
|
|
const service = ImageOperationService.getInstance();
|
|
const baseline = service.computePreflightFingerprint('/compose.yml', 'saelix/sencho:1.0.0', 'semver', 'ghcr.io/studio-saelix/sencho-hardened@sha256:aaa');
|
|
const changed = service.computePreflightFingerprint('/compose.yml', 'saelix/sencho:1.0.0', 'semver', 'ghcr.io/studio-saelix/sencho-hardened@sha256:bbb');
|
|
|
|
expect(changed).not.toBe(baseline);
|
|
});
|
|
|
|
it('executeClaimedComposeReapply transitions to recreating and watches helper exit before trigger', async () => {
|
|
const callOrder: string[] = [];
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'onceHelperExit').mockImplementation(() => {
|
|
callOrder.push('watch');
|
|
});
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'triggerComposeReapply').mockImplementation(async () => {
|
|
callOrder.push('trigger');
|
|
});
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue(null);
|
|
|
|
const service = ImageOperationService.getInstance();
|
|
const claim = await service.claimComposeReapply();
|
|
expect(claim).toEqual({ ok: true });
|
|
const result = await service.executeClaimedComposeReapply();
|
|
const current = await service.getCurrentOperation();
|
|
|
|
expect(result).toEqual({ ok: true });
|
|
expect(current?.kind).toBe('compose_reapply');
|
|
expect(current?.state).toBe('recreating');
|
|
expect(callOrder).toEqual(['watch', 'trigger']);
|
|
});
|
|
|
|
it('reconcileOnStartup resolves compose_reapply via marker-only success without pin match', async () => {
|
|
const service = ImageOperationService.getInstance();
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
|
|
const claim = await service.claimComposeReapply();
|
|
expect(claim).toEqual({ ok: true });
|
|
const current = await service.getCurrentOperation();
|
|
expect(current?.kind).toBe('compose_reapply');
|
|
expect(current?.targetImageRef).toBeNull();
|
|
|
|
const markerPath = path.join(tmpDir, `image-op-success-${current!.operationId}.json`);
|
|
await fs.writeFile(markerPath, JSON.stringify({ ok: true, operationId: current!.operationId }), 'utf8');
|
|
|
|
await service.reconcileOnStartup();
|
|
const resolved = await service.getCurrentOperation();
|
|
expect(resolved?.state).toBe('succeeded');
|
|
});
|
|
});
|