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:
Anso
2026-07-05 04:45:45 -04:00
committed by GitHub
parent 4077546492
commit f2b5c68d84
15 changed files with 450 additions and 76 deletions
+65 -53
View File
@@ -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'),
];
+21 -4
View File
@@ -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);
}
}
+30 -1
View File
@@ -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,
+1 -1
View File
@@ -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;