netbird-gitops/src/server.ts
2026-03-06 13:21:08 +02:00

286 lines
7.7 KiB
TypeScript

import type { Config } from "./config.ts";
import type { NetbirdClient } from "./netbird/client.ts";
import type { GiteaClient } from "./gitea/client.ts";
import { DesiredStateSchema, validateCrossReferences } from "./state/schema.ts";
import { fetchActualState } from "./state/actual.ts";
import { computeDiff } from "./reconcile/diff.ts";
import { executeOperations } from "./reconcile/executor.ts";
import type { OperationResult } from "./reconcile/operations.ts";
import { type PollerContext, pollOnce } from "./poller/loop.ts";
import { exportState } from "./export.ts";
export interface ServerContext {
config: Config;
netbird: NetbirdClient;
gitea: GiteaClient | null;
reconcileInProgress: { value: boolean };
}
export function createHandler(
ctx: ServerContext,
): (req: Request) => Promise<Response> {
return async (req: Request): Promise<Response> => {
const url = new URL(req.url);
// Health check — no auth required
if (url.pathname === "/health" && req.method === "GET") {
return Response.json({ status: "ok" });
}
// All other endpoints require bearer token auth
const authHeader = req.headers.get("Authorization");
if (authHeader !== `Bearer ${ctx.config.reconcilerToken}`) {
return Response.json({ error: "unauthorized" }, { status: 401 });
}
if (url.pathname === "/reconcile" && req.method === "POST") {
return handleReconcile(req, url, ctx);
}
if (url.pathname === "/sync-events" && req.method === "POST") {
return handleSyncEvents(ctx);
}
if (url.pathname === "/export" && req.method === "GET") {
return handleExport(ctx);
}
return Response.json({ error: "not found" }, { status: 404 });
};
}
// -----------------------------------------------------------------------------
// /reconcile
// -----------------------------------------------------------------------------
async function handleReconcile(
req: Request,
url: URL,
ctx: ServerContext,
): Promise<Response> {
const dryRun = url.searchParams.get("dry_run") === "true";
// Parse and validate the desired state from the request body
let body: unknown;
try {
body = await req.json();
} catch {
return Response.json(
{ status: "error", error: "invalid JSON body" },
{ status: 400 },
);
}
const parseResult = DesiredStateSchema.safeParse(body);
if (!parseResult.success) {
return Response.json(
{
status: "error",
error: "schema validation failed",
issues: parseResult.error.issues,
},
{ status: 400 },
);
}
const desired = parseResult.data;
// Cross-reference validation (e.g. group refs in policies exist)
const crossRefErrors = validateCrossReferences(desired);
if (crossRefErrors.length > 0) {
return Response.json(
{
status: "error",
error: "cross-reference validation failed",
issues: crossRefErrors,
},
{ status: 400 },
);
}
ctx.reconcileInProgress.value = true;
try {
const actual = await fetchActualState(ctx.netbird);
const ops = computeDiff(desired, actual);
if (dryRun) {
return Response.json({
status: "planned",
operations: ops.map((op) => ({
type: op.type,
name: op.name,
})),
summary: summarize(ops),
});
}
if (ops.length === 0) {
return Response.json({
status: "applied",
operations: [],
created_keys: {},
summary: { created: 0, updated: 0, deleted: 0, failed: 0 },
});
}
const { results, createdKeys } = await executeOperations(
ops,
ctx.netbird,
actual,
);
// Convert Map to plain object for JSON serialization
const createdKeysObj: Record<string, string> = {};
for (const [name, key] of createdKeys) {
createdKeysObj[name] = key;
}
return Response.json({
status: "applied",
operations: results.map((r) => ({
type: r.type,
name: r.name,
status: r.status,
})),
created_keys: createdKeysObj,
summary: summarize(results),
});
} catch (err) {
console.error(
JSON.stringify({
msg: "reconcile_error",
error: err instanceof Error ? err.message : String(err),
}),
);
return Response.json(
{
status: "error",
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
);
} finally {
ctx.reconcileInProgress.value = false;
}
}
// -----------------------------------------------------------------------------
// /sync-events
// -----------------------------------------------------------------------------
/**
* Forces a single poll cycle. Temporarily clears reconcileInProgress so
* pollOnce doesn't skip, then restores it afterward.
*/
async function handleSyncEvents(ctx: ServerContext): Promise<Response> {
const pollerCtx: PollerContext = {
config: ctx.config,
netbird: ctx.netbird,
gitea: ctx.gitea,
reconcileInProgress: { value: false },
};
try {
await pollOnce(pollerCtx);
return Response.json({ status: "synced" });
} catch (err) {
console.error(
JSON.stringify({
msg: "sync_events_error",
error: err instanceof Error ? err.message : String(err),
}),
);
return Response.json(
{
status: "error",
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
);
}
}
// -----------------------------------------------------------------------------
// /export
// -----------------------------------------------------------------------------
/**
* Fetches the current NetBird state and transforms it into the declarative
* `netbird.json` format. Useful for bootstrapping gitops from an existing
* account or inspecting what the reconciler "sees".
*/
async function handleExport(ctx: ServerContext): Promise<Response> {
try {
const actual = await fetchActualState(ctx.netbird);
const state = exportState(actual);
return Response.json({
status: "ok",
state,
meta: {
exported_at: new Date().toISOString(),
source_url: ctx.config.netbirdApiUrl,
groups_count: Object.keys(state.groups).length,
setup_keys_count: Object.keys(state.setup_keys).length,
policies_count: Object.keys(state.policies).length,
routes_count: Object.keys(state.routes).length,
dns_count: Object.keys(state.dns.nameserver_groups).length,
},
});
} catch (err) {
console.error(
JSON.stringify({
msg: "export_error",
error: err instanceof Error ? err.message : String(err),
}),
);
return Response.json(
{
status: "error",
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
);
}
}
// -----------------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------------
interface Summary {
created: number;
updated: number;
deleted: number;
failed: number;
}
/**
* Counts operations by category. Works on both raw Operation[] (for dry-run
* plans) and OperationResult[] (for executed results where failures are
* tallied separately).
*/
function summarize(ops: Array<{ type: string; status?: string }>): Summary {
let created = 0;
let updated = 0;
let deleted = 0;
let failed = 0;
for (const op of ops) {
if ((op as OperationResult).status === "failed") {
failed++;
continue;
}
if (op.type.startsWith("create_")) {
created++;
} else if (op.type.startsWith("update_") || op.type === "rename_peer") {
updated++;
} else if (op.type.startsWith("delete_")) {
deleted++;
}
}
return { created, updated, deleted, failed };
}