mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
fix(drift): reconcile the drift ledger on deploy and timestamp its history (#1405)
* fix(drift): reconcile the drift ledger on deploy and timestamp its history
The drift ledger (persisted history + activity timeline) only advanced
when someone clicked re-check on a stack's Drift tab, so the history could
sit indefinitely out of sync with the live status: a stack reading
"drifted" live while its history still said "resolved". Two corrections:
- Deploy and update reconcile the ledger against the just-deployed runtime
(the rollback route re-deploys through deployStack, so it is covered),
resolving what the change fixed and recording what it left.
- Every authoritative reconcile stamps the dossier last-checked time, and
the Drift tab labels its history "checked {time}" so a stale finding
reads as history, not a claim about the live status above it.
Adds the last_drift_check_at column and tests across the ledger reconcile
stamp, reconcileStack, the deploy hook, and the panel.
* fix(drift): stamp last-checked inside the ledger transaction
Move the dossier last-checked stamp into the same transaction as the
finding insert/resolve, so the "checked {time}" the Drift tab shows can
never persist without the ledger update it describes. The stamp still runs
on a no-op authoritative check (a transaction that only stamps), keeping
the history "as of" honest. Adds a test that a failed deploy does not
reconcile the ledger.
This commit is contained in:
@@ -118,6 +118,7 @@ vi.mock('../services/MeshService', () => ({
|
||||
}));
|
||||
|
||||
import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService';
|
||||
import { DriftLedgerService } from '../services/DriftLedgerService';
|
||||
|
||||
const originalComposeTimeout = process.env.SENCHO_COMPOSE_COMMAND_TIMEOUT_MS;
|
||||
const originalStallTimeout = process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS;
|
||||
@@ -620,6 +621,48 @@ describe('ComposeService - updateStack prune-on-update', () => {
|
||||
|
||||
// ── withRegistryAuth ───────────────────────────────────────────────────
|
||||
|
||||
describe('ComposeService - drift reconcile hook', () => {
|
||||
it('reconciles the drift ledger after a successful update', async () => {
|
||||
setupAutoCloseSpawn();
|
||||
mockListContainers.mockResolvedValue([]);
|
||||
mockGetGlobalSettings.mockReturnValue({});
|
||||
const spy = vi.spyOn(DriftLedgerService.getInstance(), 'reconcileStack').mockResolvedValue({ detected: 0, resolved: 0 });
|
||||
|
||||
const promise = ComposeService.getInstance(1).updateStack('my-stack');
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
await promise;
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(1, 'my-stack');
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('reconciles the drift ledger after a successful deploy', async () => {
|
||||
setupAutoCloseSpawn();
|
||||
mockListContainers.mockResolvedValue([]);
|
||||
mockGetGlobalSettings.mockReturnValue({});
|
||||
const spy = vi.spyOn(DriftLedgerService.getInstance(), 'reconcileStack').mockResolvedValue({ detected: 0, resolved: 0 });
|
||||
|
||||
const promise = ComposeService.getInstance(1).deployStack('my-stack');
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
await promise;
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(1, 'my-stack');
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not reconcile the ledger when a deploy fails', async () => {
|
||||
setupAutoCloseSpawn(1); // non-zero exit => the deploy rejects before the post-success hook
|
||||
mockListContainers.mockResolvedValue([]);
|
||||
mockGetGlobalSettings.mockReturnValue({});
|
||||
const spy = vi.spyOn(DriftLedgerService.getInstance(), 'reconcileStack').mockResolvedValue({ detected: 0, resolved: 0 });
|
||||
|
||||
const result = await ComposeService.getInstance(1).deployStack('my-stack').then(() => null, (e: Error) => e);
|
||||
expect(result).toBeInstanceOf(Error);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ComposeService - withRegistryAuth', () => {
|
||||
it('passes default env when no registries configured', async () => {
|
||||
mockGetRegistries.mockReturnValue([]);
|
||||
|
||||
@@ -228,6 +228,57 @@ describe('DriftLedgerService.reconcile', () => {
|
||||
expect(res).toEqual({ detected: 0, resolved: 0 });
|
||||
expect(db().getOpenDriftFindings(nodeId, 'rec')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('stamps the dossier last-checked time on every authoritative reconcile, including a no-op', () => {
|
||||
expect(db().getStackDossier(nodeId, 'rec')?.last_drift_check_at ?? null).toBeNull();
|
||||
ledger().reconcile(nodeId, 'rec', reportWith([finding('image-mismatch', 'web')], { stack: 'rec' }));
|
||||
const first = db().getStackDossier(nodeId, 'rec')?.last_drift_check_at;
|
||||
expect(typeof first).toBe('number');
|
||||
// A repeat check that records nothing new still advances the last-checked stamp.
|
||||
ledger().reconcile(nodeId, 'rec', reportWith([finding('image-mismatch', 'web')], { stack: 'rec' }));
|
||||
const second = db().getStackDossier(nodeId, 'rec')?.last_drift_check_at;
|
||||
expect(second as number).toBeGreaterThanOrEqual(first as number);
|
||||
});
|
||||
|
||||
it('does not stamp last-checked for a non-authoritative (unreachable) report', () => {
|
||||
ledger().reconcile(nodeId, 'rec', { stack: 'rec', status: 'unreachable', hasComposeFile: true, hasContainers: false, findings: [] });
|
||||
expect(db().getStackDossier(nodeId, 'rec')?.last_drift_check_at ?? null).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DriftLedgerService.reconcileStack', () => {
|
||||
const STACK_A = 'recstacka';
|
||||
let dirA: string;
|
||||
|
||||
const composeDir = () => process.env.COMPOSE_DIR as string;
|
||||
|
||||
// A running container on a different image than compose declares => image-mismatch.
|
||||
const driftedContainer = (stack: string) => ({
|
||||
id: `${stack}-c1`, name: `${stack}-web-1`, service: 'web', composeProject: stack, stack,
|
||||
state: 'running', image: 'nginx:1.26', networks: [], volumes: [], ports: [],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clearLedger(STACK_A);
|
||||
dirA = path.join(composeDir(), STACK_A);
|
||||
fs.mkdirSync(dirA, { recursive: true });
|
||||
fs.writeFileSync(path.join(dirA, 'compose.yaml'), 'services:\n web:\n image: nginx:1.27\n');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(dirA, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reconcileStack builds the report, persists drift, and stamps last-checked', async () => {
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getDependencySnapshot: vi.fn().mockResolvedValue({ containers: [driftedContainer(STACK_A)], networks: [], volumes: [] }),
|
||||
} as unknown as DockerController);
|
||||
const res = await DriftLedgerService.getInstance().reconcileStack(nodeId, STACK_A);
|
||||
expect(res).toEqual({ detected: 1, resolved: 0 });
|
||||
expect(db().getOpenDriftFindings(nodeId, STACK_A)).toHaveLength(1);
|
||||
expect(typeof db().getStackDossier(nodeId, STACK_A)?.last_drift_check_at).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DriftLedgerService.recordBaseline', () => {
|
||||
@@ -286,6 +337,8 @@ describe('drift route (GET read-only, POST recheck persists)', () => {
|
||||
// A passive read must not persist anything.
|
||||
expect(db().getOpenDriftFindings(nodeId, STACK)).toHaveLength(0);
|
||||
expect(driftActivity(STACK)).toHaveLength(0);
|
||||
// Never reconciled, so the history has no "as of" time.
|
||||
expect(res.body.lastCheckedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('POST recheck persists the current drift and returns temporal + ledger', async () => {
|
||||
@@ -297,6 +350,8 @@ describe('drift route (GET read-only, POST recheck persists)', () => {
|
||||
expect(Array.isArray(res.body.ledger)).toBe(true);
|
||||
expect(res.body.ledger).toHaveLength(1);
|
||||
expect(res.body.ledger[0]).toMatchObject({ service: 'web', kind: 'image-mismatch', resolvedAt: null });
|
||||
// The recheck reconciled, so the history carries an "as of" timestamp.
|
||||
expect(typeof res.body.lastCheckedAt).toBe('number');
|
||||
|
||||
// The transition was recorded exactly once in the activity timeline.
|
||||
const acts = driftActivity(STACK);
|
||||
|
||||
@@ -994,7 +994,7 @@ async function buildDriftPayload(
|
||||
nodeId: number,
|
||||
stackName: string,
|
||||
reconcile: boolean,
|
||||
): Promise<StackDriftReport & { temporal: DriftTemporal; ledger: DriftLedgerEntry[] }> {
|
||||
): Promise<StackDriftReport & { temporal: DriftTemporal; ledger: DriftLedgerEntry[]; lastCheckedAt: number | null }> {
|
||||
const report = await buildStackDriftReport(nodeId, stackName);
|
||||
// Only the on-disk read is best-effort: an unreadable compose is already surfaced
|
||||
// by the report as a parse error, so temporal degrades to neutral. computeTemporal
|
||||
@@ -1016,7 +1016,11 @@ async function buildDriftPayload(
|
||||
const ledger: DriftLedgerEntry[] = DatabaseService.getInstance()
|
||||
.getRecentDriftFindings(nodeId, stackName, 20)
|
||||
.map(r => ({ service: r.service, kind: r.finding_type as DriftFindingKind, message: r.message, detectedAt: r.detected_at, resolvedAt: r.resolved_at }));
|
||||
return { ...report, temporal, ledger };
|
||||
// The ledger reflects the last reconcile (re-check, deploy, or background scan),
|
||||
// not this passive read, so surface when that was: the Drift tab labels the history
|
||||
// "checked {time ago}" and a stale finding reads as history, not current truth.
|
||||
const lastCheckedAt = DatabaseService.getInstance().getStackDossier(nodeId, stackName)?.last_drift_check_at ?? null;
|
||||
return { ...report, temporal, ledger, lastCheckedAt };
|
||||
}
|
||||
|
||||
stacksRouter.get('/:stackName/drift', async (req: Request, res: Response) => {
|
||||
|
||||
@@ -455,6 +455,13 @@ export class ComposeService {
|
||||
// route: bulk, Git-source, App Store, scheduler, and webhook deploys all funnel
|
||||
// through this method. Internally guarded; awaited so it cannot race later work.
|
||||
await DriftLedgerService.getInstance().recordBaseline(this.nodeId, stackName);
|
||||
// Reconcile the ledger against the just-deployed runtime: findings this deploy
|
||||
// fixed are resolved and any it left are recorded (and surfaced in the activity
|
||||
// feed) now, instead of waiting for someone to open the Drift tab. The rollback
|
||||
// route re-deploys through this method, so it is covered; a failed atomic deploy
|
||||
// instead restores the previous files and throws above, so that recovery path
|
||||
// reconciles on its next deploy or scan, not here. Best-effort internally.
|
||||
await DriftLedgerService.getInstance().reconcileStack(this.nodeId, stackName);
|
||||
}
|
||||
|
||||
streamLogs(stackName: string, ws: WebSocket) {
|
||||
@@ -652,8 +659,10 @@ export class ComposeService {
|
||||
throw updateError;
|
||||
}
|
||||
// Reached only on a successful update; re-baseline so temporal drift compares
|
||||
// against what is now deployed (see deployStack for why this lives here).
|
||||
// against what is now deployed (see deployStack for why this lives here), then
|
||||
// reconcile the ledger against the updated runtime.
|
||||
await DriftLedgerService.getInstance().recordBaseline(this.nodeId, stackName);
|
||||
await DriftLedgerService.getInstance().reconcileStack(this.nodeId, stackName);
|
||||
}
|
||||
|
||||
public async downStack(stackName: string): Promise<void> {
|
||||
|
||||
@@ -90,6 +90,8 @@ export interface StackDossier extends StackDossierFields {
|
||||
source_hash?: string | null;
|
||||
/** SHA-256 of the parsed compose model at the last deploy (ignores comments/whitespace). */
|
||||
rendered_hash?: string | null;
|
||||
/** When the drift ledger was last reconciled for this stack (re-check, deploy, or background scan); null if never. */
|
||||
last_drift_check_at?: number | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
@@ -1284,6 +1286,7 @@ export class DatabaseService {
|
||||
custom_notes TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_drift_check_at INTEGER,
|
||||
UNIQUE(node_id, stack_name)
|
||||
);
|
||||
|
||||
@@ -1669,6 +1672,7 @@ export class DatabaseService {
|
||||
private migrateStackDossierHashes(): void {
|
||||
this.tryAddColumn('stack_dossiers', 'source_hash', 'TEXT');
|
||||
this.tryAddColumn('stack_dossiers', 'rendered_hash', 'TEXT');
|
||||
this.tryAddColumn('stack_dossiers', 'last_drift_check_at', 'INTEGER');
|
||||
}
|
||||
|
||||
private migrateGitSourceMultiFile(): void {
|
||||
@@ -2338,6 +2342,22 @@ export class DatabaseService {
|
||||
).run(nodeId, stackName, sourceHash, renderedHash, now, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp when the drift ledger was last reconciled for a stack (re-check, deploy,
|
||||
* or background scan). Mirrors setStackDossierHashes: creates a notes-empty row
|
||||
* if none exists, otherwise updates only this column so operator notes and their
|
||||
* updated_at are left untouched.
|
||||
*/
|
||||
public setStackDossierDriftCheck(nodeId: number, stackName: string, checkedAt: number): void {
|
||||
const now = Date.now();
|
||||
this.db.prepare(
|
||||
`INSERT INTO stack_dossiers (node_id, stack_name, last_drift_check_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(node_id, stack_name) DO UPDATE SET
|
||||
last_drift_check_at = excluded.last_drift_check_at`
|
||||
).run(nodeId, stackName, checkedAt, now, now);
|
||||
}
|
||||
|
||||
// --- Stack Drift Findings (the persisted drift ledger) ---
|
||||
|
||||
public insertDriftFinding(f: Omit<StackDriftFindingRow, 'id' | 'resolved_at'>): number {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { DeclaredCompose } from '../helpers/composeDependencyParse';
|
||||
import { sha256Hex } from '../utils/hashing';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { buildStackDriftReport } from './DriftDetectionService';
|
||||
import type { StackDriftReport, StackDriftFinding } from './DriftDetectionService';
|
||||
|
||||
/**
|
||||
@@ -130,6 +131,7 @@ export class DriftLedgerService {
|
||||
return { detected: 0, resolved: 0 };
|
||||
}
|
||||
const db = DatabaseService.getInstance();
|
||||
const now = Date.now();
|
||||
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]));
|
||||
|
||||
@@ -141,12 +143,13 @@ export class DriftLedgerService {
|
||||
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();
|
||||
// Stamp the check time and apply any transitions in one transaction, so the
|
||||
// "checked {time ago}" the Drift tab shows can never persist without the ledger
|
||||
// update it describes. The stamp runs even on a no-op authoritative check (no
|
||||
// transitions), so the history's "as of" stays honest while a stale finding
|
||||
// reads as history rather than as live truth.
|
||||
db.getDb().transaction(() => {
|
||||
db.setStackDossierDriftCheck(nodeId, stackName, now);
|
||||
for (const f of toInsert) {
|
||||
db.insertDriftFinding({
|
||||
node_id: nodeId,
|
||||
@@ -176,6 +179,23 @@ export class DriftLedgerService {
|
||||
return { detected: toInsert.length, resolved: toResolve.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the spatial report for one stack and reconcile it into the ledger.
|
||||
* Used by the deploy and update success hooks (and the rollback route, which
|
||||
* re-deploys through deployStack) so a change resolves the findings it fixed and
|
||||
* records what it left behind. Best-effort: a build or reconcile failure is
|
||||
* logged and swallowed so it never fails the deploy that triggered it.
|
||||
*/
|
||||
async reconcileStack(nodeId: number, stackName: string): Promise<DriftReconcileResult> {
|
||||
try {
|
||||
const report = await buildStackDriftReport(nodeId, stackName);
|
||||
return this.reconcile(nodeId, stackName, report);
|
||||
} catch (error) {
|
||||
console.error('[DriftLedger] reconcileStack failed for %s:', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
return { detected: 0, resolved: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a drift transition to the stack activity timeline. History-only (no
|
||||
* external channel dispatch): a drift signal belongs in the activity feed, not
|
||||
|
||||
Reference in New Issue
Block a user