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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261/** * Terraform entrypoint for this repo. * It stages local runtime files and passes only non-secret values into Terraform. * Use `bun run tf ...` for Terraform and `bun run tf render-staging` for a local preview. */
import { $ } from "bun";import { mkdir, mkdtemp, rm } from "node:fs/promises";import { join, resolve } from "node:path";
import { envFrom, requireEnvVarsFrom, type EnvSource } from "./lib";
/** Process env passed to Terraform. */export type TerraformEnvironment = Record<string, string>;
/** Env vars required to render staged files. */const stagingRequiredEnvNames = [ "PUBLIC_HOSTNAME", "KNOT_SERVER_OWNER", "APPVIEW_ENDPOINT", "KNOT_IMAGE_REPO", "KNOT_IMAGE_DIGEST", "DOCKERHUB_PULL_TOKEN", "KNOT_SERVER_LISTEN_ADDR", "KNOT_SERVER_INTERNAL_LISTEN_ADDR", "KNOT_SERVER_DB_PATH",] as const;
/** Repository path inside the container. */const knotRepositoryScanPath = "/home/git/repositories";
/** Non-secret env vars passed to Terraform. */export const terraformRequiredEnvNames = [ "HCLOUD_TOKEN", "HCLOUD_SERVER_NAME", "HCLOUD_LOCATION", "HCLOUD_SERVER_TYPE", "HCLOUD_IMAGE", "HCLOUD_SSH_KEY_FINGERPRINT", "TAILSCALE_ADMIN_HOST", "DOCKERHUB_USERNAME",] as const;
/** Shell env vars forwarded to Terraform. */const passthroughEnvNames = [ "HOME", "LANG", "PATH", "SSH_AUTH_SOCK", "TEMP", "TERM", "TMP", "TMPDIR", "TZ",] as const;
/** Locale prefixes forwarded to Terraform. */const passthroughEnvPrefixes = ["LC_"] as const;
/** Returns the usage text. */function usage(): string { return [ "Usage:", " bun run tf render-staging [directory]", " bun run tf <terraform-command> [args...]", ].join("\n");}
/** Returns whether a command needs staged files. */function commandNeedsStaging(command: string | undefined): boolean { if (command === undefined) { return true; }
return !new Set(["fmt", "help", "init", "providers", "validate", "version"]).has(command);}
/** Writes staged runtime files to a directory. */async function buildStagingDir( stagingDir: string, runtimeEnvPath: string, envSource: EnvSource = Bun.env,): Promise<void> { requireEnvVarsFrom(envSource, stagingRequiredEnvNames);
await mkdir(stagingDir, { recursive: true });
await Bun.write( join(stagingDir, "compose.env"), [ `PUBLIC_HOSTNAME=${envFrom(envSource, "PUBLIC_HOSTNAME")}`, `KNOT_IMAGE_REF=${envFrom(envSource, "KNOT_IMAGE_REPO")}@${envFrom(envSource, "KNOT_IMAGE_DIGEST")}`, `KNOT_RUNTIME_ENV_FILE=${runtimeEnvPath}`, "", ].join("\n"), );
await Bun.write( join(stagingDir, "knot.env"), [ `KNOT_SERVER_HOSTNAME=${envFrom(envSource, "PUBLIC_HOSTNAME")}`, `KNOT_SERVER_OWNER=${envFrom(envSource, "KNOT_SERVER_OWNER")}`, `APPVIEW_ENDPOINT=${envFrom(envSource, "APPVIEW_ENDPOINT")}`, `KNOT_SERVER_LISTEN_ADDR=${envFrom(envSource, "KNOT_SERVER_LISTEN_ADDR")}`, `KNOT_SERVER_INTERNAL_LISTEN_ADDR=${envFrom(envSource, "KNOT_SERVER_INTERNAL_LISTEN_ADDR")}`, `KNOT_SERVER_DB_PATH=${envFrom(envSource, "KNOT_SERVER_DB_PATH")}`, `KNOT_REPO_SCAN_PATH=${knotRepositoryScanPath}`, "", ].join("\n"), );
await Bun.write( join(stagingDir, "dockerhub.pull.token"), envFrom(envSource, "DOCKERHUB_PULL_TOKEN"), );}
/** Builds the base Terraform env. */export function buildTerraformBaseEnvironment( envSource: EnvSource = Bun.env,): TerraformEnvironment { requireEnvVarsFrom(envSource, terraformRequiredEnvNames);
return { ...buildPassthroughEnvironment(envSource), HCLOUD_TOKEN: envFrom(envSource, "HCLOUD_TOKEN"), TF_VAR_server_name: envFrom(envSource, "HCLOUD_SERVER_NAME"), TF_VAR_server_type: envFrom(envSource, "HCLOUD_SERVER_TYPE"), TF_VAR_location: envFrom(envSource, "HCLOUD_LOCATION"), TF_VAR_image: envFrom(envSource, "HCLOUD_IMAGE"), TF_VAR_ssh_key_fingerprint: envFrom(envSource, "HCLOUD_SSH_KEY_FINGERPRINT"), TF_VAR_tailscale_admin_host: envFrom(envSource, "TAILSCALE_ADMIN_HOST"), TF_VAR_dockerhub_username: envFrom(envSource, "DOCKERHUB_USERNAME"), TF_VAR_rollout_allow_empty_repositories: envSource.KNOT_ROLLOUT_ALLOW_EMPTY_REPOSITORIES === "1" ? "true" : "false", };}
/** Builds the full Terraform env for staged runs. */function buildTerraformEnvironment( stagingDir: string, envSource: EnvSource = Bun.env,): TerraformEnvironment { return { ...buildTerraformBaseEnvironment(envSource), TF_VAR_compose_env_file: join(stagingDir, "compose.env"), TF_VAR_knot_env_file: join(stagingDir, "knot.env"), TF_VAR_dockerhub_pull_token_file: join(stagingDir, "dockerhub.pull.token"), };}
/** Filters Bun env for Terraform child processes. */export function buildPassthroughEnvironment(envSource: EnvSource = Bun.env): TerraformEnvironment { const forwardedEntries: Array<[string, string]> = [];
for (const envName of passthroughEnvNames) { const envValue = envSource[envName]; if (envValue !== undefined && envValue !== "") { forwardedEntries.push([envName, envValue]); } }
for (const [envName, envValue] of Object.entries(envSource)) { if (envValue === undefined || envValue === "") { continue; }
if (passthroughEnvPrefixes.some((prefix) => envName.startsWith(prefix))) { forwardedEntries.push([envName, envValue]); } }
return Object.fromEntries(forwardedEntries);}
/** Runs Terraform. */async function runTerraform( args: readonly string[], terraformEnvironment: TerraformEnvironment,): Promise<number> { const terraform = await $`${["terraform", "-chdir=terraform", ...args]}` .env(terraformEnvironment) .nothrow(); return terraform.exitCode;}
/** Runs Terraform with a temporary staging directory. */async function runTerraformWithTemporaryStaging( args: readonly string[], envSource: EnvSource = Bun.env,): Promise<number> { const stagingDir = await mkdtemp(`${envSource.TMPDIR ?? "/tmp"}/knotty-`); const terraformEnvironment = buildTerraformEnvironment(stagingDir, envSource);
let cleaned = false;
/** Removes the temporary staging directory. */ async function cleanup(): Promise<void> { if (cleaned) { return; }
cleaned = true; await rm(stagingDir, { force: true, recursive: true }); }
const signalNames = ["SIGINT", "SIGTERM"] as const;
/** Cleans up staged files on exit signals. */ async function handleSignal(): Promise<void> { await cleanup(); process.exit(130); }
for (const signalName of signalNames) { process.once(signalName, handleSignal); }
try { await buildStagingDir(stagingDir, "/opt/knot/compose/knot.env", envSource); return await runTerraform(args, terraformEnvironment); } finally { for (const signalName of signalNames) { process.off(signalName, handleSignal); } await cleanup(); }}
/** Runs the CLI entrypoint. */export async function main(args: readonly string[] = Bun.argv.slice(2)): Promise<number> { const [command, ...rest] = args;
if (command === "--help" || command === "-h" || command === "help") { console.log(usage()); return 0; }
if (command === "render-staging") { const targetDir = resolve(rest[0] ?? ".tmp/staging"); await buildStagingDir(targetDir, join(targetDir, "knot.env")); return 0; }
if (command === undefined) { console.error(usage()); return 1; }
if (!commandNeedsStaging(command)) { return await runTerraform(args, buildPassthroughEnvironment()); }
return await runTerraformWithTemporaryStaging(args);}
if (import.meta.main) { const exitCode = await main(); process.exit(exitCode);}