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