feat: add filtering by container label for Docker integration (#194)

This commit is contained in:
George Ntoutsos 2025-04-25 02:03:33 +03:00 committed by GitHub
parent 6b63fe209f
commit faa61b0f1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 114 additions and 10 deletions

View File

@ -6,6 +6,11 @@ import log from '~/utils/log';
import type { HeadplaneConfig } from '../schema'; import type { HeadplaneConfig } from '../schema';
import { Integration } from './abstract'; import { Integration } from './abstract';
interface DockerContainer {
Id: string;
Names: string[];
}
type T = NonNullable<HeadplaneConfig['integration']>['docker']; type T = NonNullable<HeadplaneConfig['integration']>['docker'];
export default class DockerIntegration extends Integration<T> { export default class DockerIntegration extends Integration<T> {
private maxAttempts = 10; private maxAttempts = 10;
@ -15,13 +20,63 @@ export default class DockerIntegration extends Integration<T> {
return 'Docker'; return 'Docker';
} }
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() {
if (this.context.container_name.length === 0) { // Perform a basic check to see if any of the required properties are set
log.error('config', 'Docker container name is empty'); if (
this.context.container_name.length === 0 &&
!this.context.container_label
) {
log.error('config', 'Docker container name and label are both empty');
return false; 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; let url: URL | undefined;
try { try {
url = new URL(this.context.socket); url = new URL(this.context.socket);
@ -72,6 +127,38 @@ export default class DockerIntegration extends Integration<T> {
socketPath: url.pathname, 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; return this.client !== undefined;
} }

View File

@ -41,10 +41,16 @@ const headscaleConfig = type({
config_strict: stringToBool, config_strict: stringToBool,
}).onDeepUndeclaredKey('reject'); }).onDeepUndeclaredKey('reject');
const containerLabel = type({
name: 'string',
value: 'string',
}).optional();
const dockerConfig = type({ const dockerConfig = type({
enabled: stringToBool, enabled: stringToBool,
container_name: 'string', container_name: 'string',
socket: 'string = "unix:///var/run/docker.sock"', socket: 'string = "unix:///var/run/docker.sock"',
container_label: containerLabel,
}); });
const kubernetesConfig = type({ const kubernetesConfig = type({

View File

@ -10,6 +10,8 @@ services:
headscale: headscale:
image: "headscale/headscale:0.25.1" image: "headscale/headscale:0.25.1"
container_name: "headscale" container_name: "headscale"
labels:
- com.headplane.selector=headscale
restart: "unless-stopped" restart: "unless-stopped"
command: "serve" command: "serve"
networks: networks:

View File

@ -47,8 +47,14 @@ headscale:
integration: integration:
docker: docker:
enabled: false enabled: false
# The name (or ID) of the container running Headscale # Preferred method: use container_label to dynamically discover the Headscale container.
container_name: "headscale" 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) # 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 # Docker socket paths must start with unix:// or tcp:// and at the moment
# https connections are not supported. # https connections are not supported.

View File

@ -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. > setting up your `config.yaml` file to the appropriate values.
## Docker Integration ## Docker Integration
The Docker integration is the easiest to setup, as it only requires the Docker socket The Docker integration is the easiest to set up, as it only requires mounting the
to be mounted into the container along with some configuration. As long as Headplane Docker socket into the container along with some basic configuration. Headplane
has access to the Docker socket and the name of the Headscale container, it will uses Docker labels to discover the Headscale container. As long as Headplane has
automatically propagate config and DNS changes to Headscale without any additional access to the Docker socket and can identify the Headscale container—either by
configuration. 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 ## Native Linux (/proc) Integration
The `proc` integration is used when you are running Headscale and Headplane on The `proc` integration is used when you are running Headscale and Headplane on