/** * 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; /** 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 [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 { 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 { 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 { 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 { 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 { 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 { 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); }