feat: configure support for generic integrations

Co-authored-by: Gage Orsburn <gageorsburn@live.com>
This commit is contained in:
Aarnav Tale
2024-05-26 19:23:24 -04:00
parent 1a30185047
commit f0e4868252
10 changed files with 289 additions and 148 deletions
+124
View File
@@ -0,0 +1,124 @@
import { access, constants } from 'node:fs/promises'
import { setTimeout } from 'node:timers/promises'
import { Client } from 'undici'
import { HeadscaleError, pull } from '~/utils/headscale'
import type { Integration } from '.'
let url: URL | undefined
let container: string | undefined
async function preflight() {
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock'
try {
url = new URL(path)
} catch {
return false
}
// The API is available as an HTTP endpoint
if (url.protocol === 'tcp:') {
url.protocol = 'http:'
}
// Check if the socket is accessible
if (url.protocol === 'unix:') {
try {
await access(path, constants.R_OK)
} catch {
return false
}
}
if (url.protocol === 'http:') {
try {
await fetch(new URL('/v1.30/version', url).href)
} catch {
return false
}
}
if (url.protocol !== 'http:' && url.protocol !== 'unix:') {
return false
}
container = process.env.HEADSCALE_CONTAINER
?.trim()
.toLowerCase()
if (!container || container.length === 0) {
return false
}
return true
}
async function sighup() {
if (!url || !container) {
return
}
// Supports the DOCKER_SOCK environment variable
const client = url.protocol === 'unix:'
? new Client('http://localhost', {
socketPath: url.href,
})
: new Client(url.href)
const response = await client.request({
method: 'POST',
path: `/v1.30/containers/${container}/kill?signal=SIGHUP`,
})
if (!response.statusCode || response.statusCode !== 204) {
throw new Error('Failed to send SIGHUP to Headscale')
}
}
async function restart() {
if (!url || !container) {
return
}
// Supports the DOCKER_SOCK environment variable
const client = url.protocol === 'unix:'
? new Client('http://localhost', {
socketPath: url.href,
})
: new Client(url.href)
const response = await client.request({
method: 'POST',
path: `/v1.30/containers/${container}/restart`,
})
if (!response.statusCode || response.statusCode !== 204) {
throw new Error('Failed to restart Headscale')
}
// Wait for Headscale to restart before continuing
let attempts = 0
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, no-constant-condition
while (true) {
try {
await pull('v1', '')
return
} catch (error) {
if (error instanceof HeadscaleError && error.status === 401) {
break
}
if (attempts > 10) {
throw new Error('Headscale did not restart in time')
}
attempts++
await setTimeout(1000)
}
}
}
export default { preflight, sighup, restart } satisfies Integration
+57
View File
@@ -0,0 +1,57 @@
import docker from './docker'
import proc from './proc'
export interface Integration {
preflight: () => Promise<boolean>
sighup?: () => Promise<void>
restart?: () => Promise<void>
}
// Because we previously supported the Docker integration by
// checking for the HEADSCALE_CONTAINER variable, we need to
// check for it here as well.
//
// 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
// an integration.
export async function checkIntegration() {
let integration = process.env.HEADSCALE_INTEGRATION
?.trim()
.toLowerCase()
// Old HEADSCALE_CONTAINER variable upgrade path
if (!integration && process.env.HEADSCALE_CONTAINER) {
integration = 'docker'
}
if (!integration) {
console.log('Running Headplane without any integrations')
return
}
let module: Integration | undefined
try {
module = getIntegration(integration)
await module.preflight()
} catch (error) {
console.error('Failed to load integration', error)
return
}
return module
}
function getIntegration(name: string) {
switch (name) {
case 'docker': {
return docker
}
case 'proc': {
return proc
}
default: {
throw new Error(`Unknown integration: ${name}`)
}
}
}
+80
View File
@@ -0,0 +1,80 @@
import { access, constants, readdir, readFile } from 'node:fs/promises'
import { platform } from 'node:os'
import { join, resolve } from 'node:path'
import { kill } from 'node:process'
import type { Integration } from '.'
// Check if we have a /proc and if it's readable
async function preflight() {
if (platform() !== 'linux') {
return false
}
const dir = resolve('/proc')
try {
await access(dir, constants.R_OK)
return true
} catch (error) {
console.error('Failed to access /proc', error)
return false
}
}
async function findPid() {
const dirs = await readdir('/proc')
const promises = dirs.map(async (dir) => {
const pid = Number.parseInt(dir, 10)
if (Number.isNaN(pid)) {
return
}
const path = join('/proc', dir, 'cmdline')
try {
const data = await readFile(path, 'utf8')
if (data.includes('headscale')) {
return pid
}
} catch {}
})
const results = await Promise.allSettled(promises)
const pids = []
for (const result of results) {
if (result.status === 'fulfilled') {
pids.push(result.value)
}
}
if (pids.length > 1) {
console.warn('Found multiple Headscale processes', pids)
console.log('Disabling the /proc integration')
return
}
if (pids.length === 0) {
console.warn('Could not find Headscale process')
console.log('Disabling the /proc integration')
return
}
return pids[0]
}
async function sighup() {
const pid = await findPid()
if (!pid) {
return
}
try {
kill(pid, 'SIGHUP')
} catch (error) {
console.error('Failed to send SIGHUP to Headscale', error)
}
}
export default { preflight, sighup } satisfies Integration