mirror of
https://github.com/tale/headplane.git
synced 2026-08-19 01:16:18 +00:00
test: add docker and proc e2e testing
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user