netbird-gitops/docs/plans/2026-03-03-netbird-reconciler-implementation.md
2026-03-03 23:45:05 +02:00

84 KiB

NetBird Reconciler Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Build a Deno-based HTTP service that reconciles NetBird VPN configuration from a declarative netbird.json state file, with event-driven peer enrollment detection and Gitea Actions CI integration.

Architecture: Three-layer design. A NetBird API client wraps all management API calls. A reconciliation engine diffs desired vs actual state and produces an ordered operation plan. An HTTP server exposes /reconcile, /sync-events, and /health endpoints. A background event poller detects peer enrollments and commits state updates via Gitea API.

Tech Stack: Deno 2.x, Zod (schema validation), Deno standard library (HTTP server), Docker


Task 0: Scaffold project structure

Files:

  • Create: deno.json
  • Create: src/main.ts
  • Create: src/config.ts
  • Create: .gitignore
  • Create: Dockerfile

Step 1: Create deno.json

{
  "name": "@blastpilot/netbird-reconciler",
  "version": "0.1.0",
  "tasks": {
    "dev": "deno run --allow-net --allow-read --allow-write --allow-env --watch src/main.ts",
    "start": "deno run --allow-net --allow-read --allow-write --allow-env src/main.ts",
    "test": "deno test --allow-net --allow-read --allow-write --allow-env",
    "check": "deno check src/main.ts",
    "lint": "deno lint",
    "fmt": "deno fmt"
  },
  "imports": {
    "zod": "npm:zod@^3.23.0"
  },
  "compilerOptions": {
    "strict": true
  }
}

Step 2: Create src/config.ts

import { z } from "zod";

const ConfigSchema = z.object({
  netbirdApiUrl: z.string().url(),
  netbirdApiToken: z.string().min(1),
  giteaUrl: z.string().url(),
  giteaToken: z.string().min(1),
  giteaRepo: z.string().regex(/^[^/]+\/[^/]+$/), // owner/repo
  reconcilerToken: z.string().min(1),
  pollIntervalSeconds: z.coerce.number().int().positive().default(30),
  port: z.coerce.number().int().positive().default(8080),
  dataDir: z.string().default("/data"),
});

export type Config = z.infer<typeof ConfigSchema>;

export function loadConfig(): Config {
  return ConfigSchema.parse({
    netbirdApiUrl: Deno.env.get("NETBIRD_API_URL"),
    netbirdApiToken: Deno.env.get("NETBIRD_API_TOKEN"),
    giteaUrl: Deno.env.get("GITEA_URL"),
    giteaToken: Deno.env.get("GITEA_TOKEN"),
    giteaRepo: Deno.env.get("GITEA_REPO"),
    reconcilerToken: Deno.env.get("RECONCILER_TOKEN"),
    pollIntervalSeconds: Deno.env.get("POLL_INTERVAL_SECONDS"),
    port: Deno.env.get("PORT"),
    dataDir: Deno.env.get("DATA_DIR"),
  });
}

Step 3: Create src/main.ts (minimal placeholder)

import { loadConfig } from "./config.ts";

const config = loadConfig();
console.log(JSON.stringify({ msg: "starting", port: config.port }));

Step 4: Create .gitignore

/data/
*.log

Step 5: Create Dockerfile

FROM denoland/deno:2.2.2 AS builder
WORKDIR /app
COPY deno.json .
COPY src/ src/
RUN deno compile --allow-net --allow-read --allow-write --allow-env --output reconciler src/main.ts

FROM gcr.io/distroless/cc-debian12
COPY --from=builder /app/reconciler /usr/local/bin/reconciler
ENTRYPOINT ["reconciler"]

Step 6: Verify project compiles

Run: deno check src/main.ts Expected: no errors

Step 7: Commit

feat: scaffold netbird-reconciler project

Task 1: NetBird API client — types and base client

Files:

  • Create: src/netbird/types.ts
  • Create: src/netbird/client.ts
  • Create: src/netbird/client.test.ts

Step 1: Define NetBird API response types in src/netbird/types.ts

These types model the NetBird Management API responses. Only the fields we need for reconciliation are included.

/** Group as returned by GET /api/groups */
export interface NbGroup {
  id: string;
  name: string;
  peers_count: number;
  peers: Array<{ id: string; name: string }>;
  issued: "api" | "jwt" | "integration";
}

/** Setup key as returned by GET /api/setup-keys */
export interface NbSetupKey {
  id: number;
  name: string;
  type: "one-off" | "reusable";
  key: string;
  expires: string;
  valid: boolean;
  revoked: boolean;
  used_times: number;
  state: "valid" | "expired" | "revoked" | "overused";
  auto_groups: string[];
  usage_limit: number;
}

/** Peer as returned by GET /api/peers */
export interface NbPeer {
  id: string;
  name: string;
  ip: string;
  connected: boolean;
  hostname: string;
  os: string;
  version: string;
  groups: Array<{ id: string; name: string }>;
  last_seen: string;
  dns_label: string;
  login_expiration_enabled: boolean;
  ssh_enabled: boolean;
  inactivity_expiration_enabled: boolean;
}

/** Policy as returned by GET /api/policies */
export interface NbPolicy {
  id: string;
  name: string;
  description: string;
  enabled: boolean;
  rules: NbPolicyRule[];
}

export interface NbPolicyRule {
  id?: string;
  name: string;
  description: string;
  enabled: boolean;
  action: "accept" | "drop";
  bidirectional: boolean;
  protocol: "tcp" | "udp" | "icmp" | "all";
  ports?: string[];
  sources: Array<string | { id: string; name: string }>;
  destinations: Array<string | { id: string; name: string }>;
}

/** Route as returned by GET /api/routes */
export interface NbRoute {
  id: string;
  description: string;
  network_id: string;
  enabled: boolean;
  peer?: string;
  peer_groups?: string[];
  network?: string;
  domains?: string[];
  metric: number;
  masquerade: boolean;
  groups: string[];
  keep_route: boolean;
}

/** DNS nameserver group as returned by GET /api/dns/nameservers */
export interface NbDnsNameserverGroup {
  id: string;
  name: string;
  description: string;
  nameservers: Array<{
    ip: string;
    ns_type: string;
    port: number;
  }>;
  enabled: boolean;
  groups: string[];
  primary: boolean;
  domains: string[];
  search_domains_enabled: boolean;
}

/** Audit event as returned by GET /api/events/audit */
export interface NbEvent {
  id: number;
  timestamp: string;
  activity: string;
  activity_code: string;
  initiator_id: string;
  initiator_name: string;
  target_id: string;
  meta: Record<string, string>;
}

Step 2: Write test for base HTTP client in src/netbird/client.test.ts

Test the client can be constructed and makes authenticated requests. Use a mock fetch pattern: inject a fetch function so tests don't hit a real API.

import { assertEquals } from "jsr:@std/assert";
import { NetbirdClient } from "./client.ts";

function mockFetch(
  responses: Map<string, { status: number; body: unknown }>,
): typeof fetch {
  return (input: string | URL | Request, init?: RequestInit) => {
    const url = typeof input === "string"
      ? input
      : input instanceof URL
        ? input.toString()
        : input.url;
    const method = init?.method ?? "GET";
    const key = `${method} ${url}`;
    const resp = responses.get(key);
    if (!resp) throw new Error(`Unmocked request: ${key}`);
    return Promise.resolve(
      new Response(JSON.stringify(resp.body), {
        status: resp.status,
        headers: { "Content-Type": "application/json" },
      }),
    );
  };
}

Deno.test("NetbirdClient.listGroups sends auth header and parses response", async () => {
  const groups = [{ id: "g1", name: "pilots", peers_count: 1, peers: [], issued: "api" }];
  const client = new NetbirdClient(
    "https://nb.example.com/api",
    "test-token",
    mockFetch(new Map([
      ["GET https://nb.example.com/api/groups", { status: 200, body: groups }],
    ])),
  );
  const result = await client.listGroups();
  assertEquals(result.length, 1);
  assertEquals(result[0].name, "pilots");
});

Deno.test("NetbirdClient throws on non-2xx response", async () => {
  const client = new NetbirdClient(
    "https://nb.example.com/api",
    "test-token",
    mockFetch(new Map([
      ["GET https://nb.example.com/api/groups", { status: 401, body: { message: "unauthorized" } }],
    ])),
  );
  try {
    await client.listGroups();
    throw new Error("Should have thrown");
  } catch (e) {
    assertEquals((e as Error).message.includes("401"), true);
  }
});

Step 3: Run tests to verify they fail

Run: deno test src/netbird/client.test.ts Expected: FAIL — NetbirdClient not found

Step 4: Implement base client in src/netbird/client.ts

import type {
  NbDnsNameserverGroup,
  NbEvent,
  NbGroup,
  NbPeer,
  NbPolicy,
  NbRoute,
  NbSetupKey,
} from "./types.ts";

export class NetbirdApiError extends Error {
  constructor(
    public readonly status: number,
    public readonly method: string,
    public readonly path: string,
    public readonly body: unknown,
  ) {
    super(`NetBird API ${method} ${path} returned ${status}`);
    this.name = "NetbirdApiError";
  }
}

type FetchFn = typeof fetch;

export class NetbirdClient {
  constructor(
    private readonly baseUrl: string,
    private readonly token: string,
    private readonly fetchFn: FetchFn = fetch,
  ) {}

