feat(worker): per-user box status and lifecycle helpers
Centralize per-user box home-path derivation, status inspection, and
lifecycle (start/stop/restart) in a new box.ts so the HTTP routes and the
terminal WS bridge share one source of truth. Add inspectUserBoxStatus to
docker.ts (single `inspect --format` call, all-null/false on non-zero exit)
and resetBox to user-container.ts (clears the idle timer and drops the
registry entry so a stopped box isn't treated as held).
Manual verification (docker required, after Task 4 wires the routes): with
the worker running and a box up,
`curl -H "Authorization: Bearer $TOKEN" localhost:3921/box/status?user=<you>`
shows running:true + image + startedAt; `POST /box/lifecycle {"action":"stop"}`
flips status to running:false; `start` brings it back.
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { env } from './env';
|
||||||
|
import {
|
||||||
|
ensureUserContainer,
|
||||||
|
inspectUserBoxStatus,
|
||||||
|
stopWorkspaceContainer,
|
||||||
|
userContainerName,
|
||||||
|
} from './runtime/docker';
|
||||||
|
import { resetBox } from './user-container';
|
||||||
|
|
||||||
|
// Per-user box home layout, shared by the HTTP routes and the terminal WS bridge
|
||||||
|
// so path derivation lives in exactly one place (must match worker.ts).
|
||||||
|
export const boxHomePaths = (username: string) => ({
|
||||||
|
homeDir: path.resolve(env.workdir, 'homes', username),
|
||||||
|
containerHome: path.posix.join('/home', username),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BoxStatus = {
|
||||||
|
running: boolean;
|
||||||
|
image: string | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
memoryLimitBytes: number | null;
|
||||||
|
containerName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBoxStatus = async (username: string): Promise<BoxStatus> => {
|
||||||
|
const status = await inspectUserBoxStatus(username);
|
||||||
|
return { ...status, containerName: userContainerName(username) };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const startBox = async (username: string): Promise<void> => {
|
||||||
|
const { homeDir, containerHome } = boxHomePaths(username);
|
||||||
|
await ensureUserContainer({ username, workdir: homeDir, containerHome });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stopBox = async (username: string): Promise<void> => {
|
||||||
|
await stopWorkspaceContainer(userContainerName(username));
|
||||||
|
resetBox(username);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const restartBox = async (username: string): Promise<void> => {
|
||||||
|
await stopBox(username);
|
||||||
|
await startBox(username);
|
||||||
|
};
|
||||||
@@ -214,6 +214,42 @@ export const ensureUserContainer = async (args: {
|
|||||||
return name;
|
return name;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Inspect the per-user box in one shot. Non-zero exit (no such container) yields
|
||||||
|
// an all-null/false status. `--memory 0` means unlimited, which the Go template
|
||||||
|
// prints as `0`; we normalize that (and any unparseable value) to null.
|
||||||
|
export const inspectUserBoxStatus = async (
|
||||||
|
username: string,
|
||||||
|
): Promise<{
|
||||||
|
running: boolean;
|
||||||
|
image: string | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
memoryLimitBytes: number | null;
|
||||||
|
}> => {
|
||||||
|
const result = await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
[
|
||||||
|
'inspect',
|
||||||
|
'--format',
|
||||||
|
'{{.State.Running}}|{{.Config.Image}}|{{.State.StartedAt}}|{{.HostConfig.Memory}}',
|
||||||
|
userContainerName(username),
|
||||||
|
],
|
||||||
|
{ reject: false, stdin: 'ignore' },
|
||||||
|
);
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return { running: false, image: null, startedAt: null, memoryLimitBytes: null };
|
||||||
|
}
|
||||||
|
const [runningField, imageField, startedAtField, memoryField] = result.stdout
|
||||||
|
.trim()
|
||||||
|
.split('|');
|
||||||
|
const running = runningField === 'true';
|
||||||
|
return {
|
||||||
|
running,
|
||||||
|
image: imageField || null,
|
||||||
|
startedAt: running ? startedAtField || null : null,
|
||||||
|
memoryLimitBytes: Number(memoryField) || null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||||
|
|
||||||
// The marker is embedded literally into a `# comment` line and into a `pgrep`
|
// The marker is embedded literally into a `# comment` line and into a `pgrep`
|
||||||
|
|||||||
@@ -117,6 +117,15 @@ export const reconcileExistingBoxes = async (): Promise<void> => {
|
|||||||
|
|
||||||
export const runningBoxUsernames = (): Set<string> => new Set(boxes.keys());
|
export const runningBoxUsernames = (): Set<string> => new Set(boxes.keys());
|
||||||
|
|
||||||
|
// Drop a box from the in-memory registry (used after an explicit stop so a
|
||||||
|
// stopped box isn't treated as "held" by the idle reaper or reconcile logic).
|
||||||
|
export const resetBox = (username: string): void => {
|
||||||
|
const box = boxes.get(username);
|
||||||
|
if (!box) return;
|
||||||
|
if (box.idleTimer) clearTimeout(box.idleTimer);
|
||||||
|
boxes.delete(username);
|
||||||
|
};
|
||||||
|
|
||||||
export const _resetBoxRegistryForTests = () => {
|
export const _resetBoxRegistryForTests = () => {
|
||||||
for (const box of boxes.values()) {
|
for (const box of boxes.values()) {
|
||||||
if (box.idleTimer) clearTimeout(box.idleTimer);
|
if (box.idleTimer) clearTimeout(box.idleTimer);
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
|
||||||
|
process.env.GITHUB_APP_ID = '123';
|
||||||
|
process.env.GITHUB_APP_PRIVATE_KEY =
|
||||||
|
'-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----';
|
||||||
|
process.env.SPOON_AGENT_WORKDIR = '/work';
|
||||||
|
return await import('../../src/box');
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('boxHomePaths', () => {
|
||||||
|
afterEach(() => vi.resetModules());
|
||||||
|
|
||||||
|
test('derives the per-user home dir and container home', async () => {
|
||||||
|
const { boxHomePaths } = await load();
|
||||||
|
const paths = boxHomePaths('gib');
|
||||||
|
expect(paths.homeDir.endsWith('homes/gib')).toBe(true);
|
||||||
|
expect(paths.containerHome).toBe('/home/gib');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('distinct usernames produce distinct home dirs', async () => {
|
||||||
|
const { boxHomePaths } = await load();
|
||||||
|
expect(boxHomePaths('gib').homeDir).not.toBe(
|
||||||
|
boxHomePaths('other').homeDir,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user