From b799cb79700e0ba9bd292a7154a4dd903e397cb4 Mon Sep 17 00:00:00 2001 From: S Date: Tue, 3 Mar 2026 14:14:11 -0600 Subject: [PATCH] feat: add phases 10-11, enhance phase 8 direct-check mode, and update Caddy migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase 10: local repo cutover (rename origin→github, add Gitea remote, push branches/tags) - Phase 11: custom runner infrastructure with toolchain-based naming (go-node-runner, jvm-android-runner) and repo variables via Gitea API - Add container_options support to manage_runner.sh for KVM passthrough - Phase 8: add --allow-direct-checks flag for LAN/split-DNS staging - Phase 7.5: add Cloudflare TLS block, retry logic for probes, multi-upstream support - Add toggle_dns.sh helper and update orchestration scripts for phases 10-11 Co-Authored-By: Claude Opus 4.6 --- .env.example | 5 + lib/common.sh | 4 +- lib/phase10_common.sh | 223 ++++++++++++++ manage_runner.sh | 10 +- phase10_local_repo_cutover.sh | 511 +++++++++++++++++++++++++++++++ phase10_post_check.sh | 112 +++++++ phase10_teardown.sh | 172 +++++++++++ phase11_custom_runners.sh | 288 +++++++++++++++++ phase11_post_check.sh | 204 ++++++++++++ phase11_teardown.sh | 185 +++++++++++ phase7_5_nginx_to_caddy.sh | 40 ++- phase8_cutover.sh | 52 +++- phase8_post_check.sh | 35 ++- repo_variables.conf.example | 20 ++ run_all.sh | 45 ++- runners.conf.example | 5 + teardown_all.sh | 22 +- templates/runner-config.yaml.tpl | 4 +- toggle_dns.sh | 49 +++ 19 files changed, 1931 insertions(+), 55 deletions(-) create mode 100644 lib/phase10_common.sh create mode 100755 phase10_local_repo_cutover.sh create mode 100755 phase10_post_check.sh create mode 100755 phase10_teardown.sh create mode 100755 phase11_custom_runners.sh create mode 100755 phase11_post_check.sh create mode 100755 phase11_teardown.sh create mode 100644 repo_variables.conf.example create mode 100755 toggle_dns.sh diff --git a/.env.example b/.env.example index 74dccb5..5ad70e8 100644 --- a/.env.example +++ b/.env.example @@ -96,6 +96,11 @@ LOCAL_REGISTRY= # Local registry prefix (e.g. registry.local:5 # AUTO-POPULATED by phase3 scripts — do not fill manually: GITEA_RUNNER_REGISTRATION_TOKEN= # Retrieved from Gitea admin panel via API +# Custom runner image build contexts (phase 11) +# Absolute paths to directories containing Dockerfiles for custom runner images. +GO_NODE_RUNNER_CONTEXT= # Path to Go + Node toolchain Dockerfile (e.g. /path/to/augur/infra/runners) +JVM_ANDROID_RUNNER_CONTEXT= # Path to JDK + Android SDK toolchain Dockerfile (e.g. /path/to/periodvault/infra/runners) + # ----------------------------------------------------------------------------- # REPOSITORIES diff --git a/lib/common.sh b/lib/common.sh index 058c470..fab8db7 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -278,8 +278,8 @@ _ENV_CONDITIONAL_DB_NAMES=(GITEA_DB_PORT GITEA_DB_NAME GITEA_DB_USER GITEA_DB_PA _ENV_CONDITIONAL_DB_TYPES=(port nonempty nonempty password) # 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) +_ENV_OPTIONAL_NAMES=(UNRAID_SSH_KEY FEDORA_SSH_KEY LOCAL_REGISTRY GO_NODE_RUNNER_CONTEXT JVM_ANDROID_RUNNER_CONTEXT) +_ENV_OPTIONAL_TYPES=(optional_path optional_path nonempty optional_path optional_path) # Human-readable format hints for error messages. _validator_hint() { diff --git a/lib/phase10_common.sh b/lib/phase10_common.sh new file mode 100644 index 0000000..f9da484 --- /dev/null +++ b/lib/phase10_common.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +# ============================================================================= +# lib/phase10_common.sh — Shared helpers for phase 10 local repo cutover +# ============================================================================= + +# Shared discovery results (parallel arrays; bash 3.2 compatible). +PHASE10_REPO_NAMES=() +PHASE10_REPO_PATHS=() +PHASE10_GITHUB_URLS=() +PHASE10_DUPLICATES=() + +# Parse common git remote URL formats into: host|owner|repo +# Supports: +# - https://host/owner/repo(.git) +# - ssh://git@host/owner/repo(.git) +# - git@host:owner/repo(.git) +phase10_parse_git_url() { + local url="$1" + local rest host path owner repo + + if [[ "$url" =~ ^[a-zA-Z][a-zA-Z0-9+.-]*:// ]]; then + rest="${url#*://}" + # Drop optional userinfo component. + rest="${rest#*@}" + host="${rest%%/*}" + path="${rest#*/}" + elif [[ "$url" == *@*:* ]]; then + rest="${url#*@}" + host="${rest%%:*}" + path="${rest#*:}" + else + return 1 + fi + + path="${path#/}" + path="${path%.git}" + owner="${path%%/*}" + repo="${path#*/}" + repo="${repo%%/*}" + + if [[ -z "$host" ]] || [[ -z "$owner" ]] || [[ -z "$repo" ]] || [[ "$owner" == "$path" ]]; then + return 1 + fi + + printf '%s|%s|%s\n' "$host" "$owner" "$repo" +} + +phase10_host_matches() { + local host="$1" expected="$2" + [[ "$host" == "$expected" ]] || [[ "$host" == "${expected}:"* ]] +} + +# Return 0 when URL matches github.com//. +# If is omitted, only owner is checked. +phase10_url_is_github_repo() { + local url="$1" owner_expected="$2" repo_expected="${3:-}" + local parsed host owner repo + + parsed=$(phase10_parse_git_url "$url" 2>/dev/null) || return 1 + IFS='|' read -r host owner repo <<< "$parsed" + + phase10_host_matches "$host" "github.com" || return 1 + [[ "$owner" == "$owner_expected" ]] || return 1 + if [[ -n "$repo_expected" ]] && [[ "$repo" != "$repo_expected" ]]; then + return 1 + fi + return 0 +} + +phase10_url_is_gitea_repo() { + local url="$1" domain="$2" org="$3" repo_expected="$4" + local parsed host owner repo + + parsed=$(phase10_parse_git_url "$url" 2>/dev/null) || return 1 + IFS='|' read -r host owner repo <<< "$parsed" + + phase10_host_matches "$host" "$domain" || return 1 + [[ "$owner" == "$org" ]] || return 1 + [[ "$repo" == "$repo_expected" ]] || return 1 +} + +phase10_canonical_github_url() { + local owner="$1" repo="$2" + printf 'https://github.com/%s/%s.git' "$owner" "$repo" +} + +phase10_canonical_gitea_url() { + local domain="$1" org="$2" repo="$3" + printf 'https://%s/%s/%s.git' "$domain" "$org" "$repo" +} + +# Stable in-place sort by repo name (keeps arrays aligned). +phase10_sort_repo_arrays() { + local i j tmp + for ((i = 0; i < ${#PHASE10_REPO_NAMES[@]}; i++)); do + for ((j = i + 1; j < ${#PHASE10_REPO_NAMES[@]}; j++)); do + if [[ "${PHASE10_REPO_NAMES[$i]}" > "${PHASE10_REPO_NAMES[$j]}" ]]; then + tmp="${PHASE10_REPO_NAMES[$i]}" + PHASE10_REPO_NAMES[i]="${PHASE10_REPO_NAMES[j]}" + PHASE10_REPO_NAMES[j]="$tmp" + + tmp="${PHASE10_REPO_PATHS[i]}" + PHASE10_REPO_PATHS[i]="${PHASE10_REPO_PATHS[j]}" + PHASE10_REPO_PATHS[j]="$tmp" + + tmp="${PHASE10_GITHUB_URLS[i]}" + PHASE10_GITHUB_URLS[i]="${PHASE10_GITHUB_URLS[j]}" + PHASE10_GITHUB_URLS[j]="$tmp" + fi + done + done +} + +# Discover local repos under root that map to github.com/. +# Discovery rules: +# - Only direct children of root are considered. +# - Excludes exclude_path (typically this toolkit repo). +# - Accepts a repo if either "github" or "origin" points at GitHub owner. +# - Deduplicates by repo slug, preferring directory basename == slug. +# +# Args: +# $1 root dir (e.g., /Users/s/development) +# $2 github owner (from GITHUB_USERNAME) +# $3 exclude absolute path (optional; pass "" for none) +# $4 expected count (0 = don't enforce) +phase10_discover_local_repos() { + local root="$1" + local github_owner="$2" + local exclude_path="${3:-}" + local expected_count="${4:-0}" + + PHASE10_REPO_NAMES=() + PHASE10_REPO_PATHS=() + PHASE10_GITHUB_URLS=() + PHASE10_DUPLICATES=() + + if [[ ! -d "$root" ]]; then + log_error "Local repo root not found: ${root}" + return 1 + fi + + local dir top github_url parsed host owner repo canonical + local i idx existing existing_base new_base duplicate + for dir in "$root"/*; do + [[ -d "$dir" ]] || continue + if [[ -n "$exclude_path" ]] && [[ "$dir" == "$exclude_path" ]]; then + continue + fi + + if ! git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + continue + fi + + top=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null || true) + [[ "$top" == "$dir" ]] || continue + + github_url="" + if github_url=$(git -C "$dir" remote get-url github 2>/dev/null); then + if ! phase10_url_is_github_repo "$github_url" "$github_owner"; then + github_url="" + fi + fi + + if [[ -z "$github_url" ]] && github_url=$(git -C "$dir" remote get-url origin 2>/dev/null); then + if ! phase10_url_is_github_repo "$github_url" "$github_owner"; then + github_url="" + fi + fi + + [[ -n "$github_url" ]] || continue + + parsed=$(phase10_parse_git_url "$github_url" 2>/dev/null) || continue + IFS='|' read -r host owner repo <<< "$parsed" + canonical=$(phase10_canonical_github_url "$owner" "$repo") + + idx=-1 + for i in "${!PHASE10_REPO_NAMES[@]}"; do + if [[ "${PHASE10_REPO_NAMES[$i]}" == "$repo" ]]; then + idx="$i" + break + fi + done + + if [[ "$idx" -ge 0 ]]; then + existing="${PHASE10_REPO_PATHS[$idx]}" + existing_base="$(basename "$existing")" + new_base="$(basename "$dir")" + if [[ "$new_base" == "$repo" ]] && [[ "$existing_base" != "$repo" ]]; then + PHASE10_REPO_PATHS[idx]="$dir" + PHASE10_GITHUB_URLS[idx]="$canonical" + PHASE10_DUPLICATES+=("${repo}: preferred ${dir} over ${existing}") + else + PHASE10_DUPLICATES+=("${repo}: ignored duplicate ${dir} (using ${existing})") + fi + continue + fi + + PHASE10_REPO_NAMES+=("$repo") + PHASE10_REPO_PATHS+=("$dir") + PHASE10_GITHUB_URLS+=("$canonical") + done + + phase10_sort_repo_arrays + + for duplicate in "${PHASE10_DUPLICATES[@]}"; do + log_info "$duplicate" + done + + if [[ "${#PHASE10_REPO_NAMES[@]}" -eq 0 ]]; then + log_error "No local GitHub repos found under ${root} for owner '${github_owner}'" + return 1 + fi + + if [[ "$expected_count" -gt 0 ]] && [[ "${#PHASE10_REPO_NAMES[@]}" -ne "$expected_count" ]]; then + log_error "Expected ${expected_count} local repos under ${root}; found ${#PHASE10_REPO_NAMES[@]}" + for i in "${!PHASE10_REPO_NAMES[@]}"; do + log_error " - ${PHASE10_REPO_NAMES[$i]} -> ${PHASE10_REPO_PATHS[$i]}" + done + return 1 + fi + + return 0 +} diff --git a/manage_runner.sh b/manage_runner.sh index 8974abb..ee27cac 100755 --- a/manage_runner.sh +++ b/manage_runner.sh @@ -73,6 +73,9 @@ parse_runner_entry() { # "true" → /Library/LaunchDaemons/ (starts at boot, requires sudo) # "false" (default) → ~/Library/LaunchAgents/ (starts at login) RUNNER_BOOT=$(ini_get "$RUNNERS_CONF" "$target_name" "boot" "false") + # container_options: extra Docker flags for act_runner job containers. + # e.g. "--device=/dev/kvm" for KVM passthrough. Ignored for native runners. + RUNNER_CONTAINER_OPTIONS=$(ini_get "$RUNNERS_CONF" "$target_name" "container_options" "") # --- Host resolution --- # Also resolves RUNNER_COMPOSE_DIR: centralized compose dir on unraid/fedora, @@ -354,8 +357,9 @@ add_docker_runner() { # shellcheck disable=SC2090 # intentional — RUNNER_LABELS_YAML rendered via envsubst export RUNNER_LABELS_YAML export RUNNER_CAPACITY + export RUNNER_CONTAINER_OPTIONS render_template "${SCRIPT_DIR}/templates/runner-config.yaml.tpl" "$tmpfile" \ - "\${RUNNER_NAME} \${RUNNER_LABELS_YAML} \${RUNNER_CAPACITY}" + "\${RUNNER_NAME} \${RUNNER_LABELS_YAML} \${RUNNER_CAPACITY} \${RUNNER_CONTAINER_OPTIONS}" runner_scp "$tmpfile" "${RUNNER_DATA_PATH}/config.yaml" rm -f "$tmpfile" @@ -422,9 +426,9 @@ add_native_runner() { local tmpfile tmpfile=$(mktemp) # shellcheck disable=SC2090 # intentional — RUNNER_LABELS_YAML rendered via envsubst - export RUNNER_NAME RUNNER_DATA_PATH RUNNER_LABELS_YAML RUNNER_CAPACITY + export RUNNER_NAME RUNNER_DATA_PATH RUNNER_LABELS_YAML RUNNER_CAPACITY RUNNER_CONTAINER_OPTIONS render_template "${SCRIPT_DIR}/templates/runner-config.yaml.tpl" "$tmpfile" \ - "\${RUNNER_NAME} \${RUNNER_LABELS_YAML} \${RUNNER_CAPACITY}" + "\${RUNNER_NAME} \${RUNNER_LABELS_YAML} \${RUNNER_CAPACITY} \${RUNNER_CONTAINER_OPTIONS}" cp "$tmpfile" "${RUNNER_DATA_PATH}/config.yaml" rm -f "$tmpfile" diff --git a/phase10_local_repo_cutover.sh b/phase10_local_repo_cutover.sh new file mode 100755 index 0000000..1d392ca --- /dev/null +++ b/phase10_local_repo_cutover.sh @@ -0,0 +1,511 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================= +# phase10_local_repo_cutover.sh — Re-point local repos from GitHub to Gitea +# Depends on: Phase 8 complete (Gitea publicly reachable) + Phase 4 migrated +# +# For each discovered local repo under /Users/s/development: +# 1. Rename origin -> github (if needed) +# 2. Ensure repo exists on Gitea (create if missing) +# 3. Add/update origin to point at Gitea +# 4. Push all branches and tags to Gitea origin +# 5. Ensure every local branch tracks origin/ (Gitea) +# +# Discovery is based on local git remotes: +# - repo root is a direct child of PHASE10_LOCAL_ROOT (default /Users/s/development) +# - repo has origin/github pointing to github.com/${GITHUB_USERNAME}/ +# - duplicate clones are deduped by repo slug +# ============================================================================= + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCRIPT_DIR}/lib/common.sh" +source "${SCRIPT_DIR}/lib/phase10_common.sh" + +load_env +require_vars GITEA_ADMIN_TOKEN GITEA_ADMIN_USER GITEA_ORG_NAME GITEA_DOMAIN GITEA_INTERNAL_URL GITHUB_USERNAME + +phase_header 10 "Local Repo Remote Cutover" + +LOCAL_REPO_ROOT="${PHASE10_LOCAL_ROOT:-/Users/s/development}" +EXPECTED_REPO_COUNT="${PHASE10_EXPECTED_REPO_COUNT:-3}" +DRY_RUN=false +ASKPASS_SCRIPT="" +PHASE10_GITEA_REPO_EXISTS=false +PHASE10_REMOTE_BRANCHES="" +PHASE10_REMOTE_TAGS="" + +for arg in "$@"; do + case "$arg" in + --local-root=*) LOCAL_REPO_ROOT="${arg#*=}" ;; + --expected-count=*) EXPECTED_REPO_COUNT="${arg#*=}" ;; + --dry-run) DRY_RUN=true ;; + --help|-h) + cat < "$ASKPASS_SCRIPT" <<'EOF' +#!/usr/bin/env sh +case "$1" in + *sername*) printf '%s\n' "$GITEA_GIT_USERNAME" ;; + *assword*) printf '%s\n' "$GITEA_GIT_TOKEN" ;; + *) printf '\n' ;; +esac +EOF + chmod 700 "$ASKPASS_SCRIPT" +} + +git_with_auth() { + GIT_TERMINAL_PROMPT=0 \ + GIT_ASKPASS="$ASKPASS_SCRIPT" \ + GITEA_GIT_USERNAME="$GITEA_ADMIN_USER" \ + GITEA_GIT_TOKEN="$GITEA_ADMIN_TOKEN" \ + "$@" +} + +ensure_github_remote() { + local repo_path="$1" repo_name="$2" github_url="$3" + local existing origin_existing has_bad_github + has_bad_github=false + + if existing=$(git -C "$repo_path" remote get-url github 2>/dev/null); then + if phase10_url_is_github_repo "$existing" "$GITHUB_USERNAME" "$repo_name"; then + if [[ "$existing" != "$github_url" ]]; then + if [[ "$DRY_RUN" == "true" ]]; then + log_info "${repo_name}: would set github URL -> ${github_url}" + else + git -C "$repo_path" remote set-url github "$github_url" + fi + fi + return 0 + fi + has_bad_github=true + fi + + if origin_existing=$(git -C "$repo_path" remote get-url origin 2>/dev/null); then + if phase10_url_is_github_repo "$origin_existing" "$GITHUB_USERNAME" "$repo_name"; then + if [[ "$has_bad_github" == "true" ]]; then + if [[ "$DRY_RUN" == "true" ]]; then + log_warn "${repo_name}: would remove misconfigured 'github' remote and rebuild it from origin" + else + git -C "$repo_path" remote remove github + log_warn "${repo_name}: removed misconfigured 'github' remote and rebuilt it from origin" + fi + fi + if [[ "$DRY_RUN" == "true" ]]; then + log_info "${repo_name}: would rename origin -> github" + log_info "${repo_name}: would set github URL -> ${github_url}" + else + git -C "$repo_path" remote rename origin github + git -C "$repo_path" remote set-url github "$github_url" + log_success "${repo_name}: renamed origin -> github" + fi + return 0 + fi + fi + + if [[ "$has_bad_github" == "true" ]]; then + log_error "${repo_name}: existing 'github' remote does not point to GitHub repo ${GITHUB_USERNAME}/${repo_name}" + return 1 + fi + + log_error "${repo_name}: could not find GitHub remote in 'origin' or 'github'" + return 1 +} + +ensure_gitea_origin() { + local repo_path="$1" repo_name="$2" gitea_url="$3" + local existing + + if existing=$(git -C "$repo_path" remote get-url origin 2>/dev/null); then + if phase10_url_is_gitea_repo "$existing" "$GITEA_DOMAIN" "$GITEA_ORG_NAME" "$repo_name"; then + if [[ "$existing" != "$gitea_url" ]]; then + if [[ "$DRY_RUN" == "true" ]]; then + log_info "${repo_name}: would normalize origin URL -> ${gitea_url}" + else + git -C "$repo_path" remote set-url origin "$gitea_url" + fi + fi + return 0 + fi + # origin exists but points somewhere else; force it to Gitea. + if [[ "$DRY_RUN" == "true" ]]; then + log_info "${repo_name}: would set origin URL -> ${gitea_url}" + else + git -C "$repo_path" remote set-url origin "$gitea_url" + fi + return 0 + fi + + if [[ "$DRY_RUN" == "true" ]]; then + log_info "${repo_name}: would add origin -> ${gitea_url}" + else + git -C "$repo_path" remote add origin "$gitea_url" + fi + return 0 +} + +ensure_gitea_repo_exists() { + local repo_name="$1" + local create_payload http_code + + get_gitea_repo_http_code() { + local target_repo="$1" + local tmpfile curl_code + tmpfile=$(mktemp) + curl_code=$(curl \ + -s \ + -o "$tmpfile" \ + -w "%{http_code}" \ + -H "Authorization: token ${GITEA_ADMIN_TOKEN}" \ + -H "Accept: application/json" \ + "${GITEA_INTERNAL_URL}/api/v1/repos/${GITEA_ORG_NAME}/${target_repo}") || { + rm -f "$tmpfile" + return 1 + } + rm -f "$tmpfile" + printf '%s' "$curl_code" + } + + PHASE10_GITEA_REPO_EXISTS=false + if ! http_code="$(get_gitea_repo_http_code "$repo_name")"; then + log_error "${repo_name}: failed to query Gitea API for repo existence" + return 1 + fi + + if [[ "$http_code" == "200" ]]; then + PHASE10_GITEA_REPO_EXISTS=true + if [[ "$DRY_RUN" == "true" ]]; then + log_info "${repo_name}: Gitea repo already exists (${GITEA_ORG_NAME}/${repo_name})" + fi + return 0 + fi + + if [[ "$http_code" != "404" ]]; then + log_error "${repo_name}: unexpected Gitea API status while checking repo (${http_code})" + return 1 + fi + + create_payload=$(jq -n \ + --arg name "$repo_name" \ + '{name: $name, auto_init: false}') + + if [[ "$DRY_RUN" == "true" ]]; then + log_info "${repo_name}: would create missing Gitea repo ${GITEA_ORG_NAME}/${repo_name}" + return 0 + fi + + if gitea_api POST "/orgs/${GITEA_ORG_NAME}/repos" "$create_payload" >/dev/null 2>&1; then + log_success "${repo_name}: created missing Gitea repo ${GITEA_ORG_NAME}/${repo_name}" + return 0 + fi + + log_error "${repo_name}: failed to create Gitea repo ${GITEA_ORG_NAME}/${repo_name}" + return 1 +} + +count_items() { + local list="$1" + if [[ -z "$list" ]]; then + printf '0' + return + fi + printf '%s\n' "$list" | sed '/^$/d' | wc -l | tr -d '[:space:]' +} + +list_contains() { + local list="$1" needle="$2" + [[ -n "$list" ]] && printf '%s\n' "$list" | grep -Fxq "$needle" +} + +fetch_remote_refs() { + local url="$1" + local refs ref short + + PHASE10_REMOTE_BRANCHES="" + PHASE10_REMOTE_TAGS="" + + refs=$(git_with_auth git ls-remote --heads --tags "$url" 2>/dev/null) || return 1 + [[ -n "$refs" ]] || return 0 + + while IFS= read -r line; do + [[ -z "$line" ]] && continue + ref="${line#*[[:space:]]}" + ref="${ref#"${ref%%[![:space:]]*}"}" + [[ -n "$ref" ]] || continue + case "$ref" in + refs/heads/*) + short="${ref#refs/heads/}" + PHASE10_REMOTE_BRANCHES="${PHASE10_REMOTE_BRANCHES}${short}"$'\n' + ;; + refs/tags/*) + short="${ref#refs/tags/}" + [[ "$short" == *"^{}" ]] && continue + PHASE10_REMOTE_TAGS="${PHASE10_REMOTE_TAGS}${short}"$'\n' + ;; + esac + done <<< "$refs" + + PHASE10_REMOTE_BRANCHES="$(printf '%s' "$PHASE10_REMOTE_BRANCHES" | sed '/^$/d' | LC_ALL=C sort -u)" + PHASE10_REMOTE_TAGS="$(printf '%s' "$PHASE10_REMOTE_TAGS" | sed '/^$/d' | LC_ALL=C sort -u)" +} + +print_diff_summary() { + local repo_name="$1" kind="$2" local_list="$3" remote_list="$4" + local missing_count extra_count item + local missing_preview="" extra_preview="" + local preview_limit=5 + + missing_count=0 + while IFS= read -r item; do + [[ -z "$item" ]] && continue + if ! list_contains "$remote_list" "$item"; then + missing_count=$((missing_count + 1)) + if [[ "$missing_count" -le "$preview_limit" ]]; then + missing_preview="${missing_preview}${item}, " + fi + fi + done <<< "$local_list" + + extra_count=0 + while IFS= read -r item; do + [[ -z "$item" ]] && continue + if ! list_contains "$local_list" "$item"; then + extra_count=$((extra_count + 1)) + if [[ "$extra_count" -le "$preview_limit" ]]; then + extra_preview="${extra_preview}${item}, " + fi + fi + done <<< "$remote_list" + + if [[ "$missing_count" -eq 0 ]] && [[ "$extra_count" -eq 0 ]]; then + log_success "${repo_name}: local ${kind}s match Gitea" + return 0 + fi + + if [[ "$missing_count" -gt 0 ]]; then + missing_preview="${missing_preview%, }" + log_info "${repo_name}: ${missing_count} ${kind}(s) missing on Gitea" + if [[ -n "$missing_preview" ]]; then + log_info " missing ${kind} sample: ${missing_preview}" + fi + fi + + if [[ "$extra_count" -gt 0 ]]; then + extra_preview="${extra_preview%, }" + log_info "${repo_name}: ${extra_count} ${kind}(s) exist on Gitea but not locally" + if [[ -n "$extra_preview" ]]; then + log_info " remote-only ${kind} sample: ${extra_preview}" + fi + fi +} + +dry_run_compare_local_and_remote() { + local repo_path="$1" repo_name="$2" gitea_url="$3" + local local_branches local_tags + local local_branch_count local_tag_count remote_branch_count remote_tag_count + + local_branches="$(git -C "$repo_path" for-each-ref --format='%(refname:short)' refs/heads | LC_ALL=C sort -u)" + local_tags="$(git -C "$repo_path" tag -l | LC_ALL=C sort -u)" + local_branch_count="$(count_items "$local_branches")" + local_tag_count="$(count_items "$local_tags")" + + log_info "${repo_name}: local state = ${local_branch_count} branch(es), ${local_tag_count} tag(s)" + + if [[ "$PHASE10_GITEA_REPO_EXISTS" != "true" ]]; then + log_info "${repo_name}: remote state = repo missing (would be created)" + if [[ "$local_branch_count" -gt 0 ]]; then + log_info "${repo_name}: all local branches would be pushed to new Gitea repo" + fi + if [[ "$local_tag_count" -gt 0 ]]; then + log_info "${repo_name}: all local tags would be pushed to new Gitea repo" + fi + return 0 + fi + + if ! fetch_remote_refs "$gitea_url"; then + log_warn "${repo_name}: could not read Gitea refs via ls-remote; skipping diff" + return 0 + fi + + remote_branch_count="$(count_items "$PHASE10_REMOTE_BRANCHES")" + remote_tag_count="$(count_items "$PHASE10_REMOTE_TAGS")" + log_info "${repo_name}: remote Gitea state = ${remote_branch_count} branch(es), ${remote_tag_count} tag(s)" + + print_diff_summary "$repo_name" "branch" "$local_branches" "$PHASE10_REMOTE_BRANCHES" + print_diff_summary "$repo_name" "tag" "$local_tags" "$PHASE10_REMOTE_TAGS" +} + +push_all_refs_to_origin() { + local repo_path="$1" repo_name="$2" + if [[ "$DRY_RUN" == "true" ]]; then + log_info "${repo_name}: would push all branches to origin" + log_info "${repo_name}: would push all tags to origin" + return 0 + fi + + if ! git_with_auth git -C "$repo_path" push --all origin >/dev/null; then + log_error "${repo_name}: failed pushing branches to Gitea origin" + return 1 + fi + if ! git_with_auth git -C "$repo_path" push --tags origin >/dev/null; then + log_error "${repo_name}: failed pushing tags to Gitea origin" + return 1 + fi + return 0 +} + +retarget_tracking_to_origin() { + local repo_path="$1" repo_name="$2" + local branch upstream_remote upstream_short branch_count + branch_count=0 + + while IFS= read -r branch; do + [[ -z "$branch" ]] && continue + branch_count=$((branch_count + 1)) + + if ! git -C "$repo_path" show-ref --verify --quiet "refs/remotes/origin/${branch}"; then + # A local branch can exist without an origin ref if it never got pushed. + if [[ "$DRY_RUN" == "true" ]]; then + log_info "${repo_name}: would create origin/${branch} by pushing local ${branch}" + else + if ! git_with_auth git -C "$repo_path" push origin "refs/heads/${branch}:refs/heads/${branch}" >/dev/null; then + log_error "${repo_name}: could not create origin/${branch} while setting tracking" + return 1 + fi + fi + fi + + if [[ "$DRY_RUN" == "true" ]]; then + log_info "${repo_name}: would set upstream ${branch} -> origin/${branch}" + continue + else + if ! git -C "$repo_path" branch --set-upstream-to="origin/${branch}" "$branch" >/dev/null 2>&1; then + log_error "${repo_name}: failed to set upstream for branch '${branch}' to origin/${branch}" + return 1 + fi + + upstream_remote=$(git -C "$repo_path" for-each-ref --format='%(upstream:remotename)' "refs/heads/${branch}") + upstream_short=$(git -C "$repo_path" for-each-ref --format='%(upstream:short)' "refs/heads/${branch}") + if [[ "$upstream_remote" != "origin" ]] || [[ "$upstream_short" != "origin/${branch}" ]]; then + log_error "${repo_name}: branch '${branch}' upstream is '${upstream_short:-}' (expected origin/${branch})" + return 1 + fi + fi + done < <(git -C "$repo_path" for-each-ref --format='%(refname:short)' refs/heads) + + if [[ "$branch_count" -eq 0 ]]; then + log_warn "${repo_name}: no local branches found" + fi + + return 0 +} + +if ! phase10_discover_local_repos "$LOCAL_REPO_ROOT" "$GITHUB_USERNAME" "$SCRIPT_DIR" "$EXPECTED_REPO_COUNT"; then + exit 1 +fi + +log_info "Discovered ${#PHASE10_REPO_NAMES[@]} local repos in ${LOCAL_REPO_ROOT}" +for i in "${!PHASE10_REPO_NAMES[@]}"; do + log_info " - ${PHASE10_REPO_NAMES[$i]} -> ${PHASE10_REPO_PATHS[$i]}" +done + +setup_git_auth + +SUCCESS=0 +FAILED=0 + +for i in "${!PHASE10_REPO_NAMES[@]}"; do + repo_name="${PHASE10_REPO_NAMES[$i]}" + repo_path="${PHASE10_REPO_PATHS[$i]}" + github_url="${PHASE10_GITHUB_URLS[$i]}" + gitea_url="$(phase10_canonical_gitea_url "$GITEA_DOMAIN" "$GITEA_ORG_NAME" "$repo_name")" + + log_info "--- Processing repo: ${repo_name} (${repo_path}) ---" + + if ! ensure_github_remote "$repo_path" "$repo_name" "$github_url"; then + FAILED=$((FAILED + 1)) + continue + fi + + if ! ensure_gitea_repo_exists "$repo_name"; then + FAILED=$((FAILED + 1)) + continue + fi + + if [[ "$DRY_RUN" == "true" ]]; then + dry_run_compare_local_and_remote "$repo_path" "$repo_name" "$gitea_url" + fi + + if ! ensure_gitea_origin "$repo_path" "$repo_name" "$gitea_url"; then + log_error "${repo_name}: failed to set origin to ${gitea_url}" + FAILED=$((FAILED + 1)) + continue + fi + + if ! push_all_refs_to_origin "$repo_path" "$repo_name"; then + FAILED=$((FAILED + 1)) + continue + fi + + if ! retarget_tracking_to_origin "$repo_path" "$repo_name"; then + FAILED=$((FAILED + 1)) + continue + fi + + if [[ "$DRY_RUN" == "true" ]]; then + log_success "${repo_name}: dry-run plan complete" + else + log_success "${repo_name}: origin now points to Gitea and tracking updated" + fi + SUCCESS=$((SUCCESS + 1)) +done + +printf '\n' +TOTAL=${#PHASE10_REPO_NAMES[@]} +log_info "Results: ${SUCCESS} succeeded, ${FAILED} failed (out of ${TOTAL})" + +if [[ "$DRY_RUN" == "true" ]]; then + if [[ "$FAILED" -gt 0 ]]; then + log_error "Phase 10 dry-run found ${FAILED} error(s); no changes were made" + exit 1 + fi + log_success "Phase 10 dry-run complete — no changes were made" + exit 0 +fi + +if [[ "$FAILED" -gt 0 ]]; then + log_error "Phase 10 failed for one or more repos" + exit 1 +fi + +log_success "Phase 10 complete — local repos now push/track via Gitea origin" diff --git a/phase10_post_check.sh b/phase10_post_check.sh new file mode 100755 index 0000000..ab9d7ae --- /dev/null +++ b/phase10_post_check.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================= +# phase10_post_check.sh — Verify local repo remote cutover to Gitea +# Checks for each discovered local repo: +# 1. origin points to Gitea org/repo +# 2. github points to GitHub owner/repo +# 3. every local branch tracks origin/ +# ============================================================================= + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCRIPT_DIR}/lib/common.sh" +source "${SCRIPT_DIR}/lib/phase10_common.sh" + +load_env +require_vars GITEA_ORG_NAME GITEA_DOMAIN GITHUB_USERNAME + +phase_header 10 "Local Repo Remote Cutover — Post-Check" + +LOCAL_REPO_ROOT="${PHASE10_LOCAL_ROOT:-/Users/s/development}" +EXPECTED_REPO_COUNT="${PHASE10_EXPECTED_REPO_COUNT:-3}" + +for arg in "$@"; do + case "$arg" in + --local-root=*) LOCAL_REPO_ROOT="${arg#*=}" ;; + --expected-count=*) EXPECTED_REPO_COUNT="${arg#*=}" ;; + --help|-h) + cat </dev/null || true)" + if [[ -n "$origin_url" ]] && phase10_url_is_gitea_repo "$origin_url" "$GITEA_DOMAIN" "$GITEA_ORG_NAME" "$repo_name"; then + log_success "origin points to Gitea (${gitea_url})" + PASS=$((PASS + 1)) + else + log_error "FAIL: origin does not point to ${gitea_url} (found: ${origin_url:-})" + FAIL=$((FAIL + 1)) + fi + + github_remote_url="$(git -C "$repo_path" remote get-url github 2>/dev/null || true)" + if [[ -n "$github_remote_url" ]] && phase10_url_is_github_repo "$github_remote_url" "$GITHUB_USERNAME" "$repo_name"; then + log_success "github points to GitHub (${github_url})" + PASS=$((PASS + 1)) + else + log_error "FAIL: github does not point to ${github_url} (found: ${github_remote_url:-})" + FAIL=$((FAIL + 1)) + fi + + branch_count=0 + while IFS= read -r branch; do + [[ -z "$branch" ]] && continue + branch_count=$((branch_count + 1)) + upstream_remote=$(git -C "$repo_path" for-each-ref --format='%(upstream:remotename)' "refs/heads/${branch}") + upstream_short=$(git -C "$repo_path" for-each-ref --format='%(upstream:short)' "refs/heads/${branch}") + if [[ "$upstream_remote" == "origin" ]] && [[ "$upstream_short" == "origin/${branch}" ]]; then + log_success "branch ${branch} tracks origin/${branch}" + PASS=$((PASS + 1)) + else + log_error "FAIL: branch ${branch} tracks ${upstream_short:-} (expected origin/${branch})" + FAIL=$((FAIL + 1)) + fi + done < <(git -C "$repo_path" for-each-ref --format='%(refname:short)' refs/heads) + + if [[ "$branch_count" -eq 0 ]]; then + log_warn "No local branches found in ${repo_name}" + fi +done + +printf '\n' +log_info "Results: ${PASS} passed, ${FAIL} failed" + +if [[ "$FAIL" -gt 0 ]]; then + log_error "Phase 10 post-check FAILED" + exit 1 +fi + +log_success "Phase 10 post-check PASSED — local repos track Gitea origin" diff --git a/phase10_teardown.sh b/phase10_teardown.sh new file mode 100755 index 0000000..0a60009 --- /dev/null +++ b/phase10_teardown.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================= +# phase10_teardown.sh — Reverse local repo remote cutover from phase 10 +# Reverts local repos so GitHub is origin again: +# 1. Move Gitea origin -> gitea (if present) +# 2. Move github -> origin +# 3. Set local branch upstreams to origin/ where available +# ============================================================================= + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCRIPT_DIR}/lib/common.sh" +source "${SCRIPT_DIR}/lib/phase10_common.sh" + +load_env +require_vars GITEA_ORG_NAME GITEA_DOMAIN GITHUB_USERNAME + +phase_header 10 "Local Repo Remote Cutover — Teardown" + +LOCAL_REPO_ROOT="${PHASE10_LOCAL_ROOT:-/Users/s/development}" +EXPECTED_REPO_COUNT="${PHASE10_EXPECTED_REPO_COUNT:-3}" +AUTO_YES=false + +for arg in "$@"; do + case "$arg" in + --local-root=*) LOCAL_REPO_ROOT="${arg#*=}" ;; + --expected-count=*) EXPECTED_REPO_COUNT="${arg#*=}" ;; + --yes|-y) AUTO_YES=true ;; + --help|-h) + cat <&2 + read -r confirm + if [[ "$confirm" != "y" ]] && [[ "$confirm" != "Y" ]]; then + log_info "Teardown cancelled" + exit 0 + fi +fi + +if ! phase10_discover_local_repos "$LOCAL_REPO_ROOT" "$GITHUB_USERNAME" "$SCRIPT_DIR" "$EXPECTED_REPO_COUNT"; then + exit 1 +fi + +set_tracking_to_origin_where_available() { + local repo_path="$1" repo_name="$2" + local branch branch_count + branch_count=0 + + while IFS= read -r branch; do + [[ -z "$branch" ]] && continue + branch_count=$((branch_count + 1)) + + if git -C "$repo_path" show-ref --verify --quiet "refs/remotes/origin/${branch}"; then + if git -C "$repo_path" branch --set-upstream-to="origin/${branch}" "$branch" >/dev/null 2>&1; then + log_success "${repo_name}: branch ${branch} now tracks origin/${branch}" + else + log_warn "${repo_name}: could not set upstream for ${branch}" + fi + else + log_warn "${repo_name}: origin/${branch} not found (upstream unchanged)" + fi + done < <(git -C "$repo_path" for-each-ref --format='%(refname:short)' refs/heads) + + if [[ "$branch_count" -eq 0 ]]; then + log_warn "${repo_name}: no local branches found" + fi +} + +ensure_origin_is_github() { + local repo_path="$1" repo_name="$2" github_url="$3" gitea_url="$4" + local origin_url github_url_existing gitea_url_existing + + origin_url="$(git -C "$repo_path" remote get-url origin 2>/dev/null || true)" + github_url_existing="$(git -C "$repo_path" remote get-url github 2>/dev/null || true)" + gitea_url_existing="$(git -C "$repo_path" remote get-url gitea 2>/dev/null || true)" + + if [[ -n "$origin_url" ]]; then + if phase10_url_is_github_repo "$origin_url" "$GITHUB_USERNAME" "$repo_name"; then + git -C "$repo_path" remote set-url origin "$github_url" + if [[ -n "$github_url_existing" ]]; then + git -C "$repo_path" remote remove github + fi + return 0 + fi + + if phase10_url_is_gitea_repo "$origin_url" "$GITEA_DOMAIN" "$GITEA_ORG_NAME" "$repo_name"; then + if [[ -z "$gitea_url_existing" ]]; then + git -C "$repo_path" remote rename origin gitea + else + git -C "$repo_path" remote set-url gitea "$gitea_url" + git -C "$repo_path" remote remove origin + fi + else + log_error "${repo_name}: origin remote is unexpected (${origin_url})" + return 1 + fi + fi + + if git -C "$repo_path" remote get-url origin >/dev/null 2>&1; then + : + elif [[ -n "$github_url_existing" ]]; then + git -C "$repo_path" remote rename github origin + else + git -C "$repo_path" remote add origin "$github_url" + fi + + git -C "$repo_path" remote set-url origin "$github_url" + + if git -C "$repo_path" remote get-url github >/dev/null 2>&1; then + git -C "$repo_path" remote remove github + fi + + if git -C "$repo_path" remote get-url gitea >/dev/null 2>&1; then + git -C "$repo_path" remote set-url gitea "$gitea_url" + fi + + return 0 +} + +SUCCESS=0 +FAILED=0 + +for i in "${!PHASE10_REPO_NAMES[@]}"; do + repo_name="${PHASE10_REPO_NAMES[$i]}" + repo_path="${PHASE10_REPO_PATHS[$i]}" + github_url="${PHASE10_GITHUB_URLS[$i]}" + gitea_url="$(phase10_canonical_gitea_url "$GITEA_DOMAIN" "$GITEA_ORG_NAME" "$repo_name")" + + log_info "--- Reverting repo: ${repo_name} (${repo_path}) ---" + if ! ensure_origin_is_github "$repo_path" "$repo_name" "$github_url" "$gitea_url"; then + FAILED=$((FAILED + 1)) + continue + fi + + set_tracking_to_origin_where_available "$repo_path" "$repo_name" + SUCCESS=$((SUCCESS + 1)) +done + +printf '\n' +TOTAL=${#PHASE10_REPO_NAMES[@]} +log_info "Results: ${SUCCESS} reverted, ${FAILED} failed (out of ${TOTAL})" + +if [[ "$FAILED" -gt 0 ]]; then + log_error "Phase 10 teardown completed with failures" + exit 1 +fi + +log_success "Phase 10 teardown complete" diff --git a/phase11_custom_runners.sh b/phase11_custom_runners.sh new file mode 100755 index 0000000..73b1c2c --- /dev/null +++ b/phase11_custom_runners.sh @@ -0,0 +1,288 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================= +# phase11_custom_runners.sh — Deploy per-repo runner infrastructure & variables +# Depends on: Phase 3 complete (runner infra), Phase 4 complete (repos on Gitea) +# +# Steps: +# 1. Build custom toolchain images on Unraid (go-node-runner, jvm-android-runner) +# 2. Consolidate macOS runners into a shared instance-level runner +# 3. Deploy per-repo Docker runners via manage_runner.sh +# 4. Set Gitea repository variables from repo_variables.conf +# +# Runner strategy: +# - Linux runners: repo-scoped, separate toolchain images per repo +# - Android emulator: shared (repos=all) — any repo can use it +# - macOS runner: shared (repos=all) — any repo can use it +# +# Idempotent: skips images that already exist, runners already running, +# and variables that already match. +# ============================================================================= + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCRIPT_DIR}/lib/common.sh" + +load_env +require_vars GITEA_ADMIN_TOKEN GITEA_INTERNAL_URL GITEA_ORG_NAME \ + UNRAID_IP UNRAID_SSH_USER UNRAID_SSH_PORT \ + GO_NODE_RUNNER_CONTEXT JVM_ANDROID_RUNNER_CONTEXT \ + ACT_RUNNER_VERSION + +phase_header 11 "Custom Runner Infrastructure" + +REPO_VARS_CONF="${SCRIPT_DIR}/repo_variables.conf" +REBUILD_IMAGES=false + +for arg in "$@"; do + case "$arg" in + --rebuild-images) REBUILD_IMAGES=true ;; + *) ;; + esac +done + +SUCCESS=0 +FAILED=0 + +# --------------------------------------------------------------------------- +# Helper: rsync a build context directory to Unraid +# --------------------------------------------------------------------------- +rsync_to_unraid() { + local src="$1" dest="$2" + local ssh_key="${UNRAID_SSH_KEY:-}" + local ssh_opts="ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -p ${UNRAID_SSH_PORT}" + if [[ -n "$ssh_key" ]]; then + ssh_opts="${ssh_opts} -i ${ssh_key}" + fi + rsync -az --delete \ + --exclude='.env' \ + --exclude='.env.*' \ + --exclude='envs/' \ + --exclude='.git' \ + --exclude='.gitignore' \ + -e "$ssh_opts" \ + "${src}/" "${UNRAID_SSH_USER}@${UNRAID_IP}:${dest}/" +} + +# --------------------------------------------------------------------------- +# Helper: check if a Docker image exists on Unraid +# --------------------------------------------------------------------------- +image_exists_on_unraid() { + local tag="$1" + ssh_exec "UNRAID" "docker image inspect '${tag}' >/dev/null 2>&1" +} + +# --------------------------------------------------------------------------- +# Helper: list all keys in an INI section (for repo_variables.conf) +# --------------------------------------------------------------------------- +ini_list_keys() { + local file="$1" section="$2" + local in_section=false + local line k + while IFS= read -r line; do + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [[ -z "$line" ]] && continue + [[ "$line" == \#* ]] && continue + if [[ "$line" =~ ^\[([^]]+)\] ]]; then + if [[ "${BASH_REMATCH[1]}" == "$section" ]]; then + in_section=true + elif $in_section; then + break + fi + continue + fi + if $in_section && [[ "$line" =~ ^([^=]+)= ]]; then + k="${BASH_REMATCH[1]}" + k="${k#"${k%%[![:space:]]*}"}" + k="${k%"${k##*[![:space:]]}"}" + printf '%s\n' "$k" + fi + done < "$file" +} + +# --------------------------------------------------------------------------- +# Helper: upsert a Gitea repo variable (create or update) +# --------------------------------------------------------------------------- +upsert_repo_variable() { + local repo="$1" var_name="$2" var_value="$3" + local owner="${GITEA_ORG_NAME}" + + # Check if variable already exists with correct value + local existing + if existing=$(gitea_api GET "/repos/${owner}/${repo}/actions/variables/${var_name}" 2>/dev/null); then + local current_value + current_value=$(printf '%s' "$existing" | jq -r '.value // .data // empty' 2>/dev/null) + if [[ "$current_value" == "$var_value" ]]; then + log_info " ${var_name} already set correctly — skipping" + return 0 + fi + # Update existing variable + if gitea_api PUT "/repos/${owner}/${repo}/actions/variables/${var_name}" \ + "$(jq -n --arg v "$var_value" '{value: $v}')" >/dev/null 2>&1; then + log_success " Updated ${var_name}" + return 0 + else + log_error " Failed to update ${var_name}" + return 1 + fi + fi + + # Create new variable + if gitea_api POST "/repos/${owner}/${repo}/actions/variables" \ + "$(jq -n --arg n "$var_name" --arg v "$var_value" '{name: $n, value: $v}')" >/dev/null 2>&1; then + log_success " Created ${var_name}" + return 0 + else + log_error " Failed to create ${var_name}" + return 1 + fi +} + +# ========================================================================= +# Step 1: Build toolchain images on Unraid +# ========================================================================= +log_step 1 "Building toolchain images on Unraid" + +REMOTE_BUILD_BASE="/tmp/gitea-runner-builds" + +# Image build definitions: TAG|LOCAL_CONTEXT|DOCKER_TARGET +IMAGE_BUILDS=( + "go-node-runner:latest|${GO_NODE_RUNNER_CONTEXT}|" + "jvm-android-runner:slim|${JVM_ANDROID_RUNNER_CONTEXT}|slim" + "jvm-android-runner:full|${JVM_ANDROID_RUNNER_CONTEXT}|full" +) + +for build_entry in "${IMAGE_BUILDS[@]}"; do + IFS='|' read -r img_tag build_context docker_target <<< "$build_entry" + + if [[ "$REBUILD_IMAGES" != "true" ]] && image_exists_on_unraid "$img_tag"; then + log_info "Image ${img_tag} already exists on Unraid — skipping" + continue + fi + + # Derive a unique remote directory name from the image tag + remote_dir="${REMOTE_BUILD_BASE}/${img_tag%%:*}" + + log_info "Syncing build context for ${img_tag}..." + ssh_exec "UNRAID" "mkdir -p '${remote_dir}'" + rsync_to_unraid "$build_context" "$remote_dir" + + log_info "Building ${img_tag} on Unraid (this may take a while)..." + local_build_args="" + if [[ -n "$docker_target" ]]; then + local_build_args="--target ${docker_target}" + fi + + # shellcheck disable=SC2086 + if ssh_exec "UNRAID" "cd '${remote_dir}' && docker build ${local_build_args} -t '${img_tag}' ."; then + log_success "Built ${img_tag}" + else + log_error "Failed to build ${img_tag}" + FAILED=$((FAILED + 1)) + fi +done + +# Clean up build contexts on Unraid +ssh_exec "UNRAID" "rm -rf '${REMOTE_BUILD_BASE}'" 2>/dev/null || true + +# ========================================================================= +# Step 2: Consolidate macOS runners into shared instance-level runner +# ========================================================================= +log_step 2 "Consolidating macOS runners" + +# Old per-repo macOS runners to remove +OLD_MAC_RUNNERS=( + macbook-runner-periodvault + macbook-runner-intermittent-fasting-tracker +) + +for old_runner in "${OLD_MAC_RUNNERS[@]}"; do + if ini_list_sections "${SCRIPT_DIR}/runners.conf" | grep -qx "$old_runner" 2>/dev/null; then + log_info "Old runner section '${old_runner}' found — phase 11 runners.conf already has it removed" + log_info " (If still registered in Gitea, run: manage_runner.sh remove --name ${old_runner})" + fi + # Remove from Gitea if still registered (launchd service) + if launchctl list 2>/dev/null | grep -q "com.gitea.runner.${old_runner}"; then + log_info "Removing old macOS runner '${old_runner}'..." + "${SCRIPT_DIR}/manage_runner.sh" remove --name "$old_runner" 2>/dev/null || true + fi +done + +# Deploy the new shared macOS runner +if launchctl list 2>/dev/null | grep -q "com.gitea.runner.macbook-runner"; then + log_info "Shared macOS runner 'macbook-runner' already registered — skipping" +else + log_info "Deploying shared macOS runner 'macbook-runner'..." + if "${SCRIPT_DIR}/manage_runner.sh" add --name macbook-runner; then + log_success "Shared macOS runner deployed" + else + log_error "Failed to deploy shared macOS runner" + FAILED=$((FAILED + 1)) + fi +fi + +# ========================================================================= +# Step 3: Deploy per-repo and shared Docker runners +# ========================================================================= +log_step 3 "Deploying Docker runners" + +# Phase 11 Docker runners (defined in runners.conf) +PHASE11_DOCKER_RUNNERS=( + unraid-go-node-1 + unraid-go-node-2 + unraid-go-node-3 + unraid-jvm-slim-1 + unraid-jvm-slim-2 + unraid-android-emulator +) + +for runner_name in "${PHASE11_DOCKER_RUNNERS[@]}"; do + log_info "--- Deploying runner: ${runner_name} ---" + if "${SCRIPT_DIR}/manage_runner.sh" add --name "$runner_name"; then + SUCCESS=$((SUCCESS + 1)) + else + log_error "Failed to deploy runner '${runner_name}'" + FAILED=$((FAILED + 1)) + fi +done + +# ========================================================================= +# Step 4: Set repository variables from repo_variables.conf +# ========================================================================= +log_step 4 "Setting Gitea repository variables" + +if [[ ! -f "$REPO_VARS_CONF" ]]; then + log_warn "repo_variables.conf not found — skipping variable setup" +else + # Iterate all sections (repos) in repo_variables.conf + while IFS= read -r repo; do + [[ -z "$repo" ]] && continue + log_info "--- Setting variables for repo: ${repo} ---" + + # Iterate all keys in this section + while IFS= read -r var_name; do + [[ -z "$var_name" ]] && continue + var_value=$(ini_get "$REPO_VARS_CONF" "$repo" "$var_name" "") + if [[ -z "$var_value" ]]; then + log_warn " ${var_name} has empty value — skipping" + continue + fi + upsert_repo_variable "$repo" "$var_name" "$var_value" || FAILED=$((FAILED + 1)) + done < <(ini_list_keys "$REPO_VARS_CONF" "$repo") + + done < <(ini_list_sections "$REPO_VARS_CONF") +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +printf '\n' +log_info "Results: ${SUCCESS} runners deployed, ${FAILED} failures" + +if [[ $FAILED -gt 0 ]]; then + log_error "Some operations failed — check logs above" + exit 1 +fi + +log_success "Phase 11 complete — custom runner infrastructure deployed" diff --git a/phase11_post_check.sh b/phase11_post_check.sh new file mode 100755 index 0000000..ee9b748 --- /dev/null +++ b/phase11_post_check.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================= +# phase11_post_check.sh — Verify custom runner infrastructure deployment +# Checks: +# 1. Toolchain images exist on Unraid +# 2. All phase 11 runners registered and online in Gitea +# 3. Shared macOS runner has correct labels +# 4. Repository variables set correctly +# 5. KVM available on Unraid (warning only) +# ============================================================================= + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCRIPT_DIR}/lib/common.sh" + +load_env +require_vars GITEA_ADMIN_TOKEN GITEA_INTERNAL_URL GITEA_ORG_NAME \ + UNRAID_IP UNRAID_SSH_USER UNRAID_SSH_PORT + +phase_header 11 "Custom Runners — Post-Check" + +REPO_VARS_CONF="${SCRIPT_DIR}/repo_variables.conf" +PASS=0 +FAIL=0 +WARN=0 + +run_check() { + local desc="$1" + shift + if "$@"; then + log_success "$desc" + PASS=$((PASS + 1)) + else + log_error "FAIL: $desc" + FAIL=$((FAIL + 1)) + fi +} + +run_warn_check() { + local desc="$1" + shift + if "$@"; then + log_success "$desc" + PASS=$((PASS + 1)) + else + log_warn "WARN: $desc" + WARN=$((WARN + 1)) + fi +} + +# ========================================================================= +# Check 1: Toolchain images exist on Unraid +# ========================================================================= +log_info "--- Checking toolchain images ---" + +check_image() { + local tag="$1" + ssh_exec "UNRAID" "docker image inspect '${tag}' >/dev/null 2>&1" +} + +run_check "Image go-node-runner:latest exists on Unraid" check_image "go-node-runner:latest" +run_check "Image jvm-android-runner:slim exists on Unraid" check_image "jvm-android-runner:slim" +run_check "Image jvm-android-runner:full exists on Unraid" check_image "jvm-android-runner:full" + +# ========================================================================= +# Check 2: All phase 11 runners registered and online +# ========================================================================= +log_info "--- Checking runner status ---" + +# Fetch all runners from Gitea admin API (single call) +ALL_RUNNERS=$(gitea_api GET "/admin/runners" 2>/dev/null || echo "[]") + +check_runner_online() { + local name="$1" + local status + status=$(printf '%s' "$ALL_RUNNERS" | jq -r --arg n "$name" \ + '[.[] | select(.name == $n)] | .[0].status // "not-found"' 2>/dev/null) + if [[ "$status" == "not-found" ]] || [[ -z "$status" ]]; then + log_error " Runner '${name}' not found in Gitea" + return 1 + fi + if [[ "$status" == "offline" ]] || [[ "$status" == "2" ]]; then + log_error " Runner '${name}' is offline" + return 1 + fi + return 0 +} + +PHASE11_RUNNERS=( + macbook-runner + unraid-go-node-1 + unraid-go-node-2 + unraid-go-node-3 + unraid-jvm-slim-1 + unraid-jvm-slim-2 + unraid-android-emulator +) + +for runner in "${PHASE11_RUNNERS[@]}"; do + run_check "Runner '${runner}' registered and online" check_runner_online "$runner" +done + +# ========================================================================= +# Check 3: Shared macOS runner has correct labels +# ========================================================================= +log_info "--- Checking macOS runner labels ---" + +check_mac_labels() { + local labels + labels=$(printf '%s' "$ALL_RUNNERS" | jq -r \ + '[.[] | select(.name == "macbook-runner")] | .[0].labels // [] | .[].name' 2>/dev/null) + local missing=0 + for expected in "self-hosted" "macOS" "ARM64"; do + if ! printf '%s' "$labels" | grep -qx "$expected" 2>/dev/null; then + log_error " macbook-runner missing label: ${expected}" + missing=1 + fi + done + return "$missing" +} + +run_check "macbook-runner has labels: self-hosted, macOS, ARM64" check_mac_labels + +# ========================================================================= +# Check 4: Repository variables set correctly +# ========================================================================= +log_info "--- Checking repository variables ---" + +check_repo_variable() { + local repo="$1" var_name="$2" expected="$3" + local owner="${GITEA_ORG_NAME}" + local response + if ! response=$(gitea_api GET "/repos/${owner}/${repo}/actions/variables/${var_name}" 2>/dev/null); then + log_error " Variable ${var_name} not found on ${repo}" + return 1 + fi + local actual + actual=$(printf '%s' "$response" | jq -r '.value // .data // empty' 2>/dev/null) + if [[ "$actual" != "$expected" ]]; then + log_error " Variable ${var_name} on ${repo}: expected '${expected}', got '${actual}'" + return 1 + fi + return 0 +} + +if [[ -f "$REPO_VARS_CONF" ]]; then + while IFS= read -r repo; do + [[ -z "$repo" ]] && continue + # Read all keys from the section using inline parsing + local_in_section=false + while IFS= read -r line; do + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [[ -z "$line" ]] && continue + [[ "$line" == \#* ]] && continue + if [[ "$line" =~ ^\[([^]]+)\] ]]; then + if [[ "${BASH_REMATCH[1]}" == "$repo" ]]; then + local_in_section=true + elif $local_in_section; then + break + fi + continue + fi + if $local_in_section && [[ "$line" =~ ^([^=]+)=(.*) ]]; then + k="${BASH_REMATCH[1]}" + v="${BASH_REMATCH[2]}" + k="${k#"${k%%[![:space:]]*}"}" + k="${k%"${k##*[![:space:]]}"}" + v="${v#"${v%%[![:space:]]*}"}" + v="${v%"${v##*[![:space:]]}"}" + run_check "Variable ${k} on ${repo}" check_repo_variable "$repo" "$k" "$v" + fi + done < "$REPO_VARS_CONF" + done < <(ini_list_sections "$REPO_VARS_CONF") +else + log_warn "repo_variables.conf not found — skipping variable checks" + WARN=$((WARN + 1)) +fi + +# ========================================================================= +# Check 5: KVM available on Unraid +# ========================================================================= +log_info "--- Checking KVM availability ---" + +check_kvm() { + ssh_exec "UNRAID" "test -c /dev/kvm" +} + +run_warn_check "KVM device available on Unraid (/dev/kvm)" check_kvm + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +printf '\n' +TOTAL=$((PASS + FAIL + WARN)) +log_info "Results: ${PASS} passed, ${FAIL} failed, ${WARN} warnings (out of ${TOTAL})" + +if [[ $FAIL -gt 0 ]]; then + log_error "Some checks failed — review above" + exit 1 +fi + +log_success "Phase 11 post-check complete" diff --git a/phase11_teardown.sh b/phase11_teardown.sh new file mode 100755 index 0000000..67aec56 --- /dev/null +++ b/phase11_teardown.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================= +# phase11_teardown.sh — Remove custom runner infrastructure deployed by phase 11 +# Reverses: +# 1. Repository variables +# 2. Docker runners (per-repo + shared emulator) +# 3. Shared macOS runner → restore original per-repo macOS runners +# 4. Toolchain images on Unraid +# ============================================================================= + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCRIPT_DIR}/lib/common.sh" + +load_env +require_vars GITEA_ADMIN_TOKEN GITEA_INTERNAL_URL GITEA_ORG_NAME + +phase_header 11 "Custom Runners — Teardown" + +REPO_VARS_CONF="${SCRIPT_DIR}/repo_variables.conf" +AUTO_YES=false + +for arg in "$@"; do + case "$arg" in + --yes|-y) AUTO_YES=true ;; + *) ;; + esac +done + +if [[ "$AUTO_YES" != "true" ]]; then + log_warn "This will remove all phase 11 custom runners and repo variables." + printf 'Continue? [y/N] ' >&2 + read -r confirm + if [[ "$confirm" != "y" ]] && [[ "$confirm" != "Y" ]]; then + log_info "Aborted" + exit 0 + fi +fi + +REMOVED=0 +FAILED=0 + +# ========================================================================= +# Step 1: Delete repository variables +# ========================================================================= +log_step 1 "Removing repository variables" + +if [[ -f "$REPO_VARS_CONF" ]]; then + while IFS= read -r repo; do + [[ -z "$repo" ]] && continue + log_info "--- Removing variables for repo: ${repo} ---" + + # Parse keys from section + in_section=false + while IFS= read -r line; do + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [[ -z "$line" ]] && continue + [[ "$line" == \#* ]] && continue + if [[ "$line" =~ ^\[([^]]+)\] ]]; then + if [[ "${BASH_REMATCH[1]}" == "$repo" ]]; then + in_section=true + elif $in_section; then + break + fi + continue + fi + if $in_section && [[ "$line" =~ ^([^=]+)= ]]; then + k="${BASH_REMATCH[1]}" + k="${k#"${k%%[![:space:]]*}"}" + k="${k%"${k##*[![:space:]]}"}" + if gitea_api DELETE "/repos/${GITEA_ORG_NAME}/${repo}/actions/variables/${k}" >/dev/null 2>&1; then + log_success " Deleted ${k} from ${repo}" + REMOVED=$((REMOVED + 1)) + else + log_warn " Could not delete ${k} from ${repo} (may not exist)" + fi + fi + done < "$REPO_VARS_CONF" + + done < <(ini_list_sections "$REPO_VARS_CONF") +else + log_info "repo_variables.conf not found — skipping" +fi + +# ========================================================================= +# Step 2: Remove Docker runners +# ========================================================================= +log_step 2 "Removing Docker runners" + +PHASE11_DOCKER_RUNNERS=( + unraid-go-node-1 + unraid-go-node-2 + unraid-go-node-3 + unraid-jvm-slim-1 + unraid-jvm-slim-2 + unraid-android-emulator +) + +for runner_name in "${PHASE11_DOCKER_RUNNERS[@]}"; do + log_info "Removing runner '${runner_name}'..." + if "${SCRIPT_DIR}/manage_runner.sh" remove --name "$runner_name" 2>/dev/null; then + log_success "Removed ${runner_name}" + REMOVED=$((REMOVED + 1)) + else + log_warn "Could not remove ${runner_name} (may not exist)" + fi +done + +# ========================================================================= +# Step 3: Remove shared macOS runner, restore original per-repo runners +# ========================================================================= +log_step 3 "Restoring original macOS runner configuration" + +# Remove shared runner +if launchctl list 2>/dev/null | grep -q "com.gitea.runner.macbook-runner"; then + log_info "Removing shared macOS runner 'macbook-runner'..." + "${SCRIPT_DIR}/manage_runner.sh" remove --name macbook-runner 2>/dev/null || true + REMOVED=$((REMOVED + 1)) +fi + +# Note: original per-repo macOS runner sections were replaced in runners.conf +# during phase 11. They need to be re-added manually or by re-running +# configure_runners.sh. This teardown only cleans up deployed resources. +log_info "Note: original macOS runner sections (macbook-runner-periodvault," +log_info " macbook-runner-intermittent-fasting-tracker) must be restored in" +log_info " runners.conf manually or via git checkout." + +# ========================================================================= +# Step 4: Remove toolchain images from Unraid +# ========================================================================= +log_step 4 "Removing toolchain images from Unraid" + +IMAGES_TO_REMOVE=( + "go-node-runner:latest" + "jvm-android-runner:slim" + "jvm-android-runner:full" +) + +for img in "${IMAGES_TO_REMOVE[@]}"; do + if ssh_exec "UNRAID" "docker rmi '${img}' 2>/dev/null"; then + log_success "Removed image ${img}" + REMOVED=$((REMOVED + 1)) + else + log_warn "Could not remove image ${img} (may not exist or in use)" + fi +done + +# ========================================================================= +# Step 5: Remove phase 11 runner sections from runners.conf +# ========================================================================= +log_step 5 "Cleaning runners.conf" + +RUNNERS_CONF="${SCRIPT_DIR}/runners.conf" +PHASE11_SECTIONS=( + unraid-go-node-1 + unraid-go-node-2 + unraid-go-node-3 + unraid-jvm-slim-1 + unraid-jvm-slim-2 + unraid-android-emulator + macbook-runner +) + +for section in "${PHASE11_SECTIONS[@]}"; do + if ini_list_sections "$RUNNERS_CONF" | grep -qx "$section" 2>/dev/null; then + ini_remove_section "$RUNNERS_CONF" "$section" + log_success "Removed [${section}] from runners.conf" + REMOVED=$((REMOVED + 1)) + fi +done + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +printf '\n' +log_info "Results: ${REMOVED} items removed, ${FAILED} failures" + +if [[ $FAILED -gt 0 ]]; then + log_error "Some removals failed — check logs above" + exit 1 +fi + +log_success "Phase 11 teardown complete" diff --git a/phase7_5_nginx_to_caddy.sh b/phase7_5_nginx_to_caddy.sh index 380bad1..30aca1a 100755 --- a/phase7_5_nginx_to_caddy.sh +++ b/phase7_5_nginx_to_caddy.sh @@ -87,7 +87,7 @@ phase_header "7.5" "Nginx to Caddy Migration (Multi-domain)" # host|upstream|streaming(true/false)|body_limit|insecure_skip_verify(true/false) FULL_HOST_MAP=( - "ai.sintheus.com|http://192.168.1.82:8181|true|50MB|false" + "ai.sintheus.com|http://192.168.1.82:8181 http://192.168.1.83:8181|true|50MB|false" "photos.sintheus.com|http://192.168.1.222:2283|false|50GB|false" "fin.sintheus.com|http://192.168.1.233:8096|true||false" "disk.sintheus.com|http://192.168.1.52:80|false|20GB|false" @@ -95,11 +95,11 @@ FULL_HOST_MAP=( "plex.sintheus.com|http://192.168.1.111:32400|true||false" "sync.sintheus.com|http://192.168.1.119:8384|false||false" "syno.sintheus.com|https://100.108.182.16:5001|false||true" - "tower.sintheus.com|https://192.168.1.82:443|false||true" + "tower.sintheus.com|https://192.168.1.82:443 https://192.168.1.83:443|false||true" ) CANARY_HOST_MAP=( - "tower.sintheus.com|https://192.168.1.82:443|false||true" + "tower.sintheus.com|https://192.168.1.82:443 https://192.168.1.83:443|false||true" ) GITEA_ENTRY="${GITEA_DOMAIN}|http://${UNRAID_GITEA_IP}:3000|false||false" @@ -175,7 +175,11 @@ emit_site_block_standalone() { { echo "${host} {" - if [[ "$TLS_MODE" == "existing" ]]; then + if [[ "$TLS_MODE" == "cloudflare" ]]; then + echo " tls {" + echo " dns cloudflare {env.CF_API_TOKEN}" + echo " }" + elif [[ "$TLS_MODE" == "existing" ]]; then echo " tls ${SSL_CERT_PATH} ${SSL_KEY_PATH}" fi echo " encode zstd gzip" @@ -524,23 +528,33 @@ probe_http_code_ok() { probe_host_via_caddy() { local host="$1" upstream="$2" role="$3" + local max_attempts="${4:-5}" wait_secs="${5:-5}" local path="/" if [[ "$role" == "gitea_api" ]]; then path="/api/v1/version" fi - local tmp_body http_code + local tmp_body http_code attempt tmp_body=$(mktemp) - http_code=$(curl -sk --resolve "${host}:443:${UNRAID_CADDY_IP}" \ - -o "$tmp_body" -w "%{http_code}" "https://${host}${path}" 2>/dev/null || echo "000") - if probe_http_code_ok "$http_code" "$role"; then - log_success "Probe passed: ${host} (HTTP ${http_code})" - rm -f "$tmp_body" - return 0 - fi + for (( attempt=1; attempt<=max_attempts; attempt++ )); do + http_code=$(curl -sk --resolve "${host}:443:${UNRAID_CADDY_IP}" \ + -o "$tmp_body" -w "%{http_code}" "https://${host}${path}" 2>/dev/null) || true + [[ -z "$http_code" ]] && http_code="000" - log_error "Probe failed: ${host} (HTTP ${http_code})" + if probe_http_code_ok "$http_code" "$role"; then + log_success "Probe passed: ${host} (HTTP ${http_code})" + rm -f "$tmp_body" + return 0 + fi + + if [[ $attempt -lt $max_attempts ]]; then + log_info "Probe attempt ${attempt}/${max_attempts} for ${host} (HTTP ${http_code}) — retrying in ${wait_secs}s..." + sleep "$wait_secs" + fi + done + + log_error "Probe failed: ${host} (HTTP ${http_code}) after ${max_attempts} attempts" if [[ "$http_code" == "502" || "$http_code" == "503" || "$http_code" == "504" || "$http_code" == "000" ]]; then local upstream_probe_raw upstream_code upstream_probe_raw=$(ssh_exec UNRAID "curl -sk -o /dev/null -w '%{http_code}' '${upstream}' || true" 2>/dev/null || true) diff --git a/phase8_cutover.sh b/phase8_cutover.sh index c5f5c84..018ca51 100755 --- a/phase8_cutover.sh +++ b/phase8_cutover.sh @@ -16,6 +16,31 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" source "${SCRIPT_DIR}/lib/common.sh" +ALLOW_DIRECT_CHECKS=false + +usage() { + cat < 0 && substr(dom, dot_pos) == wild_suffix) return 1 + } + return 0 + } { line = $0 if (line ~ /^[[:space:]]*#/) next @@ -248,7 +283,7 @@ caddyfile_has_domain_block() { gsub(/[[:space:]]+/, "", labels) n = split(labels, parts, ",") for (i = 1; i <= n; i++) { - if (parts[i] == domain) { + if (matches_domain(parts[i], domain)) { found = 1 } } @@ -363,7 +398,6 @@ fi log_step 2 "Deploying Caddyfile..." GITEA_CONTAINER_IP="${UNRAID_GITEA_IP}" export GITEA_CONTAINER_IP GITEA_DOMAIN CADDY_DOMAIN -CADDYFILE_UPDATED=0 # Build TLS block based on TLS_MODE if [[ "$TLS_MODE" == "cloudflare" ]]; then @@ -404,7 +438,6 @@ if ssh_exec UNRAID "test -f '${CADDY_DATA_PATH}/Caddyfile'" 2>/dev/null; then cat "$TMP_UPDATED" "$TMP_ROUTE_BLOCK" > "${TMP_UPDATED}.final" scp_to UNRAID "${TMP_UPDATED}.final" "${CADDY_DATA_PATH}/Caddyfile" log_success "Appended managed Gitea route to existing Caddyfile" - CADDYFILE_UPDATED=1 fi rm -f "$TMP_EXISTING" "$TMP_UPDATED" "$TMP_ROUTE_BLOCK" "${TMP_UPDATED}.final" @@ -416,7 +449,6 @@ else scp_to UNRAID "$TMPFILE" "${CADDY_DATA_PATH}/Caddyfile" rm -f "$TMPFILE" log_success "Caddyfile deployed" - CADDYFILE_UPDATED=1 fi # --------------------------------------------------------------------------- @@ -505,12 +537,18 @@ fi # --------------------------------------------------------------------------- log_step 6 "Waiting for HTTPS (Caddy auto-provisions cert)..." check_unraid_gitea_backend -if wait_for_https_public "${GITEA_DOMAIN}" 30; then +if wait_for_https_public "${GITEA_DOMAIN}" 60; then log_success "HTTPS verified through current domain routing — https://${GITEA_DOMAIN} works" else log_warn "Public-domain routing to Caddy is not ready yet" - wait_for_https_via_resolve "${GITEA_DOMAIN}" "${UNRAID_CADDY_IP}" 300 - log_success "HTTPS verified via direct Caddy path; public routing can be completed later" + if [[ "$ALLOW_DIRECT_CHECKS" == "true" ]]; then + wait_for_https_via_resolve "${GITEA_DOMAIN}" "${UNRAID_CADDY_IP}" 300 + log_warn "Proceeding with direct-only HTTPS validation (--allow-direct-checks)" + else + log_error "Refusing to continue cutover without public HTTPS reachability" + log_error "Fix DNS/ingress routing and rerun Phase 8, or use --allow-direct-checks for staging only" + exit 1 + fi fi # --------------------------------------------------------------------------- diff --git a/phase8_post_check.sh b/phase8_post_check.sh index 5511a70..1324d99 100755 --- a/phase8_post_check.sh +++ b/phase8_post_check.sh @@ -15,6 +15,31 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" source "${SCRIPT_DIR}/lib/common.sh" +ALLOW_DIRECT_CHECKS=false + +usage() { + cat </dev/null; then - ACCESS_MODE="direct" log_warn "Public routing to ${GITEA_DOMAIN} not reachable from control plane" - log_warn "Using direct Caddy-IP checks via --resolve (${UNRAID_CADDY_IP})" + if [[ "$ALLOW_DIRECT_CHECKS" == "true" ]]; then + ACCESS_MODE="direct" + log_warn "Using direct Caddy-IP checks via --resolve (${UNRAID_CADDY_IP})" + else + log_error "Public HTTPS check failed; this is not a complete Phase 8 validation" + log_error "Fix DNS/ingress routing and rerun, or use --allow-direct-checks for staging-only checks" + exit 1 + fi else log_info "Using public-domain checks for ${GITEA_DOMAIN}" fi diff --git a/repo_variables.conf.example b/repo_variables.conf.example new file mode 100644 index 0000000..92394ba --- /dev/null +++ b/repo_variables.conf.example @@ -0,0 +1,20 @@ +# ============================================================================= +# repo_variables.conf — Gitea Actions Repository Variables (INI format) +# Copy to repo_variables.conf and edit. +# Used by phase11_custom_runners.sh to set per-repo CI dispatch variables. +# ============================================================================= +# +# Each [section] = Gitea repository name (must exist in GITEA_ORG_NAME). +# Keys = variable names. Values = literal string set via Gitea API. +# Workflows access these as ${{ vars.VARIABLE_NAME }}. +# +# Common pattern: repos use fromJSON(vars.CI_RUNS_ON || '["ubuntu-latest"]') +# in runs-on to dynamically select runners. + +#[my-go-repo] +#CI_RUNS_ON = ["self-hosted","Linux","X64"] + +#[my-mobile-repo] +#CI_RUNS_ON = ["self-hosted","Linux","X64"] +#CI_RUNS_ON_MACOS = ["self-hosted","macOS","ARM64"] +#CI_RUNS_ON_ANDROID = ["self-hosted","Linux","X64","android-emulator"] diff --git a/run_all.sh b/run_all.sh index 0bcff47..c48e900 100755 --- a/run_all.sh +++ b/run_all.sh @@ -3,11 +3,11 @@ set -euo pipefail # ============================================================================= # run_all.sh — Orchestrate the full Gitea migration pipeline -# Runs: setup → preflight → phase 1-9 (each with post-check) sequentially. +# Runs: setup → preflight → phase 1-11 (each with post-check) sequentially. # Stops on first failure, prints summary of what completed. # # Usage: -# ./run_all.sh # Full run: setup + preflight + phases 1-9 +# ./run_all.sh # Full run: setup + preflight + phases 1-11 # ./run_all.sh --skip-setup # Skip setup scripts, start at preflight # ./run_all.sh --start-from=3 # Run preflight, then start at phase 3 # ./run_all.sh --skip-setup --start-from=5 @@ -28,10 +28,12 @@ require_local_os "Darwin" "run_all.sh must run from macOS (the control plane)" SKIP_SETUP=false START_FROM=0 START_FROM_SET=false +ALLOW_DIRECT_CHECKS=false for arg in "$@"; do case "$arg" in --skip-setup) SKIP_SETUP=true ;; + --allow-direct-checks) ALLOW_DIRECT_CHECKS=true ;; --dry-run) exec "${SCRIPT_DIR}/post-migration-check.sh" ;; @@ -39,11 +41,11 @@ for arg in "$@"; do START_FROM="${arg#*=}" START_FROM_SET=true if ! [[ "$START_FROM" =~ ^[0-9]+$ ]]; then - log_error "--start-from must be a number (1-9)" + log_error "--start-from must be a number (1-11)" exit 1 fi - if [[ "$START_FROM" -lt 1 ]] || [[ "$START_FROM" -gt 9 ]]; then - log_error "--start-from must be between 1 and 9" + if [[ "$START_FROM" -lt 1 ]] || [[ "$START_FROM" -gt 11 ]]; then + log_error "--start-from must be between 1 and 11" exit 1 fi ;; @@ -52,16 +54,19 @@ for arg in "$@"; do Usage: $(basename "$0") [options] Options: - --skip-setup Skip configure_env + machine setup, start at preflight - --start-from=N Skip phases before N (still runs preflight) - --dry-run Run read-only infrastructure check (no mutations) - --help Show this help + --skip-setup Skip configure_env + machine setup, start at preflight + --start-from=N Skip phases before N (still runs preflight) + --allow-direct-checks Pass --allow-direct-checks to Phase 8 scripts + (LAN/split-DNS staging mode) + --dry-run Run read-only infrastructure check (no mutations) + --help Show this help Examples: - $(basename "$0") Full run - $(basename "$0") --skip-setup Skip setup, start at preflight - $(basename "$0") --start-from=3 Run preflight, then phases 3-9 - $(basename "$0") --dry-run Check current state without changing anything + $(basename "$0") Full run + $(basename "$0") --skip-setup Skip setup, start at preflight + $(basename "$0") --start-from=3 Run preflight, then phases 3-11 + $(basename "$0") --allow-direct-checks LAN mode: use direct Caddy-IP checks + $(basename "$0") --dry-run Check current state without changing anything EOF exit 0 ;; *) log_error "Unknown argument: $arg"; exit 1 ;; @@ -157,7 +162,7 @@ else fi # --------------------------------------------------------------------------- -# Phases 1-9 — run sequentially, each followed by its post-check +# Phases 1-11 — run sequentially, each followed by its post-check # The phase scripts are the "do" step, post-checks verify success. # --------------------------------------------------------------------------- PHASES=( @@ -170,6 +175,8 @@ PHASES=( "7|Phase 7: Branch Protection|phase7_branch_protection.sh|phase7_post_check.sh" "8|Phase 8: Cutover|phase8_cutover.sh|phase8_post_check.sh" "9|Phase 9: Security|phase9_security.sh|phase9_post_check.sh" + "10|Phase 10: Local Repo Cutover|phase10_local_repo_cutover.sh|phase10_post_check.sh" + "11|Phase 11: Custom Runners|phase11_custom_runners.sh|phase11_post_check.sh" ) for phase_entry in "${PHASES[@]}"; do @@ -181,8 +188,14 @@ for phase_entry in "${PHASES[@]}"; do continue fi - run_step "$phase_name" "$phase_script" - run_step "${phase_name} — post-check" "$post_check" + # Phase 8 scripts accept --allow-direct-checks for LAN/split-DNS setups. + if [[ "$phase_num" -eq 8 ]] && [[ "$ALLOW_DIRECT_CHECKS" == "true" ]]; then + run_step "$phase_name" "$phase_script" --allow-direct-checks + run_step "${phase_name} — post-check" "$post_check" --allow-direct-checks + else + run_step "$phase_name" "$phase_script" + run_step "${phase_name} — post-check" "$post_check" + fi done # --------------------------------------------------------------------------- diff --git a/runners.conf.example b/runners.conf.example index 6986dc9..5df4da1 100644 --- a/runners.conf.example +++ b/runners.conf.example @@ -55,6 +55,11 @@ # (starts at login, no sudo needed). # Ignored for docker runners. # +# container_options — Extra Docker flags for act_runner job containers. +# Passed to the container.options field in act_runner config. +# e.g. "--device=/dev/kvm" for KVM passthrough. +# Empty = no extra flags. Ignored for native runners. +# # STARTER ENTRIES (uncomment and edit): #[unraid-runner] diff --git a/teardown_all.sh b/teardown_all.sh index f81e5f3..9cb5c3b 100755 --- a/teardown_all.sh +++ b/teardown_all.sh @@ -3,11 +3,11 @@ set -euo pipefail # ============================================================================= # teardown_all.sh — Tear down migration in reverse order -# Runs phase teardown scripts from phase 9 → phase 1 (or a subset). +# Runs phase teardown scripts from phase 11 → phase 1 (or a subset). # # Usage: -# ./teardown_all.sh # Tear down everything (phases 9 → 1) -# ./teardown_all.sh --through=5 # Tear down phases 9 → 5 (leave 1-4) +# ./teardown_all.sh # Tear down everything (phases 11 → 1) +# ./teardown_all.sh --through=5 # Tear down phases 11 → 5 (leave 1-4) # ./teardown_all.sh --yes # Skip confirmation prompts # ============================================================================= @@ -25,8 +25,8 @@ for arg in "$@"; do case "$arg" in --through=*) THROUGH="${arg#*=}" - if ! [[ "$THROUGH" =~ ^[0-9]+$ ]] || [[ "$THROUGH" -lt 1 ]] || [[ "$THROUGH" -gt 9 ]]; then - log_error "--through must be a number between 1 and 9" + if ! [[ "$THROUGH" =~ ^[0-9]+$ ]] || [[ "$THROUGH" -lt 1 ]] || [[ "$THROUGH" -gt 11 ]]; then + log_error "--through must be a number between 1 and 11" exit 1 fi ;; @@ -37,14 +37,14 @@ for arg in "$@"; do Usage: $(basename "$0") [options] Options: - --through=N Only tear down phases N through 9 (default: 1 = everything) + --through=N Only tear down phases N through 11 (default: 1 = everything) --cleanup Also run setup/cleanup.sh to uninstall setup prerequisites --yes, -y Skip all confirmation prompts --help Show this help Examples: $(basename "$0") Tear down everything - $(basename "$0") --through=5 Tear down phases 5-9, leave 1-4 + $(basename "$0") --through=5 Tear down phases 11-5, leave 1-4 $(basename "$0") --cleanup Full teardown + uninstall prerequisites $(basename "$0") --yes Non-interactive teardown EOF @@ -58,9 +58,9 @@ done # --------------------------------------------------------------------------- if [[ "$AUTO_YES" == "false" ]]; then if [[ "$THROUGH" -eq 1 ]]; then - log_warn "This will tear down ALL phases (9 → 1)." + log_warn "This will tear down ALL phases (11 → 1)." else - log_warn "This will tear down phases 9 → ${THROUGH}." + log_warn "This will tear down phases 11 → ${THROUGH}." fi printf 'Are you sure? [y/N] ' read -r confirm @@ -70,9 +70,11 @@ if [[ "$AUTO_YES" == "false" ]]; then fi fi -# Teardown scripts in reverse order (9 → 1) +# Teardown scripts in reverse order (11 → 1) # Each entry: phase_num|script_path TEARDOWNS=( + "11|phase11_teardown.sh" + "10|phase10_teardown.sh" "9|phase9_teardown.sh" "8|phase8_teardown.sh" "7|phase7_teardown.sh" diff --git a/templates/runner-config.yaml.tpl b/templates/runner-config.yaml.tpl index 472898c..786fa02 100644 --- a/templates/runner-config.yaml.tpl +++ b/templates/runner-config.yaml.tpl @@ -1,5 +1,5 @@ # act_runner configuration — rendered by manage_runner.sh -# Variables: RUNNER_NAME, RUNNER_LABELS_YAML, RUNNER_CAPACITY +# Variables: RUNNER_NAME, RUNNER_LABELS_YAML, RUNNER_CAPACITY, RUNNER_CONTAINER_OPTIONS # Deployed alongside docker-compose.yml (docker) or act_runner binary (native). log: @@ -22,7 +22,7 @@ cache: container: network: "" # Empty = use default Docker network. privileged: false # Never run job containers as privileged. - options: + options: ${RUNNER_CONTAINER_OPTIONS} workdir_parent: host: diff --git a/toggle_dns.sh b/toggle_dns.sh new file mode 100755 index 0000000..775152a --- /dev/null +++ b/toggle_dns.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Toggle DNS between Pi-hole and Cloudflare on all active network services. +# Usage: ./toggle_dns.sh +# Requires sudo for networksetup. + +PIHOLE="pi.sintheus.com" +CLOUDFLARE="1.1.1.1" + +# Get all hardware network services (Wi-Fi, Ethernet, Thunderbolt, USB, etc.) +services=() +while IFS= read -r line; do + [[ "$line" == *"*"* ]] && continue # skip disabled services + services+=("$line") +done < <(networksetup -listallnetworkservices 2>/dev/null | tail -n +2) + +if [[ ${#services[@]} -eq 0 ]]; then + echo "No network services found" + exit 1 +fi + +# Detect current mode from the first service that has a DNS set +current_dns="" +for svc in "${services[@]}"; do + dns=$(networksetup -getdnsservers "$svc" 2>/dev/null | head -1) + if [[ "$dns" != *"aren't any"* ]] && [[ -n "$dns" ]]; then + current_dns="$dns" + break + fi +done + +if [[ "$current_dns" == "$CLOUDFLARE" ]]; then + target="$PIHOLE" + label="Pi-hole" +else + target="$CLOUDFLARE" + label="Cloudflare" +fi + +echo "Switching all services to ${label} (${target})..." +for svc in "${services[@]}"; do + sudo networksetup -setdnsservers "$svc" "$target" + echo " ${svc} → ${target}" +done + +sudo dscacheutil -flushcache +sudo killall -HUP mDNSResponder 2>/dev/null || true +echo "DNS set to ${label} (${target})"