  private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
    const url = `${this.baseUrl}${path}`;
    const headers: Record<string, string> = {
      "Authorization": `Token ${this.token}`,
      "Accept": "application/json",
    };
    if (body !== undefined) {
      headers["Content-Type"] = "application/json";
    }
    const resp = await this.fetchFn(url, {
      method,
      headers,
      body: body !== undefined ? JSON.stringify(body) : undefined,
    });
    if (!resp.ok) {
      const text = await resp.text().catch(() => "");
      throw new NetbirdApiError(resp.status, method, path, text);
    }
    if (resp.status === 204) return undefined as T;
    return resp.json() as Promise<T>;
  }

  // --- Groups ---
  listGroups(): Promise<NbGroup[]> {
    return this.request("GET", "/groups");
  }

  createGroup(name: string, peers: string[] = []): Promise<NbGroup> {
    return this.request("POST", "/groups", { name, peers });
  }

  updateGroup(id: string, name: string, peers: string[] = []): Promise<NbGroup> {
    return this.request("PUT", `/groups/${id}`, { name, peers });
  }

  deleteGroup(id: string): Promise<void> {
    return this.request("DELETE", `/groups/${id}`);
  }

  // --- Setup Keys ---
  listSetupKeys(): Promise<NbSetupKey[]> {
    return this.request("GET", "/setup-keys");
  }

  createSetupKey(params: {
    name: string;
    type: "one-off" | "reusable";
    expires_in: number;
    auto_groups: string[];
    usage_limit: number;
  }): Promise<NbSetupKey> {
    return this.request("POST", "/setup-keys", params);
  }

  deleteSetupKey(id: number): Promise<void> {
    return this.request("DELETE", `/setup-keys/${id}`);
  }

  // --- Peers ---
  listPeers(): Promise<NbPeer[]> {
    return this.request("GET", "/peers");
  }

  updatePeer(id: string, params: {
    name: string;
    ssh_enabled: boolean;
    login_expiration_enabled: boolean;
    inactivity_expiration_enabled: boolean;
  }): Promise<NbPeer> {
    return this.request("PUT", `/peers/${id}`, params);
  }

  deletePeer(id: string): Promise<void> {
    return this.request("DELETE", `/peers/${id}`);
  }

  // --- Policies ---
  listPolicies(): Promise<NbPolicy[]> {
    return this.request("GET", "/policies");
  }

  createPolicy(params: {
    name: string;
    description: string;
    enabled: boolean;
    rules: Array<{
      name: string;
      description: string;
      enabled: boolean;
      action: "accept" | "drop";
      bidirectional: boolean;
      protocol: string;
      ports?: string[];
      sources: string[];
      destinations: string[];
    }>;
  }): Promise<NbPolicy> {
    return this.request("POST", "/policies", params);
  }

  updatePolicy(id: string, params: {
    name: string;
    description: string;
    enabled: boolean;
    rules: Array<{
      name: string;
      description: string;
      enabled: boolean;
      action: "accept" | "drop";
      bidirectional: boolean;
      protocol: string;
      ports?: string[];
      sources: string[];
      destinations: string[];
    }>;
  }): Promise<NbPolicy> {
    return this.request("PUT", `/policies/${id}`, params);
  }

  deletePolicy(id: string): Promise<void> {
    return this.request("DELETE", `/policies/${id}`);
  }

  // --- Routes ---
  listRoutes(): Promise<NbRoute[]> {
    return this.request("GET", "/routes");
  }

  createRoute(params: {
    description: string;
    network_id: string;
    enabled: boolean;
    peer_groups?: string[];
    network?: string;
    domains?: string[];
    metric: number;
    masquerade: boolean;
    groups: string[];
    keep_route: boolean;
  }): Promise<NbRoute> {
    return this.request("POST", "/routes", params);
  }

  updateRoute(id: string, params: {
    description: string;
    network_id: string;
    enabled: boolean;
    peer_groups?: string[];
    network?: string;
    domains?: string[];
    metric: number;
    masquerade: boolean;
    groups: string[];
    keep_route: boolean;
  }): Promise<NbRoute> {
    return this.request("PUT", `/routes/${id}`, params);
  }

  deleteRoute(id: string): Promise<void> {
    return this.request("DELETE", `/routes/${id}`);
  }

  // --- DNS ---
  listDnsNameserverGroups(): Promise<NbDnsNameserverGroup[]> {
    return this.request("GET", "/dns/nameservers");
  }

  createDnsNameserverGroup(params: {
    name: string;
    description: string;
    nameservers: Array<{ ip: string; ns_type: string; port: number }>;
    enabled: boolean;
    groups: string[];
    primary: boolean;
    domains: string[];
    search_domains_enabled: boolean;
  }): Promise<NbDnsNameserverGroup> {
    return this.request("POST", "/dns/nameservers", params);
  }

  updateDnsNameserverGroup(id: string, params: {
    name: string;
    description: string;
    nameservers: Array<{ ip: string; ns_type: string; port: number }>;
    enabled: boolean;
    groups: string[];
    primary: boolean;
    domains: string[];
    search_domains_enabled: boolean;
  }): Promise<NbDnsNameserverGroup> {
    return this.request("PUT", `/dns/nameservers/${id}`, params);
  }

  deleteDnsNameserverGroup(id: string): Promise<void> {
    return this.request("DELETE", `/dns/nameservers/${id}`);
  }

  // --- Events ---
  listEvents(): Promise<NbEvent[]> {
    return this.request("GET", "/events/audit");
  }
}

Step 5: Run tests

Run: deno test src/netbird/client.test.ts Expected: PASS

Step 6: Commit

feat: add NetBird API client with types and tests

Task 2: State file schema and validation

Files:

  • Create: src/state/schema.ts
  • Create: src/state/schema.test.ts

Step 1: Define the netbird.json schema using Zod in src/state/schema.ts

import { z } from "zod";

const SetupKeySchema = z.object({
  type: z.enum(["one-off", "reusable"]),
  expires_in: z.number().int().positive(),
  usage_limit: z.number().int().nonnegative(),
  auto_groups: z.array(z.string()),
  enrolled: z.boolean(),
});

const GroupSchema = z.object({
  peers: z.array(z.string()),
});

const PolicySchema = z.object({
  description: z.string().default(""),
  enabled: z.boolean(),
  sources: z.array(z.string()),
  destinations: z.array(z.string()),
  bidirectional: z.boolean(),
  protocol: z.enum(["tcp", "udp", "icmp", "all"]).default("all"),
  action: z.enum(["accept", "drop"]).default("accept"),
  ports: z.array(z.string()).optional(),
});

const RouteSchema = z.object({
  description: z.string().default(""),
  network: z.string().optional(),
  domains: z.array(z.string()).optional(),
  peer_groups: z.array(z.string()),
  metric: z.number().int().min(1).max(9999).default(9999),
  masquerade: z.boolean().default(true),
  distribution_groups: z.array(z.string()),
  enabled: z.boolean(),
  keep_route: z.boolean().default(true),
});

const NameserverSchema = z.object({
  ip: z.string(),
  ns_type: z.string().default("udp"),
  port: z.number().int().default(53),
});

const DnsNameserverGroupSchema = z.object({
  description: z.string().default(""),
  nameservers: z.array(NameserverSchema).min(1).max(3),
  enabled: z.boolean(),
  groups: z.array(z.string()),
  primary: z.boolean(),
  domains: z.array(z.string()),
  search_domains_enabled: z.boolean().default(false),
});

export const DesiredStateSchema = z.object({
  groups: z.record(z.string(), GroupSchema),
  setup_keys: z.record(z.string(), SetupKeySchema),
  policies: z.record(z.string(), PolicySchema).default({}),
  routes: z.record(z.string(), RouteSchema).default({}),
  dns: z
    .object({
      nameserver_groups: z.record(z.string(), DnsNameserverGroupSchema).default({}),
    })
    .default({}),
});

export type DesiredState = z.infer<typeof DesiredStateSchema>;
export type SetupKeyConfig = z.infer<typeof SetupKeySchema>;
export type GroupConfig = z.infer<typeof GroupSchema>;
export type PolicyConfig = z.infer<typeof PolicySchema>;
export type RouteConfig = z.infer<typeof RouteSchema>;
export type DnsNameserverGroupConfig = z.infer<typeof DnsNameserverGroupSchema>;

/**
 * Validates cross-references within the desired state:
 * - Groups referenced in policies must exist
 * - Groups referenced in setup key auto_groups must exist
 * - Peers referenced in groups must have a corresponding setup key
 * - Groups referenced in routes must exist
 * - Groups referenced in DNS nameserver groups must exist
 *
 * Returns an array of validation error strings. Empty means valid.
 */
export function validateCrossReferences(state: DesiredState): string[] {
  const errors: string[] = [];
  const groupNames = new Set(Object.keys(state.groups));
  const keyNames = new Set(Object.keys(state.setup_keys));

  // Check peers in groups reference existing setup keys
  for (const [groupName, group] of Object.entries(state.groups)) {
    for (const peer of group.peers) {
      if (!keyNames.has(peer)) {
        errors.push(
          `Group "${groupName}" references peer "${peer}" which has no setup key`,
        );
      }
    }
  }

  // Check auto_groups in setup keys reference existing groups
  for (const [keyName, key] of Object.entries(state.setup_keys)) {
    for (const groupRef of key.auto_groups) {
      if (!groupNames.has(groupRef)) {
        errors.push(
          `Setup key "${keyName}" references auto_group "${groupRef}" which does not exist`,
        );
      }
    }
  }

  // Check groups in policies reference existing groups
  for (const [policyName, policy] of Object.entries(state.policies)) {
    for (const src of policy.sources) {
      if (!groupNames.has(src)) {
        errors.push(
          `Policy "${policyName}" source "${src}" does not exist as a group`,
        );
      }
    }
    for (const dst of policy.destinations) {
      if (!groupNames.has(dst)) {
        errors.push(
          `Policy "${policyName}" destination "${dst}" does not exist as a group`,
        );
      }
    }
  }

  // Check groups in routes
  for (const [routeName, route] of Object.entries(state.routes)) {
    for (const g of route.peer_groups) {
      if (!groupNames.has(g)) {
        errors.push(
          `Route "${routeName}" peer_group "${g}" does not exist`,
        );
      }
    }
    for (const g of route.distribution_groups) {
      if (!groupNames.has(g)) {
        errors.push(
          `Route "${routeName}" distribution_group "${g}" does not exist`,
        );
      }
    }
  }

  // Check groups in DNS
  for (const [dnsName, dns] of Object.entries(state.dns.nameserver_groups)) {
    for (const g of dns.groups) {
      if (!groupNames.has(g)) {
        errors.push(
          `DNS nameserver group "${dnsName}" group "${g}" does not exist`,
        );
      }
    }
  }

  return errors;
}

Step 2: Write tests in src/state/schema.test.ts

import { assertEquals, assertThrows } from "jsr:@std/assert";
import { DesiredStateSchema, validateCrossReferences } from "./schema.ts";

const VALID_STATE = {
  groups: {
    pilots: { peers: ["Pilot-hawk-72"] },
    "ground-stations": { peers: ["GS-hawk-72"] },
  },
  setup_keys: {
    "GS-hawk-72": {
      type: "one-off" as const,
      expires_in: 604800,
      usage_limit: 1,
      auto_groups: ["ground-stations"],
      enrolled: true,
    },
    "Pilot-hawk-72": {
      type: "one-off" as const,
      expires_in: 604800,
      usage_limit: 1,
      auto_groups: ["pilots"],
      enrolled: false,
    },
  },
  policies: {
    "pilots-to-gs": {
      description: "Allow pilots to reach ground stations",
      enabled: true,
      sources: ["pilots"],
      destinations: ["ground-stations"],
      bidirectional: true,
      protocol: "all" as const,
    },
  },
  routes: {},
  dns: { nameserver_groups: {} },
};

