feat: add operation executor with abort-on-failure semantics

This commit is contained in:
Prox 2026-03-04 00:15:19 +02:00
parent a21adf67bc
commit 376e0b5a9d
2 changed files with 612 additions and 0 deletions

View 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
View 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}`);
}
}
}