feat: add diff engine computing operations from desired vs actual state

This commit is contained in:
Prox 2026-03-04 00:10:58 +02:00
parent 9742807f91
commit a21adf67bc
3 changed files with 739 additions and 0 deletions

346
src/reconcile/diff.test.ts Normal file
View File

@ -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);
});

342
src/reconcile/diff.ts Normal file
View File

@ -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;
});
}

View File

@ -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<string, unknown>;
}
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",
];