Deno.test("DesiredStateSchema parses valid state", () => {
  const result = DesiredStateSchema.parse(VALID_STATE);
  assertEquals(Object.keys(result.groups).length, 2);
  assertEquals(Object.keys(result.setup_keys).length, 2);
});

Deno.test("DesiredStateSchema rejects invalid setup key type", () => {
  const invalid = structuredClone(VALID_STATE);
  (invalid.setup_keys["GS-hawk-72"] as Record<string, unknown>).type = "invalid";
  assertThrows(() => DesiredStateSchema.parse(invalid));
});

Deno.test("validateCrossReferences returns empty for valid state", () => {
  const state = DesiredStateSchema.parse(VALID_STATE);
  const errors = validateCrossReferences(state);
  assertEquals(errors.length, 0);
});

Deno.test("validateCrossReferences catches missing group in policy", () => {
  const bad = structuredClone(VALID_STATE);
  bad.policies["pilots-to-gs"].sources = ["nonexistent"];
  const state = DesiredStateSchema.parse(bad);
  const errors = validateCrossReferences(state);
  assertEquals(errors.length, 1);
  assertEquals(errors[0].includes("nonexistent"), true);
});

Deno.test("validateCrossReferences catches peer without setup key", () => {
  const bad = structuredClone(VALID_STATE);
  bad.groups.pilots.peers.push("Unknown-peer");
  const state = DesiredStateSchema.parse(bad);
  const errors = validateCrossReferences(state);
  assertEquals(errors.length, 1);
  assertEquals(errors[0].includes("Unknown-peer"), true);
});

Step 3: Run tests

Run: deno test src/state/schema.test.ts Expected: PASS

Step 4: Commit

feat: add desired state schema with Zod validation and cross-reference checks

Task 3: Actual state fetcher

Files:

  • Create: src/state/actual.ts
  • Create: src/state/actual.test.ts

The actual state fetcher calls all NetBird list endpoints and normalizes the results into a structure that can be compared with the desired state. The key job is building bidirectional name<->ID mappings.

Step 1: Write test in src/state/actual.test.ts

import { assertEquals } from "jsr:@std/assert";
import { fetchActualState } from "./actual.ts";
import type { NbGroup, NbPeer, NbPolicy, NbRoute, NbSetupKey, NbDnsNameserverGroup, NbEvent } from "../netbird/types.ts";

/** Minimal mock NetBird client that returns predetermined data */
function mockClient(data: {
  groups?: NbGroup[];
  setupKeys?: NbSetupKey[];
  peers?: NbPeer[];
  policies?: NbPolicy[];
  routes?: NbRoute[];
  dns?: NbDnsNameserverGroup[];
}) {
  return {
    listGroups: () => Promise.resolve(data.groups ?? []),
    listSetupKeys: () => Promise.resolve(data.setupKeys ?? []),
    listPeers: () => Promise.resolve(data.peers ?? []),
    listPolicies: () => Promise.resolve(data.policies ?? []),
    listRoutes: () => Promise.resolve(data.routes ?? []),
    listDnsNameserverGroups: () => Promise.resolve(data.dns ?? []),
    listEvents: () => Promise.resolve([] as NbEvent[]),
  };
}

Deno.test("fetchActualState builds name-to-id maps", async () => {
  const actual = await fetchActualState(mockClient({
    groups: [
      { id: "g1", name: "pilots", peers_count: 0, peers: [], issued: "api" },
    ],
    setupKeys: [
      {
        id: 1, name: "Pilot-hawk-72", type: "one-off", key: "masked",
        expires: "2026-04-01T00:00:00Z", valid: true, revoked: false,
        used_times: 0, state: "valid", auto_groups: ["g1"], usage_limit: 1,
      },
    ],
  }));
  assertEquals(actual.groupsByName.get("pilots")?.id, "g1");
  assertEquals(actual.setupKeysByName.get("Pilot-hawk-72")?.id, 1);
});

Step 2: Run test — expect FAIL

Run: deno test src/state/actual.test.ts

Step 3: Implement src/state/actual.ts

import type { NetbirdClient } from "../netbird/client.ts";
import type {
  NbDnsNameserverGroup,
  NbGroup,
  NbPeer,
  NbPolicy,
  NbRoute,
  NbSetupKey,
} from "../netbird/types.ts";

/** Indexed view of all current NetBird state */
export interface ActualState {
  groups: NbGroup[];
  groupsByName: Map<string, NbGroup>;
  groupsById: Map<string, NbGroup>;

  setupKeys: NbSetupKey[];
  setupKeysByName: Map<string, NbSetupKey>;

  peers: NbPeer[];
  peersByName: Map<string, NbPeer>;
  peersById: Map<string, NbPeer>;

  policies: NbPolicy[];
  policiesByName: Map<string, NbPolicy>;

  routes: NbRoute[];
  routesByNetworkId: Map<string, NbRoute>;

  dns: NbDnsNameserverGroup[];
  dnsByName: Map<string, NbDnsNameserverGroup>;
}

type ClientLike = Pick<
  NetbirdClient,
  | "listGroups"
  | "listSetupKeys"
  | "listPeers"
  | "listPolicies"
  | "listRoutes"
  | "listDnsNameserverGroups"
>;

export async function fetchActualState(client: ClientLike): Promise<ActualState> {
  const [groups, setupKeys, peers, policies, routes, dns] = await Promise.all([
    client.listGroups(),
    client.listSetupKeys(),
    client.listPeers(),
    client.listPolicies(),
    client.listRoutes(),
    client.listDnsNameserverGroups(),
  ]);

  return {
    groups,
    groupsByName: new Map(groups.map((g) => [g.name, g])),
    groupsById: new Map(groups.map((g) => [g.id, g])),

    setupKeys,
    setupKeysByName: new Map(setupKeys.map((k) => [k.name, k])),

    peers,
    peersByName: new Map(peers.map((p) => [p.name, p])),
    peersById: new Map(peers.map((p) => [p.id, p])),

    policies,
    policiesByName: new Map(policies.map((p) => [p.name, p])),

    routes,
    routesByNetworkId: new Map(routes.map((r) => [r.network_id, r])),

    dns,
    dnsByName: new Map(dns.map((d) => [d.name, d])),
  };
}

Step 4: Run tests

Run: deno test src/state/actual.test.ts Expected: PASS

Step 5: Commit

feat: add actual state fetcher with name/ID indexing

Task 4: Diff engine — compute operations

Files:

  • Create: src/reconcile/diff.ts
  • Create: src/reconcile/operations.ts
  • Create: src/reconcile/diff.test.ts

This is the core reconciliation logic. It compares desired state against actual state and produces an ordered list of operations.

Step 1: Define operation types in src/reconcile/operations.ts

export type OperationType =
  | "create_group"
  | "update_group"
  | "delete_group"
  | "create_setup_key"
  | "delete_setup_key"
  | "rename_peer"
  | "update_peer_groups"
  | "delete_peer"
  | "create_policy"
  | "update_policy"
  | "delete_policy"
  | "create_route"
  | "update_route"
  | "delete_route"
  | "create_dns"
  | "update_dns"
  | "delete_dns";

export interface Operation {
  type: OperationType;
  name: string;
  details?: Record<string, unknown>;
}

export interface OperationResult extends Operation {
  status: "success" | "failed" | "skipped";
  error?: string;
}

/** Order in which operation types must be executed */
export const EXECUTION_ORDER: OperationType[] = [
  // Create groups first (policies/routes depend on them)
  "create_group",
  "update_group",
  // Setup keys (peers depend on them)
  "create_setup_key",
  // Peer operations
  "rename_peer",
  "update_peer_groups",
  // Policies (depend on groups)
  "create_policy",
  "update_policy",
  // Routes (depend on groups)
  "create_route",
  "update_route",
  // DNS (depend on groups)
  "create_dns",
  "update_dns",
  // Deletions in reverse dependency order
  "delete_dns",
  "delete_route",
  "delete_policy",
  "delete_peer",
  "delete_setup_key",
  "delete_group",
];

Step 2: Write diff tests in src/reconcile/diff.test.ts

import { assertEquals } from "jsr:@std/assert";
import { computeDiff } from "./diff.ts";
import type { DesiredState } from "../state/schema.ts";
import type { ActualState } from "../state/actual.ts";

function emptyActual(): ActualState {
  return {
    groups: [], groupsByName: new Map(), groupsById: new Map(),
    setupKeys: [], setupKeysByName: new Map(),
    peers: [], peersByName: new Map(), peersById: new Map(),
    policies: [], policiesByName: new Map(),
    routes: [], routesByNetworkId: new Map(),
    dns: [], dnsByName: new Map(),
  };
}

const DESIRED: DesiredState = {
  groups: { pilots: { peers: ["Pilot-hawk-72"] } },
  setup_keys: {
    "Pilot-hawk-72": {
      type: "one-off", expires_in: 604800, usage_limit: 1,
      auto_groups: ["pilots"], enrolled: false,
    },
  },
  policies: {},
  routes: {},
  dns: { nameserver_groups: {} },
};

Deno.test("computeDiff against empty actual produces create ops", () => {
  const ops = computeDiff(DESIRED, emptyActual());
  const types = ops.map((o) => o.type);
  assertEquals(types.includes("create_group"), true);
  assertEquals(types.includes("create_setup_key"), true);
});

Deno.test("computeDiff with matching state produces no ops", () => {
  const actual = emptyActual();
  actual.groupsByName.set("pilots", {
    id: "g1", name: "pilots", peers_count: 1,
    peers: [{ id: "p1", name: "Pilot-hawk-72" }], issued: "api",
  });
  actual.groups = [actual.groupsByName.get("pilots")!];
  actual.setupKeysByName.set("Pilot-hawk-72", {
    id: 1, name: "Pilot-hawk-72", type: "one-off", key: "masked",
    expires: "2026-04-01T00:00:00Z", valid: true, revoked: false,
    used_times: 0, state: "valid", auto_groups: ["g1"], usage_limit: 1,
  });
  actual.setupKeys = [actual.setupKeysByName.get("Pilot-hawk-72")!];
  const ops = computeDiff(DESIRED, actual);
  assertEquals(ops.length, 0);
});

Step 3: Run test — expect FAIL

