From 5218717e8d9eec61ea38fd829da819e19f3d5dd0 Mon Sep 17 00:00:00 2001 From: Deeman Date: Fri, 27 Feb 2026 06:57:00 +0100 Subject: [PATCH] refactor(infra): converge on setup+bootstrap pattern, fix systemd copy bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setup_server.sh: full rewrite to match materia/template pattern — adds Docker install, git/curl/ca-certificates apt install, age + sops install (arch-aware), uv install as service user, age keypair generation, SSH config write (root+chown); removes systemd unit copy (was buggy: copied before repo was cloned) - NEW bootstrap_supervisor.sh: ~45 lines — age key check, clone/fetch, tag checkout, sops decrypt, uv sync, copy landing-backup + supervisor systemd units, enable + start - deploy.sh: replace 53-line self-install preamble (sops/age install + keypair generation + exit-1 flow) with simple sops check + decrypt; Docker blue/green logic unchanged Co-Authored-By: Claude Sonnet 4.6 --- deploy.sh | 51 +------- infra/bootstrap_supervisor.sh | 72 +++++++++++ infra/setup_server.sh | 221 +++++++++++++++++++++++----------- 3 files changed, 224 insertions(+), 120 deletions(-) create mode 100644 infra/bootstrap_supervisor.sh diff --git a/deploy.sh b/deploy.sh index 42077c0..52a00e9 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,60 +1,17 @@ #!/usr/bin/env bash set -euo pipefail -# ── Ensure sops + age are installed ─────────────────────── APP_DIR="$(cd "$(dirname "$0")" && pwd)" -BIN_DIR="$APP_DIR/bin" -mkdir -p "$BIN_DIR" -export PATH="$BIN_DIR:$PATH" -ARCH=$(uname -m) -case "$ARCH" in - x86_64) ARCH_SOPS="amd64"; ARCH_AGE="amd64" ;; - aarch64) ARCH_SOPS="arm64"; ARCH_AGE="arm64" ;; - *) echo "Unsupported architecture: $ARCH"; exit 1 ;; -esac - -if ! command -v age &>/dev/null; then - echo "==> Installing age to $BIN_DIR..." - AGE_VERSION="v1.3.1" - curl -fsSL "https://dl.filippo.io/age/${AGE_VERSION}?for=linux/${ARCH_AGE}" -o /tmp/age.tar.gz - tar -xzf /tmp/age.tar.gz -C "$BIN_DIR" --strip-components=1 age/age age/age-keygen - chmod +x "$BIN_DIR/age" "$BIN_DIR/age-keygen" - rm /tmp/age.tar.gz -fi +# ── Decrypt secrets ─────────────────────────────────────── if ! command -v sops &>/dev/null; then - echo "==> Installing sops to $BIN_DIR..." - SOPS_VERSION="v3.12.1" - curl -fsSL "https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.${ARCH_SOPS}" -o "$BIN_DIR/sops" - chmod +x "$BIN_DIR/sops" -fi - -# ── Ensure age keypair exists ───────────────────────────── -AGE_KEY_FILE="${SOPS_AGE_KEY_FILE:-$APP_DIR/age-key.txt}" -export SOPS_AGE_KEY_FILE="$AGE_KEY_FILE" - -if [ ! -f "$AGE_KEY_FILE" ]; then - echo "==> Generating age keypair..." - age-keygen -o "$AGE_KEY_FILE" 2>&1 - chmod 600 "$AGE_KEY_FILE" - AGE_PUB=$(grep "public key:" "$AGE_KEY_FILE" | awk '{print $NF}') - echo "" - echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" - echo "!! NEW SERVER — add this public key to .sops.yaml: !!" - echo "!! !!" - echo "!! $AGE_PUB !!" - echo "!! !!" - echo "!! Then run: sops updatekeys .env.prod.sops !!" - echo "!! Commit, push, and re-deploy. !!" - echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" - echo "" + echo "ERROR: sops not found — run infra/setup_server.sh first" exit 1 fi -# ── Decrypt secrets ─────────────────────────────────────── -sops --input-type dotenv --output-type dotenv -d .env.prod.sops > .env -chmod 600 .env +sops --input-type dotenv --output-type dotenv -d "${APP_DIR}/.env.prod.sops" > "${APP_DIR}/.env" +chmod 600 "${APP_DIR}/.env" COMPOSE="docker compose -f docker-compose.prod.yml" LIVE_FILE=".live-slot" diff --git a/infra/bootstrap_supervisor.sh b/infra/bootstrap_supervisor.sh new file mode 100644 index 0000000..e27d8e6 --- /dev/null +++ b/infra/bootstrap_supervisor.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# Bootstrap Padelnomics supervisor after setup_server.sh + adding keys. +# Run once on the server after SSH deploy key is added to GitLab +# and the server age key is committed to .env.prod.sops. +# +# Usage: +# ssh root@ 'bash -s' < infra/bootstrap_supervisor.sh +# +# Prerequisites: +# - setup_server.sh already run (padelnomics_service user, SSH deploy key, age keypair, uv) +# - Deploy key added to GitLab (Settings → Repository → Deploy Keys) +# - Server age public key added to .sops.yaml + .env.prod.sops committed + pushed + +set -euo pipefail + +SERVICE_USER="padelnomics_service" +REPO_DIR="/opt/padelnomics" +GITLAB_PROJECT="deemanone/padelnomics" +UV="/home/${SERVICE_USER}/.local/bin/uv" + +[ "$(id -u)" = "0" ] || { echo "ERROR: Run as root"; exit 1; } + +# ── Check age keypair ───────────────────────────────────────────────────────── + +AGE_KEY_FILE="/home/${SERVICE_USER}/.config/sops/age/keys.txt" +if [ ! -f "${AGE_KEY_FILE}" ]; then + echo "ERROR: Age keypair not found at ${AGE_KEY_FILE}" + echo "Run infra/setup_server.sh first, then add the printed keys, then re-run." + exit 1 +fi + +# ── Clone or update repository ──────────────────────────────────────────────── + +if [ -d "${REPO_DIR}/.git" ]; then + sudo -u "${SERVICE_USER}" git -C "${REPO_DIR}" fetch --tags --prune-tags origin +else + sudo -u "${SERVICE_USER}" git clone \ + "git@gitlab.com:${GITLAB_PROJECT}.git" "${REPO_DIR}" +fi + +LATEST_TAG=$(sudo -u "${SERVICE_USER}" \ + git -C "${REPO_DIR}" tag --list --sort=-version:refname "v*" | head -1) +if [ -n "${LATEST_TAG}" ]; then + sudo -u "${SERVICE_USER}" git -C "${REPO_DIR}" checkout --detach "${LATEST_TAG}" +fi + +# ── Decrypt secrets ─────────────────────────────────────────────────────────── + +sudo -u "${SERVICE_USER}" bash -c \ + "sops --input-type dotenv --output-type dotenv -d ${REPO_DIR}/.env.prod.sops > ${REPO_DIR}/.env" +chmod 600 "${REPO_DIR}/.env" + +# ── Python dependencies ─────────────────────────────────────────────────────── + +sudo -u "${SERVICE_USER}" bash -c "cd ${REPO_DIR} && ${UV} sync --all-packages" + +# ── Systemd services ────────────────────────────────────────────────────────── + +cp "${REPO_DIR}/infra/landing-backup/padelnomics-landing-backup.service" /etc/systemd/system/ +cp "${REPO_DIR}/infra/landing-backup/padelnomics-landing-backup.timer" /etc/systemd/system/ +cp "${REPO_DIR}/infra/supervisor/padelnomics-supervisor.service" /etc/systemd/system/ +systemctl daemon-reload +systemctl enable --now padelnomics-landing-backup.timer +systemctl enable --now padelnomics-supervisor + +echo "" +echo "=== Bootstrap complete! ===" +echo "" +echo "Check status: systemctl status padelnomics-supervisor" +echo "View logs: journalctl -u padelnomics-supervisor -f" +echo "Backup timer: systemctl list-timers padelnomics-landing-backup.timer" +echo "Tag: $(sudo -u "${SERVICE_USER}" git -C "${REPO_DIR}" describe --tags --always)" diff --git a/infra/setup_server.sh b/infra/setup_server.sh index 09f7bc9..1add6f5 100644 --- a/infra/setup_server.sh +++ b/infra/setup_server.sh @@ -1,101 +1,176 @@ #!/bin/bash -# One-time server setup. Run as root on a fresh server. -# Creates padelnomics_service user, installs system dependencies, -# and registers systemd services that run as that user. +# One-time server setup: create service user, install tools, SSH deploy key, age keypair. +# Run as root on a fresh Debian/Ubuntu server before running bootstrap_supervisor.sh. # # Usage: -# sudo bash infra/setup_server.sh +# bash infra/setup_server.sh +# +# What it does: +# 1. Creates padelnomics_service user (nologin) + adds to docker group +# 2. Creates /opt/padelnomics + /data/padelnomics/landing with correct ownership +# 3. Installs Docker, git, curl, age, sops, rclone, uv +# 4. Generates ed25519 SSH deploy key for GitLab read access +# 5. Generates age keypair at ~/.config/sops/age/keys.txt (as service user) +# 6. Prints both public keys + numbered next-step instructions set -euo pipefail -APP_DIR="/opt/padelnomics" SERVICE_USER="padelnomics_service" -SERVICE_HOME="/home/${SERVICE_USER}" -KEY_PATH="${SERVICE_HOME}/.ssh/padelnomics_deploy" +APP_DIR="/opt/padelnomics" +DATA_DIR="/data/padelnomics" +SSH_DIR="/home/${SERVICE_USER}/.ssh" +DEPLOY_KEY="${SSH_DIR}/padelnomics_deploy" +SOPS_AGE_DIR="/home/${SERVICE_USER}/.config/sops/age" -# Ensure running as root -if [ "$(id -u)" -ne 0 ]; then - echo "Error: must run as root (use sudo)" >&2 - exit 1 +[ "$(id -u)" = "0" ] || { echo "ERROR: Run as root: sudo bash infra/setup_server.sh"; exit 1; } + +log() { echo "$(date '+%H:%M:%S') ==> $*"; } + +# ── Service user ────────────────────────────────────────────────────────────── + +log "Creating service user ${SERVICE_USER}..." +if ! id "${SERVICE_USER}" >/dev/null 2>&1; then + useradd --system --create-home --shell /usr/sbin/nologin "${SERVICE_USER}" fi -# Create service user if not present -if ! id "$SERVICE_USER" &>/dev/null; then - useradd --system --create-home --shell /usr/sbin/nologin "$SERVICE_USER" - echo "Created user $SERVICE_USER" -else - echo "User $SERVICE_USER already exists, skipping" +# ── Directories ─────────────────────────────────────────────────────────────── + +log "Creating directories..." +mkdir -p "${APP_DIR}" "${DATA_DIR}/landing" +chown "${SERVICE_USER}:${SERVICE_USER}" "${APP_DIR}" +chown -R "${SERVICE_USER}:${SERVICE_USER}" "${DATA_DIR}" + +# ── Docker ──────────────────────────────────────────────────────────────────── + +if ! command -v docker >/dev/null 2>&1; then + log "Installing Docker..." + curl -fsSL https://get.docker.com | bash +fi +usermod -aG docker "${SERVICE_USER}" + +# ── System tools ────────────────────────────────────────────────────────────── + +log "Installing system tools..." +apt-get update -q +apt-get install -y -q git curl ca-certificates + +# ── SSH deploy key ──────────────────────────────────────────────────────────── + +log "Setting up SSH deploy key..." +sudo -u "${SERVICE_USER}" mkdir -p "${SSH_DIR}" +chmod 700 "${SSH_DIR}" + +if [ ! -f "${DEPLOY_KEY}" ]; then + sudo -u "${SERVICE_USER}" ssh-keygen -t ed25519 \ + -f "${DEPLOY_KEY}" -N "" -C "padelnomics-deploy" fi -# Add service user to docker group (needed for deploy.sh) -usermod -aG docker "$SERVICE_USER" -echo "Added $SERVICE_USER to docker group" - -# Create app directory owned by service user -mkdir -p "$APP_DIR" -if [ "$(stat -c '%U' "$APP_DIR")" != "$SERVICE_USER" ]; then - chown "$SERVICE_USER:$SERVICE_USER" "$APP_DIR" -fi -echo "Created $APP_DIR" - -# Generate deploy key as service user if not present -if [ ! -f "$KEY_PATH" ]; then - sudo -u "$SERVICE_USER" mkdir -p "${SERVICE_HOME}/.ssh" - sudo -u "$SERVICE_USER" ssh-keygen -t ed25519 -f "$KEY_PATH" -N "" -C "padelnomics-server" - chmod 700 "${SERVICE_HOME}/.ssh" - chmod 600 "$KEY_PATH" - chmod 644 "${KEY_PATH}.pub" - - # Configure SSH to use this key for gitlab.com - if ! grep -q "# padelnomics" "${SERVICE_HOME}/.ssh/config" 2>/dev/null; then - sudo -u "$SERVICE_USER" tee -a "${SERVICE_HOME}/.ssh/config" > /dev/null < "${SSH_DIR}/config" <> "${SSH_DIR}/known_hosts" 2>/dev/null +sort -u "${SSH_DIR}/known_hosts" -o "${SSH_DIR}/known_hosts" +chown "${SERVICE_USER}:${SERVICE_USER}" "${SSH_DIR}/known_hosts" +chmod 644 "${SSH_DIR}/known_hosts" + +# ── age ─────────────────────────────────────────────────────────────────────── + +ARCH=$(uname -m) +case "${ARCH}" in + x86_64) ARCH_TAG="amd64" ;; + aarch64) ARCH_TAG="arm64" ;; + *) echo "Unsupported architecture: ${ARCH}"; exit 1 ;; +esac + +if ! command -v age-keygen >/dev/null 2>&1; then + log "Installing age..." + AGE_VERSION="v1.3.1" + curl -fsSL "https://dl.filippo.io/age/${AGE_VERSION}?for=linux/${ARCH_TAG}" -o /tmp/age.tar.gz + tar -xzf /tmp/age.tar.gz -C /usr/local/bin --strip-components=1 age/age age/age-keygen + chmod +x /usr/local/bin/age /usr/local/bin/age-keygen + rm /tmp/age.tar.gz fi -# Install rclone system-wide (we are root, no sudo needed) -if ! command -v rclone &>/dev/null; then - echo "Installing rclone..." +# ── sops ────────────────────────────────────────────────────────────────────── + +if ! command -v sops >/dev/null 2>&1; then + log "Installing sops..." + SOPS_VERSION="v3.12.1" + curl -fsSL \ + "https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.${ARCH_TAG}" \ + -o /usr/local/bin/sops + chmod +x /usr/local/bin/sops +fi + +# ── rclone ──────────────────────────────────────────────────────────────────── + +if ! command -v rclone >/dev/null 2>&1; then + log "Installing rclone..." curl -fsSL https://rclone.org/install.sh | bash - echo "Installed rclone $(rclone --version | head -1)" +fi + +# ── age keypair (as service user, XDG path auto-discovered by SOPS) ─────────── + +log "Generating age keypair for ${SERVICE_USER}..." +AGE_KEY_FILE="${SOPS_AGE_DIR}/keys.txt" +if [ ! -f "${AGE_KEY_FILE}" ]; then + sudo -u "${SERVICE_USER}" mkdir -p "${SOPS_AGE_DIR}" + sudo -u "${SERVICE_USER}" age-keygen -o "${AGE_KEY_FILE}" + chmod 600 "${AGE_KEY_FILE}" + log "Age keypair generated at ${AGE_KEY_FILE}" else - echo "rclone already installed, skipping" + log "Age keypair already exists — skipping." fi -# Create data directories owned by service user -mkdir -p /data/padelnomics/landing -if [ "$(stat -c '%U' /data/padelnomics)" != "$SERVICE_USER" ]; then - chown -R "$SERVICE_USER:$SERVICE_USER" /data/padelnomics +# ── uv (installed as service user) ──────────────────────────────────────────── + +if [ ! -f "/home/${SERVICE_USER}/.local/bin/uv" ]; then + log "Installing uv..." + sudo -u "${SERVICE_USER}" bash -c 'curl -LsSf https://astral.sh/uv/install.sh | sh' fi -echo "Created /data/padelnomics/landing" -# Install and enable systemd services -cp "$APP_DIR/infra/landing-backup/padelnomics-landing-backup.service" /etc/systemd/system/ -cp "$APP_DIR/infra/landing-backup/padelnomics-landing-backup.timer" /etc/systemd/system/ -cp "$APP_DIR/infra/supervisor/padelnomics-supervisor.service" /etc/systemd/system/ -systemctl daemon-reload -systemctl enable --now padelnomics-landing-backup.timer -echo "Enabled landing backup timer (every 30 min)" -systemctl enable --now padelnomics-supervisor.service -echo "Enabled supervisor service" +# ── Summary ─────────────────────────────────────────────────────────────────── + +DEPLOY_PUB=$(cat "${DEPLOY_KEY}.pub") +AGE_PUB=$(grep "public key:" "${AGE_KEY_FILE}" | awk '{print $NF}') echo "" -echo "=== Next steps ===" -echo "1. Add this deploy key to GitLab (Settings → Repository → Deploy Keys, read-only):" +echo "==================================================================" echo "" -cat "${KEY_PATH}.pub" +echo " SSH deploy key (add to GitLab → Settings → Deploy Keys):" echo "" -echo "2. Clone the repo as $SERVICE_USER:" -echo " sudo -u $SERVICE_USER git clone git@gitlab.com:deemanone/padelnomics.git $APP_DIR" +echo " ${DEPLOY_PUB}" echo "" -echo "3. Deploy (first run generates server age keypair — follow the printed instructions):" -echo " sudo -u $SERVICE_USER bash $APP_DIR/deploy.sh" +echo " Server age public key (add to .sops.yaml):" +echo "" +echo " ${AGE_PUB}" +echo "" +echo "==================================================================" +echo "" +echo " Next steps:" +echo "" +echo " 1. Add the SSH deploy key to GitLab:" +echo " → Repository Settings → Deploy Keys → Add key (read-only)" +echo "" +echo " 2. Add the age public key to .sops.yaml on your workstation:" +echo " creation_rules:" +echo " - path_regex: \\.env\\.(dev|prod)\\.sops\$" +echo " age: >-" +echo " " +echo " + ${AGE_PUB}" +echo "" +echo " 3. Re-encrypt prod secrets to include the server key:" +echo " sops updatekeys .env.prod.sops" +echo " git add .sops.yaml .env.prod.sops" +echo " git commit -m 'chore: add server age key'" +echo " git push" +echo "" +echo " 4. Run bootstrap:" +echo " bash infra/bootstrap_supervisor.sh" +echo "" +echo "=================================================================="