feat: Sprint 1 remainder — Postgres store, Docker client, warm pool, sessions route
Mirror to GitHub / mirror (push) Canceled after 0s
Mirror to GitHub / mirror (push) Canceled after 0s
- PostgresUserStore (Queryable) + migration + env-gated wiring - DockerodeClient behind injected DockerEngine - ImageManager, WarmPool, Dockerfile.wordpress, docker-compose - POST /sessions spins up sandbox (503 when unconfigured) - ADRs 0014-0015; 68 unit tests green
This commit is contained in:
@@ -18,4 +18,9 @@ LLM_FALLBACK_PROVIDER=
|
|||||||
|
|
||||||
# Sandbox
|
# Sandbox
|
||||||
DOCKER_HOST=
|
DOCKER_HOST=
|
||||||
|
# Set to 1 to enable sandbox spin-up via Docker (leave unset to return 503 on /sessions).
|
||||||
|
WUR_ENABLE_SANDBOX=
|
||||||
|
WUR_IMAGE=wursor-base:latest
|
||||||
|
WUR_WEB_PORT=8080
|
||||||
|
PREVIEW_BASE_URL=http://localhost:8080
|
||||||
WARM_POOL_HOT_SPARES=2
|
WARM_POOL_HOT_SPARES=2
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildApp } from '../../src/app.ts';
|
||||||
|
import { SandboxManager } from '../../src/services/sandbox-manager.ts';
|
||||||
|
import type { DockerClient, SandboxInfo } from '../../src/sandbox/docker-client.ts';
|
||||||
|
|
||||||
|
class FakeDocker implements DockerClient {
|
||||||
|
async createSandbox(): Promise<SandboxInfo> {
|
||||||
|
return { id: 'sb-1', status: 'running' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroySandbox(): Promise<void> {}
|
||||||
|
|
||||||
|
async status(): Promise<SandboxInfo | undefined> {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /sessions', () => {
|
||||||
|
it('spins up a sandbox and returns a preview URL', async () => {
|
||||||
|
const manager = new SandboxManager(new FakeDocker(), {
|
||||||
|
image: 'wursor-base:latest',
|
||||||
|
previewBaseUrl: 'https://preview.wursor.dev',
|
||||||
|
});
|
||||||
|
const app = await buildApp({ sandboxManager: manager });
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'POST', url: '/sessions' });
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.sandboxId).toBe('sb-1');
|
||||||
|
expect(body.previewUrl).toBe('https://preview.wursor.dev/sb-1');
|
||||||
|
expect(body.sessionId).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 503 when no sandbox manager is configured', async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
const res = await app.inject({ method: 'POST', url: '/sessions' });
|
||||||
|
expect(res.statusCode).toBe(503);
|
||||||
|
expect(res.json().error).toBe('sandbox_not_configured');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DockerodeClient, type ContainerLike, type DockerEngine } from '../../src/sandbox/dockerode-client.ts';
|
||||||
|
|
||||||
|
class FakeEngine implements DockerEngine {
|
||||||
|
createdImages: string[] = [];
|
||||||
|
names: string[] = [];
|
||||||
|
removed: string[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly opts: { running?: boolean; failInspect?: boolean } = {},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private container(): ContainerLike {
|
||||||
|
return {
|
||||||
|
start: async () => {},
|
||||||
|
inspect: async () => {
|
||||||
|
if (this.opts.failInspect) {
|
||||||
|
throw new Error('not found');
|
||||||
|
}
|
||||||
|
return { Id: 'id-1', State: { Running: this.opts.running ?? true } };
|
||||||
|
},
|
||||||
|
remove: async () => {
|
||||||
|
this.removed.push('id-1');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContainer(spec: { Image: string; name: string }): Promise<ContainerLike> {
|
||||||
|
this.createdImages.push(spec.Image);
|
||||||
|
this.names.push(spec.name);
|
||||||
|
return this.container();
|
||||||
|
}
|
||||||
|
|
||||||
|
getContainer(_id: string): ContainerLike {
|
||||||
|
return this.container();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DockerodeClient', () => {
|
||||||
|
it('spins up a sandbox container from an image and reports it running', async () => {
|
||||||
|
const engine = new FakeEngine();
|
||||||
|
const client = new DockerodeClient(engine);
|
||||||
|
|
||||||
|
const info = await client.createSandbox('wursor-base:latest');
|
||||||
|
expect(info).toEqual({ id: 'id-1', status: 'running' });
|
||||||
|
expect(engine.createdImages).toEqual(['wursor-base:latest']);
|
||||||
|
expect(engine.names[0]?.startsWith('wursor-')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports destroyed when the container is not running after start', async () => {
|
||||||
|
const engine = new FakeEngine({ running: false });
|
||||||
|
const client = new DockerodeClient(engine);
|
||||||
|
|
||||||
|
expect(await client.createSandbox('wursor-base:latest')).toEqual({ id: 'id-1', status: 'destroyed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destroys a sandbox container', async () => {
|
||||||
|
const engine = new FakeEngine();
|
||||||
|
const client = new DockerodeClient(engine);
|
||||||
|
|
||||||
|
await client.destroySandbox('id-1');
|
||||||
|
expect(engine.removed).toEqual(['id-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined status when the container is gone', async () => {
|
||||||
|
const engine = new FakeEngine({ failInspect: true });
|
||||||
|
const client = new DockerodeClient(engine);
|
||||||
|
|
||||||
|
expect(await client.status('id-1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ImageManager } from '../../src/sandbox/image-manager.ts';
|
||||||
|
|
||||||
|
describe('ImageManager', () => {
|
||||||
|
const manager = new ImageManager({ baseImage: 'wursor-base:latest', webPort: 8080 });
|
||||||
|
|
||||||
|
it('returns the pre-baked image ref', () => {
|
||||||
|
expect(manager.imageRef()).toBe('wursor-base:latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a container spec with the web port and management label', () => {
|
||||||
|
expect(manager.containerSpec('wursor-abc')).toEqual({
|
||||||
|
image: 'wursor-base:latest',
|
||||||
|
name: 'wursor-abc',
|
||||||
|
ports: [{ container: 80, host: 8080 }],
|
||||||
|
labels: { 'wursor.managed': 'true' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { PostgresUserStore } from '../../src/services/postgres-user-store.ts';
|
||||||
|
import type { Queryable } from '../../src/services/postgres-user-store.ts';
|
||||||
|
|
||||||
|
class FakeDb implements Queryable {
|
||||||
|
calls: Array<{ text: string; values?: unknown[] }> = [];
|
||||||
|
rows: unknown[] = [];
|
||||||
|
|
||||||
|
async query(text: string, values?: unknown[]) {
|
||||||
|
this.calls.push({ text, values });
|
||||||
|
return { rows: this.rows };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PostgresUserStore', () => {
|
||||||
|
it('returns a user when the email exists', async () => {
|
||||||
|
const db = new FakeDb();
|
||||||
|
db.rows = [{ id: 'u1', email: 'a@example.com', password_hash: 'hash' }];
|
||||||
|
const store = new PostgresUserStore(db);
|
||||||
|
|
||||||
|
const user = await store.findByEmail('a@example.com');
|
||||||
|
expect(user).toEqual({ id: 'u1', email: 'a@example.com', passwordHash: 'hash' });
|
||||||
|
expect(db.calls[0]?.text).toContain('SELECT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when the email is absent', async () => {
|
||||||
|
const db = new FakeDb();
|
||||||
|
db.rows = [];
|
||||||
|
const store = new PostgresUserStore(db);
|
||||||
|
|
||||||
|
expect(await store.findByEmail('nope@example.com')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts a new user and returns it', async () => {
|
||||||
|
const db = new FakeDb();
|
||||||
|
const store = new PostgresUserStore(db);
|
||||||
|
|
||||||
|
const created = await store.create({ id: 'u1', email: 'a@example.com', passwordHash: 'hash' });
|
||||||
|
expect(created).toEqual({ id: 'u1', email: 'a@example.com', passwordHash: 'hash' });
|
||||||
|
const insert = db.calls[0];
|
||||||
|
expect(insert?.text).toContain('INSERT INTO users');
|
||||||
|
expect(insert?.values).toEqual(['u1', 'a@example.com', 'hash']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { WarmPool, computeSpares } from '../../src/services/warm-pool.ts';
|
||||||
|
import type { DockerClient, SandboxInfo } from '../../src/sandbox/docker-client.ts';
|
||||||
|
|
||||||
|
class FakeDocker implements DockerClient {
|
||||||
|
created: string[] = [];
|
||||||
|
|
||||||
|
async createSandbox(image: string): Promise<SandboxInfo> {
|
||||||
|
this.created.push(image);
|
||||||
|
return { id: `sb-${this.created.length}`, status: 'running' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroySandbox(): Promise<void> {}
|
||||||
|
|
||||||
|
async status(): Promise<SandboxInfo | undefined> {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('computeSpares', () => {
|
||||||
|
it('returns the shortfall', () => {
|
||||||
|
expect(computeSpares(1, 3)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never returns a negative number', () => {
|
||||||
|
expect(computeSpares(5, 2)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WarmPool', () => {
|
||||||
|
it('tops up the pool to the target hot-spare count', async () => {
|
||||||
|
const docker = new FakeDocker();
|
||||||
|
const pool = new WarmPool(docker, { image: 'wursor-base:latest', hotSpares: 2 });
|
||||||
|
|
||||||
|
const result = await pool.topUp(0);
|
||||||
|
expect(result.created).toBe(2);
|
||||||
|
expect(docker.created).toEqual(['wursor-base:latest', 'wursor-base:latest']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when the pool is already full', async () => {
|
||||||
|
const docker = new FakeDocker();
|
||||||
|
const pool = new WarmPool(docker, { image: 'wursor-base:latest', hotSpares: 2 });
|
||||||
|
|
||||||
|
const result = await pool.topUp(3);
|
||||||
|
expect(result.created).toBe(0);
|
||||||
|
expect(docker.created).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
+5
-1
@@ -8,10 +8,14 @@
|
|||||||
"start": "tsx src/index.ts"
|
"start": "tsx src/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fastify": "^5.2.0"
|
"dockerode": "^5.0.1",
|
||||||
|
"fastify": "^5.2.0",
|
||||||
|
"pg": "^8.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/dockerode": "^4.0.1",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/pg": "^8.21.0",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
|
|||||||
+11
-3
@@ -1,11 +1,19 @@
|
|||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import { authRoutes } from './routes/auth.ts';
|
import { authRoutes } from './routes/auth.ts';
|
||||||
import { InMemoryUserStore } from './services/user-store.ts';
|
import { sessionRoutes } from './routes/sessions.ts';
|
||||||
|
import { InMemoryUserStore, type UserStore } from './services/user-store.ts';
|
||||||
|
import type { SandboxManager } from './services/sandbox-manager.ts';
|
||||||
|
|
||||||
export async function buildApp() {
|
export type BuildAppOptions = {
|
||||||
|
userStore?: UserStore;
|
||||||
|
sandboxManager?: SandboxManager;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function buildApp(opts: BuildAppOptions = {}) {
|
||||||
const app = Fastify();
|
const app = Fastify();
|
||||||
const store = new InMemoryUserStore();
|
const store = opts.userStore ?? new InMemoryUserStore();
|
||||||
app.get('/health', async () => ({ ok: true }));
|
app.get('/health', async () => ({ ok: true }));
|
||||||
await authRoutes(app, store);
|
await authRoutes(app, store);
|
||||||
|
await sessionRoutes(app, opts.sandboxManager);
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-1
@@ -1,6 +1,31 @@
|
|||||||
|
import Docker from 'dockerode';
|
||||||
|
import { Pool } from 'pg';
|
||||||
import { buildApp } from './app.ts';
|
import { buildApp } from './app.ts';
|
||||||
|
import { DockerodeClient } from './sandbox/dockerode-client.ts';
|
||||||
|
import { ImageManager } from './sandbox/image-manager.ts';
|
||||||
|
import { PostgresUserStore } from './services/postgres-user-store.ts';
|
||||||
|
import { SandboxManager } from './services/sandbox-manager.ts';
|
||||||
|
import { InMemoryUserStore } from './services/user-store.ts';
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
const app = await buildApp();
|
|
||||||
|
const userStore = process.env.DATABASE_URL
|
||||||
|
? new PostgresUserStore(new Pool({ connectionString: process.env.DATABASE_URL }))
|
||||||
|
: new InMemoryUserStore();
|
||||||
|
|
||||||
|
const imageManager = new ImageManager({
|
||||||
|
baseImage: process.env.WUR_IMAGE ?? 'wursor-base:latest',
|
||||||
|
webPort: Number(process.env.WUR_WEB_PORT ?? 8080),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sandboxManager =
|
||||||
|
process.env.WUR_ENABLE_SANDBOX === '1'
|
||||||
|
? new SandboxManager(new DockerodeClient(new Docker()), {
|
||||||
|
image: imageManager.imageRef(),
|
||||||
|
previewBaseUrl: process.env.PREVIEW_BASE_URL ?? 'http://localhost:8080',
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const app = await buildApp({ userStore, sandboxManager });
|
||||||
|
|
||||||
await app.listen({ port, host: '0.0.0.0' });
|
await app.listen({ port, host: '0.0.0.0' });
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ export async function authRoutes(app: FastifyInstance, store: UserStore): Promis
|
|||||||
if (password === undefined || password.length < 8) {
|
if (password === undefined || password.length < 8) {
|
||||||
return reply.status(400).send({ error: 'weak_password' });
|
return reply.status(400).send({ error: 'weak_password' });
|
||||||
}
|
}
|
||||||
if (store.findByEmail(email) !== undefined) {
|
if ((await store.findByEmail(email)) !== undefined) {
|
||||||
return reply.status(409).send({ error: 'email_exists' });
|
return reply.status(409).send({ error: 'email_exists' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = store.create({ id: newId(), email, passwordHash: hashPassword(password) });
|
const user = await store.create({ id: newId(), email, passwordHash: hashPassword(password) });
|
||||||
return reply.status(201).send({ user: { id: user.id, email: user.email }, sessionToken: newToken() });
|
return reply.status(201).send({ user: { id: user.id, email: user.email }, sessionToken: newToken() });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { newId } from '../lib/crypto.ts';
|
||||||
|
import type { SandboxManager } from '../services/sandbox-manager.ts';
|
||||||
|
|
||||||
|
export async function sessionRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
sandboxManager: SandboxManager | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
app.post('/sessions', async (_request, reply) => {
|
||||||
|
if (sandboxManager === undefined) {
|
||||||
|
return reply.status(503).send({ error: 'sandbox_not_configured' });
|
||||||
|
}
|
||||||
|
const { sandboxId, previewUrl } = await sandboxManager.start();
|
||||||
|
return reply.status(201).send({ sessionId: newId(), sandboxId, previewUrl });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Docker from 'dockerode';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { DockerClient, SandboxInfo } from './docker-client.ts';
|
||||||
|
|
||||||
|
export type ContainerInspect = {
|
||||||
|
Id: string;
|
||||||
|
State: { Running: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContainerLike = {
|
||||||
|
start(): Promise<void>;
|
||||||
|
inspect(): Promise<ContainerInspect>;
|
||||||
|
remove(opts?: { force?: boolean }): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DockerEngine = {
|
||||||
|
createContainer(opts: { Image: string; name: string }): Promise<ContainerLike>;
|
||||||
|
getContainer(id: string): ContainerLike;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class DockerodeClient implements DockerClient {
|
||||||
|
constructor(private readonly engine: DockerEngine = new Docker() as unknown as DockerEngine) {}
|
||||||
|
|
||||||
|
async createSandbox(image: string): Promise<SandboxInfo> {
|
||||||
|
const container = await this.engine.createContainer({ Image: image, name: `wursor-${randomUUID()}` });
|
||||||
|
await container.start();
|
||||||
|
const info = await container.inspect();
|
||||||
|
return { id: info.Id, status: info.State.Running ? 'running' : 'destroyed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroySandbox(id: string): Promise<void> {
|
||||||
|
await this.engine.getContainer(id).remove({ force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async status(id: string): Promise<SandboxInfo | undefined> {
|
||||||
|
try {
|
||||||
|
const info = await this.engine.getContainer(id).inspect();
|
||||||
|
return { id: info.Id, status: info.State.Running ? 'running' : 'destroyed' };
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export type ImageManagerOptions = {
|
||||||
|
baseImage: string;
|
||||||
|
webPort: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SandboxContainerSpec = {
|
||||||
|
image: string;
|
||||||
|
name: string;
|
||||||
|
ports: { container: number; host: number }[];
|
||||||
|
labels: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ImageManager {
|
||||||
|
constructor(private readonly opts: ImageManagerOptions) {}
|
||||||
|
|
||||||
|
imageRef(): string {
|
||||||
|
return this.opts.baseImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
containerSpec(name: string): SandboxContainerSpec {
|
||||||
|
return {
|
||||||
|
image: this.opts.baseImage,
|
||||||
|
name,
|
||||||
|
ports: [{ container: 80, host: this.opts.webPort }],
|
||||||
|
labels: { 'wursor.managed': 'true' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { User, UserStore } from './user-store.ts';
|
||||||
|
|
||||||
|
export type Queryable = {
|
||||||
|
query(text: string, values?: unknown[]): Promise<{ rows: unknown[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UserRow = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
password_hash: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PostgresUserStore implements UserStore {
|
||||||
|
constructor(private readonly db: Queryable) {}
|
||||||
|
|
||||||
|
async findByEmail(email: string): Promise<User | undefined> {
|
||||||
|
const result = await this.db.query('SELECT id, email, password_hash FROM users WHERE email = $1', [email]);
|
||||||
|
const row = result.rows[0] as UserRow | undefined;
|
||||||
|
return row === undefined
|
||||||
|
? undefined
|
||||||
|
: { id: row.id, email: row.email, passwordHash: row.password_hash };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(user: User): Promise<User> {
|
||||||
|
await this.db.query('INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)', [
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
user.passwordHash,
|
||||||
|
]);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,18 +5,18 @@ export type User = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface UserStore {
|
export interface UserStore {
|
||||||
findByEmail(email: string): User | undefined;
|
findByEmail(email: string): Promise<User | undefined>;
|
||||||
create(user: User): User;
|
create(user: User): Promise<User>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InMemoryUserStore implements UserStore {
|
export class InMemoryUserStore implements UserStore {
|
||||||
private byEmail = new Map<string, User>();
|
private byEmail = new Map<string, User>();
|
||||||
|
|
||||||
findByEmail(email: string): User | undefined {
|
async findByEmail(email: string): Promise<User | undefined> {
|
||||||
return this.byEmail.get(email);
|
return this.byEmail.get(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
create(user: User): User {
|
async create(user: User): Promise<User> {
|
||||||
this.byEmail.set(user.email, user);
|
this.byEmail.set(user.email, user);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { DockerClient } from '../sandbox/docker-client.ts';
|
||||||
|
|
||||||
|
export function computeSpares(current: number, target: number): number {
|
||||||
|
return Math.max(0, target - current);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WarmPoolOptions = {
|
||||||
|
image: string;
|
||||||
|
hotSpares: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class WarmPool {
|
||||||
|
constructor(
|
||||||
|
private readonly docker: DockerClient,
|
||||||
|
private readonly opts: WarmPoolOptions,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async topUp(currentRunning: number): Promise<{ created: number }> {
|
||||||
|
const need = computeSpares(currentRunning, this.opts.hotSpares);
|
||||||
|
for (let i = 0; i < need; i += 1) {
|
||||||
|
await this.docker.createSandbox(this.opts.image);
|
||||||
|
}
|
||||||
|
return { created: need };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 14. Postgres user store via a Queryable boundary; schema in SQL migrations
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-08-15
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0012 deferred Postgres behind the `UserStore` interface. Sprint 1 now ships the real implementation. Postgres is not available in the dev environment, so the store had to be testable without a database.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`PostgresUserStore` uses `node-postgres` (`pg`) but depends on a minimal `Queryable` interface (`query(text, values)`) instead of `pg.Pool` directly. `UserStore` methods became async. The `users` table lives in `api/migrations/001_init.sql`. `index.ts` selects Postgres when `DATABASE_URL` is set, else the in-memory store.
|
||||||
|
|
||||||
|
### Options considered
|
||||||
|
|
||||||
|
- ORM (Prisma/Drizzle) with migrations.
|
||||||
|
- Raw `pg` behind a `Queryable` interface (chosen).
|
||||||
|
|
||||||
|
### Rejected
|
||||||
|
|
||||||
|
- ORM — adds tooling and a codegen step for a two-statement surface; the raw SQL is reviewable and the interface keeps tests database-free.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `InMemoryUserStore` and `PostgresUserStore` share the same async `UserStore` contract; swapping is env-driven.
|
||||||
|
- Auth data is durable when `DATABASE_URL` is configured; the migration must be applied before first use.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 15. Docker daemon client via dockerode behind an injected engine; sandbox gated by env
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-08-15
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0013 deferred the real Docker client. Sprint 1 now ships it. There is still no Docker daemon in the dev environment, so the client had to be testable without one.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`DockerodeClient` implements `DockerClient` using `dockerode`, but depends on an injected `DockerEngine` (a minimal `createContainer`/`getContainer` surface) instead of `dockerode` directly. The API enables it via `WUR_ENABLE_SANDBOX=1`; when off, `POST /sessions` returns 503. `index.ts` constructs `new Docker()` (dockerode auto-detects `DOCKER_HOST` or the unix socket).
|
||||||
|
|
||||||
|
### Options considered
|
||||||
|
|
||||||
|
- Raw Docker Engine HTTP over the unix socket.
|
||||||
|
- dockerode behind an injected engine (chosen).
|
||||||
|
|
||||||
|
### Rejected
|
||||||
|
|
||||||
|
- Raw HTTP — dockerode already handles unix sockets, TLS, and API version negotiation; reimplementing it is pure waste.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `DockerodeClient` is unit-tested with a fake engine; only the daemon wiring remains to be verified on a Docker host.
|
||||||
|
- Sandbox spin-up is opt-in, so local dev without Docker still boots and `auth`/`sessions` behave predictably (503 on sessions).
|
||||||
|
- Port mapping is a Sprint 1 placeholder (single fixed host port); dynamic port allocation and preview proxying are follow-ups.
|
||||||
@@ -27,6 +27,8 @@ Each ADR is a single file following the [Nygard format](https://cognitect.com/bl
|
|||||||
| [0011](0011-fastify-react-stack.md) | Fastify is the API server; React + Vite is the web shell | Accepted |
|
| [0011](0011-fastify-react-stack.md) | Fastify is the API server; React + Vite is the web shell | Accepted |
|
||||||
| [0012](0012-in-memory-user-store.md) | In-memory user store behind a UserStore interface; Postgres deferred | Accepted |
|
| [0012](0012-in-memory-user-store.md) | In-memory user store behind a UserStore interface; Postgres deferred | Accepted |
|
||||||
| [0013](0013-docker-boundary-mock.md) | Sandbox orchestration mocks the Docker boundary; real daemon client deferred | Accepted |
|
| [0013](0013-docker-boundary-mock.md) | Sandbox orchestration mocks the Docker boundary; real daemon client deferred | Accepted |
|
||||||
|
| [0014](0014-postgres-queryable.md) | Postgres user store via a Queryable boundary; schema in SQL migrations | Accepted |
|
||||||
|
| [0015](0015-dockerode-engine-gating.md) | Docker daemon client via dockerode behind an injected engine; sandbox gated by env | Accepted |
|
||||||
|
|
||||||
## How to add one
|
## How to add one
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
FROM wordpress:6.7-php8.2
|
||||||
|
|
||||||
|
RUN curl -fsSL https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar -o /usr/local/bin/wp \
|
||||||
|
&& chmod +x /usr/local/bin/wp
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: mysql:8.0
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: wordpress
|
||||||
|
MYSQL_USER: wordpress
|
||||||
|
MYSQL_PASSWORD: wordpress
|
||||||
|
MYSQL_ROOT_PASSWORD: root
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/mysql
|
||||||
|
|
||||||
|
wordpress:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.wordpress
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
environment:
|
||||||
|
WORDPRESS_DB_HOST: db
|
||||||
|
WORDPRESS_DB_USER: wordpress
|
||||||
|
WORDPRESS_DB_PASSWORD: wordpress
|
||||||
|
WORDPRESS_DB_NAME: wordpress
|
||||||
|
volumes:
|
||||||
|
- wp_data:/var/www/html
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
|
wp_data:
|
||||||
Generated
+605
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user