diff --git a/src/reconcile/diff.test.ts b/src/reconcile/diff.test.ts new file mode 100644 index 0000000..a6b97a3 --- /dev/null +++ b/src/reconcile/diff.test.ts @@ -0,0 +1,346 @@ +import { assertEquals } from "@std/assert"; +import { computeDiff } from "./diff.ts"; +import type { DesiredState } from "../state/schema.ts"; +import type { ActualState } from "../state/actual.ts"; +import type { NbGroup, NbSetupKey } from "../netbird/types.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(); + + const group: NbGroup = { + id: "g1", + name: "pilots", + peers_count: 1, + peers: [{ id: "p1", name: "Pilot-hawk-72" }], + issued: "api", + }; + actual.groupsByName.set("pilots", group); + actual.groups = [group]; + + const key: NbSetupKey = { + 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.setupKeysByName.set("Pilot-hawk-72", key); + actual.setupKeys = [key]; + + const ops = computeDiff(DESIRED, actual); + assertEquals(ops.length, 0); +}); + +Deno.test("computeDiff does not delete system groups", () => { + const desired: DesiredState = { + groups: {}, + setup_keys: {}, + policies: {}, + routes: {}, + dns: { nameserver_groups: {} }, + }; + const actual = emptyActual(); + + const jwtGroup: NbGroup = { + id: "g-jwt", + name: "All", + peers_count: 5, + peers: [], + issued: "jwt", + }; + actual.groupsByName.set("All", jwtGroup); + actual.groups = [jwtGroup]; + + const ops = computeDiff(desired, actual); + assertEquals(ops.length, 0); +}); + +Deno.test("computeDiff deletes api-issued groups not in desired", () => { + const desired: DesiredState = { + groups: {}, + setup_keys: {}, + policies: {}, + routes: {}, + dns: { nameserver_groups: {} }, + }; + const actual = emptyActual(); + + const staleGroup: NbGroup = { + id: "g-old", + name: "stale-group", + peers_count: 0, + peers: [], + issued: "api", + }; + actual.groupsByName.set("stale-group", staleGroup); + actual.groups = [staleGroup]; + + const ops = computeDiff(desired, actual); + assertEquals(ops.length, 1); + assertEquals(ops[0].type, "delete_group"); + assertEquals(ops[0].name, "stale-group"); +}); + +Deno.test("computeDiff detects group peer membership change", () => { + const actual = emptyActual(); + + const group: NbGroup = { + id: "g1", + name: "pilots", + peers_count: 0, + peers: [], // No peers currently + issued: "api", + }; + actual.groupsByName.set("pilots", group); + actual.groups = [group]; + + // Desired has a peer in the group, actual has none + const desired: DesiredState = { + groups: { pilots: { peers: ["Pilot-hawk-72"] } }, + setup_keys: {}, + policies: {}, + routes: {}, + dns: { nameserver_groups: {} }, + }; + const ops = computeDiff(desired, actual); + const updateOps = ops.filter((o) => o.type === "update_group"); + assertEquals(updateOps.length, 1); + assertEquals(updateOps[0].name, "pilots"); +}); + +Deno.test("computeDiff skips enrolled setup keys", () => { + const desired: DesiredState = { + groups: {}, + setup_keys: { + "Already-enrolled": { + type: "one-off", + expires_in: 604800, + usage_limit: 1, + auto_groups: [], + enrolled: true, + }, + }, + policies: {}, + routes: {}, + dns: { nameserver_groups: {} }, + }; + const ops = computeDiff(desired, emptyActual()); + const createKeyOps = ops.filter((o) => o.type === "create_setup_key"); + assertEquals(createKeyOps.length, 0); +}); + +Deno.test("computeDiff creates policy when not in actual", () => { + const desired: DesiredState = { + groups: {}, + setup_keys: {}, + policies: { + "allow-pilots": { + description: "Allow pilot traffic", + enabled: true, + sources: ["pilots"], + destinations: ["pilots"], + bidirectional: true, + protocol: "all", + action: "accept", + }, + }, + routes: {}, + dns: { nameserver_groups: {} }, + }; + const ops = computeDiff(desired, emptyActual()); + const policyOps = ops.filter((o) => o.type === "create_policy"); + assertEquals(policyOps.length, 1); + assertEquals(policyOps[0].name, "allow-pilots"); +}); + +Deno.test("computeDiff detects policy enabled change", () => { + const actual = emptyActual(); + + const group: NbGroup = { + id: "g1", + name: "pilots", + peers_count: 0, + peers: [], + issued: "api", + }; + actual.groupsByName.set("pilots", group); + actual.groupsById.set("g1", group); + actual.groups = [group]; + + actual.policiesByName.set("allow-pilots", { + id: "pol-1", + name: "allow-pilots", + description: "Allow pilot traffic", + enabled: true, // currently enabled + rules: [{ + name: "allow-pilots", + description: "", + enabled: true, + action: "accept", + bidirectional: true, + protocol: "all", + sources: [{ id: "g1", name: "pilots" }], + destinations: [{ id: "g1", name: "pilots" }], + }], + }); + actual.policies = [actual.policiesByName.get("allow-pilots")!]; + + const desired: DesiredState = { + groups: { pilots: { peers: [] } }, + setup_keys: {}, + policies: { + "allow-pilots": { + description: "Allow pilot traffic", + enabled: false, // desired: disabled + sources: ["pilots"], + destinations: ["pilots"], + bidirectional: true, + protocol: "all", + action: "accept", + }, + }, + routes: {}, + dns: { nameserver_groups: {} }, + }; + const ops = computeDiff(desired, actual); + const updateOps = ops.filter((o) => o.type === "update_policy"); + assertEquals(updateOps.length, 1); + assertEquals(updateOps[0].name, "allow-pilots"); +}); + +Deno.test("computeDiff creates route when not in actual", () => { + const desired: DesiredState = { + groups: {}, + setup_keys: {}, + policies: {}, + routes: { + "vpn-exit": { + description: "VPN exit route", + network: "0.0.0.0/0", + peer_groups: ["pilots"], + metric: 9999, + masquerade: true, + distribution_groups: ["pilots"], + enabled: true, + keep_route: true, + }, + }, + dns: { nameserver_groups: {} }, + }; + const ops = computeDiff(desired, emptyActual()); + const routeOps = ops.filter((o) => o.type === "create_route"); + assertEquals(routeOps.length, 1); + assertEquals(routeOps[0].name, "vpn-exit"); +}); + +Deno.test("computeDiff creates dns when not in actual", () => { + const desired: DesiredState = { + groups: {}, + setup_keys: {}, + policies: {}, + routes: {}, + dns: { + nameserver_groups: { + "cloudflare": { + description: "Cloudflare DNS", + nameservers: [{ ip: "1.1.1.1", ns_type: "udp", port: 53 }], + enabled: true, + groups: ["pilots"], + primary: true, + domains: [], + search_domains_enabled: false, + }, + }, + }, + }; + const ops = computeDiff(desired, emptyActual()); + const dnsOps = ops.filter((o) => o.type === "create_dns"); + assertEquals(dnsOps.length, 1); + assertEquals(dnsOps[0].name, "cloudflare"); +}); + +Deno.test("computeDiff operations are sorted by EXECUTION_ORDER", () => { + // Desired state that produces creates for multiple resource types + const desired: DesiredState = { + groups: { pilots: { peers: [] } }, + setup_keys: { + "new-key": { + type: "one-off", + expires_in: 604800, + usage_limit: 1, + auto_groups: ["pilots"], + enrolled: false, + }, + }, + policies: { + "test-policy": { + description: "", + enabled: true, + sources: ["pilots"], + destinations: ["pilots"], + bidirectional: true, + protocol: "all", + action: "accept", + }, + }, + routes: {}, + dns: { nameserver_groups: {} }, + }; + const ops = computeDiff(desired, emptyActual()); + + // create_group must come before create_setup_key, which must come before + // create_policy — matching EXECUTION_ORDER + const groupIdx = ops.findIndex((o) => o.type === "create_group"); + const keyIdx = ops.findIndex((o) => o.type === "create_setup_key"); + const policyIdx = ops.findIndex((o) => o.type === "create_policy"); + assertEquals(groupIdx < keyIdx, true); + assertEquals(keyIdx < policyIdx, true); +}); diff --git a/src/reconcile/diff.ts b/src/reconcile/diff.ts new file mode 100644 index 0000000..a866964 --- /dev/null +++ b/src/reconcile/diff.ts @@ -0,0 +1,342 @@ +import type { DesiredState } from "../state/schema.ts"; +import type { ActualState } from "../state/actual.ts"; +import type { NbPolicyRule } from "../netbird/types.ts"; +import { EXECUTION_ORDER, type Operation } from "./operations.ts"; + +/** + * Compares desired state against actual state and returns an ordered list of + * operations needed to reconcile the two. Operations are sorted by + * EXECUTION_ORDER so that creates happen before updates, and deletions happen + * in reverse dependency order. + */ +export function computeDiff( + desired: DesiredState, + actual: ActualState, +): Operation[] { + const ops: Operation[] = []; + + diffGroups(desired, actual, ops); + diffSetupKeys(desired, actual, ops); + diffPolicies(desired, actual, ops); + diffRoutes(desired, actual, ops); + diffDns(desired, actual, ops); + + return sortByExecutionOrder(ops); +} + +// --------------------------------------------------------------------------- +// Groups +// --------------------------------------------------------------------------- + +function diffGroups( + desired: DesiredState, + actual: ActualState, + ops: Operation[], +): void { + const desiredNames = new Set(Object.keys(desired.groups)); + + for (const [name, config] of Object.entries(desired.groups)) { + const existing = actual.groupsByName.get(name); + if (!existing) { + ops.push({ + type: "create_group", + name, + details: { peers: config.peers }, + }); + continue; + } + + // Compare peer membership by name (sorted for stable comparison) + const actualPeerNames = existing.peers.map((p) => p.name).sort(); + const desiredPeerNames = [...config.peers].sort(); + if (!arraysEqual(actualPeerNames, desiredPeerNames)) { + ops.push({ + type: "update_group", + name, + details: { + desired_peers: desiredPeerNames, + actual_peers: actualPeerNames, + }, + }); + } + } + + // Delete groups that exist in actual but not in desired. + // Only delete API-issued groups — system and JWT groups are managed externally. + for (const group of actual.groups) { + if (!desiredNames.has(group.name) && group.issued === "api") { + ops.push({ type: "delete_group", name: group.name }); + } + } +} + +// --------------------------------------------------------------------------- +// Setup Keys +// --------------------------------------------------------------------------- + +function diffSetupKeys( + desired: DesiredState, + actual: ActualState, + ops: Operation[], +): void { + const desiredNames = new Set(Object.keys(desired.setup_keys)); + + for (const [name, config] of Object.entries(desired.setup_keys)) { + const existing = actual.setupKeysByName.get(name); + if (!existing && !config.enrolled) { + ops.push({ + type: "create_setup_key", + name, + details: { + type: config.type, + auto_groups: config.auto_groups, + usage_limit: config.usage_limit, + expires_in: config.expires_in, + }, + }); + } + // Setup keys are immutable — no update path. + } + + // Delete keys that exist in actual but not in desired. + for (const key of actual.setupKeys) { + if (!desiredNames.has(key.name)) { + ops.push({ type: "delete_setup_key", name: key.name }); + } + } +} + +// --------------------------------------------------------------------------- +// Policies +// --------------------------------------------------------------------------- + +function diffPolicies( + desired: DesiredState, + actual: ActualState, + ops: Operation[], +): void { + const desiredNames = new Set(Object.keys(desired.policies)); + + for (const [name, config] of Object.entries(desired.policies)) { + const existing = actual.policiesByName.get(name); + if (!existing) { + ops.push({ + type: "create_policy", + name, + details: { + enabled: config.enabled, + sources: config.sources, + destinations: config.destinations, + }, + }); + continue; + } + + // Extract group names from actual rules for comparison. + // A policy may have multiple rules; aggregate sources/destinations + // across all rules for a flat comparison against the desired config. + const actualSources = extractGroupNames( + existing.rules.flatMap((r) => r.sources), + actual, + ).sort(); + const actualDests = extractGroupNames( + existing.rules.flatMap((r) => r.destinations), + actual, + ).sort(); + const desiredSources = [...config.sources].sort(); + const desiredDests = [...config.destinations].sort(); + + if ( + existing.enabled !== config.enabled || + !arraysEqual(actualSources, desiredSources) || + !arraysEqual(actualDests, desiredDests) + ) { + ops.push({ + type: "update_policy", + name, + details: { + enabled: config.enabled, + sources: config.sources, + destinations: config.destinations, + }, + }); + } + } + + for (const policy of actual.policies) { + if (!desiredNames.has(policy.name)) { + ops.push({ type: "delete_policy", name: policy.name }); + } + } +} + +/** + * Policy rule sources/destinations can be either plain group ID strings or + * `{id, name}` objects. This helper normalizes them to group names, falling + * back to the ID if the group is unknown (defensive). + */ +function extractGroupNames( + refs: NbPolicyRule["sources"], + actual: ActualState, +): string[] { + return refs.map((ref) => { + if (typeof ref === "object" && ref !== null) { + return ref.name; + } + // Plain string — it's a group ID. Look up the name. + const group = actual.groupsById.get(ref); + return group ? group.name : ref; + }); +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +function diffRoutes( + desired: DesiredState, + actual: ActualState, + ops: Operation[], +): void { + const desiredIds = new Set(Object.keys(desired.routes)); + + for (const [networkId, config] of Object.entries(desired.routes)) { + const existing = actual.routesByNetworkId.get(networkId); + if (!existing) { + ops.push({ + type: "create_route", + name: networkId, + details: { + network: config.network, + domains: config.domains, + enabled: config.enabled, + description: config.description, + }, + }); + continue; + } + + if ( + existing.enabled !== config.enabled || + existing.description !== config.description || + existing.network !== config.network + ) { + ops.push({ + type: "update_route", + name: networkId, + details: { + enabled: config.enabled, + description: config.description, + network: config.network, + }, + }); + } + } + + for (const route of actual.routes) { + if (!desiredIds.has(route.network_id)) { + ops.push({ type: "delete_route", name: route.network_id }); + } + } +} + +// --------------------------------------------------------------------------- +// DNS Nameserver Groups +// --------------------------------------------------------------------------- + +function diffDns( + desired: DesiredState, + actual: ActualState, + ops: Operation[], +): void { + const desiredNames = new Set( + Object.keys(desired.dns.nameserver_groups), + ); + + for ( + const [name, config] of Object.entries(desired.dns.nameserver_groups) + ) { + const existing = actual.dnsByName.get(name); + if (!existing) { + ops.push({ + type: "create_dns", + name, + details: { + enabled: config.enabled, + primary: config.primary, + nameservers: config.nameservers, + }, + }); + continue; + } + + const nsChanged = !nameserversEqual( + existing.nameservers, + config.nameservers, + ); + + if ( + existing.enabled !== config.enabled || + existing.primary !== config.primary || + nsChanged + ) { + ops.push({ + type: "update_dns", + name, + details: { + enabled: config.enabled, + primary: config.primary, + nameservers: config.nameservers, + }, + }); + } + } + + for (const ns of actual.dns) { + if (!desiredNames.has(ns.name)) { + ops.push({ type: "delete_dns", name: ns.name }); + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function arraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +/** + * Deep-compares two nameserver arrays by ip, ns_type, and port. + * Order-sensitive — the API preserves insertion order. + */ +function nameserversEqual( + a: Array<{ ip: string; ns_type: string; port: number }>, + b: Array<{ ip: string; ns_type: string; port: number }>, +): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if ( + a[i].ip !== b[i].ip || + a[i].ns_type !== b[i].ns_type || + a[i].port !== b[i].port + ) { + return false; + } + } + return true; +} + +function sortByExecutionOrder(ops: Operation[]): Operation[] { + const orderIndex = new Map(EXECUTION_ORDER.map((t, i) => [t, i])); + return ops.sort((a, b) => { + const ai = orderIndex.get(a.type) ?? Number.MAX_SAFE_INTEGER; + const bi = orderIndex.get(b.type) ?? Number.MAX_SAFE_INTEGER; + return ai - bi; + }); +} diff --git a/src/reconcile/operations.ts b/src/reconcile/operations.ts new file mode 100644 index 0000000..639a70b --- /dev/null +++ b/src/reconcile/operations.ts @@ -0,0 +1,51 @@ +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; +} + +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_group", + "update_group", + "create_setup_key", + "rename_peer", + "update_peer_groups", + "create_policy", + "update_policy", + "create_route", + "update_route", + "create_dns", + "update_dns", + // Deletions in reverse dependency order + "delete_dns", + "delete_route", + "delete_policy", + "delete_peer", + "delete_setup_key", + "delete_group", +];