diff --git a/src/reconcile/executor.test.ts b/src/reconcile/executor.test.ts new file mode 100644 index 0000000..accc8a2 --- /dev/null +++ b/src/reconcile/executor.test.ts @@ -0,0 +1,228 @@ +import { assertEquals } from "@std/assert"; +import { executeOperations } from "./executor.ts"; +import type { Operation } from "./operations.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(), + }; +} + +Deno.test("executor calls createGroup for create_group op", async () => { + const calls: string[] = []; + const mockClient = { + createGroup: (data: { name: string }) => { + calls.push(`createGroup:${data.name}`); + return Promise.resolve({ + id: "new-g1", + name: data.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", name: "key1" }), + }; + 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 +}); + +Deno.test("executor tracks created group IDs for setup key auto_groups", async () => { + const calls: Array<{ method: string; data: unknown }> = []; + const mockClient = { + createGroup: (data: { name: string }) => { + calls.push({ method: "createGroup", data }); + return Promise.resolve({ + id: "new-g1", + name: data.name, + peers_count: 0, + peers: [], + issued: "api" as const, + }); + }, + createSetupKey: (data: Record) => { + calls.push({ method: "createSetupKey", data }); + return Promise.resolve({ + id: 1, + name: data.name, + key: "raw-key-123", + type: data.type, + expires: "2026-04-01T00:00:00Z", + valid: true, + revoked: false, + used_times: 0, + state: "valid" as const, + auto_groups: data.auto_groups, + usage_limit: data.usage_limit, + }); + }, + }; + const ops: Operation[] = [ + { type: "create_group", name: "pilots" }, + { + type: "create_setup_key", + name: "key1", + details: { + type: "one-off", + auto_groups: ["pilots"], + usage_limit: 1, + expires_in: 604800, + }, + }, + ]; + const { results, createdKeys } = await executeOperations( + ops, + mockClient as never, + emptyActual(), + ); + assertEquals(results.length, 2); + assertEquals(results[0].status, "success"); + assertEquals(results[1].status, "success"); + // The setup key call should have resolved "pilots" -> "new-g1" + const setupKeyCall = calls.find((c) => c.method === "createSetupKey"); + assertEquals( + (setupKeyCall?.data as Record).auto_groups, + ["new-g1"], + ); + // Created keys map stores the raw key + assertEquals(createdKeys.get("key1"), "raw-key-123"); +}); + +Deno.test("executor resolves group IDs from actual state", async () => { + const calls: Array<{ method: string; data: unknown }> = []; + const actual = emptyActual(); + actual.groupsByName.set("pilots", { + id: "existing-g1", + name: "pilots", + peers_count: 0, + peers: [], + issued: "api", + }); + + const mockClient = { + createSetupKey: (data: Record) => { + calls.push({ method: "createSetupKey", data }); + return Promise.resolve({ + id: 1, + name: data.name, + key: "raw-key-456", + type: data.type, + expires: "2026-04-01T00:00:00Z", + valid: true, + revoked: false, + used_times: 0, + state: "valid" as const, + auto_groups: data.auto_groups, + usage_limit: data.usage_limit, + }); + }, + }; + const ops: Operation[] = [ + { + type: "create_setup_key", + name: "key1", + details: { + type: "one-off", + auto_groups: ["pilots"], + usage_limit: 1, + expires_in: 604800, + }, + }, + ]; + const { results } = await executeOperations( + ops, + mockClient as never, + actual, + ); + assertEquals(results[0].status, "success"); + const setupKeyCall = calls.find((c) => c.method === "createSetupKey"); + assertEquals( + (setupKeyCall?.data as Record).auto_groups, + ["existing-g1"], + ); +}); + +Deno.test("executor deletes group by resolving ID from actual", async () => { + const calls: string[] = []; + const actual = emptyActual(); + actual.groupsByName.set("stale-group", { + id: "g-old", + name: "stale-group", + peers_count: 0, + peers: [], + issued: "api", + }); + + const mockClient = { + deleteGroup: (id: string) => { + calls.push(`deleteGroup:${id}`); + return Promise.resolve(); + }, + }; + const ops: Operation[] = [ + { type: "delete_group", name: "stale-group" }, + ]; + const { results } = await executeOperations( + ops, + mockClient as never, + actual, + ); + assertEquals(calls, ["deleteGroup:g-old"]); + assertEquals(results[0].status, "success"); +}); + +Deno.test("executor stores error message on failure", async () => { + const mockClient = { + createGroup: () => Promise.reject(new Error("rate limited")), + }; + const ops: Operation[] = [ + { type: "create_group", name: "pilots" }, + ]; + const { results } = await executeOperations( + ops, + mockClient as never, + emptyActual(), + ); + assertEquals(results[0].status, "failed"); + assertEquals(results[0].error, "rate limited"); +}); diff --git a/src/reconcile/executor.ts b/src/reconcile/executor.ts new file mode 100644 index 0000000..f080b2b --- /dev/null +++ b/src/reconcile/executor.ts @@ -0,0 +1,384 @@ +import type { NetbirdClient } from "../netbird/client.ts"; +import type { ActualState } from "../state/actual.ts"; +import type { Operation, OperationResult } from "./operations.ts"; + +export interface ExecutionResult { + results: OperationResult[]; + createdKeys: Map; +} + +/** + * Subset of NetbirdClient methods the executor actually calls. + * + * Using a structural pick keeps tests simple — callers can pass a partial + * mock that satisfies only the methods their operations need. + */ +type ExecutorClient = Pick< + NetbirdClient, + | "createGroup" + | "updateGroup" + | "deleteGroup" + | "createSetupKey" + | "deleteSetupKey" + | "updatePeer" + | "deletePeer" + | "createPolicy" + | "updatePolicy" + | "deletePolicy" + | "createRoute" + | "updateRoute" + | "deleteRoute" + | "createDnsNameserverGroup" + | "updateDnsNameserverGroup" + | "deleteDnsNameserverGroup" +>; + +/** + * Executes a list of operations against the NetBird API, aborting on the + * first failure. Resolves names to IDs using the provided actual state and + * tracks newly created resource IDs for cross-referencing within the same run. + * + * Returns both the per-operation results and a map of created setup key + * names to their raw key values (needed for enrollment output). + */ +export async function executeOperations( + ops: Operation[], + client: ExecutorClient, + actual: ActualState, +): Promise { + const results: OperationResult[] = []; + const createdGroupIds = new Map(); + const createdKeys = new Map(); + + function resolveGroupId(name: string): string { + const created = createdGroupIds.get(name); + if (created) return created; + const existing = actual.groupsByName.get(name); + if (existing) return existing.id; + throw new Error(`group "${name}" not found`); + } + + function resolveGroupIds(names: string[]): string[] { + return names.map(resolveGroupId); + } + + function resolvePeerIds(names: string[]): string[] { + return names.map((name) => { + const peer = actual.peersByName.get(name); + if (peer) return peer.id; + throw new Error(`peer "${name}" not found`); + }); + } + + for (const op of ops) { + try { + await executeSingle(op, client, actual, { + createdGroupIds, + createdKeys, + resolveGroupId, + resolveGroupIds, + resolvePeerIds, + }); + results.push({ ...op, status: "success" }); + } catch (err) { + results.push({ + ...op, + status: "failed", + error: err instanceof Error ? err.message : String(err), + }); + break; + } + } + + return { results, createdKeys }; +} + +// --------------------------------------------------------------------------- +// Internal dispatch +// --------------------------------------------------------------------------- + +interface ExecutorContext { + createdGroupIds: Map; + createdKeys: Map; + resolveGroupId: (name: string) => string; + resolveGroupIds: (names: string[]) => string[]; + resolvePeerIds: (names: string[]) => string[]; +} + +async function executeSingle( + op: Operation, + client: ExecutorClient, + actual: ActualState, + ctx: ExecutorContext, +): Promise { + const d = op.details ?? {}; + + switch (op.type) { + // ----- Groups ----- + case "create_group": { + const peerNames = d.peers as string[] | undefined; + const peerIds = peerNames?.length ? ctx.resolvePeerIds(peerNames) : []; + const group = await client.createGroup({ + name: op.name, + peers: peerIds, + }); + ctx.createdGroupIds.set(op.name, group.id); + break; + } + case "update_group": { + const existing = actual.groupsByName.get(op.name); + if (!existing) throw new Error(`group "${op.name}" not found for update`); + const desiredPeers = d.desired_peers as string[] | undefined; + const peerIds = desiredPeers?.length + ? ctx.resolvePeerIds(desiredPeers) + : []; + await client.updateGroup(existing.id, { + name: op.name, + peers: peerIds, + }); + break; + } + case "delete_group": { + const existing = actual.groupsByName.get(op.name); + if (!existing) { + throw new Error(`group "${op.name}" not found for delete`); + } + await client.deleteGroup(existing.id); + break; + } + + // ----- Setup Keys ----- + case "create_setup_key": { + const autoGroupNames = d.auto_groups as string[] | undefined; + const autoGroupIds = autoGroupNames?.length + ? ctx.resolveGroupIds(autoGroupNames) + : []; + const key = await client.createSetupKey({ + name: op.name, + type: (d.type as "one-off" | "reusable") ?? "one-off", + expires_in: (d.expires_in as number) ?? 604800, + auto_groups: autoGroupIds, + usage_limit: d.usage_limit as number | undefined, + }); + ctx.createdKeys.set(op.name, key.key); + break; + } + case "delete_setup_key": { + const existing = actual.setupKeysByName.get(op.name); + if (!existing) { + throw new Error(`setup key "${op.name}" not found for delete`); + } + await client.deleteSetupKey(existing.id); + break; + } + + // ----- Peers ----- + case "rename_peer": { + const peerId = d.id as string; + if (!peerId) throw new Error(`rename_peer missing details.id`); + await client.updatePeer(peerId, { name: op.name }); + break; + } + case "update_peer_groups": { + // This op type updates peer-level properties; details.id is the peer ID + const peerId = d.id as string; + if (!peerId) throw new Error(`update_peer_groups missing details.id`); + await client.updatePeer(peerId, { + name: d.name as string | undefined, + ssh_enabled: d.ssh_enabled as boolean | undefined, + login_expiration_enabled: d.login_expiration_enabled as + | boolean + | undefined, + }); + break; + } + case "delete_peer": { + const peer = actual.peersByName.get(op.name); + if (!peer) throw new Error(`peer "${op.name}" not found for delete`); + await client.deletePeer(peer.id); + break; + } + + // ----- Policies ----- + case "create_policy": { + const sourceIds = ctx.resolveGroupIds(d.sources as string[] ?? []); + const destIds = ctx.resolveGroupIds(d.destinations as string[] ?? []); + await client.createPolicy({ + name: op.name, + description: (d.description as string) ?? "", + enabled: (d.enabled as boolean) ?? true, + rules: [ + { + name: op.name, + description: (d.description as string) ?? "", + enabled: (d.enabled as boolean) ?? true, + action: (d.action as "accept" | "drop") ?? "accept", + bidirectional: (d.bidirectional as boolean) ?? true, + protocol: (d.protocol as "tcp" | "udp" | "icmp" | "all") ?? "all", + ports: d.ports as string[] | undefined, + sources: sourceIds, + destinations: destIds, + }, + ], + }); + break; + } + case "update_policy": { + const existing = actual.policiesByName.get(op.name); + if (!existing) { + throw new Error(`policy "${op.name}" not found for update`); + } + const sourceIds = ctx.resolveGroupIds(d.sources as string[] ?? []); + const destIds = ctx.resolveGroupIds(d.destinations as string[] ?? []); + await client.updatePolicy(existing.id, { + name: op.name, + description: (d.description as string) ?? existing.description, + enabled: (d.enabled as boolean) ?? existing.enabled, + rules: [ + { + name: op.name, + description: (d.description as string) ?? existing.description, + enabled: (d.enabled as boolean) ?? existing.enabled, + action: (d.action as "accept" | "drop") ?? "accept", + bidirectional: (d.bidirectional as boolean) ?? true, + protocol: (d.protocol as "tcp" | "udp" | "icmp" | "all") ?? "all", + ports: d.ports as string[] | undefined, + sources: sourceIds, + destinations: destIds, + }, + ], + }); + break; + } + case "delete_policy": { + const existing = actual.policiesByName.get(op.name); + if (!existing) { + throw new Error(`policy "${op.name}" not found for delete`); + } + await client.deletePolicy(existing.id); + break; + } + + // ----- Routes ----- + case "create_route": { + const peerGroupIds = d.peer_groups + ? ctx.resolveGroupIds(d.peer_groups as string[]) + : undefined; + const distGroupIds = d.distribution_groups + ? ctx.resolveGroupIds(d.distribution_groups as string[]) + : []; + await client.createRoute({ + network_id: op.name, + description: (d.description as string) ?? "", + enabled: (d.enabled as boolean) ?? true, + network: d.network as string | undefined, + domains: d.domains as string[] | undefined, + peer: d.peer as string | undefined, + peer_groups: peerGroupIds, + 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 existing = actual.routesByNetworkId.get(op.name); + if (!existing) { + throw new Error(`route "${op.name}" not found for update`); + } + const peerGroupIds = d.peer_groups + ? ctx.resolveGroupIds(d.peer_groups as string[]) + : existing.peer_groups; + const distGroupIds = d.distribution_groups + ? ctx.resolveGroupIds(d.distribution_groups as string[]) + : existing.groups; + await client.updateRoute(existing.id, { + network_id: op.name, + description: (d.description as string) ?? existing.description, + enabled: (d.enabled as boolean) ?? existing.enabled, + network: (d.network as string | undefined) ?? existing.network, + domains: (d.domains as string[] | undefined) ?? existing.domains, + peer: (d.peer as string | undefined) ?? existing.peer, + peer_groups: peerGroupIds, + metric: (d.metric as number) ?? existing.metric, + masquerade: (d.masquerade as boolean) ?? existing.masquerade, + groups: distGroupIds, + keep_route: (d.keep_route as boolean) ?? existing.keep_route, + }); + break; + } + case "delete_route": { + const existing = actual.routesByNetworkId.get(op.name); + if (!existing) { + throw new Error(`route "${op.name}" not found for delete`); + } + await client.deleteRoute(existing.id); + break; + } + + // ----- DNS Nameserver Groups ----- + case "create_dns": { + const groupIds = d.groups + ? ctx.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) ?? true, + groups: groupIds, + primary: (d.primary as boolean) ?? false, + domains: (d.domains as string[]) ?? [], + search_domains_enabled: (d.search_domains_enabled as boolean) ?? false, + }); + break; + } + case "update_dns": { + const existing = actual.dnsByName.get(op.name); + if (!existing) { + throw new Error(`dns nameserver group "${op.name}" not found for update`); + } + const groupIds = d.groups + ? ctx.resolveGroupIds(d.groups as string[]) + : existing.groups; + await client.updateDnsNameserverGroup(existing.id, { + name: op.name, + description: (d.description as string) ?? existing.description, + nameservers: (d.nameservers as Array<{ + ip: string; + ns_type: string; + port: number; + }>) ?? existing.nameservers, + enabled: (d.enabled as boolean) ?? existing.enabled, + groups: groupIds, + primary: (d.primary as boolean) ?? existing.primary, + domains: (d.domains as string[]) ?? existing.domains, + search_domains_enabled: (d.search_domains_enabled as boolean) ?? + existing.search_domains_enabled, + }); + break; + } + case "delete_dns": { + const existing = actual.dnsByName.get(op.name); + if (!existing) { + throw new Error( + `dns nameserver group "${op.name}" not found for delete`, + ); + } + await client.deleteDnsNameserverGroup(existing.id); + break; + } + + default: { + // Exhaustiveness check — if a new OperationType is added but not + // handled here, TypeScript will flag it at compile time. + const _exhaustive: never = op.type; + throw new Error(`unknown operation type: ${_exhaustive}`); + } + } +}