feat: add service-scoped Compose update and restore (#1648)

* feat: add service-scoped Compose update and restore

Allow updating or rebuilding one declared Compose service on multi-service
stacks without recreating siblings, with recovery snapshots, health-gate
observation, and prune holds for rollback images. Full-stack update paths
and single-service UX stay unchanged.

* fix: sanitize service-scoped update log messages for CodeQL

* fix: address service-scoped update audit findings B-01 through B-07

* fix: complete service-scoped update audit metadata and surfaces

* test: wrap Updates readiness tests for deploy-feedback context

* fix: keep service recovery reachable without Deploy Progress

Make failed service-gate recovery discoverable when Deploy Progress is
disabled or dismissed, suppress stale image-scan notification side
effects, normalize ComposeService line endings, and add focused
regression coverage.

* fix: resurface ContainersHealth density and expand on multi-service stacks

Service grouping hid the summary strip and Compact/Detailed/Expand controls that still applied to multi-container stacks.
This commit is contained in:
Anso
2026-07-19 02:42:29 -04:00
committed by GitHub
parent 31d4e4669b
commit 63213c0960
89 changed files with 7608 additions and 331 deletions
+341 -18
View File
@@ -35,11 +35,58 @@ export interface GlobalSetting {
*/
export type StackCheckStatus = 'ok' | 'partial' | 'failed';
/**
* Per-service check outcome persisted in stack_update_status.services_json.
* Distinct from StackCheckStatus: a service with no checkable image ref is
* `not_checkable`, which never counts as a check failure at the aggregate
* (stack-level) status.
*/
export type ServiceCheckStatus = 'ok' | 'partial' | 'failed' | 'not_checkable';
export interface StackServiceStatus {
service: string;
image: string | null;
runtimeImages?: string[];
hasUpdate: boolean;
checkStatus: ServiceCheckStatus;
lastError: string | null;
}
export interface StackUpdateDetail {
hasUpdate: boolean;
checkStatus: StackCheckStatus;
lastError: string | null;
checkedAt: number;
/** Per-service breakdown; omitted when the stack has no persisted per-service data (no effective model available yet, or corrupt JSON). */
services?: StackServiceStatus[];
}
const SERVICES_JSON_VERSION = 1;
function isStackServiceStatus(value: unknown): value is StackServiceStatus {
if (!value || typeof value !== 'object') return false;
const v = value as Record<string, unknown>;
return typeof v.service === 'string'
&& (v.image === null || typeof v.image === 'string')
&& typeof v.hasUpdate === 'boolean'
&& (v.checkStatus === 'ok' || v.checkStatus === 'partial' || v.checkStatus === 'failed' || v.checkStatus === 'not_checkable')
&& (v.lastError === null || typeof v.lastError === 'string');
}
/** Parses stack_update_status.services_json safely; a missing, corrupt, or version-mismatched value yields an empty list rather than throwing. */
function parseServicesJson(raw: string | null | undefined): StackServiceStatus[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as { version?: unknown; services?: unknown };
if (parsed?.version !== SERVICES_JSON_VERSION || !Array.isArray(parsed.services)) return [];
return parsed.services.filter(isStackServiceStatus);
} catch {
return [];
}
}
function stringifyServicesJson(services: StackServiceStatus[], generation: number): string {
return JSON.stringify({ version: SERVICES_JSON_VERSION, generation, services });
}
export interface StackAlert {
@@ -146,7 +193,7 @@ export interface HealthGateRunRow {
node_id: number;
stack_name: string;
/** Named trigger_action because TRIGGER is reserved in SQLite. */
trigger_action: 'update' | 'deploy';
trigger_action: 'update' | 'deploy' | 'service_update' | 'service_restore';
status: 'observing' | 'passed' | 'failed' | 'unknown';
reason: string | null;
window_seconds: number;
@@ -155,6 +202,30 @@ export interface HealthGateRunRow {
started_at: number;
ended_at: number | null;
created_by: string | null;
/** Additive; legacy rows default to stack. */
target_scope: 'stack' | 'service';
service_name: string | null;
failure_source: 'primary' | 'collateral' | null;
}
/** Pre-update image snapshot enabling a manual per-service restore after a service-scoped update. */
export interface ServiceUpdateRecoveryRow {
id: string;
node_id: number;
stack_name: string;
service_name: string;
/** JSON array of pre-update replica image snapshots (imageId + repoDigest when known). */
replicas_json: string;
majority_image_id: string;
/** Audit/UI only: the tag the majority image is retagged onto during restore, never a policy scan target. */
declared_image_ref: string;
weak_floating_tag: number;
health_gate_id: string | null;
status: 'active' | 'restoring' | 'consumed' | 'expired' | 'invalidated';
expires_at: number;
claim_expires_at: number | null;
created_at: number;
created_by: string | null;
}
/** One finding within a stored preflight run. Never carries an environment value. */
@@ -1005,6 +1076,7 @@ export class DatabaseService {
has_update INTEGER DEFAULT 0,
check_status TEXT NOT NULL DEFAULT 'ok',
last_error TEXT,
services_json TEXT,
checked_at INTEGER NOT NULL,
PRIMARY KEY (node_id, stack_name)
);
@@ -1525,18 +1597,44 @@ export class DatabaseService {
id TEXT PRIMARY KEY,
node_id INTEGER NOT NULL,
stack_name TEXT NOT NULL,
trigger_action TEXT NOT NULL CHECK (trigger_action IN ('update','deploy')),
trigger_action TEXT NOT NULL CHECK (trigger_action IN ('update','deploy','service_update','service_restore')),
status TEXT NOT NULL CHECK (status IN ('observing','passed','failed','unknown')),
reason TEXT,
window_seconds INTEGER NOT NULL,
containers_json TEXT NOT NULL DEFAULT '[]',
started_at INTEGER NOT NULL,
ended_at INTEGER,
created_by TEXT
created_by TEXT,
target_scope TEXT NOT NULL DEFAULT 'stack' CHECK (target_scope IN ('stack','service')),
service_name TEXT,
failure_source TEXT CHECK (failure_source IS NULL OR failure_source IN ('primary','collateral'))
);
CREATE INDEX IF NOT EXISTS idx_health_gate_runs_node_stack
ON health_gate_runs(node_id, stack_name, started_at);
CREATE TABLE IF NOT EXISTS service_update_recovery (
id TEXT PRIMARY KEY,
node_id INTEGER NOT NULL,
stack_name TEXT NOT NULL,
service_name TEXT NOT NULL,
replicas_json TEXT NOT NULL,
majority_image_id TEXT NOT NULL,
declared_image_ref TEXT NOT NULL,
weak_floating_tag INTEGER NOT NULL DEFAULT 0,
health_gate_id TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','restoring','consumed','expired','invalidated')),
expires_at INTEGER NOT NULL,
claim_expires_at INTEGER,
created_at INTEGER NOT NULL,
created_by TEXT
);
CREATE INDEX IF NOT EXISTS idx_service_update_recovery_node_stack_service
ON service_update_recovery(node_id, stack_name, service_name, status);
CREATE INDEX IF NOT EXISTS idx_service_update_recovery_status_expires
ON service_update_recovery(status, expires_at);
CREATE INDEX IF NOT EXISTS idx_service_update_recovery_status_claim_expires
ON service_update_recovery(status, claim_expires_at);
CREATE TABLE IF NOT EXISTS secrets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
@@ -1664,6 +1762,11 @@ export class DatabaseService {
// failed check is no longer indistinguishable from "up to date".
maybeAddCol('stack_update_status', 'check_status', "TEXT NOT NULL DEFAULT 'ok'");
maybeAddCol('stack_update_status', 'last_error', 'TEXT');
// Per-service image-update snapshot. Must run AFTER the composite-PK
// recreate above so it is never silently dropped on old installs.
maybeAddCol('stack_update_status', 'services_json', 'TEXT');
this.migrateHealthGateTargetSchema();
// Drop legacy SSH/TLS columns from pre-0.7 databases (no longer read or written)
const legacyCols = ['host', 'port', 'ssh_port', 'ssh_user', 'ssh_password', 'ssh_key', 'tls_ca', 'tls_cert', 'tls_key'];
@@ -1791,6 +1894,66 @@ export class DatabaseService {
}
}
/**
* Rebuild health_gate_runs when the installed CHECK still only allows
* update|deploy or target/failure columns are missing. Idempotent and
* restart-safe: drops a stale temporary table, then rebuilds in one
* better-sqlite3 transaction so an interrupted startup cannot leave
* CREATE TABLE health_gate_runs_new blocking the next boot.
*/
private migrateHealthGateTargetSchema(): void {
const tableSql = (this.db.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'health_gate_runs'"
).get() as { sql: string } | undefined)?.sql ?? '';
const cols = this.db.pragma('table_info(health_gate_runs)') as Array<{ name: string }>;
const colNames = new Set(cols.map(c => c.name));
const hasTarget = colNames.has('target_scope');
const hasFailure = colNames.has('failure_source');
const hasWideTrigger = tableSql.includes('service_update') && tableSql.includes('service_restore');
if (hasTarget && hasFailure && hasWideTrigger) return;
// A previous crash between CREATE and RENAME leaves this temp table behind.
this.db.exec('DROP TABLE IF EXISTS health_gate_runs_new');
const targetExpr = hasTarget ? 'target_scope' : "'stack'";
const serviceExpr = colNames.has('service_name') ? 'service_name' : 'NULL';
const failureExpr = hasFailure ? 'failure_source' : 'NULL';
this.db.transaction(() => {
this.db.exec(`
CREATE TABLE health_gate_runs_new (
id TEXT PRIMARY KEY,
node_id INTEGER NOT NULL,
stack_name TEXT NOT NULL,
trigger_action TEXT NOT NULL CHECK (trigger_action IN ('update','deploy','service_update','service_restore')),
status TEXT NOT NULL CHECK (status IN ('observing','passed','failed','unknown')),
reason TEXT,
window_seconds INTEGER NOT NULL,
containers_json TEXT NOT NULL DEFAULT '[]',
started_at INTEGER NOT NULL,
ended_at INTEGER,
created_by TEXT,
target_scope TEXT NOT NULL DEFAULT 'stack' CHECK (target_scope IN ('stack','service')),
service_name TEXT,
failure_source TEXT CHECK (failure_source IS NULL OR failure_source IN ('primary','collateral'))
);
INSERT INTO health_gate_runs_new (
id, node_id, stack_name, trigger_action, status, reason, window_seconds,
containers_json, started_at, ended_at, created_by, target_scope, service_name, failure_source
)
SELECT
id, node_id, stack_name, trigger_action, status, reason, window_seconds,
containers_json, started_at, ended_at, created_by,
${targetExpr}, ${serviceExpr}, ${failureExpr}
FROM health_gate_runs;
DROP TABLE health_gate_runs;
ALTER TABLE health_gate_runs_new RENAME TO health_gate_runs;
CREATE INDEX IF NOT EXISTS idx_health_gate_runs_node_stack
ON health_gate_runs(node_id, stack_name, started_at);
`);
})();
}
private migrateEncryptNodeTokens(): void {
const crypto = CryptoService.getInstance();
const rows = this.db.prepare("SELECT id, api_token FROM nodes WHERE api_token != '' AND api_token IS NOT NULL").all() as Array<{ id: number; api_token: string }>;
@@ -3128,11 +3291,13 @@ export class DatabaseService {
public insertHealthGateRun(run: HealthGateRunRow): void {
this.db.prepare(
`INSERT INTO health_gate_runs
(id, node_id, stack_name, trigger_action, status, reason, window_seconds, containers_json, started_at, ended_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
(id, node_id, stack_name, trigger_action, status, reason, window_seconds, containers_json,
started_at, ended_at, created_by, target_scope, service_name, failure_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
run.id, run.node_id, run.stack_name, run.trigger_action, run.status, run.reason,
run.window_seconds, run.containers_json, run.started_at, run.ended_at, run.created_by,
run.target_scope, run.service_name, run.failure_source,
);
// Bounded history: keep only the 10 most recent runs per stack.
this.db.prepare(
@@ -3152,10 +3317,11 @@ export class DatabaseService {
reason: string | null,
endedAt: number,
containersJson: string,
failureSource: 'primary' | 'collateral' | null = null,
): void {
this.db.prepare(
'UPDATE health_gate_runs SET status = ?, reason = ?, ended_at = ?, containers_json = ? WHERE id = ?'
).run(status, reason, endedAt, containersJson, id);
'UPDATE health_gate_runs SET status = ?, reason = ?, ended_at = ?, containers_json = ?, failure_source = ? WHERE id = ?'
).run(status, reason, endedAt, containersJson, failureSource, id);
}
public getHealthGateRun(nodeId: number, stackName: string, id: string): HealthGateRunRow | undefined {
@@ -3178,6 +3344,128 @@ export class DatabaseService {
return result.changes;
}
// --- Service Update Recovery ---
public insertServiceUpdateRecovery(row: ServiceUpdateRecoveryRow): void {
this.db.prepare(
`INSERT INTO service_update_recovery
(id, node_id, stack_name, service_name, replicas_json, majority_image_id,
declared_image_ref, weak_floating_tag, health_gate_id, status,
expires_at, claim_expires_at, created_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
row.id, row.node_id, row.stack_name, row.service_name, row.replicas_json, row.majority_image_id,
row.declared_image_ref, row.weak_floating_tag, row.health_gate_id, row.status,
row.expires_at, row.claim_expires_at, row.created_at, row.created_by,
);
}
public getServiceUpdateRecovery(id: string): ServiceUpdateRecoveryRow | undefined {
return this.db.prepare(
'SELECT * FROM service_update_recovery WHERE id = ?'
).get(id) as ServiceUpdateRecoveryRow | undefined;
}
/** Active, unexpired rows for one service, most recent first. */
public listActiveServiceUpdateRecoveries(nodeId: number, stackName: string, serviceName: string): ServiceUpdateRecoveryRow[] {
return this.db.prepare(
`SELECT * FROM service_update_recovery
WHERE node_id = ? AND stack_name = ? AND service_name = ? AND status = 'active'
ORDER BY created_at DESC`
).all(nodeId, stackName, serviceName) as ServiceUpdateRecoveryRow[];
}
/** Attach the update flow's own health gate run id while the row is still active. */
public linkServiceUpdateRecoveryHealthGate(id: string, healthGateId: string): void {
this.db.prepare(
`UPDATE service_update_recovery SET health_gate_id = ? WHERE id = ? AND status = 'active'`
).run(healthGateId, id);
}
/**
* Atomic claim CAS for restore: active -> restoring, only when the row has
* not already expired. Returns the updated row, or undefined when another
* claim, a sweep, or a terminal status won the race.
*/
public claimServiceUpdateRecovery(id: string, claimExpiresAt: number, now: number): ServiceUpdateRecoveryRow | undefined {
const result = this.db.prepare(
`UPDATE service_update_recovery
SET status = 'restoring', claim_expires_at = ?
WHERE id = ? AND status = 'active' AND expires_at > ?`
).run(claimExpiresAt, id, now);
if (result.changes === 0) return undefined;
return this.getServiceUpdateRecovery(id);
}
/** Renewal CAS: only while still restoring. Cannot revive a consumed/expired/invalidated/active row. */
public renewServiceUpdateRecoveryClaim(id: string, claimExpiresAt: number): boolean {
const result = this.db.prepare(
`UPDATE service_update_recovery SET claim_expires_at = ? WHERE id = ? AND status = 'restoring'`
).run(claimExpiresAt, id);
return result.changes > 0;
}
/** Restore succeeded: restoring -> consumed, optionally linking the restore's own health gate run. */
public markServiceUpdateRecoveryConsumed(id: string, healthGateId: string | null = null): boolean {
const result = this.db.prepare(
`UPDATE service_update_recovery
SET status = 'consumed', claim_expires_at = NULL, health_gate_id = COALESCE(?, health_gate_id)
WHERE id = ? AND status = 'restoring'`
).run(healthGateId, id);
return result.changes > 0;
}
/** Mid-flight restore failure with the image still local: restoring -> active, claim cleared. */
public reactivateServiceUpdateRecovery(id: string): boolean {
const result = this.db.prepare(
`UPDATE service_update_recovery SET status = 'active', claim_expires_at = NULL WHERE id = ? AND status = 'restoring'`
).run(id);
return result.changes > 0;
}
/** Mid-flight restore failure with the image gone: restoring -> invalidated. */
public invalidateServiceUpdateRecovery(id: string): boolean {
const result = this.db.prepare(
`UPDATE service_update_recovery SET status = 'invalidated', claim_expires_at = NULL WHERE id = ? AND status = 'restoring'`
).run(id);
return result.changes > 0;
}
/** Startup/interval sweep: active rows whose TTL has lapsed. */
public sweepExpiredActiveServiceUpdateRecoveries(now: number): number {
const result = this.db.prepare(
`UPDATE service_update_recovery SET status = 'expired' WHERE status = 'active' AND expires_at <= ?`
).run(now);
return result.changes;
}
/**
* Startup/interval sweep: restoring rows abandoned by a dead claim (process
* died mid-restore). A restoring row with a live claim is never expired here,
* even if its original expires_at has long passed.
*/
public sweepAbandonedRestoringServiceUpdateRecoveries(now: number): number {
const result = this.db.prepare(
`UPDATE service_update_recovery
SET status = 'expired'
WHERE status = 'restoring' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?`
).run(now);
return result.changes;
}
/** Image IDs currently protected from prune: active rows and restoring rows with a live claim. */
public listHeldServiceUpdateRecoveryImageIds(nodeId: number, now: number): string[] {
const rows = this.db.prepare(
`SELECT DISTINCT majority_image_id FROM service_update_recovery
WHERE node_id = ? AND (status = 'active' OR (status = 'restoring' AND claim_expires_at > ?))`
).all(nodeId, now) as Array<{ majority_image_id: string }>;
return rows.map(r => r.majority_image_id);
}
public deleteServiceUpdateRecoveries(nodeId: number, stackName: string): void {
this.db.prepare('DELETE FROM service_update_recovery WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
}
// --- Notification History ---
private mapNotificationRow(row: any): NotificationHistory {
@@ -3569,6 +3857,7 @@ export class DatabaseService {
this.db.prepare('DELETE FROM preflight_acknowledgements WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM stack_exposure WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM health_gate_runs WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM service_update_recovery WHERE node_id = ?').run(id);
this.db.prepare('UPDATE blueprints SET pinned_node_id = NULL WHERE pinned_node_id = ?').run(id);
this.deleteRoleAssignmentsByResource('node', String(id));
this.db.prepare('DELETE FROM fleet_sync_status WHERE node_id = ?').run(id);
@@ -3644,6 +3933,12 @@ export class DatabaseService {
// --- Stack Update Status ---
/**
* `services` is omitted by legacy (non-service-aware) callers; omitting it
* leaves the stack's existing services_json untouched (COALESCE against the
* current row) rather than erasing a per-service breakdown that a model-aware
* caller persisted on a previous check.
*/
public upsertStackUpdateStatus(
nodeId: number,
stackName: string,
@@ -3651,16 +3946,20 @@ export class DatabaseService {
checkedAt: number,
checkStatus: StackCheckStatus = 'ok',
lastError: string | null = null,
services?: StackServiceStatus[],
generation = 0,
): void {
const servicesJson = services !== undefined ? stringifyServicesJson(services, generation) : null;
this.db.prepare(
`INSERT INTO stack_update_status (node_id, stack_name, has_update, check_status, last_error, checked_at)
VALUES (?, ?, ?, ?, ?, ?)
`INSERT INTO stack_update_status (node_id, stack_name, has_update, check_status, last_error, checked_at, services_json)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(node_id, stack_name) DO UPDATE SET
has_update = excluded.has_update,
check_status = excluded.check_status,
last_error = excluded.last_error,
checked_at = excluded.checked_at`
).run(nodeId, stackName, hasUpdate ? 1 : 0, checkStatus, lastError, checkedAt);
checked_at = excluded.checked_at,
services_json = COALESCE(excluded.services_json, stack_update_status.services_json)`
).run(nodeId, stackName, hasUpdate ? 1 : 0, checkStatus, lastError, checkedAt, servicesJson);
}
/**
@@ -3671,15 +3970,24 @@ export class DatabaseService {
* first-ever failed check inserts a row with has_update = 0 so the stack
* still appears with its failure reason.
*/
public recordStackCheckFailure(nodeId: number, stackName: string, lastError: string, checkedAt: number): void {
public recordStackCheckFailure(
nodeId: number,
stackName: string,
lastError: string,
checkedAt: number,
services?: StackServiceStatus[],
generation = 0,
): void {
const servicesJson = services !== undefined ? stringifyServicesJson(services, generation) : null;
this.db.prepare(
`INSERT INTO stack_update_status (node_id, stack_name, has_update, check_status, last_error, checked_at)
VALUES (?, ?, 0, 'failed', ?, ?)
`INSERT INTO stack_update_status (node_id, stack_name, has_update, check_status, last_error, checked_at, services_json)
VALUES (?, ?, 0, 'failed', ?, ?, ?)
ON CONFLICT(node_id, stack_name) DO UPDATE SET
check_status = 'failed',
last_error = excluded.last_error,
checked_at = excluded.checked_at`
).run(nodeId, stackName, lastError, checkedAt);
checked_at = excluded.checked_at,
services_json = COALESCE(excluded.services_json, stack_update_status.services_json)`
).run(nodeId, stackName, lastError, checkedAt, servicesJson);
}
public getStackUpdateStatus(nodeId?: number): Record<string, boolean> {
@@ -3700,20 +4008,35 @@ export class DatabaseService {
*/
public getStackUpdateDetail(nodeId: number): Record<string, StackUpdateDetail> {
const rows = this.db.prepare(
'SELECT stack_name, has_update, check_status, last_error, checked_at FROM stack_update_status WHERE node_id = ?'
).all(nodeId) as Array<{ stack_name: string; has_update: number; check_status: string | null; last_error: string | null; checked_at: number }>;
'SELECT stack_name, has_update, check_status, last_error, checked_at, services_json FROM stack_update_status WHERE node_id = ?'
).all(nodeId) as Array<{ stack_name: string; has_update: number; check_status: string | null; last_error: string | null; checked_at: number; services_json: string | null }>;
const result: Record<string, StackUpdateDetail> = {};
for (const row of rows) {
const services = parseServicesJson(row.services_json);
result[row.stack_name] = {
hasUpdate: row.has_update === 1,
checkStatus: (row.check_status === 'failed' || row.check_status === 'partial') ? row.check_status : 'ok',
lastError: row.last_error,
checkedAt: row.checked_at,
...(services.length > 0 ? { services } : {}),
};
}
return result;
}
/**
* Prior per-service breakdown for a single stack, used by ImageUpdateService
* to look up each service's last-known hasUpdate before reducing a fresh
* check (so a preserved value survives a partial/failed re-check). Corrupt
* or missing JSON yields an empty list.
*/
public getStackServicesJson(nodeId: number, stackName: string): StackServiceStatus[] {
const row = this.db.prepare(
'SELECT services_json FROM stack_update_status WHERE node_id = ? AND stack_name = ?'
).get(nodeId, stackName) as { services_json: string | null } | undefined;
return parseServicesJson(row?.services_json);
}
public clearStackUpdateStatus(nodeId: number, stackName: string): void {
this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
}