mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 23:32:19 +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:
@@ -73,6 +73,7 @@ function setTracker(over: Partial<import('../services/FleetUpdateTrackerService'
|
||||
previousVersion: '0.83.0',
|
||||
previousProcessStart: 1,
|
||||
wasOffline: false,
|
||||
operationKind: 'update',
|
||||
...over,
|
||||
});
|
||||
}
|
||||
@@ -370,6 +371,7 @@ describe('forced-recheck throttle', () => {
|
||||
FleetUpdateTrackerService.getInstance().set(proxyNodeId, {
|
||||
status: 'failed', startedAt: Date.now(), previousVersion: null,
|
||||
previousProcessStart: null, wasOffline: false, resolvedAt: Date.now(), error: 'boom',
|
||||
operationKind: 'update',
|
||||
});
|
||||
|
||||
const second = await request(app)
|
||||
@@ -413,3 +415,170 @@ describe('GET /api/fleet/update-status/release-notes', () => {
|
||||
expect(res.body.htmlUrl).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('compose reapply status and concurrency', () => {
|
||||
it('exposes canReapplyCompose for local when SelfUpdateService is available', async () => {
|
||||
vi.spyOn(SelfUpdateService.getInstance(), 'isAvailable').mockReturnValue(true);
|
||||
mockCompareTargetFetch();
|
||||
const res = await request(app).get('/api/fleet/update-status').set('Authorization', adminAuth);
|
||||
expect(res.status).toBe(200);
|
||||
const local = res.body.nodes.find((n: { type: string }) => n.type === 'local');
|
||||
expect(local.canReapplyCompose).toBe(true);
|
||||
});
|
||||
|
||||
it('sets canReapplyCompose false for a remote without self-update capability', async () => {
|
||||
mockMeta(ONLINE({ capabilities: ['stacks'] }));
|
||||
mockCompareTargetFetch();
|
||||
const res = await request(app).get('/api/fleet/update-status').set('Authorization', adminAuth);
|
||||
expect(res.status).toBe(200);
|
||||
const remote = res.body.nodes.find((n: { nodeId: number }) => n.nodeId === proxyNodeId);
|
||||
expect(remote.canReapplyCompose).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves a reapply tracker via startedAt change without requiring a version bump', async () => {
|
||||
setTracker({
|
||||
operationKind: 'reapply_configuration',
|
||||
previousVersion: '0.83.0',
|
||||
previousProcessStart: 1,
|
||||
startedAt: Date.now() - RECENT_MS,
|
||||
});
|
||||
mockMeta(ONLINE({ version: '0.83.0', startedAt: 2 }));
|
||||
mockCompareTargetFetch();
|
||||
expect(await getStatus()).toBe('completed');
|
||||
});
|
||||
|
||||
it('does not complete a reapply tracker via signal 4 when version is already current', async () => {
|
||||
setTracker({
|
||||
operationKind: 'reapply_configuration',
|
||||
previousVersion: '0.99.0',
|
||||
previousProcessStart: 1,
|
||||
startedAt: Date.now() - 20_000,
|
||||
});
|
||||
// Node already at compare target; signal 4 would false-complete an update,
|
||||
// but must not for reapply while startedAt is unchanged.
|
||||
mockMeta(ONLINE({ version: '0.99.0', startedAt: 1 }));
|
||||
mockCompareTargetFetch();
|
||||
expect(await getStatus()).toBe('updating');
|
||||
});
|
||||
|
||||
it('returns 409 when reapply is requested while an update tracker is in flight', async () => {
|
||||
setTracker({ operationKind: 'update' });
|
||||
mockTarget();
|
||||
const res = await request(app)
|
||||
.post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`)
|
||||
.set('Authorization', adminAuth);
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body?.error).toMatch(/already in progress/i);
|
||||
});
|
||||
|
||||
it('dispatches remote reapply to /api/system/reapply-compose without updateBlocked gating', async () => {
|
||||
mockTarget();
|
||||
mockMeta(ONLINE({ updateBlocked: true, imagePinKind: 'digest', imageChannel: 'community' }));
|
||||
let reapplyUrl: string | null = null;
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
const url = String(input);
|
||||
try {
|
||||
if (new URL(url).hostname === 'api.github.com') {
|
||||
return new Response(JSON.stringify({ tag_name: 'v0.99.0' }), { status: 200 });
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
reapplyUrl = url;
|
||||
return new Response(JSON.stringify({ message: 'ok' }), { status: 202 });
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`)
|
||||
.set('Authorization', adminAuth);
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
expect(reapplyUrl).toContain('/api/system/reapply-compose');
|
||||
expect(FleetUpdateTrackerService.getInstance().get(proxyNodeId)?.operationKind)
|
||||
.toBe('reapply_configuration');
|
||||
});
|
||||
|
||||
it('reserves the tracker before remote dispatch so a concurrent reapply gets 409 without overwriting success', async () => {
|
||||
mockTarget();
|
||||
// Hold meta so the first request sits in the dispatch set before the
|
||||
// pollable tracker exists; the second must still 409 on that lock.
|
||||
let releaseMeta!: (value: RemoteMeta) => void;
|
||||
const metaHeld = new Promise<RemoteMeta>((resolve) => { releaseMeta = resolve; });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockImplementation(async () => metaHeld);
|
||||
|
||||
let releaseRemote!: (value: Response) => void;
|
||||
const remoteHeld = new Promise<Response>((resolve) => { releaseRemote = resolve; });
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
const url = String(input);
|
||||
try {
|
||||
if (new URL(url).hostname === 'api.github.com') {
|
||||
return new Response(JSON.stringify({ tag_name: 'v0.99.0' }), { status: 200 });
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
if (url.includes('/api/system/reapply-compose')) {
|
||||
return remoteHeld;
|
||||
}
|
||||
return new Response('{}', { status: 200 });
|
||||
});
|
||||
|
||||
// Supertest is lazy until the thenable is consumed; start the request now.
|
||||
const firstPromise = request(app)
|
||||
.post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`)
|
||||
.set('Authorization', adminAuth)
|
||||
.then((res) => res);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(NodeRegistry.getInstance().fetchMetaForNode).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const secondDuringMeta = await request(app)
|
||||
.post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`)
|
||||
.set('Authorization', adminAuth);
|
||||
expect(secondDuringMeta.status).toBe(409);
|
||||
expect(secondDuringMeta.body?.error).toMatch(/already in progress/i);
|
||||
|
||||
releaseMeta(ONLINE());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(FleetUpdateTrackerService.getInstance().get(proxyNodeId)?.status).toBe('updating');
|
||||
});
|
||||
|
||||
const secondDuringPost = await request(app)
|
||||
.post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`)
|
||||
.set('Authorization', adminAuth);
|
||||
expect(secondDuringPost.status).toBe(409);
|
||||
|
||||
releaseRemote(new Response(JSON.stringify({ message: 'ok' }), { status: 202 }));
|
||||
const first = await firstPromise;
|
||||
expect(first.status).toBe(202);
|
||||
const tracker = FleetUpdateTrackerService.getInstance().get(proxyNodeId);
|
||||
expect(tracker?.status).toBe('updating');
|
||||
expect(tracker?.operationKind).toBe('reapply_configuration');
|
||||
expect(tracker?.previousVersion).toBe('0.83.0');
|
||||
expect(tracker?.previousProcessStart).toBe(1);
|
||||
expect(tracker?.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('marks a reserved remote reapply as failed when the peer rejects, without leaving a false updating row for a second request', async () => {
|
||||
mockTarget();
|
||||
mockMeta(ONLINE());
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
const url = String(input);
|
||||
try {
|
||||
if (new URL(url).hostname === 'api.github.com') {
|
||||
return new Response(JSON.stringify({ tag_name: 'v0.99.0' }), { status: 200 });
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
return new Response(JSON.stringify({
|
||||
error: 'An image operation is already in progress.',
|
||||
code: 'IMAGE_OPERATION_IN_FLIGHT',
|
||||
}), { status: 409 });
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`)
|
||||
.set('Authorization', adminAuth);
|
||||
expect(res.status).toBe(502);
|
||||
const tracker = FleetUpdateTrackerService.getInstance().get(proxyNodeId);
|
||||
expect(tracker?.status).toBe('failed');
|
||||
expect(tracker?.error).toMatch(/already in progress/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ function mk(over: Partial<UpdateTracker>): UpdateTracker {
|
||||
previousVersion: null,
|
||||
previousProcessStart: null,
|
||||
wasOffline: false,
|
||||
operationKind: 'update',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -270,4 +270,46 @@ describe('ImageOperationService', () => {
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '../helpers/selfUpdateCompose';
|
||||
import {
|
||||
buildComposeReadArgs,
|
||||
buildComposeConfigValidateArgs,
|
||||
buildSelfUpdateComposeCmd,
|
||||
buildSelfUpdateRunArgs,
|
||||
shQuote,
|
||||
@@ -302,3 +303,22 @@ describe('buildSelfUpdateRunArgs (repinWritable branch)', () => {
|
||||
expect(args).not.toContain('/opt/sencho:/opt/sencho:rw');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildComposeConfigValidateArgs', () => {
|
||||
it('runs compose config in a throwaway helper with the working dir mounted read-only', () => {
|
||||
const args = buildComposeConfigValidateArgs({
|
||||
workingDir: '/opt/sencho',
|
||||
imageName: 'saelix/sencho:1.0.0',
|
||||
configFiles: 'docker-compose.yml,/opt/sencho/override.yml',
|
||||
hostBindMounts: [{ source: '/etc/sencho', destination: '/etc/sencho' }],
|
||||
});
|
||||
expect(args).toContain('/opt/sencho:/opt/sencho:ro');
|
||||
expect(args).toContain('/var/run/docker.sock:/var/run/docker.sock');
|
||||
expect(args).toContain('/etc/sencho:/etc/sencho:ro');
|
||||
const cmd = args[args.length - 1];
|
||||
expect(cmd).toContain('docker compose');
|
||||
expect(cmd).toContain('config');
|
||||
expect(cmd).toContain(shQuote('docker-compose.yml'));
|
||||
expect(cmd).toContain(shQuote('/opt/sencho/override.yml'));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user