809 lines
23 KiB
TypeScript
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;
|
|
});
|
|
}
|