2026-03-06 16:28:01 +02:00

809 lines
23 KiB
TypeScript

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<string, infer V> ? 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<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;
});
}