diff --git a/app/integration/index.ts b/app/integration/index.ts deleted file mode 100644 index 2132020..0000000 --- a/app/integration/index.ts +++ /dev/null @@ -1,68 +0,0 @@ -import log from '~/utils/log'; - -import dockerIntegration from './docker'; -import type { IntegrationFactory } from './integration'; -import kubernetesIntegration from './kubernetes'; -import procIntegration from './proc'; - -export * from './integration'; - -export async function loadIntegration() { - let integration = process.env.HEADSCALE_INTEGRATION?.trim().toLowerCase(); - - // Old HEADSCALE_CONTAINER variable upgrade path - // This ensures that when people upgrade from older versions of Headplane - // they don't explicitly need to define the new HEADSCALE_INTEGRATION - // variable that is needed to configure docker - if (!integration && process.env.HEADSCALE_CONTAINER) { - integration = 'docker'; - } - - if (!integration) { - log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION'); - return; - } - - let integrationFactory: IntegrationFactory | undefined; - switch (integration.toLowerCase().trim()) { - case 'docker': { - integrationFactory = dockerIntegration; - break; - } - - case 'proc': - case 'native': - case 'linux': { - integrationFactory = procIntegration; - break; - } - - case 'kubernetes': - case 'k8s': { - integrationFactory = kubernetesIntegration; - break; - } - - default: { - log.error('INTG', 'Unknown integration: %s', integration); - throw new Error(`Unknown integration: ${integration}`); - } - } - - log.info('INTG', 'Loading integration: %s', integration); - try { - const res = await integrationFactory.isAvailable( - integrationFactory.context, - ); - if (!res) { - log.error('INTG', 'Integration %s is not available', integration); - return; - } - } catch (error) { - log.error('INTG', 'Failed to load integration %s: %s', integration, error); - return; - } - - log.info('INTG', 'Loaded integration: %s', integration); - return integrationFactory; -} diff --git a/app/integration/integration.ts b/app/integration/integration.ts deleted file mode 100644 index 990dc0b..0000000 --- a/app/integration/integration.ts +++ /dev/null @@ -1,11 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export interface IntegrationFactory { - name: string; - context: T; - isAvailable: (context: T) => Promise | boolean; - onConfigChange?: (context: T) => Promise | void; -} - -export function createIntegration(options: IntegrationFactory) { - return options; -} diff --git a/app/integration/proc.ts b/app/integration/proc.ts deleted file mode 100644 index 12bc6e2..0000000 --- a/app/integration/proc.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { readdir, readFile } from 'node:fs/promises'; -import { platform } from 'node:os'; -import { join, resolve } from 'node:path'; -import { kill } from 'node:process'; - -import log from '~/utils/log'; - -import { createIntegration } from './integration'; - -interface Context { - pid: number | undefined; -} - -export default createIntegration({ - name: 'Native Linux (/proc)', - context: { - pid: undefined, - }, - isAvailable: async (context) => { - if (platform() !== 'linux') { - log.error('INTG', '/proc is only available on Linux'); - return false; - } - - log.debug('INTG', 'Checking /proc for Headscale process'); - const dir = resolve('/proc'); - try { - const subdirs = await readdir(dir); - const promises = subdirs.map(async (dir) => { - const pid = Number.parseInt(dir, 10); - - if (Number.isNaN(pid)) { - return; - } - - const path = join('/proc', dir, 'cmdline'); - try { - log.debug('INTG', 'Reading %s', path); - const data = await readFile(path, 'utf8'); - if (data.includes('headscale')) { - return pid; - } - } catch (error) { - log.error('INTG', 'Failed to read %s: %s', path, error); - } - }); - - const results = await Promise.allSettled(promises); - const pids = []; - - for (const result of results) { - if (result.status === 'fulfilled' && result.value) { - pids.push(result.value); - } - } - - log.debug('INTG', 'Found Headscale processes: %o', pids); - if (pids.length > 1) { - log.error( - 'INTG', - 'Found %d Headscale processes: %s', - pids.length, - pids.join(', '), - ); - return false; - } - - if (pids.length === 0) { - log.error('INTG', 'Could not find Headscale process'); - return false; - } - - context.pid = pids[0]; - log.info('INTG', 'Found Headscale process with PID: %d', context.pid); - return true; - } catch { - log.error('INTG', 'Failed to read /proc'); - return false; - } - }, -}); diff --git a/app/routes/dns/dns-actions.ts b/app/routes/dns/dns-actions.ts index 2286cfa..c467590 100644 --- a/app/routes/dns/dns-actions.ts +++ b/app/routes/dns/dns-actions.ts @@ -1,5 +1,6 @@ import { ActionFunctionArgs, data } from 'react-router'; import { hs_getConfig, hs_patchConfig } from '~/utils/config/loader'; +import { hp_getIntegration } from '~/utils/integration/loader'; import { auth } from '~/utils/sessions.server'; export async function dnsAction({ request }: ActionFunctionArgs) { @@ -39,8 +40,6 @@ export async function dnsAction({ request }: ActionFunctionArgs) { default: return data({ success: false }, 400); } - - // TODO: Integration update } async function renameTailnet(formData: FormData) { @@ -55,6 +54,8 @@ async function renameTailnet(formData: FormData) { value: newName, }, ]); + + await hp_getIntegration()?.onConfigChange(); } async function toggleMagic(formData: FormData) { @@ -69,6 +70,8 @@ async function toggleMagic(formData: FormData) { value: newState === 'enabled', }, ]); + + await hp_getIntegration()?.onConfigChange(); } async function removeNs(formData: FormData) { @@ -104,6 +107,8 @@ async function removeNs(formData: FormData) { }, ]); } + + await hp_getIntegration()?.onConfigChange(); } async function addNs(formData: FormData) { @@ -141,6 +146,8 @@ async function addNs(formData: FormData) { }, ]); } + + await hp_getIntegration()?.onConfigChange(); } async function removeDomain(formData: FormData) { @@ -162,6 +169,8 @@ async function removeDomain(formData: FormData) { value: domains, }, ]); + + await hp_getIntegration()?.onConfigChange(); } async function addDomain(formData: FormData) { @@ -184,6 +193,8 @@ async function addDomain(formData: FormData) { value: domains, }, ]); + + await hp_getIntegration()?.onConfigChange(); } async function removeRecord(formData: FormData) { @@ -209,6 +220,8 @@ async function removeRecord(formData: FormData) { value: records, }, ]); + + await hp_getIntegration()?.onConfigChange(); } async function addRecord(formData: FormData) { @@ -234,4 +247,6 @@ async function addRecord(formData: FormData) { value: records, }, ]); + + await hp_getIntegration()?.onConfigChange(); } diff --git a/app/utils/config/loader.ts b/app/utils/config/loader.ts index e9ed692..4c0fa54 100644 --- a/app/utils/config/loader.ts +++ b/app/utils/config/loader.ts @@ -1,8 +1,8 @@ import { constants, access, readFile, writeFile } from 'node:fs/promises'; import { Document, parseDocument } from 'yaml'; +import { hp_getIntegration } from '~/utils/integration/loader'; import log from '~/utils/log'; import mutex from '~/utils/mutex'; -import type { HeadplaneConfig } from '~server/context/parser'; import { HeadscaleConfig, validateConfig } from './parser'; let runtimeYaml: Document | undefined = undefined; @@ -182,8 +182,8 @@ export async function hs_patchConfig(patches: PatchConfig[]) { // Revalidate the configuration const newRawConfig = runtimeYaml.toJSON() as unknown; - runtimeConfig = runtimeStrict - ? validateConfig(newRawConfig, runtimeStrict) + runtimeConfig = __hs_context.config_strict + ? validateConfig(newRawConfig, true) : (newRawConfig as HeadscaleConfig); log.debug( @@ -197,3 +197,4 @@ export async function hs_patchConfig(patches: PatchConfig[]) { // IMPORTANT THIS IS A SIDE EFFECT ON INIT hs_loadConfig(__hs_context.config_path, __hs_context.config_strict); +hp_getIntegration(); diff --git a/app/utils/integration/abstract.ts b/app/utils/integration/abstract.ts new file mode 100644 index 0000000..c0b0ff7 --- /dev/null +++ b/app/utils/integration/abstract.ts @@ -0,0 +1,14 @@ +export abstract class Integration { + protected context: NonNullable; + constructor(context: T) { + if (!context) { + throw new Error('Missing integration context'); + } + + this.context = context; + } + + abstract isAvailable(): Promise | boolean; + abstract onConfigChange(): Promise | void; + abstract get name(): string; +} diff --git a/app/integration/docker.ts b/app/utils/integration/docker.ts similarity index 50% rename from app/integration/docker.ts rename to app/utils/integration/docker.ts index 3b17b42..f6c78b9 100644 --- a/app/integration/docker.ts +++ b/app/utils/integration/docker.ts @@ -1,45 +1,32 @@ -import { access, constants } from 'node:fs/promises'; +import { constants, access } from 'node:fs/promises'; import { setTimeout } from 'node:timers/promises'; - import { Client } from 'undici'; - -import { HeadscaleError, pull } from '~/utils/headscale'; +import { HeadscaleError, healthcheck, pull } from '~/utils/headscale'; import log from '~/utils/log'; +import { HeadplaneConfig } from '~server/context/parser'; +import { Integration } from './abstract'; -import { createIntegration } from './integration'; +type T = NonNullable['docker']; +export default class DockerIntegration extends Integration { + private maxAttempts = 10; + private client: Client | undefined; -interface Context { - client: Client | undefined; - container: string | undefined; - maxAttempts: number; -} + get name() { + return 'Docker'; + } -export default createIntegration({ - name: 'Docker', - context: { - client: undefined, - container: undefined, - maxAttempts: 10, - }, - isAvailable: async (context) => { - // Check for the HEADSCALE_CONTAINER environment variable first - // to avoid unnecessary fetching of the Docker socket - log.debug('INTG', 'Checking Docker integration availability'); - context.container = process.env.HEADSCALE_CONTAINER?.trim().toLowerCase(); - - if (!context.container || context.container.length === 0) { - log.error('INTG', 'Missing HEADSCALE_CONTAINER variable'); + async isAvailable() { + if (this.context.container_name.length === 0) { + log.error('INTG', 'Docker container name is empty'); return false; } - log.info('INTG', 'Using container: %s', context.container); - const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock'; + log.info('INTG', 'Using container: %s', this.context.container_name); let url: URL | undefined; - try { - url = new URL(path); + url = new URL(this.context.socket); } catch { - log.error('INTG', 'Invalid Docker socket path: %s', path); + log.error('INTG', 'Invalid Docker socket path: %s', this.context.socket); return false; } @@ -58,12 +45,12 @@ export default createIntegration({ log.info('INTG', 'Checking API: %s', fetchU); await fetch(new URL('/v1.30/version', fetchU).href); } catch (error) { - log.debug('INTG', 'Failed to connect to Docker API', error); - log.error('INTG', 'Failed to connect to Docker API'); + log.error('INTG', 'Failed to connect to Docker API: %s', error); + log.debug('INTG', 'Connection error: %o', error); return false; } - context.client = new Client(fetchU); + this.client = new Client(fetchU); } // Check if the socket is accessible @@ -72,42 +59,42 @@ export default createIntegration({ log.info('INTG', 'Checking socket: %s', url.pathname); await access(url.pathname, constants.R_OK); } catch (error) { - log.debug('INTG', 'Failed to access Docker socket: %s', error); - log.error('INTG', 'Failed to access Docker socket: %s', path); + log.error('INTG', 'Failed to access Docker socket: %s', url.pathname); + log.debug('INTG', 'Access error: %o', error); return false; } - context.client = new Client('http://localhost', { + this.client = new Client('http://localhost', { socketPath: url.pathname, }); } - return context.client !== undefined; - }, + return this.client !== undefined; + } - onConfigChange: async (context) => { - if (!context.client || !context.container) { + async onConfigChange() { + if (!this.client) { return; } log.info('INTG', 'Restarting Headscale via Docker'); let attempts = 0; - while (attempts <= context.maxAttempts) { + while (attempts <= this.maxAttempts) { log.debug( 'INTG', 'Restarting container: %s (attempt %d)', - context.container, + this.context.container_name, attempts, ); - const response = await context.client.request({ + const response = await this.client.request({ method: 'POST', - path: `/v1.30/containers/${context.container}/restart`, + path: `/v1.30/containers/${this.context.container_name}/restart`, }); if (response.statusCode !== 204) { - if (attempts < context.maxAttempts) { + if (attempts < this.maxAttempts) { attempts++; await setTimeout(1000); continue; @@ -122,10 +109,11 @@ export default createIntegration({ } attempts = 0; - while (attempts <= context.maxAttempts) { + while (attempts <= this.maxAttempts) { try { log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts); - await pull('v1', ''); + await healthcheck(); + log.info('INTG', 'Headscale is up and running'); return; } catch (error) { if (error instanceof HeadscaleError && error.status === 401) { @@ -136,14 +124,19 @@ export default createIntegration({ break; } - if (attempts < context.maxAttempts) { + if (attempts < this.maxAttempts) { attempts++; await setTimeout(1000); continue; } - throw new Error(`Missed restart deadline for ${context.container}`); + log.error( + 'INTG', + 'Missed restart deadline for %s', + this.context.container_name, + ); + return; } } - }, -}); + } +} diff --git a/app/integration/kubernetes.ts b/app/utils/integration/kubernetes.ts similarity index 73% rename from app/integration/kubernetes.ts rename to app/utils/integration/kubernetes.ts index 05502f2..c961a10 100644 --- a/app/integration/kubernetes.ts +++ b/app/utils/integration/kubernetes.ts @@ -1,24 +1,25 @@ -import { readdir, readFile } from 'node:fs/promises'; +import { readFile, readdir } from 'node:fs/promises'; import { platform } from 'node:os'; import { join, resolve } from 'node:path'; import { kill } from 'node:process'; - +import { setTimeout } from 'node:timers/promises'; import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node'; - +import { HeadscaleError, healthcheck } from '~/utils/headscale'; import log from '~/utils/log'; +import { HeadplaneConfig } from '~server/context/parser'; +import { Integration } from './abstract'; -import { createIntegration } from './integration'; +// TODO: Upgrade to the new CoreV1Api from @kubernetes/client-node +type T = NonNullable['kubernetes']; +export default class KubernetesIntegration extends Integration { + private pid: number | undefined; + private maxAttempts = 10; -interface Context { - pid: number | undefined; -} + get name() { + return 'Kubernetes (k8s)'; + } -export default createIntegration({ - name: 'Kubernetes (k8s)', - context: { - pid: undefined, - }, - isAvailable: async (context) => { + async isAvailable() { if (platform() !== 'linux') { log.error('INTG', 'Kubernetes is only available on Linux'); return false; @@ -191,21 +192,58 @@ export default createIntegration({ return false; } - context.pid = pids[0]; - log.info('INTG', 'Found Headscale process with PID: %d', context.pid); + this.pid = pids[0]; + log.info('INTG', 'Found Headscale process with PID: %d', this.pid); return true; } catch { log.error('INTG', 'Failed to read /proc'); return false; } - }, + } - onConfigChange: (context) => { - if (!context.pid) { + async onConfigChange() { + if (!this.pid) { return; } - log.info('INTG', 'Sending SIGTERM to Headscale'); - kill(context.pid, 'SIGTERM'); - }, -}); + try { + log.info('INTG', 'Sending SIGTERM to Headscale'); + kill(this.pid, 'SIGTERM'); + } catch (error) { + log.error('INTG', 'Failed to send SIGTERM to Headscale: %s', error); + log.debug('INTG', 'kill(1) error: %o', error); + } + + await setTimeout(1000); + let attempts = 0; + while (attempts <= this.maxAttempts) { + try { + log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts); + await healthcheck(); + log.info('INTG', 'Headscale is up and running'); + return; + } catch (error) { + if (error instanceof HeadscaleError && error.status === 401) { + break; + } + + if (error instanceof HeadscaleError && error.status === 404) { + break; + } + + if (attempts < this.maxAttempts) { + attempts++; + await setTimeout(1000); + continue; + } + + log.error( + 'INTG', + 'Missed restart deadline for Headscale (pid %d)', + this.pid, + ); + return; + } + } + } +} diff --git a/app/utils/integration/loader.ts b/app/utils/integration/loader.ts new file mode 100644 index 0000000..509e459 --- /dev/null +++ b/app/utils/integration/loader.ts @@ -0,0 +1,69 @@ +import log from '~/utils/log'; +import { HeadplaneConfig } from '~server/context/parser'; +import { Integration } from './abstract'; +import dockerIntegration from './docker'; +import kubernetesIntegration from './kubernetes'; +import procIntegration from './proc'; + +let runtimeIntegration: Integration | undefined = undefined; + +export function hp_getIntegration() { + return runtimeIntegration; +} + +export async function hp_loadIntegration( + context: HeadplaneConfig['integration'], +) { + const integration = getIntegration(context); + if (!integration) { + return; + } + + try { + const res = await integration.isAvailable(); + if (!res) { + log.error('INTG', 'Integration %s is not available', integration); + return; + } + } catch (error) { + log.error('INTG', 'Failed to load integration %s: %s', integration, error); + log.debug('INTG', 'Loading error: %o', error); + return; + } + + runtimeIntegration = integration; +} + +function getIntegration(integration: HeadplaneConfig['integration']) { + const docker = integration?.docker; + const k8s = integration?.kubernetes; + const proc = integration?.proc; + + if (!docker?.enabled && !k8s?.enabled && !proc?.enabled) { + log.debug('INTG', 'No integrations enabled'); + return; + } + + if (docker?.enabled && k8s?.enabled && proc?.enabled) { + log.error('INTG', 'Multiple integrations enabled, please pick one only'); + return; + } + + if (docker?.enabled) { + log.info('INTG', 'Using Docker integration'); + return new dockerIntegration(integration?.docker); + } + + if (k8s?.enabled) { + log.info('INTG', 'Using Kubernetes integration'); + return new kubernetesIntegration(integration?.kubernetes); + } + + if (proc?.enabled) { + log.info('INTG', 'Using Proc integration'); + return new procIntegration(integration?.proc); + } +} + +// IMPORTANT THIS IS A SIDE EFFECT ON INIT +hp_loadIntegration(__integration_context); diff --git a/app/utils/integration/proc.ts b/app/utils/integration/proc.ts new file mode 100644 index 0000000..3e43ffc --- /dev/null +++ b/app/utils/integration/proc.ts @@ -0,0 +1,128 @@ +import { readFile, readdir } from 'node:fs/promises'; +import { platform } from 'node:os'; +import { join, resolve } from 'node:path'; +import { kill } from 'node:process'; +import { setTimeout } from 'node:timers/promises'; +import { HeadscaleError, healthcheck } from '~/utils/headscale'; +import log from '~/utils/log'; +import { HeadplaneConfig } from '~server/context/parser'; +import { Integration } from './abstract'; + +type T = NonNullable['proc']; +export default class ProcIntegration extends Integration { + private pid: number | undefined; + private maxAttempts = 10; + + get name() { + return 'Native Linux (/proc)'; + } + + async isAvailable() { + if (platform() !== 'linux') { + log.error('INTG', '/proc is only available on Linux'); + return false; + } + + log.debug('INTG', 'Checking /proc for Headscale process'); + const dir = resolve('/proc'); + try { + const subdirs = await readdir(dir); + const promises = subdirs.map(async (dir) => { + const pid = Number.parseInt(dir, 10); + + if (Number.isNaN(pid)) { + return; + } + + const path = join('/proc', dir, 'cmdline'); + try { + log.debug('INTG', 'Reading %s', path); + const data = await readFile(path, 'utf8'); + if (data.includes('headscale')) { + return pid; + } + } catch (error) { + log.error('INTG', 'Failed to read %s: %s', path, error); + } + }); + + const results = await Promise.allSettled(promises); + const pids = []; + + for (const result of results) { + if (result.status === 'fulfilled' && result.value) { + pids.push(result.value); + } + } + + log.debug('INTG', 'Found Headscale processes: %o', pids); + if (pids.length > 1) { + log.error( + 'INTG', + 'Found %d Headscale processes: %s', + pids.length, + pids.join(', '), + ); + return false; + } + + if (pids.length === 0) { + log.error('INTG', 'Could not find Headscale process'); + return false; + } + + this.pid = pids[0]; + log.info('INTG', 'Found Headscale process with PID: %d', this.pid); + return true; + } catch { + log.error('INTG', 'Failed to read /proc'); + return false; + } + } + + async onConfigChange() { + if (!this.pid) { + return; + } + + try { + log.info('INTG', 'Sending SIGTERM to Headscale'); + kill(this.pid, 'SIGTERM'); + } catch (error) { + log.error('INTG', 'Failed to send SIGTERM to Headscale: %s', error); + log.debug('INTG', 'kill(1) error: %o', error); + } + + await setTimeout(1000); + let attempts = 0; + while (attempts <= this.maxAttempts) { + try { + log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts); + await healthcheck(); + log.info('INTG', 'Headscale is up and running'); + return; + } catch (error) { + if (error instanceof HeadscaleError && error.status === 401) { + break; + } + + if (error instanceof HeadscaleError && error.status === 404) { + break; + } + + if (attempts < this.maxAttempts) { + attempts++; + await setTimeout(1000); + continue; + } + + log.error( + 'INTG', + 'Missed restart deadline for Headscale (pid %d)', + this.pid, + ); + return; + } + } + } +} diff --git a/config.example.yaml b/config.example.yaml index 0b3d901..cb3df4f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -34,6 +34,36 @@ headscale: # If you want to disable this validation, set this to false. config_strict: true +# Integration configurations for Headplane to interact with Headscale +# Only one of these should be enabled at a time or you will get errors +integration: + docker: + enabled: false + # The 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. + socket: "unix:///var/run/docker.dock" + # 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. + kubernetes: + enabled: false + # Validates the manifest for the Pod to ensure all of the criteria + # are set correctly. Turn this off if you are having issues with + # shareProcessNamespace not being validated correctly. + validate_manifest: true + + # Proc is the "Native" integration that only works when Headscale and + # Headplane are running outside of a container. There is no configuration, + # but you need to ensure that the Headplane process can terminate the + # Headscale process. + # + # (If they are both running under systemd as sudo, this will work). + proc: + enabled: false + # OIDC Configuration for simpler authentication # (This is optional, but recommended for the best experience) oidc: diff --git a/server/context/loader.ts b/server/context/loader.ts index 4b88099..bd1b681 100644 --- a/server/context/loader.ts +++ b/server/context/loader.ts @@ -17,6 +17,8 @@ declare global { config_path?: string; config_strict?: boolean; }; + + let __integration_context: HeadplaneConfig['integration']; } const envBool = type('string | undefined').pipe((v) => { @@ -131,6 +133,9 @@ export async function hp_loadConfig() { config_strict: config.headscale.config_strict, }; + // @ts-expect-error: If we remove globalThis we get a runtime error + globalThis.__integration_context = config.integration; + runtimeConfig = config; runtimeLock.release(); } diff --git a/server/context/parser.ts b/server/context/parser.ts index d238853..a7d5ba0 100644 --- a/server/context/parser.ts +++ b/server/context/parser.ts @@ -29,17 +29,46 @@ const headscaleConfig = type({ config_strict: stringToBool, }).onDeepUndeclaredKey('reject'); +const dockerConfig = type({ + enabled: stringToBool, + container_name: 'string', + socket: 'string = "unix:///var/run/docker.sock"', +}); + +const kubernetesConfig = type({ + enabled: stringToBool, + validate_manifest: stringToBool, +}); + +const procConfig = type({ + enabled: stringToBool, +}); + +const integrationConfig = type({ + 'docker?': dockerConfig, + 'kubernetes?': kubernetesConfig, + 'proc?': procConfig, +}).onDeepUndeclaredKey('reject'); + const headplaneConfig = type({ debug: stringToBool, server: serverConfig, 'oidc?': oidcConfig, + 'integration?': integrationConfig, headscale: headscaleConfig, }).onDeepUndeclaredKey('reject'); +const partialIntegrationConfig = type({ + 'docker?': dockerConfig.partial(), + 'kubernetes?': kubernetesConfig.partial(), + 'proc?': procConfig.partial(), +}).partial(); + const partialHeadplaneConfig = type({ debug: stringToBool, server: serverConfig.partial(), 'oidc?': oidcConfig.partial(), + 'integration?': partialIntegrationConfig, headscale: headscaleConfig.partial(), }).partial();