diff --git a/docs/runbooks/GITEA_VM_SETUP.md b/docs/runbooks/GITEA_VM_SETUP.md index abe0c3fd..ace86480 100644 --- a/docs/runbooks/GITEA_VM_SETUP.md +++ b/docs/runbooks/GITEA_VM_SETUP.md @@ -445,8 +445,36 @@ bash scripts/gitea/add-host-runner.sh 3 2 - idempotent: re-running just reloads the service The Homebrew `act_runner` service is runner #1; `add-host-runner.sh` adds -#2, #3, … Three runners × capacity 2 ≈ **6 parallel job slots**. Verified: -pushing a multi-job workflow lights up all three runners simultaneously. +#2, #3, … Current fleet: 3 host runners × capacity 3 ≈ **9 parallel host +slots**. Verified: pushing a multi-job workflow distributes jobs across all +three runners simultaneously. + +**Add a docker-mode runner (stronger isolation):** + +```bash +# Pull the act image once (≈2.3 GB; works through the corp proxy): +docker pull catthehacker/ubuntu:act-latest + +# Stand up runner #4 in docker mode (capacity 1): +bash scripts/gitea/add-host-runner.sh 4 1 docker +``` + +Docker mode advertises a dedicated **`docker`** label (not `ubuntu-latest`), +so it does not hijack the host-mode `ubuntu-latest` jobs. Opt a job in with +`runs-on: docker`. The generated config: + +- sets `runner.labels` to `docker:docker://` (act_runner reads labels + from the config file, **not** `register --labels`) +- `container.docker_host: "-"`, `force_pull: false` (use the locally-pulled + image), `options: --add-host=host.docker.internal:host-gateway` +- **adds `host.docker.internal` to `NO_PROXY`/`no_proxy`** — without this, + containerized jobs inherit the corp proxy env and route + `host.docker.internal:3300` through the proxy, getting an HTTP 504. Jobs must + reach Gitea via `host.docker.internal:3300` (not `localhost`) from inside the + container. + +Validated end-to-end: a `runs-on: docker` job runs in an `Ubuntu 24.04` +container and reaches Gitea (`GET /api/v1/version` → `{"version":"…"}`). List + prune the fleet: diff --git a/scripts/gitea/add-host-runner.sh b/scripts/gitea/add-host-runner.sh index d7bdac9e..1cd7b52e 100755 --- a/scripts/gitea/add-host-runner.sh +++ b/scripts/gitea/add-host-runner.sh @@ -16,8 +16,13 @@ # - its own launchd plist ~/Library/LaunchAgents/com.bytelyst.act_runner-.plist # # Usage: -# bash add-host-runner.sh [capacity] # e.g. add-host-runner.sh 2 2 -# bash add-host-runner.sh 2 2 && bash add-host-runner.sh 3 2 +# bash add-host-runner.sh [capacity] [mode] # mode: host (default) | docker +# bash add-host-runner.sh 2 2 && bash add-host-runner.sh 3 2 # two host runners +# bash add-host-runner.sh 4 1 docker # a docker-mode runner +# +# Docker mode advertises a dedicated `docker` label (not `ubuntu-latest`) so it +# does not hijack existing host-mode jobs. Target it with `runs-on: docker` and +# reach Gitea from inside the job container via host.docker.internal:3300. # # Requires: act_runner on PATH, a Gitea admin PAT at ~/.gitea_c5_pat, # and the canonical runner.env to already exist (created during runner hardening). @@ -25,9 +30,11 @@ # Idempotent: if runner is already registered it reloads the service and exits. set -euo pipefail -N="${1:?usage: add-host-runner.sh [capacity]}" +N="${1:?usage: add-host-runner.sh [capacity] [mode] (mode: host|docker)}" CAP="${2:-2}" +MODE="${3:-host}" # host | docker INSTANCE="${GITEA_INSTANCE:-http://localhost:3300}" +DOCKER_IMAGE="${RUNNER_DOCKER_IMAGE:-catthehacker/ubuntu:act-latest}" PAT_FILE="${GITEA_PAT_FILE:-$HOME/.gitea_c5_pat}" CANONICAL_CONFIG="${CANONICAL_CONFIG:-/opt/homebrew/etc/act_runner/config.yaml}" SHARED_ENV_FILE="${SHARED_ENV_FILE:-/opt/homebrew/etc/act_runner/runner.env}" @@ -48,7 +55,18 @@ PAT="$(cat "$PAT_FILE")" mkdir -p "$BASE" "$WORKDIR" "$LOG_DIR" -echo "── add host runner #$N (capacity $CAP) ──" +echo "── add $MODE runner #$N (capacity $CAP) ──" +[ "$MODE" = "docker" ] && echo " image : $DOCKER_IMAGE" + +# ── labels per mode (act_runner uses config-file labels, not --labels) ────── +if [ "$MODE" = "docker" ]; then + # Dedicated `docker` label so existing `runs-on: ubuntu-latest` jobs (which + # assume host mode + localhost Gitea) are NOT hijacked. Opt in with + # `runs-on: docker` and reach Gitea via host.docker.internal. + LABELS="docker:docker://$DOCKER_IMAGE,ubuntu-docker:docker://$DOCKER_IMAGE,self-hosted:host" +else + LABELS="ubuntu-latest:host,macos-latest:host,macos-15:host,self-hosted:host" +fi # ── already registered? just (re)load the service ─────────────────────────── if [ -f "$RUNNER_FILE" ]; then @@ -61,16 +79,32 @@ fi # ── derive a per-runner config from the canonical one ─────────────────────── # Preserve the proxy/env block + env_file; override file path, capacity, workdir. -python3 - "$CANONICAL_CONFIG" "$CONFIG" "$RUNNER_FILE" "$CAP" "$WORKDIR" "$SHARED_ENV_FILE" <<'PY' +python3 - "$CANONICAL_CONFIG" "$CONFIG" "$RUNNER_FILE" "$CAP" "$WORKDIR" "$SHARED_ENV_FILE" "$MODE" "$LABELS" <<'PY' import sys, yaml -src, dst, runner_file, cap, workdir, env_file = sys.argv[1:7] +src, dst, runner_file, cap, workdir, env_file, mode, labels = sys.argv[1:9] cfg = yaml.safe_load(open(src)) or {} cfg.setdefault("runner", {}) cfg["runner"]["file"] = runner_file cfg["runner"]["capacity"] = int(cap) cfg["runner"]["env_file"] = env_file +# act_runner reads labels from the config file (it ignores `register --labels`). +cfg["runner"]["labels"] = labels.split(",") cfg.setdefault("host", {}) cfg["host"]["workdir_parent"] = workdir +if mode == "docker": + c = cfg.setdefault("container", {}) + c["docker_host"] = "-" # auto-detect host docker daemon + c["force_pull"] = False # use locally-pulled image (corp proxy) + c["privileged"] = False + # Let job containers reach the host's Gitea on Docker Desktop. + c["options"] = "--add-host=host.docker.internal:host-gateway" + # Containerized jobs inherit the corp proxy env; without this they route + # host.docker.internal:3300 through the proxy and get a 504. Bypass it. + envs = cfg["runner"].setdefault("envs", {}) + for k in ("NO_PROXY", "no_proxy", "NPM_CONFIG_NOPROXY"): + v = envs.get(k, "") + if "host.docker.internal" not in v: + envs[k] = (v + "," if v else "") + "host.docker.internal" yaml.safe_dump(cfg, open(dst, "w"), default_flow_style=False, sort_keys=False) print(f" + wrote {dst}") PY @@ -81,13 +115,13 @@ REG_TOKEN=$(curl -fsS -H "Authorization: token $PAT" \ | python3 -c "import json,sys; print(json.load(sys.stdin)['token'])") [ -n "$REG_TOKEN" ] || { echo "✗ could not fetch registration token" >&2; exit 1; } -# ── register (host-mode labels) ───────────────────────────────────────────── +# ── register (labels come from config file; --labels is informational) ────── act_runner register \ --no-interactive \ --instance "$INSTANCE" \ --token "$REG_TOKEN" \ --name "$RUNNER_NAME" \ - --labels "ubuntu-latest:host,macos-latest:host,macos-15:host,self-hosted:host" \ + --labels "$LABELS" \ --config "$CONFIG" echo " ✓ registered as $RUNNER_NAME"