Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ Unified table using TanStack Table v8 (headless) + CSS Grid rows. Key files: `Da

Separate Bun package that runs as a sidecar container alongside Docker hosts. Provides a REST/SSE API for Docker management operations (deploy, logs, stats streaming). Uses raw `Bun.serve()` with manual route matching and timing-safe auth middleware (zero framework dependencies beyond Dockerode). The agent replaces direct Docker API calls from the worker; the main app communicates with agents rather than Docker hosts directly.

Every agent SSE route (`stats`, `logs`, `containers-events`, `zfs`) builds its `Response` through `agent/src/lib/sse-stream.ts`, which owns the headers, initial flush, 5s comment heartbeat, frame grammar, and abort/enqueue-failure teardown. It is a deliberate copy of the web app's `src/lib/sse/create-sse-stream.ts`: the agent is not a workspace member and cannot import web code, so the seam exists once on each side of the split. Routes that own a subprocess or subscription return a cleanup from `onStart` (run exactly once at teardown) and must register it before any long-running loop, otherwise a teardown mid-loop never reaches it.

The agent is intentionally NOT a workspace member of the homelab-manager `package.json`: its `agent/bun.lock` is the only lockfile the docker build (`context: ./agent`) sees, and workspace membership would mask drift by routing local `bun install` to the homelab-manager lockfile. Web/worker import only types from the agent via the TS path alias `@homelab-manager/agent/*` (resolved at compile time, no runtime dependency). Run `bun run setup` for a full install.

### Authentication (`src/lib/auth/`)
Expand Down
154 changes: 61 additions & 93 deletions agent/src/__tests__/containers-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events';
import { handleContainerEvents } from '../routes/containers-events';
import { _resetBroadcasterForTesting } from '../lib/docker-events-broadcaster';
import { zInventorySnapshotContainer } from '../types/protocol';
import { readUntil, parseDataFrames } from '../lib/test/sse-test-utils';
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const originalConsoleError = console.error;

Expand All @@ -18,37 +19,23 @@ beforeEach(() => {
_resetBroadcasterForTesting();
});

/** Read chunks from the stream until predicate is satisfied or timeout. */
async function readUntil(
response: Response,
predicate: (accumulated: string) => boolean,
timeoutMs = 3000,
): Promise<string> {
let text = '';
const reader = response.body!.getReader();
const decoder = new TextDecoder();
try {
while (true) {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(
() => reject(new Error(`readUntil timed out after ${timeoutMs}ms`)),
timeoutMs,
);
});
try {
const { done, value } = await Promise.race([reader.read(), timeoutPromise]);
if (done) break;
text += decoder.decode(value, { stream: true });
if (predicate(text)) break;
} finally {
if (timeoutId !== undefined) clearTimeout(timeoutId);
}
}
} finally {
reader.cancel();
}
return text;
function countDataFrames(text: string): number {
return text.split('\n\n').filter((frame) => frame.startsWith('data: ')).length;
}

/**
* A docker events stream whose teardown is awaitable. The broadcaster destroys
* it once its last subscriber unsubscribes, which is how a test observes that a
* disconnected client released its subscription.
*/
function makeDestroyableEventsStream(): { stream: EventEmitter; destroyed: Promise<void> } {
const stream = new EventEmitter();
let markDestroyed = () => {};
const destroyed = new Promise<void>((resolve) => {
markDestroyed = resolve;
});
Object.assign(stream, { destroy: mock(() => markDestroyed()) });
return { stream, destroyed };
}

