mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-06 23:49:01 +00:00
78475d96ef
* fix(compose-doctor): resolve effective healthcheck coverage Compose Doctor now classifies healthcheck coverage from the Compose model, running containers, and local images so image-provided HEALTHCHECKs are not false positives. Update Guard shares the same presence helper so test NONE is not treated as active. * fix(compose-doctor): fix healthcheck project label and empty compose HC Use the Compose project name for runtime container listing so stacks whose name: differs from the directory still get runtime evidence. Treat empty or timing-only healthcheck objects as absent rather than active. * fix(compose-doctor): treat inherited healthcheck as All Clear note Inherited image healthchecks no longer block All Clear; they surface under a notes section and cannot be acknowledged.
429 lines
18 KiB
TypeScript
429 lines
18 KiB
TypeScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import { randomUUID } from 'crypto';
|
|
|
|
import DockerController from './DockerController';
|
|
import { ComposeService } from './ComposeService';
|
|
import { FileSystemService } from './FileSystemService';
|
|
import { DatabaseService } from './DatabaseService';
|
|
import { computeStackHashes } from './DriftLedgerService';
|
|
import { parseComposeDependencies } from '../helpers/composeDependencyParse';
|
|
import { parseEffectiveModel, type EffectiveModel } from './preflight/effectiveModel';
|
|
import { getExposureContext } from './network/exposureContext';
|
|
import type { ExposureIntent } from './network/types';
|
|
import { runRules, SEVERITY_RANK, RULE_IDS, RENDER_FAILED_RULE_ID, isPreflightNoteFinding } from './preflight/rules';
|
|
import type {
|
|
BindCheck, NodePortBinding, PreflightContext, PreflightFinding, PreflightReport, PreflightSeverity, PreflightStatus, MissingEnvFile,
|
|
ServiceHealthcheckEvidence,
|
|
} from './preflight/types';
|
|
import { collectServiceHealthcheckEvidence } from './healthcheck/collectServiceHealthcheckEvidence';
|
|
import { applyPreflightAcknowledgements, parseServiceImages } from '../utils/preflight-ack-filter';
|
|
|
|
import { isPathWithinBase } from '../utils/validation';
|
|
import { getErrorMessage } from '../utils/errors';
|
|
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
|
|
import { parseUnsetEnvVars, parseMissingRequiredVars, readEnvFileKeys } from '../helpers/envVarParse';
|
|
import { resolveStackEnvSources } from '../helpers/envFileResolution';
|
|
import { isSelfStack } from '../helpers/selfStackGuard';
|
|
import { classifyUnsetEnvVars, type LiteralDollarWarning } from '../helpers/unsetEnvClassification';
|
|
|
|
const MAX_RENDER_ERROR = 600; // chars kept from a (redacted) render error
|
|
|
|
const ruleOrder = new Map(RULE_IDS.map((id, i) => [id, i]));
|
|
/** Severity descending, then registry order, so output is deterministic. */
|
|
function sortFindings(findings: PreflightFinding[]): PreflightFinding[] {
|
|
return [...findings].sort((a, b) =>
|
|
(SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]) ||
|
|
((ruleOrder.get(a.ruleId) ?? 0) - (ruleOrder.get(b.ruleId) ?? 0)));
|
|
}
|
|
|
|
function highestOf(findings: PreflightFinding[]): PreflightSeverity | null {
|
|
let best: PreflightSeverity | null = null;
|
|
for (const f of findings) {
|
|
if (isPreflightNoteFinding(f.ruleId)) continue;
|
|
if (best === null || SEVERITY_RANK[f.severity] > SEVERITY_RANK[best]) best = f.severity;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function activeFields(
|
|
renderable: boolean,
|
|
findings: PreflightFinding[],
|
|
): Pick<PreflightReport, 'activeStatus' | 'activeHighestSeverity' | 'activeCount' | 'acknowledgedCount'> {
|
|
// Notes stay in `findings` for display but do not affect All Clear or active severity.
|
|
const issueFindings = findings.filter(f => !isPreflightNoteFinding(f.ruleId));
|
|
const active = issueFindings.filter(f => !f.acknowledged);
|
|
const acknowledgedCount = issueFindings.length - active.length;
|
|
const activeHighestSeverity = highestOf(active);
|
|
const activeStatus: PreflightStatus = !renderable
|
|
? 'unrenderable'
|
|
: (activeHighestSeverity ?? 'pass');
|
|
return {
|
|
activeStatus,
|
|
activeHighestSeverity,
|
|
activeCount: active.length,
|
|
acknowledgedCount,
|
|
};
|
|
}
|
|
|
|
function buildServiceImages(model: EffectiveModel | null): string | null {
|
|
if (!model) return null;
|
|
const map: Record<string, string> = {};
|
|
for (const svc of model.services) {
|
|
if (svc.image) map[svc.name] = svc.image;
|
|
}
|
|
return Object.keys(map).length > 0 ? JSON.stringify(map) : null;
|
|
}
|
|
|
|
function enrichReport(
|
|
nodeId: number,
|
|
stackName: string,
|
|
report: Omit<PreflightReport, 'activeStatus' | 'activeHighestSeverity' | 'activeCount' | 'acknowledgedCount'>,
|
|
): PreflightReport {
|
|
const db = DatabaseService.getInstance();
|
|
const acks = db.getPreflightAcknowledgements(nodeId, stackName);
|
|
const serviceImages = parseServiceImages(
|
|
db.getLatestPreflightRun(nodeId, stackName)?.service_images ?? null,
|
|
);
|
|
const findings = applyPreflightAcknowledgements(
|
|
report.findings,
|
|
{ renderedHash: report.renderedHash, serviceImages },
|
|
acks,
|
|
);
|
|
return {
|
|
...report,
|
|
findings,
|
|
...activeFields(report.renderable, findings),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Compose Doctor: renders the effective model and runs the deterministic
|
|
* preflight rule registry against the active node. Advisory only (it never
|
|
* blocks a deploy), read-only with respect to the stack, and node-scoped. It
|
|
* never stores, returns, or logs an environment value.
|
|
*/
|
|
export class ComposeDoctorService {
|
|
private static instance: ComposeDoctorService | null = null;
|
|
|
|
static getInstance(): ComposeDoctorService {
|
|
if (!ComposeDoctorService.instance) ComposeDoctorService.instance = new ComposeDoctorService();
|
|
return ComposeDoctorService.instance;
|
|
}
|
|
|
|
private constructor() { /* singleton */ }
|
|
|
|
/** Run all checks, persist the result (replacing any prior run), return the report. */
|
|
async runPreflight(nodeId: number, stackName: string, ranBy: string | null): Promise<PreflightReport> {
|
|
const fsSvc = FileSystemService.getInstance(nodeId);
|
|
let source: string | null = null;
|
|
try {
|
|
source = await fsSvc.getStackContent(stackName);
|
|
} catch (err) {
|
|
// An unreadable source is logged here (not silently swallowed) so a later
|
|
// skipped hash or source comparison is traceable.
|
|
console.warn('[ComposeDoctor] Source unreadable for %s; source-derived checks skipped:',
|
|
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown')));
|
|
}
|
|
const sourceReadable = source !== null;
|
|
// renderedHash is the parsed-SOURCE-model hash (the same `rendered_hash`
|
|
// meaning the drift ledger uses), deliberately not a hash of docker's
|
|
// rendered output, which inlines resolved env values and must never be hashed.
|
|
const hashes = source !== null
|
|
? computeStackHashes(source)
|
|
: { sourceHash: null as string | null, renderedHash: null as string | null };
|
|
const sourceServiceNames = source !== null ? parseComposeDependencies(source).services.map(s => s.name) : [];
|
|
|
|
const ctx = await this.buildContext(nodeId, stackName, sourceServiceNames, sourceReadable);
|
|
const findings = sortFindings(runRules(ctx));
|
|
const highestSeverity = highestOf(findings);
|
|
const status: PreflightStatus = !ctx.renderable ? 'unrenderable' : (highestSeverity ?? 'pass');
|
|
const serviceImages = buildServiceImages(ctx.model);
|
|
|
|
const report: PreflightReport = {
|
|
stack: stackName,
|
|
ranAt: Date.now(),
|
|
ranBy,
|
|
renderable: ctx.renderable,
|
|
renderError: ctx.renderError,
|
|
status,
|
|
highestSeverity,
|
|
sourceHash: hashes.sourceHash,
|
|
renderedHash: hashes.renderedHash,
|
|
findings,
|
|
activeStatus: status,
|
|
activeHighestSeverity: highestSeverity,
|
|
activeCount: findings.length,
|
|
acknowledgedCount: 0,
|
|
};
|
|
this.persist(nodeId, report, serviceImages);
|
|
return enrichReport(nodeId, stackName, report);
|
|
}
|
|
|
|
/** Read the last stored run for a stack, mapped to the report shape. */
|
|
getLatest(nodeId: number, stackName: string): PreflightReport {
|
|
const db = DatabaseService.getInstance();
|
|
const run = db.getLatestPreflightRun(nodeId, stackName);
|
|
if (!run) {
|
|
return {
|
|
stack: stackName, ranAt: null, ranBy: null, renderable: true, renderError: null,
|
|
status: 'never-run', highestSeverity: null, sourceHash: null, renderedHash: null, findings: [],
|
|
activeStatus: 'never-run', activeHighestSeverity: null, activeCount: 0, acknowledgedCount: 0,
|
|
};
|
|
}
|
|
const findings = sortFindings(db.getPreflightFindings(run.id).map(r => ({
|
|
ruleId: r.rule_id,
|
|
severity: r.severity as PreflightSeverity,
|
|
title: r.title,
|
|
message: r.message,
|
|
sourcePath: r.source_path ?? undefined,
|
|
remediation: r.remediation ?? undefined,
|
|
service: r.service ?? undefined,
|
|
})));
|
|
const renderable = run.status !== 'unrenderable';
|
|
// The render error is carried by the render-failed finding, not a column.
|
|
const renderError = renderable ? null : (findings.find(f => f.ruleId === RENDER_FAILED_RULE_ID)?.message ?? null);
|
|
const base: Omit<PreflightReport, 'activeStatus' | 'activeHighestSeverity' | 'activeCount' | 'acknowledgedCount'> = {
|
|
stack: stackName,
|
|
ranAt: run.created_at,
|
|
ranBy: run.created_by,
|
|
renderable,
|
|
renderError,
|
|
status: run.status as PreflightStatus,
|
|
highestSeverity: (run.highest_severity as PreflightSeverity | null) ?? null,
|
|
sourceHash: run.source_hash,
|
|
renderedHash: run.rendered_hash,
|
|
findings,
|
|
};
|
|
return enrichReport(nodeId, stackName, base);
|
|
}
|
|
|
|
private async buildContext(nodeId: number, stackName: string, sourceServiceNames: string[], sourceReadable: boolean): Promise<PreflightContext> {
|
|
const fsSvc = FileSystemService.getInstance(nodeId);
|
|
const baseDir = fsSvc.getBaseDir();
|
|
|
|
let renderable = false;
|
|
let renderError: string | null = null;
|
|
let model: EffectiveModel | null = null;
|
|
let unsetEnvVars: string[] = [];
|
|
let literalDollarWarnings: LiteralDollarWarning[] = [];
|
|
let missingEnvFiles: MissingEnvFile[] = [];
|
|
|
|
let envSources: Awaited<ReturnType<typeof resolveStackEnvSources>> | null = null;
|
|
try {
|
|
envSources = await resolveStackEnvSources(nodeId, stackName);
|
|
missingEnvFiles = envSources.envFiles
|
|
.filter(f => f.isInjectionSource && f.required && f.existence === 'missing')
|
|
.map(f => ({ rawPath: f.rawPaths[0], services: f.declaringServices }));
|
|
} catch (err) {
|
|
console.warn('[ComposeDoctor] env-file resolution failed for %s:',
|
|
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown')));
|
|
}
|
|
|
|
try {
|
|
const result = await ComposeService.getInstance(nodeId).renderConfig(stackName);
|
|
if (result.rendered !== null) {
|
|
// Unset-variable warnings come from stderr and do not depend on the
|
|
// model parsing, so capture them before attempting the parse, so a parse
|
|
// failure does not also suppress the env-unset findings.
|
|
const rawUnset = parseUnsetEnvVars(result.stderr);
|
|
if (envSources) {
|
|
const envFileKeys: string[] = [];
|
|
for (const file of envSources.envFiles) {
|
|
if (!file.resolvedPath || file.existence !== 'present') continue;
|
|
const { keys, unverifiable } = await readEnvFileKeys(file.resolvedPath, baseDir);
|
|
if (!unverifiable) envFileKeys.push(...keys);
|
|
}
|
|
const classified = classifyUnsetEnvVars(rawUnset, envSources, envFileKeys);
|
|
unsetEnvVars = classified.intentional;
|
|
literalDollarWarnings = classified.literalDollar;
|
|
} else {
|
|
// Without authored env context, never surface raw stderr names as unset
|
|
// variables; they may be literal-dollar fragments from secret values.
|
|
unsetEnvVars = [];
|
|
literalDollarWarnings = rawUnset.length > 0 ? [{ likelySecret: false }] : [];
|
|
}
|
|
try {
|
|
model = parseEffectiveModel(JSON.parse(result.rendered), stackName);
|
|
renderable = true;
|
|
} catch (parseErr) {
|
|
// JSON.parse errors carry no file content, so the message is safe to log.
|
|
console.warn('[ComposeDoctor] Effective model parse failed for %s:',
|
|
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(parseErr, 'unknown')));
|
|
renderError = 'Sencho could not parse the rendered Compose model.';
|
|
}
|
|
} else {
|
|
// The raw stderr from `docker compose config` can echo file content
|
|
// (and therefore secrets), so it is never stored. We surface only safe,
|
|
// structural signals: the names of any required variables Compose
|
|
// reported as missing, otherwise a generic nudge.
|
|
const missing = parseMissingRequiredVars(result.stderr);
|
|
renderError = 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, then re-run.';
|
|
}
|
|
} catch (err) {
|
|
// Spawn failure (docker unavailable). Spawn errors carry no file content;
|
|
// redact defensively anyway.
|
|
renderError = redactSensitiveText(getErrorMessage(err, 'docker compose could not be started.')).slice(0, MAX_RENDER_ERROR).trim()
|
|
|| 'Sencho could not run docker compose on this node.';
|
|
}
|
|
|
|
const { nodePorts, existingNetworkNames, existingVolumeNames, existingContainers, nodeStateAvailable } = await this.nodeState(nodeId, fsSvc, stackName);
|
|
const [bindChecks, healthchecks] = await Promise.all([
|
|
model ? this.resolveBindChecks(model, baseDir) : Promise.resolve([] as BindCheck[]),
|
|
model
|
|
? collectServiceHealthcheckEvidence(nodeId, stackName, model, nodeStateAvailable)
|
|
: Promise.resolve({} as Record<string, ServiceHealthcheckEvidence>),
|
|
]);
|
|
const { stackIntent, serviceIntents, accessUrlPorts, hasAccessUrls, exposureAvailable } = this.exposureState(nodeId, stackName);
|
|
const selfStack = await isSelfStack(stackName);
|
|
|
|
return {
|
|
stackName,
|
|
platform: process.platform,
|
|
model,
|
|
renderable,
|
|
renderError,
|
|
unsetEnvVars,
|
|
literalDollarWarnings,
|
|
missingEnvFiles,
|
|
sourceServiceNames,
|
|
sourceReadable,
|
|
nodePorts,
|
|
existingNetworkNames,
|
|
existingVolumeNames,
|
|
existingContainers,
|
|
nodeStateAvailable,
|
|
bindChecks,
|
|
stackIntent,
|
|
serviceIntents,
|
|
accessUrlPorts,
|
|
hasAccessUrls,
|
|
exposureAvailable,
|
|
isSelfStack: selfStack,
|
|
healthchecks,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The user's stored exposure intent (resolved into stack-level + per-service)
|
|
* and the dossier's documented access-URL ports, for the exposure rules.
|
|
* Delegates to the shared exposure-context helper (also used by the live
|
|
* Networking findings engine) so both engines can never diverge on severity.
|
|
* Fail-soft: a read error defaults to unset/empty so the rules simply do not
|
|
* fire rather than the whole preflight failing.
|
|
*/
|
|
private exposureState(nodeId: number, stackName: string): {
|
|
stackIntent: ExposureIntent | null;
|
|
serviceIntents: Record<string, ExposureIntent>;
|
|
accessUrlPorts: Set<number>;
|
|
hasAccessUrls: boolean;
|
|
exposureAvailable: boolean;
|
|
} {
|
|
const context = getExposureContext(nodeId, stackName);
|
|
if (!context.available) {
|
|
return { stackIntent: null, serviceIntents: {}, accessUrlPorts: new Set(), hasAccessUrls: false, exposureAvailable: false };
|
|
}
|
|
return {
|
|
stackIntent: context.stackIntent,
|
|
serviceIntents: context.serviceIntents,
|
|
accessUrlPorts: context.accessUrlPorts,
|
|
hasAccessUrls: context.hasAccessUrls,
|
|
exposureAvailable: true,
|
|
};
|
|
}
|
|
|
|
/** Snapshot the node's ports/networks/volumes/containers. Degrades to empty if Docker is unreachable. */
|
|
private async nodeState(nodeId: number, fsSvc: FileSystemService, stackName: string): Promise<{
|
|
nodePorts: NodePortBinding[];
|
|
existingNetworkNames: Set<string>;
|
|
existingVolumeNames: Set<string>;
|
|
existingContainers: { name: string; stack: string | null }[];
|
|
nodeStateAvailable: boolean;
|
|
}> {
|
|
try {
|
|
const knownStacks = await fsSvc.getStacks();
|
|
const snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(knownStacks);
|
|
const nodePorts = snapshot.containers.flatMap(c =>
|
|
c.ports.map(p => ({ publishedPort: p.publishedPort, protocol: p.protocol, ip: p.ip, stack: c.stack })));
|
|
return {
|
|
nodePorts,
|
|
existingNetworkNames: new Set(snapshot.networks.map(n => n.name)),
|
|
existingVolumeNames: new Set(snapshot.volumes.map(v => v.name)),
|
|
existingContainers: snapshot.containers.map(c => ({ name: c.name, stack: c.stack })),
|
|
nodeStateAvailable: true,
|
|
};
|
|
} catch (error) {
|
|
console.warn('[ComposeDoctor] Node snapshot unavailable for %s; node-state checks skipped:',
|
|
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
|
|
return { nodePorts: [], existingNetworkNames: new Set(), existingVolumeNames: new Set(), existingContainers: [], nodeStateAvailable: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stat each bind-mount source. Existence/ownership is probed ONLY for sources
|
|
* that resolve inside the node's compose base dir (relative binds); absolute
|
|
* host paths are outside Sencho's filesystem view and are left unverified.
|
|
*/
|
|
private async resolveBindChecks(model: EffectiveModel, baseDir: string): Promise<BindCheck[]> {
|
|
const resolvedBase = path.resolve(baseDir);
|
|
const checks: BindCheck[] = [];
|
|
for (const svc of model.services) {
|
|
for (const bind of svc.binds) {
|
|
const withinBase = isPathWithinBase(path.resolve(bind.source), resolvedBase);
|
|
let exists = false;
|
|
let ownerUid: number | null = null;
|
|
if (withinBase) {
|
|
try {
|
|
const st = await fs.promises.stat(bind.source);
|
|
exists = true;
|
|
ownerUid = typeof st.uid === 'number' ? st.uid : null;
|
|
} catch {
|
|
exists = false;
|
|
}
|
|
}
|
|
checks.push({ service: svc.name, source: bind.source, target: bind.target, withinBase, exists, ownerUid });
|
|
}
|
|
}
|
|
return checks;
|
|
}
|
|
|
|
/** Persist the run, replacing any prior run for this stack. Best-effort. */
|
|
private persist(nodeId: number, report: PreflightReport, serviceImages: string | null): void {
|
|
if (report.ranAt === null) return;
|
|
try {
|
|
const runId = randomUUID();
|
|
DatabaseService.getInstance().replacePreflightRun(
|
|
{
|
|
id: runId,
|
|
node_id: nodeId,
|
|
stack_name: report.stack,
|
|
source_hash: report.sourceHash,
|
|
rendered_hash: report.renderedHash,
|
|
service_images: serviceImages,
|
|
status: report.status,
|
|
highest_severity: report.highestSeverity,
|
|
created_at: report.ranAt,
|
|
created_by: report.ranBy,
|
|
},
|
|
report.findings.map(f => ({
|
|
id: randomUUID(),
|
|
run_id: runId,
|
|
rule_id: f.ruleId,
|
|
severity: f.severity,
|
|
title: f.title,
|
|
message: f.message,
|
|
source_path: f.sourcePath ?? null,
|
|
remediation: f.remediation ?? null,
|
|
service: f.service ?? null,
|
|
created_at: report.ranAt!,
|
|
})),
|
|
);
|
|
} catch (error) {
|
|
console.error('[ComposeDoctor] Failed to persist preflight run for %s:',
|
|
sanitizeForLog(report.stack), sanitizeForLog(getErrorMessage(error, 'unknown')));
|
|
}
|
|
}
|
|
}
|