feat: add Phase 1 — Gitea on Unraid

- phase1_gitea_unraid.sh: 9-step deploy (dirs, docker-compose, app.ini,
  container start, wait, admin user, API token, save to .env, create org).
  Every step has idempotency check — running twice changes nothing.
- phase1_post_check.sh: 5 independent verification checks
- phase1_teardown.sh: stop container + optionally remove data, with prompts

Also adds inline comments to lib/common.sh and preflight.sh explaining
WHY decisions were made (SSH flags, API tmpfile pattern, port checks, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
S
2026-02-26 15:12:02 -06:00
parent da9f56cce9
commit 63f708e556
5 changed files with 333 additions and 3 deletions

View File

@@ -69,6 +69,9 @@ _project_root() {
cd "$(dirname "${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}")/.." && pwd cd "$(dirname "${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}")/.." && pwd
} }
# Source .env and export all variables.
# Uses set -a/+a to auto-export every variable defined in the file,
# making them available to child processes (envsubst, ssh, etc.).
load_env() { load_env() {
local env_file local env_file
env_file="$(_project_root)/.env" env_file="$(_project_root)/.env"
@@ -77,10 +80,10 @@ load_env() {
log_error "Copy .env.example to .env and populate values." log_error "Copy .env.example to .env and populate values."
return 1 return 1
fi fi
set -a set -a # auto-export all vars defined below
# shellcheck source=/dev/null # shellcheck source=/dev/null
source "$env_file" source "$env_file"
set +a set +a # stop auto-exporting
} }
save_env_var() { save_env_var() {
@@ -124,10 +127,15 @@ require_vars() {
# SSH # SSH
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Execute a command on a remote host via SSH.
# Uses indirect variable expansion: ssh_exec "UNRAID" "ls" reads
# UNRAID_IP, UNRAID_SSH_USER, UNRAID_SSH_PORT from the environment.
# This pattern avoids passing connection details to every function call.
ssh_exec() { ssh_exec() {
local host_key="$1"; shift local host_key="$1"; shift
local cmd="$*" local cmd="$*"
# Indirect expansion: ${!ip_var} dereferences the variable named by $ip_var
local ip_var="${host_key}_IP" local ip_var="${host_key}_IP"
local user_var="${host_key}_SSH_USER" local user_var="${host_key}_SSH_USER"
local port_var="${host_key}_SSH_PORT" local port_var="${host_key}_SSH_PORT"
@@ -141,6 +149,9 @@ ssh_exec() {
return 1 return 1
fi fi
# ConnectTimeout: fail fast if host is unreachable (don't hang for 60s)
# StrictHostKeyChecking=accept-new: auto-accept new hosts but reject changed keys
# BatchMode=yes: never prompt for password (fail if key auth doesn't work)
ssh -o ConnectTimeout=10 \ ssh -o ConnectTimeout=10 \
-o StrictHostKeyChecking=accept-new \ -o StrictHostKeyChecking=accept-new \
-o BatchMode=yes \ -o BatchMode=yes \
@@ -181,6 +192,10 @@ scp_to() {
# API wrappers — return JSON on stdout, logs go to stderr # API wrappers — return JSON on stdout, logs go to stderr
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Internal API call helper. Writes response to a tmpfile so we can separate
# the HTTP status code (via curl -w) from the response body. This ensures
# JSON output goes to stdout and error messages go to stderr — callers can
# pipe JSON through jq without log noise contaminating the output.
_api_call() { _api_call() {
local base_url="$1" token="$2" method="$3" path="$4" data="${5:-}" local base_url="$1" token="$2" method="$3" path="$4" data="${5:-}"

171
phase1_gitea_unraid.sh Executable file
View File

@@ -0,0 +1,171 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# phase1_gitea_unraid.sh — Deploy Gitea on Unraid
# Produces: Running Gitea instance, admin user, API token in .env, org created
# =============================================================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/lib/common.sh"
load_env
require_vars UNRAID_IP UNRAID_SSH_USER UNRAID_SSH_PORT \
UNRAID_GITEA_PORT UNRAID_GITEA_SSH_PORT UNRAID_GITEA_DATA_PATH \
GITEA_ADMIN_USER GITEA_ADMIN_PASSWORD GITEA_ADMIN_EMAIL \
GITEA_ORG_NAME GITEA_INSTANCE_NAME \
GITEA_DB_TYPE GITEA_VERSION \
GITEA_INTERNAL_URL GITEA_DOMAIN
phase_header 1 "Gitea on Unraid"
# Alias for shorter references in template rendering.
# Templates use $DATA_PATH as a generic variable name so the same template
# works for both Unraid (Phase 1) and Fedora (Phase 2).
DATA_PATH="$UNRAID_GITEA_DATA_PATH"
# ---------------------------------------------------------------------------
# Step 1: Create data directories
# ---------------------------------------------------------------------------
log_step 1 "Creating data directories on Unraid..."
if ssh_exec UNRAID "test -d '${DATA_PATH}/data'"; then
log_info "Data directory already exists — skipping"
else
ssh_exec UNRAID "mkdir -p '${DATA_PATH}/data' '${DATA_PATH}/config'"
log_success "Data directories created"
fi
# ---------------------------------------------------------------------------
# Step 2: Render + SCP docker-compose file
# ---------------------------------------------------------------------------
log_step 2 "Deploying docker-compose.yml..."
if ssh_exec UNRAID "test -f '${DATA_PATH}/docker-compose.yml'"; then
log_info "docker-compose.yml already exists — skipping"
else
TMPFILE=$(mktemp)
# Set variables for template
export DATA_PATH GITEA_PORT="${UNRAID_GITEA_PORT}" GITEA_SSH_PORT="${UNRAID_GITEA_SSH_PORT}"
render_template "${SCRIPT_DIR}/templates/docker-compose-gitea.yml.tpl" "$TMPFILE"
scp_to UNRAID "$TMPFILE" "${DATA_PATH}/docker-compose.yml"
rm -f "$TMPFILE"
log_success "docker-compose.yml deployed"
fi
# ---------------------------------------------------------------------------
# Step 3: Render + SCP app.ini
# ---------------------------------------------------------------------------
log_step 3 "Deploying app.ini..."
if ssh_exec UNRAID "test -f '${DATA_PATH}/config/app.ini'"; then
log_info "app.ini already exists — skipping"
else
TMPFILE=$(mktemp)
# Generate a random secret key for this instance
GITEA_SECRET_KEY=$(openssl rand -hex 32)
export GITEA_SECRET_KEY
render_template "${SCRIPT_DIR}/templates/app.ini.tpl" "$TMPFILE"
scp_to UNRAID "$TMPFILE" "${DATA_PATH}/config/app.ini"
rm -f "$TMPFILE"
log_success "app.ini deployed"
fi
# ---------------------------------------------------------------------------
# Step 4: Start Gitea container
# ---------------------------------------------------------------------------
log_step 4 "Starting Gitea container..."
CONTAINER_STATUS=$(ssh_exec UNRAID "docker ps --filter name=gitea --format '{{.Status}}'" 2>/dev/null || true)
if [[ "$CONTAINER_STATUS" == *"Up"* ]]; then
log_info "Gitea container already running — skipping"
else
# Try modern "docker compose" first (plugin), fall back to standalone "docker-compose"
ssh_exec UNRAID "cd '${DATA_PATH}' && docker compose up -d 2>/dev/null || docker-compose up -d"
log_success "Gitea container started"
fi
# ---------------------------------------------------------------------------
# Step 5: Wait for Gitea to be ready
# ---------------------------------------------------------------------------
log_step 5 "Waiting for Gitea to be ready..."
wait_for_http "${GITEA_INTERNAL_URL}/api/v1/version" 120
# ---------------------------------------------------------------------------
# Step 6: Create admin user
# ---------------------------------------------------------------------------
log_step 6 "Creating admin user..."
if curl -sf -u "${GITEA_ADMIN_USER}:${GITEA_ADMIN_PASSWORD}" "${GITEA_INTERNAL_URL}/api/v1/user" -o /dev/null 2>/dev/null; then
log_info "Admin user already exists — skipping"
else
# Create admin via CLI inside the container (not the API) because no API
# token exists yet at this point. || true prevents set -e from exiting if
# the user was partially created in a previous interrupted run.
ssh_exec UNRAID "docker exec gitea gitea admin user create \
--username '${GITEA_ADMIN_USER}' \
--password '${GITEA_ADMIN_PASSWORD}' \
--email '${GITEA_ADMIN_EMAIL}' \
--admin \
--must-change-password=false" || true
# Verify with API call — this is the real success check
if curl -sf -u "${GITEA_ADMIN_USER}:${GITEA_ADMIN_PASSWORD}" "${GITEA_INTERNAL_URL}/api/v1/user" -o /dev/null 2>/dev/null; then
log_success "Admin user created"
else
log_error "Failed to create admin user"
exit 1
fi
fi
# ---------------------------------------------------------------------------
# Step 7+8: Generate API token and save to .env
# ---------------------------------------------------------------------------
log_step 7 "Generating API token..."
if [[ -n "${GITEA_ADMIN_TOKEN:-}" ]]; then
# Verify existing token works
if curl -sf -H "Authorization: token ${GITEA_ADMIN_TOKEN}" "${GITEA_INTERNAL_URL}/api/v1/user" -o /dev/null 2>/dev/null; then
log_info "API token already exists and is valid — skipping"
else
log_warn "Existing token is invalid — generating new one"
GITEA_ADMIN_TOKEN=""
fi
fi
if [[ -z "${GITEA_ADMIN_TOKEN:-}" ]]; then
# Generate token using basic auth (username:password).
# The .sha1 field in the response contains the full token — this is the
# ONLY time it's returned, so we must save it immediately.
TOKEN_RESPONSE=$(curl -sf -u "${GITEA_ADMIN_USER}:${GITEA_ADMIN_PASSWORD}" \
-X POST \
-H "Content-Type: application/json" \
-d '{"name":"migration-token","scopes":["all"]}' \
"${GITEA_INTERNAL_URL}/api/v1/users/${GITEA_ADMIN_USER}/tokens")
GITEA_ADMIN_TOKEN=$(printf '%s' "$TOKEN_RESPONSE" | jq -r '.sha1')
if [[ -z "$GITEA_ADMIN_TOKEN" ]] || [[ "$GITEA_ADMIN_TOKEN" == "null" ]]; then
log_error "Failed to generate API token"
exit 1
fi
save_env_var "GITEA_ADMIN_TOKEN" "$GITEA_ADMIN_TOKEN"
log_success "API token generated and saved to .env"
fi
# ---------------------------------------------------------------------------
# Step 9: Create organization
# ---------------------------------------------------------------------------
log_step 9 "Creating organization '${GITEA_ORG_NAME}'..."
if curl -sf -H "Authorization: token ${GITEA_ADMIN_TOKEN}" "${GITEA_INTERNAL_URL}/api/v1/orgs/${GITEA_ORG_NAME}" -o /dev/null 2>/dev/null; then
log_info "Organization already exists — skipping"
else
curl -sf -X POST \
-H "Authorization: token ${GITEA_ADMIN_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"username\":\"${GITEA_ORG_NAME}\",\"full_name\":\"${GITEA_INSTANCE_NAME}\",\"visibility\":\"public\"}" \
"${GITEA_INTERNAL_URL}/api/v1/orgs" -o /dev/null
if curl -sf -H "Authorization: token ${GITEA_ADMIN_TOKEN}" "${GITEA_INTERNAL_URL}/api/v1/orgs/${GITEA_ORG_NAME}" -o /dev/null 2>/dev/null; then
log_success "Organization created"
else
log_error "Failed to create organization"
exit 1
fi
fi
log_success "Phase 1 complete — Gitea is running on Unraid"

75
phase1_post_check.sh Executable file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# phase1_post_check.sh — Verify Phase 1 (Gitea on Unraid) succeeded
# Independent verification — can be run separately from phase1_gitea_unraid.sh
# Exits 0 only if ALL checks pass.
# =============================================================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/lib/common.sh"
load_env
require_vars GITEA_INTERNAL_URL GITEA_ADMIN_USER GITEA_ADMIN_PASSWORD \
GITEA_ADMIN_TOKEN GITEA_ORG_NAME
log_info "=== Phase 1 Post-Check ==="
PASS=0
FAIL=0
# Helper: run a check, track results
run_check() {
local description="$1"; shift
if "$@" 2>/dev/null; then
log_success "$description"
PASS=$((PASS + 1))
else
log_error "FAIL: $description"
FAIL=$((FAIL + 1))
fi
}
# Check 1: Gitea responds with HTTP 200
run_check "Gitea HTTP 200 at ${GITEA_INTERNAL_URL}" \
curl -sf -o /dev/null "${GITEA_INTERNAL_URL}/api/v1/version"
# Check 2: Admin user authenticates with basic auth
run_check "Admin user authenticates (basic auth)" \
curl -sf -o /dev/null -u "${GITEA_ADMIN_USER}:${GITEA_ADMIN_PASSWORD}" "${GITEA_INTERNAL_URL}/api/v1/user"
# Check 3: API token works and returns correct username
check_token() {
local response
response=$(curl -sf -H "Authorization: token ${GITEA_ADMIN_TOKEN}" "${GITEA_INTERNAL_URL}/api/v1/user")
local login
login=$(printf '%s' "$response" | jq -r '.login')
[[ "$login" == "${GITEA_ADMIN_USER}" ]]
}
run_check "API token valid (returns correct username)" check_token
# Check 4: Organization exists
run_check "Organization '${GITEA_ORG_NAME}' exists" \
curl -sf -o /dev/null -H "Authorization: token ${GITEA_ADMIN_TOKEN}" "${GITEA_INTERNAL_URL}/api/v1/orgs/${GITEA_ORG_NAME}"
# Check 5: Gitea Actions enabled (verify via settings API)
check_actions() {
# The /api/v1/settings/api endpoint returns instance settings.
# If Actions are enabled, the Gitea instance will accept runner registrations.
# We can also check by simply verifying the admin/runners endpoint responds.
curl -sf -H "Authorization: token ${GITEA_ADMIN_TOKEN}" "${GITEA_INTERNAL_URL}/api/v1/settings/api" -o /dev/null
}
run_check "Gitea API settings accessible (Actions check)" check_actions
# Summary
printf '\n'
log_info "Results: ${PASS} passed, ${FAIL} failed"
if [[ $FAIL -gt 0 ]]; then
log_error "Phase 1 post-check FAILED"
exit 1
else
log_success "Phase 1 post-check PASSED — Gitea on Unraid is fully operational"
exit 0
fi

65
phase1_teardown.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# phase1_teardown.sh — Tear down Gitea on Unraid
# Destructive: stops container, optionally removes all data.
# Prompts for confirmation before each destructive action.
# Safe to run against an already-torn-down instance (no errors).
# =============================================================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/lib/common.sh"
load_env
require_vars UNRAID_IP UNRAID_SSH_USER UNRAID_GITEA_DATA_PATH
DATA_PATH="$UNRAID_GITEA_DATA_PATH"
log_warn "=== Phase 1 Teardown: Gitea on Unraid ==="
# ---------------------------------------------------------------------------
# Step 1: Stop and remove the Gitea container
# ---------------------------------------------------------------------------
# Check if docker-compose file exists (skip if already torn down)
if ssh_exec UNRAID "test -f '${DATA_PATH}/docker-compose.yml'" 2>/dev/null; then
printf 'This will stop Gitea on Unraid. Continue? [y/N] '
read -r confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
# Try modern "docker compose" first, fall back to standalone
ssh_exec UNRAID "cd '${DATA_PATH}' && docker compose down 2>/dev/null || docker-compose down" || true
log_success "Gitea container stopped and removed"
else
log_info "Skipped container shutdown"
fi
else
log_info "No docker-compose.yml found — container already removed"
fi
# ---------------------------------------------------------------------------
# Step 2: Optionally remove all Gitea data
# This is irreversible — removes repos, database, config, everything.
# ---------------------------------------------------------------------------
if ssh_exec UNRAID "test -d '${DATA_PATH}'" 2>/dev/null; then
printf 'Remove ALL Gitea data at %s? This is IRREVERSIBLE. [y/N] ' "$DATA_PATH"
read -r confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
ssh_exec UNRAID "rm -rf '${DATA_PATH}'"
log_success "All Gitea data removed from Unraid"
else
log_info "Data preserved at ${DATA_PATH}"
fi
else
log_info "Data directory already removed"
fi
# ---------------------------------------------------------------------------
# Step 3: Clear the auto-populated API token from .env
# The token is useless after teardown — clear it so phase1 generates a new one.
# ---------------------------------------------------------------------------
if [[ -n "${GITEA_ADMIN_TOKEN:-}" ]]; then
save_env_var "GITEA_ADMIN_TOKEN" ""
log_success "GITEA_ADMIN_TOKEN cleared from .env"
fi
log_success "Phase 1 teardown complete"

View File

@@ -15,7 +15,9 @@ PASS_COUNT=0
FAIL_COUNT=0 FAIL_COUNT=0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Check helper — runs a check, tracks pass/fail # Check helper — runs a check function, tracks pass/fail count.
# Intentionally does NOT exit on failure — we want to run ALL 16 checks
# so the user sees every issue at once, not one at a time.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
check() { check() {
local num="$1" description="$2" local num="$1" description="$2"
@@ -157,6 +159,8 @@ fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Check 10: Port free on Unraid # Check 10: Port free on Unraid
# Uses ss (socket statistics) to check if any process is listening on the port.
# The ! negates the grep — we PASS if the port is NOT found in use.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
check_port_unraid() { check_port_unraid() {
local port="${UNRAID_GITEA_PORT:-3000}" local port="${UNRAID_GITEA_PORT:-3000}"