function makeContainer(id: string, name: string, state = 'running', image = 'nginx:latest') {
Expand Down Expand Up @@ -118,7 +105,7 @@ describe('handleContainerEvents: init snapshot', () => {
const text = await readUntil(response, (s) => s.includes('"op":"init"'));
ac.abort();

const event = JSON.parse(text.split('\n\n').find(Boolean)!.replace(/^data: /, ''));
const event = parseDataFrames(text)[0];
expect(event.op).toBe('init');
expect(event.containers).toHaveLength(2);
expect(event.containers.map((c: { id: string }) => c.id).sort()).toEqual(['c1', 'c2'].sort((a, b) => a.localeCompare(b)));
Expand All @@ -138,7 +125,7 @@ describe('handleContainerEvents: init snapshot', () => {
const text = await readUntil(response, (s) => s.includes('"op":"init"'));
ac.abort();

const event = JSON.parse(text.split('\n\n').find(Boolean)!.replace(/^data: /, ''));
const event = parseDataFrames(text)[0];
expect(event.containers).toHaveLength(2);
});

Expand All @@ -153,7 +140,7 @@ describe('handleContainerEvents: init snapshot', () => {
const text = await readUntil(response, (s) => s.includes('"op":"init"'));
ac.abort();

const event = JSON.parse(text.split('\n\n').find(Boolean)!.replace(/^data: /, ''));
const event = parseDataFrames(text)[0];
const c = event.containers[0];
expect(c.id).toBe('abc123');
expect(c.name).toBe('my-app');
Expand All @@ -176,7 +163,7 @@ describe('handleContainerEvents: init snapshot', () => {
const text = await readUntil(response, (s) => s.includes('"op":"init"'));
ac.abort();

const event = JSON.parse(text.split('\n\n').find(Boolean)!.replace(/^data: /, ''));
const event = parseDataFrames(text)[0];
expect(event.containers[0].name).toBe('test-app');
});

Expand All @@ -191,7 +178,7 @@ describe('handleContainerEvents: init snapshot', () => {
const text = await readUntil(response, (s) => s.includes('"op":"init"'));
ac.abort();

const event = JSON.parse(text.split('\n\n').find(Boolean)!.replace(/^data: /, ''));
const event = parseDataFrames(text)[0];
expect(event.containers[0].state).toBe('unknown');
});

Expand All @@ -205,7 +192,7 @@ describe('handleContainerEvents: init snapshot', () => {
const text = await readUntil(response, (s) => s.includes('"op":"init"'));
ac.abort();

const event = JSON.parse(text.split('\n\n').find(Boolean)!.replace(/^data: /, ''));
const event = parseDataFrames(text)[0];
expect(event.containers).toHaveLength(0);
});
});
Expand All @@ -231,12 +218,11 @@ describe('handleContainerEvents: start event produces upsert', () => {
}) + '\n'));

const text = await readUntil(response, (s) => {
const events = s.split('\n\n').filter(Boolean);
return events.length >= 2;
return countDataFrames(s) >= 2;
});
ac.abort();

const events = text.split('\n\n').filter(Boolean).map((line) => JSON.parse(line.replace(/^data: /, '')));
const events = parseDataFrames(text);
const upsert = events.find((e) => e.op === 'upsert');
expect(upsert).toBeDefined();
expect(upsert.container.id).toBe('c1');
Expand Down Expand Up @@ -275,12 +261,11 @@ describe('handleContainerEvents: die event produces upsert with exited state', (
}) + '\n'));

const text = await readUntil(response, (s) => {
const events = s.split('\n\n').filter(Boolean);
return events.length >= 2;
return countDataFrames(s) >= 2;
});
ac.abort();

const events = text.split('\n\n').filter(Boolean).map((line) => JSON.parse(line.replace(/^data: /, '')));
const events = parseDataFrames(text);
const upsert = events.find((e) => e.op === 'upsert');
expect(upsert).toBeDefined();
expect(upsert.container.id).toBe('c1');
Expand All @@ -307,12 +292,11 @@ describe('handleContainerEvents: destroy event', () => {
}) + '\n'));

const text = await readUntil(response, (s) => {
const events = s.split('\n\n').filter(Boolean);
return events.length >= 2;
return countDataFrames(s) >= 2;
});
ac.abort();

