feat: clamp docker daemon version and support v1.24

Fixes HP-563.
This commit is contained in:
Aarnav Tale
2026-08-25 14:13:51 -07:00
parent cbc81bb5cd
commit 5515b4bc02
3 changed files with 52 additions and 37 deletions
+30 -34
View File
@@ -16,9 +16,11 @@ interface DockerContainer {
interface DockerVersionInfo { interface DockerVersionInfo {
ApiVersion?: string; ApiVersion?: string;
MinAPIVersion?: string;
} }
const REQUIRED_DOCKER_API_VERSION = "1.44"; const TARGET_DOCKER_API_VERSION = "1.44";
const MIN_DOCKER_API_VERSION = "1.24";
function compareApiVersions(current: string, required: string) { function compareApiVersions(current: string, required: string) {
const currentParts = current.split(".").map(Number); const currentParts = current.split(".").map(Number);
@@ -50,7 +52,19 @@ function compareApiVersions(current: string, required: string) {
} }
function isSupportedDockerApiVersion(apiVersion: string) { function isSupportedDockerApiVersion(apiVersion: string) {
return compareApiVersions(apiVersion, REQUIRED_DOCKER_API_VERSION) >= 0; return compareApiVersions(apiVersion, MIN_DOCKER_API_VERSION) >= 0;
}
function clampApiVersion(target: string, min: string, max: string) {
if (compareApiVersions(target, max) > 0) {
return max;
}
if (compareApiVersions(target, min) < 0) {
return min;
}
return target;
} }
const configSchema = { const configSchema = {
@@ -73,6 +87,7 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
private maxAttempts = 10; private maxAttempts = 10;
private client: Client | undefined; private client: Client | undefined;
private containerId: string | undefined; private containerId: string | undefined;
private apiVersion: string | undefined;
get name() { get name() {
return "Docker"; return "Docker";
@@ -82,35 +97,8 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
return configSchema; return configSchema;
} }
async getContainerName(label: string, value: string): Promise<string> {
if (!this.client) {
throw new Error("Docker client is not initialized");
}
const filters = encodeURIComponent(
JSON.stringify({
label: [`${label}=${value}`],
}),
);
const { body } = await this.client.request({
method: "GET",
path: `/containers/json?filters=${filters}`,
});
const containers: DockerContainer[] = (await body.json()) as DockerContainer[];
if (containers.length > 1) {
throw new Error(
`Found multiple Docker containers matching label ${label}=${value}. Please specify a container name.`,
);
}
if (containers.length === 0) {
throw new Error(`No Docker containers found matching label: ${label}=${value}`);
}
log.info("config", "Found Docker container matching label: %s=%s", label, value);
return containers[0].Id;
}
async isAvailable() { async isAvailable() {
log.info("config", "Requiring Docker API version %s or newer", REQUIRED_DOCKER_API_VERSION); log.info("config", "Requiring Docker API version %s or newer", MIN_DOCKER_API_VERSION);
// Basic configuration check, the name overrides the container_label // Basic configuration check, the name overrides the container_label
// selector because of legacy support. // selector because of legacy support.
@@ -198,10 +186,18 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
"config", "config",
"Docker API version %s is too old, require %s or newer", "Docker API version %s is too old, require %s or newer",
versionInfo.ApiVersion, versionInfo.ApiVersion,
REQUIRED_DOCKER_API_VERSION, MIN_DOCKER_API_VERSION,
); );
return false; return false;
} }
this.apiVersion = clampApiVersion(
TARGET_DOCKER_API_VERSION,
versionInfo.MinAPIVersion ?? MIN_DOCKER_API_VERSION,
versionInfo.ApiVersion,
);
log.info("config", "Using Docker API version %s", this.apiVersion);
} catch (error) { } catch (error) {
log.error("config", "Failed to validate Docker API version: %s", error); log.error("config", "Failed to validate Docker API version: %s", error);
log.debug("config", "Version check error: %o", error); log.debug("config", "Version check error: %o", error);
@@ -219,7 +215,7 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
log.debug("config", "Requesting Docker containers with filters: %s", qp.toString()); log.debug("config", "Requesting Docker containers with filters: %s", qp.toString());
const res = await this.client.request({ const res = await this.client.request({
method: "GET", method: "GET",
path: `/v${REQUIRED_DOCKER_API_VERSION}/containers/json?${qp.toString()}`, path: `/v${this.apiVersion}/containers/json?${qp.toString()}`,
}); });
if (res.statusCode !== 200) { if (res.statusCode !== 200) {
@@ -256,7 +252,7 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
} }
async onConfigChange(headscale: Headscale) { async onConfigChange(headscale: Headscale) {
if (!this.client) { if (!this.client || !this.apiVersion) {
return; return;
} }
@@ -268,7 +264,7 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
const response = await this.client.request({ const response = await this.client.request({
method: "POST", method: "POST",
path: `/v${REQUIRED_DOCKER_API_VERSION}/containers/${this.containerId}/restart`, path: `/v${this.apiVersion}/containers/${this.containerId}/restart`,
}); });
if (response.statusCode !== 204) { if (response.statusCode !== 204) {
+5
View File
@@ -100,6 +100,11 @@ because Headplane needs the following permissions:
- Access to the Docker socket (usually `/var/run/docker.sock`, you may also use - Access to the Docker socket (usually `/var/run/docker.sock`, you may also use
a proxy such as [Tecnativa/docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy)). a proxy such as [Tecnativa/docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy)).
Headplane negotiates the Docker API version with the daemon at startup. It
targets API version `1.44` and falls back to whatever the daemon serves, down
to a floor of `1.24` (Docker Engine 1.12+). This covers all modern Docker
installations as well as Podman's Docker-compatible socket.
#### Configuration #### Configuration
First you'll need to run both Headscale and Headplane in the same Docker First you'll need to run both Headscale and Headplane in the same Docker
+17 -3
View File
@@ -8,6 +8,15 @@ import { type HeadscaleEnv, startHeadscale } from "../setup/start-headscale";
const TEST_LABEL_KEY = "me.tale.headplane.integration-test"; const TEST_LABEL_KEY = "me.tale.headplane.integration-test";
const TEST_LABEL_VALUE = `docker-${Date.now()}`; const TEST_LABEL_VALUE = `docker-${Date.now()}`;
function connect(socket: string) {
const url = new URL(socket);
if (url.protocol === "unix:") {
return new Client("http://localhost", { socketPath: url.pathname });
}
return new Client(url.href.replace(url.protocol, "http:"));
}
describe("DockerIntegration", () => { describe("DockerIntegration", () => {
let env: HeadscaleEnv; let env: HeadscaleEnv;
let dockerSocket: string; let dockerSocket: string;
@@ -77,16 +86,21 @@ describe("DockerIntegration", () => {
// Health check goes through the Docker socket to avoid stale port // Health check goes through the Docker socket to avoid stale port
// mappings after container restart. // mappings after container restart.
const containerId = env.container.getId(); const containerId = env.container.getId();
const dockerClient = new Client("http://localhost", { const dockerClient = connect(dockerSocket);
socketPath: "/var/run/docker.sock",
const versionRes = await dockerClient.request({
method: "GET",
path: "/version",
}); });
const versionInfo = (await versionRes.body.json()) as { ApiVersion?: string };
const apiVersion = versionInfo.ApiVersion ?? "1.24";
const mockHeadscale = { const mockHeadscale = {
health: async () => { health: async () => {
try { try {
const res = await dockerClient.request({ const res = await dockerClient.request({
method: "GET", method: "GET",
path: `/v1.44/containers/${containerId}/json`, path: `/v${apiVersion}/containers/${containerId}/json`,
}); });
const info = (await res.body.json()) as any; const info = (await res.body.json()) as any;
return info.State?.Running === true; return info.State?.Running === true;