From faa61b0f1d24c671d5703e5fe82e7826a325b380 Mon Sep 17 00:00:00 2001 From: George Ntoutsos Date: Fri, 25 Apr 2025 02:03:33 +0300 Subject: [PATCH] feat: add filtering by container label for Docker integration (#194) --- app/server/config/integration/docker.ts | 93 ++++++++++++++++++++++++- app/server/config/schema.ts | 6 ++ compose.yaml | 2 + config.example.yaml | 10 ++- docs/Integrated-Mode.md | 13 ++-- 5 files changed, 114 insertions(+), 10 deletions(-) diff --git a/app/server/config/integration/docker.ts b/app/server/config/integration/docker.ts index e7e889f..8e11500 100644 --- a/app/server/config/integration/docker.ts +++ b/app/server/config/integration/docker.ts @@ -6,6 +6,11 @@ import log from '~/utils/log'; import type { HeadplaneConfig } from '../schema'; import { Integration } from './abstract'; +interface DockerContainer { + Id: string; + Names: string[]; +} + type T = NonNullable['docker']; export default class DockerIntegration extends Integration { private maxAttempts = 10; @@ -15,13 +20,63 @@ export default class DockerIntegration extends Integration { return 'Docker'; } + async getContainerName(label: string, value: string): Promise { + 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() { - if (this.context.container_name.length === 0) { - log.error('config', 'Docker container name is empty'); + // Perform a basic check to see if any of the required properties are set + if ( + this.context.container_name.length === 0 && + !this.context.container_label + ) { + log.error('config', 'Docker container name and label are both empty'); return false; } - log.info('config', 'Using container: %s', this.context.container_name); + if ( + this.context.container_name.length > 0 && + !this.context.container_label + ) { + log.error( + 'config', + 'Docker container name and label are mutually exclusive', + ); + return false; + } + + // Verify that Docker socket is reachable let url: URL | undefined; try { url = new URL(this.context.socket); @@ -72,6 +127,38 @@ export default class DockerIntegration extends Integration { socketPath: url.pathname, }); } + if (this.client === undefined) { + log.error('config', 'Failed to create Docker client'); + return false; + } + + if (this.context.container_name.length === 0) { + try { + if (this.context.container_label === undefined) { + log.error('config', 'Docker container label is not defined'); + return false; + } + const containerName = await this.getContainerName( + this.context.container_label.name, + this.context.container_label.value, + ); + if (containerName.length === 0) { + log.error( + 'config', + 'No Docker containers found matching label: %s=%s', + this.context.container_label.name, + this.context.container_label.value, + ); + return false; + } + this.context.container_name = containerName; + } catch (error) { + log.error('config', 'Failed to get Docker container name: %s', error); + return false; + } + } + + log.info('config', 'Using container: %s', this.context.container_name); return this.client !== undefined; } diff --git a/app/server/config/schema.ts b/app/server/config/schema.ts index df61966..e45a687 100644 --- a/app/server/config/schema.ts +++ b/app/server/config/schema.ts @@ -41,10 +41,16 @@ const headscaleConfig = type({ config_strict: stringToBool, }).onDeepUndeclaredKey('reject'); +const containerLabel = type({ + name: 'string', + value: 'string', +}).optional(); + const dockerConfig = type({ enabled: stringToBool, container_name: 'string', socket: 'string = "unix:///var/run/docker.sock"', + container_label: containerLabel, }); const kubernetesConfig = type({ diff --git a/compose.yaml b/compose.yaml index 7fcb41d..ed18ced 100644 --- a/compose.yaml +++ b/compose.yaml @@ -10,6 +10,8 @@ services: headscale: image: "headscale/headscale:0.25.1" container_name: "headscale" + labels: + - com.headplane.selector=headscale restart: "unless-stopped" command: "serve" networks: diff --git a/config.example.yaml b/config.example.yaml index 02ad0d6..b8ffd29 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -47,8 +47,14 @@ headscale: integration: docker: enabled: false - # The name (or ID) of the container running Headscale - container_name: "headscale" + # Preferred method: use container_label to dynamically discover the Headscale container. + container_label: + name: "com.headplane.selector" + value: "headscale" + # Optional fallback: directly specify the container name (or ID) + # of the container running Headscale + # container_name: "headscale" + # The path to the Docker socket (do not change this if you are unsure) # Docker socket paths must start with unix:// or tcp:// and at the moment # https connections are not supported. diff --git a/docs/Integrated-Mode.md b/docs/Integrated-Mode.md index 7b1ef7e..e2a286b 100644 --- a/docs/Integrated-Mode.md +++ b/docs/Integrated-Mode.md @@ -70,11 +70,14 @@ you build the container yourself or run Headplane in Bare-Metal mode. > setting up your `config.yaml` file to the appropriate values. ## Docker Integration -The Docker integration is the easiest to setup, as it only requires the Docker socket -to be mounted into the container along with some configuration. As long as Headplane -has access to the Docker socket and the name of the Headscale container, it will -automatically propagate config and DNS changes to Headscale without any additional -configuration. +The Docker integration is the easiest to set up, as it only requires mounting the +Docker socket into the container along with some basic configuration. Headplane +uses Docker labels to discover the Headscale container. As long as Headplane has +access to the Docker socket and can identify the Headscale container—either by +label or name—it will automatically propagate configuration and DNS changes to +Headscale without any additional setup. Alternatively, instead of using a label +to dynamically determine the container name, it is possible to directly specify +the container name. ## Native Linux (/proc) Integration The `proc` integration is used when you are running Headscale and Headplane on