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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151/** * Steady-state rollout entrypoint. * It copies the staged files to the Tailscale host and updates the compose stack. * Run this through Terraform so the host, files, and image stay in sync. */
import { envFrom, remoteOperations, requireEnvVarsFrom, shellQuote, type RemoteTarget,} from "./lib";
/** Staged file paths for one rollout. */type RolloutInput = Readonly<{ allowEmptyRepositories: boolean; composeEnvFilePath: string; composeFilePath: string; dockerhubPullTokenFilePath: string; dockerhubUsername: string; knotEnvFilePath: string;}>;
const requiredEnvNames = [ "ROLLOUT_HOST", "DOCKERHUB_USERNAME", "COMPOSE_FILE", "COMPOSE_ENV_FILE", "KNOT_ENV_FILE", "DOCKERHUB_PULL_TOKEN_FILE",] as const;
/** Optional env var that allows an intentional empty-repository rollout. */const allowEmptyRepositoriesEnvName = "KNOT_ROLLOUT_ALLOW_EMPTY_REPOSITORIES";
/** Returns the usage text. */function usage(): string { return [ "Usage:", " bun run rollout", "", "Copy the staged deploy artefacts to the Tailscale host and refresh the compose stack.", ].join("\n");}
/** Builds the remote rollout command. */export function buildRolloutCommand(rolloutInput: RolloutInput): string { const dockerConfigPath = "/root/bootstrap/docker-config"; const allowEmptyRepositories = rolloutInput.allowEmptyRepositories ? "1" : "0"; const script = [ `trap 'rm -rf ${dockerConfigPath} /root/bootstrap/dockerhub.pull.token' EXIT`, "install -d -m 0700 /opt/knot/keys", "install -d -m 0755 /opt/knot/caddy_data", "install -d -m 0755 -o 1000 -g 1000 /opt/knot/server /opt/knot/repositories", `install -d -m 0700 ${dockerConfigPath}`, `DOCKER_CONFIG=${dockerConfigPath} docker login --username ${shellQuote(rolloutInput.dockerhubUsername)} --password-stdin < /root/bootstrap/dockerhub.pull.token`, "chmod 0600 /opt/knot/compose/.env /opt/knot/compose/knot.env", `allow_empty_repositories=${allowEmptyRepositories}`, "repository_count=$(find /opt/knot/repositories -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d '[:space:]')", [ "if [ \"$repository_count\" = \"0\" ] &&", "[ -f /opt/knot/server/knotserver.db ] &&", "[ \"$allow_empty_repositories\" != \"1\" ]; then", "echo 'Refusing rollout: /opt/knot/repositories is empty while /opt/knot/server/knotserver.db exists.' >&2;", "echo 'Create a backup/snapshot and set KNOT_ROLLOUT_ALLOW_EMPTY_REPOSITORIES=1 only for an intentional empty-host rollout.' >&2;", "exit 1;", "fi", ].join(" "), `DOCKER_CONFIG=${dockerConfigPath} docker compose --env-file /opt/knot/compose/.env -f /opt/knot/compose/compose.yaml pull`, `DOCKER_CONFIG=${dockerConfigPath} docker compose --env-file /opt/knot/compose/.env -f /opt/knot/compose/compose.yaml up -d --force-recreate`, ].join(";\n");
return `sh -ceu ${shellQuote(script)}`;}
/** Copies deploy files and refreshes the stack. */async function rolloutHost( target: RemoteTarget, rolloutInput: RolloutInput,): Promise<void> { await remoteOperations.ssh( target, "install -d -m 0700 /root/bootstrap && install -d -m 0755 /opt/knot/compose", ); await remoteOperations.scpToHost( target, rolloutInput.composeFilePath, "/opt/knot/compose/compose.yaml", ); await remoteOperations.scpToHost( target, rolloutInput.composeEnvFilePath, "/opt/knot/compose/.env", ); await remoteOperations.scpToHost( target, rolloutInput.knotEnvFilePath, "/opt/knot/compose/knot.env", ); await remoteOperations.scpToHost( target, rolloutInput.dockerhubPullTokenFilePath, "/root/bootstrap/dockerhub.pull.token", ); await remoteOperations.ssh( target, buildRolloutCommand(rolloutInput), );}
/** Runs the CLI entrypoint. */export async function main( args: readonly string[] = Bun.argv.slice(2),): Promise<number> { if (args.includes("--help") || args.includes("-h") || args.includes("help")) { console.log(usage()); return 0; }
requireEnvVarsFrom(Bun.env, requiredEnvNames);
await rolloutHost( { host: envFrom(Bun.env, "ROLLOUT_HOST"), port: 2222, user: "root", }, { allowEmptyRepositories: Bun.env[allowEmptyRepositoriesEnvName] === "1", composeFilePath: envFrom(Bun.env, "COMPOSE_FILE"), composeEnvFilePath: envFrom(Bun.env, "COMPOSE_ENV_FILE"), knotEnvFilePath: envFrom(Bun.env, "KNOT_ENV_FILE"), dockerhubPullTokenFilePath: envFrom(Bun.env, "DOCKERHUB_PULL_TOKEN_FILE"), dockerhubUsername: envFrom(Bun.env, "DOCKERHUB_USERNAME"), }, ); 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); }}