mirror of
https://github.com/tale/headplane.git
synced 2026-08-30 00:47:17 +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 {
|
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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user