mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-26 02:06:49 +00:00
feat: add build-aware compose stack updates (#1561)
Detect services with build: in the update preview and run compose build --pull plus pull --ignore-buildable when Update is triggered on those stacks, while keeping the existing pull-only path for image-only stacks.
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
extractBuildServicesFromCompose,
|
||||
extractBuildServicesFromRenderedConfig,
|
||||
} from '../services/ImageUpdateService';
|
||||
|
||||
describe('extractBuildServicesFromCompose', () => {
|
||||
it('returns service names that declare build', () => {
|
||||
const yaml = `
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
api:
|
||||
image: nginx:1.25
|
||||
worker:
|
||||
build:
|
||||
context: ./worker
|
||||
dockerfile: Dockerfile
|
||||
`;
|
||||
expect(extractBuildServicesFromCompose(yaml).sort()).toEqual(['web', 'worker']);
|
||||
});
|
||||
|
||||
it('returns empty for image-only stacks', () => {
|
||||
const yaml = `
|
||||
services:
|
||||
web:
|
||||
image: nginx:1.25
|
||||
`;
|
||||
expect(extractBuildServicesFromCompose(yaml)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores empty build sections', () => {
|
||||
const yaml = `
|
||||
services:
|
||||
web:
|
||||
build: ""
|
||||
api:
|
||||
build: {}
|
||||
`;
|
||||
expect(extractBuildServicesFromCompose(yaml)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBuildServicesFromRenderedConfig', () => {
|
||||
it('reads build services from a rendered compose json model', () => {
|
||||
const rendered = JSON.stringify({
|
||||
services: {
|
||||
app: { build: { context: '/app' }, image: 'myapp:latest' },
|
||||
cache: { image: 'redis:7' },
|
||||
},
|
||||
});
|
||||
expect(extractBuildServicesFromRenderedConfig(rendered)).toEqual(['app']);
|
||||
});
|
||||
|
||||
it('includes override-only build services from merged model', () => {
|
||||
const rendered = JSON.stringify({
|
||||
services: {
|
||||
web: { image: 'nginx:1.25' },
|
||||
sidecar: { build: './sidecar' },
|
||||
},
|
||||
});
|
||||
expect(extractBuildServicesFromRenderedConfig(rendered)).toEqual(['sidecar']);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ const {
|
||||
mockMkdtempSync, mockWriteFileSync, mockUnlinkSync, mockRmdirSync,
|
||||
mockGetGlobalSettings, mockPruneDanglingImages, mockGetBindMounts,
|
||||
mockGetStackContent, mockGetEnvContent,
|
||||
mockLoadStackBuildServices,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSpawn: vi.fn(),
|
||||
mockGetContainersByStack: vi.fn().mockResolvedValue([]),
|
||||
@@ -43,6 +44,7 @@ const {
|
||||
mockGetBindMounts: vi.fn().mockResolvedValue(null),
|
||||
mockGetStackContent: vi.fn().mockResolvedValue(''),
|
||||
mockGetEnvContent: vi.fn().mockResolvedValue(''),
|
||||
mockLoadStackBuildServices: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('child_process', () => ({ spawn: mockSpawn, execFile: vi.fn() }));
|
||||
@@ -138,6 +140,11 @@ vi.mock('../services/SelfIdentityService', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/ImageUpdateService', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../services/ImageUpdateService')>()),
|
||||
loadStackBuildServices: (...args: unknown[]) => mockLoadStackBuildServices(...args),
|
||||
}));
|
||||
|
||||
import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService';
|
||||
import { DriftLedgerService } from '../services/DriftLedgerService';
|
||||
|
||||
@@ -211,6 +218,7 @@ beforeEach(() => {
|
||||
mockGetOverrideFilename.mockResolvedValue(null);
|
||||
mockEnsureStackOverride.mockResolvedValue(null);
|
||||
mockGetBindMounts.mockResolvedValue(null);
|
||||
mockLoadStackBuildServices.mockResolvedValue([]);
|
||||
delete process.env.SENCHO_MODE;
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
@@ -749,6 +757,62 @@ describe('ComposeService - deployStack', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateStack: build-aware ───────────────────────────────────────────
|
||||
|
||||
describe('ComposeService - updateStack build-aware', () => {
|
||||
it('runs build --pull, pull --ignore-buildable, and up when build services exist', async () => {
|
||||
mockLoadStackBuildServices.mockResolvedValueOnce(['app']);
|
||||
setupAutoCloseSpawn();
|
||||
mockListContainers.mockResolvedValue([]);
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
const promise = svc.updateStack('my-stack');
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
await promise;
|
||||
|
||||
const spawnArgs = mockSpawn.mock.calls.map(c => c[1] as string[]);
|
||||
expect(spawnArgs.some(args => args.includes('build') && args.includes('--pull'))).toBe(true);
|
||||
expect(spawnArgs.some(args => args.includes('pull') && args.includes('--ignore-buildable'))).toBe(true);
|
||||
expect(spawnArgs.some(args => args.includes('up') && args.includes('-d'))).toBe(true);
|
||||
});
|
||||
|
||||
it('runs plain pull + up when no build services exist', async () => {
|
||||
mockLoadStackBuildServices.mockResolvedValueOnce([]);
|
||||
setupAutoCloseSpawn();
|
||||
mockListContainers.mockResolvedValue([]);
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
const promise = svc.updateStack('my-stack');
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
await promise;
|
||||
|
||||
const spawnArgs = mockSpawn.mock.calls.map(c => c[1] as string[]);
|
||||
expect(spawnArgs.some(args => args.includes('build'))).toBe(false);
|
||||
expect(spawnArgs.some(args => args.includes('pull') && !args.includes('--ignore-buildable'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rolls back compose files when a build step fails during atomic update', async () => {
|
||||
mockLoadStackBuildServices.mockResolvedValueOnce(['app']);
|
||||
let spawnCount = 0;
|
||||
mockSpawn.mockImplementation(() => {
|
||||
spawnCount += 1;
|
||||
const proc = createMockProcess();
|
||||
Promise.resolve().then(() => proc.emit('close', spawnCount === 1 ? 1 : 0));
|
||||
return proc;
|
||||
});
|
||||
mockListContainers.mockResolvedValue([]);
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
const result = svc.updateStack('my-stack', undefined, true).then(() => null, (e: Error) => e);
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
const error = await result;
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(mockRestoreStackFiles).toHaveBeenCalled();
|
||||
expect(getComposeRollbackInfo(error)?.attempted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateStack: prune-on-update ───────────────────────────────────────
|
||||
|
||||
describe('ComposeService - updateStack prune-on-update', () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
healthchecksSignal,
|
||||
preflightSignal,
|
||||
updatePreviewSignal,
|
||||
buildServicesSignal,
|
||||
} from '../services/updateGuard/readiness';
|
||||
import type { ContainerProbe, ReadinessSignal } from '../services/updateGuard/types';
|
||||
import type { UpdatePreviewSummary } from '../services/UpdatePreviewService';
|
||||
@@ -34,6 +35,8 @@ const summary = (over: Partial<UpdatePreviewSummary> = {}): UpdatePreviewSummary
|
||||
update_kind: 'none',
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
has_build_services: false,
|
||||
rebuild_available: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
@@ -135,11 +138,44 @@ describe('updatePreviewSignal', () => {
|
||||
expect(updatePreviewSignal(summary()).status).toBe('ok');
|
||||
});
|
||||
|
||||
it('warns when only local build services need a rebuild', () => {
|
||||
const signal = updatePreviewSignal(summary({ rebuild_available: true, has_build_services: true }));
|
||||
expect(signal.status).toBe('warning');
|
||||
expect(signal.detail).toContain('rebuild');
|
||||
});
|
||||
|
||||
it('notes build services on a pending registry update', () => {
|
||||
const signal = updatePreviewSignal(summary({
|
||||
has_update: true,
|
||||
semver_bump: 'patch',
|
||||
update_kind: 'tag',
|
||||
has_build_services: true,
|
||||
}));
|
||||
expect(signal.detail).toContain('Local build services');
|
||||
});
|
||||
|
||||
it('degrades a preview failure to a non-verdict-affecting unknown', () => {
|
||||
expect(updatePreviewSignal('error')).toMatchObject({ status: 'unknown', affectsVerdict: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildServicesSignal', () => {
|
||||
it('is ok when no build services are declared', () => {
|
||||
expect(buildServicesSignal([]).status).toBe('ok');
|
||||
});
|
||||
|
||||
it('warns with service names when build services exist', () => {
|
||||
const signal = buildServicesSignal(['app', 'worker']);
|
||||
expect(signal.status).toBe('warning');
|
||||
expect(signal.affectsVerdict).toBe(false);
|
||||
expect(signal.detail).toContain('app, worker');
|
||||
});
|
||||
|
||||
it('degrades read failures to unknown', () => {
|
||||
expect(buildServicesSignal('error')).toMatchObject({ status: 'unknown', affectsVerdict: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('backupSlotSignal', () => {
|
||||
it('is ok with an existing backup and warns without one', () => {
|
||||
expect(backupSlotSignal({ exists: true, timestamp: NOW - 60_000 }, NOW).status).toBe('ok');
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('UpdateGuardService.computeUpdateReadiness wiring', () => {
|
||||
|
||||
expect(report.stack).toBe('app');
|
||||
expect(report.signals.map(s => s.id)).toEqual([
|
||||
'preflight', 'drift', 'containers', 'healthchecks', 'update_preview', 'backup_slot', 'disk',
|
||||
'preflight', 'drift', 'containers', 'healthchecks', 'update_preview', 'build_services', 'backup_slot', 'disk',
|
||||
]);
|
||||
// The container probe failure is the verdict-affecting unknown.
|
||||
expect(report.verdict).toBe('unknown');
|
||||
@@ -149,9 +149,11 @@ describe('UpdateGuardService.computeUpdateReadiness wiring', () => {
|
||||
mockGetPreview.mockResolvedValue({
|
||||
stack_name: 'app',
|
||||
images: [],
|
||||
build_services: [],
|
||||
summary: {
|
||||
has_update: true, primary_image: 'nginx', current_tag: '1.27.0', next_tag: '1.27.1',
|
||||
semver_bump: 'patch', update_kind: 'tag', blocked: false, blocked_reason: null,
|
||||
has_build_services: false, rebuild_available: false,
|
||||
},
|
||||
rollback_target: 'nginx:1.27.0',
|
||||
changelog: null,
|
||||
@@ -168,9 +170,11 @@ describe('UpdateGuardService.computeRollbackReadiness moving-tag wiring', () =>
|
||||
const preview = (images: Array<{ current_tag: string }>) => ({
|
||||
stack_name: 'app',
|
||||
images,
|
||||
build_services: [],
|
||||
summary: {
|
||||
has_update: false, primary_image: 'app', current_tag: images[0]?.current_tag ?? null,
|
||||
next_tag: null, semver_bump: 'none', update_kind: 'none', blocked: false, blocked_reason: null,
|
||||
has_build_services: false, rebuild_available: false,
|
||||
},
|
||||
rollback_target: 'app:1.2.3',
|
||||
changelog: null,
|
||||
|
||||
@@ -189,6 +189,26 @@ describe('buildSummary', () => {
|
||||
expect(preview.summary.has_update).toBe(false);
|
||||
expect(preview.summary.primary_image).toBeNull();
|
||||
expect(preview.rollback_target).toBeNull();
|
||||
expect(preview.summary.has_build_services).toBe(false);
|
||||
expect(preview.summary.rebuild_available).toBe(false);
|
||||
});
|
||||
|
||||
it('flags rebuild_available for build-only stacks', () => {
|
||||
const preview = buildSummary('build-stack', [], ['app']);
|
||||
expect(preview.build_services).toEqual(['app']);
|
||||
expect(preview.summary.has_update).toBe(false);
|
||||
expect(preview.summary.has_build_services).toBe(true);
|
||||
expect(preview.summary.rebuild_available).toBe(true);
|
||||
});
|
||||
|
||||
it('supports mixed image and build services', () => {
|
||||
const images = [
|
||||
baseImage({ service: 'web', has_update: true, semver_bump: 'patch', next_tag: '1.0.1', current_tag: '1.0.0' }),
|
||||
];
|
||||
const preview = buildSummary('mixed', images, ['worker']);
|
||||
expect(preview.summary.has_update).toBe(true);
|
||||
expect(preview.summary.has_build_services).toBe(true);
|
||||
expect(preview.summary.rebuild_available).toBe(true);
|
||||
});
|
||||
|
||||
it('computes rollback target from current tag of primary', () => {
|
||||
|
||||
Reference in New Issue
Block a user