mirror of
https://github.com/tale/headplane.git
synced 2026-10-25 22:20:30 +00:00
test(harness): run seeded scenarios from the command line
For manual testing, reproducing user setups, and the docs screenshots.
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
"typecheck": "react-router typegen && tsc",
|
||||
"test:unit": "vitest run --project unit",
|
||||
"test:integration": "vitest run --project integration --project integration:agent",
|
||||
"harness": "node --disable-warning=ExperimentalWarning --import tsx tests/harness/cli.ts",
|
||||
"docs:dev": "cd docs && react-router dev",
|
||||
"docs:build": "cd docs && react-router build && node scripts/finish-build.ts",
|
||||
"lint": "oxlint",
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// Keeps a scenario's tailnet running for manual testing, reproducing user
|
||||
// setups, and docs screenshots. Usage:
|
||||
//
|
||||
// pnpm harness up [scenario] [--version 0.29.5] [--headplane]
|
||||
// pnpm harness list
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdtemp, readdir, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import { stringify } from "yaml";
|
||||
|
||||
// Headplane's modules expect the build-time globals Vite defines.
|
||||
Object.assign(globalThis, { __VERSION__: "harness", __PREFIX__: "/admin" });
|
||||
|
||||
const { HARNESS_LABEL } = await import("./constants");
|
||||
const { applyScenario } = await import("./scenario");
|
||||
const { createTailnet } = await import("./tailnet");
|
||||
const { HEADSCALE_VERSIONS, LATEST_HEADSCALE } = await import("./versions");
|
||||
type Scenario = import("./scenario").Scenario;
|
||||
type HeadscaleVersion = import("./versions").HeadscaleVersion;
|
||||
|
||||
const scenariosDir = join(import.meta.dirname, "scenarios");
|
||||
|
||||
const { positionals, values } = parseArgs({
|
||||
allowPositionals: true,
|
||||
options: {
|
||||
version: { type: "string", default: LATEST_HEADSCALE },
|
||||
headplane: { type: "boolean", default: false },
|
||||
port: { type: "string", default: "3001" },
|
||||
},
|
||||
});
|
||||
|
||||
const [command = "up", name = "homelab"] = positionals;
|
||||
|
||||
async function loadScenario(scenario: string): Promise<Scenario> {
|
||||
const module = await import(join(scenariosDir, `${scenario}.ts`)).catch(() => undefined);
|
||||
if (!module) {
|
||||
throw new Error(`No scenario named ${scenario}. Run \`pnpm harness list\` to see them.`);
|
||||
}
|
||||
|
||||
return module.default;
|
||||
}
|
||||
|
||||
if (command === "list") {
|
||||
for (const file of (await readdir(scenariosDir)).sort()) {
|
||||
const scenario = await loadScenario(basename(file, ".ts"));
|
||||
console.log(`${basename(file, ".ts").padEnd(12)} ${scenario.description}`);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command !== "up") {
|
||||
console.error(`Unknown command ${command}. Use \`up\` or \`list\`.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const version = values.version as HeadscaleVersion;
|
||||
if (!HEADSCALE_VERSIONS.includes(version)) {
|
||||
console.error(
|
||||
`Headscale ${version} isn't supported. Pick one of ${HEADSCALE_VERSIONS.join(", ")}.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const scenario = await loadScenario(name);
|
||||
console.log(`Starting ${name} on Headscale ${version}: ${scenario.description}`);
|
||||
|
||||
const tailnet = await createTailnet({ version, headscaleConfig: scenario.headscaleConfig });
|
||||
let headplane: ReturnType<typeof spawn> | undefined;
|
||||
|
||||
async function shutdown() {
|
||||
console.log("\nStopping the tailnet...");
|
||||
headplane?.kill("SIGTERM");
|
||||
await tailnet.dispose();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
await applyScenario(tailnet, scenario);
|
||||
|
||||
const dataDir = await mkdtemp(join(tmpdir(), "hp-harness-headplane-"));
|
||||
const configPath = join(dataDir, "config.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
stringify({
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: Number(values.port),
|
||||
cookie_secret: randomBytes(16).toString("hex"),
|
||||
cookie_secure: false,
|
||||
data_path: dataDir,
|
||||
},
|
||||
headscale: {
|
||||
url: tailnet.headscale.url,
|
||||
api_key: tailnet.headscale.apiKey,
|
||||
tls_cert_path: tailnet.headscale.certPath,
|
||||
config_path: tailnet.headscale.configPath,
|
||||
},
|
||||
integration: {
|
||||
docker: {
|
||||
enabled: true,
|
||||
container_label: `${HARNESS_LABEL}=${tailnet.id}`,
|
||||
socket: process.env.DOCKER_HOST ?? "unix:///var/run/docker.sock",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
console.log(`
|
||||
Headscale ${tailnet.headscale.url}
|
||||
API key ${tailnet.headscale.apiKey}
|
||||
CA cert ${tailnet.headscale.certPath}
|
||||
Config ${tailnet.headscale.configPath}
|
||||
Headplane ${configPath}
|
||||
Exec docker exec ${tailnet.headscale.container.getId().slice(0, 12)} headscale <command>
|
||||
`);
|
||||
|
||||
if (values.headplane) {
|
||||
headplane = spawn("pnpm", ["exec", "react-router", "dev", "--port", values.port!], {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, HEADPLANE_CONFIG_PATH: configPath },
|
||||
});
|
||||
|
||||
console.log(`Headplane http://127.0.0.1:${values.port}/admin (sign in with the API key)\n`);
|
||||
} else {
|
||||
console.log(`Run Headplane against it with:
|
||||
HEADPLANE_CONFIG_PATH=${configPath} pnpm exec react-router dev --port ${values.port}
|
||||
`);
|
||||
}
|
||||
|
||||
console.log("Press Ctrl-C to stop.");
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { NodeOptions } from "./node";
|
||||
import type { Tailnet, TailnetOptions } from "./tailnet";
|
||||
|
||||
export interface ScenarioNode extends NodeOptions {
|
||||
// Routes to approve once the node has joined.
|
||||
approve?: string[];
|
||||
offline?: boolean;
|
||||
}
|
||||
|
||||
export interface Scenario {
|
||||
description: string;
|
||||
users: Array<{ name: string; email?: string; displayName?: string }>;
|
||||
nodes: ScenarioNode[];
|
||||
policy?: TailnetOptions["policy"];
|
||||
headscaleConfig?: TailnetOptions["headscaleConfig"];
|
||||
}
|
||||
|
||||
export async function applyScenario(tailnet: Tailnet, scenario: Scenario) {
|
||||
for (const user of scenario.users) {
|
||||
await tailnet.user(user.name, { email: user.email, displayName: user.displayName });
|
||||
}
|
||||
|
||||
if (scenario.policy) {
|
||||
await tailnet.setPolicy(scenario.policy);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
scenario.nodes.map(async ({ approve, offline, ...options }) => {
|
||||
const node = await tailnet.node(options);
|
||||
if (approve?.length) await tailnet.api.nodes.approveRoutes(node.id, approve);
|
||||
if (offline) await node.down();
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Scenario } from "../scenario";
|
||||
|
||||
// HP_FLEET_SIZE machines spread over ten users, for performance work.
|
||||
const size = Number(process.env.HP_FLEET_SIZE ?? 50);
|
||||
const users = Array.from({ length: 10 }, (_, i) => ({ name: `user${i + 1}` }));
|
||||
|
||||
export default {
|
||||
description: `${size} machines across ${users.length} users`,
|
||||
users,
|
||||
nodes: Array.from({ length: size }, (_, i) => ({
|
||||
name: `machine-${String(i + 1).padStart(3, "0")}`,
|
||||
user: users[i % users.length].name,
|
||||
offline: i % 7 === 0,
|
||||
})),
|
||||
} satisfies Scenario;
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Scenario } from "../scenario";
|
||||
|
||||
// A small household tailnet, used for the docs screenshots.
|
||||
export default {
|
||||
description: "Three people, a NAS sharing the home LAN, an exit node, and tagged servers",
|
||||
users: [
|
||||
{ name: "alice", email: "alice@example.com", displayName: "Alice Chen" },
|
||||
{ name: "bob", email: "bob@example.com", displayName: "Bob Okafor" },
|
||||
{ name: "carol", email: "carol@example.com", displayName: "Carol Diaz" },
|
||||
],
|
||||
headscaleConfig: {
|
||||
dns: {
|
||||
magic_dns: true,
|
||||
base_domain: "home.arpa",
|
||||
override_local_dns: true,
|
||||
nameservers: { global: ["1.1.1.1", "9.9.9.9"], split: { "lan.home.arpa": ["192.168.1.1"] } },
|
||||
search_domains: ["lan.home.arpa"],
|
||||
extra_records: [{ name: "grafana.home.arpa", type: "A", value: "100.64.0.4" }],
|
||||
},
|
||||
},
|
||||
policy: {
|
||||
groups: { "group:admins": ["alice@"], "group:family": ["alice@", "bob@", "carol@"] },
|
||||
tagOwners: {
|
||||
"tag:server": ["group:admins"],
|
||||
"tag:media": ["group:admins"],
|
||||
"tag:ci": ["group:admins"],
|
||||
},
|
||||
acls: [
|
||||
{ action: "accept", src: ["group:admins"], dst: ["*:*"] },
|
||||
{ action: "accept", src: ["group:family"], dst: ["tag:media:*", "192.168.1.0/24:*"] },
|
||||
{ action: "accept", src: ["tag:ci"], dst: ["tag:server:22,443"] },
|
||||
],
|
||||
ssh: [
|
||||
{
|
||||
action: "accept",
|
||||
src: ["group:admins"],
|
||||
dst: ["tag:server"],
|
||||
users: ["root", "autogroup:nonroot"],
|
||||
},
|
||||
],
|
||||
},
|
||||
nodes: [
|
||||
{ name: "alice-laptop", user: "alice" },
|
||||
{ name: "alice-phone", user: "alice" },
|
||||
{ name: "bob-desktop", user: "bob" },
|
||||
{ name: "bob-tablet", user: "bob", offline: true },
|
||||
{ name: "carol-laptop", user: "carol" },
|
||||
{
|
||||
name: "nas",
|
||||
user: "alice",
|
||||
ssh: true,
|
||||
routes: ["192.168.1.0/24"],
|
||||
approve: ["192.168.1.0/24"],
|
||||
},
|
||||
{ name: "gateway", user: "alice", exitNode: true },
|
||||
{ name: "jellyfin", user: "alice", tags: ["tag:media"], ssh: true },
|
||||
{ name: "grafana", user: "alice", tags: ["tag:server"], ssh: true },
|
||||
{ name: "runner", user: "alice", tags: ["tag:ci"] },
|
||||
],
|
||||
} satisfies Scenario;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Scenario } from "../scenario";
|
||||
|
||||
export default {
|
||||
description: "One user with one machine",
|
||||
users: [{ name: "alice", email: "alice@example.com" }],
|
||||
nodes: [{ name: "alice-laptop", user: "alice" }],
|
||||
} satisfies Scenario;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { applyScenario, type Scenario } from "../../harness/scenario";
|
||||
import homelab from "../../harness/scenarios/homelab";
|
||||
import minimal from "../../harness/scenarios/minimal";
|
||||
import { createTailnet } from "../../harness/tailnet";
|
||||
|
||||
// The docs screenshots depend on these, so they shouldn't rot.
|
||||
test.concurrent.for<[string, Scenario]>([
|
||||
["homelab", homelab],
|
||||
["minimal", minimal],
|
||||
])("the %s scenario starts", async ([, scenario]) => {
|
||||
await using tailnet = await createTailnet({ headscaleConfig: scenario.headscaleConfig });
|
||||
await applyScenario(tailnet, scenario);
|
||||
|
||||
const nodes = await tailnet.api.nodes.list();
|
||||
expect(nodes.map((node) => node.name).sort()).toEqual(
|
||||
scenario.nodes.map((node) => node.name).sort(),
|
||||
);
|
||||
|
||||
for (const node of scenario.nodes) {
|
||||
const machine = nodes.find((n) => n.name === node.name)!;
|
||||
expect(machine.online, node.name).toBe(!node.offline);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user