feat: rework runner config to INI format with full field support

Replace pipe-delimited runners.conf with INI-style sections supporting
host resolution, container images, repo-scoped tokens, resource limits,
capacity, and SSH key passthrough. All defaults pulled from .env.

- Add INI parsing helpers (ini_list_sections, ini_get, ini_set) to common.sh
- Add SSH key support (UNRAID_SSH_KEY, FEDORA_SSH_KEY) to ssh_exec/scp_to
- Add .env vars: RUNNER_DEFAULT_IMAGE, RUNNER_DEFAULT_CAPACITY,
  RUNNER_DEFAULT_DATA_PATH, LOCAL_RUNNER_DATA_PATH, LOCAL_REGISTRY
- Rewrite manage_runner.sh with host/image/token resolution and resource limits
- Rewrite configure_runners.sh wizard for INI format with all 9 fields
- Update phase3 scripts to use ini_list_sections instead of pipe parsing
- Add runners.conf INI validation to preflight.sh (check 5b)
- Update templates to use resolved labels, capacity, and deploy resources

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
S
2026-02-28 23:14:46 -05:00
parent fcd966f97d
commit f4a6b04d14
12 changed files with 1000 additions and 329 deletions

View File

@@ -14,6 +14,7 @@ UNRAID_SSH_PORT=22 # SSH port (default 22)
UNRAID_GITEA_PORT=3000 # Port Gitea web UI will listen on
UNRAID_GITEA_SSH_PORT=2222 # Port for git-over-SSH (host 22 is taken by SSH server)
UNRAID_GITEA_DATA_PATH= # Absolute path on NVMe for Gitea data (e.g. /mnt/nvme/gitea)
UNRAID_SSH_KEY= # Path to SSH private key (optional, uses ssh-agent default if empty)
# -----------------------------------------------------------------------------
@@ -25,6 +26,7 @@ FEDORA_SSH_PORT=22 # SSH port (default 22)
FEDORA_GITEA_PORT=3000 # Port Gitea web UI will listen on
FEDORA_GITEA_SSH_PORT=2222 # Port for git-over-SSH (host 22 is taken by SSH server)
FEDORA_GITEA_DATA_PATH= # Absolute path on NVMe for Gitea data (e.g. /mnt/nvme/gitea)
FEDORA_SSH_KEY= # Path to SSH private key (optional, uses ssh-agent default if empty)
# -----------------------------------------------------------------------------
@@ -62,9 +64,14 @@ GITEA_BACKUP_ADMIN_TOKEN= # API token for backup instance — do not fil
# -----------------------------------------------------------------------------
# RUNNERS
# Runner definitions live in runners.conf (see runners.conf.example)
# Runner definitions live in runners.conf (INI format, see runners.conf.example)
# Use manage_runner.sh to add/remove runners at any time
# -----------------------------------------------------------------------------
RUNNER_DEFAULT_IMAGE=catthehacker/ubuntu:act-latest # Default container image for docker runners
RUNNER_DEFAULT_CAPACITY=1 # Default max concurrent jobs per runner (positive integer)
RUNNER_DEFAULT_DATA_PATH=/mnt/nvme/gitea-runner # Default data path for remote (docker) runners
LOCAL_RUNNER_DATA_PATH=~/gitea-runner # Data path for native macOS runner
LOCAL_REGISTRY= # Local registry prefix (e.g. registry.local:5000), empty = Docker Hub
# AUTO-POPULATED by phase1 scripts — do not fill manually:
GITEA_RUNNER_REGISTRATION_TOKEN= # Retrieved from Gitea admin panel via API

View File

