feat: add desired state schema with Zod validation and cross-reference checks

This commit is contained in:
Prox 2026-03-04 00:04:20 +02:00
parent f9ccf1860b
commit ed12ccae77
2 changed files with 371 additions and 0 deletions

199
src/state/schema.test.ts Normal file
View File

@ -0,0 +1,199 @@
import { assertEquals, assertThrows } from "@std/assert";
import { DesiredStateSchema, validateCrossReferences } from "./schema.ts";
const VALID_STATE = {
groups: {
pilots: { peers: ["Pilot-hawk-72"] },
"ground-stations": { peers: ["GS-hawk-72"] },
},
setup_keys: {
"GS-hawk-72": {
type: "one-off" as const,
expires_in: 604800,
usage_limit: 1,
auto_groups: ["ground-stations"],
enrolled: true,
},
"Pilot-hawk-72": {
type: "one-off" as const,
expires_in: 604800,
usage_limit: 1,
auto_groups: ["pilots"],
enrolled: false,
},
},
policies: {
"pilots-to-gs": {
description: "Allow pilots to reach ground stations",
enabled: true,
sources: ["pilots"],
destinations: ["ground-stations"],
bidirectional: true,
protocol: "all" as const,
},
},
routes: {},
dns: { nameserver_groups: {} },
};
Deno.test("DesiredStateSchema parses a valid state", () => {
const result = DesiredStateSchema.parse(VALID_STATE);
assertEquals(Object.keys(result.groups).length, 2);
assertEquals(Object.keys(result.setup_keys).length, 2);
assertEquals(Object.keys(result.policies).length, 1);
assertEquals(result.policies["pilots-to-gs"].action, "accept");
assertEquals(result.policies["pilots-to-gs"].protocol, "all");
});
Deno.test("DesiredStateSchema applies defaults for optional top-level fields", () => {
const minimal = {
groups: { ops: { peers: [] } },
setup_keys: {},
};
const result = DesiredStateSchema.parse(minimal);
assertEquals(result.policies, {});
assertEquals(result.routes, {});
assertEquals(result.dns, { nameserver_groups: {} });
});
Deno.test("DesiredStateSchema rejects invalid setup key type", () => {
const bad = {
...VALID_STATE,
setup_keys: {
"bad-key": {
type: "permanent",
expires_in: 100,
usage_limit: 0,
auto_groups: [],
enrolled: false,
},
},
};
assertThrows(() => DesiredStateSchema.parse(bad));
});
Deno.test("DesiredStateSchema rejects negative expires_in", () => {
const bad = {
...VALID_STATE,
setup_keys: {
"bad-key": {
type: "one-off",
expires_in: -1,
usage_limit: 0,
auto_groups: [],
enrolled: false,
},
},
};
assertThrows(() => DesiredStateSchema.parse(bad));
});
Deno.test("DesiredStateSchema rejects route metric out of range", () => {
const bad = {
...VALID_STATE,
routes: {
"bad-route": {
peer_groups: ["pilots"],
metric: 10000,
distribution_groups: ["pilots"],
enabled: true,
},
},
};
assertThrows(() => DesiredStateSchema.parse(bad));
});
Deno.test("validateCrossReferences passes for a valid state", () => {
const state = DesiredStateSchema.parse(VALID_STATE);
const errors = validateCrossReferences(state);
assertEquals(errors, []);
});
Deno.test("validateCrossReferences catches missing group in policy source", () => {
const bad = DesiredStateSchema.parse({
...VALID_STATE,
policies: {
broken: {
enabled: true,
sources: ["nonexistent"],
destinations: ["pilots"],
bidirectional: false,
},
},
});
const errors = validateCrossReferences(bad);
assertEquals(errors.length, 1);
assertEquals(errors[0].includes("nonexistent"), true);
assertEquals(errors[0].includes("source"), true);
});
Deno.test("validateCrossReferences catches peer without matching setup key", () => {
const bad = DesiredStateSchema.parse({
groups: {
pilots: { peers: ["ghost-peer"] },
},
setup_keys: {},
});
const errors = validateCrossReferences(bad);
assertEquals(errors.length, 1);
assertEquals(errors[0].includes("ghost-peer"), true);
assertEquals(errors[0].includes("setup key"), true);
});
Deno.test("validateCrossReferences catches auto_group referencing nonexistent group", () => {
const bad = DesiredStateSchema.parse({
groups: {},
setup_keys: {
"some-key": {
type: "reusable",
expires_in: 3600,
usage_limit: 0,
auto_groups: ["phantom-group"],
enrolled: false,
},
},
});
const errors = validateCrossReferences(bad);
assertEquals(errors.length, 1);
assertEquals(errors[0].includes("phantom-group"), true);
assertEquals(errors[0].includes("auto_group"), true);
});
Deno.test("validateCrossReferences catches missing group in route peer_groups", () => {
const bad = DesiredStateSchema.parse({
groups: { ops: { peers: [] } },
setup_keys: {},
routes: {
"bad-route": {
peer_groups: ["ops", "missing"],
distribution_groups: ["ops"],
enabled: true,
},
},
});
const errors = validateCrossReferences(bad);
assertEquals(errors.length, 1);
assertEquals(errors[0].includes("missing"), true);
assertEquals(errors[0].includes("peer_group"), true);
});
Deno.test("validateCrossReferences catches missing group in DNS nameserver group", () => {
const bad = DesiredStateSchema.parse({
groups: {},
setup_keys: {},
dns: {
nameserver_groups: {
"my-dns": {
nameservers: [{ ip: "1.1.1.1" }],
enabled: true,
groups: ["ghost"],
primary: true,
domains: [],
},
},
},
});
const errors = validateCrossReferences(bad);
assertEquals(errors.length, 1);
assertEquals(errors[0].includes("ghost"), true);
});

