feat: add desired state schema with Zod validation and cross-reference checks
This commit is contained in:
parent
f9ccf1860b
commit
ed12ccae77
199
src/state/schema.test.ts
Normal file
199
src/state/schema.test.ts
Normal 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
172
src/state/schema.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user