Run: deno test src/reconcile/diff.test.ts

Step 4: Implement src/reconcile/diff.ts

This is a large module. The diff compares each resource type and produces operations.

import type { DesiredState } from "../state/schema.ts";
import type { ActualState } from "../state/actual.ts";
import type { Operation } from "./operations.ts";
import { EXECUTION_ORDER } from "./operations.ts";

export function computeDiff(
  desired: DesiredState,
  actual: ActualState,
): Operation[] {
  const ops: Operation[] = [];

  // --- Groups ---
  const desiredGroupNames = new Set(Object.keys(desired.groups));
  for (const [name, group] of Object.entries(desired.groups)) {
    const existing = actual.groupsByName.get(name);
    if (!existing) {
      ops.push({ type: "create_group", name, details: { peers: group.peers } });
    } else {
      // Check if peer membership changed
      const existingPeerNames = new Set(existing.peers.map((p) => p.name));
      const desiredPeerNames = new Set(group.peers);
      const same = existingPeerNames.size === desiredPeerNames.size &&
        [...desiredPeerNames].every((p) => existingPeerNames.has(p));
      if (!same) {
        ops.push({
          type: "update_group",
          name,
          details: { id: existing.id, peers: group.peers },
        });
      }
    }
  }
  // Groups in actual but not in desired — delete (only API-issued, not system groups)
  for (const group of actual.groups) {
    if (!desiredGroupNames.has(group.name) && group.issued === "api") {
      ops.push({ type: "delete_group", name: group.name, details: { id: group.id } });
    }
  }

  // --- Setup Keys ---
  const desiredKeyNames = new Set(Object.keys(desired.setup_keys));
  for (const [name, key] of Object.entries(desired.setup_keys)) {
    const existing = actual.setupKeysByName.get(name);
    if (!existing) {
      // Only create if not yet enrolled (enrolled keys already exist)
      if (!key.enrolled) {
        ops.push({
          type: "create_setup_key",
          name,
          details: {
            type: key.type,
            expires_in: key.expires_in,
            auto_groups: key.auto_groups,
            usage_limit: key.usage_limit,
          },
        });
      }
    }
    // Setup keys are immutable in NetBird — no update operation.
    // If config changed, user must delete and recreate (manual process).
  }
  for (const key of actual.setupKeys) {
    if (!desiredKeyNames.has(key.name)) {
      ops.push({ type: "delete_setup_key", name: key.name, details: { id: key.id } });
    }
  }

  // --- Peers ---
  // We don't create peers (they self-enroll). We rename and reassign groups.
  // Peer deletion is when a setup key is in desired but the peer should be removed.
  // For now, peers not in any desired group's peer list get flagged for deletion
  // only if they match a setup key that was removed from desired state.
  const allDesiredPeerNames = new Set<string>();
  for (const group of Object.values(desired.groups)) {
    for (const p of group.peers) allDesiredPeerNames.add(p);
  }

  // --- Policies ---
  const desiredPolicyNames = new Set(Object.keys(desired.policies));
  for (const [name, policy] of Object.entries(desired.policies)) {
    const existing = actual.policiesByName.get(name);
    if (!existing) {
      ops.push({
        type: "create_policy",
        name,
        details: {
          description: policy.description,
          enabled: policy.enabled,
          sources: policy.sources,
          destinations: policy.destinations,
          bidirectional: policy.bidirectional,
          protocol: policy.protocol,
          action: policy.action,
          ports: policy.ports,
        },
      });
    } else {
      // Check if policy needs update by comparing source/dest group names
      const existingSources = existing.rules.flatMap((r) =>
        r.sources.map((s) => typeof s === "string" ? s : s.name)
      );
      const existingDests = existing.rules.flatMap((r) =>
        r.destinations.map((d) => typeof d === "string" ? d : d.name)
      );
      const sourcesMatch = JSON.stringify(existingSources.sort()) ===
        JSON.stringify([...policy.sources].sort());
      const destsMatch = JSON.stringify(existingDests.sort()) ===
        JSON.stringify([...policy.destinations].sort());
      const enabledMatch = existing.enabled === policy.enabled;
      if (!sourcesMatch || !destsMatch || !enabledMatch) {
        ops.push({
          type: "update_policy",
          name,
          details: {
            id: existing.id,
            description: policy.description,
            enabled: policy.enabled,
            sources: policy.sources,
            destinations: policy.destinations,
            bidirectional: policy.bidirectional,
            protocol: policy.protocol,
            action: policy.action,
            ports: policy.ports,
          },
        });
      }
    }
  }
  for (const policy of actual.policies) {
    if (!desiredPolicyNames.has(policy.name)) {
      ops.push({ type: "delete_policy", name: policy.name, details: { id: policy.id } });
    }
  }

  // --- Routes ---
  const desiredRouteNames = new Set(Object.keys(desired.routes));
  for (const [name, route] of Object.entries(desired.routes)) {
    const existing = actual.routesByNetworkId.get(name);
    if (!existing) {
      ops.push({
        type: "create_route",
        name,
        details: {
          description: route.description,
          network: route.network,
          domains: route.domains,
          peer_groups: route.peer_groups,
          metric: route.metric,
          masquerade: route.masquerade,
          distribution_groups: route.distribution_groups,
          enabled: route.enabled,
          keep_route: route.keep_route,
        },
      });
    } else {
      // Simplified update check — compare key fields
      const needsUpdate = existing.enabled !== route.enabled ||
        existing.description !== route.description ||
        existing.network !== route.network;
      if (needsUpdate) {
        ops.push({
          type: "update_route",
          name,
          details: {
            id: existing.id,
            description: route.description,
            network: route.network,
            domains: route.domains,
            peer_groups: route.peer_groups,
            metric: route.metric,
            masquerade: route.masquerade,
            distribution_groups: route.distribution_groups,
            enabled: route.enabled,
            keep_route: route.keep_route,
          },
        });
      }
    }
  }
  for (const route of actual.routes) {
    if (!desiredRouteNames.has(route.network_id)) {
      ops.push({
        type: "delete_route",
        name: route.network_id,
        details: { id: route.id },
      });
    }
  }

  // --- DNS ---
  const desiredDnsNames = new Set(
    Object.keys(desired.dns.nameserver_groups),
  );
  for (const [name, dns] of Object.entries(desired.dns.nameserver_groups)) {
    const existing = actual.dnsByName.get(name);
    if (!existing) {
      ops.push({ type: "create_dns", name, details: { ...dns } });
    } else {
      const needsUpdate = existing.enabled !== dns.enabled ||
        existing.primary !== dns.primary ||
        JSON.stringify(existing.nameservers) !== JSON.stringify(dns.nameservers);
      if (needsUpdate) {
        ops.push({
          type: "update_dns",
          name,
          details: { id: existing.id, ...dns },
        });
      }
    }
  }
  for (const dns of actual.dns) {
    if (!desiredDnsNames.has(dns.name)) {
      ops.push({ type: "delete_dns", name: dns.name, details: { id: dns.id } });
    }
  }

  // Sort by execution order
  return ops.sort((a, b) =>
    EXECUTION_ORDER.indexOf(a.type) - EXECUTION_ORDER.indexOf(b.type)
  );
}

Step 5: Run tests

Run: deno test src/reconcile/diff.test.ts Expected: PASS

Step 6: Commit

feat: add diff engine computing operations from desired vs actual state

Task 5: Operation executor

Files:

  • Create: src/reconcile/executor.ts
  • Create: src/reconcile/executor.test.ts

The executor takes a list of operations and applies them against the NetBird API. It resolves names to IDs (since the diff uses names but the API needs IDs), executes in order, and aborts on first failure.

Step 1: Write test in src/reconcile/executor.test.ts

Test that the executor calls the right client methods and aborts on failure.

import { assertEquals } from "jsr:@std/assert";
import { executeOperations } from "./executor.ts";
import type { Operation, OperationResult } from "./operations.ts";
import type { ActualState } from "../state/actual.ts";

Deno.test("executor calls createGroup for create_group op", async () => {
  const calls: string[] = [];
  const mockClient = {
    createGroup: (name: string) => {
      calls.push(`createGroup:${name}`);
      return Promise.resolve({ id: "new-g1", name, peers_count: 0, peers: [], issued: "api" as const });
    },
  };
  const ops: Operation[] = [
    { type: "create_group", name: "pilots" },
  ];
  const results = await executeOperations(ops, mockClient as never, emptyActual());
  assertEquals(calls, ["createGroup:pilots"]);
  assertEquals(results[0].status, "success");
});

Deno.test("executor aborts on first failure", async () => {
  const mockClient = {
    createGroup: () => Promise.reject(new Error("API down")),
    createSetupKey: () => Promise.resolve({ id: 1, key: "k" }),
  };
  const ops: Operation[] = [
    { type: "create_group", name: "pilots" },
    { type: "create_setup_key", name: "key1" },
  ];
  const results = await executeOperations(ops, mockClient as never, emptyActual());
  assertEquals(results[0].status, "failed");
  assertEquals(results.length, 1); // second op never executed
});

function emptyActual(): ActualState {
  return {
    groups: [], groupsByName: new Map(), groupsById: new Map(),
    setupKeys: [], setupKeysByName: new Map(),
    peers: [], peersByName: new Map(), peersById: new Map(),
    policies: [], policiesByName: new Map(),
    routes: [], routesByNetworkId: new Map(),
    dns: [], dnsByName: new Map(),
  };
}

Step 2: Run test — expect FAIL

Step 3: Implement src/reconcile/executor.ts

The executor is a large switch/case that dispatches each operation type to the correct client method. It needs the actual state to resolve group name -> ID for policies/routes, and it tracks newly created group IDs to use in subsequent operations.

import type { NetbirdClient } from "../netbird/client.ts";
import type { ActualState } from "../state/actual.ts";
import type { Operation, OperationResult } from "./operations.ts";

/**
 * Execute operations sequentially. Aborts on first failure.
 * Returns results for all executed operations (including the failed one).
 * Also returns any newly created setup keys.
 */
