refactor(infra): converge on setup+bootstrap pattern, fix systemd copy bug
- 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 <noreply@anthropic.com>
This commit is contained in:
51
deploy.sh
51
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"
|
||||
|
||||
72
infra/bootstrap_supervisor.sh
Normal file
72
infra/bootstrap_supervisor.sh
Normal file
@@ -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@<server_ip> '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)"
|
||||
@@ -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 <<EOF
|
||||
|
||||
# padelnomics
|
||||
cat > "${SSH_DIR}/config" <<EOF
|
||||
Host gitlab.com
|
||||
IdentityFile $KEY_PATH
|
||||
IdentityFile ${DEPLOY_KEY}
|
||||
IdentitiesOnly yes
|
||||
EOF
|
||||
chmod 600 "${SERVICE_HOME}/.ssh/config"
|
||||
chown "${SERVICE_USER}:${SERVICE_USER}" "${SSH_DIR}/config"
|
||||
chmod 600 "${SSH_DIR}/config"
|
||||
|
||||
ssh-keyscan -H gitlab.com >> "${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
|
||||
|
||||
echo "Generated deploy key: $KEY_PATH"
|
||||
else
|
||||
echo "Deploy key already exists, skipping"
|
||||
# ── 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
|
||||
|
||||
# Install rclone system-wide (we are root, no sudo needed)
|
||||
if ! command -v rclone &>/dev/null; then
|
||||
echo "Installing rclone..."
|
||||
# ── 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 " <dev-key>"
|
||||
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 "=================================================================="
|
||||
|
||||
Reference in New Issue
Block a user