test: add docker and proc e2e testing

This commit is contained in:
Aarnav Tale
2026-03-19 12:06:14 -04:00
parent 7403ebea06
commit 246bb90ef0
14 changed files with 488 additions and 276 deletions
-11
View File
@@ -1,11 +0,0 @@
import { describe, expect, test } from 'vitest';
import { getRuntimeClient, HS_VERSIONS } from './setup/env';
describe.for(HS_VERSIONS)('Headscale %s: API Keys', (version) => {
test('api keys can be fetched', async () => {
const client = await getRuntimeClient(version);
const apiKeys = await client.getApiKeys();
expect(Array.isArray(apiKeys)).toBe(true);
expect(apiKeys.length).toBe(1);
});
});
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, test } from "vitest";
import { getRuntimeClient, HS_VERSIONS } from "../setup/env";
describe.for(HS_VERSIONS)("Headscale %s: API Keys", (version) => {
test("api keys can be fetched", async () => {
const client = await getRuntimeClient(version);
const apiKeys = await client.getApiKeys();
expect(Array.isArray(apiKeys)).toBe(true);
expect(apiKeys.length).toBe(1);
});
});
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { getBootstrapClient, getNode, getRuntimeClient, HS_VERSIONS } from "./setup/env";
import { getBootstrapClient, getNode, getRuntimeClient, HS_VERSIONS } from "../setup/env";
describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
let workingNodeId: string;
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { getBootstrapClient, getIsAtLeast, getRuntimeClient, HS_VERSIONS } from "./setup/env";
import { getBootstrapClient, getIsAtLeast, getRuntimeClient, HS_VERSIONS } from "../setup/env";
describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) => {
test("pre-auth keys can be created", async () => {
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, test } from "vitest";
import { getRuntimeClient, HS_VERSIONS } from "../setup/env";
describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
test("users can be created", async () => {
const client = await getRuntimeClient(version);
const user = await client.createUser("tale@");
expect(user).toBeDefined();
expect(user.name).toBe("tale@");
});
test("users can be created with attributes", async () => {
const client = await getRuntimeClient(version);
const user = await client.createUser(
"test-user@",
"test-user@example.com",
"Test User",
"https://github.com/tale.png",
);
expect(user).toBeDefined();
expect(user.name).toBe("test-user@");
expect(user.email).toBe("test-user@example.com");
expect(user.displayName).toBe("Test User");
expect(user.profilePicUrl).toBe("https://github.com/tale.png");
});
test("users can be listed", async () => {
const client = await getRuntimeClient(version);
const users = await client.getUsers();
expect(Array.isArray(users)).toBe(true);
expect(users.length).toBeGreaterThanOrEqual(2);
});
test("users can be listed by name", async () => {
const client = await getRuntimeClient(version);
const users = await client.getUsers(undefined, "tale@");
expect(Array.isArray(users)).toBe(true);
expect(users.length).toBe(1);
expect(users[0].name).toBe("tale@");
});
test("users can be listed by email", async () => {
const client = await getRuntimeClient(version);
const users = await client.getUsers(undefined, undefined, "test-user@example.com");
expect(Array.isArray(users)).toBe(true);
expect(users.length).toBe(1);
expect(users[0].email).toBe("test-user@example.com");
});
test("users can be renamed", async () => {
const client = await getRuntimeClient(version);
const usersBefore = await client.getUsers(undefined, "tale@");
expect(usersBefore.length).toBe(1);
const user = usersBefore[0];
await client.renameUser(user.id, "renamed-user@");
const usersAfter = await client.getUsers(undefined, "renamed-user@");
expect(usersAfter.length).toBe(1);
expect(usersAfter[0].id).toBe(user.id);
});
test("users can be deleted", async () => {
const client = await getRuntimeClient(version);
const usersBefore = await client.getUsers(undefined, "test-user@");
expect(usersBefore.length).toBe(1);
const user = usersBefore[0];
await client.deleteUser(user.id);
const usersAfter = await client.getUsers(undefined, "test-user@");
expect(usersAfter.length).toBe(0);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, test } from "vitest";
import canonicals from "~/openapi-canonical-families.json";
import { getBootstrapClient, getRuntimeClient, HS_VERSIONS, Version } from "../setup/env";
function getCanonicalVersion(version: Version) {
const canonical = Object.entries(canonicals).find(([_, family]) =>
family.includes(version),
)?.[0] as Version | undefined;
if (!canonical) {
return version;
}
return canonical;
}
describe.for(HS_VERSIONS)("Headscale %s: Runtime Client", (version) => {
test("the runtime client is usable", async () => {
const bootstrapper = await getBootstrapClient(version);
const runtimeClient = bootstrapper.getRuntimeClient("test-api-key");
expect(runtimeClient).toBeDefined();
});
test("the runtime client has the correct canonical API version", async () => {
const bootstrapper = await getBootstrapClient(version);
expect(bootstrapper.apiVersion).toBe(getCanonicalVersion(version));
});
test("the health check endpoint works", async () => {
const client = await getRuntimeClient(version);
const health = await client.isHealthy();
expect(health).toBe(true);
});
});
+104
View File
@@ -0,0 +1,104 @@
import { Client } from "undici";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import DockerIntegration from "~/server/config/integration/docker";
import { type HeadscaleEnv, startHeadscale } from "../setup/start-headscale";
const TEST_LABEL_KEY = "me.tale.headplane.integration-test";
const TEST_LABEL_VALUE = `docker-${Date.now()}`;
describe("DockerIntegration", () => {
let env: HeadscaleEnv;
let dockerSocket: string;
beforeAll(async () => {
dockerSocket = process.env.DOCKER_HOST ?? "unix:///var/run/docker.sock";
env = await startHeadscale("0.25.1", {
labels: { [TEST_LABEL_KEY]: TEST_LABEL_VALUE },
});
}, 60_000);
afterAll(async () => {
await env?.container.stop({ remove: true, removeVolumes: true });
});
test("isAvailable finds the headscale container by label", async () => {
const integration = new DockerIntegration({
enabled: true,
container_label: `${TEST_LABEL_KEY}=${TEST_LABEL_VALUE}`,
socket: dockerSocket,
});
expect(await integration.isAvailable()).toBe(true);
});
test("isAvailable finds the headscale container by name", async () => {
const name = env.container.getName().replace(/^\//, "");
const integration = new DockerIntegration({
enabled: true,
container_name: name,
container_label: "unused=unused",
socket: dockerSocket,
});
expect(await integration.isAvailable()).toBe(true);
});
test("isAvailable returns false for a non-existent label", async () => {
const integration = new DockerIntegration({
enabled: true,
container_label: "me.tale.headplane.nonexistent=true",
socket: dockerSocket,
});
expect(await integration.isAvailable()).toBe(false);
});
test("isAvailable returns false for an invalid socket", async () => {
const integration = new DockerIntegration({
enabled: true,
container_label: `${TEST_LABEL_KEY}=${TEST_LABEL_VALUE}`,
socket: "unix:///tmp/nonexistent-docker.sock",
});
expect(await integration.isAvailable()).toBe(false);
});
test("onConfigChange restarts the container", async () => {
const integration = new DockerIntegration({
enabled: true,
container_label: `${TEST_LABEL_KEY}=${TEST_LABEL_VALUE}`,
socket: dockerSocket,
});
expect(await integration.isAvailable()).toBe(true);
// Health check goes through the Docker socket to avoid stale port
// mappings after container restart.
const containerId = env.container.getId();
const dockerClient = new Client("http://localhost", {
socketPath: "/var/run/docker.sock",
});
const mockClient = {
isHealthy: async () => {
try {
const res = await dockerClient.request({
method: "GET",
path: `/v1.44/containers/${containerId}/json`,
});
const info = (await res.body.json()) as any;
return info.State?.Running === true;
} catch {
return false;
}
},
} as any;
await integration.onConfigChange(mockClient);
const healthy = await mockClient.isHealthy();
expect(healthy).toBe(true);
}, 60_000);
});
+103
View File
@@ -0,0 +1,103 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import tc from "testcontainers";
import { build } from "vite";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
// This is a little shim file that we use to execute the real proc-helper that
// is bundled through Vite in order to test against a real Linux /proc FS.
const testRunner = `
const { findHeadscaleServe } = require("./proc-helper.js");
async function main() {
const pid = await findHeadscaleServe();
console.log(JSON.stringify({
pid,
found: typeof pid === "number" && pid > 0,
}));
}
main().catch((e) => {
console.log(JSON.stringify({ error: e.message }));
process.exit(1);
});
`;
const root = fileURLToPath(new URL("../../..", import.meta.url));
const procHelper = join(root, "app/server/config/integration/proc-helper.ts");
const appDir = join(root, "app");
describe("ProcIntegration", () => {
let container: tc.StartedTestContainer;
let tmpDir: string;
beforeAll(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "headplane-proc-test-"));
const bundlePath = join(tmpDir, "proc-helper.js");
await build({
configFile: false,
logLevel: "silent",
build: {
lib: {
entry: procHelper,
formats: ["cjs"],
fileName: () => "proc-helper.js",
},
outDir: tmpDir,
emptyOutDir: false,
rollupOptions: {
external: (id) => id.startsWith("node:"),
},
},
resolve: {
alias: { "~": appDir },
},
});
const runnerPath = join(tmpDir, "test-runner.js");
await writeFile(runnerPath, testRunner);
// Build a container image that has both headscale and Node.js using
// a multi-stage Dockerfile. The headscale image is distroless (no shell)
// so we pull the binary into node:alpine via COPY --from.
await writeFile(
join(tmpDir, "Dockerfile"),
[
"FROM headscale/headscale:0.25.1-debug AS headscale",
"FROM node:24-alpine",
"COPY --from=headscale /ko-app/headscale /usr/local/bin/headscale",
].join("\n"),
);
const configPath = join(root, "tests/integration/setup/config.yaml");
const image = await tc.GenericContainer.fromDockerfile(tmpDir).build();
container = await image
.withCopyFilesToContainer([
{ source: configPath, target: "/etc/headscale/config.yaml" },
{ source: bundlePath, target: "/test/proc-helper.js" },
{ source: runnerPath, target: "/test/test-runner.js" },
])
.withCommand(["sh", "-c", "headscale serve & sleep 2 && sleep infinity"])
.withWaitStrategy(tc.Wait.forLogMessage("headscale", 1).withStartupTimeout(30_000))
.start();
}, 120_000);
afterAll(async () => {
await container?.stop({ remove: true, removeVolumes: true });
await rm(tmpDir, { recursive: true, force: true });
});
test("findHeadscaleServe finds the real headscale process", async () => {
const result = await container.exec(["node", "/test/test-runner.js"]);
const output = JSON.parse(result.stdout.trim());
expect(result.exitCode).toBe(0);
expect(output.found).toBe(true);
expect(output.pid).toBeGreaterThan(0);
});
});
+43 -40
View File
@@ -1,51 +1,54 @@
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import tc from 'testcontainers';
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import tc from "testcontainers";
export interface HeadscaleEnv {
container: tc.StartedTestContainer;
apiUrl: string;
apiKey: string;
container: tc.StartedTestContainer;
apiUrl: string;
apiKey: string;
}
export interface HeadscaleOptions {
labels?: Record<string, string>;
}
const cwd = fileURLToPath(import.meta.url);
const config = join(cwd, '..', 'config.yaml');
const config = join(cwd, "..", "config.yaml");
export async function startHeadscale(version: string): Promise<HeadscaleEnv> {
const container = await new tc.GenericContainer(
`headscale/headscale:${version}`,
)
.withExposedPorts(8080)
.withWaitStrategy(
tc.Wait.forHttp('/health', 8080)
.withStartupTimeout(30_000)
.forStatusCode(200),
)
.withCopyFilesToContainer([
{
source: config,
target: '/etc/headscale/config.yaml',
},
])
.withCommand(['serve'])
.start();
export async function startHeadscale(
version: string,
options?: HeadscaleOptions,
): Promise<HeadscaleEnv> {
let builder = new tc.GenericContainer(`headscale/headscale:${version}`)
.withExposedPorts(8080)
.withWaitStrategy(
tc.Wait.forHttp("/health", 8080).withStartupTimeout(30_000).forStatusCode(200),
)
.withCopyFilesToContainer([
{
source: config,
target: "/etc/headscale/config.yaml",
},
])
.withCommand(["serve"]);
const host = container.getHost();
const port = container.getMappedPort(8080);
const apiUrl = `http://${host}:${port}`;
if (options?.labels) {
builder = builder.withLabels(options.labels);
}
const exec = await container.exec([
'headscale',
'apikeys',
'create',
'-o',
'json',
]);
const container = await builder.start();
if (exec.exitCode !== 0) {
throw new Error(`headscale apikeys create failed:\n${exec.stderr}`);
}
const host = container.getHost();
const port = container.getMappedPort(8080);
const apiUrl = `http://${host}:${port}`;
const apiKey = JSON.parse(exec.stdout.toString());
return { container, apiUrl, apiKey };
const exec = await container.exec(["headscale", "apikeys", "create", "-o", "json"]);
if (exec.exitCode !== 0) {
throw new Error(`headscale apikeys create failed:\n${exec.stderr}`);
}
const apiKey = JSON.parse(exec.stdout.toString());
return { container, apiUrl, apiKey };
}
-77
View File
@@ -1,77 +0,0 @@
import { describe, expect, test } from 'vitest';
import { getRuntimeClient, HS_VERSIONS } from './setup/env';
describe.sequential.for(HS_VERSIONS)('Headscale %s: Users', (version) => {
test('users can be created', async () => {
const client = await getRuntimeClient(version);
const user = await client.createUser('tale@');
expect(user).toBeDefined();
expect(user.name).toBe('tale@');
});
test('users can be created with attributes', async () => {
const client = await getRuntimeClient(version);
const user = await client.createUser(
'test-user@',
'test-user@example.com',
'Test User',
'https://github.com/tale.png',
);
expect(user).toBeDefined();
expect(user.name).toBe('test-user@');
expect(user.email).toBe('test-user@example.com');
expect(user.displayName).toBe('Test User');
expect(user.profilePicUrl).toBe('https://github.com/tale.png');
});
test('users can be listed', async () => {
const client = await getRuntimeClient(version);
const users = await client.getUsers();
expect(Array.isArray(users)).toBe(true);
expect(users.length).toBeGreaterThanOrEqual(2);
});
test('users can be listed by name', async () => {
const client = await getRuntimeClient(version);
const users = await client.getUsers(undefined, 'tale@');
expect(Array.isArray(users)).toBe(true);
expect(users.length).toBe(1);
expect(users[0].name).toBe('tale@');
});
test('users can be listed by email', async () => {
const client = await getRuntimeClient(version);
const users = await client.getUsers(
undefined,
undefined,
'test-user@example.com',
);
expect(Array.isArray(users)).toBe(true);
expect(users.length).toBe(1);
expect(users[0].email).toBe('test-user@example.com');
});
test('users can be renamed', async () => {
const client = await getRuntimeClient(version);
const usersBefore = await client.getUsers(undefined, 'tale@');
expect(usersBefore.length).toBe(1);
const user = usersBefore[0];
await client.renameUser(user.id, 'renamed-user@');
const usersAfter = await client.getUsers(undefined, 'renamed-user@');
expect(usersAfter.length).toBe(1);
expect(usersAfter[0].id).toBe(user.id);
});
test('users can be deleted', async () => {
const client = await getRuntimeClient(version);
const usersBefore = await client.getUsers(undefined, 'test-user@');
expect(usersBefore.length).toBe(1);
const user = usersBefore[0];
await client.deleteUser(user.id);
const usersAfter = await client.getUsers(undefined, 'test-user@');
expect(usersAfter.length).toBe(0);
});
});
-39
View File
@@ -1,39 +0,0 @@
import { describe, expect, test } from 'vitest';
import canonicals from '~/openapi-canonical-families.json';
import {
getBootstrapClient,
getRuntimeClient,
HS_VERSIONS,
Version,
} from './setup/env';
function getCanonicalVersion(version: Version) {
const canonical = Object.entries(canonicals).find(([_, family]) =>
family.includes(version),
)?.[0] as Version | undefined;
if (!canonical) {
return version;
}
return canonical;
}
describe.for(HS_VERSIONS)('Headscale %s: Runtime Client', (version) => {
test('the runtime client is usable', async () => {
const bootstrapper = await getBootstrapClient(version);
const runtimeClient = bootstrapper.getRuntimeClient('test-api-key');
expect(runtimeClient).toBeDefined();
});
test('the runtime client has the correct canonical API version', async () => {
const bootstrapper = await getBootstrapClient(version);
expect(bootstrapper.apiVersion).toBe(getCanonicalVersion(version));
});
test('the health check endpoint works', async () => {
const client = await getRuntimeClient(version);
const health = await client.isHealthy();
expect(health).toBe(true);
});
});