export async function executeOperations(
  ops: Operation[],
  client: NetbirdClient,
  actual: ActualState,
): Promise<OperationResult[]> {
  const results: OperationResult[] = [];
  // Track name->ID for resources created during this execution
  const createdGroupIds = new Map<string, string>();
  const createdKeys = new Map<string, string>(); // name -> raw key

  /** Resolve group name to NetBird ID, checking both actual state and newly created */
  const resolveGroupId = (name: string): string | undefined => {
    return createdGroupIds.get(name) ?? actual.groupsByName.get(name)?.id;
  };

  /** Resolve multiple group names to IDs. Throws if any not found. */
  const resolveGroupIds = (names: string[]): string[] => {
    return names.map((n) => {
      const id = resolveGroupId(n);
      if (!id) throw new Error(`Cannot resolve group "${n}" to ID`);
      return id;
    });
  };

  /** Resolve peer names to IDs using actual state */
  const resolvePeerIds = (names: string[]): string[] => {
    return names.flatMap((n) => {
      const peer = actual.peersByName.get(n);
      return peer ? [peer.id] : [];
    });
  };

  for (const op of ops) {
    try {
      switch (op.type) {
        case "create_group": {
          const peerIds = resolvePeerIds(
            (op.details?.peers as string[]) ?? [],
          );
          const created = await client.createGroup(op.name, peerIds);
          createdGroupIds.set(op.name, created.id);
          break;
        }
        case "update_group": {
          const peerIds = resolvePeerIds(
            (op.details?.peers as string[]) ?? [],
          );
          await client.updateGroup(
            op.details!.id as string,
            op.name,
            peerIds,
          );
          break;
        }
        case "delete_group":
          await client.deleteGroup(op.details!.id as string);
          break;

        case "create_setup_key": {
          const d = op.details!;
          const autoGroupIds = resolveGroupIds(d.auto_groups as string[]);
          const created = await client.createSetupKey({
            name: op.name,
            type: d.type as "one-off" | "reusable",
            expires_in: d.expires_in as number,
            auto_groups: autoGroupIds,
            usage_limit: d.usage_limit as number,
          });
          createdKeys.set(op.name, created.key);
          break;
        }
        case "delete_setup_key":
          await client.deleteSetupKey(op.details!.id as number);
          break;

        case "rename_peer":
          await client.updatePeer(op.details!.id as string, {
            name: op.name,
            ssh_enabled: false,
            login_expiration_enabled: false,
            inactivity_expiration_enabled: false,
          });
          break;

        case "delete_peer":
          await client.deletePeer(op.details!.id as string);
          break;

        case "create_policy": {
          const d = op.details!;
          const sourceIds = resolveGroupIds(d.sources as string[]);
          const destIds = resolveGroupIds(d.destinations as string[]);
          await client.createPolicy({
            name: op.name,
            description: (d.description as string) ?? "",
            enabled: d.enabled as boolean,
            rules: [{
              name: op.name,
              description: (d.description as string) ?? "",
              enabled: d.enabled as boolean,
              action: (d.action as "accept" | "drop") ?? "accept",
              bidirectional: d.bidirectional as boolean,
              protocol: d.protocol as string,
              ports: d.ports as string[] | undefined,
              sources: sourceIds,
              destinations: destIds,
            }],
          });
          break;
        }
        case "update_policy": {
          const d = op.details!;
          const sourceIds = resolveGroupIds(d.sources as string[]);
          const destIds = resolveGroupIds(d.destinations as string[]);
          await client.updatePolicy(d.id as string, {
            name: op.name,
            description: (d.description as string) ?? "",
            enabled: d.enabled as boolean,
            rules: [{
              name: op.name,
              description: (d.description as string) ?? "",
              enabled: d.enabled as boolean,
              action: (d.action as "accept" | "drop") ?? "accept",
              bidirectional: d.bidirectional as boolean,
              protocol: d.protocol as string,
              ports: d.ports as string[] | undefined,
              sources: sourceIds,
              destinations: destIds,
            }],
          });
          break;
        }
        case "delete_policy":
          await client.deletePolicy(op.details!.id as string);
          break;

        case "create_route": {
          const d = op.details!;
          const peerGroupIds = resolveGroupIds(d.peer_groups as string[]);
          const distGroupIds = resolveGroupIds(d.distribution_groups as string[]);
          await client.createRoute({
            description: (d.description as string) ?? "",
            network_id: op.name,
            enabled: d.enabled as boolean,
            peer_groups: peerGroupIds,
            network: d.network as string | undefined,
            domains: d.domains as string[] | undefined,
            metric: (d.metric as number) ?? 9999,
            masquerade: (d.masquerade as boolean) ?? true,
            groups: distGroupIds,
            keep_route: (d.keep_route as boolean) ?? true,
          });
          break;
        }
        case "update_route": {
          const d = op.details!;
          const peerGroupIds = resolveGroupIds(d.peer_groups as string[]);
          const distGroupIds = resolveGroupIds(d.distribution_groups as string[]);
          await client.updateRoute(d.id as string, {
            description: (d.description as string) ?? "",
            network_id: op.name,
            enabled: d.enabled as boolean,
            peer_groups: peerGroupIds,
            network: d.network as string | undefined,
            domains: d.domains as string[] | undefined,
            metric: (d.metric as number) ?? 9999,
            masquerade: (d.masquerade as boolean) ?? true,
            groups: distGroupIds,
            keep_route: (d.keep_route as boolean) ?? true,
          });
          break;
        }
        case "delete_route":
          await client.deleteRoute(op.details!.id as string);
          break;

        case "create_dns": {
          const d = op.details!;
          const groupIds = resolveGroupIds(d.groups as string[]);
          await client.createDnsNameserverGroup({
            name: op.name,
            description: (d.description as string) ?? "",
            nameservers: d.nameservers as Array<{ ip: string; ns_type: string; port: number }>,
            enabled: d.enabled as boolean,
            groups: groupIds,
            primary: d.primary as boolean,
            domains: d.domains as string[],
            search_domains_enabled: (d.search_domains_enabled as boolean) ?? false,
          });
          break;
        }
        case "update_dns": {
          const d = op.details!;
          const groupIds = resolveGroupIds(d.groups as string[]);
          await client.updateDnsNameserverGroup(d.id as string, {
            name: op.name,
            description: (d.description as string) ?? "",
            nameservers: d.nameservers as Array<{ ip: string; ns_type: string; port: number }>,
            enabled: d.enabled as boolean,
            groups: groupIds,
            primary: d.primary as boolean,
            domains: d.domains as string[],
            search_domains_enabled: (d.search_domains_enabled as boolean) ?? false,
          });
          break;
        }
        case "delete_dns":
          await client.deleteDnsNameserverGroup(op.details!.id as string);
          break;

        default: {
          const _exhaustive: never = op.type;
          throw new Error(`Unknown operation type: ${_exhaustive}`);
        }
      }
      results.push({ ...op, status: "success" });
    } catch (err) {
      results.push({
        ...op,
        status: "failed",
        error: err instanceof Error ? err.message : String(err),
      });
      break; // Abort on first failure
    }
  }

  return results;
}

/** Extract created keys from executor results (call after execution) */
export function getCreatedKeys(
  _results: OperationResult[],
): Map<string, string> {
  // The executor tracks this internally. For external access, we need
  // to return it from executeOperations. This is a placeholder —
  // refactor executeOperations to return { results, createdKeys }.
  return new Map();
}

Note: The createdKeys map is local to executeOperations right now. Refactor the return type to include it:

export interface ExecutionResult {
  results: OperationResult[];
  createdKeys: Map<string, string>;
}

Update the function signature and return accordingly. The test should verify createdKeys is populated when a setup key is created.

Step 4: Run tests

Run: deno test src/reconcile/executor.test.ts Expected: PASS

Step 5: Commit

feat: add operation executor with abort-on-failure semantics

Task 6: Event poller

Files:

  • Create: src/poller/poller.ts
  • Create: src/poller/poller.test.ts

Step 1: Write test in src/poller/poller.test.ts

import { assertEquals } from "jsr:@std/assert";
import { processEnrollmentEvents } from "./poller.ts";
import type { NbEvent } from "../netbird/types.ts";

Deno.test("processEnrollmentEvents detects peer.setupkey.add", () => {
  const events: NbEvent[] = [
    {
      id: 1,
      timestamp: "2026-03-03T10:00:00Z",
      activity: "Peer added",
      activity_code: "peer.setupkey.add",
      initiator_id: "system",
      initiator_name: "System",
      target_id: "peer-abc",
      meta: { name: "random-hostname", setup_key: "GS-hawk-72" },
    },
    {
      id: 2,
      timestamp: "2026-03-03T10:01:00Z",
      activity: "Route created",
      activity_code: "route.add",
      initiator_id: "user-1",
      initiator_name: "John",
      target_id: "route-1",
      meta: { name: "my-route" },
    },
  ];

  const knownKeys = new Set(["GS-hawk-72", "Pilot-hawk-72"]);
  const enrollments = processEnrollmentEvents(events, knownKeys, null);

  assertEquals(enrollments.length, 1);
  assertEquals(enrollments[0].setupKeyName, "GS-hawk-72");
  assertEquals(enrollments[0].peerId, "peer-abc");
});

Deno.test("processEnrollmentEvents filters by lastTimestamp", () => {
  const events: NbEvent[] = [
    {
      id: 1,
      timestamp: "2026-03-03T09:00:00Z",
      activity: "Peer added",
      activity_code: "peer.setupkey.add",
      initiator_id: "system",
      initiator_name: "System",
      target_id: "peer-old",
      meta: { name: "old-peer", setup_key: "GS-hawk-72" },
    },
    {
      id: 2,
      timestamp: "2026-03-03T11:00:00Z",
      activity: "Peer added",
      activity_code: "peer.setupkey.add",
      initiator_id: "system",
      initiator_name: "System",
      target_id: "peer-new",
      meta: { name: "new-peer", setup_key: "Pilot-hawk-72" },
    },
  ];

  const knownKeys = new Set(["GS-hawk-72", "Pilot-hawk-72"]);
  const enrollments = processEnrollmentEvents(
    events,
    knownKeys,
    "2026-03-03T10:00:00Z",
  );

  assertEquals(enrollments.length, 1);
  assertEquals(enrollments[0].setupKeyName, "Pilot-hawk-72");
});

Deno.test("processEnrollmentEvents ignores unknown keys", () => {
  const events: NbEvent[] = [
    {
      id: 1,
      timestamp: "2026-03-03T10:00:00Z",
      activity: "Peer added",
      activity_code: "peer.setupkey.add",
      initiator_id: "system",
      initiator_name: "System",
      target_id: "peer-unknown",
      meta: { name: "mystery-peer", setup_key: "Unknown-key" },
    },
  ];

  const knownKeys = new Set(["GS-hawk-72"]);
  const enrollments = processEnrollmentEvents(events, knownKeys, null);
  assertEquals(enrollments.length, 0);
});

