Files
sencho/backend/src/__tests__/image-operation-service.test.ts
T
Anso b0b423b234 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.
2026-07-28 14:26:46 -04:00

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');
});
});