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[] = []; diffPostureChecks(desired, actual, ops); diffGroups(desired, actual, ops); diffSetupKeys(desired, actual, ops); diffNetworks(desired, actual, ops); diffPeers(desired, actual, ops); diffUsers(desired, actual, ops); diffPolicies(desired, actual, ops); diffRoutes(desired, actual, ops); diffDns(desired, actual, ops); return sortByExecutionOrder(ops); } // --------------------------------------------------------------------------- // Posture Checks // --------------------------------------------------------------------------- function diffPostureChecks( desired: DesiredState, actual: ActualState, ops: Operation[], ): void { const desiredNames = new Set(Object.keys(desired.posture_checks)); for (const [name, config] of Object.entries(desired.posture_checks)) { const existing = actual.postureChecksByName.get(name); if (!existing) { ops.push({ type: "create_posture_check", name, details: { description: config.description, checks: config.checks, }, }); continue; } if ( existing.description !== config.description || JSON.stringify(existing.checks) !== JSON.stringify(config.checks) ) { ops.push({ type: "update_posture_check", name, details: { description: config.description, checks: config.checks, }, }); } } for (const pc of actual.postureChecks) { if (!desiredNames.has(pc.name)) { ops.push({ type: "delete_posture_check", name: pc.name }); } } } // --------------------------------------------------------------------------- // 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" && group.name !== "All" ) { 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 }); } } } // --------------------------------------------------------------------------- // Networks (including resources and routers) // --------------------------------------------------------------------------- function diffNetworks( desired: DesiredState, actual: ActualState, ops: Operation[], ): void { const desiredNames = new Set(Object.keys(desired.networks)); for (const [name, config] of Object.entries(desired.networks)) { const existing = actual.networksByName.get(name); if (!existing) { ops.push({ type: "create_network", name, details: { description: config.description }, }); // All resources and routers under a new network are creates for (const res of config.resources) { ops.push({ type: "create_network_resource", name: res.name, details: { network_name: name, description: res.description, type: res.type, address: res.address, enabled: res.enabled, groups: res.groups, }, }); } for (const router of config.routers) { ops.push({ type: "create_network_router", name: routerKey(router), details: { network_name: name, peer: router.peer, peer_groups: router.peer_groups, metric: router.metric, masquerade: router.masquerade, enabled: router.enabled, }, }); } continue; } // Network exists — check for description change if (existing.description !== config.description) { ops.push({ type: "update_network", name, details: { description: config.description }, }); } // Diff resources within this network const actualResources = actual.networkResources.get(existing.id) ?? []; diffNetworkResources(name, config.resources, actualResources, actual, ops); // Diff routers within this network const actualRouters = actual.networkRouters.get(existing.id) ?? []; diffNetworkRouters(name, config.routers, actualRouters, actual, ops); } // Delete networks not in desired (this also implicitly removes their resources/routers) for (const network of actual.networks) { if (!desiredNames.has(network.name)) { // Delete routers and resources first (execution order handles this, // but we still emit the ops) const routers = actual.networkRouters.get(network.id) ?? []; for (const router of routers) { ops.push({ type: "delete_network_router", name: actualRouterKey(router, actual), details: { network_name: network.name, router_id: router.id }, }); } const resources = actual.networkResources.get(network.id) ?? []; for (const res of resources) { ops.push({ type: "delete_network_resource", name: res.name, details: { network_name: network.name, resource_id: res.id }, }); } ops.push({ type: "delete_network", name: network.name }); } } } function diffNetworkResources( networkName: string, desiredResources: DesiredState["networks"][string]["resources"], actualResources: ActualState["networkResources"] extends Map< string, infer V > ? V : never, actual: ActualState, ops: Operation[], ): void { const actualByName = new Map(actualResources.map((r) => [r.name, r])); const desiredNames = new Set(desiredResources.map((r) => r.name)); for (const res of desiredResources) { const existing = actualByName.get(res.name); if (!existing) { ops.push({ type: "create_network_resource", name: res.name, details: { network_name: networkName, description: res.description, type: res.type, address: res.address, enabled: res.enabled, groups: res.groups, }, }); continue; } // Compare fields: resolve actual group names for comparison const actualGroupNames = existing.groups.map((g) => g.name).sort(); const desiredGroupNames = [...res.groups].sort(); if ( existing.description !== res.description || existing.type !== res.type || existing.address !== res.address || existing.enabled !== res.enabled || !arraysEqual(actualGroupNames, desiredGroupNames) ) { ops.push({ type: "update_network_resource", name: res.name, details: { network_name: networkName, resource_id: existing.id, description: res.description, type: res.type, address: res.address, enabled: res.enabled, groups: res.groups, }, }); } } // Delete resources not in desired for (const res of actualResources) { if (!desiredNames.has(res.name)) { ops.push({ type: "delete_network_resource", name: res.name, details: { network_name: networkName, resource_id: res.id }, }); } } } function diffNetworkRouters( networkName: string, desiredRouters: DesiredState["networks"][string]["routers"], actualRouters: ActualState["networkRouters"] extends Map ? V : never, actual: ActualState, ops: Operation[], ): void { // Match routers by their key (peer name or serialized peer_groups) const actualByKey = new Map( actualRouters.map((r) => [actualRouterKey(r, actual), r]), ); const desiredKeys = new Set(desiredRouters.map((r) => routerKey(r))); for (const router of desiredRouters) { const key = routerKey(router); const existing = actualByKey.get(key); if (!existing) { ops.push({ type: "create_network_router", name: key, details: { network_name: networkName, peer: router.peer, peer_groups: router.peer_groups, metric: router.metric, masquerade: router.masquerade, enabled: router.enabled, }, }); continue; } // Compare mutable fields if ( existing.metric !== router.metric || existing.masquerade !== router.masquerade || existing.enabled !== router.enabled ) { ops.push({ type: "update_network_router", name: key, details: { network_name: networkName, router_id: existing.id, peer: router.peer, peer_groups: router.peer_groups, metric: router.metric, masquerade: router.masquerade, enabled: router.enabled, }, }); } } // Delete routers not in desired for (const router of actualRouters) { const key = actualRouterKey(router, actual); if (!desiredKeys.has(key)) { ops.push({ type: "delete_network_router", name: key, details: { network_name: networkName, router_id: router.id }, }); } } } /** * Generates a stable key for a desired router config. * Uses the peer name if set, otherwise serializes peer_groups sorted. */ function routerKey( router: { peer?: string; peer_groups?: string[] }, ): string { if (router.peer) return `peer:${router.peer}`; return `groups:${[...(router.peer_groups ?? [])].sort().join(",")}`; } /** * Generates a stable key for an actual router, resolving peer ID to name. */ function actualRouterKey( router: { peer: string | null; peer_groups: string[] | null }, actual: ActualState, ): string { if (router.peer) { const peer = actual.peersById.get(router.peer); return `peer:${peer ? peer.name : router.peer}`; } // peer_groups on actual routers are group IDs — resolve to names const groupNames = (router.peer_groups ?? []) .map((id) => { const g = actual.groupsById.get(id); return g ? g.name : id; }) .sort(); return `groups:${groupNames.join(",")}`; } // --------------------------------------------------------------------------- // Peers // --------------------------------------------------------------------------- function diffPeers( desired: DesiredState, actual: ActualState, ops: Operation[], ): void { for (const [name, config] of Object.entries(desired.peers)) { const existing = actual.peersByName.get(name); if (!existing) continue; // Never create or delete peers let changed = false; // Compare groups (excluding "All"), resolve actual peer group names const actualGroupNames = existing.groups .map((g) => g.name) .filter((n) => n !== "All") .sort(); const desiredGroupNames = [...config.groups].sort(); if (!arraysEqual(actualGroupNames, desiredGroupNames)) { changed = true; } if ( existing.login_expiration_enabled !== config.login_expiration_enabled || existing.inactivity_expiration_enabled !== config.inactivity_expiration_enabled || existing.ssh_enabled !== config.ssh_enabled ) { changed = true; } if (changed) { ops.push({ type: "update_peer", name, details: { groups: config.groups, login_expiration_enabled: config.login_expiration_enabled, inactivity_expiration_enabled: config.inactivity_expiration_enabled, ssh_enabled: config.ssh_enabled, }, }); } } } // --------------------------------------------------------------------------- // Users // --------------------------------------------------------------------------- function diffUsers( desired: DesiredState, actual: ActualState, ops: Operation[], ): void { const desiredEmails = new Set(Object.keys(desired.users)); for (const [email, config] of Object.entries(desired.users)) { const existing = actual.usersByEmail.get(email); if (!existing) { ops.push({ type: "create_user", name: email, details: { email, name: config.name, role: config.role, auto_groups: config.auto_groups, }, }); continue; } // Compare role and auto_groups const actualAutoGroupNames = resolveIds( existing.auto_groups, actual, ).sort(); const desiredAutoGroupNames = [...config.auto_groups].sort(); if ( existing.role !== config.role || !arraysEqual(actualAutoGroupNames, desiredAutoGroupNames) ) { ops.push({ type: "update_user", name: email, details: { name: config.name, role: config.role, auto_groups: config.auto_groups, }, }); } } // Delete users not in desired, but NEVER delete owners for (const user of actual.users) { if (!desiredEmails.has(user.email) && user.role !== "owner") { ops.push({ type: "delete_user", name: user.email }); } } } /** Resolves group IDs to group names using actual state. */ function resolveIds(ids: string[], actual: ActualState): string[] { return ids.map((id) => { const group = actual.groupsById.get(id); return group ? group.name : id; }); } // --------------------------------------------------------------------------- // 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, destination_resource: config.destination_resource, source_posture_checks: config.source_posture_checks, }, }); continue; } // Extract group names from actual rules for comparison. const actualSources = extractGroupNames( existing.rules.flatMap((r) => r.sources ?? []), actual, ).sort(); const desiredSources = [...config.sources].sort(); let destsChanged = false; if (config.destination_resource) { // When desired has destination_resource, compare against actual rule's destinationResource const actualDestRes = existing.rules[0]?.destinationResource; if ( !actualDestRes || actualDestRes.id !== config.destination_resource.id || actualDestRes.type !== config.destination_resource.type ) { destsChanged = true; } } else { // Standard group-based destination comparison const actualDests = extractGroupNames( existing.rules.flatMap((r) => r.destinations ?? []), actual, ).sort(); const desiredDests = [...config.destinations].sort(); destsChanged = !arraysEqual(actualDests, desiredDests); } // Compare source_posture_checks const actualPostureChecks = [ ...(existing.source_posture_checks ?? []), ].sort(); const desiredPostureChecks = [...config.source_posture_checks].sort(); const postureChecksChanged = !arraysEqual( actualPostureChecks, desiredPostureChecks, ); if ( existing.enabled !== config.enabled || !arraysEqual(actualSources, desiredSources) || destsChanged || postureChecksChanged ) { ops.push({ type: "update_policy", name, details: { enabled: config.enabled, sources: config.sources, destinations: config.destinations, destination_resource: config.destination_resource, source_posture_checks: config.source_posture_checks, }, }); } } 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: NonNullable, 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; }); }