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:
36
bastion/src/labd/package.json
Normal file
36
bastion/src/labd/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
145
bastion/src/labd/prisma/schema.prisma
Normal file
145
bastion/src/labd/prisma/schema.prisma
Normal 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
|
||||
}
|
||||
19
bastion/src/labd/src/config.ts
Normal file
19
bastion/src/labd/src/config.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
91
bastion/src/labd/src/main.ts
Normal file
91
bastion/src/labd/src/main.ts
Normal 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);
|
||||
});
|
||||
33
bastion/src/labd/src/middleware/auth.ts
Normal file
33
bastion/src/labd/src/middleware/auth.ts
Normal 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)}...`);
|
||||
}
|
||||
};
|
||||
}
|
||||
163
bastion/src/labd/src/routes/auth.ts
Normal file
163
bastion/src/labd/src/routes/auth.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
28
bastion/src/labd/src/routes/health.ts
Normal file
28
bastion/src/labd/src/routes/health.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
64
bastion/src/labd/src/routes/servers.ts
Normal file
64
bastion/src/labd/src/routes/servers.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
63
bastion/src/labd/src/server.ts
Normal file
63
bastion/src/labd/src/server.ts
Normal 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 };
|
||||
}
|
||||
17
bastion/src/labd/src/services/logger.ts
Normal file
17
bastion/src/labd/src/services/logger.ts
Normal 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()],
|
||||
});
|
||||
65
bastion/src/labd/tests/health.test.ts
Normal file
65
bastion/src/labd/tests/health.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
12
bastion/src/labd/tsconfig.json
Normal file
12
bastion/src/labd/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../shared" }
|
||||
]
|
||||
}
|
||||
8
bastion/src/labd/vitest.config.ts
Normal file
8
bastion/src/labd/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineProject } from 'vitest/config';
|
||||
|
||||
export default defineProject({
|
||||
test: {
|
||||
name: 'labd',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user