mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 03:36:59 +00:00
feat: add Compose Doctor preflight checks for stacks (#1348)
* feat: add Compose Doctor preflight checks for stacks Add an on-demand, advisory preflight that renders a stack's effective Compose model with `docker compose config` and runs a registry of deterministic checks before deploy, surfacing findings grouped by severity (blocker, high, warning, info) with a remediation for each. Findings cover unset env vars, host-port conflicts on the node, broad 0.0.0.0 exposure, missing bind-mount paths, a mounted Docker socket, privileged and host networking, moving image tags, missing restart policy and healthcheck, Swarm-only deploy fields, missing external networks or volumes, and container_name collisions. The report is node-scoped and stored as the last run per stack, and the route auto-proxies to the active node so a remote stack is checked on the node that owns it. A new Doctor tab on the stack detail panel runs preflight and shows the grouped findings, with a severity dot on the tab when the last run has blocker or high findings. The tab is gated on a compose-doctor capability so older nodes hide it. No environment value is ever stored, returned, or logged: only env key names and structural facts are read, and render failures surface a generic message or the missing required-variable names, never raw stderr. * fix: scroll the stack tab strip when its tabs overflow Adding the Doctor tab can push the per-stack Anatomy tab strip past the panel width on narrower layouts. Make the tab row scroll horizontally with subtle edge fades that appear only while there is more to scroll in that direction, so a panel wide enough to show every tab is unchanged. * fix: add clickable arrows and wheel scroll to the stack tab strip Hiding the scrollbar left mouse users with no way to scroll the overflowing tab row: a vertical wheel does not move a horizontal overflow and native rows do not drag-scroll. Replace the passive edge fades with clickable chevron arrows shown only when the row overflows that edge, and translate a vertical wheel over the row into horizontal scroll. * fix: inline the path-injection barrier in renderConfig CodeQL's path-injection check does not credit the wrapped isPathWithinBase helper as a sanitizer, so move the containment check inline at the spawn cwd sink, matching the canonical barrier used elsewhere in the codebase. Behavior is unchanged: the resolved stack directory must be contained in the compose base and may not be the base itself. * fix: hoist the compose-config spawn into the path-barrier scope The earlier inline barrier sat in a different scope than the spawn cwd sink (separated by the Promise-executor closure) and used a compound guard, so CodeQL did not credit it. Use the exact canonical startsWith barrier and hoist the spawn into the same scope as the check. Behavior is unchanged: the executor runs synchronously in the same tick as the spawn, so handlers still attach before any event can fire.
This commit is contained in:
@@ -93,6 +93,33 @@ export interface StackDossier extends StackDossierFields {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
/** A stored Compose Doctor run. Replace-on-run keeps one row per (node, stack). */
|
||||
export interface PreflightRunRow {
|
||||
id: string;
|
||||
node_id: number;
|
||||
stack_name: string;
|
||||
source_hash: string | null;
|
||||
rendered_hash: string | null;
|
||||
status: string;
|
||||
highest_severity: string | null;
|
||||
created_at: number;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
/** One finding within a stored preflight run. Never carries an environment value. */
|
||||
export interface PreflightFindingRow {
|
||||
id: string;
|
||||
run_id: string;
|
||||
rule_id: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
message: string;
|
||||
source_path: string | null;
|
||||
remediation: string | null;
|
||||
service: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
/** A persisted drift finding: one service-scoped divergence, open until resolved. */
|
||||
export interface StackDriftFindingRow {
|
||||
id: number;
|
||||
@@ -1216,6 +1243,35 @@ export class DatabaseService {
|
||||
CREATE INDEX IF NOT EXISTS idx_stack_drift_findings_open
|
||||
ON stack_drift_findings(node_id, stack_name, resolved_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS preflight_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id INTEGER NOT NULL,
|
||||
stack_name TEXT NOT NULL,
|
||||
source_hash TEXT,
|
||||
rendered_hash TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN ('pass','unrenderable','blocker','high','warning','info')),
|
||||
highest_severity TEXT CHECK (highest_severity IN ('blocker','high','warning','info')),
|
||||
created_at INTEGER NOT NULL,
|
||||
created_by TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_preflight_runs_node_stack
|
||||
ON preflight_runs(node_id, stack_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS preflight_findings (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL,
|
||||
rule_id TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('blocker','high','warning','info')),
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
source_path TEXT,
|
||||
remediation TEXT,
|
||||
service TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_preflight_findings_run
|
||||
ON preflight_findings(run_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS secrets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
@@ -2206,6 +2262,45 @@ export class DatabaseService {
|
||||
this.db.prepare('DELETE FROM stack_drift_findings WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
|
||||
}
|
||||
|
||||
// --- Compose Doctor / Preflight ---
|
||||
|
||||
/** Store a run and its findings, replacing any prior run for this (node, stack). */
|
||||
public replacePreflightRun(run: PreflightRunRow, findings: PreflightFindingRow[]): void {
|
||||
this.transaction(() => {
|
||||
this.db.prepare(
|
||||
'DELETE FROM preflight_findings WHERE run_id IN (SELECT id FROM preflight_runs WHERE node_id = ? AND stack_name = ?)'
|
||||
).run(run.node_id, run.stack_name);
|
||||
this.db.prepare('DELETE FROM preflight_runs WHERE node_id = ? AND stack_name = ?').run(run.node_id, run.stack_name);
|
||||
this.db.prepare(
|
||||
`INSERT INTO preflight_runs
|
||||
(id, node_id, stack_name, source_hash, rendered_hash, status, highest_severity, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(run.id, run.node_id, run.stack_name, run.source_hash, run.rendered_hash, run.status, run.highest_severity, run.created_at, run.created_by);
|
||||
const insert = this.db.prepare(
|
||||
`INSERT INTO preflight_findings
|
||||
(id, run_id, rule_id, severity, title, message, source_path, remediation, service, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
for (const f of findings) {
|
||||
insert.run(f.id, f.run_id, f.rule_id, f.severity, f.title, f.message, f.source_path, f.remediation, f.service, f.created_at);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** The most recent stored run for a stack, or undefined when none exists. */
|
||||
public getLatestPreflightRun(nodeId: number, stackName: string): PreflightRunRow | undefined {
|
||||
return this.db.prepare(
|
||||
'SELECT * FROM preflight_runs WHERE node_id = ? AND stack_name = ? ORDER BY created_at DESC, id DESC LIMIT 1'
|
||||
).get(nodeId, stackName) as PreflightRunRow | undefined;
|
||||
}
|
||||
|
||||
/** Findings for a run, in insertion order (the caller re-sorts by severity). */
|
||||
public getPreflightFindings(runId: string): PreflightFindingRow[] {
|
||||
return this.db.prepare(
|
||||
'SELECT * FROM preflight_findings WHERE run_id = ? ORDER BY rowid ASC'
|
||||
).all(runId) as PreflightFindingRow[];
|
||||
}
|
||||
|
||||
// --- Notification History ---
|
||||
|
||||
private mapNotificationRow(row: any): NotificationHistory {
|
||||
@@ -2544,6 +2639,8 @@ export class DatabaseService {
|
||||
this.db.prepare('DELETE FROM stack_labels WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM stack_dossiers WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM stack_drift_findings WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM preflight_findings WHERE run_id IN (SELECT id FROM preflight_runs WHERE node_id = ?)').run(id);
|
||||
this.db.prepare('DELETE FROM preflight_runs 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);
|
||||
|
||||
Reference in New Issue
Block a user