172
src/state/schema.ts Normal file
View File

@ -0,0 +1,172 @@
import { z } from "zod";
// --- Leaf schemas ---
export const SetupKeySchema = z.object({
type: z.enum(["one-off", "reusable"]),
expires_in: z.number().int().positive(),
usage_limit: z.number().int().nonnegative(),
auto_groups: z.array(z.string()),
enrolled: z.boolean(),
});
export const GroupSchema = z.object({
peers: z.array(z.string()),
});
export const PolicySchema = z.object({
description: z.string().default(""),
enabled: z.boolean(),
sources: z.array(z.string()),
destinations: z.array(z.string()),
bidirectional: z.boolean(),
protocol: z.enum(["tcp", "udp", "icmp", "all"]).default("all"),
action: z.enum(["accept", "drop"]).default("accept"),
ports: z.array(z.string()).optional(),
});
export const RouteSchema = z.object({
description: z.string().default(""),
network: z.string().optional(),
domains: z.array(z.string()).optional(),
peer_groups: z.array(z.string()),
metric: z.number().int().min(1).max(9999).default(9999),
masquerade: z.boolean().default(true),
distribution_groups: z.array(z.string()),
enabled: z.boolean(),
keep_route: z.boolean().default(true),
});
export const NameserverSchema = z.object({
ip: z.string(),
ns_type: z.string().default("udp"),
port: z.number().int().default(53),
});
export const DnsNameserverGroupSchema = z.object({
description: z.string().default(""),
nameservers: z.array(NameserverSchema).min(1).max(3),
enabled: z.boolean(),
groups: z.array(z.string()),
primary: z.boolean(),
domains: z.array(z.string()),
search_domains_enabled: z.boolean().default(false),
});
// --- Top-level schema ---
export const DesiredStateSchema = z.object({
groups: z.record(z.string(), GroupSchema),
setup_keys: z.record(z.string(), SetupKeySchema),
policies: z.record(z.string(), PolicySchema).default({}),
routes: z.record(z.string(), RouteSchema).default({}),
dns: z.object({
nameserver_groups: z.record(z.string(), DnsNameserverGroupSchema)
.default({}),
}).default({ nameserver_groups: {} }),
});
// --- Inferred types ---
export type DesiredState = z.infer<typeof DesiredStateSchema>;
export type SetupKeyConfig = z.infer<typeof SetupKeySchema>;
export type GroupConfig = z.infer<typeof GroupSchema>;
export type PolicyConfig = z.infer<typeof PolicySchema>;
export type RouteConfig = z.infer<typeof RouteSchema>;
export type DnsNameserverGroupConfig = z.infer<typeof DnsNameserverGroupSchema>;
// --- Cross-reference validation ---
/**
* Validates that all cross-references within a parsed DesiredState are
* consistent. Returns an array of human-readable error strings an empty
* array means the state is internally consistent.
*
* Checks performed:
* 1. Every peer listed in a group corresponds to an existing setup key.
* 2. Every auto_group on a setup key references an existing group.
* 3. Every source/destination in a policy references an existing group.
* 4. Every peer_group and distribution_group in a route references an
* existing group.
* 5. Every group in a DNS nameserver group references an existing group.
*/
export function validateCrossReferences(state: DesiredState): string[] {
const errors: string[] = [];
const groupNames = new Set(Object.keys(state.groups));
const setupKeyNames = new Set(Object.keys(state.setup_keys));
// 1. Peers in groups must reference existing setup keys
for (const [groupName, group] of Object.entries(state.groups)) {
for (const peer of group.peers) {
if (!setupKeyNames.has(peer)) {
errors.push(
`group "${groupName}": peer "${peer}" does not match any setup key`,
);
}
}
}
// 2. auto_groups on setup keys must reference existing groups
for (const [keyName, key] of Object.entries(state.setup_keys)) {
for (const ag of key.auto_groups) {
if (!groupNames.has(ag)) {
errors.push(
`setup_key "${keyName}": auto_group "${ag}" does not match any group`,
);
}
}
}
// 3. Policy sources and destinations must reference existing groups
for (const [policyName, policy] of Object.entries(state.policies)) {
for (const src of policy.sources) {
if (!groupNames.has(src)) {
errors.push(
`policy "${policyName}": source "${src}" does not match any group`,
);
}
}
for (const dst of policy.destinations) {
if (!groupNames.has(dst)) {
errors.push(
`policy "${policyName}": destination "${dst}" does not match any group`,
);
}
}
}
// 4. Route peer_groups and distribution_groups must reference existing groups
for (const [routeName, route] of Object.entries(state.routes)) {
for (const pg of route.peer_groups) {
if (!groupNames.has(pg)) {
errors.push(
`route "${routeName}": peer_group "${pg}" does not match any group`,
);
}
}
for (const dg of route.distribution_groups) {
if (!groupNames.has(dg)) {
errors.push(
`route "${routeName}": distribution_group "${dg}" does not match any group`,
);
}
}
}
// 5. DNS nameserver group references must match existing groups
for (
const [nsGroupName, nsGroup] of Object.entries(
state.dns.nameserver_groups,
)
) {
for (const g of nsGroup.groups) {
if (!groupNames.has(g)) {
errors.push(
`dns.nameserver_groups "${nsGroupName}": group "${g}" does not match any group`,
);
}
}
}
return errors;
}