diff --git a/app/server/config/integration/docker.ts b/app/server/config/integration/docker.ts index 8e11500..0753b75 100644 --- a/app/server/config/integration/docker.ts +++ b/app/server/config/integration/docker.ts @@ -15,6 +15,7 @@ type T = NonNullable['docker']; export default class DockerIntegration extends Integration { private maxAttempts = 10; private client: Client | undefined; + private containerId: string | undefined; get name() { return 'Docker'; @@ -56,22 +57,13 @@ export default class DockerIntegration extends Integration { } async isAvailable() { - // 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; - } - - if ( - this.context.container_name.length > 0 && - !this.context.container_label - ) { + // Basic configuration check, the name overrides the container_label + // selector because of legacy support. + const { container_name, container_label } = this.context; + if (container_name.length === 0 && container_label.length === 0) { log.error( 'config', - 'Docker container name and label are mutually exclusive', + 'Missing a Docker `container_name` or `container_label`', ); return false; } @@ -127,40 +119,73 @@ 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; - } + const qp = new URLSearchParams({ + filters: JSON.stringify( + container_name.length > 0 + ? { name: container_name } + : { label: [container_label] }, + ), + }); + + const res = await this.client.request({ + method: 'GET', + path: `/v1.30/containers/json?${qp.toString()}`, + }); + + if (res.statusCode !== 200) { + log.error('config', 'Could not request available Docker containers'); + log.debug('config', 'Error Details: %o', await res.body.json()); + return false; } - log.info('config', 'Using container: %s', this.context.container_name); + const data = (await res.body.json()) as DockerContainer[]; + if (data.length > 1) { + if (container_name.length > 0) { + log.error( + 'config', + `Found multiple containers with name ${container_name}`, + ); + } else { + log.error( + 'config', + `Found multiple containers with label ${container_label}`, + ); + } - return this.client !== undefined; + return false; + } + + if (data.length === 0) { + if (container_name.length > 0) { + log.error( + 'config', + `No container found with the name ${container_name}`, + ); + } else { + log.error( + 'config', + `No container found with the label ${container_label}`, + ); + } + + return false; + } + + this.containerId = data[0].Id; + log.info( + 'config', + 'Using container: %s (ID: %s)', + data[0].Names[0], + this.containerId, + ); + + return this.client !== undefined && this.containerId !== undefined; } async onConfigChange(client: ApiClient) { @@ -175,13 +200,13 @@ export default class DockerIntegration extends Integration { log.debug( 'config', 'Restarting container: %s (attempt %d)', - this.context.container_name, + this.containerId, attempts, ); const response = await this.client.request({ method: 'POST', - path: `/v1.30/containers/${this.context.container_name}/restart`, + path: `/v1.30/containers/${this.containerId}/restart`, }); if (response.statusCode !== 204) { @@ -217,11 +242,7 @@ export default class DockerIntegration extends Integration { continue; } - log.error( - 'config', - 'Missed restart deadline for %s', - this.context.container_name, - ); + log.error('config', 'Missed restart deadline for %s', this.containerId); return; } } diff --git a/app/server/config/integration/index.ts b/app/server/config/integration/index.ts index 69a7ef2..3bef910 100644 --- a/app/server/config/integration/index.ts +++ b/app/server/config/integration/index.ts @@ -13,7 +13,7 @@ export async function loadIntegration(context: HeadplaneConfig['integration']) { try { const res = await integration.isAvailable(); if (!res) { - log.error('config', 'Integration %s is not available', integration); + log.error('config', 'Integration %s is not available', integration.name); return; } } catch (error) { diff --git a/app/server/config/schema.ts b/app/server/config/schema.ts index d1f7822..1b246d7 100644 --- a/app/server/config/schema.ts +++ b/app/server/config/schema.ts @@ -46,11 +46,6 @@ const headscaleConfig = type({ dns_records_path: 'string?', }).onDeepUndeclaredKey('reject'); -const containerLabel = type({ - name: 'string', - value: 'string', -}).optional(); - const agentConfig = type({ enabled: stringToBool.default(false), host_name: 'string = "headplane-agent"', @@ -64,8 +59,8 @@ const agentConfig = type({ const dockerConfig = type({ enabled: stringToBool, container_name: 'string = ""', + container_label: 'string = "me.tale.headplane.target=headscale"', socket: 'string = "unix:///var/run/docker.sock"', - container_label: containerLabel, }); const kubernetesConfig = type({ diff --git a/compose.yaml b/compose.yaml index ed18ced..8314de0 100644 --- a/compose.yaml +++ b/compose.yaml @@ -11,7 +11,7 @@ services: image: "headscale/headscale:0.25.1" container_name: "headscale" labels: - - com.headplane.selector=headscale + me.tale.headplane.target: headscale restart: "unless-stopped" command: "serve" networks: diff --git a/config.example.yaml b/config.example.yaml index a1f1dbd..14df23d 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -84,18 +84,23 @@ integration: # at the same time as any of these and is recommended for the best experience. docker: enabled: false - # 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 + + # By default we check for the presence of a container label (see the docs) + # to determine the container to signal when changes are made to DNS settings. + container_label: "me.tale.headplane.target=headscale" + + # HOWEVER, you can fallback to a container name if you desire, but this is + # not recommended as its brittle and doesn't work with orchestrators that + # automatically assign container names. + # + # If `container_name` is set, it will override any label checks. # 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. socket: "unix:///var/run/docker.sock" + # Please refer to docs/integration/Kubernetes.md for more information # on how to configure the Kubernetes integration. There are requirements in # order to allow Headscale to be controlled by Headplane in a cluster. diff --git a/docs/Integrated-Mode.md b/docs/Integrated-Mode.md index 764006c..f4125e3 100644 --- a/docs/Integrated-Mode.md +++ b/docs/Integrated-Mode.md @@ -58,6 +58,9 @@ services: container_name: headscale restart: unless-stopped command: serve + labels: + # This is needed for Headplane to find it and signal it + me.tale.headplane.target: headscale ports: - '8080:8080' volumes: