Files
sencho/backend/src/services/DriftLedgerService.ts
T
Anso b21324f97a 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.
2026-06-07 20:44:22 -04:00

206 lines
9.2 KiB
TypeScript

import { DatabaseService } from './DatabaseService';
import type { StackDriftFindingRow } from './DatabaseService';
import { FileSystemService } from './FileSystemService';
import { parseComposeDependencies } from '../helpers/composeDependencyParse';
import type { DeclaredCompose } from '../helpers/composeDependencyParse';
import { sha256Hex } from '../utils/hashing';
import { sanitizeForLog } from '../utils/safeLog';
import { getErrorMessage } from '../utils/errors';
import type { StackDriftReport, StackDriftFinding } from './DriftDetectionService';
/**
* The persistence-backed Drift Ledger that builds on the read-only spatial
* engine (DriftDetectionService). It adds the two things the engine deliberately
* leaves out: a deploy-time baseline (so "the source changed since you deployed"
* can be answered) and a persisted history of findings (so drift can be seen to
* appear and resolve over time). Node-local: it reads and writes the database of
* whichever node owns the stack.
*/
/** Temporal alignment of the on-disk compose against the last deploy baseline. */
export interface DriftTemporal {
/** True once a deploy through Sencho has recorded baseline hashes. */
hasBaseline: boolean;
/** The compose file's text differs from the last deploy. */
sourceChanged: boolean;
/** Parsed compose model differs from the last deploy (ignores comments/whitespace). */
renderedChanged: boolean;
}
export interface DriftReconcileResult {
detected: number;
resolved: number;
}
/** Stable identity for a finding across checks: same service + kind is the same finding. */
function findingKey(service: string, kind: string): string {
return JSON.stringify([service, kind]);
}
/**
* Order-independent serialization of the parsed model so two compose files that
* differ only in comments, whitespace, or key order hash equal, while a real
* change to images/ports/services/networks/volumes changes the hash. Returns null
* when the local parser cannot produce a model (for example a file over the parse
* size cap), so the caller stores no rendered baseline rather than a sentinel that
* would make a later real change read as unchanged.
*/
function stableModelString(model: DeclaredCompose): string | null {
if (model.parseError) return null;
const services = [...model.services]
.sort((a, b) => a.name.localeCompare(b.name))
.map(s => ({
name: s.name,
image: s.image ?? null,
dependsOn: [...s.dependsOn].sort(),
networks: [...s.networks].sort(),
volumes: [...s.volumes].sort(),
ports: s.ports.map(p => `${p.hostIp}:${p.publishedPort}/${p.protocol}`).sort(),
}));
const networks = Object.keys(model.networks).sort().map(k => ({ key: k, ...model.networks[k] }));
const volumes = Object.keys(model.volumes).sort().map(k => ({ key: k, ...model.volumes[k] }));
return JSON.stringify({ services, networks, volumes });
}
/**
* Hashes a compose file two ways: the raw text (source) and the parsed model
* (rendered). renderedHash is null when the model cannot be parsed, so a
* model-level comparison is simply skipped rather than forced to a false equal.
*/
export function computeStackHashes(content: string): { sourceHash: string; renderedHash: string | null } {
const model = stableModelString(parseComposeDependencies(content));
return {
sourceHash: sha256Hex(content),
renderedHash: model === null ? null : sha256Hex(model),
};
}
export class DriftLedgerService {
private static instance: DriftLedgerService | null = null;
static getInstance(): DriftLedgerService {
if (!DriftLedgerService.instance) DriftLedgerService.instance = new DriftLedgerService();
return DriftLedgerService.instance;
}
private constructor() { /* singleton */ }
/**
* Record the deploy-time baseline hashes for a stack. Best-effort: a read or
* hash failure is logged and swallowed so it never fails the deploy that
* triggered it.
*/
async recordBaseline(nodeId: number, stackName: string): Promise<void> {
try {
const content = await FileSystemService.getInstance(nodeId).getStackContent(stackName);
const { sourceHash, renderedHash } = computeStackHashes(content);
DatabaseService.getInstance().setStackDossierHashes(nodeId, stackName, sourceHash, renderedHash);
} catch (error) {
console.error('[DriftLedger] Failed to record baseline for %s:', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
}
}
/** Compare the current compose content against the stored deploy baseline. */
computeTemporal(nodeId: number, stackName: string, content: string): DriftTemporal {
const dossier = DatabaseService.getInstance().getStackDossier(nodeId, stackName);
const storedSource = dossier?.source_hash ?? null;
const storedRendered = dossier?.rendered_hash ?? null;
if (!storedSource) {
return { hasBaseline: false, sourceChanged: false, renderedChanged: false };
}
const { sourceHash, renderedHash } = computeStackHashes(content);
return {
hasBaseline: true,
sourceChanged: sourceHash !== storedSource,
// Only a real model change counts; if either side has no parseable model, skip it.
renderedChanged: storedRendered != null && renderedHash != null && renderedHash !== storedRendered,
};
}
/**
* Reconcile the spatial report's current findings against the persisted ledger:
* insert findings that are newly seen, resolve ones that have cleared. Idempotent
* (a repeat check with no change writes nothing). Skipped when the report is not
* authoritative (Docker unreachable or a compose parse error) so open findings are
* never falsely resolved. On a real transition it records one summary activity row
* per direction so the stack Activity timeline shows drift appearing and clearing.
*/
reconcile(nodeId: number, stackName: string, report: StackDriftReport): DriftReconcileResult {
if (report.status === 'unreachable' || report.parseError) {
return { detected: 0, resolved: 0 };
}
const db = DatabaseService.getInstance();
const openByKey = new Map(db.getOpenDriftFindings(nodeId, stackName).map(r => [findingKey(r.service, r.finding_type), r]));
const currentByKey = new Map(report.findings.map(f => [findingKey(f.service, f.kind), f]));
const toInsert: StackDriftFinding[] = [];
for (const [key, f] of currentByKey) {
if (!openByKey.has(key)) toInsert.push(f);
}
const toResolve: StackDriftFindingRow[] = [];
for (const [key, row] of openByKey) {
if (!currentByKey.has(key)) toResolve.push(row);
}
if (toInsert.length === 0 && toResolve.length === 0) {
return { detected: 0, resolved: 0 };
}
const now = Date.now();
db.getDb().transaction(() => {
for (const f of toInsert) {
db.insertDriftFinding({
node_id: nodeId,
stack_name: stackName,
service: f.service,
finding_type: f.kind,
severity: 'warning',
message: f.detail,
expected_json: f.expected !== undefined ? JSON.stringify(f.expected) : null,
actual_json: f.actual !== undefined ? JSON.stringify(f.actual) : null,
detected_at: now,
});
}
for (const row of toResolve) {
db.resolveDriftFinding(row.id, now);
}
})();
if (toInsert.length > 0) {
this.recordActivity(nodeId, stackName, 'drift_detected', 'warning',
`Drift detected on ${stackName}: ${toInsert.length} new finding${toInsert.length === 1 ? '' : 's'}`, now);
}
if (toResolve.length > 0) {
this.recordActivity(nodeId, stackName, 'drift_resolved', 'info',
`Drift resolved on ${stackName}: ${toResolve.length} finding${toResolve.length === 1 ? '' : 's'} cleared`, now);
}
return { detected: toInsert.length, resolved: toResolve.length };
}
/**
* Write a drift transition to the stack activity timeline. History-only (no
* external channel dispatch): a drift signal belongs in the activity feed, not
* in every configured Discord/Slack webhook.
*/
private recordActivity(
nodeId: number,
stackName: string,
category: 'drift_detected' | 'drift_resolved',
level: 'info' | 'warning',
message: string,
timestamp: number,
): void {
try {
DatabaseService.getInstance().addNotificationHistory(nodeId, {
level,
category,
message,
timestamp,
stack_name: stackName,
actor_username: null,
});
} catch (error) {
console.error('[DriftLedger] Failed to record activity for %s:', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
}
}
}