Runs alongside Gitea, mounts Docker socket for Docker-based workflows. Token passed via GITEA_RUNNER_TOKEN env var (set in gitea/.env on server). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
224 lines
8.1 KiB
Bash
224 lines
8.1 KiB
Bash
#!/bin/bash
|
|
# Phase 2: Recover compose files, deploy services, start Gitea.
|
|
# Pipeable — run after setup.sh and after rsync'ing the repo.
|
|
#
|
|
# Usage (from workstation):
|
|
# ssh root@<server-ip> 'bash -s' < bootstrap.sh
|
|
#
|
|
# Prerequisites:
|
|
# - setup.sh already run (infra_service user, dirs, uv)
|
|
# - Repo rsync'd to /opt/server-infra/
|
|
#
|
|
# What it does:
|
|
# 1. Validates prerequisites
|
|
# 2. Recovers compose files from running containers (umami, reverse-proxy)
|
|
# 3. Creates data directories
|
|
# 4. Sets correct ownership
|
|
# 5. Prints next steps (start Gitea, add proxy host, push repo to Gitea)
|
|
|
|
set -euo pipefail
|
|
|
|
SERVICE_USER="infra_service"
|
|
REPO_DIR="/opt/server-infra"
|
|
DATA_DIR="/data/server-infra"
|
|
UV="/home/${SERVICE_USER}/.local/bin/uv"
|
|
|
|
[ "$(id -u)" = "0" ] || { echo "ERROR: Run as root"; exit 1; }
|
|
|
|
log() { echo "$(date '+%H:%M:%S') ==> $*"; }
|
|
warn() { echo "$(date '+%H:%M:%S') ==> WARNING: $*" >&2; }
|
|
|
|
# ── Preflight checks ───────────────────────────────────────────────────────────
|
|
|
|
if ! id "${SERVICE_USER}" >/dev/null 2>&1; then
|
|
echo "ERROR: ${SERVICE_USER} user not found. Run setup.sh first."
|
|
exit 1
|
|
fi
|
|
|
|
if [ ! -d "${REPO_DIR}/.git" ]; then
|
|
echo "ERROR: Repo not found at ${REPO_DIR}. Rsync the repo first:"
|
|
echo " rsync -av --chown=root:root ~/Projects/server-infra/ root@<server-ip>:${REPO_DIR}/"
|
|
exit 1
|
|
fi
|
|
|
|
command -v docker >/dev/null 2>&1 || { echo "ERROR: docker not found"; exit 1; }
|
|
|
|
[ -f "${UV}" ] || { echo "ERROR: uv not found at ${UV}. Run setup.sh first."; exit 1; }
|
|
|
|
# ── Inline autocompose script (replaces abandoned docker-autocompose package) ──
|
|
# Uses modern docker SDK. Written to /tmp, run via uv (fetches deps on demand).
|
|
|
|
cat > /tmp/server-infra-autocompose.py << 'PYEOF'
|
|
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = ["docker>=7.0", "pyyaml>=6.0"]
|
|
# ///
|
|
"""Generate a docker-compose services block from running containers."""
|
|
import sys
|
|
import docker
|
|
import yaml
|
|
|
|
|
|
def container_to_service(container):
|
|
attrs = container.attrs
|
|
config = attrs["Config"]
|
|
host_config = attrs["HostConfig"]
|
|
|
|
service = {"image": config["Image"]}
|
|
|
|
restart = (host_config.get("RestartPolicy") or {}).get("Name", "no")
|
|
if restart and restart not in ("no", ""):
|
|
service["restart"] = restart
|
|
|
|
env = config.get("Env") or []
|
|
if env:
|
|
service["environment"] = env
|
|
|
|
port_bindings = host_config.get("PortBindings") or {}
|
|
if port_bindings:
|
|
ports = []
|
|
for container_port, bindings in port_bindings.items():
|
|
for binding in (bindings or []):
|
|
host_ip = binding.get("HostIp", "")
|
|
host_port = binding.get("HostPort", "")
|
|
if host_ip and host_ip not in ("0.0.0.0", "::"):
|
|
ports.append(f"{host_ip}:{host_port}:{container_port}")
|
|
elif host_port:
|
|
ports.append(f"{host_port}:{container_port}")
|
|
else:
|
|
ports.append(container_port)
|
|
if ports:
|
|
service["ports"] = ports
|
|
|
|
volumes = []
|
|
for mount in attrs.get("Mounts") or []:
|
|
dst = mount["Destination"]
|
|
if mount["Type"] == "bind":
|
|
src = mount["Source"]
|
|
mode = mount.get("Mode", "rw")
|
|
volumes.append(f"{src}:{dst}" if mode == "rw" else f"{src}:{dst}:{mode}")
|
|
elif mount["Type"] == "volume":
|
|
name = mount.get("Name", "")
|
|
if name:
|
|
volumes.append(f"{name}:{dst}")
|
|
if volumes:
|
|
service["volumes"] = volumes
|
|
|
|
return service
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) < 2:
|
|
print("Usage: autocompose.py <container_name> [...]", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
client = docker.from_env()
|
|
services = {}
|
|
|
|
for name in sys.argv[1:]:
|
|
try:
|
|
container = client.containers.get(name)
|
|
labels = (container.attrs["Config"].get("Labels") or {})
|
|
service_name = labels.get("com.docker.compose.service", name)
|
|
services[service_name] = container_to_service(container)
|
|
except docker.errors.NotFound:
|
|
print(f"WARNING: Container {name} not found", file=sys.stderr)
|
|
|
|
print(yaml.dump({"services": services}, default_flow_style=False, sort_keys=False))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
PYEOF
|
|
|
|
# ── Recover compose files from running containers ──────────────────────────────
|
|
|
|
recover_project() {
|
|
local outfile="$1"
|
|
shift
|
|
local containers=("$@")
|
|
|
|
local running=0
|
|
for c in "${containers[@]}"; do
|
|
if docker inspect "${c}" >/dev/null 2>&1; then
|
|
running=1
|
|
break
|
|
fi
|
|
done
|
|
|
|
if [ "${running}" = "0" ]; then
|
|
warn "None of [${containers[*]}] are running — skipping recovery."
|
|
return 0
|
|
fi
|
|
|
|
log "Recovering: ${containers[*]}"
|
|
(cd /tmp && sudo -u "${SERVICE_USER}" \
|
|
"${UV}" run /tmp/server-infra-autocompose.py "${containers[@]}") > "${outfile}"
|
|
log " Saved to ${outfile}"
|
|
}
|
|
|
|
log "Recovering compose files from running containers..."
|
|
|
|
recover_project \
|
|
"${REPO_DIR}/umami/docker-compose.yml" \
|
|
umami-umami-1 umami-db-1
|
|
|
|
recover_project \
|
|
"${REPO_DIR}/reverse-proxy/docker-compose.yml" \
|
|
reverse_proxy-app-1
|
|
|
|
# ── Data directories ───────────────────────────────────────────────────────────
|
|
|
|
log "Creating data directories..."
|
|
mkdir -p "${DATA_DIR}/gitea" "${DATA_DIR}/act_runner"
|
|
|
|
# ── Ownership ──────────────────────────────────────────────────────────────────
|
|
|
|
log "Setting ownership..."
|
|
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${REPO_DIR}"
|
|
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${DATA_DIR}"
|
|
|
|
# ── Summary ────────────────────────────────────────────────────────────────────
|
|
|
|
SERVER_IP=$(hostname -I | awk '{print $1}')
|
|
|
|
echo ""
|
|
echo "=================================================================="
|
|
echo ""
|
|
echo " Bootstrap complete."
|
|
echo ""
|
|
echo " Services ready in ${REPO_DIR}/:"
|
|
[ -f "${REPO_DIR}/umami/docker-compose.yml" ] && echo " umami (recovered)"
|
|
[ -f "${REPO_DIR}/reverse-proxy/docker-compose.yml" ] && echo " reverse-proxy (recovered)"
|
|
[ -f "${REPO_DIR}/gitea/docker-compose.yml" ] && echo " gitea (from repo)"
|
|
echo ""
|
|
echo " Service user: ${SERVICE_USER}"
|
|
echo " Data dir: ${DATA_DIR}/"
|
|
echo ""
|
|
echo "=================================================================="
|
|
echo ""
|
|
echo " Next steps (run from workstation):"
|
|
echo ""
|
|
echo " 1. Pull recovered compose files back to workstation:"
|
|
echo " rsync -av hetzner_root:${REPO_DIR}/umami/docker-compose.yml ~/Projects/server-infra/umami/"
|
|
echo " rsync -av hetzner_root:${REPO_DIR}/reverse-proxy/docker-compose.yml ~/Projects/server-infra/reverse-proxy/"
|
|
echo ""
|
|
echo " 2. Commit recovered files:"
|
|
echo " cd ~/Projects/server-infra"
|
|
echo " git add umami/docker-compose.yml reverse-proxy/docker-compose.yml"
|
|
echo " git commit -m 'chore: recover compose files from running containers'"
|
|
echo ""
|
|
echo " 3. Start Gitea:"
|
|
echo " ssh hetzner_root 'sudo -u ${SERVICE_USER} docker compose -f ${REPO_DIR}/gitea/docker-compose.yml up -d'"
|
|
echo " # Web installer at http://${SERVER_IP}:3000"
|
|
echo " # Set ROOT_URL to your public domain (e.g. https://git.yourdomain.com)"
|
|
echo ""
|
|
echo " 4. Add proxy host in nginx proxy manager → ${SERVER_IP}:3000"
|
|
echo ""
|
|
echo " 5. Push repo to Gitea:"
|
|
echo " ssh hetzner_root 'sudo -u ${SERVICE_USER} git -C ${REPO_DIR} remote add origin https://git.yourdomain.com/youruser/server-infra.git'"
|
|
echo " ssh hetzner_root 'sudo -u ${SERVICE_USER} git -C ${REPO_DIR} push -u origin master'"
|
|
echo ""
|
|
echo "=================================================================="
|
|
echo ""
|