Step 2: Run test — expect FAIL

Step 3: Implement src/poller/poller.ts

import type { NbEvent } from "../netbird/types.ts";

export interface EnrollmentDetection {
  setupKeyName: string;
  peerId: string;
  peerHostname: string;
  timestamp: string;
}

/**
 * Filters enrollment events from the full event list.
 * Returns enrollments for peers that enrolled using a known setup key
 * and whose timestamp is after lastTimestamp (if provided).
 */
export function processEnrollmentEvents(
  events: NbEvent[],
  knownKeyNames: Set<string>,
  lastTimestamp: string | null,
): EnrollmentDetection[] {
  return events
    .filter((e) => {
      if (e.activity_code !== "peer.setupkey.add") return false;
      if (lastTimestamp && e.timestamp <= lastTimestamp) return false;
      if (!knownKeyNames.has(e.meta.setup_key)) {
        console.log(
          JSON.stringify({
            msg: "unknown_enrollment",
            setup_key: e.meta.setup_key,
            peer_id: e.target_id,
          }),
        );
        return false;
      }
      return true;
    })
    .map((e) => ({
      setupKeyName: e.meta.setup_key,
      peerId: e.target_id,
      peerHostname: e.meta.name,
      timestamp: e.timestamp,
    }));
}

Step 4: Run tests

Run: deno test src/poller/poller.test.ts Expected: PASS

Step 5: Commit

feat: add enrollment event detection from NetBird audit events

Task 7: Gitea API client (for state commits)

Files:

  • Create: src/gitea/client.ts
  • Create: src/gitea/client.test.ts

Step 1: Write test in src/gitea/client.test.ts

import { assertEquals } from "jsr:@std/assert";
import { GiteaClient } from "./client.ts";

function mockFetch(
  responses: Map<string, { status: number; body: unknown }>,
): typeof fetch {
  return (input: string | URL | Request, init?: RequestInit) => {
    const url = typeof input === "string"
      ? input
      : input instanceof URL
        ? input.toString()
        : input.url;
    const method = init?.method ?? "GET";
    const key = `${method} ${url}`;
    const resp = responses.get(key);
    if (!resp) throw new Error(`Unmocked: ${key}`);
    return Promise.resolve(
      new Response(JSON.stringify(resp.body), {
        status: resp.status,
        headers: { "Content-Type": "application/json" },
      }),
    );
  };
}

Deno.test("GiteaClient.getFileContent fetches file with SHA", async () => {
  const client = new GiteaClient(
    "https://gitea.example.com",
    "test-token",
    "BlastPilot/netbird-gitops",
    mockFetch(new Map([
      [
        "GET https://gitea.example.com/api/v1/repos/BlastPilot/netbird-gitops/contents/netbird.json?ref=main",
        {
          status: 200,
          body: {
            content: btoa('{"groups":{}}'),
            sha: "abc123",
          },
        },
      ],
    ])),
  );
  const result = await client.getFileContent("netbird.json", "main");
  assertEquals(result.sha, "abc123");
  assertEquals(result.content, '{"groups":{}}');
});

Step 2: Run test — expect FAIL

Step 3: Implement src/gitea/client.ts

type FetchFn = typeof fetch;

export class GiteaClient {
  constructor(
    private readonly baseUrl: string,
    private readonly token: string,
    private readonly repo: string, // "owner/repo"
    private readonly fetchFn: FetchFn = fetch,
  ) {}

  private async request<T>(
    method: string,
    path: string,
    body?: unknown,
  ): Promise<T> {
    const url = `${this.baseUrl}/api/v1${path}`;
    const headers: Record<string, string> = {
      Authorization: `token ${this.token}`,
      Accept: "application/json",
    };
    if (body !== undefined) {
      headers["Content-Type"] = "application/json";
    }
    const resp = await this.fetchFn(url, {
      method,
      headers,
      body: body !== undefined ? JSON.stringify(body) : undefined,
    });
    if (!resp.ok) {
      const text = await resp.text().catch(() => "");
      throw new Error(`Gitea API ${method} ${path} returned ${resp.status}: ${text}`);
    }
    return resp.json() as Promise<T>;
  }

  /** Get file content and SHA for optimistic concurrency */
  async getFileContent(
    path: string,
    ref: string,
  ): Promise<{ content: string; sha: string }> {
    const data = await this.request<{ content: string; sha: string }>(
      "GET",
      `/repos/${this.repo}/contents/${path}?ref=${ref}`,
    );
    return {
      content: atob(data.content),
      sha: data.sha,
    };
  }

  /** Update file with optimistic concurrency (SHA check) */
  async updateFile(
    path: string,
    content: string,
    sha: string,
    message: string,
    branch: string,
  ): Promise<void> {
    await this.request(
      "PUT",
      `/repos/${this.repo}/contents/${path}`,
      {
        content: btoa(content),
        sha,
        message,
        branch,
      },
    );
  }

  /** Post or update a PR comment */
  async postIssueComment(
    issueNumber: number,
    body: string,
  ): Promise<void> {
    await this.request(
      "POST",
      `/repos/${this.repo}/issues/${issueNumber}/comments`,
      { body },
    );
  }
}

Step 4: Run tests

Run: deno test src/gitea/client.test.ts Expected: PASS

Step 5: Commit

feat: add Gitea API client for state commits and PR comments

Task 8: Poller background loop with Gitea commit

Files:

  • Create: src/poller/loop.ts
  • Create: src/poller/state.ts

This task wires the enrollment detection from Task 6 to the Gitea client from Task 7, creating the full background polling loop.

Step 1: Create src/poller/state.ts — persists poll state to disk

import { join } from "jsr:@std/path";

export interface PollerState {
  lastEventTimestamp: string | null;
}

export async function loadPollerState(dataDir: string): Promise<PollerState> {
  const path = join(dataDir, "poller-state.json");
  try {
    const text = await Deno.readTextFile(path);
    return JSON.parse(text) as PollerState;
  } catch {
    return { lastEventTimestamp: null };
  }
}

export async function savePollerState(
  dataDir: string,
  state: PollerState,
): Promise<void> {
  const path = join(dataDir, "poller-state.json");
  await Deno.mkdir(dataDir, { recursive: true });
  await Deno.writeTextFile(path, JSON.stringify(state, null, 2));
}

Step 2: Create src/poller/loop.ts — the background loop

import type { NetbirdClient } from "../netbird/client.ts";
import type { GiteaClient } from "../gitea/client.ts";
import type { Config } from "../config.ts";
import type { DesiredState } from "../state/schema.ts";
import { DesiredStateSchema } from "../state/schema.ts";
import { processEnrollmentEvents } from "./poller.ts";
import { loadPollerState, savePollerState } from "./state.ts";

export interface PollerContext {
  config: Config;
  netbird: NetbirdClient;
  gitea: GiteaClient;
  /** Set to true while a reconcile is in progress — poller defers */
  reconcileInProgress: { value: boolean };
}

export async function pollOnce(ctx: PollerContext): Promise<void> {
  if (ctx.reconcileInProgress.value) {
    console.log(JSON.stringify({ msg: "poll_deferred", reason: "reconcile_in_progress" }));
    return;
  }

  const pollerState = await loadPollerState(ctx.config.dataDir);

  // Fetch current desired state from git
  let desired: DesiredState;
  try {
    const { content } = await ctx.gitea.getFileContent("netbird.json", "main");
    desired = DesiredStateSchema.parse(JSON.parse(content));
  } catch (err) {
    console.log(JSON.stringify({
      msg: "poll_error",
      error: "failed to fetch desired state",
      detail: err instanceof Error ? err.message : String(err),
    }));
    return;
  }

  const knownKeys = new Set(Object.keys(desired.setup_keys));
  const unenrolledKeys = new Set(
    Object.entries(desired.setup_keys)
      .filter(([_, v]) => !v.enrolled)
      .map(([k]) => k),
  );

  if (unenrolledKeys.size === 0) {
    // Nothing to watch for
    return;
  }

  // Fetch events
  const events = await ctx.netbird.listEvents();
  const enrollments = processEnrollmentEvents(
    events,
    unenrolledKeys,
    pollerState.lastEventTimestamp,
  );

  if (enrollments.length === 0) return;

  // Process each enrollment
  for (const enrollment of enrollments) {
    console.log(JSON.stringify({
      msg: "enrollment_detected",
      setup_key: enrollment.setupKeyName,
      peer_id: enrollment.peerId,
    }));

    // Rename peer
    try {
      await ctx.netbird.updatePeer(enrollment.peerId, {
        name: enrollment.setupKeyName,
        ssh_enabled: false,
        login_expiration_enabled: false,
        inactivity_expiration_enabled: false,
      });
      console.log(JSON.stringify({
        msg: "peer_renamed",
        peer_id: enrollment.peerId,
        new_name: enrollment.setupKeyName,
      }));
    } catch (err) {
      console.log(JSON.stringify({
        msg: "peer_rename_failed",
        peer_id: enrollment.peerId,
        error: err instanceof Error ? err.message : String(err),
      }));
      continue;
    }

    // Update enrolled status in git
    try {
      const { content, sha } = await ctx.gitea.getFileContent(
        "netbird.json",
        "main",
      );
      const state = JSON.parse(content);
      if (state.setup_keys?.[enrollment.setupKeyName]) {
        state.setup_keys[enrollment.setupKeyName].enrolled = true;
      }
      await ctx.gitea.updateFile(
        "netbird.json",
        JSON.stringify(state, null, 2),
        sha,
        `chore: mark ${enrollment.setupKeyName} as enrolled [automated]`,
        "main",
      );
      console.log(JSON.stringify({
        msg: "state_committed",
        setup_key: enrollment.setupKeyName,
      }));
    } catch (err) {
      console.log(JSON.stringify({
        msg: "state_commit_failed",
        setup_key: enrollment.setupKeyName,
        error: err instanceof Error ? err.message : String(err),
      }));
    }
  }

  // Update last event timestamp
  const latestTimestamp = enrollments[enrollments.length - 1].timestamp;
  await savePollerState(ctx.config.dataDir, {
    lastEventTimestamp: latestTimestamp,
  });
}

