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:
Anso
2026-06-07 20:44:22 -04:00
committed by GitHub
parent 421177e4a6
commit b21324f97a
11 changed files with 920 additions and 18 deletions
@@ -21,6 +21,8 @@ interface DriftReport {
hasContainers: boolean;
findings: Array<{ kind: string; service: string; detail: string; expected?: string; actual?: string }>;
parseError?: string;
temporal?: { hasBaseline: boolean; sourceChanged: boolean; renderedChanged: boolean };
ledger?: Array<{ service: string; kind: string; message: string; detectedAt: number; resolvedAt: number | null }>;
}
function report(partial: Partial<DriftReport>): DriftReport {
@@ -133,12 +135,79 @@ describe('DriftPanel', () => {
expect(screen.queryByTestId('drift-retry-btn')).not.toBeInTheDocument();
});
it('re-checks on demand', async () => {
it('re-checks on demand via the recheck endpoint (a POST), not the read GET', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({ status: 'in-sync' })));
render(<DriftPanel stackName="web" />);
await screen.findByTestId('drift-status');
expect(apiFetch).toHaveBeenCalledTimes(1);
expect(apiFetch).toHaveBeenLastCalledWith('/stacks/web/drift');
fireEvent.click(screen.getByTestId('drift-recheck-btn'));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
expect(apiFetch).toHaveBeenLastCalledWith('/stacks/web/drift/recheck', { method: 'POST' });
});
it('omits the temporal card when the report carries no temporal field (older node)', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({ status: 'in-sync' }))); // no temporal field
render(<DriftPanel stackName="web" />);
await screen.findByTestId('drift-status');
expect(screen.queryByTestId('drift-temporal')).not.toBeInTheDocument();
});
it('shows "no deploy baseline" when the report has no temporal baseline', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
status: 'in-sync',
temporal: { hasBaseline: false, sourceChanged: false, renderedChanged: false },
})));
render(<DriftPanel stackName="web" />);
const temporal = await screen.findByTestId('drift-temporal');
expect(temporal).toHaveAttribute('data-temporal', 'no-baseline');
});
it('flags a source change since the last deploy', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
status: 'in-sync',
temporal: { hasBaseline: true, sourceChanged: true, renderedChanged: true },
})));
render(<DriftPanel stackName="web" />);
const temporal = await screen.findByTestId('drift-temporal');
expect(temporal).toHaveAttribute('data-temporal', 'source-changed');
expect(screen.getByText(/changed since the last deploy/i)).toBeInTheDocument();
});
it('notes a formatting-only change when source changed but the model did not', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
status: 'in-sync',
temporal: { hasBaseline: true, sourceChanged: true, renderedChanged: false },
})));
render(<DriftPanel stackName="web" />);
const temporal = await screen.findByTestId('drift-temporal');
expect(temporal).toHaveAttribute('data-temporal', 'source-changed');
expect(screen.getByText(/formatting only/i)).toBeInTheDocument();
});
it('shows "matches last deploy" when the source is unchanged', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
status: 'in-sync',
temporal: { hasBaseline: true, sourceChanged: false, renderedChanged: false },
})));
render(<DriftPanel stackName="web" />);
const temporal = await screen.findByTestId('drift-temporal');
expect(temporal).toHaveAttribute('data-temporal', 'matches');
});
it('renders the persisted drift history with open and resolved entries', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
status: 'drifted',
findings: [{ kind: 'image-mismatch', service: 'web', detail: 'image differs' }],
ledger: [
{ service: 'web', kind: 'image-mismatch', message: 'image differs', detectedAt: Date.now(), resolvedAt: null },
{ service: 'db', kind: 'service-missing', message: 'db not running', detectedAt: Date.now() - 1000, resolvedAt: Date.now() },
],
})));
render(<DriftPanel stackName="web" />);
await screen.findByTestId('drift-status');
expect(screen.getByText(/drift history/i)).toBeInTheDocument();
expect(screen.getByText('open')).toBeInTheDocument();
expect(screen.getByText('resolved')).toBeInTheDocument();
});
});
+131 -11
View File
@@ -1,11 +1,15 @@
import { useEffect, useState } from 'react';
import { Check, TriangleAlert, CircleSlash, WifiOff, RefreshCw, type LucideIcon } from 'lucide-react';
import {
Check, TriangleAlert, CircleSlash, WifiOff, RefreshCw,
FileClock, FileCheck2, FileQuestion, type LucideIcon,
} from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui/toast-store';
import { formatTimeAgo } from '@/lib/relativeTime';
import { useNodes } from '@/context/NodeContext';
// Mirrors the backend StackDriftReport shape (the frontend never imports backend).
// Mirrors the backend payload shape (the frontend never imports backend).
type StackDriftStatus = 'in-sync' | 'drifted' | 'missing-runtime' | 'unreachable';
type DriftFindingKind = 'service-missing' | 'service-undeclared' | 'image-mismatch' | 'ports-mismatch';
@@ -17,6 +21,20 @@ interface StackDriftFinding {
actual?: string;
}
interface DriftTemporal {
hasBaseline: boolean;
sourceChanged: boolean;
renderedChanged: boolean;
}
interface DriftLedgerEntry {
service: string;
kind: DriftFindingKind;
message: string;
detectedAt: number;
resolvedAt: number | null;
}
interface StackDriftReport {
stack: string;
status: StackDriftStatus;
@@ -24,11 +42,15 @@ interface StackDriftReport {
hasContainers: boolean;
findings: StackDriftFinding[];
parseError?: string;
// Optional so a report from an older remote node (no ledger layer) still renders.
temporal?: DriftTemporal;
ledger?: DriftLedgerEntry[];
}
const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle';
const ACTION_CLASS =
'inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors disabled:opacity-40';
const CARD_CLASS = 'rounded-lg border px-3 py-2.5';
const STATUS_META: Record<StackDriftStatus, { label: string; icon: LucideIcon; tone: string; line: string }> = {
'in-sync': {
@@ -64,6 +86,37 @@ const FINDING_LABEL: Record<DriftFindingKind, string> = {
'ports-mismatch': 'ports',
};
/** The temporal overlay: how the on-disk compose compares to the last deploy baseline. */
function temporalMeta(temporal: DriftTemporal): { label: string; icon: LucideIcon; tone: string; line: string; key: string } {
if (!temporal.hasBaseline) {
return {
key: 'no-baseline',
label: 'no deploy baseline',
icon: FileQuestion,
tone: 'border-muted bg-card/40 text-stat-subtitle',
line: 'Deploy through Sencho to start tracking changes since deploy.',
};
}
if (temporal.sourceChanged) {
return {
key: 'source-changed',
label: 'source changed',
icon: FileClock,
tone: 'border-warning/40 bg-warning/[0.06] text-warning',
line: temporal.renderedChanged
? 'The compose model changed since the last deploy.'
: 'The compose file changed since the last deploy (formatting only).',
};
}
return {
key: 'matches',
label: 'matches last deploy',
icon: FileCheck2,
tone: 'border-success/40 bg-success/[0.06] text-success',
line: 'The compose source is unchanged since the last deploy.',
};
}
function Finding({ finding }: { finding: StackDriftFinding }) {
return (
<div className="border-t border-muted py-2 first:border-t-0">
@@ -84,6 +137,26 @@ function Finding({ finding }: { finding: StackDriftFinding }) {
);
}
function LedgerRow({ entry }: { entry: DriftLedgerEntry }) {
const resolved = entry.resolvedAt != null;
return (
<div className="border-t border-muted py-2 first:border-t-0">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">{entry.service}</span>
<span className="font-mono text-[10px] uppercase tracking-wide text-stat-subtitle">{FINDING_LABEL[entry.kind] ?? entry.kind}</span>
<span className={cn('font-mono text-[10px] uppercase tracking-wide', resolved ? 'text-success' : 'text-warning')}>
{resolved ? 'resolved' : 'open'}
</span>
</div>
<div className="mt-1 text-[12px] text-foreground/90">{entry.message}</div>
<div className="mt-1 font-mono text-[10px] text-stat-subtitle">
detected {formatTimeAgo(entry.detectedAt)}
{entry.resolvedAt != null ? ` · resolved ${formatTimeAgo(entry.resolvedAt)}` : ''}
</div>
</div>
);
}
export default function DriftPanel({ stackName }: { stackName: string }) {
const { activeNode } = useNodes();
const nodeId = activeNode?.id;
@@ -91,16 +164,16 @@ export default function DriftPanel({ stackName }: { stackName: string }) {
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [rechecking, setRechecking] = useState(false);
// Refetch when the stack OR the active node changes (the same stack can exist on
// two nodes), and on an explicit re-check. Drift is a point-in-time snapshot, so
// a failed load shows a distinct retry state rather than a stale or blank report.
// Passive load when the stack OR active node changes (the same stack can exist on
// two nodes), and on an explicit retry. Read-only: it never writes the ledger, so
// opening the tab has no side effects. A failed load shows a distinct retry state
// rather than a stale or blank report.
useEffect(() => {
let cancelled = false;
const run = async () => {
setLoading(true);
// Clear any prior failure so an in-flight re-check shows the checking
// affordance instead of leaving the error card up.
setLoadError(false);
try {
const res = await apiFetch(`/stacks/${stackName}/drift`);
@@ -125,8 +198,34 @@ export default function DriftPanel({ stackName }: { stackName: string }) {
return () => { cancelled = true; };
}, [stackName, nodeId, reloadKey]);
// Re-check reconciles the ledger server-side (recording newly detected / resolved
// findings) and returns the fresh payload, so the history reflects this check.
const recheck = async () => {
setRechecking(true);
try {
const res = await apiFetch(`/stacks/${stackName}/drift/recheck`, { method: 'POST' });
if (!res.ok) {
toast.error('Failed to re-check drift.');
return;
}
setReport((await res.json()) as StackDriftReport);
setLoadError(false);
} catch {
toast.error('Failed to re-check drift.');
} finally {
setRechecking(false);
}
};
const meta = report ? STATUS_META[report.status] : null;
const StatusIcon = meta?.icon;
// Only render the temporal card when the payload actually carries it. A report
// proxied from an older node without the ledger layer omits it; showing "no deploy
// baseline" there would be misleading, so the card is left out entirely.
const temporal = report?.temporal ? temporalMeta(report.temporal) : null;
const TemporalIcon = temporal?.icon;
const ledger = report?.ledger ?? [];
const busy = loading || rechecking;
return (
<div data-testid="drift-panel" className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4">
@@ -135,11 +234,11 @@ export default function DriftPanel({ stackName }: { stackName: string }) {
<button
type="button"
data-testid="drift-recheck-btn"
onClick={() => setReloadKey(k => k + 1)}
disabled={loading}
onClick={recheck}
disabled={busy}
className={ACTION_CLASS}
>
<RefreshCw className={cn('h-3 w-3', loading && 'animate-spin')} strokeWidth={1.5} /> re-check
<RefreshCw className={cn('h-3 w-3', busy && 'animate-spin')} strokeWidth={1.5} /> re-check
</button>
</div>
@@ -160,7 +259,7 @@ export default function DriftPanel({ stackName }: { stackName: string }) {
) : (
<>
{meta && StatusIcon && (
<div data-testid="drift-status" data-status={report.status} className={cn('rounded-lg border px-3 py-2.5', meta.tone)}>
<div data-testid="drift-status" data-status={report.status} className={cn(CARD_CLASS, meta.tone)}>
<div className="flex items-center gap-2">
<StatusIcon className="h-4 w-4 shrink-0" strokeWidth={1.5} />
<span className="font-mono text-[11px] uppercase tracking-wide">{meta.label}</span>
@@ -174,6 +273,16 @@ export default function DriftPanel({ stackName }: { stackName: string }) {
</div>
)}
{temporal && TemporalIcon && (
<div data-testid="drift-temporal" data-temporal={temporal.key} className={cn(CARD_CLASS, temporal.tone)}>
<div className="flex items-center gap-2">
<TemporalIcon className="h-4 w-4 shrink-0" strokeWidth={1.5} />
<span className="font-mono text-[11px] uppercase tracking-wide">{temporal.label}</span>
</div>
<div className="mt-1 font-mono text-[11px] leading-relaxed text-foreground/80">{temporal.line}</div>
</div>
)}
{report.parseError && (
<div className="rounded-lg border border-destructive/40 bg-destructive/[0.06] px-3 py-2 font-mono text-[11px] text-destructive">
{report.parseError}
@@ -190,6 +299,17 @@ export default function DriftPanel({ stackName }: { stackName: string }) {
</div>
</section>
)}
{ledger.length > 0 && (
<section>
<div className={cn(LABEL_CLASS, 'mb-1.5')}>drift history</div>
<div className="rounded-lg border border-muted bg-card/40 px-3 py-1">
{ledger.map((e, i) => (
<LedgerRow key={`${e.service}-${e.kind}-${e.detectedAt}-${i}`} entry={e} />
))}
</div>
</section>
)}
</>
)}
</div>
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Rocket, RefreshCcw, CircleStop, Play, ArrowUp, Activity, Loader2, AlertCircle,
TriangleAlert, CircleCheck,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -33,6 +34,8 @@ const CATEGORY_ICON: Record<string, LucideIcon> = {
stack_stopped: CircleStop,
stack_started: Play,
image_update_applied: ArrowUp,
drift_detected: TriangleAlert,
drift_resolved: CircleCheck,
};
const DAY_MS = 86_400_000;