mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
feat(stacks): persist a drift ledger with temporal source-change detection (#1333)
* feat(stacks): persist a drift ledger with temporal source-change detection Build on the read-only compose-vs-runtime drift check so a stack's drift is remembered over time, not just shown at a glance. - Record a deploy baseline: on a successful deploy, update, or rollback, store the deployed compose file's source and rendered-model hashes on the stack so the Drift tab can tell whether the file has changed since the last deploy. - Surface temporal drift in the Drift tab: "matches last deploy", "source changed since last deploy" (distinguishing a model change from a formatting-only edit), or "no deploy baseline yet". - Persist findings into a drift ledger: a re-check reconciles the current findings, recording newly detected ones and resolving cleared ones, and shows a short drift history under the findings. The drift report read stays side-effect-free; only an explicit re-check (and a deploy) writes the ledger. - Write drift detected/resolved events to the stack Activity timeline so the provenance sits alongside deploys and restarts. Node-local and available on the Community tier. Reconciliation is skipped when a check is not authoritative (Docker unreachable or a compose parse error) so an open finding is never falsely cleared. * fix(stacks): record the drift baseline for every deploy path and harden the ledger Address review feedback on the drift ledger: - Record the deploy baseline in ComposeService.deployStack/updateStack instead of only the manual route, so bulk, Git-source, App Store, scheduler, and webhook deploys all capture source/rendered hashes. Reconciliation stays on the explicit re-check. - Store no rendered baseline when the local parser cannot model the compose (for example a file over the parse cap) rather than a sentinel that would make a later real change read as unchanged. - Let temporal-overlay failures surface as a 500 instead of being hidden behind a neutral "no baseline"; only the compose read stays best-effort. - Omit the temporal card entirely when a report (for example from an older remote node) carries no temporal data, instead of showing a misleading "no baseline". - Keep drift_detected / drift_resolved history-only by excluding them from the routable-category whitelist, so they are never offered as a channel route that would never fire. - Use a JSON separator for the finding identity key so the source file is plain text (no embedded control byte). * fix(stacks): sanitize logged errors in the drift report handlers The drift report and re-check handlers logged the caught error object raw alongside the stack name, which a code scan flagged as a log-injection vector: a crafted stack name surfacing inside an error message or stack could forge log lines. Route the error through the log sanitizer so control characters are stripped before writing. Render it with util.inspect first so the stack trace, cause chain, and underlying error codes are preserved for debugging.
This commit is contained in:
@@ -85,10 +85,29 @@ export interface StackDossier extends StackDossierFields {
|
||||
id?: number;
|
||||
node_id: number;
|
||||
stack_name: string;
|
||||
/** SHA-256 of the compose file's UTF-8 text at the last deploy through Sencho (baseline for temporal drift). */
|
||||
source_hash?: string | null;
|
||||
/** SHA-256 of the parsed compose model at the last deploy (ignores comments/whitespace). */
|
||||
rendered_hash?: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
/** A persisted drift finding: one service-scoped divergence, open until resolved. */
|
||||
export interface StackDriftFindingRow {
|
||||
id: number;
|
||||
node_id: number;
|
||||
stack_name: string;
|
||||
service: string;
|
||||
finding_type: string;
|
||||
severity: string;
|
||||
message: string;
|
||||
expected_json: string | null;
|
||||
actual_json: string | null;
|
||||
detected_at: number;
|
||||
resolved_at: number | null;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -697,6 +716,7 @@ export class DatabaseService {
|
||||
this.migrateAddBlueprintPinnedNode();
|
||||
this.migrateAutoHealNodeId();
|
||||
this.migrateFleetSyncStickyError();
|
||||
this.migrateStackDossierHashes();
|
||||
|
||||
// Reset the cache once at end of constructor in case any migration
|
||||
// populated it via getGlobalSettings() and a subsequent migration
|
||||
@@ -1175,6 +1195,22 @@ export class DatabaseService {
|
||||
UNIQUE(node_id, stack_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stack_drift_findings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER NOT NULL,
|
||||
stack_name TEXT NOT NULL,
|
||||
service TEXT NOT NULL,
|
||||
finding_type TEXT NOT NULL,
|
||||
severity TEXT NOT NULL DEFAULT 'warning',
|
||||
message TEXT NOT NULL,
|
||||
expected_json TEXT,
|
||||
actual_json TEXT,
|
||||
detected_at INTEGER NOT NULL,
|
||||
resolved_at INTEGER
|
||||
);
|
||||
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 secrets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
@@ -1473,6 +1509,11 @@ export class DatabaseService {
|
||||
this.tryAddColumn('notification_history', 'container_name', 'TEXT');
|
||||
}
|
||||
|
||||
private migrateStackDossierHashes(): void {
|
||||
this.tryAddColumn('stack_dossiers', 'source_hash', 'TEXT');
|
||||
this.tryAddColumn('stack_dossiers', 'rendered_hash', 'TEXT');
|
||||
}
|
||||
|
||||
private migrateScanPolicyFleetColumns(): void {
|
||||
this.tryAddColumn('scan_policies', 'node_identity', "TEXT NOT NULL DEFAULT ''");
|
||||
this.tryAddColumn('scan_policies', 'replicated_from_control', 'INTEGER NOT NULL DEFAULT 0');
|
||||
@@ -2109,6 +2150,55 @@ export class DatabaseService {
|
||||
this.db.prepare('DELETE FROM stack_dossiers WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the deploy-time baseline hashes for a stack. Creates a dossier row
|
||||
* with empty operator notes if none exists; on conflict updates only the hash
|
||||
* columns so operator-authored notes and their updated_at are left untouched.
|
||||
*/
|
||||
public setStackDossierHashes(nodeId: number, stackName: string, sourceHash: string, renderedHash: string | null): void {
|
||||
const now = Date.now();
|
||||
this.db.prepare(
|
||||
`INSERT INTO stack_dossiers (node_id, stack_name, source_hash, rendered_hash, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(node_id, stack_name) DO UPDATE SET
|
||||
source_hash = excluded.source_hash,
|
||||
rendered_hash = excluded.rendered_hash`
|
||||
).run(nodeId, stackName, sourceHash, renderedHash, now, now);
|
||||
}
|
||||
|
||||
// --- Stack Drift Findings (the persisted drift ledger) ---
|
||||
|
||||
public insertDriftFinding(f: Omit<StackDriftFindingRow, 'id' | 'resolved_at'>): number {
|
||||
const res = this.db.prepare(
|
||||
`INSERT INTO stack_drift_findings
|
||||
(node_id, stack_name, service, finding_type, severity, message, expected_json, actual_json, detected_at, resolved_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`
|
||||
).run(f.node_id, f.stack_name, f.service, f.finding_type, f.severity, f.message, f.expected_json, f.actual_json, f.detected_at);
|
||||
return res.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
public resolveDriftFinding(id: number, resolvedAt: number): void {
|
||||
this.db.prepare('UPDATE stack_drift_findings SET resolved_at = ? WHERE id = ? AND resolved_at IS NULL').run(resolvedAt, id);
|
||||
}
|
||||
|
||||
/** Open (unresolved) findings for a stack, oldest first. */
|
||||
public getOpenDriftFindings(nodeId: number, stackName: string): StackDriftFindingRow[] {
|
||||
return this.db.prepare(
|
||||
'SELECT * FROM stack_drift_findings WHERE node_id = ? AND stack_name = ? AND resolved_at IS NULL ORDER BY detected_at ASC, id ASC'
|
||||
).all(nodeId, stackName) as StackDriftFindingRow[];
|
||||
}
|
||||
|
||||
/** Recent findings for a stack: open ones first, then resolved, each newest first. */
|
||||
public getRecentDriftFindings(nodeId: number, stackName: string, limit: number): StackDriftFindingRow[] {
|
||||
return this.db.prepare(
|
||||
'SELECT * FROM stack_drift_findings WHERE node_id = ? AND stack_name = ? ORDER BY (resolved_at IS NOT NULL) ASC, detected_at DESC, id DESC LIMIT ?'
|
||||
).all(nodeId, stackName, limit) as StackDriftFindingRow[];
|
||||
}
|
||||
|
||||
public deleteStackDriftFindings(nodeId: number, stackName: string): void {
|
||||
this.db.prepare('DELETE FROM stack_drift_findings WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
|
||||
}
|
||||
|
||||
// --- Notification History ---
|
||||
|
||||
private mapNotificationRow(row: any): NotificationHistory {
|
||||
@@ -2446,6 +2536,7 @@ export class DatabaseService {
|
||||
this.db.prepare('DELETE FROM stack_label_assignments WHERE node_id = ?').run(id);
|
||||
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('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