feat: add operation executor with abort-on-failure semantics
This commit is contained in:
parent
a21adf67bc
commit
376e0b5a9d
228
src/reconcile/executor.test.ts
Normal file
228
src/reconcile/executor.test.ts
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import { assertEquals } from "@std/assert";
|
||||||
|
import { executeOperations } from "./executor.ts";
|
||||||
|
import type { Operation } from "./operations.ts";
|
||||||
|
import type { ActualState } from "../state/actual.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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("executor calls createGroup for create_group op", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const mockClient = {
|
||||||
|
createGroup: (data: { name: string }) => {
|
||||||
|
calls.push(`createGroup:${data.name}`);
|
||||||
|
return Promise.resolve({
|
||||||
|
id: "new-g1",
|
||||||
|
name: data.name,
|
||||||
|
peers_count: 0,
|
||||||
|
peers: [],
|
||||||
|
issued: "api" as const,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ops: Operation[] = [
|
||||||
|
{ type: "create_group", name: "pilots" },
|
||||||
|
];
|
||||||
|
const { results } = await executeOperations(
|
||||||
|
ops,
|
||||||
|
mockClient as never,
|
||||||
|
emptyActual(),
|
||||||
|
);
|
||||||
|
assertEquals(calls, ["createGroup:pilots"]);
|
||||||
|
assertEquals(results[0].status, "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("executor aborts on first failure", async () => {
|
||||||
|
const mockClient = {
|
||||||
|
createGroup: () => Promise.reject(new Error("API down")),
|
||||||
|
createSetupKey: () =>
|
||||||
|
Promise.resolve({ id: 1, key: "k", name: "key1" }),
|
||||||
|
};
|
||||||
|
const ops: Operation[] = [
|
||||||
|
{ type: "create_group", name: "pilots" },
|
||||||
|
{ type: "create_setup_key", name: "key1" },
|
||||||
|
];
|
||||||
|
const { results } = await executeOperations(
|
||||||
|
ops,
|
||||||
|
mockClient as never,
|
||||||
|
emptyActual(),
|
||||||
|
);
|
||||||
|
assertEquals(results[0].status, "failed");
|
||||||
|
assertEquals(results.length, 1); // second op never executed
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("executor tracks created group IDs for setup key auto_groups", async () => {
|
||||||
|
const calls: Array<{ method: string; data: unknown }> = [];
|
||||||
|
const mockClient = {
|
||||||
|
createGroup: (data: { name: string }) => {
|
||||||
|
calls.push({ method: "createGroup", data });
|
||||||
|
return Promise.resolve({
|
||||||
|
id: "new-g1",
|
||||||
|
name: data.name,
|
||||||
|
peers_count: 0,
|
||||||
|
peers: [],
|
||||||
|
issued: "api" as const,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
createSetupKey: (data: Record<string, unknown>) => {
|
||||||
|
calls.push({ method: "createSetupKey", data });
|
||||||
|
return Promise.resolve({
|
||||||
|
id: 1,
|
||||||
|
name: data.name,
|
||||||
|
key: "raw-key-123",
|
||||||
|
type: data.type,
|
||||||
|
expires: "2026-04-01T00:00:00Z",
|
||||||
|
valid: true,
|
||||||
|
revoked: false,
|
||||||
|
used_times: 0,
|
||||||
|
state: "valid" as const,
|
||||||
|
auto_groups: data.auto_groups,
|
||||||
|
usage_limit: data.usage_limit,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ops: Operation[] = [
|
||||||
|
{ type: "create_group", name: "pilots" },
|
||||||
|
{
|
||||||
|
type: "create_setup_key",
|
||||||
|
name: "key1",
|
||||||
|
details: {
|
||||||
|
type: "one-off",
|
||||||
|
auto_groups: ["pilots"],
|
||||||
|
usage_limit: 1,
|
||||||
|
expires_in: 604800,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const { results, createdKeys } = await executeOperations(
|
||||||
|
ops,
|
||||||
|
mockClient as never,
|
||||||
|
emptyActual(),
|
||||||
|
);
|
||||||
|
assertEquals(results.length, 2);
|
||||||
|
assertEquals(results[0].status, "success");
|
||||||
|
assertEquals(results[1].status, "success");
|
||||||
|
// The setup key call should have resolved "pilots" -> "new-g1"
|
||||||
|
const setupKeyCall = calls.find((c) => c.method === "createSetupKey");
|
||||||
|
assertEquals(
|
||||||
|
(setupKeyCall?.data as Record<string, unknown>).auto_groups,
|
||||||
|
["new-g1"],
|
||||||
|
);
|
||||||
|
// Created keys map stores the raw key
|
||||||
|
assertEquals(createdKeys.get("key1"), "raw-key-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("executor resolves group IDs from actual state", async () => {
|
||||||
|
const calls: Array<{ method: string; data: unknown }> = [];
|
||||||
|
const actual = emptyActual();
|
||||||
|
actual.groupsByName.set("pilots", {
|
||||||
|
id: "existing-g1",
|
||||||
|
name: "pilots",
|
||||||
|
peers_count: 0,
|
||||||
|
peers: [],
|
||||||
|
issued: "api",
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockClient = {
|
||||||
|
createSetupKey: (data: Record<string, unknown>) => {
|
||||||
|
calls.push({ method: "createSetupKey", data });
|
||||||
|
return Promise.resolve({
|
||||||
|
id: 1,
|
||||||
|
name: data.name,
|
||||||
|
key: "raw-key-456",
|
||||||
|
type: data.type,
|
||||||
|
expires: "2026-04-01T00:00:00Z",
|
||||||
|
valid: true,
|
||||||
|
revoked: false,
|
||||||
|
used_times: 0,
|
||||||
|
state: "valid" as const,
|
||||||
|
auto_groups: data.auto_groups,
|
||||||
|
usage_limit: data.usage_limit,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ops: Operation[] = [
|
||||||
|
{
|
||||||
|
type: "create_setup_key",
|
||||||
|
name: "key1",
|
||||||
|
details: {
|
||||||
|
type: "one-off",
|
||||||
|
auto_groups: ["pilots"],
|
||||||
|
usage_limit: 1,
|
||||||
|
expires_in: 604800,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const { results } = await executeOperations(
|
||||||
|
ops,
|
||||||
|
mockClient as never,
|
||||||
|
actual,
|
||||||
|
);
|
||||||
|
assertEquals(results[0].status, "success");
|
||||||
|
const setupKeyCall = calls.find((c) => c.method === "createSetupKey");
|
||||||
|
assertEquals(
|
||||||
|
(setupKeyCall?.data as Record<string, unknown>).auto_groups,
|
||||||
|
["existing-g1"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("executor deletes group by resolving ID from actual", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const actual = emptyActual();
|
||||||
|
actual.groupsByName.set("stale-group", {
|
||||||
|
id: "g-old",
|
||||||
|
name: "stale-group",
|
||||||
|
peers_count: 0,
|
||||||
|
peers: [],
|
||||||
|
issued: "api",
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockClient = {
|
||||||
|
deleteGroup: (id: string) => {
|
||||||
|
calls.push(`deleteGroup:${id}`);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ops: Operation[] = [
|
||||||
|
{ type: "delete_group", name: "stale-group" },
|
||||||
|
];
|
||||||
|
const { results } = await executeOperations(
|
||||||
|
ops,
|
||||||
|
mockClient as never,
|
||||||
|
actual,
|
||||||
|
);
|
||||||
|
assertEquals(calls, ["deleteGroup:g-old"]);
|
||||||
|
assertEquals(results[0].status, "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("executor stores error message on failure", async () => {
|
||||||
|
const mockClient = {
|
||||||
|
createGroup: () => Promise.reject(new Error("rate limited")),
|
||||||
|
};
|
||||||
|
const ops: Operation[] = [
|
||||||
|
{ type: "create_group", name: "pilots" },
|
||||||
|
];
|
||||||
|
const { results } = await executeOperations(
|
||||||
|
ops,
|
||||||
|
mockClient as never,
|
||||||
|
emptyActual(),
|
||||||
|
);
|
||||||
|
assertEquals(results[0].status, "failed");
|
||||||
|
assertEquals(results[0].error, "rate limited");
|
||||||
|
});
|
||||||
384
src/reconcile/executor.ts
Normal file
384
src/reconcile/executor.ts
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
import type { NetbirdClient } from "../netbird/client.ts";
|
||||||
|
import type { ActualState } from "../state/actual.ts";
|
||||||
|
import type { Operation, OperationResult } from "./operations.ts";
|
||||||
|
|
||||||
|
export interface ExecutionResult {
|
||||||
|
results: OperationResult[];
|
||||||
|
createdKeys: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subset of NetbirdClient methods the executor actually calls.
|
||||||
|
*
|
||||||
|
* Using a structural pick keeps tests simple — callers can pass a partial
|
||||||
|
* mock that satisfies only the methods their operations need.
|
||||||
|
*/
|
||||||
|
type ExecutorClient = Pick<
|
||||||
|
NetbirdClient,
|
||||||
|
| "createGroup"
|
||||||
|
| "updateGroup"
|
||||||
|
| "deleteGroup"
|
||||||
|
| "createSetupKey"
|
||||||
|
| "deleteSetupKey"
|
||||||
|
| "updatePeer"
|
||||||
|
| "deletePeer"
|
||||||
|
| "createPolicy"
|
||||||
|
| "updatePolicy"
|
||||||
|
| "deletePolicy"
|
||||||
|
| "createRoute"
|
||||||
|
| "updateRoute"
|
||||||
|
| "deleteRoute"
|
||||||
|
| "createDnsNameserverGroup"
|
||||||
|
| "updateDnsNameserverGroup"
|
||||||
|
| "deleteDnsNameserverGroup"
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a list of operations against the NetBird API, aborting on the
|
||||||
|
* first failure. Resolves names to IDs using the provided actual state and
|
||||||
|
* tracks newly created resource IDs for cross-referencing within the same run.
|
||||||
|
*
|
||||||
|
* Returns both the per-operation results and a map of created setup key
|
||||||
|
* names to their raw key values (needed for enrollment output).
|
||||||
|
*/
|
||||||
|
export async function executeOperations(
|
||||||
|
ops: Operation[],
|
||||||
|
client: ExecutorClient,
|
||||||
|
actual: ActualState,
|
||||||
|
): Promise<ExecutionResult> {
|
||||||
|
const results: OperationResult[] = [];
|
||||||
|
const createdGroupIds = new Map<string, string>();
|
||||||
|
const createdKeys = new Map<string, string>();
|
||||||
|
|
||||||
|
function resolveGroupId(name: string): string {
|
||||||
|
const created = createdGroupIds.get(name);
|
||||||
|
if (created) return created;
|
||||||
|
const existing = actual.groupsByName.get(name);
|
||||||
|
if (existing) return existing.id;
|
||||||
|
throw new Error(`group "${name}" not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGroupIds(names: string[]): string[] {
|
||||||
|
return names.map(resolveGroupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePeerIds(names: string[]): string[] {
|
||||||
|
return names.map((name) => {
|
||||||
|
const peer = actual.peersByName.get(name);
|
||||||
|
if (peer) return peer.id;
|
||||||
|
throw new Error(`peer "${name}" not found`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const op of ops) {
|
||||||
|
try {
|
||||||
|
await executeSingle(op, client, actual, {
|
||||||
|
createdGroupIds,
|
||||||
|
createdKeys,
|
||||||
|
resolveGroupId,
|
||||||
|
resolveGroupIds,
|
||||||
|
resolvePeerIds,
|
||||||
|
});
|
||||||
|
results.push({ ...op, status: "success" });
|
||||||
|
} catch (err) {
|
||||||
|
results.push({
|
||||||
|
...op,
|
||||||
|
status: "failed",
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { results, createdKeys };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal dispatch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ExecutorContext {
|
||||||
|
createdGroupIds: Map<string, string>;
|
||||||
|
createdKeys: Map<string, string>;
|
||||||
|
resolveGroupId: (name: string) => string;
|
||||||
|
resolveGroupIds: (names: string[]) => string[];
|
||||||
|
resolvePeerIds: (names: string[]) => string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeSingle(
|
||||||
|
op: Operation,
|
||||||
|
client: ExecutorClient,
|
||||||
|
actual: ActualState,
|
||||||
|
ctx: ExecutorContext,
|
||||||
|
): Promise<void> {
|
||||||
|
const d = op.details ?? {};
|
||||||
|
|
||||||
|
switch (op.type) {
|
||||||
|
// ----- Groups -----
|
||||||
|
case "create_group": {
|
||||||
|
const peerNames = d.peers as string[] | undefined;
|
||||||
|
const peerIds = peerNames?.length ? ctx.resolvePeerIds(peerNames) : [];
|
||||||
|
const group = await client.createGroup({
|
||||||
|
name: op.name,
|
||||||
|
peers: peerIds,
|
||||||
|
});
|
||||||
|
ctx.createdGroupIds.set(op.name, group.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "update_group": {
|
||||||
|
const existing = actual.groupsByName.get(op.name);
|
||||||
|
if (!existing) throw new Error(`group "${op.name}" not found for update`);
|
||||||
|
const desiredPeers = d.desired_peers as string[] | undefined;
|
||||||
|
const peerIds = desiredPeers?.length
|
||||||
|
? ctx.resolvePeerIds(desiredPeers)
|
||||||
|
: [];
|
||||||
|
await client.updateGroup(existing.id, {
|
||||||
|
name: op.name,
|
||||||
|
peers: peerIds,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "delete_group": {
|
||||||
|
const existing = actual.groupsByName.get(op.name);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`group "${op.name}" not found for delete`);
|
||||||
|
}
|
||||||
|
await client.deleteGroup(existing.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Setup Keys -----
|
||||||
|
case "create_setup_key": {
|
||||||
|
const autoGroupNames = d.auto_groups as string[] | undefined;
|
||||||
|
const autoGroupIds = autoGroupNames?.length
|
||||||
|
? ctx.resolveGroupIds(autoGroupNames)
|
||||||
|
: [];
|
||||||
|
const key = await client.createSetupKey({
|
||||||
|
name: op.name,
|
||||||
|
type: (d.type as "one-off" | "reusable") ?? "one-off",
|
||||||
|
expires_in: (d.expires_in as number) ?? 604800,
|
||||||
|
auto_groups: autoGroupIds,
|
||||||
|
usage_limit: d.usage_limit as number | undefined,
|
||||||
|
});
|
||||||
|
ctx.createdKeys.set(op.name, key.key);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "delete_setup_key": {
|
||||||
|
const existing = actual.setupKeysByName.get(op.name);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`setup key "${op.name}" not found for delete`);
|
||||||
|
}
|
||||||
|
await client.deleteSetupKey(existing.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Peers -----
|
||||||
|
case "rename_peer": {
|
||||||
|
const peerId = d.id as string;
|
||||||
|
if (!peerId) throw new Error(`rename_peer missing details.id`);
|
||||||
|
await client.updatePeer(peerId, { name: op.name });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "update_peer_groups": {
|
||||||
|
// This op type updates peer-level properties; details.id is the peer ID
|
||||||
|
const peerId = d.id as string;
|
||||||
|
if (!peerId) throw new Error(`update_peer_groups missing details.id`);
|
||||||
|
await client.updatePeer(peerId, {
|
||||||
|
name: d.name as string | undefined,
|
||||||
|
ssh_enabled: d.ssh_enabled as boolean | undefined,
|
||||||
|
login_expiration_enabled: d.login_expiration_enabled as
|
||||||
|
| boolean
|
||||||
|
| undefined,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "delete_peer": {
|
||||||
|
const peer = actual.peersByName.get(op.name);
|
||||||
|
if (!peer) throw new Error(`peer "${op.name}" not found for delete`);
|
||||||
|
await client.deletePeer(peer.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Policies -----
|
||||||
|
case "create_policy": {
|
||||||
|
const sourceIds = ctx.resolveGroupIds(d.sources as string[] ?? []);
|
||||||
|
const destIds = ctx.resolveGroupIds(d.destinations as string[] ?? []);
|
||||||
|
await client.createPolicy({
|
||||||
|
name: op.name,
|
||||||
|
description: (d.description as string) ?? "",
|
||||||
|
enabled: (d.enabled as boolean) ?? true,
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
name: op.name,
|
||||||
|
description: (d.description as string) ?? "",
|
||||||
|
enabled: (d.enabled as boolean) ?? true,
|
||||||
|
action: (d.action as "accept" | "drop") ?? "accept",
|
||||||
|
bidirectional: (d.bidirectional as boolean) ?? true,
|
||||||
|
protocol: (d.protocol as "tcp" | "udp" | "icmp" | "all") ?? "all",
|
||||||
|
ports: d.ports as string[] | undefined,
|
||||||
|
sources: sourceIds,
|
||||||
|
destinations: destIds,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "update_policy": {
|
||||||
|
const existing = actual.policiesByName.get(op.name);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`policy "${op.name}" not found for update`);
|
||||||
|
}
|
||||||
|
const sourceIds = ctx.resolveGroupIds(d.sources as string[] ?? []);
|
||||||
|
const destIds = ctx.resolveGroupIds(d.destinations as string[] ?? []);
|
||||||
|
await client.updatePolicy(existing.id, {
|
||||||
|
name: op.name,
|
||||||
|
description: (d.description as string) ?? existing.description,
|
||||||
|
enabled: (d.enabled as boolean) ?? existing.enabled,
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
name: op.name,
|
||||||
|
description: (d.description as string) ?? existing.description,
|
||||||
|
enabled: (d.enabled as boolean) ?? existing.enabled,
|
||||||
|
action: (d.action as "accept" | "drop") ?? "accept",
|
||||||
|
bidirectional: (d.bidirectional as boolean) ?? true,
|
||||||
|
protocol: (d.protocol as "tcp" | "udp" | "icmp" | "all") ?? "all",
|
||||||
|
ports: d.ports as string[] | undefined,
|
||||||
|
sources: sourceIds,
|
||||||
|
destinations: destIds,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "delete_policy": {
|
||||||
|
const existing = actual.policiesByName.get(op.name);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`policy "${op.name}" not found for delete`);
|
||||||
|
}
|
||||||
|
await client.deletePolicy(existing.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Routes -----
|
||||||
|
case "create_route": {
|
||||||
|
const peerGroupIds = d.peer_groups
|
||||||
|
? ctx.resolveGroupIds(d.peer_groups as string[])
|
||||||
|
: undefined;
|
||||||
|
const distGroupIds = d.distribution_groups
|
||||||
|
? ctx.resolveGroupIds(d.distribution_groups as string[])
|
||||||
|
: [];
|
||||||
|
await client.createRoute({
|
||||||
|
network_id: op.name,
|
||||||
|
description: (d.description as string) ?? "",
|
||||||
|
enabled: (d.enabled as boolean) ?? true,
|
||||||
|
network: d.network as string | undefined,
|
||||||
|
domains: d.domains as string[] | undefined,
|
||||||
|
peer: d.peer as string | undefined,
|
||||||
|
peer_groups: peerGroupIds,
|
||||||
|
metric: (d.metric as number) ?? 9999,
|
||||||
|
masquerade: (d.masquerade as boolean) ?? true,
|
||||||
|
groups: distGroupIds,
|
||||||
|
keep_route: (d.keep_route as boolean) ?? true,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "update_route": {
|
||||||
|
const existing = actual.routesByNetworkId.get(op.name);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`route "${op.name}" not found for update`);
|
||||||
|
}
|
||||||
|
const peerGroupIds = d.peer_groups
|
||||||
|
? ctx.resolveGroupIds(d.peer_groups as string[])
|
||||||
|
: existing.peer_groups;
|
||||||
|
const distGroupIds = d.distribution_groups
|
||||||
|
? ctx.resolveGroupIds(d.distribution_groups as string[])
|
||||||
|
: existing.groups;
|
||||||
|
await client.updateRoute(existing.id, {
|
||||||
|
network_id: op.name,
|
||||||
|
description: (d.description as string) ?? existing.description,
|
||||||
|
enabled: (d.enabled as boolean) ?? existing.enabled,
|
||||||
|
network: (d.network as string | undefined) ?? existing.network,
|
||||||
|
domains: (d.domains as string[] | undefined) ?? existing.domains,
|
||||||
|
peer: (d.peer as string | undefined) ?? existing.peer,
|
||||||
|
peer_groups: peerGroupIds,
|
||||||
|
metric: (d.metric as number) ?? existing.metric,
|
||||||
|
masquerade: (d.masquerade as boolean) ?? existing.masquerade,
|
||||||
|
groups: distGroupIds,
|
||||||
|
keep_route: (d.keep_route as boolean) ?? existing.keep_route,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "delete_route": {
|
||||||
|
const existing = actual.routesByNetworkId.get(op.name);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`route "${op.name}" not found for delete`);
|
||||||
|
}
|
||||||
|
await client.deleteRoute(existing.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- DNS Nameserver Groups -----
|
||||||
|
case "create_dns": {
|
||||||
|
const groupIds = d.groups
|
||||||
|
? ctx.resolveGroupIds(d.groups as string[])
|
||||||
|
: [];
|
||||||
|
await client.createDnsNameserverGroup({
|
||||||
|
name: op.name,
|
||||||
|
description: (d.description as string) ?? "",
|
||||||
|
nameservers: (d.nameservers as Array<{
|
||||||
|
ip: string;
|
||||||
|
ns_type: string;
|
||||||
|
port: number;
|
||||||
|
}>) ?? [],
|
||||||
|
enabled: (d.enabled as boolean) ?? true,
|
||||||
|
groups: groupIds,
|
||||||
|
primary: (d.primary as boolean) ?? false,
|
||||||
|
domains: (d.domains as string[]) ?? [],
|
||||||
|
search_domains_enabled: (d.search_domains_enabled as boolean) ?? false,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "update_dns": {
|
||||||
|
const existing = actual.dnsByName.get(op.name);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`dns nameserver group "${op.name}" not found for update`);
|
||||||
|
}
|
||||||
|
const groupIds = d.groups
|
||||||
|
? ctx.resolveGroupIds(d.groups as string[])
|
||||||
|
: existing.groups;
|
||||||
|
await client.updateDnsNameserverGroup(existing.id, {
|
||||||
|
name: op.name,
|
||||||
|
description: (d.description as string) ?? existing.description,
|
||||||
|
nameservers: (d.nameservers as Array<{
|
||||||
|
ip: string;
|
||||||
|
ns_type: string;
|
||||||
|
port: number;
|
||||||
|
}>) ?? existing.nameservers,
|
||||||
|
enabled: (d.enabled as boolean) ?? existing.enabled,
|
||||||
|
groups: groupIds,
|
||||||
|
primary: (d.primary as boolean) ?? existing.primary,
|
||||||
|
domains: (d.domains as string[]) ?? existing.domains,
|
||||||
|
search_domains_enabled: (d.search_domains_enabled as boolean) ??
|
||||||
|
existing.search_domains_enabled,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "delete_dns": {
|
||||||
|
const existing = actual.dnsByName.get(op.name);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(
|
||||||
|
`dns nameserver group "${op.name}" not found for delete`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await client.deleteDnsNameserverGroup(existing.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
// Exhaustiveness check — if a new OperationType is added but not
|
||||||
|
// handled here, TypeScript will flag it at compile time.
|
||||||
|
const _exhaustive: never = op.type;
|
||||||
|
throw new Error(`unknown operation type: ${_exhaustive}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user