/** Starts the polling loop. Returns an AbortController to stop it. */
export function startPollerLoop(ctx: PollerContext): AbortController {
  const controller = new AbortController();
  const intervalMs = ctx.config.pollIntervalSeconds * 1000;

  const run = async () => {
    while (!controller.signal.aborted) {
      try {
        await pollOnce(ctx);
      } catch (err) {
        console.log(JSON.stringify({
          msg: "poll_error",
          error: err instanceof Error ? err.message : String(err),
        }));
      }
      await new Promise<void>((resolve) => {
        const timer = setTimeout(resolve, intervalMs);
        controller.signal.addEventListener("abort", () => {
          clearTimeout(timer);
          resolve();
        }, { once: true });
      });
    }
  };

  run(); // Fire and forget — runs in background
  return controller;
}

Step 3: Commit

feat: add poller background loop with Gitea state commit

Task 9: HTTP server

Files:

  • Modify: src/main.ts
  • Create: src/server.ts

Step 1: Create src/server.ts

This is the HTTP server with three endpoints. It wires together all the components.

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 { pollOnce, type PollerContext } from "./poller/loop.ts";

export interface ServerContext {
  config: Config;
  netbird: NetbirdClient;
  gitea: GiteaClient;
  reconcileInProgress: { value: boolean };
}

export function createHandler(ctx: ServerContext): Deno.ServeHandler {
  return async (req: Request): Promise<Response> => {
    const url = new URL(req.url);

    // Health check — no auth
    if (url.pathname === "/health" && req.method === "GET") {
      return Response.json({ status: "ok" });
    }

    // Auth check for all other endpoints
    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);
    }

    return Response.json({ error: "not found" }, { status: 404 });
  };
}

async function handleReconcile(
  req: Request,
  url: URL,
  ctx: ServerContext,
): Promise<Response> {
  const dryRun = url.searchParams.get("dry_run") === "true";

  // Parse and validate desired state
  let body: unknown;
  try {
    body = await req.json();
  } catch {
    return Response.json({ error: "invalid JSON body" }, { status: 400 });
  }

  const parseResult = DesiredStateSchema.safeParse(body);
  if (!parseResult.success) {
    return Response.json(
      { error: "invalid state schema", details: parseResult.error.issues },
      { status: 400 },
    );
  }
  const desired = parseResult.data;

  const crossRefErrors = validateCrossReferences(desired);
  if (crossRefErrors.length > 0) {
    return Response.json(
      { error: "cross-reference validation failed", details: crossRefErrors },
      { status: 400 },
    );
  }

  // Fetch actual state
  ctx.reconcileInProgress.value = true;
  try {
    const actual = await fetchActualState(ctx.netbird);
    const operations = computeDiff(desired, actual);

    if (dryRun) {
      return Response.json({
        status: "planned",
        operations: operations.map((op) => ({
          type: op.type,
          name: op.name,
        })),
        summary: summarize(operations.map((op) => ({ ...op, status: "pending" }))),
      });
    }

    if (operations.length === 0) {
      return Response.json({
        status: "applied",
        operations: [],
        created_keys: {},
        summary: { created: 0, updated: 0, deleted: 0, failed: 0 },
      });
    }

    const { results, createdKeys } = await executeOperations(
      operations,
      ctx.netbird,
      actual,
    );

    const failed = results.some((r) => r.status === "failed");

    return Response.json({
      status: failed ? "error" : "applied",
      operations: results.map((r) => ({
        type: r.type,
        name: r.name,
        status: r.status,
        ...(r.error ? { error: r.error } : {}),
      })),
      created_keys: Object.fromEntries(createdKeys),
      summary: summarize(results),
    });
  } finally {
    ctx.reconcileInProgress.value = false;
  }
}

async function handleSyncEvents(ctx: ServerContext): Promise<Response> {
  const pollerCtx: PollerContext = {
    config: ctx.config,
    netbird: ctx.netbird,
    gitea: ctx.gitea,
    reconcileInProgress: ctx.reconcileInProgress,
  };
  // Force reconcileInProgress to false so pollOnce runs
  const saved = ctx.reconcileInProgress.value;
  ctx.reconcileInProgress.value = false;
  try {
    await pollOnce(pollerCtx);
  } finally {
    ctx.reconcileInProgress.value = saved;
  }
  return Response.json({ status: "synced" });
}

function summarize(
  results: Array<{ type: string; status: string }>,
): { created: number; updated: number; deleted: number; failed: number } {
  let created = 0, updated = 0, deleted = 0, failed = 0;
  for (const r of results) {
    if (r.status === "failed") { failed++; continue; }
    if (r.type.startsWith("create_")) created++;
    else if (r.type.startsWith("update_") || r.type === "rename_peer") updated++;
    else if (r.type.startsWith("delete_")) deleted++;
  }
  return { created, updated, deleted, failed };
}

Step 2: Update src/main.ts to start the server and poller

import { loadConfig } from "./config.ts";
import { NetbirdClient } from "./netbird/client.ts";
import { GiteaClient } from "./gitea/client.ts";
import { createHandler } from "./server.ts";
import { startPollerLoop } from "./poller/loop.ts";

const config = loadConfig();

const netbird = new NetbirdClient(config.netbirdApiUrl, config.netbirdApiToken);
const gitea = new GiteaClient(
  config.giteaUrl,
  config.giteaToken,
  config.giteaRepo,
);

const reconcileInProgress = { value: false };

// Start background poller
const pollerAbort = startPollerLoop({
  config,
  netbird,
  gitea,
  reconcileInProgress,
});

// Start HTTP server
const handler = createHandler({
  config,
  netbird,
  gitea,
  reconcileInProgress,
});

console.log(JSON.stringify({ msg: "starting", port: config.port }));

Deno.serve({ port: config.port, handler });

// Graceful shutdown
Deno.addSignalListener("SIGTERM", () => {
  console.log(JSON.stringify({ msg: "shutting_down" }));
  pollerAbort.abort();
  Deno.exit(0);
});

Step 3: Verify compilation

Run: deno check src/main.ts Expected: no errors

Step 4: Commit

feat: add HTTP server with /reconcile, /sync-events, /health endpoints

Task 10: CI workflow files

Files:

  • Create: .gitea/workflows/dry-run.yml
  • Create: .gitea/workflows/reconcile.yml
  • Create: .gitea/workflows/release.yml

Step 1: Create .gitea/workflows/dry-run.yml

name: Dry Run

on:
  pull_request:
    paths:
      - 'netbird.json'

jobs:
  dry-run:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run dry-run reconcile
        id: plan
        run: |
          RESPONSE=$(curl -sf \
            -X POST \
            -H "Authorization: Bearer ${{ secrets.RECONCILER_TOKEN }}" \
            -H "Content-Type: application/json" \
            -d @netbird.json \
            "${{ secrets.RECONCILER_URL }}/reconcile?dry_run=true")
          echo "response<<EOF" >> "$GITHUB_OUTPUT"
          echo "$RESPONSE" >> "$GITHUB_OUTPUT"
          echo "EOF" >> "$GITHUB_OUTPUT"

      - name: Format plan as markdown
        id: format
        run: |
          cat <<'SCRIPT' > format.py
          import json, sys
          data = json.loads(sys.stdin.read())
          ops = data.get("operations", [])
          summary = data.get("summary", {})
          lines = ["## NetBird Reconciliation Plan\n"]
          if not ops:
              lines.append("No changes detected.\n")
          else:
              lines.append("| Operation | Name |")
              lines.append("|-----------|------|")
              for op in ops:
                  lines.append(f"| `{op['type']}` | {op['name']} |")
              lines.append("")
              s = summary
              lines.append(f"**Summary:** {s.get('created',0)} create, {s.get('updated',0)} update, {s.get('deleted',0)} delete")
          print("\n".join(lines))
          SCRIPT
          COMMENT=$(echo '${{ steps.plan.outputs.response }}' | python3 format.py)
          echo "comment<<EOF" >> "$GITHUB_OUTPUT"
          echo "$COMMENT" >> "$GITHUB_OUTPUT"
          echo "EOF" >> "$GITHUB_OUTPUT"

      - name: Post PR comment
        env:
          GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
        run: |
          curl -sf \
            -X POST \
            -H "Authorization: token ${GITEA_TOKEN}" \
            -H "Content-Type: application/json" \
            -d "{\"body\": $(echo '${{ steps.format.outputs.comment }}' | jq -Rs .)}" \
            "${{ secrets.GITEA_URL }}/api/v1/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments"

Step 2: Create .gitea/workflows/reconcile.yml

name: Reconcile

on:
  push:
    branches:
      - main
    paths:
      - 'netbird.json'

jobs:
  reconcile:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Sync events
        run: |
          curl -sf \
            -X POST \
            -H "Authorization: Bearer ${{ secrets.RECONCILER_TOKEN }}" \
            "${{ secrets.RECONCILER_URL }}/sync-events"

      - name: Pull latest (poller may have committed)
        run: git pull --rebase

      - name: Apply reconcile
        id: reconcile
        run: |
          RESPONSE=$(curl -sf \
            -X POST \
            -H "Authorization: Bearer ${{ secrets.RECONCILER_TOKEN }}" \
            -H "Content-Type: application/json" \
            -d @netbird.json \
            "${{ secrets.RECONCILER_URL }}/reconcile")
          echo "response<<EOF" >> "$GITHUB_OUTPUT"
          echo "$RESPONSE" >> "$GITHUB_OUTPUT"
          echo "EOF" >> "$GITHUB_OUTPUT"

          STATUS=$(echo "$RESPONSE" | jq -r '.status')
          if [ "$STATUS" = "error" ]; then
            echo "Reconcile failed"
            echo "$RESPONSE" | jq .
            exit 1
          fi

      - name: Encrypt and upload setup keys
        if: success()
        run: |
          KEYS=$(echo '${{ steps.reconcile.outputs.response }}' | jq -r '.created_keys // empty')
          if [ -n "$KEYS" ] && [ "$KEYS" != "{}" ] && [ "$KEYS" != "null" ]; then
            echo "$KEYS" | age -r "${{ secrets.AGE_PUBLIC_KEY }}" -o setup-keys.age
            echo "Setup keys encrypted to setup-keys.age"
          else
            echo "No new keys created"
            exit 0
          fi

      - name: Upload artifact
        if: success()
        uses: actions/upload-artifact@v4
        with:
          name: setup-keys
          path: setup-keys.age
          if-no-files-found: ignore

Step 3: Create .gitea/workflows/release.yml

name: Release

on:
  push:
    tags:
      - 'v*'

