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 {
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) {
const currentParts = current.split(".").map(Number);
@@ -50,7 +52,19 @@ function compareApiVersions(current: string, required: 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 = {
@@ -73,6 +87,7 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
private maxAttempts = 10;
private client: Client | undefined;
private containerId: string | undefined;
private apiVersion: string | undefined;
get name() {
return "Docker";
@@ -82,35 +97,8 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
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() {
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
// selector because of legacy support.
@@ -198,10 +186,18 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
"config",
"Docker API version %s is too old, require %s or newer",
versionInfo.ApiVersion,
REQUIRED_DOCKER_API_VERSION,
MIN_DOCKER_API_VERSION,
);
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) {
log.error("config", "Failed to validate Docker API version: %s", 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());
const res = await this.client.request({
method: "GET",
path: `/v${REQUIRED_DOCKER_API_VERSION}/containers/json?${qp.toString()}`,
path: `/v${this.apiVersion}/containers/json?${qp.toString()}`,
});
if (res.statusCode !== 200) {
@@ -256,7 +252,7 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
}
async onConfigChange(headscale: Headscale) {
if (!this.client) {
if (!this.client || !this.apiVersion) {
return;
}
@@ -268,7 +264,7 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
const response = await this.client.request({
method: "POST",
path: `/v${REQUIRED_DOCKER_API_VERSION}/containers/${this.containerId}/restart`,
path: `/v${this.apiVersion}/containers/${this.containerId}/restart`,
});
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
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
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_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", () => {
let env: HeadscaleEnv;
let dockerSocket: string;
@@ -77,16 +86,21 @@ describe("DockerIntegration", () => {
// 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 dockerClient = connect(dockerSocket);
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 = {
health: async () => {
try {
const res = await dockerClient.request({
method: "GET",
path: `/v1.44/containers/${containerId}/json`,
path: `/v${apiVersion}/containers/${containerId}/json`,
});
const info = (await res.body.json()) as any;
return info.State?.Running === true;