diff --git a/package.json b/package.json index 7ef781ea..654fb9da 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/harness/cli.ts b/tests/harness/cli.ts new file mode 100644 index 00000000..6e884d7a --- /dev/null +++ b/tests/harness/cli.ts @@ -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 { + 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 | 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 +`); + +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."); diff --git a/tests/harness/scenario.ts b/tests/harness/scenario.ts new file mode 100644 index 00000000..32ad334d --- /dev/null +++ b/tests/harness/scenario.ts @@ -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(); + }), + ); +} diff --git a/tests/harness/scenarios/fleet.ts b/tests/harness/scenarios/fleet.ts new file mode 100644 index 00000000..af1424ff --- /dev/null +++ b/tests/harness/scenarios/fleet.ts @@ -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; diff --git a/tests/harness/scenarios/homelab.ts b/tests/harness/scenarios/homelab.ts new file mode 100644 index 00000000..8eba1c71 --- /dev/null +++ b/tests/harness/scenarios/homelab.ts @@ -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; diff --git a/tests/harness/scenarios/minimal.ts b/tests/harness/scenarios/minimal.ts new file mode 100644 index 00000000..eebacade --- /dev/null +++ b/tests/harness/scenarios/minimal.ts @@ -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; diff --git a/tests/integration/harness/scenarios.test.ts b/tests/integration/harness/scenarios.test.ts new file mode 100644 index 00000000..4ce915c9 --- /dev/null +++ b/tests/integration/harness/scenarios.test.ts @@ -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); + } +});