feat(stacks): add compose-vs-runtime drift detection (#1329)

* feat(stacks): add compose-vs-runtime drift engine

Add a read-only engine that compares a stack's on-disk compose model
against the live Docker runtime and reports where the two diverge.

GET /api/stacks/:stackName/drift returns a per-stack report with a
status (in-sync, drifted, missing-runtime, unreachable) and typed,
service-scoped findings: a declared service with no running container,
a running container not declared in compose, an image mismatch, and a
published-port mismatch. The report is computed at request time with no
persistence and is available on every tier.

The check reuses the existing compose parser and Docker dependency
snapshot; the compose parser now also captures each service's declared
image. Boundaries fail closed: an unreadable compose file reports
drifted and an unreachable Docker daemon reports unreachable, never a
false in-sync.

* fix(stacks): keep drift hasContainers accurate on compose parse error

assembleStackDrift hardcoded hasContainers: false on the parse-error
path, contradicting the field's contract when the runtime actually has
running containers. Compute it once from the container set and reuse it
across all return paths.

Also add a route test that exercises the successful 200 path for an
existing stack on the Community tier (stubbing only the Docker boundary),
so a tier gate or handler regression after the existence check is caught.

* feat(stacks): add a drift detection tab to the stack view

Surface the compose-vs-runtime drift report on the per-stack Anatomy
panel as a read-only Drift tab. It shows the stack's status (in sync,
drifted, not running, unreachable) and, when drifted, the specific
service-scoped reasons with the declared and running values side by
side. A re-check action reruns the comparison.

The tab lives in the shared anatomy panel, so it appears on both the
desktop stack view and the mobile stack detail. Available on every tier.

* fix(stacks): sanitize the logged error in the drift report builder

