Something went wrong. Try again.
Terraform and Bun scripts to deploy one self-hosted Knot server to Hetzner VM
knot tailscale typescript bun terraform hetzner
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278/** * One-time bootstrap entrypoint for a new host. * It applies the server and firewall targets, waits for cloud-init, and finishes setup over public SSH. * Use this once per host, then return to `bun run tf apply`. */
import { $ } from "bun";import { mkdtemp, rm } from "node:fs/promises";import { join } from "node:path";
import { envFrom, remoteOperations as defaultBootstrapRemoteOperations, requireEnvVarsFrom, shellQuote, type EnvSource, type RemoteOperations, type RemoteTarget,} from "./lib";import { buildTerraformBaseEnvironment, type TerraformEnvironment,} from "./tf";
/** Runs one Terraform command. */export type BootstrapCommandRunner = ( args: readonly string[], terraformEnvironment: TerraformEnvironment,) => Promise<number>;
/** Reads one Terraform output. */export type TerraformOutputReader = ( outputName: string, terraformEnvironment: TerraformEnvironment,) => Promise<string>;
/** Narrow remote surface used by the CLI. */export type BootstrapRemoteOperations = Pick<RemoteOperations, "scpToHost" | "ssh">;
/** Line-oriented output sink. */export type OutputWriter = (line: string) => void;
/** Retry sleep function. */export type SleepFunction = (milliseconds: number) => Promise<void>;
const bootstrapRequiredEnvNames = ["TAILSCALE_AUTH_KEY"] as const;
const bootstrapTargetArgs = [ "-target=hcloud_firewall.knot_public", "-target=hcloud_server.knot",] as const;
const bootstrapReadinessCommand = "test -f /var/lib/cloud/instance/boot-finished";const bootstrapWaitPollMilliseconds = 5_000;const bootstrapWaitTimeoutMilliseconds = 10 * 60 * 1_000;
/** Returns the usage text. */export function usage(): string { return [ "Usage:", " bun run bootstrap [terraform-apply args...]", "", "Provision the server and firewall, then bootstrap the host over public SSH on port 22.", ].join("\n");}
/** Builds the Terraform apply arguments for bootstrap. */export function buildBootstrapApplyArgs( args: readonly string[] = [],): readonly string[] { const forwardedArgs = args[0] === "--" ? args.slice(1) : args; return ["apply", ...bootstrapTargetArgs, ...forwardedArgs];}
/** Builds the remote bootstrap command. */export function buildBootstrapCommand(): string { const script = [ "trap 'rm -f /root/bootstrap/tailscale-auth.key' EXIT", "install -d -m 0700 /root/bootstrap", "install -d -m 0755 /etc/apt/keyrings", "curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc", "chmod a+r /etc/apt/keyrings/docker.asc", 'architecture="$(dpkg --print-architecture)"', "printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu noble stable\\n' \"$architecture\" >/etc/apt/sources.list.d/docker.list", "curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/noble.noarmor.gpg -o /usr/share/keyrings/tailscale-archive-keyring.gpg", "curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/noble.tailscale-keyring.list -o /etc/apt/sources.list.d/tailscale.list", "apt-get update", "apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin tailscale", "systemctl enable --now docker containerd tailscaled", 'tailscale up --reset --auth-key="$(cat /root/bootstrap/tailscale-auth.key)"', "printf 'Port 2222\\n' >/etc/ssh/sshd_config.d/70-knot-admin.conf", "if systemctl cat ssh.socket >/dev/null 2>&1; then systemctl disable --now ssh.socket; fi", "systemctl enable ssh.service", "systemctl restart ssh.service", "ss -H -ltnp 'sport = :22' | sed -n 's/.*pid=\\([0-9][0-9]*\\).*/\\1/p' | sort -u | xargs -r kill", ].join(";\n");
return `sh -ceu ${shellQuote(script)}`;}
/** Builds the Terraform env for bootstrap. */export function buildBootstrapTerraformEnvironment( envSource: EnvSource = Bun.env,): TerraformEnvironment { requireEnvVarsFrom(envSource, bootstrapRequiredEnvNames);
return buildTerraformBaseEnvironment(envSource);}
/** Waits for cloud-init to finish. */export async function waitForBootstrapReadiness( target: RemoteTarget, bootstrapRemoteOperations: BootstrapRemoteOperations = defaultBootstrapRemoteOperations, outputWriter: OutputWriter = console.log, sleep: SleepFunction = Bun.sleep, now: () => number = Date.now, timeoutMilliseconds = bootstrapWaitTimeoutMilliseconds, pollMilliseconds = bootstrapWaitPollMilliseconds,): Promise<void> { const startedAt = now(); let attemptCount = 0;
while (true) { try { await bootstrapRemoteOperations.ssh(target, bootstrapReadinessCommand); return; } catch (error) { const elapsedMilliseconds = now() - startedAt; if (elapsedMilliseconds >= timeoutMilliseconds) { const message = error instanceof Error ? error.message : String(error); throw new Error( `Timed out waiting for bootstrap readiness on ${target.host}:${target.port}. Last error: ${message}`, ); }
attemptCount += 1; outputWriter( `Waiting for bootstrap readiness on ${target.host}:${target.port} (attempt ${attemptCount}).`, ); await sleep(pollMilliseconds); } }}
/** Copies the Tailscale key and runs bootstrap. */export async function bootstrapHost( target: RemoteTarget, tailscaleAuthKeyFilePath: string, bootstrapRemoteOperations: BootstrapRemoteOperations = defaultBootstrapRemoteOperations,): Promise<void> { await bootstrapRemoteOperations.ssh(target, "install -d -m 0700 /root/bootstrap"); await bootstrapRemoteOperations.scpToHost( target, tailscaleAuthKeyFilePath, "/root/bootstrap/tailscale-auth.key", ); await bootstrapRemoteOperations.ssh(target, buildBootstrapCommand());}
/** Runs Terraform and returns the exit code. */export async function runTerraformCommand( args: readonly string[], terraformEnvironment: TerraformEnvironment,): Promise<number> { const terraform = await $`${["terraform", "-chdir=terraform", ...args]}` .env(terraformEnvironment) .nothrow(); return terraform.exitCode;}
/** Reads one Terraform output. */export async function readTerraformOutput( outputName: string, terraformEnvironment: TerraformEnvironment,): Promise<string> { const terraform = await $`${[ "terraform", "-chdir=terraform", "output", "-raw", outputName, ]}` .env(terraformEnvironment) .quiet() .nothrow(); const exitCode = terraform.exitCode;
if (exitCode !== 0) { throw new Error( `Command failed with exit code ${exitCode}: terraform -chdir=terraform output -raw ${outputName}`, ); }
return (await terraform.text()).trim();}
/** Runs the bootstrap workflow. */export async function main( args: readonly string[] = Bun.argv.slice(2), envSource: EnvSource = Bun.env, bootstrapCommandRunner: BootstrapCommandRunner = runTerraformCommand, terraformOutputReader: TerraformOutputReader = readTerraformOutput, lowLevelBootstrapRemoteOperations: BootstrapRemoteOperations = defaultBootstrapRemoteOperations, outputWriter: OutputWriter = console.log, sleep: SleepFunction = Bun.sleep, now: () => number = Date.now,): Promise<number> { if (args.includes("--help") || args.includes("-h") || args.includes("help")) { outputWriter(usage()); return 0; }
const terraformEnvironment = buildBootstrapTerraformEnvironment(envSource); const applyExitCode = await bootstrapCommandRunner( buildBootstrapApplyArgs(args), terraformEnvironment, );
if (applyExitCode !== 0) { return applyExitCode; }
const bootstrapHostName = ( await terraformOutputReader("bootstrap_host", terraformEnvironment) ).trim(); const stagingDirectory = await mkdtemp(`${envSource.TMPDIR ?? "/tmp"}/knotty-bootstrap-`); const tailscaleAuthKeyFilePath = join(stagingDirectory, "tailscale-auth.key");
try { await Bun.write( tailscaleAuthKeyFilePath, `${envFrom(envSource, "TAILSCALE_AUTH_KEY")}\n`, );
const target = { host: bootstrapHostName, port: 22, user: "root", } as const;
try { await waitForBootstrapReadiness( target, lowLevelBootstrapRemoteOperations, outputWriter, sleep, now, ); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error( `Public bootstrap could not reach ${bootstrapHostName}:22. If this host has already been bootstrapped, stop using \`bun run bootstrap\` and switch to \`bun run tf apply\`. Last error: ${message}`, ); }
await bootstrapHost( target, tailscaleAuthKeyFilePath, lowLevelBootstrapRemoteOperations, ); } finally { await rm(stagingDirectory, { force: true, recursive: true }); }
return 0;}
if (import.meta.main) { try { const exitCode = await main(); process.exit(exitCode); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(message); process.exit(1); }}