diff --git a/.gitea/actionlint.yaml b/.gitea/actionlint.yaml index b7c699a..b5a9cc3 100644 --- a/.gitea/actionlint.yaml +++ b/.gitea/actionlint.yaml @@ -7,4 +7,5 @@ self-hosted-runner: labels: - arch - homelab + - homelab-pr - prod diff --git a/.gitea/runner/README.md b/.gitea/runner/README.md index e229986..7a2375e 100644 --- a/.gitea/runner/README.md +++ b/.gitea/runner/README.md @@ -1,17 +1,18 @@ # Homelab CI/CD -The native Gitea runner runs on **vps**; production runs on **workstation**. -Compose, workflow, shell, Python, formatting, YAML, Dockerfile and Kubernetes -checks appear as separate jobs. Jobs run on `homelab:host`, one at a time; the -build waits for every check to pass. CI and deploy runs also show a summary with +The native Gitea runners run on **vps**; production runs on **workstation**. +Main-branch checks and image builds use `homelab:host`. Pull request and +non-main checks use `homelab-pr:host` under a separate account without Docker +access. Each runner accepts one job at a time; the build waits for every check +to pass. CI and deploy runs also show a summary with the release SHA, image build or reuse results, deploy mode, selected services, and image digests. Failed runs keep a summary of completed image builds, stage results, apply results, and recorded Kubernetes recovery. The final deploy summary is in the smoke job; earlier jobs show the state observed at that time. Apply success is separate from health and recovery. Update the installed workstation controller with `setup-workstation.sh` when no deploy is running. -No job images or Kubernetes credentials -are needed on the VPS. Builds use one pinned BuildKit helper container. CI and deploy are separate workflows. +No job images or Kubernetes credentials are needed on the VPS. Builds use one +pinned BuildKit helper container. CI and deploy are separate workflows. ## Runner installation @@ -37,6 +38,32 @@ pushes directly to the registry, and caps retained local cache at 1 GiB with a 2 GiB free-space target. This is not a hard limit on peak build disk usage. Nothing runs `docker system prune`, removes unrelated images, or deletes volumes. +### Pull request runner + +Install the unprivileged host runner on the VPS: + +```sh +sudo bash .gitea/runner/setup-pr-runner.sh +``` + +Get a registration token from the repository Actions runner settings. Run the +installer in a terminal. It asks for the token without echoing it, registers the +runner as `homelab-pr` with label `homelab-pr:host`, then enables the service. +The work directory is `/var/lib/gitea-pr-runner`. Confirm that Gitea lists +`homelab-pr:host` before merging the workflow change. An unmatched label can +fall back to the default job image. + +Renovate PR validation uses `pull_request_target`, which reads the workflow from +the base branch. It checks out the PR head only after runner selection and runs +that code on `homelab-pr`. Keep this workflow read-only and do not add secrets. + +The PR runner has a separate home and tool cache. Do not add it to the `docker` +group or give it access to `/var/run/docker.sock`. It runs repository code from +pull requests, so keep its registration and permissions separate from the +trusted `homelab` runner. This separates users and host permissions, but both +runners still share the VPS kernel and network. Use a disposable VM if PRs from +untrusted external authors must be fully isolated. + ## Workstation setup As the existing SSH deploy user on workstation: diff --git a/.gitea/runner/pr-config.yaml b/.gitea/runner/pr-config.yaml new file mode 100644 index 0000000..2b6ee49 --- /dev/null +++ b/.gitea/runner/pr-config.yaml @@ -0,0 +1,8 @@ +runner: + file: /var/lib/gitea-pr-runner/.runner + capacity: 1 + timeout: 5h + labels: + - homelab-pr:host +cache: + enabled: false diff --git a/.gitea/runner/pr-runner.service b/.gitea/runner/pr-runner.service new file mode 100644 index 0000000..9c6fd5a --- /dev/null +++ b/.gitea/runner/pr-runner.service @@ -0,0 +1,27 @@ +[Unit] +Description=Gitea Actions untrusted pull request runner +After=network-online.target +Wants=network-online.target + +[Service] +User=gitea-pr-runner +Group=gitea-pr-runner +WorkingDirectory=/var/lib/gitea-pr-runner +Environment=HOME=/var/lib/gitea-pr-runner +Environment=PATH=/var/lib/gitea-pr-runner/.cache/homelab-ci/bin:/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/local/bin/gitea-runner daemon --config /etc/gitea-pr-runner/config.yaml +Restart=on-failure +RestartSec=5 +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=full +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +LockPersonality=yes +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/.gitea/runner/setup-pr-runner.sh b/.gitea/runner/setup-pr-runner.sh new file mode 100755 index 0000000..a476b88 --- /dev/null +++ b/.gitea/runner/setup-pr-runner.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Install a native runner for untrusted PR jobs without Docker access. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +[ "$(id -u)" -eq 0 ] || { echo 'Run with sudo on the runner host' >&2; exit 1; } +for tool in cp cut date getent id install runuser systemctl useradd; do + command -v "$tool" >/dev/null || { echo "Install missing prerequisite: $tool" >&2; exit 1; } +done +command -v /usr/local/bin/gitea-runner >/dev/null || { + echo 'Install gitea-runner 3.0.2 at /usr/local/bin/gitea-runner first' >&2 + exit 1 +} + +id gitea-pr-runner >/dev/null 2>&1 || \ + useradd --system --create-home --home-dir /var/lib/gitea-pr-runner --shell /usr/bin/bash gitea-pr-runner +runner_home="$(getent passwd gitea-pr-runner | cut -d: -f6)" +[ "$runner_home" = /var/lib/gitea-pr-runner ] || { + echo 'Unexpected PR runner home; inspect the existing service first' >&2 + exit 1 +} +case " $(id -nG gitea-pr-runner) " in + *' docker '*) + echo 'The PR runner account must not belong to the docker group' >&2 + exit 1 + ;; +esac + +install -d -m 0755 /etc/gitea-pr-runner +stamp="$(date -u +%Y%m%dT%H%M%SZ)" +for existing in /etc/gitea-pr-runner/config.yaml /etc/systemd/system/gitea-pr-runner.service; do + [ ! -f "$existing" ] || cp -p "$existing" "$existing.before-$stamp" +done +install -m 0644 "$here/pr-config.yaml" /etc/gitea-pr-runner/config.yaml +install -m 0644 "$here/pr-runner.service" /etc/systemd/system/gitea-pr-runner.service + +if [ ! -f /var/lib/gitea-pr-runner/.runner ]; then + read -r -s -p 'Enter the Gitea repository runner registration token: ' runner_token + printf '\n' + [ -n "$runner_token" ] || { echo 'Runner token is required' >&2; exit 1; } + export GITEA_RUNNER_REGISTRATION_TOKEN="$runner_token" + unset runner_token + runuser --preserve-environment -u gitea-pr-runner -- \ + /usr/local/bin/gitea-runner register \ + --config /etc/gitea-pr-runner/config.yaml \ + --instance https://gitea.forust.xyz \ + --name homelab-pr \ + --labels homelab-pr:host \ + --no-interactive + unset GITEA_RUNNER_REGISTRATION_TOKEN +fi + +systemctl daemon-reload +systemctl enable --now gitea-pr-runner.service +systemctl restart gitea-pr-runner.service +echo "PR runner ready. Configuration backups: *.before-$stamp" diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 5713d9c..7f7e11a 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -14,7 +14,7 @@ concurrency: jobs: compose: name: Compose - runs-on: homelab + runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} timeout-minutes: 15 steps: - name: Checkout repository @@ -66,7 +66,7 @@ jobs: fi workflows: name: Workflows - runs-on: homelab + runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} timeout-minutes: 15 steps: - name: Checkout repository @@ -102,7 +102,7 @@ jobs: fi shell: name: Shell - runs-on: homelab + runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} timeout-minutes: 15 steps: - name: Checkout repository @@ -146,7 +146,7 @@ jobs: fi formatting: name: Formatting - runs-on: homelab + runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} timeout-minutes: 15 steps: - name: Checkout repository @@ -194,7 +194,7 @@ jobs: fi python: name: Python and tests - runs-on: homelab + runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} timeout-minutes: 15 steps: - name: Checkout repository @@ -232,7 +232,7 @@ jobs: fi yaml: name: YAML - runs-on: homelab + runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} timeout-minutes: 15 steps: - name: Checkout repository @@ -280,7 +280,7 @@ jobs: fi dockerfiles: name: Dockerfiles - runs-on: homelab + runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} timeout-minutes: 15 steps: - name: Checkout repository @@ -326,7 +326,7 @@ jobs: fi kubernetes: name: Kubernetes - runs-on: homelab + runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} timeout-minutes: 15 steps: - name: Checkout repository diff --git a/.gitea/workflows/renovate-ci.yaml b/.gitea/workflows/renovate-ci.yaml index 5592dc7..ea3b92b 100644 --- a/.gitea/workflows/renovate-ci.yaml +++ b/.gitea/workflows/renovate-ci.yaml @@ -1,7 +1,9 @@ name: renovate-ci on: - pull_request: + # Read the workflow from the trusted base branch. PR code runs only on the + # unprivileged runner selected below. + pull_request_target: paths: - "renovate/**" - ".gitea/workflows/renovate-ci.yaml" @@ -26,37 +28,47 @@ permissions: jobs: validate-renovate: - runs-on: homelab + runs-on: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} timeout-minutes: 20 steps: - name: Checkout repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }} - # renovate/k8s/cronjob.yaml is the single source of truth for the image tag, - # so the same version that runs in the cluster is the one validated here. - - name: Resolve the deployed Renovate image + # renovate/k8s/cronjob.yaml is the single source of truth for the version. + - name: Resolve the deployed Renovate version id: image shell: bash run: | set -euo pipefail image="$(sed -n 's|.*image:[[:space:]]*\(renovate/renovate:[^[:space:]]*\).*|\1|p' \ renovate/k8s/cronjob.yaml | head -1)" - if [ -z "$image" ]; then - echo "::error::no renovate/renovate image found in renovate/k8s/cronjob.yaml" + if [[ ! "$image" =~ ^renovate/renovate:([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "::error::expected a pinned renovate/renovate semantic version in renovate/k8s/cronjob.yaml" exit 1 fi - echo "using $image" - echo "image=$image" >> "$GITHUB_OUTPUT" + version="${BASH_REMATCH[1]}" + echo "using Renovate $version" + printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" - - name: Validate Renovate repository config + - name: Prepare pinned validation tools shell: bash run: | set -euo pipefail - docker run --rm \ - -v "$PWD/renovate:/opt/renovate:ro" \ - -e RENOVATE_CONFIG_FILE=/opt/renovate/renovate.json \ - "${{ steps.image.outputs.image }}" \ - renovate-config-validator /opt/renovate/renovate.json + tools_dir="$(bash .gitea/workflows/install-ci-tools.sh kubeconform node)" + echo "$tools_dir" >> "$GITHUB_PATH" + + - name: Validate Renovate repository config + shell: bash + env: + RENOVATE_VERSION: ${{ steps.image.outputs.version }} + run: | + set -euo pipefail + npm_cache="$(mktemp -d "${RUNNER_TEMP:-/tmp}/renovate-npm-cache.XXXXXXXX")" + trap 'rm -rf "$npm_cache"' EXIT + NPM_CONFIG_CACHE="$npm_cache" RENOVATE_CONFIG_FILE="$PWD/renovate/renovate.json" \ + npm exec --yes --package="renovate@${RENOVATE_VERSION}" -- renovate-config-validator # The CronJob cannot read the repository, so renovate/k8s/configmap.yaml # carries an inlined copy of the config. Fail if it no longer matches. @@ -70,8 +82,6 @@ jobs: shell: bash run: | set -euo pipefail - tools_dir="$(bash .gitea/workflows/install-ci-tools.sh kubeconform)" - export PATH="$tools_dir:$PATH" kubeconform \ -strict \ -ignore-missing-schemas \ diff --git a/.gitea/workflows/renovate-run.yaml b/.gitea/workflows/renovate-run.yaml index 05d221a..7e32fc9 100644 --- a/.gitea/workflows/renovate-run.yaml +++ b/.gitea/workflows/renovate-run.yaml @@ -32,11 +32,14 @@ concurrency: jobs: run-renovate: + if: github.ref == 'refs/heads/main' runs-on: homelab timeout-minutes: 60 steps: - name: Checkout repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: refs/heads/main # renovate/k8s/cronjob.yaml is the single source of truth for the image tag. # Reading it here means this workflow validates and runs the exact version @@ -48,21 +51,23 @@ jobs: set -euo pipefail image="$(sed -n 's|.*image:[[:space:]]*\(renovate/renovate:[^[:space:]]*\).*|\1|p' \ renovate/k8s/cronjob.yaml | head -1)" - if [ -z "$image" ]; then - echo "::error::no renovate/renovate image found in renovate/k8s/cronjob.yaml" + if [[ ! "$image" =~ ^renovate/renovate:[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::expected a pinned renovate/renovate semantic version in renovate/k8s/cronjob.yaml" exit 1 fi echo "using $image" - echo "image=$image" >> "$GITHUB_OUTPUT" + printf 'image=%s\n' "$image" >> "$GITHUB_OUTPUT" - name: Validate Renovate config shell: bash + env: + RENOVATE_IMAGE: ${{ steps.image.outputs.image }} run: | set -euo pipefail docker run --rm \ -v "$PWD/renovate/renovate.json:/opt/renovate/renovate.json:ro" \ -e RENOVATE_CONFIG_FILE=/opt/renovate/renovate.json \ - "${{ steps.image.outputs.image }}" \ + "$RENOVATE_IMAGE" \ renovate-config-validator - name: Run Renovate @@ -73,6 +78,7 @@ jobs: RENOVATE_REPOSITORIES: ${{ inputs.repositories }} RENOVATE_DRY_RUN: ${{ inputs.dry_run && 'full' || '' }} LOG_LEVEL: ${{ inputs.log_level }} + RENOVATE_IMAGE: ${{ steps.image.outputs.image }} run: | set -euo pipefail @@ -89,4 +95,4 @@ jobs: -e RENOVATE_CONFIG_FILE=/opt/renovate/renovate.json \ -e RENOVATE_BASE_DIR=/tmp/renovate \ -e LOG_LEVEL="${LOG_LEVEL:-info}" \ - "${{ steps.image.outputs.image }}" + "$RENOVATE_IMAGE"