The compose-read and Docker-snapshot catch blocks logged the raw error
object, whose message can embed the user-controlled stack path (e.g. an
ENOENT path). Log the error through the existing sanitizer so a crafted
stack name cannot forge log lines, matching the pattern used elsewhere
in the stacks router.
This commit is contained in:
Anso
2026-06-07 15:48:04 -04:00
committed by GitHub
parent 8302048bc4
commit 421177e4a6
10 changed files with 1069 additions and 0 deletions
@@ -0,0 +1,144 @@
/**
* Covers the read-only drift panel: it renders each per-stack status, lists
* findings with their expected/actual values, surfaces a parse error, shows a
* retry state on load failure, and re-checks on demand.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 } }) }));
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import DriftPanel from './DriftPanel';
interface DriftReport {
stack: string;
status: string;
hasComposeFile: boolean;
hasContainers: boolean;
findings: Array<{ kind: string; service: string; detail: string; expected?: string; actual?: string }>;
parseError?: string;
}
function report(partial: Partial<DriftReport>): DriftReport {
return { stack: 'web', status: 'in-sync', hasComposeFile: true, hasContainers: true, findings: [], ...partial };
}
function jsonRes(body: unknown, ok = true) {
return { ok, status: ok ? 200 : 500, json: async () => body, text: async () => '' } as unknown as Response;
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('DriftPanel', () => {
it('renders the in-sync status', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({ status: 'in-sync' })));
render(<DriftPanel stackName="web" />);
const status = await screen.findByTestId('drift-status');
expect(status).toHaveAttribute('data-status', 'in-sync');
expect(screen.getByText(/Runtime matches/i)).toBeInTheDocument();
// A clean stack shows no findings section.
expect(screen.queryByText(/findings/i)).not.toBeInTheDocument();
});
it('renders every finding kind with its label and expected/actual values', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
status: 'drifted',
findings: [
{ kind: 'image-mismatch', service: 'web', detail: 'Service "web" runs a different image than compose declares.', expected: 'nginx:1.25', actual: 'nginx:1.24' },
{ kind: 'ports-mismatch', service: 'web', detail: 'Service "web" publishes different ports than compose declares.', expected: '8080/tcp', actual: '9090/tcp' },
{ kind: 'service-missing', service: 'db', detail: 'Service "db" is declared in compose but is not running.' },
{ kind: 'service-undeclared', service: 'sidecar', detail: 'Service "sidecar" is running but is not declared in compose.' },
],
})));
render(<DriftPanel stackName="web" />);
const status = await screen.findByTestId('drift-status');
expect(status).toHaveAttribute('data-status', 'drifted');
expect(screen.getByText(/4 findings/)).toBeInTheDocument();
// Finding-kind labels.
expect(screen.getByText('image')).toBeInTheDocument();
expect(screen.getByText('ports')).toBeInTheDocument();
expect(screen.getByText('service missing')).toBeInTheDocument();
expect(screen.getByText('undeclared')).toBeInTheDocument();
// Comparison values for image and ports findings.
expect(screen.getByText('nginx:1.25')).toBeInTheDocument();
expect(screen.getByText('nginx:1.24')).toBeInTheDocument();
expect(screen.getByText('8080/tcp')).toBeInTheDocument();
expect(screen.getByText('9090/tcp')).toBeInTheDocument();
});
it('uses the singular noun for a single finding', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
status: 'drifted',
findings: [{ kind: 'service-missing', service: 'db', detail: 'Service "db" is declared in compose but is not running.' }],
})));
render(<DriftPanel stackName="web" />);
await screen.findByTestId('drift-status');
expect(screen.getByText(/1 finding$/)).toBeInTheDocument();
});
it('renders the missing-runtime status', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({ status: 'missing-runtime', hasContainers: false })));
render(<DriftPanel stackName="web" />);
const status = await screen.findByTestId('drift-status');
expect(status).toHaveAttribute('data-status', 'missing-runtime');
});
it('renders the unreachable status', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({ status: 'unreachable', hasContainers: false })));
render(<DriftPanel stackName="web" />);
const status = await screen.findByTestId('drift-status');
expect(status).toHaveAttribute('data-status', 'unreachable');
expect(screen.getByText(/Docker is unreachable/i)).toBeInTheDocument();
});
it('surfaces a compose parse error', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
status: 'drifted', hasComposeFile: false, parseError: 'Could not parse compose file: bad yaml',
})));
render(<DriftPanel stackName="web" />);
await screen.findByTestId('drift-status');
expect(screen.getByText(/Could not parse compose file/i)).toBeInTheDocument();
});
it('shows a retry state (not a status) when the load fails', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ error: 'down' }, false));
render(<DriftPanel stackName="web" />);
await screen.findByTestId('drift-retry-btn');
expect(screen.queryByTestId('drift-status')).not.toBeInTheDocument();
expect(toast.error).toHaveBeenCalled();
});
it('shows the retry state when the request throws', async () => {
vi.mocked(apiFetch).mockRejectedValue(new Error('network'));
render(<DriftPanel stackName="web" />);
await screen.findByTestId('drift-retry-btn');
expect(screen.queryByTestId('drift-status')).not.toBeInTheDocument();
expect(toast.error).toHaveBeenCalled();
});
it('retry refetches and recovers to a status', async () => {
vi.mocked(apiFetch)
.mockResolvedValueOnce(jsonRes({ error: 'down' }, false))
.mockResolvedValueOnce(jsonRes(report({ status: 'in-sync' })));
render(<DriftPanel stackName="web" />);
fireEvent.click(await screen.findByTestId('drift-retry-btn'));
const status = await screen.findByTestId('drift-status');
expect(status).toHaveAttribute('data-status', 'in-sync');
expect(screen.queryByTestId('drift-retry-btn')).not.toBeInTheDocument();
});
it('re-checks on demand', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({ status: 'in-sync' })));
render(<DriftPanel stackName="web" />);
await screen.findByTestId('drift-status');
expect(apiFetch).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByTestId('drift-recheck-btn'));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
});
});
@@ -0,0 +1,197 @@
import { useEffect, useState } from 'react';
import { Check, TriangleAlert, CircleSlash, WifiOff, RefreshCw, type LucideIcon } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
// Mirrors the backend StackDriftReport shape (the frontend never imports backend).
type StackDriftStatus = 'in-sync' | 'drifted' | 'missing-runtime' | 'unreachable';
type DriftFindingKind = 'service-missing' | 'service-undeclared' | 'image-mismatch' | 'ports-mismatch';
interface StackDriftFinding {
kind: DriftFindingKind;
service: string;
detail: string;
expected?: string;
actual?: string;
}
interface StackDriftReport {
stack: string;
status: StackDriftStatus;
hasComposeFile: boolean;
hasContainers: boolean;
findings: StackDriftFinding[];
parseError?: string;
}
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 STATUS_META: Record<StackDriftStatus, { label: string; icon: LucideIcon; tone: string; line: string }> = {
'in-sync': {
label: 'in sync',
icon: Check,
tone: 'border-success/40 bg-success/[0.06] text-success',
line: 'Runtime matches the compose file.',
},
drifted: {
label: 'drifted',
icon: TriangleAlert,
tone: 'border-warning/40 bg-warning/[0.06] text-warning',
line: 'Runtime differs from the compose file.',
},
'missing-runtime': {
label: 'not running',
icon: CircleSlash,
tone: 'border-muted bg-card/40 text-stat-subtitle',
line: 'Defined on disk but no containers are running.',
},
unreachable: {
label: 'unreachable',
icon: WifiOff,
tone: 'border-destructive/40 bg-destructive/[0.06] text-destructive',
line: 'Docker is unreachable, so drift cannot be assessed.',
},
};
const FINDING_LABEL: Record<DriftFindingKind, string> = {
'service-missing': 'service missing',
'service-undeclared': 'undeclared',
'image-mismatch': 'image',
'ports-mismatch': 'ports',
};
function Finding({ finding }: { finding: StackDriftFinding }) {
return (
<div className="border-t border-muted py-2 first:border-t-0">
<div className="flex items-center gap-2">
<span className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">{finding.service}</span>
<span className="font-mono text-[10px] uppercase tracking-wide text-stat-subtitle">{FINDING_LABEL[finding.kind]}</span>
</div>
<div className="mt-1 text-[12px] text-foreground/90">{finding.detail}</div>
{finding.expected !== undefined && finding.actual !== undefined && (
<div className="mt-1 flex flex-wrap items-center gap-1.5 font-mono text-[11px]">
<span className="text-stat-subtitle">compose</span>
<span className="text-foreground/90">{finding.expected}</span>
<span className="text-stat-subtitle"> running</span>
<span className="font-semibold text-foreground">{finding.actual}</span>
</div>
)}
</div>
);
}
export default function DriftPanel({ stackName }: { stackName: string }) {
const { activeNode } = useNodes();
const nodeId = activeNode?.id;
const [report, setReport] = useState<StackDriftReport | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
// 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.
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`);
if (cancelled) return;
if (!res.ok) {
setLoadError(true);
toast.error('Failed to load the drift report.');
return;
}
setReport((await res.json()) as StackDriftReport);
setLoadError(false);
} catch {
if (!cancelled) {
setLoadError(true);
toast.error('Failed to load the drift report.');
}
} finally {
if (!cancelled) setLoading(false);
}
};
void run();
return () => { cancelled = true; };
}, [stackName, nodeId, reloadKey]);
const meta = report ? STATUS_META[report.status] : null;
const StatusIcon = meta?.icon;
return (
<div data-testid="drift-panel" className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<span className={LABEL_CLASS}>compose vs runtime</span>
<button
type="button"
data-testid="drift-recheck-btn"
onClick={() => setReloadKey(k => k + 1)}
disabled={loading}
className={ACTION_CLASS}
>
<RefreshCw className={cn('h-3 w-3', loading && 'animate-spin')} strokeWidth={1.5} /> re-check
</button>
</div>
{loadError ? (
<div className="flex items-center justify-between gap-3 rounded-lg border border-destructive/40 bg-destructive/[0.06] px-3 py-3">
<span className="font-mono text-[11px] text-destructive">Could not load the drift report.</span>
<button
type="button"
data-testid="drift-retry-btn"
onClick={() => setReloadKey(k => k + 1)}
className="font-mono text-[10px] uppercase tracking-wide text-destructive hover:underline"
>
retry
</button>
</div>
) : !report ? (
<div className="py-3 font-mono text-[11px] text-stat-subtitle">Checking drift</div>
) : (
<>
{meta && StatusIcon && (
<div data-testid="drift-status" data-status={report.status} className={cn('rounded-lg border px-3 py-2.5', 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>
{report.findings.length > 0 && (
<span className="font-mono text-[10px] text-stat-subtitle">
· {report.findings.length} finding{report.findings.length === 1 ? '' : 's'}
</span>
)}
</div>
<div className="mt-1 font-mono text-[11px] leading-relaxed text-foreground/80">{meta.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}
</div>
)}
{report.findings.length > 0 && (
<section>
<div className={cn(LABEL_CLASS, 'mb-1.5')}>findings</div>
<div className="rounded-lg border border-muted bg-card/40 px-3 py-1">
{report.findings.map((f, i) => (
<Finding key={`${f.service}-${f.kind}-${i}`} finding={f} />
))}
</div>
</section>
)}
</>
)}
</div>
);
}