diff --git a/assets/css/typography.css b/assets/css/typography.css index 7d60ba7..eb2e8d0 100644 --- a/assets/css/typography.css +++ b/assets/css/typography.css @@ -19,9 +19,13 @@ h1, h2, h3, h4, h5, h6 { } h1 { font-size: clamp(2rem, 3vw, 2.5rem); } + h2 { font-size: clamp(1.625rem, 2.4vw, 2rem); } + h3 { font-size: clamp(1.375rem, 2vw, 1.625rem); } +.card h3 { text-transform: capitalize; } summary h3 { display: inline-block; } + h4 { font-size: clamp(1.25rem, 1.8vw, 1.375rem); } p { margin: 0 0 0.875rem; color: var(--text); } diff --git a/assets/files/gitea/forge b/assets/files/gitea/forge new file mode 100644 index 0000000..1476355 --- /dev/null +++ b/assets/files/gitea/forge @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +set -euo pipefail + +# =========================== +# SoloForge Toolkit (forge) +# =========================== +# +# Handy commands for managing: +# - Gitea (SoloForge) +# - Gitea Actions runner +# - Backups +# +# Paths: adjust if you move things. +GITEA_DIR="/gitea" +RUNNER_DIR="/gitea/gitea-runner" +BACKUP_DIR="/gitea/backups" + +GITEA_COMPOSE="$GITEA_DIR/docker-compose.yml" +RUNNER_COMPOSE="$RUNNER_DIR/docker-compose.yml" + +GITEA_CONTAINER_NAME="Gitea" +RUNNER_CONTAINER_NAME="gitea-act-runner" + +# Prefer `docker compose`, fall back to `docker-compose` if needed +dc() { + if command -v docker >/dev/null 2>&1; then + if docker compose version >/dev/null 2>&1; then + docker compose "$@" + elif command -v docker-compose >/dev/null 2>&1; then + docker-compose "$@" + else + echo "Error: neither 'docker compose' nor 'docker-compose' found." >&2 + exit 1 + fi + else + echo "Error: docker not found in PATH." >&2 + exit 1 + fi +} + +usage() { + cat </dev/null | head -n1 || true) + + if [[ -z "$dump" ]]; then + echo "Error: no gitea-dump-*.zip found under $GITEA_DIR/gitea" >&2 + exit 1 + fi + + local base + base=$(basename "$dump") + local dest="$BACKUP_DIR/$base" + + echo "Moving $dump -> $dest" + mv "$dump" "$dest" + + echo "Backup complete: $dest" +} + +cmd_restart_gitea() { + echo "Restarting Gitea stack (compose: $GITEA_COMPOSE)" + (cd "$GITEA_DIR" && dc -f "$GITEA_COMPOSE" down) + (cd "$GITEA_DIR" && dc -f "$GITEA_COMPOSE" up -d) + echo "Gitea stack restarted." +} + +cmd_restart_runner() { + echo "Restarting runner stack (compose: $RUNNER_COMPOSE)" + (cd "$RUNNER_DIR" && dc -f "$RUNNER_COMPOSE" down || true) + (cd "$RUNNER_DIR" && dc -f "$RUNNER_COMPOSE" up -d) + echo "Runner stack restarted." +} + +cmd_runner_reset() { + echo "Resetting runner registration..." + (cd "$RUNNER_DIR" && dc -f "$RUNNER_COMPOSE" down || true) + + local runner_state="$RUNNER_DIR/data/.runner" + if [[ -f "$runner_state" ]]; then + echo "Removing $runner_state" + rm -f "$runner_state" + else + echo "No existing .runner file found (nothing to delete)." + fi + + echo "Bringing runner stack up (will auto-register with current env)..." + (cd "$RUNNER_DIR" && dc -f "$RUNNER_COMPOSE" up -d) + + echo "Runner reset requested. Check runner logs via: forge runner-logs" +} + +cmd_diag() { + echo "== SoloForge diagnostic ==" + echo + echo "-- Paths --" + echo "GITEA_DIR = $GITEA_DIR" + echo "RUNNER_DIR = $RUNNER_DIR" + echo "BACKUP_DIR = $BACKUP_DIR" + echo + + echo "-- Containers --" + docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' \ + | grep -E "$GITEA_CONTAINER_NAME|$RUNNER_CONTAINER_NAME" || echo " (no Gitea/runner containers found)" + echo + + echo "-- Runner .runner file --" + if [[ -f "$RUNNER_DIR/data/.runner" ]]; then + echo "Found: $RUNNER_DIR/data/.runner" + else + echo "No .runner file present (runner may not be registered)." + fi + echo + + echo "-- Latest backup --" + if [[ -d "$BACKUP_DIR" ]]; then + local latest + latest=$(ls -1t "$BACKUP_DIR"/gitea-dump-*.zip 2>/dev/null | head -n1 || true) + if [[ -n "$latest" ]]; then + echo " $latest" + else + echo " No backups found in $BACKUP_DIR" + fi + else + echo " Backup dir does not exist: $BACKUP_DIR" + fi +} + +# =========================== +# Dispatch +# =========================== + +cmd="${1:-help}" +shift || true + +case "$cmd" in + status|ps) + cmd_status + ;; + gitea-logs) + cmd_gitea_logs + ;; + runner-logs) + cmd_runner_logs + ;; + backup) + cmd_backup + ;; + restart-gitea) + cmd_restart_gitea + ;; + restart-runner) + cmd_restart_runner + ;; + runner-reset) + cmd_runner_reset + ;; + diag) + cmd_diag + ;; + help|--help|-h) + usage + ;; + *) + echo "Unknown command: $cmd" >&2 + echo + usage + exit 1 + ;; +esac diff --git a/assets/files/gitea/git-bulk b/assets/files/gitea/git-bulk new file mode 100644 index 0000000..d95619a --- /dev/null +++ b/assets/files/gitea/git-bulk @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ---- Config: set these at the top ---- +GITEA_BASE="https://git.keithsolomon.net" +GITEA_USER="keith" +GITEA_TOKEN="f4b01fa50f56271f8ba002221fb68404de955077" +REMOTE_NAME="origin" # which remote to use/push +SEARCH_ROOT="${1:-$PWD}" # default to current dir if not provided + +# sanity checks +command -v jq >/dev/null 2>&1 || { echo "jq is required. Install with: sudo apt install jq"; exit 1; } + +: "${GITEA_BASE:?Need GITEA_BASE}" +: "${GITEA_USER:?Need GITEA_USER}" +: "${GITEA_TOKEN:?Need GITEA_TOKEN}" + +SEARCH_ROOT="$(realpath "$SEARCH_ROOT")" + +echo "Scanning for git repos under: $SEARCH_ROOT" +echo + +# Use -print0 to safely handle spaces/newlines in paths +find "$SEARCH_ROOT" -type d -name ".git" -print0 | while IFS= read -r -d '' gitdir; do + # Absolute repo dir + repo_dir="$(dirname "$gitdir")" + repo_dir="$(realpath "$repo_dir")" + + echo "=== $repo_dir ===" + + # Run everything for this repo in a subshell so cd doesn't affect the outer loop + ( + cd "$repo_dir" || exit 0 + + # Get origin URL (or skip if none) + if ! url=$(git remote get-url "$REMOTE_NAME" 2>/dev/null); then + echo " -> Skipping (no '$REMOTE_NAME' remote)" + exit 0 + fi + + # Only migrate repos that point (or pointed) at git.keithsolomon.net + if [[ "$url" != *"git.keithsolomon.net"* ]]; then + echo " -> Skipping (origin is not git.keithsolomon.net)" + exit 0 + fi + + # Derive repo name: strip .git and take basename + clean_url="${url%.git}" + repo_name="$(basename "$clean_url")" + + echo " -> Repo name on Gitea: $repo_name" + + # Create repo via Gitea API (idempotent: 201 = created, 409 = already exists) + create_payload=$(jq -n \ + --arg name "$repo_name" \ + --arg desc "Imported from local clone at $repo_dir" \ + '{name: $name, private: true, description: $desc}') + + http_status=$( + curl -sS -o /tmp/gitea-create-repo.out \ + -w "%{http_code}" \ + -X POST "$GITEA_BASE/api/v1/user/repos" \ + -H "Content-Type: application/json" \ + -H "Authorization: token $GITEA_TOKEN" \ + -d "$create_payload" || echo "000" + ) + + if [[ "$http_status" == "201" ]]; then + echo " -> Created repo on Gitea" + elif [[ "$http_status" == "409" ]]; then + echo " -> Repo already exists on Gitea, continuing" + else + echo " !! Unexpected HTTP status $http_status creating repo, output:" + cat /tmp/gitea-create-repo.out + echo " !! Skipping push for $repo_dir" + echo + exit 0 + fi + + # Make sure origin URL points at the new Gitea HTTPS remote + new_url="$GITEA_BASE/$GITEA_USER/$repo_name.git" + git remote set-url "$REMOTE_NAME" "$new_url" 2>/dev/null || git remote add "$REMOTE_NAME" "$new_url" + + echo " -> Pushing all branches..." + git push --all "$REMOTE_NAME" || echo " !! Failed pushing branches" + echo " -> Pushing tags..." + git push --tags "$REMOTE_NAME" || echo " !! Failed pushing tags" + echo + ) +done + +echo "Done." diff --git a/assets/files/gitea/git-flip b/assets/files/gitea/git-flip new file mode 100644 index 0000000..f93854f --- /dev/null +++ b/assets/files/gitea/git-flip @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +GITEA_BASE="https://git.keithsolomon.net" +GITEA_USER="keith" # your username +GITEA_TOKEN="f4b01fa50f56271f8ba002221fb68404de955077" + +echo "Fetching repos for user: $GITEA_USER" +repos=$(curl -sS \ + -H "Authorization: token $GITEA_TOKEN" \ + "$GITEA_BASE/api/v1/users/$GITEA_USER/repos" | jq -r '.[].name') + +for repo in $repos; do + echo "Setting $repo → public..." + + curl -sS -X PATCH \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"private":false}' \ + "$GITEA_BASE/api/v1/repos/$GITEA_USER/$repo" \ + >/dev/null + + echo "✓ $repo is now public" +done + +echo "Done." diff --git a/assets/files/gitea/gitea-docker-compose.yml b/assets/files/gitea/gitea-docker-compose.yml new file mode 100644 index 0000000..e7424a3 --- /dev/null +++ b/assets/files/gitea/gitea-docker-compose.yml @@ -0,0 +1,65 @@ +services: + gitea-db: + container_name: Gitea-db + restart: unless-stopped + image: postgres:16 + + environment: + POSTGRES_USER: gitea + POSTGRES_PASSWORD: + POSTGRES_DB: gitea + + healthcheck: + test: ["CMD-SHELL", "pg_isready -U gitea"] + interval: 10s + timeout: 5s + retries: 5 + + networks: + - frontend + + volumes: + - /gitea/postgres:/var/lib/postgresql/data + + gitea: + container_name: Gitea + restart: unless-stopped + image: gitea/gitea:latest + + depends_on: + gitea-db: + condition: service_healthy + + environment: + USER_UID: 1000 + USER_GID: 1000 + GITEA__database__DB_TYPE: postgres + GITEA__database__HOST: gitea-db:5432 + GITEA__database__NAME: gitea + GITEA__database__USER: gitea + GITEA__database__PASSWD: + GITEA__server__DOMAIN: git.keithsolomon.net + GITEA__server__ROOT_URL: https://git.keithsolomon.net/ + GITEA__server__SSH_PORT: 222 + GITEA__log__MODE: console + + volumes: + - /gitea/gitea:/data + + networks: + - frontend + + ports: + - "3000:3000" # HTTP web UI + - "222:22" # SSH for git over SSH + + labels: + - "traefik.enable=true" + - "traefik.docker.network=frontend" + - "traefik.http.routers.gitea.rule=Host(`git.keithsolomon.net`)" + - "traefik.http.services.gitea.loadbalancer.server.port=3000" # Gitea's default web port + +networks: + frontend: + external: true + name: frontend diff --git a/assets/files/gitea/gitea-runner-docker-compose.yml b/assets/files/gitea/gitea-runner-docker-compose.yml new file mode 100644 index 0000000..cc25777 --- /dev/null +++ b/assets/files/gitea/gitea-runner-docker-compose.yml @@ -0,0 +1,20 @@ +services: + runner: + container_name: gitea-act-runner + restart: unless-stopped + image: gitea/act_runner:latest + + working_dir: /data + + environment: + GITEA_INSTANCE_URL: "https://git.keithsolomon.net" + GITEA_RUNNER_REGISTRATION_TOKEN: "vmdpAbiuPoaX8pEmzZkJjUJpIlI4iIIlXGGEBU5n" + GITEA_RUNNER_NAME: "hetzner-runner-1" + GITEA_RUNNER_LABELS: "ubuntu-latest:docker://node:20-bullseye,self-hosted,linux,x86_64,docker" + TZ: "America/Winnipeg" + + command: ["act_runner", "daemon"] + + volumes: + - /gitea/gitea-runner/data:/data + - /var/run/docker.sock:/var/run/docker.sock diff --git a/content/soloforge-docs.md b/content/soloforge-docs.md new file mode 100644 index 0000000..21341fb --- /dev/null +++ b/content/soloforge-docs.md @@ -0,0 +1,254 @@ +--- +title: SoloForge Documentation +section: docs +summary: SoloForge Infrastructure Documentation for Self-Hosted Gitea with Actions Runner on Hetzner +tags: [servers, infrasctructure, gitea, ci/cd, hetzner, docker] +nav: 1 +--- + +## Self-Hosted Gitea + Actions Runner (Hetzner Deployment) + +This document describes the current SoloForge setup — Gitea, Traefik routing, the Gitea Actions runner, backup routines, directory layout, and operational notes. + +This repo exists so future-me (and actual-me) don’t need to reverse-engineer anything when something eventually explodes. + +## 1. Overview + +SoloForge is a self-hosted Gitea instance running on a Hetzner VM. + +It provides: + +- Private (but login-protected) Git hosting +- Public-visible repositories (instance auth is required anyway) +- Gitea Actions, backed by a Docker-based self-hosted runner +- Automatic CI for TODO-to-issue sync and other workflows +- Reverse-proxy via Traefik +- Automated Gitea backups + +The system replaces the original Proxmox-hosted Gitea instance lost due to disk failure. + +--- + +## 2. System Layout + +### Hetzner VM + +- Debian 13 +- Docker + Docker Compose installed +- Traefik reverse proxy (existing before SoloForge migration) +- HTTPS termination handled by Traefik via Let’s Encrypt + +### Directory structure + +```bash +/gitea/ +├── gitea/ # Gitea app + persistent data +├── postgres/ # PostgreSQL data directory (if using Postgres) +└── docker-compose.yml # Main Gitea stack +``` + +### Runner lives separately + +```bash +/gitea/gitea-runner/ +├── docker-compose.yml # Actions runner stack +└── data/ # Contains .runner registration + job cache +``` + +--- + +## 3. Gitea Deployment + +### 3.1 Gitea Compose Service + +Gitea is launched via Docker Compose and reverse-proxied through Traefik. +Data lives under `/gitea/gitea` to ensure persistence. + +### 3.2 Traefik Routing + +Traefik handles: + +- HTTPS certificate generation +- Routing git.keithsolomon.net → Gitea web UI +- Exposing SSH port (222) for git-over-SSH + +No YAML generator required anymore — everything is stable and hand-maintained. + +--- + +## 4. Gitea Actions Runner + +SoloForge uses a self-hosted Gitea Actions runner, running via Docker and capable of executing JavaScript (Node-based) GitHub-style actions. + +### 4.1 Runner compose file + +Located at: + +`/gitea/gitea-runner/docker-compose.yml` + +Core configuration: + +```yaml +environment: + GITEA_INSTANCE_URL: "https://git.keithsolomon.net" + GITEA_RUNNER_REGISTRATION_TOKEN: "" + GITEA_RUNNER_NAME: "hetzner-runner-1" + GITEA_RUNNER_LABELS: "ubuntu-latest:docker://node:20-bullseye,self-hosted,linux,x86_64,docker" +``` + +By default, GitHub runners provide Node.js preinstalled. +Self-hosted runners do NOT. + +Mapping: + +`ubuntu-latest:docker://node:20-bullseye` + +ensures any workflow using: + +```yaml +runs-on: ubuntu-latest +``` + +runs inside a Node-enabled container, fixing "node: command not found" errors. + +### 4.3 Re-registering the runner (important!) + +If labels change or the runner breaks: + +```bash +cd /gitea/gitea-runner +docker compose down +rm -f data/.runner # Forces new registration +docker compose up -d # Registers with current labels +``` + +Check runner status in Gitea: + +**Site Admin → Actions → Runners** + +--- + +## 5. Workflows + +### 5.1 TODO-to-Issue Sync + +Certain repos use a custom JavaScript action to: + +- Parse TODO comments +- Generate/close GitHub-style issues inside Gitea + +These workflows run cleanly now because: + +- The runner supports Node (ubuntu-latest → node:20 container) +- Repository permissions allow issue writing + +### 5.2 Secret tokens + +Unlike GitHub, Gitea does not auto-inject GITHUB_TOKEN. +Workflows requiring an auth token need one defined manually in: + +**Repo → Settings → Secrets** + +Example: + +`GITHUB_TOKEN = ` + +(Or rename to something more Gitea-themed.) + +--- + +## 6. Repository Management + +### 6.1 Bulk import + +All repos were migrated using a [custom bulk-mirror script](/assets/files/gitea/git-bulk) that: + +- Created missing repos via the Gitea API +- Pushed full history via git push --all and --tags + +### 6.2 Public visibility + +All repos are public (since Gitea login protects everything). +A [bulk-update script](/assets/files/gitea/git-flip) is available to flip visibility via API if needed. + +--- + +## 7. Backups + +Gitea supports built-in dumps via: + +`gitea dump` + +A cronjob is installed to dump nightly at 3am: + +```bash +/gitea-backups/ +└── gitea-dump-YYYYMMDD.zip +``` + +Recommended: sync this folder offsite or back to home lab. + +--- + +## 8. Restore Notes + +If Gitea must be restored from dump: + +```bash +docker compose down +rm -rf gitea/* postgres/* +unzip gitea-dump.zip into /gitea/gitea +docker compose up -d +``` + +If the runner needs re-registration, follow section 4.3. + +--- + +## 9. Future Improvements (Optional) + +- Mirror “source of truth” repos between GitHub ↔ Gitea +- Add automated org-level secrets +- Configure multiple runners (home lab, Hetzner, etc.) +- Add Prometheus metrics + Grafana board for CI activity +- Set up Gitea’s dependency listing or vulnerability scanning + +--- + +## 10. `forge` CLI Tool + +A custom CLI tool [`forge`](/assets/files/gitea/forge) exists to help manage common tasks: + +| Command | Description | +| --------------------------- | ---------------------------------------------------------- | +| `forge status` | Show status of Gitea and runner containers | +| `forge ps` | Alias for status | +| `forge gitea-logs` | Tail logs from Gitea container | +| `forge runner-logs` | Tail logs from Actions runner container | +| `forge backup` | Run a Gitea dump and move it into BACKUP_DIR | +| `forge restart-gitea` | Restart Gitea stack | +| `forge restart-runner` | Restart Actions runner stack | +| `forge runner-reset` | Re-register runner with current labels (destroys .runner) | +| `forge diag` | Quick diagnostic summary | + +## TL;DR Cheat Sheet + +### Runner broke? + +`→ delete data/.runner, docker compose up -d` + +### `node` not found? + +`→ ensure ubuntu-latest label is mapped to node:20-bullseye` + +### Release workflows failing? + +`→ they're GitHub-only; they run on GitHub mirrors` + +### Backup? + +`→ see /gitea-backups, nightly gitea dump` + +### Repo not found? + +`→ bulk import script: auto-create + push mirror`