jobs:
  build:
    runs-on: ubuntu-latest
    container:
      image: denoland/deno:debian
    steps:
      - uses: actions/checkout@v4

      - name: Compile
        run: deno compile --allow-net --allow-read --allow-write --allow-env --output reconciler src/main.ts

      - name: Build Docker image
        run: |
          docker build -t ${{ secrets.GITEA_URL }}/blastpilot/netbird-reconciler:${{ github.ref_name }} .
          docker tag ${{ secrets.GITEA_URL }}/blastpilot/netbird-reconciler:${{ github.ref_name }} \
                     ${{ secrets.GITEA_URL }}/blastpilot/netbird-reconciler:latest

      - name: Push Docker image
        run: |
          echo "${{ secrets.PACKAGE_TOKEN }}" | docker login ${{ secrets.GITEA_URL }} -u achilles-ci-bot --password-stdin
          docker push ${{ secrets.GITEA_URL }}/blastpilot/netbird-reconciler:${{ github.ref_name }}
          docker push ${{ secrets.GITEA_URL }}/blastpilot/netbird-reconciler:latest

Step 4: Commit

feat: add Gitea Actions CI workflows for dry-run, reconcile, and release

Task 11: Seed netbird.json with initial state

Files:

  • Create: netbird.json

Step 1: Create the initial state file

This should reflect the current BlastPilot NetBird configuration. Start minimal — populate with actual groups/policies after deploying the reconciler and importing existing state.

{
  "groups": {},
  "setup_keys": {},
  "policies": {},
  "routes": {},
  "dns": {
    "nameserver_groups": {}
  }
}

Step 2: Commit

feat: add empty netbird.json state file

Task 12: Docker Compose deployment config

Files:

  • Create: deploy/docker-compose.yml
  • Create: deploy/.env.example

Step 1: Create deploy/docker-compose.yml

services:
  netbird-reconciler:
    image: gitea.internal/blastpilot/netbird-reconciler:latest
    restart: unless-stopped
    env_file: .env
    volumes:
      - reconciler-data:/data
    ports:
      - "127.0.0.1:8080:8080"
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
      interval: 30s
      timeout: 5s
      retries: 3
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.reconciler.rule=Host(`reconciler.internal`)"
      - "traefik.http.services.reconciler.loadbalancer.server.port=8080"

volumes:
  reconciler-data:

Step 2: Create deploy/.env.example

NETBIRD_API_URL=https://netbird.example.com/api
NETBIRD_API_TOKEN=
GITEA_URL=https://gitea.example.com
GITEA_TOKEN=
GITEA_REPO=BlastPilot/netbird-gitops
RECONCILER_TOKEN=
POLL_INTERVAL_SECONDS=30
PORT=8080
DATA_DIR=/data

Step 3: Commit

feat: add Docker Compose deployment config

Task 13: Integration test with mock NetBird server

Files:

  • Create: src/integration.test.ts

Write an end-to-end test that starts the HTTP server, posts a reconcile request with a known desired state against a mock NetBird API, and verifies the correct API calls were made.

Step 1: Write integration test

import { assertEquals } from "jsr:@std/assert";
import { createHandler } from "./server.ts";
import { NetbirdClient } from "./netbird/client.ts";
import { GiteaClient } from "./gitea/client.ts";

/** Tracks all API calls made */
interface ApiCall {
  method: string;
  path: string;
  body?: unknown;
}

function createMockNetbird(): { client: NetbirdClient; calls: ApiCall[] } {
  const calls: ApiCall[] = [];
  const responses = new Map<string, { status: number; body: unknown }>([
    // List endpoints return empty by default
    ["GET /groups", { status: 200, body: [] }],
    ["GET /setup-keys", { status: 200, body: [] }],
    ["GET /peers", { status: 200, body: [] }],
    ["GET /policies", { status: 200, body: [] }],
    ["GET /routes", { status: 200, body: [] }],
    ["GET /dns/nameservers", { status: 200, body: [] }],
    ["GET /events/audit", { status: 200, body: [] }],
  ]);

  const mockFetch: typeof fetch = async (input, init) => {
    const url = typeof input === "string"
      ? input
      : input instanceof URL ? input.toString() : input.url;
    const path = url.replace("https://nb.test/api", "");
    const method = init?.method ?? "GET";
    calls.push({
      method,
      path,
      body: init?.body ? JSON.parse(init.body as string) : undefined,
    });

    // Handle create operations
    if (method === "POST" && path === "/groups") {
      const body = JSON.parse(init?.body as string);
      return new Response(
        JSON.stringify({ id: `g-${body.name}`, name: body.name, peers_count: 0, peers: [], issued: "api" }),
        { status: 201, headers: { "Content-Type": "application/json" } },
      );
    }
    if (method === "POST" && path === "/setup-keys") {
      const body = JSON.parse(init?.body as string);
      return new Response(
        JSON.stringify({ id: 1, name: body.name, key: "TEST-KEY-12345", state: "valid" }),
        { status: 201, headers: { "Content-Type": "application/json" } },
      );
    }
    if (method === "POST" && path === "/policies") {
      const body = JSON.parse(init?.body as string);
      return new Response(
        JSON.stringify({ id: `p-${body.name}`, ...body }),
        { status: 201, headers: { "Content-Type": "application/json" } },
      );
    }

    const key = `${method} ${path}`;
    const resp = responses.get(key);
    if (!resp) {
      return new Response(JSON.stringify({ message: "not found" }), { status: 404 });
    }
    return new Response(JSON.stringify(resp.body), {
      status: resp.status,
      headers: { "Content-Type": "application/json" },
    });
  };

  const client = new NetbirdClient("https://nb.test/api", "test", mockFetch);
  return { client, calls };
}

Deno.test("POST /reconcile dry_run returns planned operations", async () => {
  const { client } = createMockNetbird();
  const handler = createHandler({
    config: { reconcilerToken: "secret" } as never,
    netbird: client,
    gitea: {} as never,
    reconcileInProgress: { value: false },
  });

  const body = JSON.stringify({
    groups: { pilots: { peers: [] } },
    setup_keys: {
      "Pilot-hawk-72": {
        type: "one-off",
        expires_in: 604800,
        usage_limit: 1,
        auto_groups: ["pilots"],
        enrolled: false,
      },
    },
    policies: {},
    routes: {},
    dns: { nameserver_groups: {} },
  });

  const resp = await handler(
    new Request("http://localhost/reconcile?dry_run=true", {
      method: "POST",
      headers: {
        "Authorization": "Bearer secret",
        "Content-Type": "application/json",
      },
      body,
    }),
  );

  assertEquals(resp.status, 200);
  const data = await resp.json();
  assertEquals(data.status, "planned");
  const types = data.operations.map((o: { type: string }) => o.type);
  assertEquals(types.includes("create_group"), true);
  assertEquals(types.includes("create_setup_key"), true);
});

Deno.test("POST /reconcile apply creates resources and returns keys", async () => {
  const { client, calls } = createMockNetbird();
  const handler = createHandler({
    config: { reconcilerToken: "secret" } as never,
    netbird: client,
    gitea: {} as never,
    reconcileInProgress: { value: false },
  });

  const body = JSON.stringify({
    groups: { pilots: { peers: [] } },
    setup_keys: {
      "Pilot-hawk-72": {
        type: "one-off",
        expires_in: 604800,
        usage_limit: 1,
        auto_groups: ["pilots"],
        enrolled: false,
      },
    },
    policies: {},
    routes: {},
    dns: { nameserver_groups: {} },
  });

  const resp = await handler(
    new Request("http://localhost/reconcile", {
      method: "POST",
      headers: {
        "Authorization": "Bearer secret",
        "Content-Type": "application/json",
      },
      body,
    }),
  );

  assertEquals(resp.status, 200);
  const data = await resp.json();
  assertEquals(data.status, "applied");
  assertEquals(data.created_keys["Pilot-hawk-72"], "TEST-KEY-12345");

  // Verify API calls were made
  const postCalls = calls.filter((c) => c.method === "POST");
  assertEquals(postCalls.some((c) => c.path === "/groups"), true);
  assertEquals(postCalls.some((c) => c.path === "/setup-keys"), true);
});

Deno.test("POST /reconcile rejects unauthorized requests", async () => {
  const handler = createHandler({
    config: { reconcilerToken: "secret" } as never,
    netbird: {} as never,
    gitea: {} as never,
    reconcileInProgress: { value: false },
  });

  const resp = await handler(
    new Request("http://localhost/reconcile", {
      method: "POST",
      headers: { "Authorization": "Bearer wrong" },
    }),
  );

  assertEquals(resp.status, 401);
});

Step 2: Run tests

Run: deno test src/integration.test.ts Expected: PASS

Step 3: Commit

test: add integration tests for reconcile HTTP endpoint

Task 14: Update blastpilot-public enrollment pipeline

Files:

  • Modify: ../blastpilot-public/api/src/services/enrollment-pipeline.ts
  • Modify: ../blastpilot-public/api/src/services/netbird.ts

This task modifies the enrollment pipeline to write to netbird.json in the netbird-gitops repo instead of creating peers/enrollment-{N}.json files.

Step 1: Update handleApproval() in enrollment-pipeline.ts

Change from creating a standalone peer JSON file to modifying netbird.json:

  • Fetch current netbird.json from netbird-gitops repo via Gitea API
  • Add setup key entries for GS and Pilot
  • Add peer references to appropriate groups
  • Create PR with the modified netbird.json

Step 2: Remove direct NetBird API calls from handlePRMerge()

The reconciler now handles key creation. handlePRMerge() should be simplified or removed (key delivery is manual for now).

Step 3: Update tests

Step 4: Commit

refactor: update enrollment pipeline to write to netbird.json instead of peer files

Task 15: Deploy and test

Step 1: Create Gitea repo BlastPilot/netbird-gitops

Via Gitea UI or API. Push all code.

Step 2: Generate tokens

  • Generate RECONCILER_TOKEN (random 32-byte hex)
  • Ensure NETBIRD_API_TOKEN has management API access
  • Ensure GITEA_TOKEN has repo write access
  • Generate AGE_PUBLIC_KEY / private key pair

Step 3: Deploy Docker Compose on VPS

cd deploy
cp .env.example .env
# Fill in .env values
docker compose up -d
docker compose logs -f

Step 4: Test health endpoint

curl http://localhost:8080/health
# Expected: {"status":"ok"}

Step 5: Test dry-run

curl -X POST \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d @netbird.json \
  http://localhost:8080/reconcile?dry_run=true

Step 6: Test with a real enrollment

  • Add a test setup key + peer to netbird.json
  • Push, verify CI creates key
  • Use the key to enroll a device
  • Verify poller detects enrollment, renames peer, commits state

Step 7: Commit any fixes

fix: address deployment issues found during testing