const events = text.split('\n\n').filter(Boolean).map((line) => JSON.parse(line.replace(/^data: /, '')));
const events = parseDataFrames(text);
const destroy = events.find((e) => e.op === 'destroy');
expect(destroy).toBeDefined();
expect(destroy.containerId).toBe('c1');
Expand Down Expand Up @@ -420,27 +404,32 @@ describe('handleContainerEvents: request abort cleanup', () => {
expect(done).toBe(true);
});

test('already-aborted request does not register a broadcaster subscriber', async () => {
const docker = makeDocker([]);
test('already-aborted request unsubscribes the broadcaster once subscribe resolves', async () => {
const { stream, destroyed } = makeDestroyableEventsStream();
const docker = makeDocker([], stream);
const ac = new AbortController();
ac.abort();
const request = new Request('http://localhost/containers/events', { signal: ac.signal });
const response = await handleContainerEvents(docker as any, request);

const reader = response.body!.getReader();
const result = await reader.read();
expect(result.done).toBe(true);
expect(docker.getEvents).not.toHaveBeenCalled();
let done = false;
while (!done) {
done = (await reader.read()).done;
}

await destroyed;
});

test('abort during broadcasterSubscribe tears down the late-arriving subscriber', async () => {
// Slow listContainers so subscribe()'s await is in-flight when we abort.
const listHolder: { resolve: ((v: unknown[]) => void) | null } = { resolve: null };
const { stream, destroyed } = makeDestroyableEventsStream();
const docker = {
listContainers: mock(() => new Promise<unknown[]>((resolve) => {
listHolder.resolve = resolve;
})),
getEvents: mock(() => Promise.resolve(new EventEmitter())),
getEvents: mock(() => Promise.resolve(stream)),
getContainer: mock(() => ({
inspect: mock(() => Promise.reject(Object.assign(new Error('n/a'), { statusCode: 404 }))),
})),
Expand All @@ -455,8 +444,11 @@ describe('handleContainerEvents: request abort cleanup', () => {
ac.abort();
listHolder.resolve?.([]);

const result = await reader.read();
expect(result.done).toBe(true);
let done = false;
while (!done) {
done = (await reader.read()).done;
}
await destroyed;
});
});

Expand Down Expand Up @@ -512,7 +504,7 @@ describe('handleContainerEvents: ports and mounts pass-through', () => {
const text = await readUntil(response, (s) => s.includes('"op":"init"'));
ac.abort();

const event = JSON.parse(text.split('\n\n').find(Boolean)!.replace(/^data: /, ''));
const event = parseDataFrames(text)[0];
const c = event.containers[0];
expect(c.ports).toEqual([{ containerPort: 80, protocol: 'tcp', hostIp: '0.0.0.0', hostPort: 8080 }]);
expect(c.mounts).toEqual([{ type: 'bind', source: '/host', destination: '/data', rw: true }]);
Expand All @@ -529,7 +521,7 @@ describe('handleContainerEvents: ports and mounts pass-through', () => {
const text = await readUntil(response, (s) => s.includes('"op":"init"'));
ac.abort();

const event = JSON.parse(text.split('\n\n').find(Boolean)!.replace(/^data: /, ''));
const event = parseDataFrames(text)[0];
const c = event.containers[0];
expect(c.ports).toEqual([]);
expect(c.mounts).toEqual([]);
Expand Down Expand Up @@ -569,12 +561,11 @@ describe('handleContainerEvents: ports and mounts pass-through', () => {
}) + '\n'));

const text = await readUntil(response, (s) => {
const events = s.split('\n\n').filter(Boolean);
return events.length >= 2;
return countDataFrames(s) >= 2;
});
ac.abort();

const events = text.split('\n\n').filter(Boolean).map((line) => JSON.parse(line.replace(/^data: /, '')));
const events = parseDataFrames(text);
const upsert = events.find((e) => e.op === 'upsert');
expect(upsert.container.ports).toEqual([{ containerPort: 53, protocol: 'udp', hostIp: '::', hostPort: 5353 }]);
expect(upsert.container.mounts).toEqual([{ type: 'volume', source: 'vol1', destination: '/var/data', rw: true }]);
Expand Down Expand Up @@ -612,55 +603,32 @@ describe('zInventorySnapshotContainer: ports/mounts schema round-trip', () => {

describe('handleContainerEvents: idle heartbeat', () => {
const realSetInterval = globalThis.setInterval;
const realClearInterval = globalThis.clearInterval;

afterEach(() => {
globalThis.setInterval = realSetInterval;
globalThis.clearInterval = realClearInterval;
});

test('enqueues a comment heartbeat to keep the idle socket alive', async () => {
// A quiet host emits no container events, so without this the socket sits
// silent past Bun's 10s HTTP idleTimeout and the worker reconnects in a loop.
let captured: (() => void) | null = null;
globalThis.setInterval = mock((cb: () => void) => {
captured = cb;
return 1 as unknown as ReturnType<typeof setInterval>;
test('a quiet host still gets a comment heartbeat on the 5s cadence', async () => {
// Bun's HTTP idleTimeout defaults to 10s, so a host with no container
// activity would otherwise go silent long enough to drop the socket.
const ticks: Array<{ cb: () => void; ms: number }> = [];
globalThis.setInterval = mock((cb: () => void, ms: number) => {
ticks.push({ cb, ms });
return ticks.length as unknown as ReturnType<typeof setInterval>;
}) as unknown as typeof setInterval;

const docker = makeDocker([]);
const ac = new AbortController();
const request = new Request('http://localhost/containers/events', { signal: ac.signal });
const response = await handleContainerEvents(docker as any, request);

// Let start() finish broadcaster setup and register the interval.
await new Promise((r) => setTimeout(r, 50));
expect(captured).not.toBeNull();
expect(ticks).toHaveLength(1);
expect(ticks[0].ms).toBe(5000);

captured!();
const text = await readUntil(response, (s) => s.includes(':\n\n'));
ticks[0].cb();
const text = await readUntil(response, (s) => s.split('\n\n').includes(':'));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
ac.abort();

// Init frame plus a bare ':' comment heartbeat.
expect(text.split('\n\n')).toContain(':');
});

test('clears the heartbeat interval when the request aborts', async () => {
const fakeId = Symbol('hb') as unknown as ReturnType<typeof setInterval>;
globalThis.setInterval = mock(() => fakeId) as unknown as typeof setInterval;
const clearSpy = mock(() => {});
globalThis.clearInterval = clearSpy as unknown as typeof clearInterval;

const docker = makeDocker([]);
const ac = new AbortController();
const request = new Request('http://localhost/containers/events', { signal: ac.signal });
const response = await handleContainerEvents(docker as any, request);

await new Promise((r) => setTimeout(r, 50)); // let the interval register
ac.abort();
await new Promise((r) => setTimeout(r, 10));

expect(clearSpy).toHaveBeenCalledWith(fakeId);
void response;
});
});
5 changes: 4 additions & 1 deletion agent/src/__tests__/logs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,9 +381,11 @@ describe('handleLogStream', () => {
const originalSetInterval = globalThis.setInterval;
const originalClearInterval = globalThis.clearInterval;
let heartbeatCb: (() => void) | null = null;
const heartbeatDelays: number[] = [];
const fakeTimerId = 999;
globalThis.setInterval = ((cb: () => void) => {
globalThis.setInterval = ((cb: () => void, ms: number) => {
heartbeatCb = cb;
heartbeatDelays.push(ms);
return fakeTimerId as unknown as ReturnType<typeof setInterval>;
}) as typeof setInterval;
globalThis.clearInterval = mock(() => {}) as typeof clearInterval;
Expand All @@ -399,6 +401,7 @@ describe('handleLogStream', () => {

// Fire the heartbeat callback
expect(heartbeatCb).not.toBeNull();
expect(heartbeatDelays).toEqual([5000]);
heartbeatCb!();

liveEmitter.emit('end');
Expand Down
Loading