feat: scaffold labd — master daemon with CockroachDB + Prisma

New @lab/labd workspace package:
- Fastify HTTP server + WebSocket for agent connections
- Prisma schema (CockroachDB): Server, Agent, User, Role, Permission,
  UserRole, JoinToken, AuditLog, PulumiRun, Cluster models
- Health endpoint with DB connectivity check
- Server listing with cloud/env/status filters
- Auth routes: agent enrollment, join token management
- Placeholder mTLS auth middleware
- Dev stack: CockroachDB single-node in docker-compose
- 32 tests passing (2 new for labd health)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-03-18 00:13:16 +00:00
parent 897844fae0
commit 44f1ebb843
17 changed files with 1162 additions and 34 deletions

View File

@@ -0,0 +1,36 @@
{
"name": "@lab/labd",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/main.js",
"types": "./dist/main.d.ts",
"exports": {
".": {
"import": "./dist/main.js",
"types": "./dist/main.d.ts"
}
},
"scripts": {
"build": "tsc --build",
"clean": "rimraf dist",
"dev": "tsx src/main.ts",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:generate": "prisma generate"
},
"dependencies": {
"@lab/shared": "workspace:*",
"@prisma/client": "^6.9.0",
"fastify": "^5.3.3",
"@fastify/websocket": "^11.0.2",
"winston": "^3.17.0"
},
"devDependencies": {
"@types/node": "^22.14.1",
"prisma": "^6.9.0",
"rimraf": "^6.1.3",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,145 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "cockroachdb"
url = env("DATABASE_URL")
}
model Server {
id String @id @default(uuid())
hostname String @unique
mac String? @unique
cloud String @default("baremetal")
environment String @default("default")
role String @default("worker")
labels Json @default("{}")
ip String?
agentVersion String?
status String @default("unknown") // unknown, online, offline, provisioning
lastHeartbeat DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agent Agent?
auditLogs AuditLog[]
}
model Agent {
id String @id @default(uuid())
serverId String @unique
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
certificatePem String?
enrolledAt DateTime @default(now())
lastSeen DateTime?
@@index([serverId])
}
model User {
id String @id @default(uuid())
username String @unique
displayName String?
certFingerprint String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
roleBindings UserRole[]
auditLogs AuditLog[]
}
model Role {
id String @id @default(uuid())
name String @unique
description String?
createdAt DateTime @default(now())
permissions Permission[]
userBindings UserRole[]
}
model Permission {
id String @id @default(uuid())
roleId String
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
type String @default("allow") // allow or deny
action String // read, exec, apply, destroy, manage, admin, kubectl, *
cloud String @default("*")
environment String @default("*")
server String @default("*")
@@index([roleId])
}
model UserRole {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
roleId String
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@unique([userId, roleId])
@@index([userId])
@@index([roleId])
}
model JoinToken {
id String @id @default(uuid())
token String @unique
type String @default("one-time") // one-time or reusable
label String?
usedBy String? // server hostname that used it
usedAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
expiresAt DateTime?
}
model AuditLog {
id String @id @default(uuid())
userId String?
user User? @relation(fields: [userId], references: [id])
serverId String?
server Server? @relation(fields: [serverId], references: [id])
sessionId String?
action String // exec, kubectl, apply, login, rbac-denied, etc.
resourceType String? // server, cluster, role, app, etc.
resourceName String?
args String? // sanitized command args
result String @default("success") // success, denied, error
durationMs Int?
sourceIp String?
timestamp DateTime @default(now())
@@index([userId])
@@index([serverId])
@@index([sessionId])
@@index([timestamp])
@@index([action])
}
model PulumiRun {
id String @id @default(uuid())
userId String
stackName String
action String // up, preview, destroy
status String @default("pending") // pending, running, succeeded, failed
output String?
startedAt DateTime @default(now())
completedAt DateTime?
@@index([userId])
@@index([stackName])
}
model Cluster {
id String @id @default(uuid())
name String @unique
cloud String @default("baremetal")
environment String @default("default")
kubeconfigEnc String? // encrypted kubeconfig
labels Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@@ -0,0 +1,19 @@
// Configuration from environment variables with sensible defaults.
export interface LabdConfig {
port: number;
host: string;
databaseUrl: string;
caDir: string;
logLevel: string;
}
export function loadConfig(overrides: Partial<LabdConfig> = {}): LabdConfig {
return {
port: overrides.port ?? parseInt(process.env["LABD_PORT"] ?? "3100", 10),
host: overrides.host ?? process.env["LABD_HOST"] ?? "0.0.0.0",
databaseUrl: overrides.databaseUrl ?? process.env["DATABASE_URL"] ?? "",
caDir: overrides.caDir ?? process.env["CA_DIR"] ?? "/etc/labd/ca",
logLevel: overrides.logLevel ?? process.env["LABD_LOG_LEVEL"] ?? "info",
};
}

View File

@@ -0,0 +1,91 @@
// Entry point for the lab master daemon (labd).
// Initializes Prisma, starts Fastify with WebSocket support, registers routes.
import { loadConfig } from "./config.js";
import { createApp } from "./server.js";
import { logger } from "./services/logger.js";
async function main(): Promise<void> {
const config = loadConfig();
// Initialize Prisma client (wrapped in try/catch for when DB isn't available)
let db;
try {
const { PrismaClient } = await import("@prisma/client");
const prisma = new PrismaClient({
datasources: config.databaseUrl
? { db: { url: config.databaseUrl } }
: undefined,
});
await prisma.$connect();
logger.info("Database connected");
db = prisma;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.warn(`Database not available: ${message}`);
logger.warn("Running without database -- some features will be unavailable");
// Create a stub db client that returns errors for all operations
db = {
$queryRaw: async () => {
throw new Error("Database not connected");
},
server: {
findMany: async () => {
throw new Error("Database not connected");
},
findUnique: async () => {
throw new Error("Database not connected");
},
},
joinToken: {
findUnique: async () => {
throw new Error("Database not connected");
},
findMany: async () => {
throw new Error("Database not connected");
},
create: async () => {
throw new Error("Database not connected");
},
update: async () => {
throw new Error("Database not connected");
},
},
};
}
// Create Fastify app
const { app } = createApp(config, db);
// Start server
try {
await app.listen({ port: config.port, host: config.host });
logger.info(`labd listening on ${config.host}:${config.port}`);
} catch (err) {
logger.error(`Failed to start server: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
// Graceful shutdown
const shutdown = async (): Promise<void> => {
logger.info("Shutting down...");
await app.close();
if (db !== null && "$disconnect" in db) {
await (db as { $disconnect: () => Promise<void> }).$disconnect();
}
logger.info("Goodbye");
process.exit(0);
};
process.on("SIGINT", () => void shutdown());
process.on("SIGTERM", () => void shutdown());
// Keep process alive
await new Promise(() => {});
}
main().catch((err) => {
console.error("Failed to start labd:", err);
process.exit(1);
});

View File

@@ -0,0 +1,33 @@
// Placeholder mTLS auth middleware.
// Extracts client certificate info from the request and resolves user/agent identity.
import type { FastifyRequest, FastifyReply } from "fastify";
import { logger } from "../services/logger.js";
declare module "fastify" {
interface FastifyRequest {
clientCertFingerprint?: string;
authenticatedUser?: string;
authenticatedAgent?: string;
}
}
export function createMtlsAuthMiddleware(): (
request: FastifyRequest,
reply: FastifyReply,
) => Promise<void> {
return async function mtlsAuthMiddleware(
request: FastifyRequest,
_reply: FastifyReply,
): Promise<void> {
// TODO: Extract client certificate from TLS connection
// const cert = (request.raw.socket as TLSSocket).getPeerCertificate();
// For now, this is a no-op placeholder
const certHeader = request.headers["x-client-cert-fingerprint"];
if (typeof certHeader === "string" && certHeader.length > 0) {
request.clientCertFingerprint = certHeader;
logger.info(`mTLS: client cert fingerprint=${certHeader.slice(0, 16)}...`);
}
};
}

View File

@@ -0,0 +1,163 @@
// Authentication and token management routes.
// POST /api/auth/enroll — agent enrollment (token + CSR -> signed cert)
// POST /api/tokens — create join token
// GET /api/tokens — list tokens
// DELETE /api/tokens/:id — revoke token
import { randomBytes } from "node:crypto";
import type { FastifyInstance } from "fastify";
import type { DbClient } from "../server.js";
import { logger } from "../services/logger.js";
export function registerAuthRoutes(app: FastifyInstance, db: DbClient): void {
// Agent enrollment: validate join token, accept CSR, return signed cert
app.post<{
Body: {
token?: string;
hostname?: string;
csr?: string;
};
}>("/api/auth/enroll", async (request, reply) => {
const { token, hostname, csr } = request.body ?? {};
if (token === undefined || token === "") {
return reply.code(400).send({ error: "token is required" });
}
if (hostname === undefined || hostname === "") {
return reply.code(400).send({ error: "hostname is required" });
}
try {
// Validate token
const joinToken = await db.joinToken.findUnique({
where: { token },
}) as { id: string; type: string; usedBy: string | null; revokedAt: Date | null; expiresAt: Date | null } | null;
if (joinToken === null) {
return reply.code(401).send({ error: "Invalid join token" });
}
if (joinToken.revokedAt !== null) {
return reply.code(401).send({ error: "Token has been revoked" });
}
if (joinToken.expiresAt !== null && joinToken.expiresAt < new Date()) {
return reply.code(401).send({ error: "Token has expired" });
}
if (joinToken.type === "one-time" && joinToken.usedBy !== null) {
return reply.code(401).send({ error: "Token has already been used" });
}
// Mark token as used
await db.joinToken.update({
where: { id: joinToken.id },
data: {
usedBy: hostname,
usedAt: new Date(),
},
});
logger.info(`AGENT ENROLLED: ${hostname} (token=${joinToken.id.slice(0, 8)}...)`);
// TODO: Sign CSR with CA and return certificate
// For now, return a placeholder acknowledging enrollment
return reply.send({
status: "enrolled",
hostname,
message: "Agent enrolled successfully",
certificatePem: null, // TODO: implement CA signing
csr: csr !== undefined ? "received" : "not provided",
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return reply.code(500).send({ error: "Enrollment failed", detail: message });
}
});
// Create a new join token
app.post<{
Body: {
type?: string;
label?: string;
expiresInHours?: number;
};
}>("/api/tokens", async (request, reply) => {
const { type, label, expiresInHours } = request.body ?? {};
const tokenType = type ?? "one-time";
if (tokenType !== "one-time" && tokenType !== "reusable") {
return reply.code(400).send({ error: "type must be 'one-time' or 'reusable'" });
}
const tokenValue = randomBytes(32).toString("hex");
const expiresAt = expiresInHours !== undefined
? new Date(Date.now() + expiresInHours * 60 * 60 * 1000)
: undefined;
try {
const created = await db.joinToken.create({
data: {
token: tokenValue,
type: tokenType,
label: label ?? null,
expiresAt: expiresAt ?? null,
},
});
logger.info(`TOKEN CREATED: ${(created as { id: string }).id} type=${tokenType} label=${label ?? "(none)"}`);
return reply.code(201).send({
id: (created as { id: string }).id,
token: tokenValue,
type: tokenType,
label: label ?? null,
expiresAt: expiresAt?.toISOString() ?? null,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return reply.code(500).send({ error: "Failed to create token", detail: message });
}
});
// List tokens
app.get("/api/tokens", async (_request, reply) => {
try {
const tokens = await db.joinToken.findMany({
orderBy: { createdAt: "desc" },
select: {
id: true,
type: true,
label: true,
usedBy: true,
usedAt: true,
revokedAt: true,
createdAt: true,
expiresAt: true,
// Intentionally omit token value for security
},
});
return reply.send(tokens);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return reply.code(500).send({ error: "Failed to list tokens", detail: message });
}
});
// Revoke a token
app.delete<{
Params: { id: string };
}>("/api/tokens/:id", async (request, reply) => {
const { id } = request.params;
try {
await db.joinToken.update({
where: { id },
data: { revokedAt: new Date() },
});
logger.info(`TOKEN REVOKED: ${id}`);
return reply.send({ status: "revoked", id });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return reply.code(500).send({ error: "Failed to revoke token", detail: message });
}
});
}

View File

@@ -0,0 +1,28 @@
// Health check routes.
import type { FastifyInstance } from "fastify";
import type { DbClient } from "../server.js";
export function registerHealthRoutes(app: FastifyInstance, db: DbClient): void {
app.get("/healthz", async (_request, reply) => {
let dbOk = false;
try {
await db.$queryRaw`SELECT 1`;
dbOk = true;
} catch {
// DB not reachable
}
const status = dbOk ? "healthy" : "degraded";
const statusCode = dbOk ? 200 : 503;
return reply.code(statusCode).send({
status,
uptime: process.uptime(),
timestamp: new Date().toISOString(),
checks: {
database: dbOk ? "ok" : "error",
},
});
});
}

View File

@@ -0,0 +1,64 @@
// Server management routes.
// GET /api/servers — list servers with optional filters (cloud, environment, label)
// GET /api/servers/:id — get server details
import type { FastifyInstance } from "fastify";
import type { DbClient } from "../server.js";
export function registerServerRoutes(app: FastifyInstance, db: DbClient): void {
// List servers with optional filters
app.get<{
Querystring: {
cloud?: string;
environment?: string;
status?: string;
};
}>("/api/servers", async (request, reply) => {
const { cloud, environment, status } = request.query;
const where: Record<string, unknown> = {};
if (cloud !== undefined && cloud !== "") {
where["cloud"] = cloud;
}
if (environment !== undefined && environment !== "") {
where["environment"] = environment;
}
if (status !== undefined && status !== "") {
where["status"] = status;
}
try {
const servers = await db.server.findMany({
where: Object.keys(where).length > 0 ? where : undefined,
orderBy: { hostname: "asc" },
});
return reply.send(servers);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return reply.code(500).send({ error: "Failed to list servers", detail: message });
}
});
// Get server details by ID
app.get<{
Params: { id: string };
}>("/api/servers/:id", async (request, reply) => {
const { id } = request.params;
try {
const server = await db.server.findUnique({
where: { id },
include: { agent: true },
});
if (server === null) {
return reply.code(404).send({ error: "Server not found", id });
}
return reply.send(server);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return reply.code(500).send({ error: "Failed to get server", detail: message });
}
});
}

View File

@@ -0,0 +1,63 @@
// Fastify application setup with all routes registered.
import Fastify from "fastify";
import websocket from "@fastify/websocket";
import type { LabdConfig } from "./config.js";
import { logger } from "./services/logger.js";
import { registerHealthRoutes } from "./routes/health.js";
import { registerServerRoutes } from "./routes/servers.js";
import { registerAuthRoutes } from "./routes/auth.js";
export interface DbClient {
$queryRaw: (query: TemplateStringsArray) => Promise<unknown>;
server: {
findMany: (args?: unknown) => Promise<unknown[]>;
findUnique: (args: unknown) => Promise<unknown>;
};
joinToken: {
findUnique: (args: unknown) => Promise<unknown>;
findMany: (args?: unknown) => Promise<unknown[]>;
create: (args: unknown) => Promise<unknown>;
update: (args: unknown) => Promise<unknown>;
};
}
export function createApp(_config: LabdConfig, db: DbClient): {
app: ReturnType<typeof Fastify>;
} {
const app = Fastify({
logger: false, // We use winston instead
});
// Register WebSocket support
void app.register(websocket);
// Register route handlers
registerHealthRoutes(app, db);
registerServerRoutes(app, db);
registerAuthRoutes(app, db);
// WebSocket handler for agent connections
app.register(async (fastify) => {
fastify.get("/ws/agent", { websocket: true }, (socket, _request) => {
logger.info("Agent WebSocket connection established");
socket.on("message", (message: Buffer) => {
const data = message.toString();
logger.info(`Agent message: ${data}`);
// TODO: Handle agent heartbeat, command relay, etc.
});
socket.on("close", () => {
logger.info("Agent WebSocket connection closed");
});
});
});
// Log all requests
app.addHook("onRequest", async (request) => {
logger.info(`HTTP: ${request.ip} ${request.method} ${request.url}`);
});
return { app };
}

View File

@@ -0,0 +1,17 @@
// Winston logger instance shared across the labd application.
import winston from "winston";
export const logger = winston.createLogger({
level: process.env["LABD_LOG_LEVEL"] ?? "info",
format: winston.format.combine(
winston.format.timestamp({ format: "HH:mm:ss" }),
winston.format.printf(({ timestamp, level, message }) => {
const prefix = level === "error" ? "\x1b[31m[labd]\x1b[0m"
: level === "warn" ? "\x1b[33m[labd]\x1b[0m"
: "\x1b[36m[labd]\x1b[0m";
return `${prefix} ${timestamp as string} ${message as string}`;
}),
),
transports: [new winston.transports.Console()],
});

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi } from "vitest";
import Fastify from "fastify";
import { registerHealthRoutes } from "../src/routes/health.js";
import type { DbClient } from "../src/server.js";
function createMockDb(overrides: Partial<DbClient> = {}): DbClient {
return {
$queryRaw: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
server: {
findMany: vi.fn().mockResolvedValue([]),
findUnique: vi.fn().mockResolvedValue(null),
},
joinToken: {
findUnique: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue({ id: "test-id" }),
update: vi.fn().mockResolvedValue({}),
},
...overrides,
};
}
describe("Health endpoint", () => {
it("returns healthy when database is reachable", async () => {
const app = Fastify({ logger: false });
const db = createMockDb();
registerHealthRoutes(app, db);
const response = await app.inject({
method: "GET",
url: "/healthz",
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.status).toBe("healthy");
expect(body.checks.database).toBe("ok");
expect(body.uptime).toBeTypeOf("number");
expect(body.timestamp).toBeTypeOf("string");
await app.close();
});
it("returns degraded when database is unreachable", async () => {
const app = Fastify({ logger: false });
const db = createMockDb({
$queryRaw: vi.fn().mockRejectedValue(new Error("Connection refused")),
});
registerHealthRoutes(app, db);
const response = await app.inject({
method: "GET",
url: "/healthz",
});
expect(response.statusCode).toBe(503);
const body = JSON.parse(response.body);
expect(body.status).toBe("degraded");
expect(body.checks.database).toBe("error");
await app.close();
});
});

View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../shared" }
]
}

View File

@@ -0,0 +1,8 @@
import { defineProject } from 'vitest/config';
export default defineProject({
test: {
name: 'labd',
include: ['tests/**/*.test.ts'],
},
});