diff --git a/backend/src/__tests__/compose-build-services.test.ts b/backend/src/__tests__/compose-build-services.test.ts new file mode 100644 index 00000000..f0379498 --- /dev/null +++ b/backend/src/__tests__/compose-build-services.test.ts @@ -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']); + }); +}); diff --git a/backend/src/__tests__/compose-service.test.ts b/backend/src/__tests__/compose-service.test.ts index aa3ff69c..39c49920 100644 --- a/backend/src/__tests__/compose-service.test.ts +++ b/backend/src/__tests__/compose-service.test.ts @@ -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()), + 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', () => { diff --git a/backend/src/__tests__/update-guard-readiness.test.ts b/backend/src/__tests__/update-guard-readiness.test.ts index 5adb4392..c92a2b0f 100644 --- a/backend/src/__tests__/update-guard-readiness.test.ts +++ b/backend/src/__tests__/update-guard-readiness.test.ts @@ -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 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'); diff --git a/backend/src/__tests__/update-guard-service.test.ts b/backend/src/__tests__/update-guard-service.test.ts index 10cbc487..2a725c39 100644 --- a/backend/src/__tests__/update-guard-service.test.ts +++ b/backend/src/__tests__/update-guard-service.test.ts @@ -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, diff --git a/backend/src/__tests__/update-preview-service.test.ts b/backend/src/__tests__/update-preview-service.test.ts index 603fe7ef..267aa855 100644 --- a/backend/src/__tests__/update-preview-service.test.ts +++ b/backend/src/__tests__/update-preview-service.test.ts @@ -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', () => { diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 44dce70c..5c6163e8 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -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 { + private async assertRequiredEnvPresent(stackName: string): Promise { 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 { - 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 { - await this.assertRequiredEnvPresent(stackName); - await this.assertSafePilotBindMapping(stackName); + } + + private async assertSafePilotBindMapping(stackName: string): Promise { + 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 { + 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 { - await this.assertRequiredEnvPresent(stackName); - await this.assertSafePilotBindMapping(stackName); + async updateStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise { + 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()); diff --git a/backend/src/services/ImageUpdateService.ts b/backend/src/services/ImageUpdateService.ts index d0d4257b..e4ca1444 100644 --- a/backend/src/services/ImageUpdateService.ts +++ b/backend/src/services/ImageUpdateService.ts @@ -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).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; + try { + parsed = YAML.parse(yamlContent) as Record; + } catch { + return []; + } + if (!parsed?.services || typeof parsed.services !== 'object') return []; + + const out: string[] = []; + for (const [service, svc] of Object.entries(parsed.services as Record)) { + if (!svc || typeof svc !== 'object') continue; + if (serviceHasBuild((svc as Record).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 }; + 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 { + 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 { + 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 { diff --git a/backend/src/services/UpdateGuardService.ts b/backend/src/services/UpdateGuardService.ts index f46c5dad..8c01c41f 100644 --- a/backend/src/services/UpdateGuardService.ts +++ b/backend/src/services/UpdateGuardService.ts @@ -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'), ]; diff --git a/backend/src/services/UpdatePreviewService.ts b/backend/src/services/UpdatePreviewService.ts index 56f41a9f..9f9232c7 100644 --- a/backend/src/services/UpdatePreviewService.ts +++ b/backend/src/services/UpdatePreviewService.ts @@ -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 { - 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); } } diff --git a/backend/src/services/updateGuard/readiness.ts b/backend/src/services/updateGuard/readiness.ts index 96610f5e..0274e869 100644 --- a/backend/src/services/updateGuard/readiness.ts +++ b/backend/src/services/updateGuard/readiness.ts @@ -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, diff --git a/backend/src/services/updateGuard/types.ts b/backend/src/services/updateGuard/types.ts index b3ed2ad4..1bfd7adb 100644 --- a/backend/src/services/updateGuard/types.ts +++ b/backend/src/services/updateGuard/types.ts @@ -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; diff --git a/docs/features/editor.mdx b/docs/features/editor.mdx index 431a8090..5264b0d4 100644 --- a/docs/features/editor.mdx +++ b/docs/features/editor.mdx @@ -97,7 +97,7 @@ The Anatomy tab lists: - **network** and its driver - **source** with `git · /#` 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 diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 5f8df0c9..65898bb9 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -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. | diff --git a/frontend/src/components/StackAnatomyPanel.test.tsx b/frontend/src/components/StackAnatomyPanel.test.tsx index f4e0c99f..28103b3a 100644 --- a/frontend/src/components/StackAnatomyPanel.test.tsx +++ b/frontend/src/components/StackAnatomyPanel.test.tsx @@ -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)); diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index faf5487a..da9fae89 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -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 (
@@ -529,13 +554,13 @@ export default function StackAnatomyPanel({ )} - {hasUpdate && updatePreview && ( + {showUpdateBanner && updatePreview && (
- 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 && ( {' · '} {updatePreview.summary.current_tag} @@ -548,6 +573,8 @@ export default function StackAnatomyPanel({ {[ bumpLabel, bannerLeadIn, + buildHint, + gitRebuildHint, updatePreview.changelog ? updatePreview.changelog.split(/[.\n]/)[0] : '', ].filter(Boolean).join(' · ')}
@@ -565,7 +592,7 @@ export default function StackAnatomyPanel({ onClick={onApplyUpdate} > - {applying ? 'applying...' : 'apply'} + {applyLabel} )}