@@ -168,6 +168,19 @@ validate_ssl_mode() {
[[ "$1" == "letsencrypt" ]] || [[ "$1" == "existing" ]]
}
validate_positive_integer() {
[[ "$1" =~ ^[1-9][0-9]*$ ]]
}
validate_optional() {
return 0 # always valid — value can be empty or anything
}
validate_optional_path() {
# shellcheck disable=SC2088 # tilde intentionally stored as literal string
[[ -z "$1" ]] || [[ "$1" == /* ]] || [[ "$1" == "~/"* ]]
}
# ---------------------------------------------------------------------------
# .env format validation — validate_env()
# Checks every .env variable against its expected type.
@@ -187,6 +200,7 @@ _ENV_VAR_NAMES=(
GITEA_DOMAIN GITEA_INTERNAL_URL
GITEA_BACKUP_INTERNAL_URL GITEA_BACKUP_MIRROR_INTERVAL
BACKUP_STORAGE_PATH BACKUP_RETENTION_COUNT
RUNNER_DEFAULT_IMAGE RUNNER_DEFAULT_CAPACITY RUNNER_DEFAULT_DATA_PATH LOCAL_RUNNER_DATA_PATH
GITHUB_USERNAME GITHUB_TOKEN
REPO_1_NAME REPO_2_NAME REPO_3_NAME
MIGRATE_ISSUES MIGRATE_LABELS MIGRATE_MILESTONES MIGRATE_WIKI
@@ -206,6 +220,7 @@ _ENV_VAR_TYPES=(
nonempty url
url nonempty
path integer
nonempty positive_integer nonempty nonempty
nonempty nonempty
nonempty nonempty nonempty
bool bool bool bool
@@ -220,20 +235,27 @@ _ENV_CONDITIONAL_NAMES=(SSL_EMAIL SSL_CERT_PATH SSL_KEY_PATH)
_ENV_CONDITIONAL_TYPES=(email path path)
_ENV_CONDITIONAL_WHEN=( letsencrypt existing existing)
# Optional variables — validated only when non-empty (never required).
_ENV_OPTIONAL_NAMES=(UNRAID_SSH_KEY FEDORA_SSH_KEY LOCAL_REGISTRY)
_ENV_OPTIONAL_TYPES=(optional_path optional_path nonempty)
# Human-readable format hints for error messages.
_validator_hint() {
case "$1" in
ip) echo "expected: x.x.x.x" ;;
port) echo "expected: 1-65535" ;;
email) echo "must contain @" ;;
path) echo "must start with /" ;;
url) echo "must start with http:// or https://" ;;
bool) echo "must be true or false" ;;
integer) echo "must be a number" ;;
nonempty) echo "cannot be empty" ;;
password) echo "must be at least 8 characters" ;;
ssl_mode) echo "must be letsencrypt or existing" ;;
*) echo "invalid" ;;
ip) echo "expected: x.x.x.x" ;;
port) echo "expected: 1-65535" ;;
email) echo "must contain @" ;;
path) echo "must start with /" ;;
optional_path) echo "must start with / or ~/ (or be empty)" ;;
url) echo "must start with http:// or https://" ;;
bool) echo "must be true or false" ;;
integer) echo "must be a number" ;;
positive_integer) echo "must be a positive integer (>= 1)" ;;
nonempty) echo "cannot be empty" ;;
password) echo "must be at least 8 characters" ;;
ssl_mode) echo "must be letsencrypt or existing" ;;
optional) echo "any value or empty" ;;
*) echo "invalid" ;;
esac
}
@@ -277,6 +299,21 @@ validate_env() {
fi
done
# Validate optional variables (only when non-empty)
for ((i = 0; i < ${#_ENV_OPTIONAL_NAMES[@]}; i++)); do
var_name="${_ENV_OPTIONAL_NAMES[$i]}"
var_type="${_ENV_OPTIONAL_TYPES[$i]}"
value="${!var_name:-}"
# Skip if empty — these are optional
[[ -z "$value" ]] && continue
if ! "validate_${var_type}" "$value"; then
log_error "$var_name='$value' ($(_validator_hint "$var_type"))"
errors=$((errors + 1))
fi
done
if [[ $errors -gt 0 ]]; then
log_error "$errors .env variable(s) failed format validation"
return 1
@@ -349,10 +386,12 @@ ssh_exec() {
local ip_var="${host_key}_IP"
local user_var="${host_key}_SSH_USER"
local port_var="${host_key}_SSH_PORT"
local key_var="${host_key}_SSH_KEY"
local ip="${!ip_var:-}"
local user="${!user_var:-}"
local port="${!port_var:-22}"
local key="${!key_var:-}"
if [[ -z "$ip" ]] || [[ -z "$user" ]]; then
log_error "SSH config incomplete for $host_key: ${ip_var}=${ip:-<empty>}, ${user_var}=${user:-<empty>}"
@@ -362,7 +401,9 @@ ssh_exec() {
# 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 \
# ${key:+-i "$key"}: pass -i only when SSH_KEY is set (otherwise use ssh-agent default)
ssh ${key:+-i "$key"} \
-o ConnectTimeout=10 \
-o StrictHostKeyChecking=accept-new \
-o BatchMode=yes \
-p "$port" \
@@ -381,17 +422,20 @@ scp_to() {
local ip_var="${host_key}_IP"
local user_var="${host_key}_SSH_USER"
local port_var="${host_key}_SSH_PORT"
local key_var="${host_key}_SSH_KEY"
local ip="${!ip_var:-}"
local user="${!user_var:-}"
local port="${!port_var:-22}"
local key="${!key_var:-}"
if [[ -z "$ip" ]] || [[ -z "$user" ]]; then
log_error "SCP config incomplete for $host_key"
return 1
fi
scp -o ConnectTimeout=10 \
scp ${key:+-i "$key"} \
-o ConnectTimeout=10 \
-o StrictHostKeyChecking=accept-new \
-o BatchMode=yes \
-P "$port" \
@@ -612,6 +656,142 @@ check_remote_min_version() {
fi
}
# ---------------------------------------------------------------------------
# INI file parsing — used by runners.conf (INI-style sections)
# ---------------------------------------------------------------------------
# List all [section] names from an INI file.
# Usage: ini_list_sections "runners.conf"
# Outputs one section name per line (without brackets).
ini_list_sections() {
local file="$1"
if [[ ! -f "$file" ]]; then
return 1
fi
sed -n 's/^[[:space:]]*\[\([^]]*\)\].*/\1/p' "$file"
}
# Get a value from an INI file by section and key.
# Usage: ini_get "runners.conf" "unraid-runner" "host" "default_value"
# Returns the value (stripped of inline comments and whitespace), or
# the default if key not found. Exit code 1 if section not found.
ini_get() {
local file="$1" section="$2" key="$3" default="${4:-}"
if [[ ! -f "$file" ]]; then
printf '%s' "$default"
return 1
fi
local in_section=false
local line k v
while IFS= read -r line; do
# Strip leading/trailing whitespace
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
# Skip empty lines and comments
[[ -z "$line" ]] && continue
[[ "$line" == \#* ]] && continue
# Check for section header
if [[ "$line" =~ ^\[([^]]+)\] ]]; then
if [[ "${BASH_REMATCH[1]}" == "$section" ]]; then
in_section=true
elif $in_section; then
# Moved past our section without finding the key
break
fi
continue
fi
# Parse key = value lines within our section
if $in_section && [[ "$line" =~ ^([^=]+)=(.*) ]]; then
k="${BASH_REMATCH[1]}"
v="${BASH_REMATCH[2]}"
# Trim whitespace from key and value
k="${k#"${k%%[![:space:]]*}"}"
k="${k%"${k##*[![:space:]]}"}"
v="${v#"${v%%[![:space:]]*}"}"
v="${v%"${v##*[![:space:]]}"}"
# Strip inline comment (# preceded by whitespace)
v="${v%%[[:space:]]#*}"
# Trim trailing whitespace again after comment strip
v="${v%"${v##*[![:space:]]}"}"
if [[ "$k" == "$key" ]]; then
printf '%s' "$v"
return 0
fi
fi
done < "$file"
printf '%s' "$default"
if $in_section; then
return 0 # section found, key missing — return default
fi
return 1 # section not found
}
# Set a value in an INI file (create section/key if missing).
# Usage: ini_set "runners.conf" "unraid-runner" "host" "unraid"
ini_set() {
local file="$1" section="$2" key="$3" value="$4"
if [[ ! -f "$file" ]]; then
# Create file with section and key
printf '[%s]\n%s = %s\n' "$section" "$key" "$value" > "$file"
return 0
fi
local tmpfile
tmpfile=$(mktemp)
local in_section=false
local key_written=false
local section_exists=false
while IFS= read -r line; do
# Check for section header
if [[ "$line" =~ ^[[:space:]]*\[([^]]+)\] ]]; then
local found_section="${BASH_REMATCH[1]}"
if $in_section && ! $key_written; then
# Leaving our section without writing key — append it
printf '%s = %s\n' "$key" "$value" >> "$tmpfile"
key_written=true
fi
if [[ "$found_section" == "$section" ]]; then
in_section=true
section_exists=true
else
in_section=false
fi
printf '%s\n' "$line" >> "$tmpfile"
continue
fi
# Replace existing key within our section
if $in_section && ! $key_written; then
local stripped="${line#"${line%%[![:space:]]*}"}"
if [[ "$stripped" =~ ^${key}[[:space:]]*= ]]; then
printf '%s = %s\n' "$key" "$value" >> "$tmpfile"
key_written=true
continue
fi
fi
printf '%s\n' "$line" >> "$tmpfile"
done < "$file"
# Handle end-of-file cases
if $in_section && ! $key_written; then
printf '%s = %s\n' "$key" "$value" >> "$tmpfile"
elif ! $section_exists; then
printf '\n[%s]\n%s = %s\n' "$section" "$key" "$value" >> "$tmpfile"
fi
mv "$tmpfile" "$file"
}
# ---------------------------------------------------------------------------
# Install manifest — tracks what each setup script installs for rollback
# ---------------------------------------------------------------------------

View File

@@ -3,7 +3,7 @@ set -euo pipefail
# =============================================================================
# manage_runner.sh — Add, remove, or list Gitea Actions runners
# Reads runner definitions from runners.conf (pipe-delimited format).
# Reads runner definitions from runners.conf (INI format).
# Supports two runner types:
# docker — Linux hosts, deployed as Docker container via docker-compose
# native — macOS, deployed as binary + launchd service
@@ -31,16 +31,19 @@ Commands:
list Show all runners with status
Options:
--name <name> Runner name as defined in runners.conf
--name <name> Runner name (= section name in runners.conf)
--help Show this help
EOF
exit 1
}
# ---------------------------------------------------------------------------
# Parse a runner entry from runners.conf by name
# Sets: RUNNER_NAME, RUNNER_SSH_HOST, RUNNER_SSH_USER, RUNNER_SSH_PORT,
# RUNNER_DATA_PATH, RUNNER_LABELS, RUNNER_TYPE
# Parse a runner entry from runners.conf (INI format) by section name.
# Sets globals: RUNNER_NAME, RUNNER_HOST, RUNNER_TYPE, RUNNER_DATA_PATH,
# RUNNER_LABELS, RUNNER_DEFAULT_IMAGE, RUNNER_REPOS, RUNNER_CAPACITY,
# RUNNER_CPU, RUNNER_MEMORY
# Also resolves: RUNNER_SSH_HOST, RUNNER_SSH_USER, RUNNER_SSH_PORT,
# RUNNER_SSH_KEY (from .env or custom section keys)
# Returns 1 if not found.
# ---------------------------------------------------------------------------
parse_runner_entry() {
@@ -51,63 +54,205 @@ parse_runner_entry() {
return 1
fi
while IFS='|' read -r name host user port path labels type; do
# Skip comments and blank lines
[[ "$name" =~ ^[[:space:]]*# ]] && continue
[[ -z "$name" ]] && continue
# Trim whitespace from fields
name=$(echo "$name" | xargs)
if [[ "$name" == "$target_name" ]]; then
RUNNER_NAME="$name"
RUNNER_SSH_HOST=$(echo "$host" | xargs)
RUNNER_SSH_USER=$(echo "$user" | xargs)
RUNNER_SSH_PORT=$(echo "$port" | xargs)
RUNNER_DATA_PATH=$(echo "$path" | xargs)
RUNNER_LABELS=$(echo "$labels" | xargs)
RUNNER_TYPE=$(echo "$type" | xargs)
return 0
fi
done < "$RUNNERS_CONF"
log_error "Runner '$target_name' not found in runners.conf"
return 1
}
# ---------------------------------------------------------------------------
# List all runner entries from runners.conf (without looking up a name)
# Outputs lines to stdout: name|host|user|port|path|labels|type
# ---------------------------------------------------------------------------
all_runner_entries() {
if [[ ! -f "$RUNNERS_CONF" ]]; then
log_error "runners.conf not found at $RUNNERS_CONF"
# Check section exists
if ! ini_list_sections "$RUNNERS_CONF" | grep -qx "$target_name"; then
log_error "Runner '$target_name' not found in runners.conf"
return 1
fi
while IFS= read -r line; do
# Skip comments and blank lines
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "$line" ]] && continue
echo "$line"
done < "$RUNNERS_CONF"
RUNNER_NAME="$target_name"
RUNNER_HOST=$(ini_get "$RUNNERS_CONF" "$target_name" "host" "")
RUNNER_TYPE=$(ini_get "$RUNNERS_CONF" "$target_name" "type" "")
RUNNER_DATA_PATH=$(ini_get "$RUNNERS_CONF" "$target_name" "data_path" "")
RUNNER_LABELS=$(ini_get "$RUNNERS_CONF" "$target_name" "labels" "")
RUNNER_DEFAULT_IMAGE=$(ini_get "$RUNNERS_CONF" "$target_name" "default_image" "")
RUNNER_REPOS=$(ini_get "$RUNNERS_CONF" "$target_name" "repos" "all")
RUNNER_CAPACITY=$(ini_get "$RUNNERS_CONF" "$target_name" "capacity" "${RUNNER_DEFAULT_CAPACITY:-1}")
RUNNER_CPU=$(ini_get "$RUNNERS_CONF" "$target_name" "cpu" "")
RUNNER_MEMORY=$(ini_get "$RUNNERS_CONF" "$target_name" "memory" "")
# --- Host resolution ---
case "$RUNNER_HOST" in
unraid)
RUNNER_SSH_HOST="${UNRAID_IP:-}"
RUNNER_SSH_USER="${UNRAID_SSH_USER:-}"
RUNNER_SSH_PORT="${UNRAID_SSH_PORT:-22}"
RUNNER_SSH_KEY="${UNRAID_SSH_KEY:-}"
;;
fedora)
RUNNER_SSH_HOST="${FEDORA_IP:-}"
RUNNER_SSH_USER="${FEDORA_SSH_USER:-}"
RUNNER_SSH_PORT="${FEDORA_SSH_PORT:-22}"
RUNNER_SSH_KEY="${FEDORA_SSH_KEY:-}"
;;
local)
RUNNER_SSH_HOST="local"
RUNNER_SSH_USER=""
RUNNER_SSH_PORT=""
RUNNER_SSH_KEY=""
;;
custom)
RUNNER_SSH_HOST=$(ini_get "$RUNNERS_CONF" "$target_name" "ssh_host" "")
RUNNER_SSH_USER=$(ini_get "$RUNNERS_CONF" "$target_name" "ssh_user" "")
RUNNER_SSH_PORT=$(ini_get "$RUNNERS_CONF" "$target_name" "ssh_port" "22")
RUNNER_SSH_KEY=$(ini_get "$RUNNERS_CONF" "$target_name" "ssh_key" "")
;;
*)
log_error "Runner '$target_name': unknown host '$RUNNER_HOST' (must be unraid, fedora, local, or custom)"
return 1
;;
esac
# --- Validate required fields ---
if [[ -z "$RUNNER_TYPE" ]]; then
log_error "Runner '$target_name': type is empty (must be docker or native)"
return 1
fi
if [[ "$RUNNER_TYPE" != "docker" ]] && [[ "$RUNNER_TYPE" != "native" ]]; then
log_error "Runner '$target_name': type='$RUNNER_TYPE' (must be docker or native)"
return 1
fi
if [[ -z "$RUNNER_DATA_PATH" ]]; then
log_error "Runner '$target_name': data_path is empty"
return 1
fi
if [[ -z "$RUNNER_LABELS" ]]; then
log_error "Runner '$target_name': labels is empty"
return 1
fi
if ! [[ "$RUNNER_CAPACITY" =~ ^[1-9][0-9]*$ ]]; then
log_error "Runner '$target_name': capacity='$RUNNER_CAPACITY' (must be positive integer >= 1)"
return 1
fi
return 0
}
# ---------------------------------------------------------------------------
# Resolve the runner's image (with LOCAL_REGISTRY prefix if set).
# Sets RUNNER_RESOLVED_IMAGE.
# ---------------------------------------------------------------------------
resolve_runner_image() {
local image="${RUNNER_DEFAULT_IMAGE:-${RUNNER_DEFAULT_IMAGE_ENV:-}}"
if [[ -z "$image" ]] && [[ "$RUNNER_TYPE" == "docker" ]]; then
image="${RUNNER_DEFAULT_IMAGE:-catthehacker/ubuntu:act-latest}"
fi
if [[ -n "$image" ]] && [[ -n "${LOCAL_REGISTRY:-}" ]]; then
RUNNER_RESOLVED_IMAGE="${LOCAL_REGISTRY}/${image}"
else
RUNNER_RESOLVED_IMAGE="${image}"
fi
}
# ---------------------------------------------------------------------------
# Build act_runner label specs from labels + default_image.
# Docker: "linux:docker://image" | Native: "macos:host"
# Sets RUNNER_LABELS_CSV and RUNNER_LABELS_YAML.
# ---------------------------------------------------------------------------
build_runner_labels() {
resolve_runner_image
local labels_csv=""
local labels_yaml=""
local IFS=','
# shellcheck disable=SC2206
local -a parts=($RUNNER_LABELS)
local label spec
for label in "${parts[@]}"; do
label=$(echo "$label" | xargs)
[[ -z "$label" ]] && continue
if [[ "$label" == *:* ]]; then
# Already a full spec (e.g. linux:docker://node:20)
spec="$label"
elif [[ "$RUNNER_TYPE" == "docker" ]]; then
spec="${label}:docker://${RUNNER_RESOLVED_IMAGE}"
else
spec="${label}:host"
fi
if [[ -z "$labels_csv" ]]; then
labels_csv="$spec"
else
labels_csv="${labels_csv},${spec}"
fi
# shellcheck disable=SC2089 # intentional — value rendered via envsubst, not shell expansion
labels_yaml="${labels_yaml} - \"${spec}\"
"
done
export RUNNER_LABELS_CSV="$labels_csv"
# shellcheck disable=SC2090 # intentional — value rendered via envsubst, not shell expansion
export RUNNER_LABELS_YAML="$labels_yaml"
}
# ---------------------------------------------------------------------------
# Resolve registration token based on repos field.
# repos=all → instance-level token from .env
# repos=<name> → fetch repo-level token from Gitea API
# Sets RUNNER_REG_TOKEN.
# ---------------------------------------------------------------------------
resolve_registration_token() {
if [[ "$RUNNER_REPOS" == "all" ]] || [[ -z "$RUNNER_REPOS" ]]; then
RUNNER_REG_TOKEN="${GITEA_RUNNER_REGISTRATION_TOKEN:-}"
else
# Fetch repo-level registration token from Gitea API
local owner="${GITEA_ORG_NAME:-}"
if [[ -z "$owner" ]]; then
log_error "GITEA_ORG_NAME is empty — cannot fetch repo-level token"
return 1
fi
log_info "Fetching registration token for repo '${owner}/${RUNNER_REPOS}'..."
local response
response=$(gitea_api GET "/repos/${owner}/${RUNNER_REPOS}/actions/runners/registration-token" 2>/dev/null) || {
log_error "Failed to fetch registration token for repo '${RUNNER_REPOS}'"
return 1
}
RUNNER_REG_TOKEN=$(printf '%s' "$response" | jq -r '.token // empty' 2>/dev/null)
if [[ -z "$RUNNER_REG_TOKEN" ]]; then
log_error "Empty registration token returned for repo '${RUNNER_REPOS}'"
return 1
fi
fi
export RUNNER_REG_TOKEN
}
# ---------------------------------------------------------------------------
# Build Docker deploy.resources block (only when cpu/memory are set).
# Sets RUNNER_DEPLOY_RESOURCES.
# ---------------------------------------------------------------------------
build_deploy_resources() {
if [[ -n "$RUNNER_CPU" ]] || [[ -n "$RUNNER_MEMORY" ]]; then
local block=" deploy:\n resources:\n limits:"
if [[ -n "$RUNNER_CPU" ]]; then
block="${block}\n cpus: \"${RUNNER_CPU}\""
fi
if [[ -n "$RUNNER_MEMORY" ]]; then
block="${block}\n memory: \"${RUNNER_MEMORY}\""
fi
RUNNER_DEPLOY_RESOURCES=$(printf '%b' "$block")
else
RUNNER_DEPLOY_RESOURCES=""
fi
export RUNNER_DEPLOY_RESOURCES
}
# ---------------------------------------------------------------------------
# Execute a command on the runner's host.
# For "local" hosts (macOS), runs the command directly.
# For remote hosts, SSHs into them — uses direct ssh (not ssh_exec from
# common.sh) because runner hosts have their own SSH creds defined in
# runners.conf, not from the standard *_IP/*_SSH_USER env vars.
# For remote hosts, SSHs into them using resolved SSH credentials.
# ---------------------------------------------------------------------------
runner_ssh() {
local cmd="$*"
if [[ "$RUNNER_SSH_HOST" == "local" ]]; then
# macOS runner — execute directly on this machine
eval "$cmd"
else
ssh -o ConnectTimeout=10 \
# shellcheck disable=SC2086
ssh ${RUNNER_SSH_KEY:+-i "$RUNNER_SSH_KEY"} \
-o ConnectTimeout=10 \
-o StrictHostKeyChecking=accept-new \
-o BatchMode=yes \
-p "${RUNNER_SSH_PORT}" \
@@ -126,7 +271,9 @@ runner_scp() {
if [[ "$RUNNER_SSH_HOST" == "local" ]]; then
cp "$src" "$dst"
else
scp -o ConnectTimeout=10 \
# shellcheck disable=SC2086
scp ${RUNNER_SSH_KEY:+-i "$RUNNER_SSH_KEY"} \
-o ConnectTimeout=10 \
-o StrictHostKeyChecking=accept-new \
-o BatchMode=yes \
-P "${RUNNER_SSH_PORT}" \
@@ -136,16 +283,13 @@ runner_scp() {
# ---------------------------------------------------------------------------
# add_docker_runner — Deploy a runner as a Docker container on a Linux host
# Steps:
# 1. Create data directory
# 2. Render + SCP docker-compose.yml
# 3. Render + SCP runner-config.yaml
# 4. Start container
# The act_runner Docker image auto-registers with Gitea using env vars
# (GITEA_INSTANCE_URL + GITEA_RUNNER_REGISTRATION_TOKEN) on first boot.
# ---------------------------------------------------------------------------
add_docker_runner() {
log_info "Deploying Docker runner '${RUNNER_NAME}' on ${RUNNER_SSH_HOST}..."
log_info "Deploying Docker runner '${RUNNER_NAME}' on ${RUNNER_HOST} (${RUNNER_SSH_HOST})..."
build_runner_labels
resolve_registration_token
build_deploy_resources
# Check if container is already running
local status
@@ -158,44 +302,41 @@ add_docker_runner() {
# Create data directory on remote host
runner_ssh "mkdir -p '${RUNNER_DATA_PATH}'"
# Render docker-compose template with runner-specific vars
# Render docker-compose template
local tmpfile
tmpfile=$(mktemp)
export RUNNER_NAME RUNNER_LABELS RUNNER_DATA_PATH
export GITEA_RUNNER_REGISTRATION_TOKEN="${GITEA_RUNNER_REGISTRATION_TOKEN:-}"
export RUNNER_NAME RUNNER_DATA_PATH RUNNER_LABELS_CSV RUNNER_REG_TOKEN RUNNER_DEPLOY_RESOURCES
render_template "${SCRIPT_DIR}/templates/docker-compose-runner.yml.tpl" "$tmpfile" \
"\${ACT_RUNNER_VERSION} \${RUNNER_NAME} \${GITEA_INTERNAL_URL} \${GITEA_RUNNER_REGISTRATION_TOKEN} \${RUNNER_LABELS} \${RUNNER_DATA_PATH}"
"\${ACT_RUNNER_VERSION} \${RUNNER_NAME} \${GITEA_INTERNAL_URL} \${RUNNER_REG_TOKEN} \${RUNNER_LABELS_CSV} \${RUNNER_DATA_PATH} \${RUNNER_DEPLOY_RESOURCES}"
runner_scp "$tmpfile" "${RUNNER_DATA_PATH}/docker-compose.yml"
rm -f "$tmpfile"
# Render runner config
tmpfile=$(mktemp)
# shellcheck disable=SC2090 # intentional — RUNNER_LABELS_YAML rendered via envsubst
export RUNNER_LABELS_YAML
export RUNNER_CAPACITY
render_template "${SCRIPT_DIR}/templates/runner-config.yaml.tpl" "$tmpfile" \
"\${RUNNER_NAME} \${RUNNER_LABELS}"
"\${RUNNER_NAME} \${RUNNER_LABELS_YAML} \${RUNNER_CAPACITY}"
runner_scp "$tmpfile" "${RUNNER_DATA_PATH}/config.yaml"
rm -f "$tmpfile"
# Start the container
runner_ssh "cd '${RUNNER_DATA_PATH}' && docker compose up -d 2>/dev/null || docker-compose up -d"
log_success "Docker runner '${RUNNER_NAME}' started on ${RUNNER_SSH_HOST}"
log_success "Docker runner '${RUNNER_NAME}' started on ${RUNNER_HOST} (${RUNNER_SSH_HOST})"
}
# ---------------------------------------------------------------------------
# add_native_runner — Deploy a runner as a native binary on macOS
# Steps:
# 1. Download act_runner binary for macOS (arm64 or amd64)
# 2. Register with Gitea (--no-interactive)
# 3. Render launchd plist
# 4. Load plist via launchctl
# Native runners are used on macOS because Docker Desktop is heavyweight
# and unreliable for long-running background services.
# ---------------------------------------------------------------------------
add_native_runner() {
# Native runners use launchctl + macOS-specific paths — must be macOS
require_local_os "Darwin" "Native runner deployment requires macOS (uses launchctl)"
log_info "Deploying native runner '${RUNNER_NAME}' on local machine..."
build_runner_labels
resolve_registration_token
# Resolve ~ to actual home directory for local execution
RUNNER_DATA_PATH="${RUNNER_DATA_PATH/#\~/$HOME}"
export RUNNER_DATA_PATH
@@ -214,7 +355,6 @@ add_native_runner() {
# Download act_runner binary if not present
if [[ ! -x "${RUNNER_DATA_PATH}/act_runner" ]]; then
# Detect architecture — Apple Silicon (arm64) vs Intel (x86_64)
local arch
arch=$(uname -m)
case "$arch" in
@@ -230,26 +370,26 @@ add_native_runner() {
log_success "act_runner binary downloaded"
fi
# Register the runner with Gitea (generates .runner file in data dir)
# --no-interactive skips prompts, --config generates default config if missing
# Register the runner with Gitea
if [[ ! -f "${RUNNER_DATA_PATH}/.runner" ]]; then
log_info "Registering runner with Gitea..."
"${RUNNER_DATA_PATH}/act_runner" register \
--no-interactive \
--instance "${GITEA_INTERNAL_URL}" \
--token "${GITEA_RUNNER_REGISTRATION_TOKEN:-}" \
--token "${RUNNER_REG_TOKEN}" \
--name "${RUNNER_NAME}" \
--labels "${RUNNER_LABELS}" \
--labels "${RUNNER_LABELS_CSV}" \
--config "${RUNNER_DATA_PATH}/config.yaml"
log_success "Runner registered"
fi
# Render runner config (overwrites any default generated by register)
# Render runner config
local tmpfile
tmpfile=$(mktemp)
export RUNNER_NAME RUNNER_LABELS RUNNER_DATA_PATH
# shellcheck disable=SC2090 # intentional — RUNNER_LABELS_YAML rendered via envsubst
export RUNNER_NAME RUNNER_DATA_PATH RUNNER_LABELS_YAML RUNNER_CAPACITY
render_template "${SCRIPT_DIR}/templates/runner-config.yaml.tpl" "$tmpfile" \
"\${RUNNER_NAME} \${RUNNER_LABELS}"
"\${RUNNER_NAME} \${RUNNER_LABELS_YAML} \${RUNNER_CAPACITY}"
cp "$tmpfile" "${RUNNER_DATA_PATH}/config.yaml"
rm -f "$tmpfile"
@@ -270,9 +410,8 @@ add_native_runner() {
# remove_docker_runner — Stop + remove Docker runner container
# ---------------------------------------------------------------------------
remove_docker_runner() {
log_info "Removing Docker runner '${RUNNER_NAME}' from ${RUNNER_SSH_HOST}..."
log_info "Removing Docker runner '${RUNNER_NAME}' from ${RUNNER_HOST} (${RUNNER_SSH_HOST})..."
# Check if docker-compose file exists
if runner_ssh "test -f '${RUNNER_DATA_PATH}/docker-compose.yml'" 2>/dev/null; then
runner_ssh "cd '${RUNNER_DATA_PATH}' && docker compose down 2>/dev/null || docker-compose down" || true
log_success "Docker runner '${RUNNER_NAME}' stopped"
@@ -285,30 +424,25 @@ remove_docker_runner() {
# remove_native_runner — Unload launchd service + remove binary + plist
# ---------------------------------------------------------------------------
remove_native_runner() {
# Native runners use launchctl + macOS-specific paths — must be macOS
require_local_os "Darwin" "Native runner removal requires macOS (uses launchctl)"
log_info "Removing native runner '${RUNNER_NAME}' from local machine..."
# Resolve ~ to actual home directory
RUNNER_DATA_PATH="${RUNNER_DATA_PATH/#\~/$HOME}"
local plist_name="com.gitea.runner.${RUNNER_NAME}.plist"
local plist_path="$HOME/Library/LaunchAgents/${plist_name}"
# Unload launchd service if loaded
if launchctl list 2>/dev/null | grep -q "com.gitea.runner.${RUNNER_NAME}"; then
launchctl unload "$plist_path" 2>/dev/null || true
log_success "Launchd service unloaded"
fi
# Remove plist file
if [[ -f "$plist_path" ]]; then
rm -f "$plist_path"
log_success "Plist removed: $plist_path"
fi
# Remove runner data directory (binary, config, registration)
if [[ -d "${RUNNER_DATA_PATH}" ]]; then
printf 'Remove runner data at %s? [y/N] ' "${RUNNER_DATA_PATH}"
read -r confirm
@@ -323,41 +457,35 @@ remove_native_runner() {
# ---------------------------------------------------------------------------
# list_runners — Print table of all runners with their Gitea status
# Queries the Gitea admin API for registered runners and cross-references
# with runners.conf to show which are online/offline/unregistered.
# ---------------------------------------------------------------------------
list_runners() {
log_info "Listing runners..."
# Fetch registered runners from Gitea admin API
if [[ ! -f "$RUNNERS_CONF" ]]; then
log_error "runners.conf not found at $RUNNERS_CONF"
return 1
fi
local api_runners
api_runners=$(gitea_api GET "/admin/runners" 2>/dev/null || echo "[]")
# Print header
printf '%-20s %-16s %-10s %-8s %-10s\n' "NAME" "HOST" "LABELS" "TYPE" "STATUS"
printf '%-20s %-16s %-10s %-8s %-10s\n' "----" "----" "------" "----" "------"
printf '%-20s %-10s %-10s %-8s %-6s %-10s\n' "NAME" "HOST" "LABELS" "TYPE" "CAP" "STATUS"
printf '%-20s %-10s %-10s %-8s %-6s %-10s\n' "----" "----" "------" "----" "---" "------"
# For each entry in runners.conf, look up status in API response
while IFS='|' read -r name host user port path labels type; do
# Skip comments and blank lines
[[ "$name" =~ ^[[:space:]]*# ]] && continue
[[ -z "$name" ]] && continue
local name host labels runner_type capacity status
while IFS= read -r name; do
host=$(ini_get "$RUNNERS_CONF" "$name" "host" "")
labels=$(ini_get "$RUNNERS_CONF" "$name" "labels" "")
runner_type=$(ini_get "$RUNNERS_CONF" "$name" "type" "")
capacity=$(ini_get "$RUNNERS_CONF" "$name" "capacity" "1")
name=$(echo "$name" | xargs)
host=$(echo "$host" | xargs)
labels=$(echo "$labels" | xargs)
type=$(echo "$type" | xargs)
# Search for this runner in the API response by name
local status
status=$(printf '%s' "$api_runners" | jq -r --arg n "$name" '.[] | select(.name == $n) | .status' 2>/dev/null || true)
if [[ -z "$status" ]]; then
status="not-found"
fi
printf '%-20s %-16s %-10s %-8s %-10s\n' "$name" "$host" "$labels" "$type" "$status"
done < "$RUNNERS_CONF"
printf '%-20s %-10s %-10s %-8s %-6s %-10s\n' "$name" "$host" "$labels" "$runner_type" "$capacity" "$status"
done < <(ini_list_sections "$RUNNERS_CONF")
}
# ---------------------------------------------------------------------------
@@ -388,7 +516,6 @@ case "$COMMAND" in
log_error "add requires --name <runner_name>"
usage
fi
require_vars GITEA_RUNNER_REGISTRATION_TOKEN
parse_runner_entry "$RUNNER_ARG_NAME"
case "$RUNNER_TYPE" in
docker) add_docker_runner ;;

View File

@@ -36,13 +36,9 @@ API_RUNNERS=$(gitea_api GET "/admin/runners" 2>/dev/null || echo "[]")
# Check each runner: exists in API response AND status is online/idle/active
# A runner that registered but is not running will show as "offline".
# ---------------------------------------------------------------------------
while IFS='|' read -r name rest; do
# Skip comments and blank lines
[[ "$name" =~ ^[[:space:]]*# ]] && continue
while IFS= read -r name; do
[[ -z "$name" ]] && continue
name=$(echo "$name" | xargs)
# Look up runner by name in the API response
local_status=$(printf '%s' "$API_RUNNERS" | jq -r --arg n "$name" '.[] | select(.name == $n) | .status' 2>/dev/null || true)
@@ -56,12 +52,12 @@ while IFS='|' read -r name rest; do
log_success "Runner '${name}' is ${local_status}"
PASS=$((PASS + 1))
fi
done < "$RUNNERS_CONF"
done < <(ini_list_sections "$RUNNERS_CONF")
# ---------------------------------------------------------------------------
# Check: runner count matches runners.conf
# ---------------------------------------------------------------------------
EXPECTED_COUNT=$(grep -Evc '^[[:space:]]*($|#)' "$RUNNERS_CONF")
EXPECTED_COUNT=$(ini_list_sections "$RUNNERS_CONF" | wc -l | xargs)
ACTUAL_COUNT=$(printf '%s' "$API_RUNNERS" | jq 'length' 2>/dev/null || echo 0)
if [[ "$ACTUAL_COUNT" -ge "$EXPECTED_COUNT" ]]; then

View File

@@ -29,8 +29,8 @@ if [[ ! -f "$RUNNERS_CONF" ]]; then
exit 1
fi
# Count non-comment, non-blank lines to verify there are runners to deploy
RUNNER_COUNT=$(grep -Evc '^[[:space:]]*($|#)' "$RUNNERS_CONF")
# Count INI sections to verify there are runners to deploy
RUNNER_COUNT=$(ini_list_sections "$RUNNERS_CONF" | wc -l | xargs)
if [[ "$RUNNER_COUNT" -eq 0 ]]; then
log_error "No runners defined in runners.conf"
exit 1
@@ -63,8 +63,7 @@ fi
# ---------------------------------------------------------------------------
# Step 2: Deploy each runner via manage_runner.sh
# Iterates over every non-comment line in runners.conf, extracts the name
# (first pipe-delimited field), and invokes manage_runner.sh add.
# Iterates over every INI section in runners.conf (each section = one runner).
# manage_runner.sh handles its own idempotency (skips already-running runners).
# ---------------------------------------------------------------------------
log_step 2 "Deploying runners..."
@@ -72,12 +71,8 @@ log_step 2 "Deploying runners..."
DEPLOYED=0
FAILED=0
while IFS='|' read -r name rest; do
# Skip comments and blank lines
[[ "$name" =~ ^[[:space:]]*# ]] && continue
while IFS= read -r name; do
[[ -z "$name" ]] && continue
name=$(echo "$name" | xargs)
log_info "Processing runner: ${name}"
if "${SCRIPT_DIR}/manage_runner.sh" add --name "$name"; then
@@ -86,7 +81,7 @@ while IFS='|' read -r name rest; do
log_error "Failed to deploy runner: ${name}"
FAILED=$((FAILED + 1))
fi
done < "$RUNNERS_CONF"
done < <(ini_list_sections "$RUNNERS_CONF")
# ---------------------------------------------------------------------------
# Summary

View File

@@ -31,15 +31,11 @@ fi
printf 'This will stop and remove all runners. Continue? [y/N] '
read -r confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
while IFS='|' read -r name rest; do
# Skip comments and blank lines
[[ "$name" =~ ^[[:space:]]*# ]] && continue
while IFS= read -r name; do
[[ -z "$name" ]] && continue
name=$(echo "$name" | xargs)
log_info "Removing runner: ${name}"
"${SCRIPT_DIR}/manage_runner.sh" remove --name "$name" || true
done < "$RUNNERS_CONF"
done < <(ini_list_sections "$RUNNERS_CONF")
log_success "All runners removed"
else
log_info "Skipped runner removal"

View File

@@ -99,6 +99,122 @@ if [[ ! -f "${SCRIPT_DIR}/runners.conf" ]]; then
log_error " → runners.conf not found. Copy runners.conf.example to runners.conf."
fi
# ---------------------------------------------------------------------------
# Check 5b: runners.conf INI format validation
# Validates required fields, enum values, and format per runner section.
# ---------------------------------------------------------------------------
# shellcheck disable=SC2329
check_runners_ini_format() {
local conf="${SCRIPT_DIR}/runners.conf"
[[ -f "$conf" ]] || return 1
local errors=0
local section
while IFS= read -r section; do
[[ -z "$section" ]] && continue
# Required fields
local host type data_path labels capacity repos
host=$(ini_get "$conf" "$section" "host" "")
type=$(ini_get "$conf" "$section" "type" "")
data_path=$(ini_get "$conf" "$section" "data_path" "")
labels=$(ini_get "$conf" "$section" "labels" "")
capacity=$(ini_get "$conf" "$section" "capacity" "")
repos=$(ini_get "$conf" "$section" "repos" "all")
# host: must be unraid, fedora, local, or custom
case "$host" in
unraid|fedora|local|custom) ;;
"")
log_error " → [$section] host is empty (must be unraid, fedora, local, or custom)"
errors=$((errors + 1))
;;
*)
log_error " → [$section] host='$host' (must be unraid, fedora, local, or custom)"
errors=$((errors + 1))
;;
esac
# type: must be docker or native
case "$type" in
docker|native) ;;
"")
log_error " → [$section] type is empty (must be docker or native)"
errors=$((errors + 1))
;;
*)
log_error " → [$section] type='$type' (must be docker or native)"
errors=$((errors + 1))
;;
esac
# data_path: must start with / or ~/
# shellcheck disable=SC2088 # tilde intentionally stored as literal string
if [[ -z "$data_path" ]]; then
log_error " → [$section] data_path is empty"
errors=$((errors + 1))
elif [[ "$data_path" != /* ]] && [[ "$data_path" != "~/"* ]] && [[ "$data_path" != "~" ]]; then
log_error " → [$section] data_path='$data_path' (must start with / or ~/)"
errors=$((errors + 1))
fi
# labels: non-empty
if [[ -z "$labels" ]]; then
log_error " → [$section] labels is empty"
errors=$((errors + 1))
fi
# capacity: positive integer >= 1
if [[ -n "$capacity" ]] && ! [[ "$capacity" =~ ^[1-9][0-9]*$ ]]; then
log_error " → [$section] capacity='$capacity' (must be positive integer >= 1)"
errors=$((errors + 1))
fi
# repos: must be "all" or a known REPO_*_NAME
if [[ "$repos" != "all" ]] && [[ -n "$repos" ]]; then
local repos_valid=false
for var in REPO_1_NAME REPO_2_NAME REPO_3_NAME; do
if [[ "$repos" == "${!var:-}" ]]; then repos_valid=true; break; fi
done
if ! $repos_valid; then
log_error " → [$section] repos='$repos' (must be 'all' or a known repo name)"
errors=$((errors + 1))
fi
fi
# docker runner: default_image should be set
if [[ "$type" == "docker" ]]; then
local default_image
default_image=$(ini_get "$conf" "$section" "default_image" "")
if [[ -z "$default_image" ]] && [[ -z "${RUNNER_DEFAULT_IMAGE:-}" ]]; then
log_error " → [$section] default_image is empty and RUNNER_DEFAULT_IMAGE not set in .env"
errors=$((errors + 1))
fi
fi
# custom host: extra SSH keys required
if [[ "$host" == "custom" ]]; then
local ssh_host ssh_user
ssh_host=$(ini_get "$conf" "$section" "ssh_host" "")
ssh_user=$(ini_get "$conf" "$section" "ssh_user" "")
if [[ -z "$ssh_host" ]]; then
log_error " → [$section] ssh_host is empty (required for host=custom)"
errors=$((errors + 1))
fi
if [[ -z "$ssh_user" ]]; then
log_error " → [$section] ssh_user is empty (required for host=custom)"
errors=$((errors + 1))
fi
fi
done < <(ini_list_sections "$conf")
[[ $errors -eq 0 ]]
}
if [[ -f "${SCRIPT_DIR}/runners.conf" ]]; then
check 5b "runners.conf INI format validation" check_runners_ini_format
fi
# ---------------------------------------------------------------------------
# Load env for remaining checks (may fail if .env missing)
# ---------------------------------------------------------------------------
@@ -118,6 +234,7 @@ REQUIRED_VARS=(
GITEA_BACKUP_INTERNAL_URL BACKUP_STORAGE_PATH
GITHUB_USERNAME GITHUB_TOKEN
REPO_1_NAME REPO_2_NAME REPO_3_NAME
RUNNER_DEFAULT_IMAGE RUNNER_DEFAULT_DATA_PATH LOCAL_RUNNER_DATA_PATH
GITHUB_MIRROR_TOKEN
NGINX_CONTAINER_NAME NGINX_CONF_PATH SSL_MODE
)
@@ -262,11 +379,31 @@ if [[ "$SKIP_PORT_CHECKS" == "true" ]]; then
else
check_port_unraid() {
local port="${UNRAID_GITEA_PORT:-3000}"
! ssh_exec UNRAID "ss -tlnp | grep -q ':${port} '" 2>/dev/null
local ss_output
local grep_rc
# Fail closed on SSH/remote command errors.
ss_output=$(ssh_exec UNRAID "ss -tlnp" 2>/dev/null) || return 1
# grep exit codes:
# 0 => match found (port in use) => FAIL check
# 1 => no match (port free) => PASS check
# >1 => grep error => FAIL check
if printf '%s\n' "$ss_output" | grep -q ":${port} "; then
grep_rc=0
else
grep_rc=$?
fi
case "$grep_rc" in
0) return 1 ;;
1) return 0 ;;
*) return 1 ;;
esac
}
check 13 "Port ${UNRAID_GITEA_PORT:-3000} free on Unraid" check_port_unraid
if ! check_port_unraid 2>/dev/null; then
log_error " → Port ${UNRAID_GITEA_PORT:-3000} already in use on Unraid."
log_error " → Port check failed or port ${UNRAID_GITEA_PORT:-3000} is already in use on Unraid."
fi
# ---------------------------------------------------------------------------
@@ -274,11 +411,31 @@ else
# ---------------------------------------------------------------------------
check_port_fedora() {
local port="${FEDORA_GITEA_PORT:-3000}"
! ssh_exec FEDORA "ss -tlnp | grep -q ':${port} '" 2>/dev/null
local ss_output
local grep_rc
# Fail closed on SSH/remote command errors.
ss_output=$(ssh_exec FEDORA "ss -tlnp" 2>/dev/null) || return 1
# grep exit codes:
# 0 => match found (port in use) => FAIL check
# 1 => no match (port free) => PASS check
# >1 => grep error => FAIL check
if printf '%s\n' "$ss_output" | grep -q ":${port} "; then
grep_rc=0
else
grep_rc=$?
fi
case "$grep_rc" in
0) return 1 ;;
1) return 0 ;;
*) return 1 ;;
esac
}
check 14 "Port ${FEDORA_GITEA_PORT:-3000} free on Fedora" check_port_fedora
if ! check_port_fedora 2>/dev/null; then
log_error " → Port ${FEDORA_GITEA_PORT:-3000} already in use on Fedora."
log_error " → Port check failed or port ${FEDORA_GITEA_PORT:-3000} is already in use on Fedora."
fi
fi
@@ -286,9 +443,19 @@ fi
# Check 15: DNS resolves
# ---------------------------------------------------------------------------
check_dns() {
# Fail closed when required values are missing.
[[ -n "${GITEA_DOMAIN:-}" ]] || return 1
[[ -n "${UNRAID_IP:-}" ]] || return 1
local resolved
resolved=$(dig +short "${GITEA_DOMAIN:-}" 2>/dev/null | head -1)
[[ "$resolved" == "${UNRAID_IP:-}" ]]
resolved=$(dig +short "${GITEA_DOMAIN}" A 2>/dev/null | sed '/^[[:space:]]*$/d') || return 1
[[ -n "$resolved" ]] || return 1
# Pass only if one of the domain's A records exactly matches UNRAID_IP.
if printf '%s\n' "$resolved" | grep -Fxq "${UNRAID_IP}"; then
return 0
fi
return 1
}
check 15 "DNS: ${GITEA_DOMAIN:-<not set>} resolves to ${UNRAID_IP:-<not set>}" check_dns
if ! check_dns 2>/dev/null; then
@@ -312,13 +479,21 @@ fi
# shellcheck disable=SC2329
check_github_repos() {
local all_ok=0
if [[ -z "${GITHUB_USERNAME:-}" ]]; then
log_error " → GITHUB_USERNAME is empty"
return 1
fi
for var in REPO_1_NAME REPO_2_NAME REPO_3_NAME; do
local repo="${!var:-}"
if [[ -z "$repo" ]]; then
log_error "${var} is empty"
all_ok=1
continue
fi
if ! curl -sf -H "Authorization: token ${GITHUB_TOKEN:-}" "https://api.github.com/repos/${GITHUB_USERNAME:-}/${repo}" -o /dev/null 2>/dev/null; then
log_error " → GitHub repo ${repo} not found under ${GITHUB_USERNAME:-}"
if ! curl -sf -H "Authorization: token ${GITHUB_TOKEN:-}" "https://api.github.com/repos/${GITHUB_USERNAME}/${repo}" -o /dev/null 2>/dev/null; then
log_error " → GitHub repo ${repo} not found under ${GITHUB_USERNAME}"
all_ok=1
fi
done

View File

@@ -1,20 +1,82 @@
# =============================================================================
# runners.conf — Gitea Actions Runner Definitions
# Copy to runners.conf and edit. One runner per line.
# runners.conf — Gitea Actions Runner Definitions (INI format)
# Copy to runners.conf and edit, or generate interactively with:
# ./setup/configure_runners.sh
# Use manage_runner.sh to add/remove runners dynamically.
# =============================================================================
#
# FORMAT: name|ssh_host|ssh_user|ssh_port|data_path|labels|type
# Each [section] defines one runner. Section name = runner display name in Gitea.
#
# name — Display name in Gitea admin panel
# ssh_host — IP address or hostname (for local machine, use "local")
# ssh_user — SSH username (ignored if ssh_host is "local")
# ssh_port — SSH port (ignored if ssh_host is "local")
# data_path — Absolute path for runner binary + data on that machine
# labels — Comma-separated runner labels (used in workflow runs-on)
# type — "docker" (Linux, runs jobs in containers) or "native" (macOS, runs jobs on host)
# FIELD REFERENCE:
#
# EXAMPLES:
unraid-runner|192.168.1.10|root|22|/mnt/nvme/gitea-runner|linux|docker
fedora-runner|192.168.1.20|user|22|/mnt/nvme/gitea-runner|linux|docker
macbook-runner|local|_|_|~/gitea-runner|macos|native
# host — enum: unraid, fedora, local, custom
# Known hosts resolve SSH from .env (IP, user, port, key).
# "local" runs on this machine. "custom" requires extra keys
# in the section: ssh_host, ssh_user, ssh_port, ssh_key.
#
# type — enum: docker, native
# "docker" = Linux container on remote host via Docker Compose.
# "native" = macOS binary + launchd on local machine.
#
# data_path — Absolute path (/ or ~/) where act_runner stores its binary,
# config, and job cache. NOT Gitea's data path.
# Default: RUNNER_DEFAULT_DATA_PATH (docker), LOCAL_RUNNER_DATA_PATH (native)
#
# labels — Workflow runs-on value (e.g. linux, macos, ubuntu-latest).
# Combined with default_image at deploy time to produce
# act_runner label spec: "linux:docker://image" or "macos:host".
#
# default_image — Docker image for job execution (docker runners only).
# Default: RUNNER_DEFAULT_IMAGE from .env.
# If LOCAL_REGISTRY is set, resolved as LOCAL_REGISTRY/image.
# Leave empty for native runners.
#
# repos — Token hint (not an act_runner setting): controls which
# registration token manage_runner.sh fetches at deploy time.
# "all" = instance-level token (runner available to all repos).
# Repo name = fetch repo-level token from Gitea API.
#
# capacity — Positive integer (>= 1). Max concurrent jobs.
# Default: RUNNER_DEFAULT_CAPACITY from .env.
# Constrained by host CPU/RAM. 0 and negative are invalid.
#
# cpu — Docker CPU limit (e.g. 2.0, 0.5). Ignored for native.
# Empty = no limit.
#
# memory — Docker memory limit (e.g. 2g, 512m). Ignored for native.
# Empty = no limit.
#
# STARTER ENTRIES (uncomment and edit):
#[unraid-runner]
#host = unraid
#type = docker
#data_path = /mnt/nvme/gitea-runner
#labels = linux
#default_image = catthehacker/ubuntu:act-latest
#repos = all
#capacity = 1
#cpu =
#memory =
#[fedora-runner]
#host = fedora
#type = docker
#data_path = /mnt/nvme/gitea-runner
#labels = linux
#default_image = catthehacker/ubuntu:act-latest
#repos = all
#capacity = 1
#cpu =
#memory =
#[macbook-runner]
#host = local
#type = native
#data_path = ~/gitea-runner
#labels = macos
#default_image =
#repos = all
#capacity = 1
#cpu =
#memory =

View File

@@ -64,7 +64,7 @@ done < "$ENV_FILE"
# ---------------------------------------------------------------------------
# Prompt function
# ---------------------------------------------------------------------------
TOTAL_PROMPTS=50
TOTAL_PROMPTS=59
CURRENT_PROMPT=0
LAST_SECTION=""
@@ -122,6 +122,11 @@ prompt_var() {
continue
fi
# Optional fields accept any value including empty
if [[ "$validation" == "optional" ]]; then
break
fi
case "$validation" in
ip)
if validate_ip "$value"; then break; fi
@@ -151,6 +156,10 @@ prompt_var() {
if validate_integer "$value"; then break; fi
printf '%b Invalid: must be a number%b\n' "$C_RED" "$C_RESET"
;;
positive_integer)
if validate_positive_integer "$value"; then break; fi
printf '%b Invalid: must be a positive integer (>= 1)%b\n' "$C_RED" "$C_RESET"
;;
password)
if validate_password "$value"; then break; fi
printf '%b Invalid: password must be at least 8 characters%b\n' "$C_RED" "$C_RESET"
@@ -218,6 +227,7 @@ prompt_var "UNRAID_SSH_PORT" "SSH port"
prompt_var "UNRAID_GITEA_PORT" "Port Gitea web UI will listen on" port "3000" "UNRAID SERVER"
prompt_var "UNRAID_GITEA_SSH_PORT" "Port for git-over-SSH" port "2222" "UNRAID SERVER"
prompt_var "UNRAID_GITEA_DATA_PATH" "Absolute path on NVMe for Gitea data" path "" "UNRAID SERVER"
prompt_var "UNRAID_SSH_KEY" "Path to SSH private key (empty = ssh-agent)" optional "" "UNRAID SERVER"
# --- FEDORA SERVER ---
prompt_var "FEDORA_IP" "Static IP of Fedora server" ip "" "FEDORA SERVER"
@@ -226,6 +236,7 @@ prompt_var "FEDORA_SSH_PORT" "SSH port"
prompt_var "FEDORA_GITEA_PORT" "Port Gitea web UI will listen on" port "3000" "FEDORA SERVER"
prompt_var "FEDORA_GITEA_SSH_PORT" "Port for git-over-SSH" port "2222" "FEDORA SERVER"
prompt_var "FEDORA_GITEA_DATA_PATH" "Absolute path on NVMe for Gitea data" path "" "FEDORA SERVER"
prompt_var "FEDORA_SSH_KEY" "Path to SSH private key (empty = ssh-agent)" optional "" "FEDORA SERVER"
# --- GITEA SHARED CREDENTIALS ---
prompt_var "GITEA_ADMIN_USER" "Admin username (same on both instances)" nonempty "" "GITEA SHARED CREDENTIALS"
@@ -258,6 +269,14 @@ prompt_var "MIGRATE_LABELS" "Migrate GitHub labels" bool
prompt_var "MIGRATE_MILESTONES" "Migrate GitHub milestones" bool "false" "REPOSITORIES"
prompt_var "MIGRATE_WIKI" "Migrate GitHub wiki" bool "false" "REPOSITORIES"
# --- RUNNERS ---
prompt_var "RUNNER_DEFAULT_IMAGE" "Default container image for docker runners" nonempty "catthehacker/ubuntu:act-latest" "RUNNERS"
prompt_var "RUNNER_DEFAULT_CAPACITY" "Default max concurrent jobs per runner" positive_integer "1" "RUNNERS"
prompt_var "RUNNER_DEFAULT_DATA_PATH" "Default data path for remote (docker) runners" nonempty "/mnt/nvme/gitea-runner" "RUNNERS"
# shellcheck disable=SC2088 # tilde intentionally stored as literal (expanded at runtime)
prompt_var "LOCAL_RUNNER_DATA_PATH" "Data path for native macOS runner" nonempty "~/gitea-runner" "RUNNERS"
prompt_var "LOCAL_REGISTRY" "Local registry prefix (empty = Docker Hub)" optional "" "RUNNERS"
# --- GITHUB MIRROR ---
prompt_var "GITHUB_MIRROR_TOKEN" "GitHub PAT with repo write scope" nonempty "" "GITHUB MIRROR"
prompt_var "GITHUB_MIRROR_INTERVAL" "How often Gitea pushes to GitHub" nonempty "8h" "GITHUB MIRROR"

View File

@@ -4,6 +4,8 @@ set -euo pipefail
# =============================================================================
# setup/configure_runners.sh — Interactive runners.conf configuration wizard
# Prompts for each runner's fields with validation and progress tracking.
# Writes INI-style runners.conf (see runners.conf.example for format).
# Defaults are pulled from .env — nothing is hardcoded.
# =============================================================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -14,6 +16,9 @@ RUNNERS_CONF="${PROJECT_ROOT}/runners.conf"
# shellcheck disable=SC1091
source "${PROJECT_ROOT}/lib/common.sh"
# Load .env for defaults (RUNNER_DEFAULT_IMAGE, RUNNER_DEFAULT_CAPACITY, etc.)
load_env
# Colors — only emit ANSI escapes when stdout is a terminal (not piped/redirected)
if [[ -t 1 ]]; then
C_RESET='\033[0m'; C_BOLD='\033[1m'; C_GREEN='\033[0;32m'
@@ -24,62 +29,56 @@ fi
# ---------------------------------------------------------------------------
# Load existing runner entries as defaults (idempotent re-runs).
# If runners.conf already exists, parse each non-comment line into parallel
# arrays so we can pre-fill prompts with previous values.
# If runners.conf already exists and is INI format, we read existing values
# to pre-fill prompts when re-running the wizard.
# ---------------------------------------------------------------------------
EXISTING_NAMES=()
EXISTING_HOSTS=()
EXISTING_USERS=()
EXISTING_PORTS=()
EXISTING_PATHS=()
EXISTING_LABELS=()
EXISTING_TYPES=()
EXISTING_SECTIONS=()
if [[ -f "$RUNNERS_CONF" ]]; then
while IFS='|' read -r name host user port path labels type; do
[[ "$name" =~ ^[[:space:]]*# ]] && continue # skip comments
[[ -z "$name" ]] && continue # skip blank lines
# Trim whitespace from each field (xargs strips leading/trailing spaces)
# shellcheck disable=SC2005
EXISTING_NAMES+=("$(echo "$name" | xargs)")
# shellcheck disable=SC2005
EXISTING_HOSTS+=("$(echo "$host" | xargs)")
# shellcheck disable=SC2005
EXISTING_USERS+=("$(echo "$user" | xargs)")
# shellcheck disable=SC2005
EXISTING_PORTS+=("$(echo "$port" | xargs)")
# shellcheck disable=SC2005
EXISTING_PATHS+=("$(echo "$path" | xargs)")
# shellcheck disable=SC2005
EXISTING_LABELS+=("$(echo "$labels" | xargs)")
# shellcheck disable=SC2005
EXISTING_TYPES+=("$(echo "$type" | xargs)")
done < "$RUNNERS_CONF"
while IFS= read -r sec; do
[[ -z "$sec" ]] && continue
EXISTING_SECTIONS+=("$sec")
done < <(ini_list_sections "$RUNNERS_CONF")
fi
EXISTING_COUNT=${#EXISTING_NAMES[@]}
EXISTING_COUNT=${#EXISTING_SECTIONS[@]}
# ---------------------------------------------------------------------------
# Validation helpers
# ---------------------------------------------------------------------------
# Must start with alphanumeric; rest can include hyphens and underscores.
# Matches the naming convention Gitea uses in its admin panel.
validate_runner_name() {
[[ "$1" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]]
}
# Only two runner types exist: docker (Linux containers) and native (macOS binary)
validate_runner_type() {
[[ "$1" == "docker" || "$1" == "native" ]]
}
# Absolute paths (/) for remote hosts, or ~/ for native macOS runners.
# Tilde is stored literally; manage_runner.sh expands it at runtime.
validate_runner_host() {
[[ "$1" == "unraid" || "$1" == "fedora" || "$1" == "local" || "$1" == "custom" ]]
}
validate_runner_path() {
# shellcheck disable=SC2088 # tilde intentionally stored as literal string
[[ "$1" == /* || "$1" == "~/"* || "$1" == "~" ]]
}
validate_runner_repos() {
if [[ "$1" == "all" ]]; then return 0; fi
# Check against known REPO_*_NAME values
[[ "$1" == "${REPO_1_NAME:-}" ]] || [[ "$1" == "${REPO_2_NAME:-}" ]] || [[ "$1" == "${REPO_3_NAME:-}" ]]
}
validate_capacity() {
[[ "$1" =~ ^[1-9][0-9]*$ ]]
}
validate_docker_cpu() {
[[ -z "$1" ]] || [[ "$1" =~ ^[0-9]+(\.[0-9]+)?$ ]]
}
validate_docker_memory() {
[[ -z "$1" ]] || [[ "$1" =~ ^[0-9]+[kmgKMG]?$ ]]
}
# ---------------------------------------------------------------------------
# Prompt function — matches configure_env.sh UX (progress counter, default
# values in yellow brackets, validation loop with red error hints).
@@ -91,16 +90,15 @@ TOTAL_PROMPTS=0
prompt_field() {
local field_name="$1" # e.g. "name", "ssh_host"
local description="$2" # human-readable hint shown in parentheses
local validation="$3" # validator key: runner_type, runner_name, ip, port, etc.
local validation="$3" # validator key
local default="${4:-}" # pre-filled value shown in [brackets]; Enter accepts it
local allow_empty="${5:-false}" # true if empty is a valid answer
CURRENT_PROMPT=$((CURRENT_PROMPT + 1))
# Progress indicator in dim grey: [3/21]
local progress
progress=$(printf '[%d/%d]' "$CURRENT_PROMPT" "$TOTAL_PROMPTS")
# Format: [3/21] field_name (description) [default]: _
local prompt_text
if [[ -n "$default" ]]; then
prompt_text=$(printf '%b%s%b %s (%s) %b[%s]%b: ' "$C_DIM" "$progress" "$C_RESET" "$field_name" "$description" "$C_YELLOW" "$default" "$C_RESET")
@@ -108,32 +106,57 @@ prompt_field() {
prompt_text=$(printf '%b%s%b %s (%s): ' "$C_DIM" "$progress" "$C_RESET" "$field_name" "$description")
fi
# Validation loop — re-prompts on invalid input until the value passes
local value
while true; do
printf '%b' "$prompt_text"
read -r value
# Empty input → accept the default (if one exists)
if [[ -z "$value" ]]; then
value="$default"
fi
# Reject empty when no default was available
# Allow empty when explicitly permitted (optional fields)
if [[ -z "$value" ]] && [[ "$allow_empty" == "true" ]]; then
break
fi
if [[ -z "$value" ]]; then
printf '%b Invalid: value cannot be empty%b\n' "$C_RED" "$C_RESET"
continue
fi
# Dispatch to the appropriate validator
case "$validation" in
runner_name)
if validate_runner_name "$value"; then break; fi
printf '%b Invalid: alphanumeric, hyphens, and underscores only%b\n' "$C_RED" "$C_RESET"
;;
runner_type)
if validate_runner_type "$value"; then break; fi
printf '%b Invalid: must be "docker" or "native"%b\n' "$C_RED" "$C_RESET"
;;
runner_name)
if validate_runner_name "$value"; then break; fi
printf '%b Invalid: alphanumeric, hyphens, and underscores only%b\n' "$C_RED" "$C_RESET"
runner_host)
if validate_runner_host "$value"; then break; fi
printf '%b Invalid: must be unraid, fedora, local, or custom%b\n' "$C_RED" "$C_RESET"
;;
runner_path)
if validate_runner_path "$value"; then break; fi
printf '%b Invalid path (must start with / or ~/)%b\n' "$C_RED" "$C_RESET"
;;
runner_repos)
if validate_runner_repos "$value"; then break; fi
printf '%b Invalid: must be "all" or a known repo name%b\n' "$C_RED" "$C_RESET"
;;
capacity)
if validate_capacity "$value"; then break; fi
printf '%b Invalid: must be a positive integer (>= 1)%b\n' "$C_RED" "$C_RESET"
;;
docker_cpu)
if validate_docker_cpu "$value"; then break; fi
printf '%b Invalid: must be a positive number (e.g. 2.0, 0.5) or empty%b\n' "$C_RED" "$C_RESET"
;;
docker_memory)
if validate_docker_memory "$value"; then break; fi
printf '%b Invalid: must be Docker memory format (e.g. 2g, 512m) or empty%b\n' "$C_RED" "$C_RESET"
;;
ip)
if validate_ip "$value"; then break; fi
@@ -143,9 +166,9 @@ prompt_field() {
if validate_port "$value"; then break; fi
printf '%b Invalid port (expected: 1-65535)%b\n' "$C_RED" "$C_RESET"
;;
runner_path)
if validate_runner_path "$value"; then break; fi
printf '%b Invalid path (must start with / or ~/)%b\n' "$C_RED" "$C_RESET"
optional_path)
if validate_optional_path "$value"; then break; fi
printf '%b Invalid path (must start with / or ~/ or be empty)%b\n' "$C_RED" "$C_RESET"
;;
nonempty|*)
if validate_nonempty "$value"; then break; fi
@@ -154,7 +177,6 @@ prompt_field() {
esac
done
# Return the validated value via global (bash has no return-by-value)
PROMPT_RESULT="$value"
}
@@ -166,6 +188,7 @@ printf '\n%b╔═════════════════════
printf '%b║ Gitea Migration — Runner Setup ║%b\n' "$C_BOLD" "$C_RESET"
printf '%b╚══════════════════════════════════════════════════════════╝%b\n\n' "$C_BOLD" "$C_RESET"
printf 'Press Enter to keep the current value shown in [brackets].\n'
printf 'Defaults are pulled from .env variables.\n'
# ---------------------------------------------------------------------------
# Ask how many runners to configure.
@@ -187,47 +210,28 @@ if ! [[ "$runner_count" =~ ^[0-9]+$ ]] || [[ "$runner_count" -lt 1 ]]; then
runner_count=$local_default
fi
# Each runner has 7 fields. Native runners auto-fill 3 (host/user/port) but
# we still count them toward the total so the progress bar stays consistent.
TOTAL_PROMPTS=$((runner_count * 7))
# Prompt count per runner:
# name(1) + host(1) + type(1) + data_path(1) + labels(1) + default_image(1) +
# repos(1) + capacity(1) + cpu(1) + memory(1) = 10
# Custom host adds: ssh_host(1) + ssh_user(1) + ssh_port(1) + ssh_key(1) = 4
# We estimate max 10 per runner for progress display
TOTAL_PROMPTS=$((runner_count * 10))
# ---------------------------------------------------------------------------
# Collect runner definitions
# ---------------------------------------------------------------------------
# Storage arrays for collected values
# Storage arrays for collected INI data
COLLECTED_NAMES=()
COLLECTED_HOSTS=()
COLLECTED_USERS=()
COLLECTED_PORTS=()
COLLECTED_PATHS=()
COLLECTED_LABELS=()
COLLECTED_TYPES=()
declare -A COLLECTED_DATA # COLLECTED_DATA["name:key"] = value
for ((i = 0; i < runner_count; i++)); do
runner_num=$((i + 1))
printf '\n%b── RUNNER %d OF %d ──────────────────────────────────────────%b\n' "$C_BOLD" "$runner_num" "$runner_count" "$C_RESET"
# Defaults from existing config (if available)
ex_type="${EXISTING_TYPES[$i]:-}"
ex_name="${EXISTING_NAMES[$i]:-}"
ex_host="${EXISTING_HOSTS[$i]:-}"
ex_user="${EXISTING_USERS[$i]:-}"
ex_port="${EXISTING_PORTS[$i]:-}"
ex_path="${EXISTING_PATHS[$i]:-}"
ex_labels="${EXISTING_LABELS[$i]:-}"
# Existing defaults from previous runners.conf (if available)
ex_name="${EXISTING_SECTIONS[$i]:-}"
# --- type (asked first — drives conditionals below) ---
# Like SSL_MODE in configure_env.sh, type determines which fields are prompted
# vs auto-filled. Docker runners need SSH creds; native runners run locally.
type_default="$ex_type"
if [[ -z "$type_default" ]]; then
# Smart default: first two runners are docker (Unraid/Fedora), third is native (MacBook)
if [[ $i -lt 2 ]]; then type_default="docker"; else type_default="native"; fi
fi
prompt_field "type" "docker (Linux) or native (macOS)" "runner_type" "$type_default"
r_type="$PROMPT_RESULT"
# --- name (shown in Gitea admin panel) ---
# --- name ---
name_default="$ex_name"
if [[ -z "$name_default" ]]; then
case $i in
@@ -237,120 +241,229 @@ for ((i = 0; i < runner_count; i++)); do
*) name_default="runner-${runner_num}" ;;
esac
fi
prompt_field "name" "display name in Gitea admin" "runner_name" "$name_default"
prompt_field "name" "runner display name in Gitea" "runner_name" "$name_default"
r_name="$PROMPT_RESULT"
COLLECTED_NAMES+=("$r_name")
# --- Conditional SSH fields ---
# Native runners execute on the local macOS machine — no SSH needed.
# Docker runners deploy to a remote Linux host via SSH.
if [[ "$r_type" == "native" ]]; then
r_host="local"
r_user="_"
r_port="_"
printf '%b → ssh_host=local, ssh_user=_, ssh_port=_ (native runner)%b\n' "$C_DIM" "$C_RESET"
# Increment counter for the 3 skipped prompts to keep progress accurate
CURRENT_PROMPT=$((CURRENT_PROMPT + 3))
else
# --- ssh_host (IP of remote Linux host) ---
prompt_field "ssh_host" "IP address of remote host" "ip" "${ex_host:-}"
r_host="$PROMPT_RESULT"
# --- ssh_user ---
user_default="$ex_user"
if [[ -z "$user_default" ]]; then user_default="root"; fi
prompt_field "ssh_user" "SSH username" "nonempty" "$user_default"
r_user="$PROMPT_RESULT"
# --- ssh_port ---
port_default="$ex_port"
if [[ -z "$port_default" ]]; then port_default="22"; fi
prompt_field "ssh_port" "SSH port" "port" "$port_default"
r_port="$PROMPT_RESULT"
# --- host ---
host_default=""
if [[ -n "$ex_name" ]]; then
host_default=$(ini_get "$RUNNERS_CONF" "$ex_name" "host" "")
fi
if [[ -z "$host_default" ]]; then
case $i in
0) host_default="unraid" ;;
1) host_default="fedora" ;;
2) host_default="local" ;;
*) host_default="unraid" ;;
esac
fi
prompt_field "host" "unraid, fedora, local, or custom" "runner_host" "$host_default"
r_host="$PROMPT_RESULT"
COLLECTED_DATA["${r_name}:host"]="$r_host"
# --- data_path (where runner binary + config + data live) ---
path_default="$ex_path"
# Show resolved SSH details for known hosts
case "$r_host" in
unraid)
printf '%b → SSH: %s@%s:%s%b\n' "$C_DIM" "${UNRAID_SSH_USER:-<not set>}" "${UNRAID_IP:-<not set>}" "${UNRAID_SSH_PORT:-22}" "$C_RESET"
if [[ -n "${UNRAID_SSH_KEY:-}" ]]; then
printf '%b → SSH key: %s%b\n' "$C_DIM" "${UNRAID_SSH_KEY}" "$C_RESET"
fi
;;
fedora)
printf '%b → SSH: %s@%s:%s%b\n' "$C_DIM" "${FEDORA_SSH_USER:-<not set>}" "${FEDORA_IP:-<not set>}" "${FEDORA_SSH_PORT:-22}" "$C_RESET"
if [[ -n "${FEDORA_SSH_KEY:-}" ]]; then
printf '%b → SSH key: %s%b\n' "$C_DIM" "${FEDORA_SSH_KEY}" "$C_RESET"
fi
;;
local)
printf '%b → Runs on this machine (no SSH)%b\n' "$C_DIM" "$C_RESET"
;;
custom)
# Prompt for custom SSH details
ex_ssh_host=""
ex_ssh_user=""
ex_ssh_port=""
ex_ssh_key=""
if [[ -n "$ex_name" ]]; then
ex_ssh_host=$(ini_get "$RUNNERS_CONF" "$ex_name" "ssh_host" "")
ex_ssh_user=$(ini_get "$RUNNERS_CONF" "$ex_name" "ssh_user" "")
ex_ssh_port=$(ini_get "$RUNNERS_CONF" "$ex_name" "ssh_port" "22")
ex_ssh_key=$(ini_get "$RUNNERS_CONF" "$ex_name" "ssh_key" "")
fi
prompt_field "ssh_host" "IP address of remote host" "ip" "$ex_ssh_host"
COLLECTED_DATA["${r_name}:ssh_host"]="$PROMPT_RESULT"
prompt_field "ssh_user" "SSH username" "nonempty" "${ex_ssh_user:-root}"
COLLECTED_DATA["${r_name}:ssh_user"]="$PROMPT_RESULT"
prompt_field "ssh_port" "SSH port" "port" "${ex_ssh_port:-22}"
COLLECTED_DATA["${r_name}:ssh_port"]="$PROMPT_RESULT"
prompt_field "ssh_key" "path to SSH key (empty = ssh-agent)" "optional_path" "$ex_ssh_key" "true"
COLLECTED_DATA["${r_name}:ssh_key"]="$PROMPT_RESULT"
# Adjust total prompts (added 4 custom fields, but we already allocated 10)
;;
esac
# --- type ---
type_default=""
if [[ -n "$ex_name" ]]; then
type_default=$(ini_get "$RUNNERS_CONF" "$ex_name" "type" "")
fi
if [[ -z "$type_default" ]]; then
if [[ "$r_host" == "local" ]]; then type_default="native"; else type_default="docker"; fi
fi
prompt_field "type" "docker (Linux) or native (macOS)" "runner_type" "$type_default"
r_type="$PROMPT_RESULT"
COLLECTED_DATA["${r_name}:type"]="$r_type"
# --- data_path ---
path_default=""
if [[ -n "$ex_name" ]]; then
path_default=$(ini_get "$RUNNERS_CONF" "$ex_name" "data_path" "")
fi
if [[ -z "$path_default" ]]; then
if [[ "$r_type" == "native" ]]; then
# shellcheck disable=SC2088 # literal tilde — expanded by manage_runner.sh
path_default="~/gitea-runner"
path_default="${LOCAL_RUNNER_DATA_PATH:-~/gitea-runner}"
else
path_default="/mnt/nvme/gitea-runner" # typical Unraid/Fedora NVMe mount
path_default="${RUNNER_DEFAULT_DATA_PATH:-/mnt/nvme/gitea-runner}"
fi
fi
prompt_field "data_path" "absolute path for runner data" "runner_path" "$path_default"
r_path="$PROMPT_RESULT"
COLLECTED_DATA["${r_name}:data_path"]="$PROMPT_RESULT"
# --- labels (matched by workflow `runs-on:` directives) ---
labels_default="$ex_labels"
# --- labels ---
labels_default=""
if [[ -n "$ex_name" ]]; then
labels_default=$(ini_get "$RUNNERS_CONF" "$ex_name" "labels" "")
fi
if [[ -z "$labels_default" ]]; then
if [[ "$r_type" == "native" ]]; then labels_default="macos"; else labels_default="linux"; fi
fi
prompt_field "labels" "workflow runs-on labels" "nonempty" "$labels_default"
r_labels="$PROMPT_RESULT"
prompt_field "labels" "workflow runs-on value" "nonempty" "$labels_default"
COLLECTED_DATA["${r_name}:labels"]="$PROMPT_RESULT"
# Append this runner's values to the collection arrays
COLLECTED_NAMES+=("$r_name")
COLLECTED_HOSTS+=("$r_host")
COLLECTED_USERS+=("$r_user")
COLLECTED_PORTS+=("$r_port")
COLLECTED_PATHS+=("$r_path")
COLLECTED_LABELS+=("$r_labels")
COLLECTED_TYPES+=("$r_type")
# --- default_image (skip for native) ---
if [[ "$r_type" == "docker" ]]; then
image_default=""
if [[ -n "$ex_name" ]]; then
image_default=$(ini_get "$RUNNERS_CONF" "$ex_name" "default_image" "")
fi
if [[ -z "$image_default" ]]; then
image_default="${RUNNER_DEFAULT_IMAGE:-catthehacker/ubuntu:act-latest}"
fi
prompt_field "default_image" "Docker image for job execution" "nonempty" "$image_default"
COLLECTED_DATA["${r_name}:default_image"]="$PROMPT_RESULT"
else
COLLECTED_DATA["${r_name}:default_image"]=""
CURRENT_PROMPT=$((CURRENT_PROMPT + 1)) # skip but count for progress
fi
# --- repos ---
repos_default=""
if [[ -n "$ex_name" ]]; then
repos_default=$(ini_get "$RUNNERS_CONF" "$ex_name" "repos" "all")
fi
if [[ -z "$repos_default" ]]; then repos_default="all"; fi
# Build hint with known repo names
repos_hint="token scope: all"
for var in REPO_1_NAME REPO_2_NAME REPO_3_NAME; do
if [[ -n "${!var:-}" ]]; then
repos_hint="${repos_hint}, ${!var}"
fi
done
prompt_field "repos" "$repos_hint" "runner_repos" "$repos_default"
COLLECTED_DATA["${r_name}:repos"]="$PROMPT_RESULT"
# --- capacity ---
cap_default=""
if [[ -n "$ex_name" ]]; then
cap_default=$(ini_get "$RUNNERS_CONF" "$ex_name" "capacity" "")
fi
if [[ -z "$cap_default" ]]; then
cap_default="${RUNNER_DEFAULT_CAPACITY:-1}"
fi
prompt_field "capacity" "max concurrent jobs (>= 1)" "capacity" "$cap_default"
COLLECTED_DATA["${r_name}:capacity"]="$PROMPT_RESULT"
# --- cpu (skip for native) ---
if [[ "$r_type" == "docker" ]]; then
cpu_default=""
if [[ -n "$ex_name" ]]; then
cpu_default=$(ini_get "$RUNNERS_CONF" "$ex_name" "cpu" "")
fi
prompt_field "cpu" "Docker CPU limit (e.g. 2.0, empty = no limit)" "docker_cpu" "$cpu_default" "true"
COLLECTED_DATA["${r_name}:cpu"]="$PROMPT_RESULT"
else
COLLECTED_DATA["${r_name}:cpu"]=""
CURRENT_PROMPT=$((CURRENT_PROMPT + 1))
fi
# --- memory (skip for native) ---
if [[ "$r_type" == "docker" ]]; then
mem_default=""
if [[ -n "$ex_name" ]]; then
mem_default=$(ini_get "$RUNNERS_CONF" "$ex_name" "memory" "")
fi
prompt_field "memory" "Docker memory limit (e.g. 2g, empty = no limit)" "docker_memory" "$mem_default" "true"
COLLECTED_DATA["${r_name}:memory"]="$PROMPT_RESULT"
else
COLLECTED_DATA["${r_name}:memory"]=""
CURRENT_PROMPT=$((CURRENT_PROMPT + 1))
fi
done
# ---------------------------------------------------------------------------
# Write runners.conf — overwrites any existing file with the header comment
# block and one pipe-delimited line per runner.
# Write runners.conf — INI format with header comment block
# ---------------------------------------------------------------------------
cat > "$RUNNERS_CONF" <<'HEADER'
# =============================================================================
# runners.conf — Gitea Actions Runner Definitions
# runners.conf — Gitea Actions Runner Definitions (INI format)
# Generated by setup/configure_runners.sh — edit manually or re-run wizard.
# Use manage_runner.sh to add/remove runners dynamically.
# See runners.conf.example for field reference.
# =============================================================================
#
# FORMAT: name|ssh_host|ssh_user|ssh_port|data_path|labels|type
#
# name — Display name in Gitea admin panel
# ssh_host — IP address or hostname (for local machine, use "local")
# ssh_user — SSH username (ignored if ssh_host is "local")
# ssh_port — SSH port (ignored if ssh_host is "local")
# data_path — Absolute path for runner binary + data on that machine
# labels — Comma-separated runner labels (used in workflow runs-on)
# type — "docker" (Linux, runs jobs in containers) or "native" (macOS, runs jobs on host)
#
HEADER
for ((i = 0; i < ${#COLLECTED_NAMES[@]}; i++)); do
printf '%s|%s|%s|%s|%s|%s|%s\n' \
"${COLLECTED_NAMES[$i]}" \
"${COLLECTED_HOSTS[$i]}" \
"${COLLECTED_USERS[$i]}" \
"${COLLECTED_PORTS[$i]}" \
"${COLLECTED_PATHS[$i]}" \
"${COLLECTED_LABELS[$i]}" \
"${COLLECTED_TYPES[$i]}" >> "$RUNNERS_CONF"
for r_name in "${COLLECTED_NAMES[@]}"; do
printf '[%s]\n' "$r_name" >> "$RUNNERS_CONF"
# Write fields in canonical order
for key in host type data_path labels default_image repos capacity cpu memory; do
local_val="${COLLECTED_DATA["${r_name}:${key}"]:-}"
printf '%-14s= %s\n' "$key" "$local_val" >> "$RUNNERS_CONF"
done
# Write custom SSH fields if host=custom
if [[ "${COLLECTED_DATA["${r_name}:host"]:-}" == "custom" ]]; then
for key in ssh_host ssh_user ssh_port ssh_key; do
local_val="${COLLECTED_DATA["${r_name}:${key}"]:-}"
printf '%-14s= %s\n' "$key" "$local_val" >> "$RUNNERS_CONF"
done
fi
printf '\n' >> "$RUNNERS_CONF"
done
# ===========================================================================
# Summary — green border box + table matching manage_runner.sh list format
# Summary — green border box + table
# ===========================================================================
printf '\n%b╔══════════════════════════════════════════════════════════╗%b\n' "$C_GREEN" "$C_RESET"
printf '%b║ Runner Configuration Complete ║%b\n' "$C_GREEN" "$C_RESET"
printf '%b╚══════════════════════════════════════════════════════════╝%b\n\n' "$C_GREEN" "$C_RESET"
printf '%bRunners configured:%b %d\n\n' "$C_BOLD" "$C_RESET" "${#COLLECTED_NAMES[@]}"
printf '%-20s %-16s %-10s %-8s %-24s\n' "NAME" "HOST" "LABELS" "TYPE" "DATA PATH"
printf '%-20s %-16s %-10s %-8s %-24s\n' "----" "----" "------" "----" "---------"
printf '%-20s %-10s %-8s %-10s %-6s %-10s %-24s\n' "NAME" "HOST" "TYPE" "LABELS" "CAP" "REPOS" "DATA PATH"
printf '%-20s %-10s %-8s %-10s %-6s %-10s %-24s\n' "----" "----" "----" "------" "---" "-----" "---------"
for ((i = 0; i < ${#COLLECTED_NAMES[@]}; i++)); do
printf '%-20s %-16s %-10s %-8s %-24s\n' \
"${COLLECTED_NAMES[$i]}" \
"${COLLECTED_HOSTS[$i]}" \
"${COLLECTED_LABELS[$i]}" \
"${COLLECTED_TYPES[$i]}" \
"${COLLECTED_PATHS[$i]}"
for r_name in "${COLLECTED_NAMES[@]}"; do
printf '%-20s %-10s %-8s %-10s %-6s %-10s %-24s\n' \
"$r_name" \
"${COLLECTED_DATA["${r_name}:host"]:-}" \
"${COLLECTED_DATA["${r_name}:type"]:-}" \
"${COLLECTED_DATA["${r_name}:labels"]:-}" \
"${COLLECTED_DATA["${r_name}:capacity"]:-}" \
"${COLLECTED_DATA["${r_name}:repos"]:-}" \
"${COLLECTED_DATA["${r_name}:data_path"]:-}"
done
printf '\n Saved to: %s\n\n' "$RUNNERS_CONF"

View File

@@ -7,9 +7,10 @@ services:
restart: unless-stopped
environment:
- GITEA_INSTANCE_URL=${GITEA_INTERNAL_URL}
- GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}
- GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_REG_TOKEN}
- GITEA_RUNNER_NAME=${RUNNER_NAME}
- GITEA_RUNNER_LABELS=${RUNNER_LABELS}
- GITEA_RUNNER_LABELS=${RUNNER_LABELS_CSV}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${RUNNER_DATA_PATH}:/data
${RUNNER_DEPLOY_RESOURCES}

View File

@@ -5,8 +5,8 @@ log:
runner:
name: ${RUNNER_NAME}
labels:
- "${RUNNER_LABELS}:docker://node:20-bookworm"
capacity: 1
${RUNNER_LABELS_YAML}
capacity: ${RUNNER_CAPACITY}
timeout: 3h
insecure: false
fetch_timeout: 5s