feat: add diff engine computing operations from desired vs actual state
This commit is contained in:
parent
9742807f91
commit
a21adf67bc
346
src/reconcile/diff.test.ts
Normal file
346
src/reconcile/diff.test.ts
Normal 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
342
src/reconcile/diff.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
51
src/reconcile/operations.ts
Normal file
51
src/reconcile/operations.ts
Normal 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",
|
||||
];
|
||||
Loading…
x
Reference in New Issue
Block a user