From 9742807f91d4c6e98bdfcfefc7f06db07e84b1ab Mon Sep 17 00:00:00 2001 From: Prox Date: Wed, 4 Mar 2026 00:07:23 +0200 Subject: [PATCH] feat: add actual state fetcher with name/ID indexing --- src/state/actual.test.ts | 125 +++++++++++++++++++++++++++++++++++++++ src/state/actual.ts | 78 ++++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 src/state/actual.test.ts create mode 100644 src/state/actual.ts diff --git a/src/state/actual.test.ts b/src/state/actual.test.ts new file mode 100644 index 0000000..926fdb6 --- /dev/null +++ b/src/state/actual.test.ts @@ -0,0 +1,125 @@ +import { assertEquals } from "@std/assert"; +import { fetchActualState } from "./actual.ts"; +import type { + NbDnsNameserverGroup, + NbGroup, + NbPeer, + NbPolicy, + NbRoute, + NbSetupKey, +} 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 ?? []), + }; +} + +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); +}); + +Deno.test("fetchActualState returns empty maps for empty input", async () => { + const actual = await fetchActualState(mockClient({})); + assertEquals(actual.groups.length, 0); + assertEquals(actual.groupsByName.size, 0); + assertEquals(actual.groupsById.size, 0); + assertEquals(actual.setupKeys.length, 0); + assertEquals(actual.peers.length, 0); + assertEquals(actual.policies.length, 0); + assertEquals(actual.routes.length, 0); + assertEquals(actual.dns.length, 0); +}); + +Deno.test("fetchActualState indexes all resource types", async () => { + const actual = await fetchActualState( + mockClient({ + groups: [ + { id: "g1", name: "ops", peers_count: 1, peers: [{ id: "p1", name: "drone-1" }], issued: "api" }, + ], + peers: [ + { + id: "p1", name: "drone-1", ip: "100.64.0.1", connected: true, + hostname: "drone-1", os: "linux", version: "0.28.0", + groups: [{ id: "g1", name: "ops" }], last_seen: "2026-03-01T00:00:00Z", + dns_label: "drone-1", login_expiration_enabled: false, + ssh_enabled: false, inactivity_expiration_enabled: false, + }, + ], + policies: [ + { + id: "pol1", name: "allow-ops", description: "ops traffic", + enabled: true, rules: [], + }, + ], + routes: [ + { + id: "r1", description: "lan", network_id: "lan-net", + enabled: true, network: "10.0.0.0/24", metric: 100, + masquerade: true, groups: ["g1"], keep_route: false, + }, + ], + dns: [ + { + id: "d1", name: "internal-dns", description: "internal", + nameservers: [{ ip: "1.1.1.1", ns_type: "udp", port: 53 }], + enabled: true, groups: ["g1"], primary: true, + domains: ["internal."], search_domains_enabled: false, + }, + ], + }), + ); + + // Groups indexed both ways + assertEquals(actual.groupsByName.get("ops")?.id, "g1"); + assertEquals(actual.groupsById.get("g1")?.name, "ops"); + + // Peers indexed both ways + assertEquals(actual.peersByName.get("drone-1")?.id, "p1"); + assertEquals(actual.peersById.get("p1")?.name, "drone-1"); + + // Policies by name + assertEquals(actual.policiesByName.get("allow-ops")?.id, "pol1"); + + // Routes by network_id + assertEquals(actual.routesByNetworkId.get("lan-net")?.id, "r1"); + + // DNS by name + assertEquals(actual.dnsByName.get("internal-dns")?.id, "d1"); +}); diff --git a/src/state/actual.ts b/src/state/actual.ts new file mode 100644 index 0000000..d4121a6 --- /dev/null +++ b/src/state/actual.ts @@ -0,0 +1,78 @@ +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; + groupsById: Map; + setupKeys: NbSetupKey[]; + setupKeysByName: Map; + peers: NbPeer[]; + peersByName: Map; + peersById: Map; + policies: NbPolicy[]; + policiesByName: Map; + routes: NbRoute[]; + routesByNetworkId: Map; + dns: NbDnsNameserverGroup[]; + dnsByName: Map; +} + +/** + * Subset of NetbirdClient needed for fetching state. + * + * Using a structural pick rather than the full class keeps this module + * testable with plain object mocks and avoids pulling in fetch/auth deps. + */ +type ClientLike = Pick< + NetbirdClient, + | "listGroups" + | "listSetupKeys" + | "listPeers" + | "listPolicies" + | "listRoutes" + | "listDnsNameserverGroups" +>; + +/** + * Fetches all resource collections from the NetBird API in parallel and + * returns them with bidirectional name<->ID indexes for O(1) lookup + * during diff/reconciliation. + */ +export async function fetchActualState( + client: ClientLike, +): Promise { + 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])), + }; +}