mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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', () => {
|
||||
|
||||
@@ -10,8 +10,8 @@ import { MeshService } from './MeshService';
|
||||
import { LogFormatter } from './LogFormatter';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { RegistryService } from './RegistryService';
|
||||
import { DriftLedgerService } from './DriftLedgerService';
|
||||
import SelfIdentityService from './SelfIdentityService';
|
||||
import { DriftLedgerService } from './DriftLedgerService';
|
||||
import SelfIdentityService from './SelfIdentityService';
|
||||
import { parseEffectiveModel } from './preflight/effectiveModel';
|
||||
import { deriveStackExposure } from './preflight/exposure';
|
||||
|
||||
@@ -22,8 +22,9 @@ import { describeSpawnError } from '../utils/spawnErrors';
|
||||
import { isPathWithinBase, isValidStackName } from '../utils/validation';
|
||||
import { authoredComposeFileArgs, authoredComposeEnvFileArgs } from '../utils/authoredComposeArgs';
|
||||
import { parseMissingRequiredVars } from '../helpers/envVarParse';
|
||||
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
|
||||
import { pathsMatch, resolveHostBindPath } from '../utils/composePathMapping';
|
||||
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
|
||||
import { pathsMatch, resolveHostBindPath } from '../utils/composePathMapping';
|
||||
import { loadStackBuildServices } from './ImageUpdateService';
|
||||
|
||||
export class ComposeRollbackError extends Error {
|
||||
public readonly rollbackAttempted: boolean;
|
||||
@@ -397,7 +398,7 @@ export class ComposeService {
|
||||
* no env value is materialized. Default off and any settings-read failure both
|
||||
* fall through without blocking.
|
||||
*/
|
||||
private async assertRequiredEnvPresent(stackName: string): Promise<void> {
|
||||
private async assertRequiredEnvPresent(stackName: string): Promise<void> {
|
||||
let enabled = false;
|
||||
try {
|
||||
enabled = DatabaseService.getInstance().getGlobalSettings()['env_block_deploy_on_missing_required'] === '1';
|
||||
@@ -413,49 +414,49 @@ export class ComposeService {
|
||||
`Deploy blocked: required environment variable${plural ? 's' : ''} ${missing.join(', ')} ` +
|
||||
`${plural ? 'are' : 'is'} missing. Define ${plural ? 'them' : 'it'} in a .env or env_file, then deploy again.`,
|
||||
);
|
||||
}
|
||||
|
||||
private async assertSafePilotBindMapping(stackName: string): Promise<void> {
|
||||
if (process.env.SENCHO_MODE !== 'pilot') return;
|
||||
|
||||
let mounts: Array<{ source: string; destination: string }> | null;
|
||||
try {
|
||||
mounts = await SelfIdentityService.getInstance().getBindMounts();
|
||||
} catch (error) {
|
||||
console.warn('[ComposeService] Could not verify pilot compose path mapping:', sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
return;
|
||||
}
|
||||
if (mounts === null) return;
|
||||
|
||||
const composeDir = path.resolve(this.baseDir);
|
||||
const hostComposeDir = resolveHostBindPath(composeDir, mounts);
|
||||
if (!hostComposeDir || pathsMatch(hostComposeDir, composeDir)) return;
|
||||
|
||||
const rendered = await this.renderConfig(stackName);
|
||||
if (rendered.rendered === null) return;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rendered.rendered);
|
||||
} catch (error) {
|
||||
console.warn('[ComposeService] Could not inspect rendered binds for pilot path safety:', sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
return;
|
||||
}
|
||||
const model = parseEffectiveModel(parsed, stackName);
|
||||
const unsafeBind = model.services
|
||||
.flatMap((service) => service.binds)
|
||||
.find((bind) => isPathWithinBase(path.resolve(bind.source), composeDir));
|
||||
if (!unsafeBind) return;
|
||||
|
||||
throw new Error(
|
||||
`Deploy blocked: relative bind mounts resolve under ${composeDir}, but the host path is ${hostComposeDir}. ` +
|
||||
`Use a 1:1 mount with the same absolute path on the host and in the Pilot Agent, then retry.`,
|
||||
);
|
||||
}
|
||||
|
||||
async deployStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
|
||||
await this.assertRequiredEnvPresent(stackName);
|
||||
await this.assertSafePilotBindMapping(stackName);
|
||||
}
|
||||
|
||||
private async assertSafePilotBindMapping(stackName: string): Promise<void> {
|
||||
if (process.env.SENCHO_MODE !== 'pilot') return;
|
||||
|
||||
let mounts: Array<{ source: string; destination: string }> | null;
|
||||
try {
|
||||
mounts = await SelfIdentityService.getInstance().getBindMounts();
|
||||
} catch (error) {
|
||||
console.warn('[ComposeService] Could not verify pilot compose path mapping:', sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
return;
|
||||
}
|
||||
if (mounts === null) return;
|
||||
|
||||
const composeDir = path.resolve(this.baseDir);
|
||||
const hostComposeDir = resolveHostBindPath(composeDir, mounts);
|
||||
if (!hostComposeDir || pathsMatch(hostComposeDir, composeDir)) return;
|
||||
|
||||
const rendered = await this.renderConfig(stackName);
|
||||
if (rendered.rendered === null) return;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rendered.rendered);
|
||||
} catch (error) {
|
||||
console.warn('[ComposeService] Could not inspect rendered binds for pilot path safety:', sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
return;
|
||||
}
|
||||
const model = parseEffectiveModel(parsed, stackName);
|
||||
const unsafeBind = model.services
|
||||
.flatMap((service) => service.binds)
|
||||
.find((bind) => isPathWithinBase(path.resolve(bind.source), composeDir));
|
||||
if (!unsafeBind) return;
|
||||
|
||||
throw new Error(
|
||||
`Deploy blocked: relative bind mounts resolve under ${composeDir}, but the host path is ${hostComposeDir}. ` +
|
||||
`Use a 1:1 mount with the same absolute path on the host and in the Pilot Agent, then retry.`,
|
||||
);
|
||||
}
|
||||
|
||||
async deployStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
|
||||
await this.assertRequiredEnvPresent(stackName);
|
||||
await this.assertSafePilotBindMapping(stackName);
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const debug = isDebugEnabled();
|
||||
const t0 = Date.now();
|
||||
@@ -647,9 +648,9 @@ export class ComposeService {
|
||||
startStream();
|
||||
}
|
||||
|
||||
async updateStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
|
||||
await this.assertRequiredEnvPresent(stackName);
|
||||
await this.assertSafePilotBindMapping(stackName);
|
||||
async updateStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
|
||||
await this.assertRequiredEnvPresent(stackName);
|
||||
await this.assertSafePilotBindMapping(stackName);
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const debug = isDebugEnabled();
|
||||
const t0 = Date.now();
|
||||
@@ -674,9 +675,20 @@ export class ComposeService {
|
||||
console.warn('Failed to clean up legacy containers for %s:', sanitizeForLog(stackName), e);
|
||||
}
|
||||
|
||||
const buildServices = await loadStackBuildServices(this.nodeId, stackName);
|
||||
const buildAware = buildServices.length > 0;
|
||||
|
||||
await this.withRegistryAuth(async (env) => {
|
||||
sendOutput('=== Pulling latest images ===\n');
|
||||
await this.execute('docker', await this.authoredComposeArgs(stackName, ['pull']), stackDir, ws, true, env, getComposeStallTimeoutMs());
|
||||
if (buildAware) {
|
||||
sendOutput('=== Building images ===\n');
|
||||
await this.execute('docker', await this.authoredComposeArgs(stackName, ['build', '--pull']), stackDir, ws, true, env, getComposeStallTimeoutMs());
|
||||
|
||||
sendOutput('=== Pulling registry images ===\n');
|
||||
await this.execute('docker', await this.authoredComposeArgs(stackName, ['pull', '--ignore-buildable']), stackDir, ws, true, env, getComposeStallTimeoutMs());
|
||||
} else {
|
||||
sendOutput('=== Pulling latest images ===\n');
|
||||
await this.execute('docker', await this.authoredComposeArgs(stackName, ['pull']), stackDir, ws, true, env, getComposeStallTimeoutMs());
|
||||
}
|
||||
|
||||
sendOutput('=== Recreating containers ===\n');
|
||||
await this.execute('docker', await this.authoredComposeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env, getComposeStallTimeoutMs());
|
||||
|
||||
@@ -166,6 +166,83 @@ export async function loadEffectiveServiceImages(nodeId: number, stackName: stri
|
||||
return extractServiceImagesFromRenderedConfig(rendered.rendered);
|
||||
}
|
||||
|
||||
/** True when a service declares a non-empty `build:` section (string path or object). */
|
||||
function serviceHasBuild(build: unknown): boolean {
|
||||
if (build === undefined || build === null) return false;
|
||||
if (typeof build === 'string') return build.trim().length > 0;
|
||||
if (typeof build === 'object') return Object.keys(build as Record<string, unknown>).length > 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Service names that declare `build:` in raw compose YAML (single-file path). */
|
||||
export function extractBuildServicesFromCompose(yamlContent: string): string[] {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = YAML.parse(yamlContent) as Record<string, unknown>;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!parsed?.services || typeof parsed.services !== 'object') return [];
|
||||
|
||||
const out: string[] = [];
|
||||
for (const [service, svc] of Object.entries(parsed.services as Record<string, unknown>)) {
|
||||
if (!svc || typeof svc !== 'object') continue;
|
||||
if (serviceHasBuild((svc as Record<string, unknown>).build)) {
|
||||
out.push(service);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service names with a `build:` section from a `docker compose config --format json`
|
||||
* render (merged + interpolated; no env substitution needed).
|
||||
*/
|
||||
export function extractBuildServicesFromRenderedConfig(renderedJson: string): string[] {
|
||||
let parsed: { services?: Record<string, { build?: unknown }> };
|
||||
try {
|
||||
parsed = JSON.parse(renderedJson);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!parsed?.services || typeof parsed.services !== 'object') return [];
|
||||
const out: string[] = [];
|
||||
for (const [service, svc] of Object.entries(parsed.services)) {
|
||||
if (serviceHasBuild(svc?.build)) out.push(service);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service names that use `build:` for a stack. For a Git stack with an applied
|
||||
* multi-file / context-dir spec, reads the effective merged model so override-only
|
||||
* build services are included. Returns null for single-file stacks (and on render
|
||||
* failure) so the caller falls back to the root-compose parse.
|
||||
*/
|
||||
export async function loadEffectiveBuildServices(nodeId: number, stackName: string): Promise<string[] | null> {
|
||||
const spec = DatabaseService.getInstance().getGitSource(stackName)?.applied_deploy_spec;
|
||||
if (!spec || spec.files.length === 0) return null;
|
||||
const { ComposeService } = await import('./ComposeService');
|
||||
const rendered = await ComposeService.getInstance(nodeId).renderConfig(stackName);
|
||||
if (!rendered.rendered) {
|
||||
console.warn(
|
||||
`[ImageUpdateService] effective build render failed for "${sanitizeForLog(stackName)}" (code=${rendered.code} timedOut=${rendered.timedOut}); falling back to root-compose parse: ${sanitizeForLog(rendered.stderr)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return extractBuildServicesFromRenderedConfig(rendered.rendered);
|
||||
}
|
||||
|
||||
/** Resolved build-service names for any stack (effective model or root compose). */
|
||||
export async function loadStackBuildServices(nodeId: number, stackName: string): Promise<string[]> {
|
||||
const effective = await loadEffectiveBuildServices(nodeId, stackName);
|
||||
if (effective) return effective;
|
||||
|
||||
const fs = FileSystemService.getInstance(nodeId);
|
||||
const composeContent = await fs.getStackContent(stackName);
|
||||
return extractBuildServicesFromCompose(composeContent);
|
||||
}
|
||||
|
||||
// ─── Service ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export class ImageUpdateService {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
aggregateVerdict,
|
||||
backupSlotSignal,
|
||||
buildRollbackItems,
|
||||
buildServicesSignal,
|
||||
containersSignal,
|
||||
diskSignal,
|
||||
driftSignal,
|
||||
@@ -109,6 +110,7 @@ export class UpdateGuardService {
|
||||
containersSignal(containers),
|
||||
healthchecksSignal(containers),
|
||||
updatePreviewSignal(preview === 'error' ? 'error' : preview.summary),
|
||||
buildServicesSignal(preview === 'error' ? 'error' : preview.build_services),
|
||||
backupSlotSignal(backup, now),
|
||||
diskSignal(typeof disk === 'number' ? { usePercent: disk, limitPercent } : 'error'),
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
extractServiceImagesFromCompose,
|
||||
loadDotEnv,
|
||||
loadEffectiveServiceImages,
|
||||
loadStackBuildServices,
|
||||
type ComposeServiceImage,
|
||||
} from './ImageUpdateService';
|
||||
import {
|
||||
@@ -42,11 +43,16 @@ export interface UpdatePreviewSummary {
|
||||
update_kind: UpdateKind;
|
||||
blocked: boolean;
|
||||
blocked_reason: string | null;
|
||||
/** True when one or more services declare `build:` in the effective model. */
|
||||
has_build_services: boolean;
|
||||
/** True when a manual update can rebuild local build services (always when has_build_services). */
|
||||
rebuild_available: boolean;
|
||||
}
|
||||
|
||||
export interface UpdatePreview {
|
||||
stack_name: string;
|
||||
images: UpdatePreviewImage[];
|
||||
build_services: string[];
|
||||
summary: UpdatePreviewSummary;
|
||||
rollback_target: string | null;
|
||||
changelog: string | null;
|
||||
@@ -226,7 +232,11 @@ function buildRollbackTarget(image: string, currentTag: string): string | null {
|
||||
return `${base}:${currentTag}`;
|
||||
}
|
||||
|
||||
export function buildSummary(stackName: string, images: UpdatePreviewImage[]): UpdatePreview {
|
||||
export function buildSummary(
|
||||
stackName: string,
|
||||
images: UpdatePreviewImage[],
|
||||
buildServices: string[] = [],
|
||||
): UpdatePreview {
|
||||
const updated = images.filter(i => i.has_update);
|
||||
const hasUpdate = updated.length > 0;
|
||||
const primary = updated[0] ?? images[0] ?? null;
|
||||
@@ -235,6 +245,7 @@ export function buildSummary(stackName: string, images: UpdatePreviewImage[]): U
|
||||
'none',
|
||||
);
|
||||
const blocked = overallBump === 'major';
|
||||
const hasBuildServices = buildServices.length > 0;
|
||||
// 'tag' means at least one image has a strictly newer tag; 'digest' means
|
||||
// the only updates available are same-tag rebuilds (digest changed); 'none'
|
||||
// means there is nothing to apply.
|
||||
@@ -246,6 +257,7 @@ export function buildSummary(stackName: string, images: UpdatePreviewImage[]): U
|
||||
return {
|
||||
stack_name: stackName,
|
||||
images,
|
||||
build_services: buildServices,
|
||||
summary: {
|
||||
has_update: hasUpdate,
|
||||
primary_image: primary ? primary.image : null,
|
||||
@@ -255,6 +267,8 @@ export function buildSummary(stackName: string, images: UpdatePreviewImage[]): U
|
||||
update_kind: updateKind,
|
||||
blocked,
|
||||
blocked_reason: blocked ? 'Major version jumps require human review before applying.' : null,
|
||||
has_build_services: hasBuildServices,
|
||||
rebuild_available: hasBuildServices,
|
||||
},
|
||||
rollback_target: primary ? buildRollbackTarget(primary.image, primary.current_tag) : null,
|
||||
changelog: null,
|
||||
@@ -272,9 +286,12 @@ export class UpdatePreviewService {
|
||||
}
|
||||
|
||||
public async getPreview(nodeId: number, stackName: string): Promise<UpdatePreview> {
|
||||
const stackImages = await loadStackImages(nodeId, stackName);
|
||||
const [stackImages, buildServices] = await Promise.all([
|
||||
loadStackImages(nodeId, stackName),
|
||||
loadStackBuildServices(nodeId, stackName),
|
||||
]);
|
||||
if (stackImages.length === 0) {
|
||||
return buildSummary(stackName, []);
|
||||
return buildSummary(stackName, [], buildServices);
|
||||
}
|
||||
|
||||
const docker = DockerController.getInstance(nodeId);
|
||||
@@ -301,6 +318,6 @@ export class UpdatePreviewService {
|
||||
const results = await Promise.all(
|
||||
stackImages.map(({ service, image }) => computeImagePreview(service, image, deps)),
|
||||
);
|
||||
return buildSummary(stackName, results);
|
||||
return buildSummary(stackName, results, buildServices);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,11 +128,40 @@ export function updatePreviewSignal(input: UpdatePreviewSummary | Errored): Read
|
||||
}
|
||||
if (input.has_update) {
|
||||
const kind = input.update_kind === 'digest' ? 'a same-tag image refresh' : `a ${input.semver_bump} update`;
|
||||
return { ...base, status: 'ok', affectsVerdict: true, detail: `Pending: ${kind}.` };
|
||||
const buildNote = input.has_build_services
|
||||
? ' Local build services will also be rebuilt from source.'
|
||||
: '';
|
||||
return { ...base, status: 'ok', affectsVerdict: true, detail: `Pending: ${kind}.${buildNote}` };
|
||||
}
|
||||
if (input.rebuild_available) {
|
||||
const n = input.has_build_services ? 'Local build service(s)' : 'Build';
|
||||
return {
|
||||
...base,
|
||||
status: 'warning',
|
||||
affectsVerdict: true,
|
||||
detail: `${n} require a rebuild from source; the update rebuilds images and recreates containers.`,
|
||||
};
|
||||
}
|
||||
return { ...base, status: 'ok', affectsVerdict: true, detail: 'No pending image update detected; the update re-pulls and recreates with current tags.' };
|
||||
}
|
||||
|
||||
export function buildServicesSignal(buildServices: string[] | Errored): ReadinessSignal {
|
||||
const base = { id: 'build_services' as const, title: 'Local build services', affectsVerdict: false };
|
||||
if (buildServices === 'error') {
|
||||
return { ...base, status: 'unknown', detail: 'Build services could not be detected from the compose model.' };
|
||||
}
|
||||
if (buildServices.length === 0) {
|
||||
return { ...base, status: 'ok', detail: 'No services declare a local build; the update pulls registry images only.' };
|
||||
}
|
||||
const plural = buildServices.length === 1 ? 'service' : 'services';
|
||||
const names = buildServices.join(', ');
|
||||
return {
|
||||
...base,
|
||||
status: 'warning',
|
||||
detail: `${buildServices.length} ${plural} (${names}) rebuild from source. This may take longer and depends on the local Dockerfile context, network access, and base-image availability.`,
|
||||
};
|
||||
}
|
||||
|
||||
export function backupSlotSignal(
|
||||
input: { exists: boolean; timestamp: number | null } | Errored,
|
||||
now: number,
|
||||
|
||||
@@ -6,7 +6,7 @@ export type SignalStatus = 'ok' | 'warning' | 'attention' | 'blocked' | 'unknown
|
||||
|
||||
/** One input to the readiness verdict (preflight, drift, containers, ...). */
|
||||
export interface ReadinessSignal {
|
||||
id: 'preflight' | 'drift' | 'containers' | 'healthchecks' | 'update_preview' | 'backup_slot' | 'disk';
|
||||
id: 'preflight' | 'drift' | 'containers' | 'healthchecks' | 'update_preview' | 'build_services' | 'backup_slot' | 'disk';
|
||||
status: SignalStatus;
|
||||
/** Short headline ("Compose Doctor", "Running containers"). */
|
||||
title: string;
|
||||
|
||||
@@ -97,7 +97,7 @@ The Anatomy tab lists:
|
||||
- **network** and its driver
|
||||
- **source** with `git · <host>/<repo>#<branch>` when the stack is linked to a Git repository, or `local` when it is not. Clicking the row opens the Git source dialog. A pulsing brand-color dot means an upstream change is queued.
|
||||
|
||||
When an image update is available, an inline banner appears at the top of the panel. Its tone follows the version-bump severity: `safe to apply` (patch), `review recommended` (minor), `breaking changes possible` (major), or `review required` when the bump cannot be classified. The banner has an inline **apply** button that runs the same operation as the action bar's **Update**; it is hidden for roles that lack the `stack:edit` permission and when the bump is flagged as blocked.
|
||||
When an image update is available, or the stack declares services with a local `build:` section, an inline banner appears at the top of the panel. Registry updates follow version-bump severity: `safe to apply` (patch), `review recommended` (minor), `breaking changes possible` (major), or `review required` when the bump cannot be classified. Build-only stacks show **Rebuild available** with a **Rebuild & Update** button. The banner runs the same operation as the action bar's **Update**; it is hidden for roles that lack the `stack:edit` permission and when the bump is flagged as blocked.
|
||||
|
||||
## Editor mode
|
||||
|
||||
|
||||
@@ -269,7 +269,9 @@ Each row maps one compose concept to the value it resolves to right now:
|
||||
|
||||
A footer card under the rows surfaces the first published port as a clickable **EXPOSED** link, so you can jump straight to the running app.
|
||||
|
||||
If an image update is available for the primary service, an inline banner appears below the rows with the version bump (`27.1.4 → 27.1.5`), risk classification (`safe · patch`, `minor`, or `major · review required`), and an **apply** button. Major bumps show a rose banner and require explicit review before applying.
|
||||
If an image update is available for the primary service, or the stack declares one or more services with a local `build:` section, an inline banner appears below the rows. Registry updates show the version bump (`27.1.4 → 27.1.5`), risk classification (`safe · patch`, `minor`, or `major · review required`), and an **apply** button. Build-only stacks show **Rebuild available** with a **Rebuild & Update** button instead of a version bump. Mixed stacks (registry images plus local builds) show both signals. Major bumps show a rose banner and require explicit review before applying.
|
||||
|
||||
Rebuilds can take longer than a registry pull and depend on the local Dockerfile context, network access, and base-image availability. Atomic rollback restores compose and env files only; previously built image layers are not rolled back automatically.
|
||||
|
||||
### Activity
|
||||
|
||||
@@ -344,7 +346,7 @@ The stack header groups actions by frequency of use. The most common action is t
|
||||
|-----------|--------|---------|--------------|
|
||||
| Primary | **Restart** | `docker compose restart` | Restarts all containers in the stack. |
|
||||
| Secondary | **Stop** | `docker compose stop` | Stops containers without removing them. State is preserved. |
|
||||
| Secondary | **Update** | `docker compose pull` + `up -d` | Pulls the latest image tags and recreates containers. |
|
||||
| Secondary | **Update** | `docker compose pull` + `up -d` (or build-aware rebuild when services declare `build:`) | Pulls registry images and recreates containers. When one or more services use `build:`, Update rebuilds those images from source (`compose build --pull`), pulls any remaining registry images, then recreates containers. |
|
||||
| Overflow | **Rollback** | Restores backup | Reverts compose and env files to the pre-deploy snapshot and redeploys. Only shown when a backup exists. |
|
||||
| Overflow | **Scan config** | Trivy config scan | Scans the compose file for misconfigurations (admin role). |
|
||||
| Overflow | **Delete** | `down --volumes` + removes files | Stops and removes containers and volumes, then deletes the stack directory. |
|
||||
@@ -354,7 +356,7 @@ The stack header groups actions by frequency of use. The most common action is t
|
||||
| Placement | Button | Command | What it does |
|
||||
|-----------|--------|---------|--------------|
|
||||
| Primary | **Start** | `docker compose up -d` | Starts the stack. |
|
||||
| Secondary | **Update** | `docker compose pull` + `up -d` | Pulls the latest image tags and recreates containers. |
|
||||
| Secondary | **Update** | `docker compose pull` + `up -d` (or build-aware rebuild when services declare `build:`) | Pulls registry images and recreates containers. When one or more services use `build:`, Update rebuilds those images from source (`compose build --pull`), pulls any remaining registry images, then recreates containers. |
|
||||
| Overflow | **Delete** | Removes files | Deletes the stack directory. |
|
||||
|
||||
<Warning>
|
||||
|
||||
@@ -23,16 +23,21 @@ import StackAnatomyPanel from './StackAnatomyPanel';
|
||||
|
||||
const COMPOSE = 'services:\n web:\n image: nginx:1.25\n';
|
||||
|
||||
function previewBody(hasUpdate: boolean) {
|
||||
function previewBody(hasUpdate: boolean, buildServices: string[] = []) {
|
||||
const hasBuild = buildServices.length > 0;
|
||||
return {
|
||||
build_services: buildServices,
|
||||
summary: {
|
||||
has_update: hasUpdate,
|
||||
primary_image: 'nginx',
|
||||
current_tag: '1.25',
|
||||
next_tag: '1.26',
|
||||
semver_bump: 'minor',
|
||||
update_kind: hasUpdate ? 'tag' : 'none',
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
has_build_services: hasBuild,
|
||||
rebuild_available: hasBuild,
|
||||
},
|
||||
changelog: null,
|
||||
};
|
||||
@@ -86,6 +91,21 @@ describe('StackAnatomyPanel update banner', () => {
|
||||
expect(onApply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows Rebuild & Update for build-only stacks', async () => {
|
||||
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/update-preview')) return jsonRes(previewBody(false, ['app']));
|
||||
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
|
||||
return jsonRes(null, false);
|
||||
});
|
||||
|
||||
render(panel(false));
|
||||
|
||||
expect(await screen.findByTestId('update-available-banner')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Rebuild available/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Rebuild & Update' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the apply button and shows progress while applying', async () => {
|
||||
const onApply = vi.fn();
|
||||
const { rerender } = render(panel(false, onApply));
|
||||
|
||||
@@ -39,6 +39,7 @@ interface StackAnatomyPanelProps {
|
||||
}
|
||||
|
||||
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
|
||||
type UpdateKind = 'tag' | 'digest' | 'none';
|
||||
|
||||
interface UpdatePreviewSummary {
|
||||
has_update: boolean;
|
||||
@@ -46,12 +47,16 @@ interface UpdatePreviewSummary {
|
||||
current_tag: string | null;
|
||||
next_tag: string | null;
|
||||
semver_bump: SemverBump;
|
||||
update_kind?: UpdateKind;
|
||||
blocked: boolean;
|
||||
blocked_reason: string | null;
|
||||
has_build_services: boolean;
|
||||
rebuild_available: boolean;
|
||||
}
|
||||
|
||||
interface UpdatePreview {
|
||||
summary: UpdatePreviewSummary;
|
||||
build_services?: string[];
|
||||
changelog: string | null;
|
||||
}
|
||||
|
||||
@@ -335,6 +340,10 @@ export default function StackAnatomyPanel({
|
||||
|
||||
const bump = updatePreview?.summary.semver_bump ?? 'none';
|
||||
const hasUpdate = Boolean(updatePreview?.summary.has_update);
|
||||
const hasBuildServices = Boolean(updatePreview?.summary.has_build_services);
|
||||
const rebuildAvailable = Boolean(updatePreview?.summary.rebuild_available);
|
||||
const showUpdateBanner = hasUpdate || rebuildAvailable;
|
||||
const updateKind = updatePreview?.summary.update_kind ?? 'none';
|
||||
const blocked = Boolean(updatePreview?.summary.blocked);
|
||||
const bannerSeverity: 'danger' | 'warn' | 'ok' = bump === 'major' || blocked
|
||||
? 'danger'
|
||||
@@ -352,13 +361,29 @@ export default function StackAnatomyPanel({
|
||||
const bumpLabel = bump === 'none' || bump === 'unknown' ? '' : `${bump}`;
|
||||
const bannerLeadIn = blocked
|
||||
? 'review required'
|
||||
: bump === 'patch'
|
||||
? 'safe to apply'
|
||||
: bump === 'minor'
|
||||
? 'review recommended'
|
||||
: bump === 'major'
|
||||
? 'breaking changes possible'
|
||||
: '';
|
||||
: hasUpdate && updateKind === 'digest'
|
||||
? 'same-tag digest rebuild'
|
||||
: hasUpdate && hasBuildServices
|
||||
? 'registry update + local rebuild'
|
||||
: rebuildAvailable && !hasUpdate
|
||||
? 'local build / rebuild required'
|
||||
: bump === 'patch'
|
||||
? 'safe to apply'
|
||||
: bump === 'minor'
|
||||
? 'review recommended'
|
||||
: bump === 'major'
|
||||
? 'breaking changes possible'
|
||||
: '';
|
||||
const buildServiceNames = updatePreview?.build_services ?? [];
|
||||
const buildHint = hasBuildServices
|
||||
? `Rebuilds ${buildServiceNames.length} local build service${buildServiceNames.length === 1 ? '' : 's'} from Dockerfile context; may take longer and needs network access for base images.`
|
||||
: '';
|
||||
const gitRebuildHint = hasBuildServices && activeGitSource
|
||||
? 'After applying Git source changes, use Rebuild & Update to deploy the updated source.'
|
||||
: '';
|
||||
const applyLabel = hasBuildServices
|
||||
? (applying ? 'rebuilding...' : 'Rebuild & Update')
|
||||
: (applying ? 'applying...' : 'apply');
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col rounded-xl border border-muted bg-card/40">
|
||||
@@ -529,13 +554,13 @@ export default function StackAnatomyPanel({
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
{hasUpdate && updatePreview && (
|
||||
{showUpdateBanner && updatePreview && (
|
||||
<div data-testid="update-available-banner" className={cn('mt-3 mb-3 rounded-lg border p-3', bannerTone)}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-xs uppercase tracking-wide">
|
||||
Update available
|
||||
{updatePreview.summary.current_tag && updatePreview.summary.next_tag && (
|
||||
{hasBuildServices && !hasUpdate ? 'Rebuild available' : 'Update available'}
|
||||
{updatePreview.summary.current_tag && updatePreview.summary.next_tag && hasUpdate && (
|
||||
<span className="text-foreground">
|
||||
{' · '}
|
||||
<span className="text-stat-subtitle">{updatePreview.summary.current_tag}</span>
|
||||
@@ -548,6 +573,8 @@ export default function StackAnatomyPanel({
|
||||
{[
|
||||
bumpLabel,
|
||||
bannerLeadIn,
|
||||
buildHint,
|
||||
gitRebuildHint,
|
||||
updatePreview.changelog ? updatePreview.changelog.split(/[.\n]/)[0] : '',
|
||||
].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
@@ -565,7 +592,7 @@ export default function StackAnatomyPanel({
|
||||
onClick={onApplyUpdate}
|
||||
>
|
||||
<Rocket className={cn('h-3 w-3', applying && 'animate-pulse')} strokeWidth={1.5} />
|
||||
{applying ? 'applying...' : 'apply'}
|
||||
{applyLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user