mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +00:00
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:
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Effective Service Model: per-service facts that service-scoped update and
|
||||
* restore key off of (declared image, build presence, expected replica
|
||||
* count, dependencies, healthcheck presence), derived from the
|
||||
* FULLY-MERGED effective model (`docker compose config --format json`)
|
||||
* instead of a single compose file, so a multi-file Git source's overrides
|
||||
* are always reflected.
|
||||
*
|
||||
* Fail closed: unlike the read-only facts readers in this module family
|
||||
* (effectiveAnatomy, storage inventory, etc.), this model backs mutation and
|
||||
* readiness decisions, so a render failure must never fall back to a
|
||||
* root-file parse. A caller that mutates or gates on `renderable: false`
|
||||
* would risk pulling the wrong image or under/over-recreating replicas
|
||||
* against a stale guess.
|
||||
*
|
||||
* Reads only the structural fields a mutation needs; it never reads
|
||||
* `environment`, `command`, `entrypoint`, `labels`, `secrets`, or `configs`.
|
||||
*/
|
||||
import { ComposeService } from './ComposeService';
|
||||
import { parseMissingRequiredVars } from '../helpers/envVarParse';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
const MAX_RENDER_ERROR = 600;
|
||||
|
||||
export interface EffectiveServiceSpec {
|
||||
name: string;
|
||||
declaredImage: string | null;
|
||||
hasBuild: boolean;
|
||||
/** May be 0 (explicit `scale: 0` or `deploy.replicas: 0`); defaults to 1 when neither is set, matching Compose's own default. */
|
||||
expectedReplicas: number;
|
||||
dependsOn: string[];
|
||||
hasHealthcheck: boolean;
|
||||
}
|
||||
|
||||
export type EffectiveServiceModelResult =
|
||||
| { renderable: true; services: EffectiveServiceSpec[] }
|
||||
| { renderable: false; code: 'effective_model_render_failed'; error: string };
|
||||
|
||||
function asString(v: unknown): string | undefined {
|
||||
if (typeof v === 'string') return v;
|
||||
if (typeof v === 'number') return String(v);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function asNumber(v: unknown): number | undefined {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* `depends_on` always renders as a map keyed by target service name (Compose
|
||||
* normalizes the short list form into the same map shape), so a plain key
|
||||
* extraction covers both authoring styles; the list branch is a defensive
|
||||
* fallback for a hand-built fixture.
|
||||
*/
|
||||
function dependsOnNames(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(asString).filter((s): s is string => s !== undefined);
|
||||
}
|
||||
if (value && typeof value === 'object') return Object.keys(value as Record<string, unknown>);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Expected replica count mirrors Compose's own `ServiceConfig.GetScale()`:
|
||||
* the top-level `scale` field wins when set (Compose keeps it in sync with
|
||||
* `deploy.replicas`), else `deploy.replicas`, else the Compose default of 1.
|
||||
*/
|
||||
function expectedReplicasOf(svc: Record<string, unknown>, deploy: Record<string, unknown> | undefined): number {
|
||||
return asNumber(svc.scale) ?? asNumber(deploy?.replicas) ?? 1;
|
||||
}
|
||||
|
||||
function parseServiceSpec(name: string, raw: unknown): EffectiveServiceSpec {
|
||||
const svc = (raw ?? {}) as Record<string, unknown>;
|
||||
const deploy = (svc.deploy && typeof svc.deploy === 'object') ? svc.deploy as Record<string, unknown> : undefined;
|
||||
const healthcheck = svc.healthcheck;
|
||||
const hasHealthcheck = !!healthcheck
|
||||
&& typeof healthcheck === 'object'
|
||||
&& (healthcheck as Record<string, unknown>).disable !== true;
|
||||
return {
|
||||
name,
|
||||
declaredImage: asString(svc.image) ?? null,
|
||||
hasBuild: !!svc.build && typeof svc.build === 'object',
|
||||
expectedReplicas: expectedReplicasOf(svc, deploy),
|
||||
dependsOn: dependsOnNames(svc.depends_on),
|
||||
hasHealthcheck,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse the raw `docker compose config --format json` output into the per-service specs. Tolerant of missing/garbage fields: an empty or malformed `services` map yields an empty list rather than throwing. */
|
||||
function parseEffectiveServices(parsed: unknown): EffectiveServiceSpec[] {
|
||||
const root = (parsed && typeof parsed === 'object') ? parsed as Record<string, unknown> : {};
|
||||
const rawServices = (root.services && typeof root.services === 'object' && !Array.isArray(root.services))
|
||||
? root.services as Record<string, unknown>
|
||||
: {};
|
||||
return Object.entries(rawServices).map(([name, svc]) => parseServiceSpec(name, svc));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the fully-merged effective Compose model for a stack and extract the
|
||||
* per-service specs service-scoped update/restore key off of. Mirrors the
|
||||
* render-error handling of the other effective-facts readers (redacted,
|
||||
* never raw stderr or an exception), but never falls back to a root-file
|
||||
* parse: a render failure leaves `renderable: false` with no services, so a
|
||||
* caller that skips the `renderable` check cannot silently mutate against a
|
||||
* stale or partial model.
|
||||
*/
|
||||
export async function buildEffectiveServiceModel(nodeId: number, stackName: string): Promise<EffectiveServiceModelResult> {
|
||||
try {
|
||||
const result = await ComposeService.getInstance(nodeId).renderConfig(stackName);
|
||||
if (result.rendered !== null) {
|
||||
try {
|
||||
return { renderable: true, services: parseEffectiveServices(JSON.parse(result.rendered)) };
|
||||
} catch (parseErr) {
|
||||
// JSON.parse errors carry no file content, so the message is safe to log.
|
||||
console.warn('[EffectiveServiceModel] Effective model parse failed for %s:',
|
||||
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(parseErr, 'unknown')));
|
||||
return {
|
||||
renderable: false,
|
||||
code: 'effective_model_render_failed',
|
||||
error: 'Sencho could not parse the rendered Compose model.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Raw stderr can echo file content/secrets and is never surfaced; only the
|
||||
// names of any missing required variables, otherwise a generic nudge.
|
||||
const missing = parseMissingRequiredVars(result.stderr);
|
||||
const error = missing.length
|
||||
? `Required variable${missing.length > 1 ? 's' : ''} ${missing.join(', ')} ${missing.length > 1 ? 'have' : 'has'} no value, so the effective model cannot be rendered.`
|
||||
: 'Sencho could not render the effective Compose model. Check the compose and env files for a YAML syntax error, an unresolved include or merge, or a required variable with no value.';
|
||||
return { renderable: false, code: 'effective_model_render_failed', error };
|
||||
} catch (err) {
|
||||
// Spawn failure (docker unavailable), or an unexpected throw before/inside the
|
||||
// render. Leave a sanitized breadcrumb so a non-spawn bug is not invisible, then
|
||||
// redact the surfaced message defensively.
|
||||
console.warn('[EffectiveServiceModel] Render failed for %s:',
|
||||
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown')));
|
||||
const error = redactSensitiveText(getErrorMessage(err, 'docker compose could not be started.')).slice(0, MAX_RENDER_ERROR).trim()
|
||||
|| 'Sencho could not run docker compose on this node.';
|
||||
return { renderable: false, code: 'effective_model_render_failed', error };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user