mirror of
https://github.com/tale/headplane.git
synced 2026-08-29 08:27:08 +00:00
feat: clamp docker daemon version and support v1.24
Fixes HP-563.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user