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