mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-03 14:18:02 +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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user