# Reconciler PoC Validation — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to > implement this plan task-by-task. **Goal:** Validate the reconciler end-to-end on a fresh, isolated NetBird instance (VPS-A) with state export and a `GITEA_ENABLED` feature flag. **Architecture:** Deploy NetBird + Caddy + Gitea + Reconciler on VPS-A via Ansible. Add `GITEA_ENABLED` flag to make Gitea optional. Add state export tool (`GET /export` + `--export` CLI). Test reconcile, enrollment detection, and state round-trip. **Tech Stack:** Deno 2.x / TypeScript, Zod, Docker Compose, Ansible, Caddy, NetBird self-hosted, Gitea **Design doc:** `docs/plans/2026-03-06-reconciler-poc-validation.md` --- ## Task 1: `GITEA_ENABLED` Feature Flag — Config Make Gitea env vars conditionally required based on a new `GITEA_ENABLED` env var. **Files:** - Modify: `src/config.ts` - Test: `src/config.test.ts` (create) **Step 1: Write config tests** Create `src/config.test.ts`: ```typescript import { assertEquals, assertThrows } from "@std/assert"; // We can't easily test loadConfig() directly because it reads Deno.env. // Instead, test the schema validation logic by extracting it. // For now, integration-level tests via the main module are sufficient. // The key behavioral test is: does the service start without GITEA_* vars // when GITEA_ENABLED=false? // This is tested implicitly via integration.test.ts updates in Task 3. ``` Actually — `loadConfig()` reads directly from `Deno.env`, making unit tests awkward. The feature flag is better tested at integration level. Skip unit test file creation; test via integration tests in Task 3. **Step 2: Modify `src/config.ts`** Current code has a single `ConfigSchema` that requires all Gitea fields. Split into base + conditional: ```typescript import { z } from "zod"; const BaseConfigSchema = z.object({ netbirdApiUrl: z.string().url(), netbirdApiToken: z.string().min(1), reconcilerToken: z.string().min(1), giteaEnabled: z.coerce.boolean().default(true), pollIntervalSeconds: z.coerce.number().int().positive().default(30), port: z.coerce.number().int().positive().default(8080), dataDir: z.string().default("/data"), }); const GiteaConfigSchema = z.object({ giteaUrl: z.string().url(), giteaToken: z.string().min(1), giteaRepo: z.string().regex(/^[^/]+\/[^/]+$/), }); // When giteaEnabled=true, Gitea fields are present. // When false, they are undefined. export type Config = z.infer & { giteaUrl?: string; giteaToken?: string; giteaRepo?: string; }; export function loadConfig(): Config { const raw = { netbirdApiUrl: Deno.env.get("NETBIRD_API_URL"), netbirdApiToken: Deno.env.get("NETBIRD_API_TOKEN"), reconcilerToken: Deno.env.get("RECONCILER_TOKEN"), giteaEnabled: Deno.env.get("GITEA_ENABLED"), pollIntervalSeconds: Deno.env.get("POLL_INTERVAL_SECONDS"), port: Deno.env.get("PORT"), dataDir: Deno.env.get("DATA_DIR"), }; const base = BaseConfigSchema.parse(raw); if (base.giteaEnabled) { const giteaRaw = { giteaUrl: Deno.env.get("GITEA_URL"), giteaToken: Deno.env.get("GITEA_TOKEN"), giteaRepo: Deno.env.get("GITEA_REPO"), }; const gitea = GiteaConfigSchema.parse(giteaRaw); return { ...base, ...gitea }; } return base; } ``` Key behavior: `GITEA_ENABLED` defaults to `true` (backward compat). When `false`, `GITEA_URL`, `GITEA_TOKEN`, `GITEA_REPO` are not required and not validated. `z.coerce.boolean()` converts string `"false"` → `false`. **Step 3: Run type checker** Run: `deno task check` Expected: Type errors in `main.ts` and `server.ts` because `Config` fields are now optional. Fix in next tasks. **Step 4: Commit** ``` feat: make Gitea env vars conditional via GITEA_ENABLED flag ``` --- ## Task 2: `GITEA_ENABLED` — Wire Into Main and Server Update `main.ts`, `server.ts`, and `poller/loop.ts` to handle `giteaEnabled=false`. **Files:** - Modify: `src/main.ts` - Modify: `src/server.ts` - Modify: `src/poller/loop.ts` **Step 1: Update `src/main.ts`** Conditionally create Gitea client and poller: ```typescript import { ZodError } from "zod"; 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"; let config; try { config = loadConfig(); } catch (err) { if (err instanceof ZodError) { console.error( JSON.stringify({ msg: "invalid config", issues: err.issues }), ); Deno.exit(1); } throw err; } const netbird = new NetbirdClient(config.netbirdApiUrl, config.netbirdApiToken); const gitea = config.giteaEnabled ? new GiteaClient(config.giteaUrl!, config.giteaToken!, config.giteaRepo!) : null; 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, gitea_enabled: config.giteaEnabled, })); Deno.serve({ port: config.port, handler }); // Graceful shutdown Deno.addSignalListener("SIGTERM", () => { console.log(JSON.stringify({ msg: "shutting_down" })); pollerAbort.abort(); Deno.exit(0); }); ``` **Step 2: Update `ServerContext` in `src/server.ts`** Change `gitea` field to nullable: ```typescript export interface ServerContext { config: Config; netbird: NetbirdClient; gitea: GiteaClient | null; reconcileInProgress: { value: boolean }; } ``` The `handleReconcile` function does NOT use `gitea` — it receives desired state from the request body. No changes needed there. `handleSyncEvents` uses `gitea` via `pollOnce`. Guard it: ```typescript async function handleSyncEvents(ctx: ServerContext): Promise { if (!ctx.gitea) { // Still allow sync-events — poller will work without Gitea commit-back } const pollerCtx: PollerContext = { config: ctx.config, netbird: ctx.netbird, gitea: ctx.gitea, reconcileInProgress: { value: false }, }; // ... rest unchanged } ``` **Step 3: Update `PollerContext` in `src/poller/loop.ts`** Make `gitea` nullable. Guard all Gitea calls: ```typescript export interface PollerContext { config: Config; netbird: NetbirdClient; gitea: GiteaClient | null; reconcileInProgress: { value: boolean }; } ``` In `pollOnce()`: - If `gitea` is null, skip fetching `netbird.json` from Gitea. Instead, read from local file at `{dataDir}/netbird.json` (or skip polling entirely if no local file exists). - Actually — the simplest approach: **if gitea is null, the poller still runs** but only detects enrollment events and renames peers. It does NOT read desired state or commit back. It uses the set of ALL setup key names from the last reconcile call (which we don't have in the poller context). Revised approach for `GITEA_ENABLED=false`: The poller needs to know which setup keys exist to detect enrollments. Without Gitea, it can't read `netbird.json`. Two options: 1. Read from a local file at `{dataDir}/netbird.json`. 2. Fetch setup keys directly from NetBird API (they're already there after reconcile). Option 2 is cleaner — the reconciler just created these keys, they're in NetBird: ```typescript export async function pollOnce(ctx: PollerContext): Promise { const { config, netbird, gitea, reconcileInProgress } = ctx; if (reconcileInProgress.value) { console.log( JSON.stringify({ msg: "poll_skipped", reason: "reconcile_in_progress" }), ); return; } const pollerState = await loadPollerState(config.dataDir); // Determine unenrolled setup keys let unenrolledKeys: Set; let desired: DesiredState | null = null; let fileSha: string | null = null; if (gitea) { // Full Gitea mode: read netbird.json from repo const file = await gitea.getFileContent("netbird.json", "main"); desired = DesiredStateSchema.parse(JSON.parse(file.content)); fileSha = file.sha; unenrolledKeys = new Set(); for ( const [name, key] of Object.entries(desired.setup_keys) as [ string, SetupKeyConfig, ][] ) { if (!key.enrolled) unenrolledKeys.add(name); } } else { // Standalone mode: get setup key names from NetBird API directly const keys = await netbird.listSetupKeys(); unenrolledKeys = new Set(); for (const key of keys) { // Consider unused keys as "unenrolled" if (key.valid && !key.revoked && key.used_times < key.usage_limit) { unenrolledKeys.add(key.name); } } } if (unenrolledKeys.size === 0) { console.log(JSON.stringify({ msg: "poll_no_unenrolled_keys" })); return; } const events = await netbird.listEvents(); const enrollments = processEnrollmentEvents( events, unenrolledKeys, pollerState.lastEventTimestamp, ); if (enrollments.length === 0) return; console.log(JSON.stringify({ msg: "poll_enrollments_detected", count: enrollments.length, })); let latestTimestamp = pollerState.lastEventTimestamp; for (const enrollment of enrollments) { if (gitea && desired && fileSha) { // Full mode: rename peer + commit enrolled:true await processEnrollment( ctx, enrollment, desired, fileSha, (newSha, newDesired) => { fileSha = newSha; desired = newDesired; }, ); } else { // Standalone mode: rename peer only, log enrollment await processEnrollmentStandalone(netbird, enrollment); } if (!latestTimestamp || enrollment.timestamp > latestTimestamp) { latestTimestamp = enrollment.timestamp; } } await savePollerState(config.dataDir, { lastEventTimestamp: latestTimestamp, }); } ``` Add a new standalone enrollment processor (same file, after `processEnrollment`): ```typescript async function processEnrollmentStandalone( netbird: NetbirdClient, enrollment: EnrollmentDetection, ): Promise { const { setupKeyName, peerId, peerHostname } = enrollment; try { await netbird.updatePeer(peerId, { name: setupKeyName }); console.log(JSON.stringify({ msg: "peer_renamed", peer_id: peerId, from: peerHostname, to: setupKeyName, mode: "standalone", })); } catch (err) { console.error(JSON.stringify({ msg: "peer_rename_failed", peer_id: peerId, error: err instanceof Error ? err.message : String(err), })); } console.log(JSON.stringify({ msg: "enrollment_detected", setup_key: setupKeyName, peer_id: peerId, mode: "standalone", note: "gitea commit-back skipped (GITEA_ENABLED=false)", })); } ``` **Step 4: Run type checker and tests** Run: `deno task check && deno task test` Expected: Check passes. Some tests may need updates if they create `PollerContext` with non-null gitea — they should still pass since they provide a gitea mock. **Step 5: Commit** ``` feat: wire GITEA_ENABLED flag into main, server, and poller When GITEA_ENABLED=false, the reconciler starts without a Gitea client. The poller detects enrollments by reading setup keys directly from the NetBird API and renames peers, but skips the commit-back of enrolled:true. ``` --- ## Task 3: Update Integration Tests for GITEA_ENABLED Ensure existing tests pass and add a test for standalone (no-Gitea) mode. **Files:** - Modify: `src/integration.test.ts` **Step 1: Read the existing integration test** File: `src/integration.test.ts` — understand the current test setup and how it creates the server handler. **Step 2: Update tests** The existing tests create a `ServerContext` with a mock `gitea`. They should continue working since `gitea` is now `GiteaClient | null` and the mocks are non-null. Add one new test: ```typescript Deno.test("reconcile works with gitea=null (standalone mode)", async () => { const ctx: ServerContext = { config: { ...baseConfig, giteaEnabled: false }, netbird: mockNetbird, gitea: null, reconcileInProgress: { value: false }, }; const handler = createHandler(ctx); const resp = await handler( new Request("http://localhost/reconcile?dry_run=true", { method: "POST", headers: { "Authorization": `Bearer ${baseConfig.reconcilerToken}` }, body: JSON.stringify({ groups: { "test-group": { peers: [] } }, setup_keys: {}, }), }), ); assertEquals(resp.status, 200); const body = await resp.json(); assertEquals(body.status, "planned"); }); ``` Exact mock setup depends on the existing test file — read it first to match patterns. **Step 3: Run tests** Run: `deno task test` Expected: All tests pass, including the new one. **Step 4: Commit** ``` test: add integration test for standalone (no-Gitea) reconcile mode ``` --- ## Task 4: State Export — `src/export.ts` New module that transforms `ActualState` into a valid `netbird.json`. **Files:** - Create: `src/export.ts` - Create: `src/export.test.ts` **Step 1: Write the export test** ```typescript import { assertEquals } from "@std/assert"; import { exportState } from "./export.ts"; import type { ActualState } from "./state/actual.ts"; Deno.test("exportState produces valid DesiredState from actual", () => { const actual: ActualState = { groups: [ { id: "g1", name: "pilots", peers_count: 1, peers: [{ id: "p1", name: "Pilot-Hawk-1" }], issued: "api", }, { id: "g2", name: "ground-stations", peers_count: 0, peers: [], issued: "api", }, { id: "g-all", name: "All", peers_count: 1, peers: [{ id: "p1", name: "Pilot-Hawk-1" }], issued: "integration", }, ], groupsByName: new Map(), groupsById: new Map(), setupKeys: [ { id: 1, name: "Pilot-Hawk-1", type: "one-off", key: "secret", expires: "2026-03-13T00:00:00Z", valid: true, revoked: false, used_times: 1, state: "overused", auto_groups: ["g1"], usage_limit: 1, }, { id: 2, name: "GS-Hawk-1", type: "one-off", key: "secret2", expires: "2026-03-13T00:00:00Z", valid: true, revoked: false, used_times: 0, state: "valid", auto_groups: ["g2"], usage_limit: 1, }, ], setupKeysByName: new Map(), peers: [{ id: "p1", name: "Pilot-Hawk-1", ip: "100.64.0.1", connected: true, hostname: "laptop", os: "linux", version: "0.35.0", groups: [{ id: "g1", name: "pilots" }], last_seen: "2026-03-06T10:00:00Z", dns_label: "pilot-hawk-1", login_expiration_enabled: false, ssh_enabled: false, inactivity_expiration_enabled: false, }], peersByName: new Map(), peersById: new Map(), policies: [ { id: "pol1", name: "pilots-to-gs", description: "Allow pilots to reach GS", enabled: true, rules: [{ name: "pilots-to-gs", description: "", enabled: true, action: "accept", bidirectional: true, protocol: "all", sources: [{ id: "g1", name: "pilots" }], destinations: [{ id: "g2", name: "ground-stations" }], }], }, ], policiesByName: new Map(), routes: [], routesByNetworkId: new Map(), dns: [], dnsByName: new Map(), }; const exported = exportState(actual); // Groups: "All" and system groups should be excluded assertEquals(Object.keys(exported.groups).sort(), [ "ground-stations", "pilots", ]); assertEquals(exported.groups["pilots"].peers, ["Pilot-Hawk-1"]); assertEquals(exported.groups["ground-stations"].peers, []); // Setup keys assertEquals(Object.keys(exported.setup_keys).sort(), [ "GS-Hawk-1", "Pilot-Hawk-1", ]); assertEquals(exported.setup_keys["Pilot-Hawk-1"].enrolled, true); // used_times >= usage_limit assertEquals(exported.setup_keys["GS-Hawk-1"].enrolled, false); // not yet used // Policies assertEquals(Object.keys(exported.policies), ["pilots-to-gs"]); assertEquals(exported.policies["pilots-to-gs"].sources, ["pilots"]); assertEquals(exported.policies["pilots-to-gs"].destinations, [ "ground-stations", ]); assertEquals(exported.policies["pilots-to-gs"].bidirectional, true); // Routes and DNS should be empty assertEquals(exported.routes, {}); assertEquals(exported.dns.nameserver_groups, {}); }); Deno.test("exportState handles empty state", () => { const actual: ActualState = { groups: [{ id: "g-all", name: "All", peers_count: 0, peers: [], issued: "integration", }], 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 exported = exportState(actual); assertEquals(exported.groups, {}); assertEquals(exported.setup_keys, {}); assertEquals(exported.policies, {}); assertEquals(exported.routes, {}); }); Deno.test("exportState maps auto_groups IDs to group names", () => { const actual: ActualState = { groups: [ { id: "g1", name: "pilots", peers_count: 0, peers: [], issued: "api" }, ], groupsByName: new Map([["pilots", { id: "g1", name: "pilots", peers_count: 0, peers: [], issued: "api" as const, }]]), groupsById: new Map([["g1", { id: "g1", name: "pilots", peers_count: 0, peers: [], issued: "api" as const, }]]), setupKeys: [ { id: 1, name: "Test-Key", type: "one-off" as const, key: "k", expires: "", valid: true, revoked: false, used_times: 0, state: "valid" as const, auto_groups: ["g1"], usage_limit: 1, }, ], setupKeysByName: new Map(), peers: [], peersByName: new Map(), peersById: new Map(), policies: [], policiesByName: new Map(), routes: [], routesByNetworkId: new Map(), dns: [], dnsByName: new Map(), }; const exported = exportState(actual); // auto_groups should contain group names, not IDs assertEquals(exported.setup_keys["Test-Key"].auto_groups, ["pilots"]); }); ``` **Step 2: Run test to verify it fails** Run: `deno test src/export.test.ts` Expected: FAIL — `exportState` not found. **Step 3: Implement `src/export.ts`** ```typescript import type { ActualState } from "./state/actual.ts"; import type { DesiredState } from "./state/schema.ts"; /** Groups that are auto-created by NetBird and should not be exported. */ const SYSTEM_GROUP_NAMES = new Set(["All"]); const SYSTEM_GROUP_ISSUERS = new Set(["integration", "jwt"]); /** * Transforms live ActualState (fetched from NetBird API) into a valid * DesiredState object (netbird.json format). Maps all IDs to names. * * Skips system-generated groups (All, JWT-issued, integration-issued). * Setup keys with used_times >= usage_limit are marked enrolled:true. */ export function exportState(actual: ActualState): DesiredState { // Build ID->name lookup for groups (needed for auto_groups, policies, routes, DNS) const groupIdToName = new Map(); for (const g of actual.groups) { groupIdToName.set(g.id, g.name); } // Build setup key name set for peer->key name mapping in groups const setupKeyNames = new Set(actual.setupKeys.map((k) => k.name)); // --- Groups --- const groups: DesiredState["groups"] = {}; for (const g of actual.groups) { if (SYSTEM_GROUP_NAMES.has(g.name) || SYSTEM_GROUP_ISSUERS.has(g.issued)) { continue; } // Map peer names — only include peers whose name matches a setup key // (the reconciler convention is that peer names equal setup key names) const peers = g.peers .map((p) => p.name) .filter((name) => setupKeyNames.has(name)); groups[g.name] = { peers }; } // --- Setup Keys --- const setup_keys: DesiredState["setup_keys"] = {}; for (const k of actual.setupKeys) { // Map auto_groups from IDs to names const autoGroupNames = k.auto_groups .map((id) => groupIdToName.get(id)) .filter((name): name is string => name !== undefined); setup_keys[k.name] = { type: k.type, expires_in: 604800, // Default 7 days — NetBird API doesn't return the original expires_in usage_limit: k.usage_limit, auto_groups: autoGroupNames, enrolled: k.used_times >= k.usage_limit && k.usage_limit > 0, }; } // --- Policies --- const policies: DesiredState["policies"] = {}; for (const p of actual.policies) { if (p.rules.length === 0) continue; const rule = p.rules[0]; // Reconciler creates single-rule policies const sources = rule.sources.map((s) => typeof s === "string" ? (groupIdToName.get(s) ?? s) : s.name ); const destinations = rule.destinations.map((d) => typeof d === "string" ? (groupIdToName.get(d) ?? d) : d.name ); policies[p.name] = { description: p.description, enabled: p.enabled, sources, destinations, bidirectional: rule.bidirectional, protocol: rule.protocol, action: rule.action, ...(rule.ports && rule.ports.length > 0 ? { ports: rule.ports } : {}), }; } // --- Routes --- const routes: DesiredState["routes"] = {}; for (const r of actual.routes) { const peerGroups = (r.peer_groups ?? []) .map((id) => groupIdToName.get(id)) .filter((name): name is string => name !== undefined); const distributionGroups = r.groups .map((id) => groupIdToName.get(id)) .filter((name): name is string => name !== undefined); routes[r.network_id] = { description: r.description, ...(r.network ? { network: r.network } : {}), ...(r.domains && r.domains.length > 0 ? { domains: r.domains } : {}), peer_groups: peerGroups, metric: r.metric, masquerade: r.masquerade, distribution_groups: distributionGroups, enabled: r.enabled, keep_route: r.keep_route, }; } // --- DNS --- const nameserver_groups: DesiredState["dns"]["nameserver_groups"] = {}; for (const ns of actual.dns) { const nsGroups = ns.groups .map((id) => groupIdToName.get(id)) .filter((name): name is string => name !== undefined); nameserver_groups[ns.name] = { description: ns.description, nameservers: ns.nameservers.map((n) => ({ ip: n.ip, ns_type: n.ns_type, port: n.port, })), enabled: ns.enabled, groups: nsGroups, primary: ns.primary, domains: ns.domains, search_domains_enabled: ns.search_domains_enabled, }; } return { groups, setup_keys, policies, routes, dns: { nameserver_groups }, }; } ``` **Step 4: Run tests** Run: `deno test src/export.test.ts` Expected: All 3 tests pass. **Step 5: Commit** ``` feat: add state export module (ActualState -> netbird.json) ``` --- ## Task 5: State Export — HTTP Endpoint and CLI Flag Wire the export function into the server (`GET /export`) and add `--export` CLI mode. **Files:** - Modify: `src/server.ts` - Modify: `src/main.ts` - Modify: `src/integration.test.ts` **Step 1: Add `GET /export` to `src/server.ts`** In `createHandler()`, add before the 404 fallback: ```typescript if (url.pathname === "/export" && req.method === "GET") { return handleExport(ctx); } ``` Add the handler function: ```typescript async function handleExport(ctx: ServerContext): Promise { 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 }, ); } } ``` Add import at top of `server.ts`: ```typescript import { exportState } from "./export.ts"; ``` The `/export` endpoint requires bearer auth (same as other endpoints — it exposes internal state). **Step 2: Add `--export` CLI mode to `src/main.ts`** Before the config loading block, check for CLI args: ```typescript // CLI mode: --export dumps state to stdout and exits if (Deno.args.includes("--export")) { const apiUrl = getCliArg("--netbird-api-url") ?? Deno.env.get("NETBIRD_API_URL"); const apiToken = getCliArg("--netbird-api-token") ?? Deno.env.get("NETBIRD_API_TOKEN"); if (!apiUrl || !apiToken) { console.error( "Usage: --export --netbird-api-url --netbird-api-token ", ); console.error("Or set NETBIRD_API_URL and NETBIRD_API_TOKEN env vars."); Deno.exit(1); } const client = new NetbirdClient(apiUrl, apiToken); const actual = await fetchActualState(client); const state = exportState(actual); console.log(JSON.stringify(state, null, 2)); Deno.exit(0); } function getCliArg(name: string): string | undefined { const idx = Deno.args.indexOf(name); if (idx === -1 || idx + 1 >= Deno.args.length) return undefined; return Deno.args[idx + 1]; } ``` Add imports to `main.ts`: ```typescript import { fetchActualState } from "./state/actual.ts"; import { exportState } from "./export.ts"; ``` This block must come BEFORE the `loadConfig()` call, since `--export` mode doesn't need the full config. **Step 3: Add integration test for `/export`** ```typescript Deno.test("GET /export returns exported state", async () => { // ... create handler with mock netbird that returns test data const resp = await handler( new Request("http://localhost/export", { method: "GET", headers: { "Authorization": `Bearer ${baseConfig.reconcilerToken}` }, }), ); assertEquals(resp.status, 200); const body = await resp.json(); assertEquals(body.status, "ok"); assertEquals(typeof body.state, "object"); assertEquals(typeof body.meta.exported_at, "string"); }); ``` **Step 4: Run tests** Run: `deno task check && deno task test` Expected: All pass. **Step 5: Commit** ``` feat: add GET /export endpoint and --export CLI mode for state export ``` --- ## Task 6: Update `.env.example` and Dockerfile Add `GITEA_ENABLED` to env example and ensure Dockerfile passes CLI args. **Files:** - Modify: `deploy/.env.example` - Modify: `Dockerfile` (no change needed — already passes env vars) **Step 1: Update `deploy/.env.example`** Add after `NETBIRD_API_TOKEN`: ``` # Set to false to run without Gitea integration (standalone mode) GITEA_ENABLED=true ``` **Step 2: Add a `deno.json` task for export** Add to `deno.json` tasks: ```json "export": "deno run --allow-net --allow-env src/main.ts --export" ``` **Step 3: Commit** ``` chore: add GITEA_ENABLED to .env.example, add export task to deno.json ``` --- ## Task 7: PoC Ansible — Inventory and Variables Create the Ansible structure for deploying the full stack to VPS-A. **Files:** - Create: `poc/ansible/inventory.yml` - Create: `poc/ansible/group_vars/all/vars.yml` - Create: `poc/ansible/group_vars/all/vault.yml.example` - Create: `poc/ansible/.gitignore` **Step 1: Create inventory** ```yaml --- all: children: poc_servers: hosts: netbird-poc-a: ansible_host: 46.225.220.61 ansible_ssh_private_key_file: ~/.ssh/id_ed25519 ansible_user: root ``` **Step 2: Create `vars.yml`** ```yaml --- # PoC Reconciler Validation — Non-secret configuration # Domain netbird_domain: vps-a.networkmonitor.cc # Versions netbird_version: "0.63.0" dashboard_version: "v2.27.1" caddy_version: "2.10.2" coturn_version: "4.8.0-r0" # Reconciler reconciler_image: "ghcr.io/blastpilot/netbird-reconciler:latest" reconciler_port: 8080 gitea_enabled: false # Standalone mode for initial testing # Gitea gitea_version: "1.23" gitea_http_port: 3000 gitea_ssh_port: 2222 gitea_admin_user: "blastpilot" gitea_org_name: "BlastPilot" gitea_repo_name: "netbird-gitops" # Directories base_dir: /opt/netbird-poc ``` **Step 3: Create `vault.yml.example`** ```yaml --- # Copy to vault.yml and fill in values. # This file is gitignored — do NOT commit real secrets. # Auto-generated at deploy time (leave empty, playbook generates them): vault_encryption_key: "" vault_turn_password: "" vault_relay_secret: "" # Reconciler auth token (generate with: openssl rand -hex 32) vault_reconciler_token: "" # Gitea admin password (for initial setup) vault_gitea_admin_password: "" ``` **Step 4: Create `.gitignore`** ``` group_vars/all/vault.yml ``` **Step 5: Commit** ``` feat: add PoC ansible inventory and variable files ``` --- ## Task 8: PoC Ansible — Templates Create all Docker Compose and config templates. Adapt from the existing PoC templates in `PoC/netbird-routing-docs-poc/ansible/netbird/templates/`. **Files:** - Create: `poc/ansible/templates/docker-compose.yml.j2` - Create: `poc/ansible/templates/management.json.j2` - Create: `poc/ansible/templates/Caddyfile.j2` - Create: `poc/ansible/templates/dashboard.env.j2` - Create: `poc/ansible/templates/relay.env.j2` - Create: `poc/ansible/templates/turnserver.conf.j2` - Create: `poc/ansible/templates/reconciler.env.j2` **Step 1: Create `docker-compose.yml.j2`** Extend the existing PoC template with Gitea and Reconciler services. All on a shared `netbird` network. Services: - `caddy` — reverse proxy (same as existing PoC) - `dashboard` — NetBird dashboard - `signal` — NetBird signal server - `relay` — NetBird relay - `management` — NetBird management API - `coturn` — STUN/TURN - `gitea` — Gitea (SQLite, minimal config) - `reconciler` — our reconciler service The reconciler image: for the PoC, build locally or use a pre-built image. Since there's no release yet, the playbook should build the Docker image on VPS-A from the repo. Alternative: copy the compiled binary. Simplest: mount the Deno binary from a pre-compiled artifact. Actually — simplest for a PoC: build the Docker image on VPS-A as part of the playbook. Clone the repo, `docker build`, done. The Dockerfile already works. **Step 2: Create all other templates** Copy from existing PoC (`management.json.j2`, `Caddyfile.j2`, `dashboard.env.j2`, `relay.env.j2`, `turnserver.conf.j2`) and adapt variable names. Add to `Caddyfile.j2` — route for reconciler: ``` # Reconciler API handle_path /reconciler/* { reverse_proxy reconciler:{{ reconciler_port }} } ``` Add to `Caddyfile.j2` — route for Gitea: ``` # Gitea handle_path /gitea/* { reverse_proxy gitea:{{ gitea_http_port }} } ``` Or better — serve Gitea on a subpath or a separate port. Gitea behind a subpath requires `ROOT_URL` config. Simplest: expose Gitea on port 3000 directly (not through Caddy) since it's a PoC. Create `reconciler.env.j2`: ``` NETBIRD_API_URL=https://{{ netbird_domain }}/api NETBIRD_API_TOKEN={{ vault_netbird_api_token }} RECONCILER_TOKEN={{ vault_reconciler_token }} GITEA_ENABLED={{ gitea_enabled }} {% if gitea_enabled | bool %} GITEA_URL=http://gitea:{{ gitea_http_port }} GITEA_TOKEN={{ vault_gitea_token }} GITEA_REPO={{ gitea_org_name }}/{{ gitea_repo_name }} {% endif %} POLL_INTERVAL_SECONDS=30 PORT={{ reconciler_port }} DATA_DIR=/data ``` **Step 3: Commit** ``` feat: add PoC ansible templates for full stack deployment ``` --- ## Task 9: PoC Ansible — Playbook The main playbook that orchestrates deployment. **Files:** - Create: `poc/ansible/playbook.yml` - Create: `poc/ansible/files/netbird-seed.json` **Step 1: Create seed state file** `poc/ansible/files/netbird-seed.json`: ```json { "groups": { "ground-stations": { "peers": [] }, "pilots": { "peers": [] } }, "setup_keys": { "GS-TestHawk-1": { "type": "one-off", "expires_in": 604800, "usage_limit": 1, "auto_groups": ["ground-stations"], "enrolled": false }, "Pilot-TestHawk-1": { "type": "one-off", "expires_in": 604800, "usage_limit": 1, "auto_groups": ["pilots"], "enrolled": false } }, "policies": { "pilots-to-gs": { "enabled": true, "sources": ["pilots"], "destinations": ["ground-stations"], "bidirectional": true } }, "routes": {}, "dns": { "nameserver_groups": {} } } ``` **Step 2: Create playbook** `poc/ansible/playbook.yml` — Task groups: 1. **Generate secrets** — encryption key, TURN password, relay secret, reconciler token (if not in vault.yml) 2. **Install Docker** — apt repo, docker-ce, docker-compose-plugin 3. **Create directories** — `{{ base_dir }}/{config,data,reconciler-data,gitea-data}` 4. **Template configs** — all `.j2` templates → `{{ base_dir }}/config/` 5. **Build reconciler image** — clone repo (or copy Dockerfile + src), `docker build` 6. **Start stack** — `docker compose up -d` 7. **Wait for health** — poll management + reconciler health endpoints 8. **Create NetBird admin** — first login to embedded IdP creates admin 9. **Generate NetBird API token** — via management API (may need manual step) 10. **(If Gitea enabled)** — Create admin user, org, repo, seed `netbird.json` 11. **Print summary** — URLs, credentials, next steps Note: generating a NetBird API token programmatically requires authenticating via the embedded IdP OAuth2 flow, which is non-trivial to automate. For the PoC, this will likely be a manual step: 1. Open dashboard, create admin account 2. Go to Settings → API Tokens → generate token 3. Put token in `vault.yml` as `vault_netbird_api_token` 4. Re-run playbook (or just restart reconciler container) The playbook should handle this gracefully — start the reconciler last, after the token is known. **Step 3: Commit** ``` feat: add PoC ansible playbook and seed state file ``` --- ## Task 10: PoC Ansible — README Document how to use the PoC. **Files:** - Create: `poc/README.md` **Step 1: Write README** Cover: - Prerequisites (SSH access to VPS-A, DNS records) - Setup steps (copy vault.yml.example, fill secrets) - Deploy command - Post-deploy steps (create NetBird admin, generate API token) - Testing steps (dry-run reconcile, apply, enrollment) - Teardown **Step 2: Commit** ``` docs: add PoC README with setup and testing instructions ``` --- ## Task 11: Run All Tests and Type Check Final verification before deployment. **Files:** None (verification only) **Step 1: Run full test suite** Run: `deno task check && deno task test && deno task lint` Expected: All pass. **Step 2: Run format check** Run: `deno task fmt --check` Expected: No formatting issues (or fix them). **Step 3: Commit any fixes** ``` chore: fix lint/format issues ``` --- ## Summary of Tasks | # | Task | Type | Est. | | -- | ------------------------------------- | ----------- | ------ | | 1 | `GITEA_ENABLED` feature flag — config | Code | 10 min | | 2 | Wire flag into main, server, poller | Code | 20 min | | 3 | Update integration tests | Test | 10 min | | 4 | State export module | Code + Test | 20 min | | 5 | Export endpoint + CLI flag | Code + Test | 15 min | | 6 | Update .env.example and deno.json | Config | 5 min | | 7 | PoC Ansible — inventory + vars | Ansible | 10 min | | 8 | PoC Ansible — templates | Ansible | 30 min | | 9 | PoC Ansible — playbook + seed | Ansible | 30 min | | 10 | PoC README | Docs | 10 min | | 11 | Final verification | Test | 5 min | **Total estimated: ~2.5 hours** Tasks 1-6 are the reconciler code changes (feature flag + export). Tasks 7-10 are the Ansible deployment. Task 11 is final verification. Tasks 1-6 must be done sequentially (each builds on the previous). Tasks 7-10 can be done in parallel with each other but after Tasks 1-6 (the Ansible templates reference the feature flag and export endpoint).