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:
+11
-3
@@ -1,11 +1,19 @@
|
||||
import Fastify from 'fastify';
|
||||
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 store = new InMemoryUserStore();
|
||||
const store = opts.userStore ?? new InMemoryUserStore();
|
||||
app.get('/health', async () => ({ ok: true }));
|
||||
await authRoutes(app, store);
|
||||
await sessionRoutes(app, opts.sandboxManager);
|
||||
return app;
|
||||
}
|
||||
|
||||
+26
-1
@@ -1,6 +1,31 @@
|
||||
import Docker from 'dockerode';
|
||||
import { Pool } from 'pg';
|
||||
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 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' });
|
||||
|
||||
@@ -20,11 +20,11 @@ export async function authRoutes(app: FastifyInstance, store: UserStore): Promis
|
||||
if (password === undefined || password.length < 8) {
|
||||
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' });
|
||||
}
|
||||
|
||||
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() });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
findByEmail(email: string): User | undefined;
|
||||
create(user: User): User;
|
||||
findByEmail(email: string): Promise<User | undefined>;
|
||||
create(user: User): Promise<User>;
|
||||
}
|
||||
|
||||
export class InMemoryUserStore implements UserStore {
|
||||
private byEmail = new Map<string, User>();
|
||||
|
||||
findByEmail(email: string): User | undefined {
|
||||
async findByEmail(email: string): Promise<User | undefined> {
|
||||
return this.byEmail.get(email);
|
||||
}
|
||||
|
||||
create(user: User): User {
|
||||
async create(user: User): Promise<User> {
|
||||
this.byEmail.set(user.email, 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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user