From 5afd9f5b971d2051750a47bfbec8ce11f0139f83 Mon Sep 17 00:00:00 2001 From: theaussiepom Date: Mon, 22 Dec 2025 07:32:31 +0000 Subject: [PATCH] Rename runner-pi to runner Rename runner-pi -> runner across scripts, units, docs, examples, and tests. Also harden APPLIANCE_ROOT validation to prevent misquoted roots creating junk directories, keep kcov at 100%, and tighten lint-permissions to validate git-tracked executable bits (including tests stubs). --- .devcontainer/devcontainer.json | 2 +- .github/copilot-instructions.md | 6 +- .github/workflows/ci.yml | 32 +- .gitignore | 4 + CONTRIBUTING.md | 4 +- Makefile | 2 +- README.md | 79 ++--- ci/05-lint-permissions.sh | 41 ++- cloud-init/user-data.example.yml | 39 ++- docs/architecture.md | 81 ++--- docs/config-examples.md | 35 +- docs/glossary.md | 17 +- examples/config.env | 16 +- examples/config.env.example | 23 +- examples/pi-imager/user-data.example.yml | 32 +- scripts/bootstrap.sh | 10 +- scripts/ci-nspawn-run.sh | 207 ++++++++++++ scripts/container-hooks.sh | 186 +++++++++++ scripts/healthcheck.sh | 52 --- scripts/install.sh | 47 +-- scripts/lib/common.sh | 22 ++ scripts/lib/config.sh | 12 +- scripts/lib/logging.sh | 2 +- scripts/mode/enter-primary-mode.sh | 33 -- scripts/mode/enter-secondary-mode.sh | 33 -- scripts/mode/primary-mode.sh | 46 --- scripts/mode/secondary-mode.sh | 46 --- scripts/runner-service.sh | 50 +++ scripts/uninstall.sh | 66 ++++ systemd/runner-install.service | 19 ++ systemd/runner.service | 14 + .../template-appliance-healthcheck.service | 7 - systemd/template-appliance-healthcheck.timer | 10 - systemd/template-appliance-install.service | 24 -- systemd/template-appliance-primary.service | 14 - systemd/template-appliance-secondary.service | 14 - tests/bin/kcov-line-coverage.sh | 316 ++++++++++++------ tests/bin/recalc-path-coverage.sh | 8 +- tests/bin/run-bats-integration.sh | 2 +- tests/bin/run-bats-unit.sh | 2 +- tests/coverage/required-paths.txt | 124 ++----- tests/helpers/common.bash | 7 +- tests/integration/bootstrap.bats | 10 +- tests/integration/ci-nspawn-run.bats | 70 ++++ .../integration/container-hooks-coverage.bats | 116 +++++++ tests/integration/healthcheck.bats | 27 -- tests/integration/install.bats | 8 +- tests/integration/libdir-resolution.bats | 35 +- tests/integration/mode.bats | 53 --- tests/integration/runner-service.bats | 93 ++++++ tests/integration/uninstall.bats | 39 +++ tests/stubs/dirname | 0 tests/stubs/machinectl | 12 + tests/stubs/systemctl | 7 +- tests/stubs/systemd-nspawn | 12 + tests/stubs/systemd-run | 5 + tests/unit/lib-common.bats | 26 +- tests/unit/lib-config.bats | 16 +- tests/unit/lib-logging.bats | 2 +- 59 files changed, 1469 insertions(+), 848 deletions(-) create mode 100755 scripts/ci-nspawn-run.sh create mode 100755 scripts/container-hooks.sh delete mode 100755 scripts/healthcheck.sh delete mode 100644 scripts/mode/enter-primary-mode.sh delete mode 100644 scripts/mode/enter-secondary-mode.sh delete mode 100644 scripts/mode/primary-mode.sh delete mode 100644 scripts/mode/secondary-mode.sh create mode 100755 scripts/runner-service.sh create mode 100755 scripts/uninstall.sh create mode 100644 systemd/runner-install.service create mode 100644 systemd/runner.service delete mode 100644 systemd/template-appliance-healthcheck.service delete mode 100644 systemd/template-appliance-healthcheck.timer delete mode 100644 systemd/template-appliance-install.service delete mode 100644 systemd/template-appliance-primary.service delete mode 100644 systemd/template-appliance-secondary.service mode change 100644 => 100755 tests/bin/kcov-line-coverage.sh create mode 100644 tests/integration/ci-nspawn-run.bats create mode 100644 tests/integration/container-hooks-coverage.bats delete mode 100644 tests/integration/healthcheck.bats delete mode 100644 tests/integration/mode.bats create mode 100644 tests/integration/runner-service.bats create mode 100644 tests/integration/uninstall.bats mode change 100644 => 100755 tests/stubs/dirname create mode 100755 tests/stubs/machinectl create mode 100755 tests/stubs/systemd-nspawn create mode 100755 tests/stubs/systemd-run diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f7da8d1..61f5868 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,5 +1,5 @@ { - "name": "template-appliance", + "name": "runner", "build": { "dockerfile": "Dockerfile" }, diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2aad126..6b45a99 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,4 +1,4 @@ -# GitHub Copilot instructions (template-appliance) +# GitHub Copilot instructions (runner) These instructions describe how this repo is structured, how CI runs, and what “good changes” look like. @@ -6,7 +6,7 @@ If anything here conflicts with an explicit user request in the chat, follow the ## 1) What this repo is -`template-appliance` is a Bash-first skeleton project that ships scripts + systemd units for a generic appliance with a primary mode, a secondary mode, and a healthcheck-based failover. +`runner` is a Bash-first appliance project that ships scripts + systemd units for running a single GitHub Actions self-hosted runner. The codebase emphasizes: - Script correctness and predictable behavior under `set -euo pipefail` @@ -23,7 +23,7 @@ Preferred ways to run things: - One CI part: `./scripts/ci.sh lint-sh` (or other part names) - Make targets (when available): `make lint`, `make test-unit`, `make test-integration`, `make ci` -The Makefile runs commands inside a Docker devcontainer image (`template-appliance-devcontainer:local`). If `docker` isn’t available, the Makefile may fall back to running locally. +The Makefile runs commands inside a Docker devcontainer image (`runner-devcontainer:local`). If `docker` isn’t available, the Makefile may fall back to running locally. ## 3) CI/router model (important) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e2262d..b18af62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: with: context: . file: .devcontainer/Dockerfile - tags: template-appliance-devcontainer:ci + tags: runner-devcontainer:ci load: true cache-from: type=gha cache-to: type=gha,mode=max @@ -37,7 +37,7 @@ jobs: docker run --rm \ -v "$PWD:/work" \ -w /work \ - template-appliance-devcontainer:ci \ + runner-devcontainer:ci \ bash -lc './scripts/ci.sh lint-permissions' lint-naming: @@ -54,7 +54,7 @@ jobs: with: context: . file: .devcontainer/Dockerfile - tags: template-appliance-devcontainer:ci + tags: runner-devcontainer:ci load: true cache-from: type=gha cache-to: type=gha,mode=max @@ -66,7 +66,7 @@ jobs: docker run --rm \ -v "$PWD:/work" \ -w /work \ - template-appliance-devcontainer:ci \ + runner-devcontainer:ci \ bash -lc './scripts/ci.sh lint-naming' lint-sh: @@ -83,7 +83,7 @@ jobs: with: context: . file: .devcontainer/Dockerfile - tags: template-appliance-devcontainer:ci + tags: runner-devcontainer:ci load: true cache-from: type=gha cache-to: type=gha,mode=max @@ -95,7 +95,7 @@ jobs: docker run --rm \ -v "$PWD:/work" \ -w /work \ - template-appliance-devcontainer:ci \ + runner-devcontainer:ci \ bash -lc './scripts/ci.sh lint-sh' test-all: @@ -112,7 +112,7 @@ jobs: with: context: . file: .devcontainer/Dockerfile - tags: template-appliance-devcontainer:ci + tags: runner-devcontainer:ci load: true cache-from: type=gha cache-to: type=gha,mode=max @@ -124,7 +124,7 @@ jobs: docker run --rm \ -v "$PWD:/work" \ -w /work \ - template-appliance-devcontainer:ci \ + runner-devcontainer:ci \ bash -lc './scripts/ci.sh tests' test-coverage: @@ -142,7 +142,7 @@ jobs: with: context: . file: .devcontainer/Dockerfile - tags: template-appliance-devcontainer:ci + tags: runner-devcontainer:ci load: true cache-from: type=gha cache-to: type=gha,mode=max @@ -155,7 +155,7 @@ jobs: docker run --rm \ -v "$PWD:/work" \ -w /work \ - template-appliance-devcontainer:ci \ + runner-devcontainer:ci \ bash -lc './scripts/ci.sh coverage' - name: Show kcov output paths @@ -217,7 +217,7 @@ jobs: with: context: . file: .devcontainer/Dockerfile - tags: template-appliance-devcontainer:ci + tags: runner-devcontainer:ci load: true cache-from: type=gha cache-to: type=gha,mode=max @@ -229,7 +229,7 @@ jobs: docker run --rm \ -v "$PWD:/work" \ -w /work \ - template-appliance-devcontainer:ci \ + runner-devcontainer:ci \ bash -lc './scripts/ci.sh lint-yaml' lint-systemd: @@ -246,7 +246,7 @@ jobs: with: context: . file: .devcontainer/Dockerfile - tags: template-appliance-devcontainer:ci + tags: runner-devcontainer:ci load: true cache-from: type=gha cache-to: type=gha,mode=max @@ -258,7 +258,7 @@ jobs: docker run --rm \ -v "$PWD:/work" \ -w /work \ - template-appliance-devcontainer:ci \ + runner-devcontainer:ci \ bash -lc './scripts/ci.sh lint-systemd' lint-markdown: @@ -275,7 +275,7 @@ jobs: with: context: . file: .devcontainer/Dockerfile - tags: template-appliance-devcontainer:ci + tags: runner-devcontainer:ci load: true cache-from: type=gha cache-to: type=gha,mode=max @@ -287,5 +287,5 @@ jobs: docker run --rm \ -v "$PWD:/work" \ -w /work \ - template-appliance-devcontainer:ci \ + runner-devcontainer:ci \ bash -lc './scripts/ci.sh lint-markdown' diff --git a/.gitignore b/.gitignore index e568c02..eac9207 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,10 @@ tests/.tmp/ .tmp/ .tmp-bats-trace.txt +# Junk dirs accidentally created by misquoted paths +"mp_dir/ +"PPLIANCE_ROOT/ + # Coverage artifacts (generated by kcov runs) coverage/ coverage-devcontainer*/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f6f9ab8..ebdd162 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,7 +63,7 @@ That is the same pipeline GitHub CI uses. Build the devcontainer image: ```bash -docker build -t template-appliance-devcontainer -f .devcontainer/Dockerfile . +docker build -t runner-devcontainer -f .devcontainer/Dockerfile . ``` Run the full pipeline inside it: @@ -72,7 +72,7 @@ Run the full pipeline inside it: docker run --rm \ -v "$PWD:/work" \ -w /work \ - template-appliance-devcontainer \ + runner-devcontainer \ bash -lc './scripts/ci.sh' ``` diff --git a/Makefile b/Makefile index fd60316..f0e66fe 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ SHELL := /usr/bin/env bash ci DOCKER ?= docker -DEVCONTAINER_IMAGE ?= template-appliance-devcontainer:local +DEVCONTAINER_IMAGE ?= runner-devcontainer:local DEVCONTAINER_DOCKERFILE ?= .devcontainer/Dockerfile DEVCONTAINER_CONTEXT ?= . DEVCONTAINER_WORKDIR ?= /work diff --git a/README.md b/README.md index 8986c76..bc3e134 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ -# template-appliance +# runner -Reusable Bash + systemd appliance skeleton built around a simple model: +Bash + systemd appliance for running a single GitHub Actions self-hosted runner on Linux (including Raspberry Pi). -- Primary mode: run your main workload (`APPLIANCE_PRIMARY_CMD`) -- Secondary mode: run a fallback workload (`APPLIANCE_SECONDARY_CMD`) -- Healthcheck timer: if primary is not active, start secondary +Goal: -This repo is intentionally minimal: you provide the commands and any extra packages. +- Keep exactly one host runner process managed by systemd. +- Route job execution into an ephemeral `systemd-nspawn` guest (systemd PID1 semantics) + via GitHub Actions runner container hooks. ## Documentation @@ -19,7 +19,7 @@ This repo is intentionally minimal: you provide the commands and any extra packa Build the devcontainer image: ```bash -docker build -t template-appliance-devcontainer -f .devcontainer/Dockerfile . +docker build -t runner-devcontainer -f .devcontainer/Dockerfile . ``` Run the full CI pipeline inside it: @@ -28,7 +28,7 @@ Run the full CI pipeline inside it: docker run --rm \ -v "$PWD:/work" \ -w /work \ - template-appliance-devcontainer \ + runner-devcontainer \ bash -lc './scripts/ci.sh' ``` @@ -42,27 +42,18 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the required pre-PR checks. ## Runtime model -At a glance, systemd manages three key units: +At a glance, systemd manages two key units: -- `template-appliance-primary.service` -- `template-appliance-secondary.service` -- `template-appliance-healthcheck.timer` (runs `template-appliance-healthcheck.service`) - -```mermaid -flowchart TD - PRIMARY["template-appliance-primary.service
runs APPLIANCE_PRIMARY_CMD"] - SECONDARY["template-appliance-secondary.service
runs APPLIANCE_SECONDARY_CMD"] - TIMER["template-appliance-healthcheck.timer"] --> CHECK["template-appliance-healthcheck.service"] - CHECK -->|"primary inactive"| SECONDARY -``` +- `runner-install.service` (first-boot installer; retried until it succeeds) +- `runner.service` (runs the configured GitHub runner) ## Installation (cloud-init / Pi Imager) The recommended install flow is: -1. cloud-init writes `/etc/template-appliance/config.env`. +1. cloud-init writes `/etc/runner/config.env`. 2. cloud-init installs a one-time installer unit + bootstrap script. -3. systemd runs `template-appliance-install.service` until install succeeds. +3. systemd runs `runner-install.service` until install succeeds. Examples: @@ -71,40 +62,44 @@ Examples: ## Configuration -Runtime configuration lives in `/etc/template-appliance/config.env`. +Runtime configuration lives in `/etc/runner/config.env`. Required (first-boot bootstrap): - `APPLIANCE_REPO_URL` - `APPLIANCE_REPO_REF` (branch/tag/commit; pinning to a tag/commit is recommended) -Required (runtime modes): - -- `APPLIANCE_PRIMARY_CMD` -- `APPLIANCE_SECONDARY_CMD` - Optional: -- `APPLIANCE_CHECKOUT_DIR` (default: `/opt/template-appliance`) -- `APPLIANCE_INSTALLED_MARKER` (default: `/var/lib/template-appliance/installed`) +- `APPLIANCE_CHECKOUT_DIR` (default: `/opt/runner`) +- `APPLIANCE_INSTALLED_MARKER` (default: `/var/lib/runner/installed`) - `APPLIANCE_APT_PACKAGES` (space-separated extra packages for install) -- `APPLIANCE_PRIMARY_SERVICE` / `APPLIANCE_SECONDARY_SERVICE` (override healthcheck targets) - `APPLIANCE_DRY_RUN=1` (do not modify system; record intended actions) +Runner: + +- `RUNNER_ACTIONS_RUNNER_DIR` (default: `/opt/runner/actions-runner`) +- `RUNNER_HOOKS_DIR` (default: `/usr/local/lib/runner`) + +Job isolation (`systemd-nspawn`): + +- `RUNNER_NSPAWN_BASE_ROOTFS` (default: `/var/lib/runner/nspawn/base-rootfs`) +- `RUNNER_NSPAWN_READY_TIMEOUT_S` (default: `20`) +- `RUNNER_NSPAWN_BIND` / `RUNNER_NSPAWN_BIND_RO` (space-separated bind mount entries) + ## Day-2 operations -Switch modes: +Inspect service status: ```bash -systemctl start template-appliance-primary.service -systemctl start template-appliance-secondary.service +systemctl status runner.service --no-pager ``` Inspect install/boot status: ```bash -systemctl status template-appliance-install.service --no-pager -ls -l /var/lib/template-appliance/installed || true +systemctl status runner-install.service --no-pager +ls -l /var/lib/runner/installed || true ``` ## Manual install (no cloud-init) @@ -118,18 +113,18 @@ sudo apt-get update sudo apt-get install -y --no-install-recommends ca-certificates curl git ``` -1. Create `/etc/template-appliance/config.env` (start from the example): +1. Create `/etc/runner/config.env` (start from the example): ```bash -sudo mkdir -p /etc/template-appliance -sudo cp /path/to/template-appliance/examples/config.env.example /etc/template-appliance/config.env -sudo nano /etc/template-appliance/config.env +sudo mkdir -p /etc/runner +sudo cp /path/to/runner/examples/config.env.example /etc/runner/config.env +sudo nano /etc/runner/config.env ``` 1. Clone the repo and run the installer as root: ```bash -git clone https://github.com/your-org/template-appliance.git /opt/template-appliance -cd /opt/template-appliance +git clone https://github.com/your-org/github-runner.git /opt/runner +cd /opt/runner sudo ./scripts/install.sh ``` diff --git a/ci/05-lint-permissions.sh b/ci/05-lint-permissions.sh index c482dfd..dec4622 100755 --- a/ci/05-lint-permissions.sh +++ b/ci/05-lint-permissions.sh @@ -13,24 +13,37 @@ echo "== lint-permissions: executable bits ==" fail=0 -check_tree() { +check_git_mode() { local prefix="$1" - - # Only consider tracked files to avoid false positives from local build artifacts. - mapfile -t files < <(git ls-files "$prefix" | grep -E '\.sh$' || true) - - local f - for f in "${files[@]}"; do - if [[ ! -x "$f" ]]; then - echo "lint-permissions [error]: not executable (expected +x): $f" >&2 - fail=1 + local pattern_re="$2" + local expected_mode="$3" + + # Check the *git index* mode, not the filesystem mode. In some dev/test + # environments the worktree may be mounted 0777 which would mask missing +x. + # + # git ls-files --stage emits: + while IFS=$'\t' read -r meta path; do + [[ -n "$path" ]] || continue + + local mode + mode="${meta%% *}" + + if [[ "$path" =~ $pattern_re ]]; then + if [[ "$mode" != "$expected_mode" ]]; then + echo "lint-permissions [error]: wrong git mode (expected $expected_mode): $path (got $mode)" >&2 + fail=1 + fi fi - done + done < <(git ls-files --stage "$prefix") } -check_tree "ci" -check_tree "scripts" -check_tree "tests/bin" +check_git_mode "ci" '^ci/.*\.sh$' 100755 +check_git_mode "scripts" '^scripts/.*\.sh$' 100755 +check_git_mode "tests/bin" '^tests/bin/.*\.sh$' 100755 + +# Test stubs are invoked via PATH and must be executable, but they intentionally +# do not all end with .sh. +check_git_mode "tests/stubs" '^tests/stubs/.*' 100755 if [[ "$fail" -ne 0 ]]; then echo "lint-permissions [hint]: fix with: git update-index --chmod=+x " >&2 diff --git a/cloud-init/user-data.example.yml b/cloud-init/user-data.example.yml index 7a2aaf9..b8dbdfc 100644 --- a/cloud-init/user-data.example.yml +++ b/cloud-init/user-data.example.yml @@ -1,37 +1,42 @@ #cloud-config --- # Example cloud-init for Raspberry Pi Imager. -# Write your real values into /etc/template-appliance/config.env. +# Write your real values into /etc/runner/config.env. write_files: - - path: /etc/template-appliance/config.env + - path: /etc/runner/config.env permissions: "0644" content: | # Core config (required) # Pin to a tag/commit for deterministic builds. - APPLIANCE_REPO_URL=https://github.com/your-org/template-appliance.git + APPLIANCE_REPO_URL=https://github.com/your-org/github-runner.git APPLIANCE_REPO_REF=main # Optional: where to clone to - # APPLIANCE_CHECKOUT_DIR=/opt/template-appliance + # APPLIANCE_CHECKOUT_DIR=/opt/runner - # Runtime commands (required) - APPLIANCE_PRIMARY_CMD='echo "primary"; sleep infinity' - APPLIANCE_SECONDARY_CMD='echo "secondary"; sleep infinity' + # Optional: install extra packages + # APPLIANCE_APT_PACKAGES="ca-certificates curl git" - - path: /usr/local/lib/template-appliance/bootstrap.sh + # Runner installation location + # RUNNER_ACTIONS_RUNNER_DIR=/opt/runner/actions-runner + + # systemd-nspawn base rootfs + # RUNNER_NSPAWN_BASE_ROOTFS=/var/lib/runner/nspawn/base-rootfs + + - path: /usr/local/lib/runner/bootstrap.sh permissions: "0755" content: | #!/usr/bin/env bash set -euo pipefail - log() { echo "template-appliance bootstrap: $*" >&2; } + log() { echo "runner bootstrap: $*" >&2; } die() { log "$*"; exit 1; } require_cmd() { command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"; } network_ok() { getent hosts github.com >/dev/null 2>&1 && curl -fsS https://github.com >/dev/null 2>&1; } main() { - if [[ -f /var/lib/template-appliance/installed ]]; then + if [[ -f /var/lib/runner/installed ]]; then log "Marker present; nothing to do." exit 0 fi @@ -48,7 +53,7 @@ write_files: [[ -n "$repo_url" ]] || die "APPLIANCE_REPO_URL is required" [[ -n "$repo_ref" ]] || die "APPLIANCE_REPO_REF is required" - local checkout_dir="${APPLIANCE_CHECKOUT_DIR:-/opt/template-appliance}" + local checkout_dir="${APPLIANCE_CHECKOUT_DIR:-/opt/runner}" if [[ ! -d "$checkout_dir/.git" ]]; then log "Cloning $repo_url -> $checkout_dir" rm -rf "$checkout_dir" @@ -68,19 +73,19 @@ write_files: main "$@" - - path: /etc/systemd/system/template-appliance-install.service + - path: /etc/systemd/system/runner-install.service permissions: "0644" content: | [Unit] - Description=template-appliance one-time installer + Description=runner first-boot installer Wants=network-online.target After=network-online.target - ConditionPathExists=!/var/lib/template-appliance/installed + ConditionPathExists=!/var/lib/runner/installed [Service] Type=oneshot - EnvironmentFile=-/etc/template-appliance/config.env - ExecStart=/usr/local/lib/template-appliance/bootstrap.sh + EnvironmentFile=-/etc/runner/config.env + ExecStart=/usr/local/lib/runner/bootstrap.sh Restart=on-failure RestartSec=30 StartLimitIntervalSec=0 @@ -97,4 +102,4 @@ runcmd: - systemctl daemon-reload - - bash - -lc - - systemctl enable --now template-appliance-install.service + - systemctl enable --now runner-install.service diff --git a/docs/architecture.md b/docs/architecture.md index a979873..44e2aae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,8 +1,9 @@ # Architecture -template-appliance is a reusable skeleton for a systemd-managed appliance. +runner is a systemd-managed appliance for running a single GitHub Actions self-hosted runner. -The design goal is a small, copyable “runner” that you can adapt by setting commands in a config file. +The runner process lives on the host, but job execution is intended to run inside an ephemeral +`systemd-nspawn` guest (systemd PID1 semantics). ## Glossary @@ -10,11 +11,9 @@ See [Glossary](glossary.md). ## Goals -- Provide a predictable baseline for a systemd-managed appliance. -- Support two modes: - - Primary mode: your main workload. - - Secondary mode: a fallback workload. +- Keep host lifecycle predictable (systemd unit for install, systemd unit for runner). - Make first-boot installs idempotent and retryable. +- Avoid Docker at runtime by routing containerized jobs through `systemd-nspawn`. ## High-level component map @@ -22,40 +21,37 @@ See [Glossary](glossary.md). flowchart TD subgraph HOST[Linux host] SYSTEMD[systemd] - INSTALL[template-appliance-install.service] - PRIMARY[template-appliance-primary.service] - SECONDARY[template-appliance-secondary.service] - TIMER[template-appliance-healthcheck.timer] - CHECK[template-appliance-healthcheck.service] + INSTALL[runner-install.service] + RUNNER[runner.service] + HOOKS[ACTIONS_RUNNER_CONTAINER_HOOKS\ncontainer-hooks.sh] + NSPAWN[ci-nspawn-run\n(systemd-nspawn + systemd-run -M)] end SYSTEMD --> INSTALL - SYSTEMD --> PRIMARY - SYSTEMD --> SECONDARY - TIMER --> CHECK - CHECK -->|"primary inactive"| SECONDARY + SYSTEMD --> RUNNER + RUNNER --> HOOKS + HOOKS --> NSPAWN ``` -Configuration is loaded from `/etc/template-appliance/config.env`. +Configuration is loaded from `/etc/runner/config.env`. ## Systemd units Install-time: -- `template-appliance-install.service`: one-time installer (first boot; retried until it succeeds). +- `runner-install.service`: one-time installer (first boot; retried until it succeeds). Runtime: -- `template-appliance-primary.service`: runs `/usr/local/lib/template-appliance/primary-mode.sh`. -- `template-appliance-secondary.service`: runs `/usr/local/lib/template-appliance/secondary-mode.sh`. -- `template-appliance-healthcheck.timer`: periodically runs `template-appliance-healthcheck.service`. +- `runner.service`: runs `/usr/local/lib/runner/runner-service.sh`. ## Installed layout -- `/etc/template-appliance/config.env`: runtime configuration. -- `/usr/local/lib/template-appliance/*.sh`: installed scripts. -- `/usr/local/lib/template-appliance/lib/*.sh`: shared lib helpers. -- `/var/lib/template-appliance/installed`: one-time install marker. +- `/etc/runner/config.env`: runtime configuration. +- `/usr/local/lib/runner/*.sh`: installed scripts. +- `/usr/local/lib/runner/lib/*.sh`: shared lib helpers. +- `/usr/local/bin/ci-nspawn-run`: helper used by container hooks. +- `/var/lib/runner/installed`: one-time install marker. ## Boot and installation flow @@ -75,11 +71,11 @@ sequenceDiagram participant REPO as repo checkout participant INST as scripts/install.sh - CI->>CI: write /etc/template-appliance/config.env - CI->>SD: install template-appliance-install.service - CI->>CI: write /usr/local/lib/template-appliance/bootstrap.sh - SD->>BOOT: start template-appliance-install.service - BOOT->>BOOT: check /var/lib/template-appliance/installed + CI->>CI: write /etc/runner/config.env + CI->>SD: install runner-install.service + CI->>CI: write /usr/local/lib/runner/bootstrap.sh + SD->>BOOT: start runner-install.service + BOOT->>BOOT: check /var/lib/runner/installed alt already installed BOOT-->>SD: exit 0 else not installed @@ -92,26 +88,19 @@ sequenceDiagram If you want to re-run install without reflashing, delete the marker file and restart the unit: ```bash -sudo rm -f /var/lib/template-appliance/installed -sudo systemctl restart template-appliance-install.service +sudo rm -f /var/lib/runner/installed +sudo systemctl restart runner-install.service ``` -## Mode behavior +## GitHub runner container hooks -The mode runners execute your commands: +`runner-service.sh` sets `ACTIONS_RUNNER_CONTAINER_HOOKS` when `container-hooks.sh` is present. -- Primary: `bash -lc "$APPLIANCE_PRIMARY_CMD"` -- Secondary: `bash -lc "$APPLIANCE_SECONDARY_CMD"` +This is intended to allow the runner to execute containerized jobs without Docker by routing the hook +callbacks through `ci-nspawn-run`. -If a command is missing, the service logs and sleeps (so the unit stays active and you can update config without -rapid restart loops). +Limitations: -## Healthcheck/failover - -The healthcheck service checks whether the primary service is active. - -- If primary is active, it exits success. -- If primary is inactive, it starts the secondary service. - -By default it checks `template-appliance-primary.service` and starts `template-appliance-secondary.service`, but you -can override them via `APPLIANCE_PRIMARY_SERVICE` and `APPLIANCE_SECONDARY_SERVICE`. +- Hooks are only used by the runner in scenarios where it runs steps in a container + (for example: workflows using job `container:`). +- `run_container_step` is not implemented (only `run_script_step` is routed). diff --git a/docs/config-examples.md b/docs/config-examples.md index e5f50f9..d234449 100644 --- a/docs/config-examples.md +++ b/docs/config-examples.md @@ -1,25 +1,20 @@ # Configuration examples -template-appliance is configured via `/etc/template-appliance/config.env`. +runner is configured via `/etc/runner/config.env`. Notes: - `APPLIANCE_REPO_URL` + `APPLIANCE_REPO_REF` are required for first-boot installs (bootstrap clones the repo). -- `APPLIANCE_PRIMARY_CMD` and `APPLIANCE_SECONDARY_CMD` are required for runtime. - A line like `FOO=` means “set but empty”. --- -## 1) Minimal: pin the repo + set mode commands +## 1) Minimal: pin the repo ```bash # Required: bootstrap pin -APPLIANCE_REPO_URL=https://github.com/your-org/template-appliance.git +APPLIANCE_REPO_URL=https://github.com/your-org/github-runner.git APPLIANCE_REPO_REF=main - -# Required: runtime commands -APPLIANCE_PRIMARY_CMD='echo "primary"; sleep infinity' -APPLIANCE_SECONDARY_CMD='echo "secondary"; sleep infinity' ``` --- @@ -27,27 +22,31 @@ APPLIANCE_SECONDARY_CMD='echo "secondary"; sleep infinity' ## 2) Deterministic installs: pin to a tag or commit SHA ```bash -APPLIANCE_REPO_URL=https://github.com/your-org/template-appliance.git +APPLIANCE_REPO_URL=https://github.com/your-org/github-runner.git APPLIANCE_REPO_REF=v0.1.0 - -APPLIANCE_PRIMARY_CMD='your-primary-binary --flag' -APPLIANCE_SECONDARY_CMD='your-secondary-binary --safe-mode' ``` --- -## 3) Customize checkout and install packages +## 3) Customize checkout, install packages, and nspawn settings ```bash -APPLIANCE_REPO_URL=https://github.com/your-org/template-appliance.git +APPLIANCE_REPO_URL=https://github.com/your-org/github-runner.git APPLIANCE_REPO_REF=main # Where bootstrap clones the repo -APPLIANCE_CHECKOUT_DIR=/opt/template-appliance +APPLIANCE_CHECKOUT_DIR=/opt/runner # Space-separated list of extra packages to install -APPLIANCE_APT_PACKAGES="jq ca-certificates" +APPLIANCE_APT_PACKAGES="ca-certificates curl git" + +# Runner installation location +RUNNER_ACTIONS_RUNNER_DIR=/opt/runner/actions-runner + +# systemd-nspawn base rootfs to use for ephemeral guests +RUNNER_NSPAWN_BASE_ROOTFS=/var/lib/runner/nspawn/base-rootfs -APPLIANCE_PRIMARY_CMD='jq --version && sleep infinity' -APPLIANCE_SECONDARY_CMD='echo fallback && sleep infinity' +# Optional bind mounts into the guest (space-separated) +RUNNER_NSPAWN_BIND="/dev/dri:/dev/dri" +RUNNER_NSPAWN_BIND_RO="/etc/resolv.conf:/etc/resolv.conf" ``` diff --git a/docs/glossary.md b/docs/glossary.md index fc1af2d..5db062e 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -4,10 +4,8 @@ Short definitions for terms used across the docs. ## Appliance concepts -- **Mode**: One of the mutually-exclusive runtime states managed by systemd. -- **Primary mode**: The main workload, run by `template-appliance-primary.service`. -- **Secondary mode**: The fallback workload, run by `template-appliance-secondary.service`. -- **Failover**: Automatically starting secondary mode if primary mode is not active. +- **Bootstrap**: The first-boot script that fetches/clones a pinned repo ref and runs the installer. +- **Install marker**: A file (default: `/var/lib/runner/installed`) that prevents rerunning install. - **Repo pinning**: Installing from a specific branch/tag/commit via `APPLIANCE_REPO_URL` + `APPLIANCE_REPO_REF`. ## Linux + systemd @@ -20,6 +18,17 @@ Short definitions for terms used across the docs. - **journald**: The system logging daemon used by systemd. - **journalctl**: The command used to read logs from journald. +## GitHub runner + +- **Container hooks**: A GitHub Actions runner integration point configured via + `ACTIONS_RUNNER_CONTAINER_HOOKS`. The runner invokes a hook script with a JSON payload and expects a + JSON response written to a response file. + +## Isolation + +- **systemd-nspawn**: A lightweight container manager that can boot a full userspace with a systemd + PID1 inside a guest. + ## Repo tooling - **Devcontainer**: A Docker image + configuration used to provide a consistent toolchain for development and CI. diff --git a/examples/config.env b/examples/config.env index d35e237..ec07f33 100644 --- a/examples/config.env +++ b/examples/config.env @@ -1,13 +1,15 @@ -# Example configuration for template-appliance -# Copy to: /etc/template-appliance/config.env +# Example configuration for runner +# Copy to: /etc/runner/config.env # Repo pinning for the one-time installer bootstrap (required) -APPLIANCE_REPO_URL=https://github.com/your-org/template-appliance.git +APPLIANCE_REPO_URL=https://github.com/your-org/github-runner.git APPLIANCE_REPO_REF=main -# Runtime commands (required) -APPLIANCE_PRIMARY_CMD='echo "primary"; sleep infinity' -APPLIANCE_SECONDARY_CMD='echo "secondary"; sleep infinity' - # Optional: install extra packages # APPLIANCE_APT_PACKAGES="jq" + +# Runner installation location +# RUNNER_ACTIONS_RUNNER_DIR=/opt/runner/actions-runner + +# systemd-nspawn base rootfs +# RUNNER_NSPAWN_BASE_ROOTFS=/var/lib/runner/nspawn/base-rootfs diff --git a/examples/config.env.example b/examples/config.env.example index 94219bc..05af00a 100644 --- a/examples/config.env.example +++ b/examples/config.env.example @@ -1,22 +1,23 @@ -# Example configuration for /etc/template-appliance/config.env +# Example configuration for /etc/runner/config.env # Repo pinning for the one-time installer bootstrap (required) -APPLIANCE_REPO_URL=https://github.com/your-org/template-appliance.git +APPLIANCE_REPO_URL=https://github.com/your-org/github-runner.git APPLIANCE_REPO_REF=main -# Runtime commands (required) -APPLIANCE_PRIMARY_CMD='echo "primary"; sleep infinity' -APPLIANCE_SECONDARY_CMD='echo "secondary"; sleep infinity' - # Optional: where bootstrap clones to -# APPLIANCE_CHECKOUT_DIR=/opt/template-appliance +# APPLIANCE_CHECKOUT_DIR=/opt/runner # Optional: extra apt packages for install (space-separated) # APPLIANCE_APT_PACKAGES="jq ca-certificates" -# Optional: healthcheck target units -# APPLIANCE_PRIMARY_SERVICE=template-appliance-primary.service -# APPLIANCE_SECONDARY_SERVICE=template-appliance-secondary.service - # Optional: dry-run (do not modify system; record intended actions) # APPLIANCE_DRY_RUN=1 + +# Runner installation location +# RUNNER_ACTIONS_RUNNER_DIR=/opt/runner/actions-runner + +# Container hooks + nspawn +# RUNNER_HOOKS_DIR=/usr/local/lib/runner +# RUNNER_NSPAWN_BASE_ROOTFS=/var/lib/runner/nspawn/base-rootfs +# RUNNER_NSPAWN_BIND="/dev/dri:/dev/dri" +# RUNNER_NSPAWN_BIND_RO="/etc/resolv.conf:/etc/resolv.conf" diff --git a/examples/pi-imager/user-data.example.yml b/examples/pi-imager/user-data.example.yml index a728ffa..c49783e 100644 --- a/examples/pi-imager/user-data.example.yml +++ b/examples/pi-imager/user-data.example.yml @@ -4,30 +4,32 @@ # Copy/adjust for your environment. write_files: - - path: /etc/template-appliance/config.env + - path: /etc/runner/config.env permissions: "0644" content: | # Core config (required) - APPLIANCE_REPO_URL=https://github.com/your-org/template-appliance.git + APPLIANCE_REPO_URL=https://github.com/your-org/github-runner.git APPLIANCE_REPO_REF=main - # App config (examples) - APPLIANCE_PRIMARY_CMD='echo "primary"; sleep infinity' - APPLIANCE_SECONDARY_CMD='echo "secondary"; sleep infinity' + # Optional: where to clone to + # APPLIANCE_CHECKOUT_DIR=/opt/runner - - path: /usr/local/lib/template-appliance/bootstrap.sh + # Runner installation location + # RUNNER_ACTIONS_RUNNER_DIR=/opt/runner/actions-runner + + - path: /usr/local/lib/runner/bootstrap.sh permissions: "0755" content: | #!/usr/bin/env bash set -euo pipefail - log() { echo "template-appliance bootstrap: $*" >&2; } + log() { echo "runner bootstrap: $*" >&2; } die() { log "$*"; exit 1; } require_cmd() { command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"; } network_ok() { getent hosts github.com >/dev/null 2>&1 && curl -fsS https://github.com >/dev/null 2>&1; } main() { - if [[ -f /var/lib/template-appliance/installed ]]; then + if [[ -f /var/lib/runner/installed ]]; then log "Marker present; nothing to do." exit 0 fi @@ -44,7 +46,7 @@ write_files: [[ -n "$repo_url" ]] || die "APPLIANCE_REPO_URL is required" [[ -n "$repo_ref" ]] || die "APPLIANCE_REPO_REF is required" - local checkout_dir="${APPLIANCE_CHECKOUT_DIR:-/opt/template-appliance}" + local checkout_dir="${APPLIANCE_CHECKOUT_DIR:-/opt/runner}" if [[ ! -d "$checkout_dir/.git" ]]; then log "Cloning $repo_url -> $checkout_dir" rm -rf "$checkout_dir" @@ -64,19 +66,19 @@ write_files: main "$@" - - path: /etc/systemd/system/template-appliance-install.service + - path: /etc/systemd/system/runner-install.service permissions: "0644" content: | [Unit] - Description=template-appliance one-time installer + Description=runner first-boot installer Wants=network-online.target After=network-online.target - ConditionPathExists=!/var/lib/template-appliance/installed + ConditionPathExists=!/var/lib/runner/installed [Service] Type=oneshot - EnvironmentFile=-/etc/template-appliance/config.env - ExecStart=/usr/local/lib/template-appliance/bootstrap.sh + EnvironmentFile=-/etc/runner/config.env + ExecStart=/usr/local/lib/runner/bootstrap.sh Restart=on-failure RestartSec=30 StartLimitIntervalSec=0 @@ -93,4 +95,4 @@ runcmd: - systemctl daemon-reload - - bash - -lc - - systemctl enable --now template-appliance-install.service + - systemctl enable --now runner-install.service diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 5638386..1767b39 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -16,7 +16,7 @@ if [[ -d "$SCRIPT_DIR/lib" ]]; then elif [[ -d "$SCRIPT_DIR/../lib" ]]; then LIB_DIR="$SCRIPT_DIR/../lib" else - echo "template-appliance bootstrap [error]: unable to locate scripts/lib" >&2 + echo "runner bootstrap [error]: unable to locate scripts/lib" >&2 exit 1 fi @@ -33,10 +33,10 @@ network_ok() { } main() { - export APPLIANCE_LOG_PREFIX="template-appliance bootstrap" + export APPLIANCE_LOG_PREFIX="runner bootstrap" local installed_marker - installed_marker="${APPLIANCE_INSTALLED_MARKER:-$(appliance_path /var/lib/template-appliance/installed)}" + installed_marker="${APPLIANCE_INSTALLED_MARKER:-$(appliance_path /var/lib/runner/installed)}" if [[ -f "$installed_marker" ]]; then cover_path "bootstrap:installed-marker" @@ -60,14 +60,14 @@ main() { if [[ -z "$repo_url" ]]; then cover_path "bootstrap:missing-repo-url" - die "APPLIANCE_REPO_URL is required (set in /etc/template-appliance/config.env)" + die "APPLIANCE_REPO_URL is required (set in /etc/runner/config.env)" fi if [[ -z "$repo_ref" ]]; then cover_path "bootstrap:missing-repo-ref" die "APPLIANCE_REPO_REF is required (branch/tag/commit)" fi - local checkout_dir="${APPLIANCE_CHECKOUT_DIR:-$(appliance_path /opt/template-appliance)}" + local checkout_dir="${APPLIANCE_CHECKOUT_DIR:-$(appliance_path /opt/runner)}" if [[ ! -d "$checkout_dir/.git" ]]; then cover_path "bootstrap:clone" diff --git a/scripts/ci-nspawn-run.sh b/scripts/ci-nspawn-run.sh new file mode 100755 index 0000000..f11420b --- /dev/null +++ b/scripts/ci-nspawn-run.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +LIB_DIR="" +if [[ -d "$SCRIPT_DIR/lib" ]]; then + LIB_DIR="$SCRIPT_DIR/lib" +elif [[ -d "$SCRIPT_DIR/../lib" ]]; then + LIB_DIR="$SCRIPT_DIR/../lib" +else + echo "ci-nspawn-run [error]: unable to locate scripts/lib" >&2 + exit 1 +fi + +# shellcheck source=scripts/lib/logging.sh +source "$LIB_DIR/logging.sh" +# shellcheck source=scripts/lib/common.sh +source "$LIB_DIR/common.sh" +# shellcheck source=scripts/lib/config.sh +source "$LIB_DIR/config.sh" + +usage() { + cat >&2 << 'EOF' +Usage: + ci-nspawn-run + +Boots an ephemeral systemd-nspawn guest (systemd PID1) and runs the provided +command inside it. + +Bind mounts: + - GitHub workspace is mounted to /ci/work. + +Config: + All configuration comes from /etc/runner/config.env. +EOF +} + +machine_name() { + local ts + ts="$(date +%s)" + echo "runner-job-${ts}-$$" +} + +wait_for_machine() { + local machine="$1" + local deadline_s="${2:-20}" + + local start + start="$(date +%s)" + + while true; do + if machinectl show "$machine" > /dev/null 2>&1; then + return 0 + fi + + local now + now="$(date +%s)" + if ((now - start >= deadline_s)); then + return 1 + fi + sleep 0.2 + done +} + +main() { + export APPLIANCE_LOG_PREFIX="ci-nspawn-run" + load_config_env + + local -a env_kv=() + local workdir="" + + while [[ $# -gt 0 ]]; do + case "$1" in + -h | --help) + usage + return 0 + ;; + --cwd) + workdir="${2:-}" + shift 2 + ;; + --env) + env_kv+=("${2:-}") + shift 2 + ;; + --) + shift + break + ;; + *) + break + ;; + esac + done + + if [[ $# -lt 1 ]]; then + cover_path "ci-nspawn-run:missing-command" + usage + die "Missing command" + fi + + local workspace="${GITHUB_WORKSPACE:-$(pwd)}" + + local base_rootfs + base_rootfs="${RUNNER_NSPAWN_BASE_ROOTFS:-$(appliance_path /var/lib/runner/nspawn/base-rootfs)}" + if [[ ! -d "$base_rootfs" ]]; then + cover_path "ci-nspawn-run:base-missing" + die "Base rootfs missing: $base_rootfs" + fi + + machine="$(machine_name)" + + local -a systemd_run_args=( + -M "$machine" + --wait + --collect + --pipe + ) + if [[ -n "$workdir" ]]; then + systemd_run_args+=("--working-directory=$workdir") + fi + if [[ ${#env_kv[@]} -gt 0 ]]; then + local kv + for kv in "${env_kv[@]}"; do + systemd_run_args+=("--setenv=$kv") + done + fi + + local -a nspawn_args=( + --quiet + --boot + --ephemeral + --machine="$machine" + --directory="$base_rootfs" + --bind="$workspace:/ci/work" + --setenv=GITHUB_WORKSPACE=/ci/work + ) + + # Optional extra bind mounts from config. + # Format: space-separated entries: + # RUNNER_NSPAWN_BIND="/dev/dri:/dev/dri /dev/input:/dev/input" + if [[ -n "${RUNNER_NSPAWN_BIND:-}" ]]; then + cover_path "ci-nspawn-run:bind-extra" + local entry + for entry in ${RUNNER_NSPAWN_BIND}; do + nspawn_args+=(--bind="$entry") + done + fi + + if [[ -n "${RUNNER_NSPAWN_BIND_RO:-}" ]]; then + cover_path "ci-nspawn-run:bind-ro" + local entry + for entry in ${RUNNER_NSPAWN_BIND_RO}; do + nspawn_args+=(--bind-ro="$entry") + done + fi + + cover_path "ci-nspawn-run:start" + + # In dry-run mode we only record what we *would* run. Do not require + # systemd-nspawn/machinectl/systemd-run, since CI/devcontainers may not have + # them installed. + if [[ "${APPLIANCE_DRY_RUN:-0}" == "1" ]]; then + record_call "systemd-nspawn ${nspawn_args[*]}" + record_call "systemd-run ${systemd_run_args[*]} -- /bin/bash -lc " + cover_path "ci-nspawn-run:dry-run" + return 0 + fi + + require_cmd systemd-nspawn + require_cmd machinectl + require_cmd systemd-run + + nspawn_pid="" + cleanup() { + # Best-effort teardown. + if [[ -n "$machine" ]]; then + machinectl terminate "$machine" > /dev/null 2>&1 || true + fi + if [[ -n "$nspawn_pid" ]]; then + kill "$nspawn_pid" > /dev/null 2>&1 || true + wait "$nspawn_pid" > /dev/null 2>&1 || true + fi + cover_path "ci-nspawn-run:cleanup" + } + trap cleanup EXIT + + systemd-nspawn "${nspawn_args[@]}" & + nspawn_pid="$!" + + if ! wait_for_machine "$machine" "${RUNNER_NSPAWN_READY_TIMEOUT_S:-20}"; then + cover_path "ci-nspawn-run:machine-timeout" + die "Machine did not come up: $machine" + fi + + cover_path "ci-nspawn-run:exec" + # Run the command inside the guest via systemd. + systemd-run "${systemd_run_args[@]}" -- /bin/bash -lc "$(printf '%q ' "$@")" + + cover_path "ci-nspawn-run:poweroff" + machinectl poweroff "$machine" > /dev/null 2>&1 || true +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/scripts/container-hooks.sh b/scripts/container-hooks.sh new file mode 100755 index 0000000..c6349d6 --- /dev/null +++ b/scripts/container-hooks.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +set -euo pipefail + +# GitHub Actions Runner "container hooks" entrypoint. +# +# This script is used when ACTIONS_RUNNER_CONTAINER_HOOKS points to it. +# It implements the minimal subset of the container hooks protocol needed to +# run job containers without Docker. +# +# Contract intent: route container job execution through ci-nspawn-run. + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +LIB_DIR="" +if [[ -d "$SCRIPT_DIR/lib" ]]; then + LIB_DIR="$SCRIPT_DIR/lib" +elif [[ -d "$SCRIPT_DIR/../lib" ]]; then + LIB_DIR="$SCRIPT_DIR/../lib" +else + echo "container-hooks [error]: unable to locate scripts/lib" >&2 + exit 1 +fi + +# shellcheck source=scripts/lib/logging.sh +source "$LIB_DIR/logging.sh" +# shellcheck source=scripts/lib/common.sh +source "$LIB_DIR/common.sh" +# shellcheck source=scripts/lib/config.sh +source "$LIB_DIR/config.sh" + +payload_file="" + +cleanup_payload() { + if [[ -n "${payload_file:-}" ]]; then + rm -f "$payload_file" > /dev/null 2>&1 || true + fi +} + +json_get_required_str() { + local payload_path="$1" + local key="$2" + jq -er --arg key "$key" '.[ $key ] | select(type == "string" and length > 0)' "$payload_path" +} + +json_args_get_str() { + local payload_path="$1" + local key="$2" + jq -r --arg key "$key" '((.args // {})[$key] // "") | if type == "string" then . else "" end' "$payload_path" +} + +json_args_get_str_list() { + local payload_path="$1" + local key="$2" + jq -r --arg key "$key" '((.args // {})[$key] // []) | if type == "array" then .[] | select(type == "string") else empty end' "$payload_path" +} + +json_args_get_env_kv_lines() { + local payload_path="$1" + jq -r '(.args.environmentVariables // {}) | if type == "object" then to_entries[] | select(.key | type == "string" and . != "") | select(.value | type == "string") | "\(.key)\t\(.value)" else empty end' "$payload_path" +} + +main() { + export APPLIANCE_LOG_PREFIX="runner hooks" + load_config_env + + require_cmd jq + + cover_path "container-hooks:called" + + payload_file="$(mktemp)" + trap cleanup_payload EXIT + cat > "$payload_file" + + local cmd + if ! cmd="$(json_get_required_str "$payload_file" "command")"; then + die "Invalid container hook payload (unable to read command)" + fi + + local response_file + if ! response_file="$(json_get_required_str "$payload_file" "responseFile")"; then + die "Invalid container hook payload (unable to read responseFile)" + fi + + # The runner creates the response file before running the hook, but we + # defensively ensure the parent dir exists. + mkdir -p "$(appliance_dirname "$response_file")" + + write_response() { + local json="$1" + printf '%s\n' "$json" > "$response_file" + } + + case "$cmd" in + prepare_job) + cover_path "container-hooks:prepare-job" + + local has_container + has_container="$( + jq -r '(.args // {}) | if (type == "object" and .container != null) then "1" else "0" end' "$payload_file" + )" + + if [[ "$has_container" == "1" ]]; then + # Runner requires isAlpine when a job container exists. + write_response '{"state":{},"isAlpine":false}' + else + write_response '{"state":{}}' + fi + ;; + + run_script_step) + cover_path "container-hooks:run-script-step" + + local entry_point + entry_point="$(json_args_get_str "$payload_file" "entryPoint")" + [[ -n "$entry_point" ]] || die "Invalid container hook payload (missing entryPoint)" + + local working_directory + working_directory="$(json_args_get_str "$payload_file" "workingDirectory")" + + local -a entry_args=() + local entry_args_file + entry_args_file="$(mktemp)" + json_args_get_str_list "$payload_file" "entryPointArgs" > "$entry_args_file" || true + local -a entry_arg_lines=() + mapfile -t entry_arg_lines < "$entry_args_file" || true + local line + for line in "${entry_arg_lines[@]}"; do + [[ -n "$line" ]] || continue + entry_args+=("$line") + done + rm -f "$entry_args_file" > /dev/null 2>&1 || true + + local -a env_args=() + local env_args_file + env_args_file="$(mktemp)" + json_args_get_env_kv_lines "$payload_file" > "$env_args_file" || true + local -a env_kv_lines=() + mapfile -t env_kv_lines < "$env_args_file" || true + local kv + for kv in "${env_kv_lines[@]}"; do + [[ -n "$kv" ]] || continue + local k + local v + k="${kv%%$'\t'*}" + v="${kv#*$'\t'}" + [[ -n "$k" ]] || continue + env_args+=(--env "$k=$v") + done + rm -f "$env_args_file" > /dev/null 2>&1 || true + + local -a nspawn_cmd=("$SCRIPT_DIR/ci-nspawn-run.sh") + if [[ -n "$working_directory" ]]; then + nspawn_cmd+=(--cwd "$working_directory") + fi + if [[ ${#env_args[@]} -gt 0 ]]; then + nspawn_cmd+=("${env_args[@]}") + fi + nspawn_cmd+=(-- "$entry_point" "${entry_args[@]}") + + "${nspawn_cmd[@]}" + + write_response '{"state":{}}' + ;; + + cleanup_job) + cover_path "container-hooks:cleanup-job" + write_response '{"state":{}}' + ;; + + run_container_step) + cover_path "container-hooks:run-container-step" + write_response '{"state":{}}' + die "container hooks: run_container_step not supported" + ;; + + *) + cover_path "container-hooks:unknown-command" + write_response '{"state":{}}' + die "Unknown container hook command: $cmd" + ;; + esac +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh deleted file mode 100755 index 50f32b2..0000000 --- a/scripts/healthcheck.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" - -LIB_DIR="" -if [[ -d "$SCRIPT_DIR/lib" ]]; then - LIB_DIR="$SCRIPT_DIR/lib" -elif [[ -d "$SCRIPT_DIR/../lib" ]]; then - LIB_DIR="$SCRIPT_DIR/../lib" -else - echo "healthcheck [error]: unable to locate scripts/lib" >&2 - exit 1 -fi - -# shellcheck source=scripts/lib/logging.sh -source "$LIB_DIR/logging.sh" -# shellcheck source=scripts/lib/common.sh -source "$LIB_DIR/common.sh" -# shellcheck source=scripts/lib/config.sh -source "$LIB_DIR/config.sh" - -is_active() { systemctl is-active --quiet "$1"; } - -main() { - export APPLIANCE_LOG_PREFIX="healthcheck" - load_config_env - - local primary_service="${APPLIANCE_PRIMARY_SERVICE:-template-appliance-primary.service}" - local secondary_service="${APPLIANCE_SECONDARY_SERVICE:-template-appliance-secondary.service}" - - if is_active "$primary_service"; then - cover_path "healthcheck:primary-active" - log "$primary_service active" - exit 0 - fi - cover_path "healthcheck:primary-inactive" - - log "$primary_service inactive; starting $secondary_service" - if [[ "${APPLIANCE_DRY_RUN:-0}" == "1" ]]; then - cover_path "healthcheck:dry-run" - record_call "systemctl start $secondary_service" - exit 0 - fi - - cover_path "healthcheck:start-secondary" - run_cmd systemctl start "$secondary_service" || true -} - -if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then - main "$@" -fi diff --git a/scripts/install.sh b/scripts/install.sh index 1bc5e33..4b59c67 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -10,8 +10,8 @@ source "$SCRIPT_DIR/lib/common.sh" # shellcheck source=scripts/lib/config.sh source "$SCRIPT_DIR/lib/config.sh" -MARKER_FILE="${APPLIANCE_INSTALLED_MARKER:-$(appliance_path /var/lib/template-appliance/installed)}" -LOCK_FILE="${APPLIANCE_INSTALL_LOCK:-$(appliance_path /var/lock/template-appliance-install.lock)}" +MARKER_FILE="${APPLIANCE_INSTALLED_MARKER:-$(appliance_path /var/lib/runner/installed)}" +LOCK_FILE="${APPLIANCE_INSTALL_LOCK:-$(appliance_path /var/lock/runner-install.lock)}" require_root() { if [[ "${APPLIANCE_ALLOW_NON_ROOT:-0}" == "1" ]]; then @@ -62,6 +62,7 @@ install_packages() { ca-certificates \ curl \ git \ + jq \ "${extra_pkgs[@]}" } @@ -73,8 +74,8 @@ install_files() { local lib_dir local bin_dir local systemd_dir - etc_dir="$(appliance_path /etc/template-appliance)" - lib_dir="${APPLIANCE_LIBDIR:-$(appliance_path /usr/local/lib/template-appliance)}" + etc_dir="$(appliance_path /etc/runner)" + lib_dir="${APPLIANCE_LIBDIR:-$(appliance_path /usr/local/lib/runner)}" bin_dir="${APPLIANCE_BINDIR:-$(appliance_path /usr/local/bin)}" systemd_dir="${APPLIANCE_SYSTEMD_DIR:-$(appliance_path /etc/systemd/system)}" @@ -87,6 +88,13 @@ install_files() { # Install bootstrap + core installer assets. run_cmd install -m 0755 "$repo_root/scripts/bootstrap.sh" "$lib_dir/bootstrap.sh" + # Install runner management + job isolation helpers. + run_cmd install -m 0755 "$repo_root/scripts/runner-service.sh" "$lib_dir/runner-service.sh" + run_cmd install -m 0755 "$repo_root/scripts/container-hooks.sh" "$lib_dir/container-hooks.sh" + run_cmd install -m 0755 "$repo_root/scripts/uninstall.sh" "$lib_dir/uninstall.sh" + run_cmd install -m 0755 "$repo_root/scripts/ci-nspawn-run.sh" "$bin_dir/ci-nspawn-run" + run_cmd ln -sf "$lib_dir/uninstall.sh" "$bin_dir/runner-uninstall" + # Install shared lib helpers. if [[ -d "$repo_root/scripts/lib" ]]; then run_cmd install -m 0755 "$repo_root/scripts/lib/common.sh" "$lib_dir/lib/common.sh" @@ -97,38 +105,17 @@ install_files() { fi fi - # Install appliance scripts. - if [[ -f "$repo_root/scripts/mode/primary-mode.sh" ]]; then - run_cmd install -m 0755 "$repo_root/scripts/mode/primary-mode.sh" "$lib_dir/primary-mode.sh" - fi - if [[ -f "$repo_root/scripts/mode/secondary-mode.sh" ]]; then - run_cmd install -m 0755 "$repo_root/scripts/mode/secondary-mode.sh" "$lib_dir/secondary-mode.sh" - fi - if [[ -f "$repo_root/scripts/mode/enter-primary-mode.sh" ]]; then - run_cmd install -m 0755 "$repo_root/scripts/mode/enter-primary-mode.sh" "$lib_dir/enter-primary-mode.sh" - fi - if [[ -f "$repo_root/scripts/mode/enter-secondary-mode.sh" ]]; then - run_cmd install -m 0755 "$repo_root/scripts/mode/enter-secondary-mode.sh" "$lib_dir/enter-secondary-mode.sh" - fi - - if [[ -f "$repo_root/scripts/healthcheck.sh" ]]; then - run_cmd install -m 0755 "$repo_root/scripts/healthcheck.sh" "$lib_dir/healthcheck.sh" - fi + # Only runner scripts are installed. # Install systemd units. - run_cmd install -m 0644 "$repo_root/systemd/template-appliance-install.service" "$systemd_dir/template-appliance-install.service" - run_cmd install -m 0644 "$repo_root/systemd/template-appliance-primary.service" "$systemd_dir/template-appliance-primary.service" - run_cmd install -m 0644 "$repo_root/systemd/template-appliance-secondary.service" "$systemd_dir/template-appliance-secondary.service" - run_cmd install -m 0644 "$repo_root/systemd/template-appliance-healthcheck.service" "$systemd_dir/template-appliance-healthcheck.service" - run_cmd install -m 0644 "$repo_root/systemd/template-appliance-healthcheck.timer" "$systemd_dir/template-appliance-healthcheck.timer" + run_cmd install -m 0644 "$repo_root/systemd/runner-install.service" "$systemd_dir/runner-install.service" + run_cmd install -m 0644 "$repo_root/systemd/runner.service" "$systemd_dir/runner.service" } enable_services() { run_cmd systemctl daemon-reload - # Default to primary mode on boot; secondary mode is started by healthcheck/failover. - run_cmd systemctl enable template-appliance-primary.service > /dev/null 2>&1 || true - run_cmd systemctl enable template-appliance-healthcheck.timer > /dev/null 2>&1 || true + run_cmd systemctl enable runner.service > /dev/null 2>&1 || true } write_marker() { @@ -145,7 +132,7 @@ write_marker() { main() { require_root load_config_env - export APPLIANCE_LOG_PREFIX="template-appliance install" + export APPLIANCE_LOG_PREFIX="runner install" if [[ -f "$MARKER_FILE" ]]; then cover_path "install:marker-present-early" diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index 99d9aab..7404938 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -39,6 +39,28 @@ appliance_root() { # Filesystem root prefix for tests. # Use APPLIANCE_ROOT="$TEST_ROOT" to make scripts operate within a fake FS. local root="${APPLIANCE_ROOT:-/}" + + if [[ -n "${APPLIANCE_ROOT:-}" ]]; then + # Guard against common misquoting mistakes that can create junk directories + # in the current working directory (e.g. '"mp_dir/work"'). + if [[ "$root" != /* ]]; then + appliance__cover_path_raw "lib-common:root-invalid-relative" + if [[ "$(type -t die || true)" == "function" ]]; then + die "APPLIANCE_ROOT must be an absolute path: $root" + fi + echo "APPLIANCE_ROOT must be an absolute path: $root" >&2 + return 1 + fi + if [[ "$root" == *'"'* || "$root" == *"'"* ]]; then + appliance__cover_path_raw "lib-common:root-invalid-quote" + if [[ "$(type -t die || true)" == "function" ]]; then + die "APPLIANCE_ROOT must not contain quote characters: $root" + fi + echo "APPLIANCE_ROOT must not contain quote characters: $root" >&2 + return 1 + fi + fi + # Normalize trailing slash (keep '/' as-is). if [[ "$root" != "/" ]]; then appliance__cover_path_raw "lib-common:root-non-slash" diff --git a/scripts/lib/config.sh b/scripts/lib/config.sh index 2fe5706..4c87ffb 100755 --- a/scripts/lib/config.sh +++ b/scripts/lib/config.sh @@ -5,19 +5,11 @@ set -euo pipefail appliance_config_env_path() { # Location of the config.env file. - # Tests can override with APPLIANCE_CONFIG_ENV. - local configured="${APPLIANCE_CONFIG_ENV:-}" - if [[ -n "$configured" ]]; then - if declare -F cover_path > /dev/null 2>&1; then - cover_path "lib-config:env-override" - fi - echo "$configured" - return 0 - fi + # Contract: all configuration comes from /etc/runner/config.env. if declare -F cover_path > /dev/null 2>&1; then cover_path "lib-config:env-default" fi - appliance_path "/etc/template-appliance/config.env" + appliance_path "/etc/runner/config.env" } load_config_env() { diff --git a/scripts/lib/logging.sh b/scripts/lib/logging.sh index 94a75b4..bbe5ce6 100755 --- a/scripts/lib/logging.sh +++ b/scripts/lib/logging.sh @@ -27,7 +27,7 @@ appliance_log_prefix() { else logging__cover_path_raw "lib-logging:prefix-default" fi - echo "${APPLIANCE_LOG_PREFIX:-template-appliance}" + echo "${APPLIANCE_LOG_PREFIX:-runner}" } log() { diff --git a/scripts/mode/enter-primary-mode.sh b/scripts/mode/enter-primary-mode.sh deleted file mode 100644 index 3df2995..0000000 --- a/scripts/mode/enter-primary-mode.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" - -LIB_DIR="" -if [[ -d "$SCRIPT_DIR/lib" ]]; then - LIB_DIR="$SCRIPT_DIR/lib" -elif [[ -d "$SCRIPT_DIR/../lib" ]]; then - LIB_DIR="$SCRIPT_DIR/../lib" -else - echo "enter-primary-mode [error]: unable to locate scripts/lib" >&2 - exit 1 -fi - -# shellcheck source=scripts/lib/logging.sh -source "$LIB_DIR/logging.sh" -# shellcheck source=scripts/lib/common.sh -source "$LIB_DIR/common.sh" - -main() { - export APPLIANCE_LOG_PREFIX="enter-primary-mode" - - cover_path "enter-primary-mode:stop-secondary" - svc_stop template-appliance-secondary.service || true - - cover_path "enter-primary-mode:start-primary" - svc_start template-appliance-primary.service -} - -if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then - main "$@" -fi diff --git a/scripts/mode/enter-secondary-mode.sh b/scripts/mode/enter-secondary-mode.sh deleted file mode 100644 index 6936725..0000000 --- a/scripts/mode/enter-secondary-mode.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" - -LIB_DIR="" -if [[ -d "$SCRIPT_DIR/lib" ]]; then - LIB_DIR="$SCRIPT_DIR/lib" -elif [[ -d "$SCRIPT_DIR/../lib" ]]; then - LIB_DIR="$SCRIPT_DIR/../lib" -else - echo "enter-secondary-mode [error]: unable to locate scripts/lib" >&2 - exit 1 -fi - -# shellcheck source=scripts/lib/logging.sh -source "$LIB_DIR/logging.sh" -# shellcheck source=scripts/lib/common.sh -source "$LIB_DIR/common.sh" - -main() { - export APPLIANCE_LOG_PREFIX="enter-secondary-mode" - - cover_path "enter-secondary-mode:stop-primary" - svc_stop template-appliance-primary.service || true - - cover_path "enter-secondary-mode:start-secondary" - svc_start template-appliance-secondary.service -} - -if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then - main "$@" -fi diff --git a/scripts/mode/primary-mode.sh b/scripts/mode/primary-mode.sh deleted file mode 100644 index 082ecb6..0000000 --- a/scripts/mode/primary-mode.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" - -LIB_DIR="" -if [[ -d "$SCRIPT_DIR/lib" ]]; then - LIB_DIR="$SCRIPT_DIR/lib" -elif [[ -d "$SCRIPT_DIR/../lib" ]]; then - LIB_DIR="$SCRIPT_DIR/../lib" -else - echo "primary-mode [error]: unable to locate scripts/lib" >&2 - exit 1 -fi - -# shellcheck source=scripts/lib/logging.sh -source "$LIB_DIR/logging.sh" -# shellcheck source=scripts/lib/common.sh -source "$LIB_DIR/common.sh" -# shellcheck source=scripts/lib/config.sh -source "$LIB_DIR/config.sh" - -main() { - export APPLIANCE_LOG_PREFIX="primary-mode" - load_config_env - - if [[ "${APPLIANCE_DRY_RUN:-0}" == "1" ]]; then - cover_path "primary-mode:dry-run" - record_call "exec primary" - exit 0 - fi - - if [[ -z "${APPLIANCE_PRIMARY_CMD:-}" ]]; then - cover_path "primary-mode:cmd-missing" - log "APPLIANCE_PRIMARY_CMD is not set; sleeping" - while true; do sleep 3600; done - fi - - cover_path "primary-mode:cmd-present" - log "Starting primary command" - exec bash -lc "$APPLIANCE_PRIMARY_CMD" -} - -if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then - main "$@" -fi diff --git a/scripts/mode/secondary-mode.sh b/scripts/mode/secondary-mode.sh deleted file mode 100644 index d0ced20..0000000 --- a/scripts/mode/secondary-mode.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" - -LIB_DIR="" -if [[ -d "$SCRIPT_DIR/lib" ]]; then - LIB_DIR="$SCRIPT_DIR/lib" -elif [[ -d "$SCRIPT_DIR/../lib" ]]; then - LIB_DIR="$SCRIPT_DIR/../lib" -else - echo "secondary-mode [error]: unable to locate scripts/lib" >&2 - exit 1 -fi - -# shellcheck source=scripts/lib/logging.sh -source "$LIB_DIR/logging.sh" -# shellcheck source=scripts/lib/common.sh -source "$LIB_DIR/common.sh" -# shellcheck source=scripts/lib/config.sh -source "$LIB_DIR/config.sh" - -main() { - export APPLIANCE_LOG_PREFIX="secondary-mode" - load_config_env - - if [[ "${APPLIANCE_DRY_RUN:-0}" == "1" ]]; then - cover_path "secondary-mode:dry-run" - record_call "exec secondary" - exit 0 - fi - - if [[ -z "${APPLIANCE_SECONDARY_CMD:-}" ]]; then - cover_path "secondary-mode:cmd-missing" - log "APPLIANCE_SECONDARY_CMD is not set; sleeping" - while true; do sleep 3600; done - fi - - cover_path "secondary-mode:cmd-present" - log "Starting secondary command" - exec bash -lc "$APPLIANCE_SECONDARY_CMD" -} - -if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then - main "$@" -fi diff --git a/scripts/runner-service.sh b/scripts/runner-service.sh new file mode 100755 index 0000000..548d673 --- /dev/null +++ b/scripts/runner-service.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +LIB_DIR="" +if [[ -d "$SCRIPT_DIR/lib" ]]; then + LIB_DIR="$SCRIPT_DIR/lib" +elif [[ -d "$SCRIPT_DIR/../lib" ]]; then + LIB_DIR="$SCRIPT_DIR/../lib" +else + echo "runner-service [error]: unable to locate scripts/lib" >&2 + exit 1 +fi + +# shellcheck source=scripts/lib/logging.sh +source "$LIB_DIR/logging.sh" +# shellcheck source=scripts/lib/common.sh +source "$LIB_DIR/common.sh" +# shellcheck source=scripts/lib/config.sh +source "$LIB_DIR/config.sh" + +main() { + export APPLIANCE_LOG_PREFIX="runner runner" + load_config_env + + local runner_dir="${RUNNER_ACTIONS_RUNNER_DIR:-$(appliance_path /opt/runner/actions-runner)}" + local hook_dir="${RUNNER_HOOKS_DIR:-$(appliance_path /usr/local/lib/runner)}" + + if [[ ! -x "$runner_dir/runsvc.sh" ]]; then + cover_path "runner-service:missing-runner" + die "Runner not installed/configured: missing $runner_dir/runsvc.sh" + fi + + # Prefer using container hooks to avoid Docker. + # If jobs use `container:`, these hooks can route execution through nspawn. + if [[ -x "$hook_dir/container-hooks.sh" ]]; then + export ACTIONS_RUNNER_CONTAINER_HOOKS="$hook_dir/container-hooks.sh" + cover_path "runner-service:container-hooks" + else + cover_path "runner-service:no-container-hooks" + fi + + cover_path "runner-service:exec" + exec "$runner_dir/runsvc.sh" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..f77b933 --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=scripts/lib/logging.sh +source "$SCRIPT_DIR/lib/logging.sh" +# shellcheck source=scripts/lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" +# shellcheck source=scripts/lib/config.sh +source "$SCRIPT_DIR/lib/config.sh" + +MARKER_FILE="${APPLIANCE_INSTALLED_MARKER:-$(appliance_path /var/lib/runner/installed)}" +LOCK_FILE="${APPLIANCE_INSTALL_LOCK:-$(appliance_path /var/lock/runner-install.lock)}" + +require_root() { + if [[ "${APPLIANCE_ALLOW_NON_ROOT:-0}" == "1" ]]; then + cover_path "uninstall:allow-non-root" + return 0 + fi + local effective_uid="${APPLIANCE_EUID_OVERRIDE:-${EUID:-$(id -u)}}" + if [[ "$effective_uid" -ne 0 ]]; then + cover_path "uninstall:root-required" + die "Must run as root" + fi + cover_path "uninstall:root-ok" +} + +main() { + require_root + load_config_env + export APPLIANCE_LOG_PREFIX="runner uninstall" + + local lib_dir="${APPLIANCE_LIBDIR:-$(appliance_path /usr/local/lib/runner)}" + local bin_dir="${APPLIANCE_BINDIR:-$(appliance_path /usr/local/bin)}" + local systemd_dir="${APPLIANCE_SYSTEMD_DIR:-$(appliance_path /etc/systemd/system)}" + + cover_path "uninstall:stop-services" + run_cmd systemctl disable --now runner.service > /dev/null 2>&1 || true + run_cmd systemctl disable --now runner-install.service > /dev/null 2>&1 || true + run_cmd systemctl daemon-reload > /dev/null 2>&1 || true + + cover_path "uninstall:remove-units" + run_cmd rm -f "$systemd_dir/runner.service" "$systemd_dir/runner-install.service" || true + + cover_path "uninstall:remove-bins" + run_cmd rm -f "$bin_dir/ci-nspawn-run" "$bin_dir/runner-uninstall" || true + + cover_path "uninstall:remove-lib" + run_cmd rm -rf "$lib_dir" || true + + cover_path "uninstall:remove-state" + run_cmd rm -rf "$(appliance_path /var/lib/runner)" || true + run_cmd rm -f "$LOCK_FILE" || true + run_cmd rm -f "$MARKER_FILE" || true + + cover_path "uninstall:remove-config" + run_cmd rm -rf "$(appliance_path /etc/runner)" || true + + cover_path "uninstall:done" + log "Uninstall complete" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/systemd/runner-install.service b/systemd/runner-install.service new file mode 100644 index 0000000..28bad7b --- /dev/null +++ b/systemd/runner-install.service @@ -0,0 +1,19 @@ +[Unit] +Description=runner first-boot installer +Wants=network-online.target +After=network-online.target + +StartLimitIntervalSec=0 + +# Don't rerun once installed. +ConditionPathExists=!/var/lib/runner/installed + +[Service] +Type=oneshot +EnvironmentFile=-/etc/runner/config.env +ExecStart=/usr/local/lib/runner/bootstrap.sh +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/systemd/runner.service b/systemd/runner.service new file mode 100644 index 0000000..cfac826 --- /dev/null +++ b/systemd/runner.service @@ -0,0 +1,14 @@ +[Unit] +Description=GitHub Actions runner (single) +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +EnvironmentFile=-/etc/runner/config.env +ExecStart=/usr/local/lib/runner/runner-service.sh +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/systemd/template-appliance-healthcheck.service b/systemd/template-appliance-healthcheck.service deleted file mode 100644 index cd5e3fe..0000000 --- a/systemd/template-appliance-healthcheck.service +++ /dev/null @@ -1,7 +0,0 @@ -[Unit] -Description=template-appliance healthcheck/failover - -[Service] -Type=oneshot -EnvironmentFile=-/etc/template-appliance/config.env -ExecStart=/usr/local/lib/template-appliance/healthcheck.sh diff --git a/systemd/template-appliance-healthcheck.timer b/systemd/template-appliance-healthcheck.timer deleted file mode 100644 index be015bf..0000000 --- a/systemd/template-appliance-healthcheck.timer +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=template-appliance healthcheck timer - -[Timer] -OnBootSec=1min -OnUnitActiveSec=1min -Unit=template-appliance-healthcheck.service - -[Install] -WantedBy=timers.target diff --git a/systemd/template-appliance-install.service b/systemd/template-appliance-install.service deleted file mode 100644 index c169e98..0000000 --- a/systemd/template-appliance-install.service +++ /dev/null @@ -1,24 +0,0 @@ -[Unit] -Description=template-appliance one-time installer -Wants=network-online.target -After=network-online.target - -# Never re-run once successful. -ConditionPathExists=!/var/lib/template-appliance/installed - -# Avoid systemd start-rate limiting while retrying. -StartLimitIntervalSec=0 - -[Service] -Type=oneshot -EnvironmentFile=-/etc/template-appliance/config.env - -# Bootstrap is responsible for acquiring the installer and executing it. -ExecStart=/usr/local/lib/template-appliance/bootstrap.sh - -# Retry until network is available and installation succeeds. -Restart=on-failure -RestartSec=30 - -[Install] -WantedBy=multi-user.target diff --git a/systemd/template-appliance-primary.service b/systemd/template-appliance-primary.service deleted file mode 100644 index 744cca2..0000000 --- a/systemd/template-appliance-primary.service +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=template-appliance primary mode -Wants=network.target -After=network.target - -[Service] -Type=simple -EnvironmentFile=-/etc/template-appliance/config.env -ExecStart=/usr/local/lib/template-appliance/primary-mode.sh -Restart=on-failure -RestartSec=2 - -[Install] -WantedBy=multi-user.target diff --git a/systemd/template-appliance-secondary.service b/systemd/template-appliance-secondary.service deleted file mode 100644 index 52d41bc..0000000 --- a/systemd/template-appliance-secondary.service +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=template-appliance secondary mode -Wants=network.target -After=network.target - -[Service] -Type=simple -EnvironmentFile=-/etc/template-appliance/config.env -ExecStart=/usr/local/lib/template-appliance/secondary-mode.sh -Restart=on-failure -RestartSec=2 - -[Install] -WantedBy=multi-user.target diff --git a/tests/bin/kcov-line-coverage.sh b/tests/bin/kcov-line-coverage.sh old mode 100644 new mode 100755 index 1f858ff..dbe1386 --- a/tests/bin/kcov-line-coverage.sh +++ b/tests/bin/kcov-line-coverage.sh @@ -37,16 +37,12 @@ export APPLIANCE_PATHS_FILE="$tmp_dir/paths.log" export APPLIANCE_PATH_COVERAGE=1 # Minimal config env for scripts that read config. -config_env="$tmp_dir/config.env" -cat >"$config_env" <<'EOF' -APPLIANCE_LOG_PREFIX="template-appliance" -APPLIANCE_PRIMARY_CMD="" -APPLIANCE_SECONDARY_CMD="" +mkdir -p "$APPLIANCE_ROOT/etc/runner" +cat >"$APPLIANCE_ROOT/etc/runner/config.env" <<'EOF' +APPLIANCE_LOG_PREFIX="runner" APPLIANCE_DRY_RUN=1 EOF -export APPLIANCE_CONFIG_ENV="$config_env" - # Load libs in-process so we can exercise pure functions without forks. # shellcheck source=/dev/null source "${REPO_ROOT}/scripts/lib/logging.sh" @@ -131,6 +127,52 @@ kcov_wrap_run() { "${kcov_cmd[@]}" >/dev/null } +kcov_wrap_run_stdin() { + local label="$1" + local stdin_file="$2" + shift 2 + + if [[ "$kcov_wrap_enabled" != "1" ]]; then + "$@" <"$stdin_file" + return $? + fi + + local out="${kcov_parts_dir}/${label}" + rm -rf "$out" + + local -a args=() + if kcov_has_flag '--bash-parser=cmd'; then + args+=(--bash-parser=/bin/bash) + fi + if kcov_has_flag '--bash-method=method'; then + args+=(--bash-method=DEBUG) + fi + if kcov_has_flag '--report-type'; then + args+=(--report-type=html --report-type=json) + fi + args+=( + --include-path="$REPO_ROOT/scripts" + --exclude-pattern="$REPO_ROOT/tests,$REPO_ROOT/tests/vendor,$REPO_ROOT/scripts/ci.sh,$REPO_ROOT/scripts/ci" + ) + + local arg_order="${KCOV_ARG_ORDER:-opts_first}" + + local -a kcov_cmd=(kcov) + if [[ "$arg_order" == "out_first" ]]; then + kcov_cmd+=("$out" "${args[@]}" "$@") + else + kcov_cmd+=("${args[@]}" "$out" "$@") + fi + + local timeout_seconds="${KCOV_WRAP_TIMEOUT_SECONDS:-}" + if [[ -n "$timeout_seconds" && -x "$(command -v timeout 2>/dev/null || true)" ]]; then + timeout --foreground -k 1s "${timeout_seconds}s" "${kcov_cmd[@]}" <"$stdin_file" >/dev/null 2>&1 || true + return 0 + fi + + "${kcov_cmd[@]}" <"$stdin_file" >/dev/null +} + kcov_wrap_merge() { if [[ "$kcov_wrap_enabled" != "1" ]]; then return 0 @@ -167,6 +209,21 @@ appliance_root >/dev/null APPLIANCE_ROOT="$tmp_dir/root/" appliance_root >/dev/null APPLIANCE_ROOT="$tmp_dir/root" appliance_root >/dev/null +# common.sh: appliance_root invalid APPLIANCE_ROOT values (exercise both die() and no-die branches) +( + set +e + + die() { echo "die: $*" >&2; return 1; } + APPLIANCE_ROOT="relative" appliance_root >/dev/null 2>&1 + APPLIANCE_ROOT='/tmp/"bad' appliance_root >/dev/null 2>&1 + + unset -f die + APPLIANCE_ROOT="relative" appliance_root >/dev/null 2>&1 + APPLIANCE_ROOT='/tmp/"bad' appliance_root >/dev/null 2>&1 + + exit 0 +) || true + APPLIANCE_ROOT="$tmp_dir/root" appliance_path /etc/foo >/dev/null APPLIANCE_ROOT="/" appliance_path /etc/foo >/dev/null appliance_path relative/path >/dev/null @@ -187,6 +244,10 @@ record_call "two" || true APPLIANCE_DRY_RUN=1 run_cmd echo hi >/dev/null APPLIANCE_DRY_RUN=0 run_cmd true +# common.sh: systemctl wrappers (dry-run avoids side effects) +APPLIANCE_DRY_RUN=1 svc_start runner.service >/dev/null +APPLIANCE_DRY_RUN=1 svc_stop runner.service >/dev/null + appliance_realpath_m "." >/dev/null # appliance_is_sourced true/false branches. @@ -210,19 +271,15 @@ chmod +x "$is_sourced_wrapper" kcov_wrap_run "lib-common-is-sourced-false" "$is_sourced_helper" >/dev/null 2>&1 || true kcov_wrap_run "lib-common-is-sourced-true" "$is_sourced_wrapper" >/dev/null 2>&1 || true -# config.sh: env override/default + load present/missing -export APPLIANCE_CONFIG_ENV="$tmp_dir/config.override.env" -printf '%s\n' 'APPLIANCE_LOG_PREFIX="override"' >"$APPLIANCE_CONFIG_ENV" +# config.sh: env default + load present/missing +export APPLIANCE_ROOT="$tmp_dir/root" appliance_config_env_path >/dev/null +rm -f "$APPLIANCE_ROOT/etc/runner/config.env" load_config_env -export APPLIANCE_CONFIG_ENV="$tmp_dir/config.missing.env" -rm -f "$APPLIANCE_CONFIG_ENV" +printf '%s\n' 'RUNNER_TEST_VAR="kcov"' >"$APPLIANCE_ROOT/etc/runner/config.env" load_config_env -unset -v APPLIANCE_CONFIG_ENV -appliance_config_env_path >/dev/null - # path.sh: equal/base-root/child/false appliance_path_is_under "/a" "/a" || true appliance_path_is_under "/" "/anything" || true @@ -235,18 +292,16 @@ appliance_path_is_under "/a" "/b" || true no_lib_dir="$tmp_dir/no-lib" mkdir -p "$no_lib_dir" ln -sf "$REPO_ROOT/scripts/bootstrap.sh" "$no_lib_dir/bootstrap.sh" -ln -sf "$REPO_ROOT/scripts/healthcheck.sh" "$no_lib_dir/healthcheck.sh" -ln -sf "$REPO_ROOT/scripts/mode/primary-mode.sh" "$no_lib_dir/primary-mode.sh" -ln -sf "$REPO_ROOT/scripts/mode/secondary-mode.sh" "$no_lib_dir/secondary-mode.sh" -ln -sf "$REPO_ROOT/scripts/mode/enter-primary-mode.sh" "$no_lib_dir/enter-primary-mode.sh" -ln -sf "$REPO_ROOT/scripts/mode/enter-secondary-mode.sh" "$no_lib_dir/enter-secondary-mode.sh" +ln -sf "$REPO_ROOT/scripts/ci-nspawn-run.sh" "$no_lib_dir/ci-nspawn-run.sh" +ln -sf "$REPO_ROOT/scripts/runner-service.sh" "$no_lib_dir/runner-service.sh" +ln -sf "$REPO_ROOT/scripts/container-hooks.sh" "$no_lib_dir/container-hooks.sh" +ln -sf "$REPO_ROOT/scripts/uninstall.sh" "$no_lib_dir/uninstall.sh" ( set +e; kcov_wrap_run "bootstrap-no-lib" "$no_lib_dir/bootstrap.sh"; exit 0 ) >/dev/null 2>&1 || true -( set +e; kcov_wrap_run "healthcheck-no-lib" "$no_lib_dir/healthcheck.sh"; exit 0 ) >/dev/null 2>&1 || true -( set +e; kcov_wrap_run "primary-no-lib" "$no_lib_dir/primary-mode.sh"; exit 0 ) >/dev/null 2>&1 || true -( set +e; kcov_wrap_run "secondary-no-lib" "$no_lib_dir/secondary-mode.sh"; exit 0 ) >/dev/null 2>&1 || true -( set +e; kcov_wrap_run "enter-primary-no-lib" "$no_lib_dir/enter-primary-mode.sh"; exit 0 ) >/dev/null 2>&1 || true -( set +e; kcov_wrap_run "enter-secondary-no-lib" "$no_lib_dir/enter-secondary-mode.sh"; exit 0 ) >/dev/null 2>&1 || true +( set +e; kcov_wrap_run "ci-nspawn-run-no-lib" "$no_lib_dir/ci-nspawn-run.sh"; exit 0 ) >/dev/null 2>&1 || true +( set +e; kcov_wrap_run "runner-service-no-lib" "$no_lib_dir/runner-service.sh"; exit 0 ) >/dev/null 2>&1 || true +( set +e; kcov_wrap_run "container-hooks-no-lib" "$no_lib_dir/container-hooks.sh"; exit 0 ) >/dev/null 2>&1 || true +( set +e; kcov_wrap_run "uninstall-no-lib" "$no_lib_dir/uninstall.sh"; exit 0 ) >/dev/null 2>&1 || true # bootstrap.sh: hit key branches without touching the network. export APPLIANCE_DRY_RUN=1 @@ -311,76 +366,7 @@ rm -rf "$APPLIANCE_CHECKOUT_DIR" ( set +e; kcov_wrap_run "bootstrap-clone-installer-missing" "$REPO_ROOT/scripts/bootstrap.sh"; exit 0 ) >/dev/null 2>&1 || true export APPLIANCE_DRY_RUN=1 -# healthcheck.sh: active/inactive + dry-run + start-secondary -export APPLIANCE_DRY_RUN=1 -export SYSTEMCTL_ACTIVE_PRIMARY=0 -kcov_wrap_run "healthcheck-primary-active" "$REPO_ROOT/scripts/healthcheck.sh" >/dev/null 2>&1 || true -export SYSTEMCTL_ACTIVE_PRIMARY=1 -export APPLIANCE_PRIMARY_SERVICE="template-appliance-primary.service" -export APPLIANCE_SECONDARY_SERVICE="template-appliance-secondary.service" - -# Force inactive by telling stub systemctl to say "inactive". -export SYSTEMCTL_ACTIVE_PRIMARY=1 -kcov_wrap_run "healthcheck-dry-run" "$REPO_ROOT/scripts/healthcheck.sh" >/dev/null 2>&1 || true - -export APPLIANCE_DRY_RUN=0 -kcov_wrap_run "healthcheck-start-secondary" "$REPO_ROOT/scripts/healthcheck.sh" >/dev/null 2>&1 || true - -# Cover healthcheck LIB_DIR fallback branch (SCRIPT_DIR/../lib). -healthcheck_fallback_root="$tmp_dir/healthcheck-fallback" -mkdir -p "$healthcheck_fallback_root/scripts" -ln -sf "$REPO_ROOT/scripts/lib" "$healthcheck_fallback_root/lib" -ln -sf "$REPO_ROOT/scripts/healthcheck.sh" "$healthcheck_fallback_root/scripts/healthcheck.sh" -kcov_wrap_run "healthcheck-libdir-fallback" "$healthcheck_fallback_root/scripts/healthcheck.sh" >/dev/null 2>&1 || true - -# primary/secondary mode: dry-run, cmd-missing loop (timeout), cmd-present exec -export APPLIANCE_DRY_RUN=1 -kcov_wrap_run "primary-dry-run" "$REPO_ROOT/scripts/mode/primary-mode.sh" >/dev/null 2>&1 || true -kcov_wrap_run "secondary-dry-run" "$REPO_ROOT/scripts/mode/secondary-mode.sh" >/dev/null 2>&1 || true - -export APPLIANCE_DRY_RUN=0 -unset -v APPLIANCE_PRIMARY_CMD - -# Make the infinite sleep loop terminate quickly for coverage by stubbing sleep -# to fail once (set -e exits the script). -sleep_fail_stub_dir="$tmp_dir/stubs-sleep-fail" -mkdir -p "$sleep_fail_stub_dir" -cat >"$sleep_fail_stub_dir/sleep" <<'EOF' -#!/usr/bin/env bash -exit 1 -EOF -chmod +x "$sleep_fail_stub_dir/sleep" -old_path_sleep="$PATH" -export PATH="$sleep_fail_stub_dir:$PATH" -kcov_wrap_run "primary-cmd-missing" "$REPO_ROOT/scripts/mode/primary-mode.sh" >/dev/null 2>&1 || true -unset -v APPLIANCE_SECONDARY_CMD -kcov_wrap_run "secondary-cmd-missing" "$REPO_ROOT/scripts/mode/secondary-mode.sh" >/dev/null 2>&1 || true -export PATH="$old_path_sleep" -rm -rf "$sleep_fail_stub_dir" - -export APPLIANCE_PRIMARY_CMD="true" -kcov_wrap_run "primary-exec" "$REPO_ROOT/scripts/mode/primary-mode.sh" >/dev/null 2>&1 || true -export APPLIANCE_SECONDARY_CMD="true" -kcov_wrap_run "secondary-exec" "$REPO_ROOT/scripts/mode/secondary-mode.sh" >/dev/null 2>&1 || true - -# Cover mode scripts LIB_DIR="$SCRIPT_DIR/lib" branch. -mode_with_lib="$tmp_dir/mode-with-lib" -mkdir -p "$mode_with_lib" -ln -sf "$REPO_ROOT/scripts/lib" "$mode_with_lib/lib" -ln -sf "$REPO_ROOT/scripts/mode/primary-mode.sh" "$mode_with_lib/primary-mode.sh" -ln -sf "$REPO_ROOT/scripts/mode/secondary-mode.sh" "$mode_with_lib/secondary-mode.sh" -ln -sf "$REPO_ROOT/scripts/mode/enter-primary-mode.sh" "$mode_with_lib/enter-primary-mode.sh" -ln -sf "$REPO_ROOT/scripts/mode/enter-secondary-mode.sh" "$mode_with_lib/enter-secondary-mode.sh" -export APPLIANCE_DRY_RUN=1 -kcov_wrap_run "primary-libdir-direct" "$mode_with_lib/primary-mode.sh" >/dev/null 2>&1 || true -kcov_wrap_run "secondary-libdir-direct" "$mode_with_lib/secondary-mode.sh" >/dev/null 2>&1 || true -kcov_wrap_run "enter-primary-libdir-direct" "$mode_with_lib/enter-primary-mode.sh" >/dev/null 2>&1 || true -kcov_wrap_run "enter-secondary-libdir-direct" "$mode_with_lib/enter-secondary-mode.sh" >/dev/null 2>&1 || true - -# enter mode scripts: ensure stop/start calls are exercised (systemctl is stubbed) -export APPLIANCE_DRY_RUN=0 -kcov_wrap_run "enter-primary" "$REPO_ROOT/scripts/mode/enter-primary-mode.sh" >/dev/null 2>&1 || true -kcov_wrap_run "enter-secondary" "$REPO_ROOT/scripts/mode/enter-secondary-mode.sh" >/dev/null 2>&1 || true +# (legacy primary/secondary mode + healthcheck scripts removed) # install.sh: cover key branches without side effects (DRY_RUN + stubs). export APPLIANCE_DRY_RUN=1 @@ -432,4 +418,144 @@ EOF chmod +x "$install_write_marker_helper" kcov_wrap_run "install-write-marker" "$install_write_marker_helper" >/dev/null 2>&1 || true +# ---------- runner scripts ---------- + +# runner-service.sh: missing-runner + container-hooks/no-hooks + exec. +runner_dir="$APPLIANCE_ROOT/opt/runner/actions-runner" +hook_dir="$APPLIANCE_ROOT/usr/local/lib/runner" + +( set +e; kcov_wrap_run "runner-service-missing" "$REPO_ROOT/scripts/runner-service.sh"; exit 0 ) >/dev/null 2>&1 || true + +mkdir -p "$runner_dir" "$hook_dir" +cat >"$runner_dir/runsvc.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF +chmod +x "$runner_dir/runsvc.sh" + +# Cover runner-service LIB_DIR fallback branch (SCRIPT_DIR/../lib). +runner_service_fallback_root="$tmp_dir/runner-service-fallback" +mkdir -p "$runner_service_fallback_root/scripts" +ln -sf "$REPO_ROOT/scripts/lib" "$runner_service_fallback_root/lib" +ln -sf "$REPO_ROOT/scripts/runner-service.sh" "$runner_service_fallback_root/scripts/runner-service.sh" +kcov_wrap_run "runner-service-libdir-fallback" "$runner_service_fallback_root/scripts/runner-service.sh" >/dev/null 2>&1 || true + +kcov_wrap_run "runner-service-no-hooks" "$REPO_ROOT/scripts/runner-service.sh" >/dev/null 2>&1 || true + +cat >"$hook_dir/container-hooks.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF +chmod +x "$hook_dir/container-hooks.sh" + +kcov_wrap_run "runner-service-hooks" "$REPO_ROOT/scripts/runner-service.sh" >/dev/null 2>&1 || true + +# container-hooks.sh: invoked -> die. +( set +e; kcov_wrap_run "container-hooks-called" "$REPO_ROOT/scripts/container-hooks.sh"; exit 0 ) >/dev/null 2>&1 || true + +# Cover container-hooks LIB_DIR fallback branch (SCRIPT_DIR/../lib). +container_hooks_fallback_root="$tmp_dir/container-hooks-fallback" +mkdir -p "$container_hooks_fallback_root/scripts" +ln -sf "$REPO_ROOT/scripts/lib" "$container_hooks_fallback_root/lib" +ln -sf "$REPO_ROOT/scripts/container-hooks.sh" "$container_hooks_fallback_root/scripts/container-hooks.sh" +( set +e; kcov_wrap_run "container-hooks-libdir-fallback" "$container_hooks_fallback_root/scripts/container-hooks.sh"; exit 0 ) >/dev/null 2>&1 || true + +# container-hooks.sh: exercise supported commands and common error paths. +container_hooks_work_dir="$tmp_dir/work" +container_hooks_resp_file="$tmp_dir/resp.json" + +mkdir -p "$APPLIANCE_ROOT/var/lib/runner/nspawn/base-rootfs" +mkdir -p "$container_hooks_work_dir" + +export GITHUB_WORKSPACE="$container_hooks_work_dir" +export APPLIANCE_ALLOW_NON_ROOT=1 +export APPLIANCE_DRY_RUN=1 + +payload_container_hooks_prepare_container="$tmp_dir/container-hooks-prepare-container.payload.json" +cat >"$payload_container_hooks_prepare_container" </dev/null 2>&1 || true + +payload_container_hooks_prepare_none="$tmp_dir/container-hooks-prepare-none.payload.json" +cat >"$payload_container_hooks_prepare_none" </dev/null 2>&1 || true + +payload_container_hooks_run_script="$tmp_dir/container-hooks-run-script.payload.json" +cat >"$payload_container_hooks_run_script" </dev/null 2>&1 || true + +payload_container_hooks_cleanup="$tmp_dir/container-hooks-cleanup.payload.json" +cat >"$payload_container_hooks_cleanup" </dev/null 2>&1 || true + +payload_container_hooks_run_container_step="$tmp_dir/container-hooks-run-container-step.payload.json" +cat >"$payload_container_hooks_run_container_step" </dev/null 2>&1 || true + +payload_container_hooks_unknown="$tmp_dir/container-hooks-unknown.payload.json" +cat >"$payload_container_hooks_unknown" </dev/null 2>&1 || true + +payload_container_hooks_invalid="$tmp_dir/container-hooks-invalid.payload.json" +cat >"$payload_container_hooks_invalid" </dev/null 2>&1 || true + +payload_container_hooks_invalid_response_file="$tmp_dir/container-hooks-invalid-response-file.payload.json" +cat >"$payload_container_hooks_invalid_response_file" </dev/null 2>&1 || true + +# ci-nspawn-run.sh: missing-command, base-missing, dry-run, timeout, exec. +base_rootfs="$APPLIANCE_ROOT/var/lib/runner/nspawn/base-rootfs" +rm -rf "$base_rootfs" +( set +e; kcov_wrap_run "ci-nspawn-run-missing" "$REPO_ROOT/scripts/ci-nspawn-run.sh"; exit 0 ) >/dev/null 2>&1 || true + +export APPLIANCE_DRY_RUN=1 +( set +e; kcov_wrap_run "ci-nspawn-run-base-missing" "$REPO_ROOT/scripts/ci-nspawn-run.sh" echo hi; exit 0 ) >/dev/null 2>&1 || true + +mkdir -p "$base_rootfs" +export RUNNER_NSPAWN_BIND="/dev/dri:/dev/dri" +export RUNNER_NSPAWN_BIND_RO="/etc/hosts:/etc/hosts" +kcov_wrap_run "ci-nspawn-run-dry-run" "$REPO_ROOT/scripts/ci-nspawn-run.sh" echo hi >/dev/null 2>&1 || true +unset -v RUNNER_NSPAWN_BIND RUNNER_NSPAWN_BIND_RO + +export APPLIANCE_DRY_RUN=0 +export RUNNER_NSPAWN_READY_TIMEOUT_S=0 +export MACHINECTL_SHOW_EXIT_CODE=1 +( set +e; kcov_wrap_run "ci-nspawn-run-timeout" "$REPO_ROOT/scripts/ci-nspawn-run.sh" echo hi; exit 0 ) >/dev/null 2>&1 || true + +export RUNNER_NSPAWN_READY_TIMEOUT_S=2 +export MACHINECTL_SHOW_EXIT_CODE=0 +kcov_wrap_run "ci-nspawn-run-exec" "$REPO_ROOT/scripts/ci-nspawn-run.sh" echo hi >/dev/null 2>&1 || true + +unset -v RUNNER_NSPAWN_READY_TIMEOUT_S MACHINECTL_SHOW_EXIT_CODE +export APPLIANCE_DRY_RUN=1 + +# uninstall.sh: allow-non-root + root-required/root-ok. +export APPLIANCE_DRY_RUN=1 +export APPLIANCE_ALLOW_NON_ROOT=1 +kcov_wrap_run "uninstall-dry-run" "$REPO_ROOT/scripts/uninstall.sh" >/dev/null 2>&1 || true + +unset -v APPLIANCE_ALLOW_NON_ROOT +export APPLIANCE_EUID_OVERRIDE=1000 +( set +e; kcov_wrap_run "uninstall-root-required" "$REPO_ROOT/scripts/uninstall.sh"; exit 0 ) >/dev/null 2>&1 || true + +export APPLIANCE_EUID_OVERRIDE=0 +kcov_wrap_run "uninstall-root-ok" "$REPO_ROOT/scripts/uninstall.sh" >/dev/null 2>&1 || true +unset -v APPLIANCE_EUID_OVERRIDE + kcov_wrap_merge diff --git a/tests/bin/recalc-path-coverage.sh b/tests/bin/recalc-path-coverage.sh index 4ce8d84..d706d2f 100755 --- a/tests/bin/recalc-path-coverage.sh +++ b/tests/bin/recalc-path-coverage.sh @@ -10,8 +10,8 @@ Usage: tests/bin/recalc-path-coverage.sh [--run|--no-run] Prints path-coverage counts derived from: - tests/coverage/required-paths.txt - - tests/.tmp/template-appliance-paths.unit.log - - tests/.tmp/template-appliance-paths.log + - tests/.tmp/runner-paths.unit.log + - tests/.tmp/runner-paths.log IDs are partitioned by convention: - unit owns IDs that start with "lib-" @@ -36,8 +36,8 @@ repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" do_run=0 required_file="$repo_root/tests/coverage/required-paths.txt" -unit_log="$repo_root/tests/.tmp/template-appliance-paths.unit.log" -integration_log="$repo_root/tests/.tmp/template-appliance-paths.log" +unit_log="$repo_root/tests/.tmp/runner-paths.unit.log" +integration_log="$repo_root/tests/.tmp/runner-paths.log" while [[ "$#" -gt 0 ]]; do case "$1" in diff --git a/tests/bin/run-bats-integration.sh b/tests/bin/run-bats-integration.sh index 926f721..da6780e 100755 --- a/tests/bin/run-bats-integration.sh +++ b/tests/bin/run-bats-integration.sh @@ -16,7 +16,7 @@ export BATS_LOAD_PATH="$ROOT_DIR/tests:$ROOT_DIR/tests/vendor" export BATS_LIB_PATH="$ROOT_DIR/tests/vendor:$ROOT_DIR/tests" # Stable path-coverage log shared across Bats invocations. -PATHS_LOG="${APPLIANCE_PATHS_FILE:-$ROOT_DIR/tests/.tmp/template-appliance-paths.log}" +PATHS_LOG="${APPLIANCE_PATHS_FILE:-$ROOT_DIR/tests/.tmp/runner-paths.log}" mkdir -p "$(dirname "$PATHS_LOG")" rm -f "$PATHS_LOG" export APPLIANCE_PATHS_FILE="$PATHS_LOG" diff --git a/tests/bin/run-bats-unit.sh b/tests/bin/run-bats-unit.sh index e7a29f1..1885fd9 100755 --- a/tests/bin/run-bats-unit.sh +++ b/tests/bin/run-bats-unit.sh @@ -17,7 +17,7 @@ export BATS_LIB_PATH="$ROOT_DIR/tests/vendor:$ROOT_DIR/tests" # Enable suite-wide path-coverage logging for unit runs. # This allows us to measure which PATH ids are covered by unit tests. -PATHS_LOG="${APPLIANCE_PATHS_FILE:-$ROOT_DIR/tests/.tmp/template-appliance-paths.unit.log}" +PATHS_LOG="${APPLIANCE_PATHS_FILE:-$ROOT_DIR/tests/.tmp/runner-paths.unit.log}" mkdir -p "$(dirname "$PATHS_LOG")" rm -f "$PATHS_LOG" export APPLIANCE_PATHS_FILE="$PATHS_LOG" diff --git a/tests/coverage/required-paths.txt b/tests/coverage/required-paths.txt index 101e995..b0c8b8e 100644 --- a/tests/coverage/required-paths.txt +++ b/tests/coverage/required-paths.txt @@ -19,14 +19,22 @@ bootstrap:missing-repo-url bootstrap:network-not-ready bootstrap:network-ok bootstrap:reuse-checkout -enter-primary-mode:start-primary -enter-primary-mode:stop-secondary -enter-secondary-mode:start-secondary -enter-secondary-mode:stop-primary -healthcheck:dry-run -healthcheck:primary-active -healthcheck:primary-inactive -healthcheck:start-secondary +ci-nspawn-run:base-missing +ci-nspawn-run:bind-extra +ci-nspawn-run:bind-ro +ci-nspawn-run:cleanup +ci-nspawn-run:dry-run +ci-nspawn-run:exec +ci-nspawn-run:machine-timeout +ci-nspawn-run:missing-command +ci-nspawn-run:poweroff +ci-nspawn-run:start +container-hooks:called +container-hooks:cleanup-job +container-hooks:prepare-job +container-hooks:run-container-step +container-hooks:run-script-step +container-hooks:unknown-command install:allow-non-root install:lock-acquired install:lock-busy @@ -39,6 +47,20 @@ install:user-created install:user-exists install:write-marker-dry-run install:write-marker-write +runner-service:container-hooks +runner-service:exec +runner-service:missing-runner +runner-service:no-container-hooks +uninstall:allow-non-root +uninstall:done +uninstall:remove-bins +uninstall:remove-config +uninstall:remove-lib +uninstall:remove-state +uninstall:remove-units +uninstall:root-ok +uninstall:root-required +uninstall:stop-services lib-common:dirname-collapse-root lib-common:dirname-empty lib-common:dirname-has-slash @@ -57,95 +79,9 @@ lib-common:root-slash lib-common:run-dry-run lib-common:run-exec lib-config:env-default -lib-config:env-override lib-config:load-missing lib-config:load-present lib-path:is-under-base-root lib-path:is-under-child lib-path:is-under-equal lib-path:is-under-false -primary-mode:cmd-missing -primary-mode:cmd-present -primary-mode:dry-run -secondary-mode:cmd-missing -secondary-mode:cmd-present -secondary-mode:dry-run -# Path coverage IDs (must all be hit by the Bats suite) -# Format: - -bootstrap:clone -bootstrap:installed-marker -bootstrap:installer-dry-run -bootstrap:installer-exec -bootstrap:installer-missing -bootstrap:missing-repo-ref -bootstrap:missing-repo-url -bootstrap:network-not-ready -bootstrap:network-ok -bootstrap:reuse-checkout - -enter-primary-mode:start-primary -enter-primary-mode:stop-secondary -enter-secondary-mode:start-secondary -enter-secondary-mode:stop-primary - -healthcheck:dry-run -healthcheck:primary-active -healthcheck:primary-inactive -healthcheck:start-secondary - -install:allow-non-root -install:lock-acquired -install:lock-busy -install:marker-after-lock -install:marker-present-early -install:optional-features-none -install:root-ok -install:root-required -install:user-created -install:user-exists -install:write-marker-dry-run -install:write-marker-write - -lib-common:dirname-collapse-root -lib-common:dirname-empty -lib-common:dirname-has-slash -lib-common:dirname-no-slash -lib-common:dirname-trailing-slash -lib-common:is-sourced-false -lib-common:is-sourced-true -lib-common:path-prefixed -lib-common:path-relative -lib-common:path-root-slash -lib-common:realpath-called -lib-common:record-append -lib-common:record-append-none -lib-common:record-primary -lib-common:record-primary-none -lib-common:root-non-slash -lib-common:root-slash -lib-common:run-dry-run -lib-common:run-exec - -lib-config:env-default -lib-config:env-override -lib-config:load-missing -lib-config:load-present - -lib-logging:die -lib-logging:log -lib-logging:prefix-default -lib-logging:prefix-override -lib-logging:warn - -lib-path:is-under-base-root -lib-path:is-under-child -lib-path:is-under-equal -lib-path:is-under-false - -primary-mode:cmd-missing -primary-mode:cmd-present -primary-mode:dry-run -secondary-mode:cmd-missing -secondary-mode:cmd-present -secondary-mode:dry-run diff --git a/tests/helpers/common.bash b/tests/helpers/common.bash index e1c036e..64d79fe 100644 --- a/tests/helpers/common.bash +++ b/tests/helpers/common.bash @@ -22,7 +22,7 @@ setup_test_root() { # Suite-wide aggregation for path coverage assertions. export APPLIANCE_PATH_COVERAGE=1 # Use a stable file so we can run tests individually (per-test timeout runner). - export APPLIANCE_PATHS_FILE="${APPLIANCE_PATHS_FILE:-$APPLIANCE_REPO_ROOT/tests/.tmp/template-appliance-paths.log}" + export APPLIANCE_PATHS_FILE="${APPLIANCE_PATHS_FILE:-$APPLIANCE_REPO_ROOT/tests/.tmp/runner-paths.log}" # Avoid depending on external dirname (PATH may be intentionally minimal). local paths_dir="${APPLIANCE_PATHS_FILE%/*}" if [[ -z "$paths_dir" || "$paths_dir" == "$APPLIANCE_PATHS_FILE" ]]; then @@ -31,7 +31,7 @@ setup_test_root() { mkdir -p "$paths_dir" export APPLIANCE_CALLS_FILE_APPEND="$APPLIANCE_PATHS_FILE" - mkdir -p "$TEST_ROOT/etc/template-appliance" "$TEST_ROOT/var/lib/template-appliance" "$TEST_ROOT/var/lock" + mkdir -p "$TEST_ROOT/etc/runner" "$TEST_ROOT/var/lib/runner" "$TEST_ROOT/var/lock" # Ensure stubs override system commands. export PATH="$APPLIANCE_REPO_ROOT/tests/stubs:$PATH" @@ -91,9 +91,8 @@ teardown_test_root() { write_config_env() { local content="$1" - local path="$TEST_ROOT/etc/template-appliance/config.env" + local path="$TEST_ROOT/etc/runner/config.env" printf '%s\n' "$content" >"$path" - export APPLIANCE_CONFIG_ENV="$path" } # Convenience: assert a file contains a substring. diff --git a/tests/integration/bootstrap.bats b/tests/integration/bootstrap.bats index fcb3c99..ad20be5 100644 --- a/tests/integration/bootstrap.bats +++ b/tests/integration/bootstrap.bats @@ -17,7 +17,7 @@ bootstrap_script() { } @test "bootstrap: installed marker exits early" { - touch "$TEST_ROOT/var/lib/template-appliance/installed" + touch "$TEST_ROOT/var/lib/runner/installed" run env APPLIANCE_DRY_RUN=1 bash "$(bootstrap_script)" [ "$status" -eq 0 ] } @@ -42,7 +42,7 @@ bootstrap_script() { @test "bootstrap: clone path and installer dry-run" { write_config_env $'APPLIANCE_REPO_URL="https://example.invalid/repo"\nAPPLIANCE_REPO_REF="main"' - checkout_dir="$TEST_ROOT/opt/template-appliance" + checkout_dir="$TEST_ROOT/opt/runner" mkdir -p "$checkout_dir/scripts" cat >"$checkout_dir/scripts/install.sh" <<'EOF' #!/usr/bin/env bash @@ -56,7 +56,7 @@ EOF @test "bootstrap: reuse checkout path and installer dry-run" { write_config_env $'APPLIANCE_REPO_URL="https://example.invalid/repo"\nAPPLIANCE_REPO_REF="main"' - checkout_dir="$TEST_ROOT/opt/template-appliance" + checkout_dir="$TEST_ROOT/opt/runner" mkdir -p "$checkout_dir/.git" "$checkout_dir/scripts" cat >"$checkout_dir/scripts/install.sh" <<'EOF' #!/usr/bin/env bash @@ -70,7 +70,7 @@ EOF @test "bootstrap: installer missing" { write_config_env $'APPLIANCE_REPO_URL="https://example.invalid/repo"\nAPPLIANCE_REPO_REF="main"' - checkout_dir="$TEST_ROOT/opt/template-appliance" + checkout_dir="$TEST_ROOT/opt/runner" mkdir -p "$checkout_dir/.git" GETENT_HOSTS_EXIT_CODE=0 CURL_EXIT_CODE=0 run env APPLIANCE_DRY_RUN=1 APPLIANCE_CHECKOUT_DIR="$checkout_dir" bash "$(bootstrap_script)" @@ -79,7 +79,7 @@ EOF @test "bootstrap: installer exec" { write_config_env $'APPLIANCE_REPO_URL="https://example.invalid/repo"\nAPPLIANCE_REPO_REF="main"' - checkout_dir="$TEST_ROOT/opt/template-appliance" + checkout_dir="$TEST_ROOT/opt/runner" mkdir -p "$checkout_dir/.git" "$checkout_dir/scripts" cat >"$checkout_dir/scripts/install.sh" <<'EOF' #!/usr/bin/env bash diff --git a/tests/integration/ci-nspawn-run.bats b/tests/integration/ci-nspawn-run.bats new file mode 100644 index 0000000..ba20f07 --- /dev/null +++ b/tests/integration/ci-nspawn-run.bats @@ -0,0 +1,70 @@ +#!/usr/bin/env bats + +load '../helpers/common.bash' + +setup() { + setup_test_root + write_config_env 'APPLIANCE_LOG_PREFIX="runner"' + + # Provide a base rootfs path in the sandbox. + mkdir -p "$TEST_ROOT/var/lib/runner/nspawn/base-rootfs" +} + +teardown() { + teardown_test_root +} + +@test "ci-nspawn-run: missing command" { + run env APPLIANCE_ALLOW_NON_ROOT=1 APPLIANCE_DRY_RUN=1 bash "$APPLIANCE_REPO_ROOT/scripts/ci-nspawn-run.sh" + [ "$status" -ne 0 ] +} + +@test "ci-nspawn-run: base rootfs missing" { + rm -rf "$TEST_ROOT/var/lib/runner/nspawn/base-rootfs" + run env APPLIANCE_ALLOW_NON_ROOT=1 APPLIANCE_DRY_RUN=1 bash "$APPLIANCE_REPO_ROOT/scripts/ci-nspawn-run.sh" echo hi + [ "$status" -ne 0 ] +} + +@test "ci-nspawn-run: dry-run records nspawn + systemd-run" { + run env APPLIANCE_ALLOW_NON_ROOT=1 APPLIANCE_DRY_RUN=1 GITHUB_WORKSPACE="$TEST_ROOT/work" bash "$APPLIANCE_REPO_ROOT/scripts/ci-nspawn-run.sh" echo hi + [ "$status" -eq 0 ] + assert_file_contains "$APPLIANCE_CALLS_FILE" "systemd-nspawn" + assert_file_contains "$APPLIANCE_CALLS_FILE" "systemd-run" +} + +@test "ci-nspawn-run: dry-run supports extra binds" { + run env \ + APPLIANCE_ALLOW_NON_ROOT=1 \ + APPLIANCE_DRY_RUN=1 \ + RUNNER_NSPAWN_BIND="/dev/dri:/dev/dri /dev/input:/dev/input" \ + RUNNER_NSPAWN_BIND_RO="/etc/hosts:/etc/hosts" \ + bash "$APPLIANCE_REPO_ROOT/scripts/ci-nspawn-run.sh" echo hi + [ "$status" -eq 0 ] + assert_file_contains "$APPLIANCE_CALLS_FILE" "--bind=/dev/dri:/dev/dri" + assert_file_contains "$APPLIANCE_CALLS_FILE" "--bind=/dev/input:/dev/input" + assert_file_contains "$APPLIANCE_CALLS_FILE" "--bind-ro=/etc/hosts:/etc/hosts" +} + +@test "ci-nspawn-run: machine timeout cleanup" { + # Force the wait loop to time out immediately. + # machinectl show stub exits non-zero by default. + run env \ + APPLIANCE_ALLOW_NON_ROOT=1 \ + APPLIANCE_DRY_RUN=0 \ + RUNNER_NSPAWN_READY_TIMEOUT_S=0 \ + bash "$APPLIANCE_REPO_ROOT/scripts/ci-nspawn-run.sh" echo hi + [ "$status" -ne 0 ] + assert_file_contains "$APPLIANCE_CALLS_FILE" "machinectl terminate" +} + +@test "ci-nspawn-run: exec path powers off machine" { + # Make the machine appear immediately. + run env \ + APPLIANCE_ALLOW_NON_ROOT=1 \ + APPLIANCE_DRY_RUN=0 \ + MACHINECTL_SHOW_EXIT_CODE=0 \ + bash "$APPLIANCE_REPO_ROOT/scripts/ci-nspawn-run.sh" echo hi + [ "$status" -eq 0 ] + assert_file_contains "$APPLIANCE_CALLS_FILE" "systemd-run -M" + assert_file_contains "$APPLIANCE_CALLS_FILE" "machinectl poweroff" +} diff --git a/tests/integration/container-hooks-coverage.bats b/tests/integration/container-hooks-coverage.bats new file mode 100644 index 0000000..4163b83 --- /dev/null +++ b/tests/integration/container-hooks-coverage.bats @@ -0,0 +1,116 @@ +#!/usr/bin/env bats + +load '../helpers/common.bash' + +setup() { + setup_test_root + write_config_env 'APPLIANCE_LOG_PREFIX="runner"' + + # Provide a base rootfs path in the sandbox for ci-nspawn-run (used by hooks). + mkdir -p "$TEST_ROOT/var/lib/runner/nspawn/base-rootfs" + mkdir -p "$TEST_ROOT/work" +} + +teardown() { + teardown_test_root +} + +@test "container-hooks: coverage success paths" { + local resp_prepare_container="$TEST_ROOT/resp-prepare-container.json" + local resp_prepare_none="$TEST_ROOT/resp-prepare-none.json" + local resp_run_script_1="$TEST_ROOT/resp-run-script-1.json" + local resp_run_script_2="$TEST_ROOT/resp-run-script-2.json" + local resp_cleanup="$TEST_ROOT/resp-cleanup.json" + + local payload_prepare_container + payload_prepare_container='{"command":"prepare_job","responseFile":"'$resp_prepare_container'","args":{"container":{"image":"ubuntu:22.04"}},"state":{}}' + local payload_prepare_none + payload_prepare_none='{"command":"prepare_job","responseFile":"'$resp_prepare_none'","args":{},"state":{}}' + + local payload_run_script_1 + payload_run_script_1='{"command":"run_script_step","responseFile":"'$resp_run_script_1'","args":{"entryPoint":"echo","entryPointArgs":["hi"],"workingDirectory":"/ci/work","environmentVariables":{"FOO":"bar"},"prependPath":[]},"state":{}}' + local payload_run_script_2 + payload_run_script_2='{"command":"run_script_step","responseFile":"'$resp_run_script_2'","args":{"entryPoint":"echo","entryPointArgs":["hi"],"environmentVariables":{},"prependPath":[]},"state":{}}' + + local payload_cleanup + payload_cleanup='{"command":"cleanup_job","responseFile":"'$resp_cleanup'","args":{},"state":{}}' + + run env \ + APPLIANCE_ALLOW_NON_ROOT=1 \ + APPLIANCE_DRY_RUN=1 \ + GITHUB_WORKSPACE="$TEST_ROOT/work" \ + RESP_PREPARE_CONTAINER="$resp_prepare_container" \ + RESP_PREPARE_NONE="$resp_prepare_none" \ + RESP_RUN_SCRIPT_1="$resp_run_script_1" \ + RESP_RUN_SCRIPT_2="$resp_run_script_2" \ + RESP_CLEANUP="$resp_cleanup" \ + PAYLOAD_PREPARE_CONTAINER="$payload_prepare_container" \ + PAYLOAD_PREPARE_NONE="$payload_prepare_none" \ + PAYLOAD_RUN_SCRIPT_1="$payload_run_script_1" \ + PAYLOAD_RUN_SCRIPT_2="$payload_run_script_2" \ + PAYLOAD_CLEANUP="$payload_cleanup" \ + bash -c ' + set -euo pipefail + source "$APPLIANCE_REPO_ROOT/scripts/container-hooks.sh" + + main <<<"$PAYLOAD_PREPARE_CONTAINER" + grep -Fq -- "\"isAlpine\":false" "$RESP_PREPARE_CONTAINER" + + main <<<"$PAYLOAD_PREPARE_NONE" + grep -Fq -- "\"state\"" "$RESP_PREPARE_NONE" + ! grep -Fq -- "\"isAlpine\"" "$RESP_PREPARE_NONE" + + main <<<"$PAYLOAD_RUN_SCRIPT_1" + grep -Fq -- "systemd-run" "$APPLIANCE_CALLS_FILE" + grep -Fq -- "--setenv=FOO=bar" "$APPLIANCE_CALLS_FILE" + grep -Fq -- "--working-directory=/ci/work" "$APPLIANCE_CALLS_FILE" + grep -Fq -- "\"state\"" "$RESP_RUN_SCRIPT_1" + + main <<<"$PAYLOAD_RUN_SCRIPT_2" + grep -Fq -- "\"state\"" "$RESP_RUN_SCRIPT_2" + + main <<<"$PAYLOAD_CLEANUP" + grep -Fq -- "\"state\"" "$RESP_CLEANUP" + ' + [ "$status" -eq 0 ] +} + +@test "container-hooks: run_container_step fails but writes response" { + local resp="$TEST_ROOT/resp-run-container.json" + local payload + payload='{"command":"run_container_step","responseFile":"'$resp'","args":{},"state":{}}' + + run env APPLIANCE_ALLOW_NON_ROOT=1 PAYLOAD="$payload" bash -c ' + set -euo pipefail + source "$APPLIANCE_REPO_ROOT/scripts/container-hooks.sh" + main <<<"$PAYLOAD" + ' + [ "$status" -ne 0 ] + assert_file_contains "$resp" '"state"' +} + +@test "container-hooks: unknown command fails but writes response" { + local resp="$TEST_ROOT/resp-unknown.json" + local payload + payload='{"command":"nope","responseFile":"'$resp'","args":{},"state":{}}' + + run env APPLIANCE_ALLOW_NON_ROOT=1 PAYLOAD="$payload" bash -c ' + set -euo pipefail + source "$APPLIANCE_REPO_ROOT/scripts/container-hooks.sh" + main <<<"$PAYLOAD" + ' + [ "$status" -ne 0 ] + assert_file_contains "$resp" '"state"' +} + +@test "container-hooks: invalid payload fails" { + local payload + payload='{"responseFile":"'$TEST_ROOT'/resp.json","args":{},"state":{}}' + + run env APPLIANCE_ALLOW_NON_ROOT=1 PAYLOAD="$payload" bash -c ' + set -euo pipefail + source "$APPLIANCE_REPO_ROOT/scripts/container-hooks.sh" + main <<<"$PAYLOAD" + ' + [ "$status" -ne 0 ] +} diff --git a/tests/integration/healthcheck.bats b/tests/integration/healthcheck.bats deleted file mode 100644 index 8f45ee0..0000000 --- a/tests/integration/healthcheck.bats +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bats - -load '../helpers/common.bash' - -setup() { - setup_test_root - write_config_env '' -} - -teardown() { - teardown_test_root -} - -@test "healthcheck: primary active" { - SYSTEMCTL_ACTIVE_PRIMARY=0 run env APPLIANCE_DRY_RUN=0 bash "$APPLIANCE_REPO_ROOT/scripts/healthcheck.sh" - [ "$status" -eq 0 ] -} - -@test "healthcheck: primary inactive (dry-run)" { - SYSTEMCTL_ACTIVE_PRIMARY=1 run env APPLIANCE_DRY_RUN=1 bash "$APPLIANCE_REPO_ROOT/scripts/healthcheck.sh" - [ "$status" -eq 0 ] -} - -@test "healthcheck: primary inactive (start secondary)" { - SYSTEMCTL_ACTIVE_PRIMARY=1 SYSTEMCTL_ACTIVE_SECONDARY=1 run env APPLIANCE_DRY_RUN=0 bash "$APPLIANCE_REPO_ROOT/scripts/healthcheck.sh" - [ "$status" -eq 0 ] -} diff --git a/tests/integration/install.bats b/tests/integration/install.bats index 301f39f..8aad1cd 100644 --- a/tests/integration/install.bats +++ b/tests/integration/install.bats @@ -4,7 +4,7 @@ load '../helpers/common.bash' setup() { setup_test_root - write_config_env 'APPLIANCE_LOG_PREFIX="template-appliance"' + write_config_env 'APPLIANCE_LOG_PREFIX="runner"' } teardown() { @@ -24,7 +24,7 @@ teardown() { @test "install: marker present early" { write_config_env '' - touch "$TEST_ROOT/var/lib/template-appliance/installed" + touch "$TEST_ROOT/var/lib/runner/installed" run env APPLIANCE_ALLOW_NON_ROOT=1 APPLIANCE_DRY_RUN=1 bash "$APPLIANCE_REPO_ROOT/scripts/install.sh" [ "$status" -eq 0 ] } @@ -37,7 +37,7 @@ teardown() { @test "install: marker appears while waiting for lock" { write_config_env '' - marker="$TEST_ROOT/var/lib/template-appliance/installed" + marker="$TEST_ROOT/var/lib/runner/installed" APPLIANCE_STUB_FLOCK_EXIT_CODE=0 APPLIANCE_STUB_FLOCK_TOUCH_MARKER=1 APPLIANCE_INSTALLED_MARKER="$marker" run env APPLIANCE_ALLOW_NON_ROOT=1 APPLIANCE_DRY_RUN=1 bash "$APPLIANCE_REPO_ROOT/scripts/install.sh" [ "$status" -eq 0 ] } @@ -57,7 +57,7 @@ teardown() { } @test "install: write_marker dry-run vs write" { - marker="$TEST_ROOT/var/lib/template-appliance/installed" + marker="$TEST_ROOT/var/lib/runner/installed" run bash -c "set -euo pipefail; export APPLIANCE_ROOT=\"$TEST_ROOT\"; export APPLIANCE_DRY_RUN=1; export APPLIANCE_INSTALLED_MARKER=\"$marker\"; source \"$APPLIANCE_REPO_ROOT/scripts/install.sh\"; write_marker" [ "$status" -eq 0 ] diff --git a/tests/integration/libdir-resolution.bats b/tests/integration/libdir-resolution.bats index e9feac7..d11c7e6 100644 --- a/tests/integration/libdir-resolution.bats +++ b/tests/integration/libdir-resolution.bats @@ -34,11 +34,9 @@ make_layout_with_parent_lib() { # the fallback for each file to satisfy line coverage. local scripts=( "$APPLIANCE_REPO_ROOT/scripts/bootstrap.sh" - "$APPLIANCE_REPO_ROOT/scripts/healthcheck.sh" - "$APPLIANCE_REPO_ROOT/scripts/mode/primary-mode.sh" - "$APPLIANCE_REPO_ROOT/scripts/mode/secondary-mode.sh" - "$APPLIANCE_REPO_ROOT/scripts/mode/enter-primary-mode.sh" - "$APPLIANCE_REPO_ROOT/scripts/mode/enter-secondary-mode.sh" + "$APPLIANCE_REPO_ROOT/scripts/ci-nspawn-run.sh" + "$APPLIANCE_REPO_ROOT/scripts/container-hooks.sh" + "$APPLIANCE_REPO_ROOT/scripts/runner-service.sh" ) # Make bootstrap exit quickly. @@ -52,17 +50,19 @@ make_layout_with_parent_lib() { run env APPLIANCE_ROOT="$TEST_ROOT" APPLIANCE_INSTALLED_MARKER="$marker" APPLIANCE_DRY_RUN=1 bash "$layout_script" [ "$status" -eq 0 ] ;; - healthcheck.sh) - run env APPLIANCE_ROOT="$TEST_ROOT" SYSTEMCTL_ACTIVE_PRIMARY=0 bash "$layout_script" + ci-nspawn-run.sh) + run env APPLIANCE_ROOT="$TEST_ROOT" APPLIANCE_DRY_RUN=1 bash "$layout_script" --help [ "$status" -eq 0 ] ;; - primary-mode.sh|secondary-mode.sh) - run env APPLIANCE_ROOT="$TEST_ROOT" APPLIANCE_DRY_RUN=1 bash "$layout_script" - [ "$status" -eq 0 ] + container-hooks.sh) + run env APPLIANCE_ROOT="$TEST_ROOT" APPLIANCE_DRY_RUN=1 bash "$layout_script" &1 [ "$status" -ne 0 ] + [[ "$output" == *"unable to locate scripts/lib"* ]] done } diff --git a/tests/integration/mode.bats b/tests/integration/mode.bats deleted file mode 100644 index 4a53d89..0000000 --- a/tests/integration/mode.bats +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bats - -load '../helpers/common.bash' - -setup() { - setup_test_root - write_config_env '' -} - -teardown() { - teardown_test_root -} - -@test "primary/secondary mode: dry-run exits" { - run env APPLIANCE_DRY_RUN=1 bash "$APPLIANCE_REPO_ROOT/scripts/mode/primary-mode.sh" - [ "$status" -eq 0 ] - - run env APPLIANCE_DRY_RUN=1 bash "$APPLIANCE_REPO_ROOT/scripts/mode/secondary-mode.sh" - [ "$status" -eq 0 ] -} - -@test "primary/secondary mode: cmd-present execs" { - write_config_env $'APPLIANCE_PRIMARY_CMD="echo primary"\nAPPLIANCE_SECONDARY_CMD="echo secondary"' - - run env APPLIANCE_DRY_RUN=0 bash "$APPLIANCE_REPO_ROOT/scripts/mode/primary-mode.sh" - [ "$status" -eq 0 ] - [[ "$output" == *"primary"* ]] - - run env APPLIANCE_DRY_RUN=0 bash "$APPLIANCE_REPO_ROOT/scripts/mode/secondary-mode.sh" - [ "$status" -eq 0 ] - [[ "$output" == *"secondary"* ]] -} - -@test "primary/secondary mode: cmd-missing enters sleep loop" { - if ! command -v timeout >/dev/null 2>&1; then - skip "timeout not available" - fi - - write_config_env '' - run env APPLIANCE_DRY_RUN=0 timeout 0.2s bash "$APPLIANCE_REPO_ROOT/scripts/mode/primary-mode.sh" - [ "$status" -ne 0 ] - - run env APPLIANCE_DRY_RUN=0 timeout 0.2s bash "$APPLIANCE_REPO_ROOT/scripts/mode/secondary-mode.sh" - [ "$status" -ne 0 ] -} - -@test "enter mode scripts call systemctl" { - run env APPLIANCE_DRY_RUN=1 bash "$APPLIANCE_REPO_ROOT/scripts/mode/enter-primary-mode.sh" - [ "$status" -eq 0 ] - - run env APPLIANCE_DRY_RUN=1 bash "$APPLIANCE_REPO_ROOT/scripts/mode/enter-secondary-mode.sh" - [ "$status" -eq 0 ] -} diff --git a/tests/integration/runner-service.bats b/tests/integration/runner-service.bats new file mode 100644 index 0000000..c8e92a9 --- /dev/null +++ b/tests/integration/runner-service.bats @@ -0,0 +1,93 @@ +#!/usr/bin/env bats + +load '../helpers/common.bash' + +setup() { + setup_test_root + write_config_env 'APPLIANCE_LOG_PREFIX="runner"' + + # Provide a base rootfs path in the sandbox for ci-nspawn-run (used by hooks). + mkdir -p "$TEST_ROOT/var/lib/runner/nspawn/base-rootfs" +} + +teardown() { + teardown_test_root +} + +@test "runner-service: missing runner fails" { + run env APPLIANCE_ALLOW_NON_ROOT=1 bash "$APPLIANCE_REPO_ROOT/scripts/runner-service.sh" + [ "$status" -ne 0 ] +} + +@test "runner-service: no container hooks still runs" { + mkdir -p "$TEST_ROOT/opt/runner/actions-runner" + cat >"$TEST_ROOT/opt/runner/actions-runner/runsvc.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + chmod +x "$TEST_ROOT/opt/runner/actions-runner/runsvc.sh" + + run env APPLIANCE_ALLOW_NON_ROOT=1 bash "$APPLIANCE_REPO_ROOT/scripts/runner-service.sh" + [ "$status" -eq 0 ] +} + +@test "runner-service: sets container hooks when present" { + mkdir -p "$TEST_ROOT/opt/runner/actions-runner" + cat >"$TEST_ROOT/opt/runner/actions-runner/runsvc.sh" <<'EOF' +#!/usr/bin/env bash +if [[ -z "${ACTIONS_RUNNER_CONTAINER_HOOKS:-}" ]]; then + # Fail if hooks weren't set. + exit 2 +fi +exit 0 +EOF + chmod +x "$TEST_ROOT/opt/runner/actions-runner/runsvc.sh" + + mkdir -p "$TEST_ROOT/usr/local/lib/runner" + cat >"$TEST_ROOT/usr/local/lib/runner/container-hooks.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + chmod +x "$TEST_ROOT/usr/local/lib/runner/container-hooks.sh" + + run env APPLIANCE_ALLOW_NON_ROOT=1 bash "$APPLIANCE_REPO_ROOT/scripts/runner-service.sh" + [ "$status" -eq 0 ] +} + +@test "container-hooks: prepare_job writes response" { + local resp="$TEST_ROOT/resp.json" + local payload + payload='{"command":"prepare_job","responseFile":"'$resp'","args":{"container":{"image":"ubuntu:22.04"}},"state":{}}' + + run env APPLIANCE_ALLOW_NON_ROOT=1 bash "$APPLIANCE_REPO_ROOT/scripts/container-hooks.sh" <<<"$payload" + [ "$status" -eq 0 ] + assert_file_contains "$resp" '"isAlpine":false' +} + +@test "container-hooks: run_script_step routes through ci-nspawn-run" { + mkdir -p "$TEST_ROOT/work" + local resp="$TEST_ROOT/resp.json" + local payload + payload='{"command":"run_script_step","responseFile":"'$resp'","args":{"entryPoint":"echo","entryPointArgs":["hi"],"workingDirectory":"/ci/work","environmentVariables":{"FOO":"bar"},"prependPath":[]},"state":{}}' + + run env \ + APPLIANCE_ALLOW_NON_ROOT=1 \ + APPLIANCE_DRY_RUN=1 \ + GITHUB_WORKSPACE="$TEST_ROOT/work" \ + bash "$APPLIANCE_REPO_ROOT/scripts/container-hooks.sh" <<<"$payload" + [ "$status" -eq 0 ] + assert_file_contains "$APPLIANCE_CALLS_FILE" "systemd-run" + assert_file_contains "$APPLIANCE_CALLS_FILE" "--setenv=FOO=bar" + assert_file_contains "$APPLIANCE_CALLS_FILE" "--working-directory=/ci/work" + assert_file_contains "$resp" '"state"' +} + +@test "container-hooks: cleanup_job writes response" { + local resp="$TEST_ROOT/resp.json" + local payload + payload='{"command":"cleanup_job","responseFile":"'$resp'","args":{},"state":{}}' + + run env APPLIANCE_ALLOW_NON_ROOT=1 bash "$APPLIANCE_REPO_ROOT/scripts/container-hooks.sh" <<<"$payload" + [ "$status" -eq 0 ] + assert_file_contains "$resp" '"state"' +} diff --git a/tests/integration/uninstall.bats b/tests/integration/uninstall.bats new file mode 100644 index 0000000..db2e716 --- /dev/null +++ b/tests/integration/uninstall.bats @@ -0,0 +1,39 @@ +#!/usr/bin/env bats + +load '../helpers/common.bash' + +setup() { + setup_test_root + write_config_env 'APPLIANCE_LOG_PREFIX="runner"' +} + +teardown() { + teardown_test_root +} + +@test "uninstall: require_root branches" { + run bash -c "set -euo pipefail; export APPLIANCE_ROOT=\"$TEST_ROOT\"; export APPLIANCE_ALLOW_NON_ROOT=1; source \"$APPLIANCE_REPO_ROOT/scripts/uninstall.sh\"; require_root" + [ "$status" -eq 0 ] + + run bash -c "set -euo pipefail; export APPLIANCE_ROOT=\"$TEST_ROOT\"; export APPLIANCE_ALLOW_NON_ROOT=0; export APPLIANCE_EUID_OVERRIDE=1000; source \"$APPLIANCE_REPO_ROOT/scripts/uninstall.sh\"; require_root" + [ "$status" -ne 0 ] + + run bash -c "set -euo pipefail; export APPLIANCE_ROOT=\"$TEST_ROOT\"; export APPLIANCE_ALLOW_NON_ROOT=0; export APPLIANCE_EUID_OVERRIDE=0; source \"$APPLIANCE_REPO_ROOT/scripts/uninstall.sh\"; require_root" + [ "$status" -eq 0 ] +} + +@test "uninstall: dry-run removes everything (safe on partial install)" { + # Create partial install artifacts. + mkdir -p "$TEST_ROOT/usr/local/lib/runner" "$TEST_ROOT/usr/local/bin" "$TEST_ROOT/etc/systemd/system" "$TEST_ROOT/var/lib/runner" + touch "$TEST_ROOT/etc/systemd/system/runner.service" + touch "$TEST_ROOT/etc/systemd/system/runner-install.service" + touch "$TEST_ROOT/usr/local/bin/ci-nspawn-run" + touch "$TEST_ROOT/usr/local/bin/runner-uninstall" + touch "$TEST_ROOT/var/lib/runner/installed" + + run env APPLIANCE_ALLOW_NON_ROOT=1 APPLIANCE_DRY_RUN=1 bash "$APPLIANCE_REPO_ROOT/scripts/uninstall.sh" + [ "$status" -eq 0 ] + assert_file_contains "$APPLIANCE_CALLS_FILE" "systemctl disable --now runner.service" + assert_file_contains "$APPLIANCE_CALLS_FILE" "rm -f" + assert_file_contains "$APPLIANCE_CALLS_FILE" "rm -rf" +} diff --git a/tests/stubs/dirname b/tests/stubs/dirname old mode 100644 new mode 100755 diff --git a/tests/stubs/machinectl b/tests/stubs/machinectl new file mode 100755 index 0000000..443217f --- /dev/null +++ b/tests/stubs/machinectl @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "machinectl $*" >>"${APPLIANCE_CALLS_FILE:-/dev/null}" || true + +# Minimal behavior for ci-nspawn-run waiting. +if [[ "${1:-}" == "show" ]]; then + # Default to "not found" so tests can exercise the timeout branch. + exit "${MACHINECTL_SHOW_EXIT_CODE:-1}" +fi + +exit 0 diff --git a/tests/stubs/systemctl b/tests/stubs/systemctl index 264c56a..ee2cee0 100755 --- a/tests/stubs/systemctl +++ b/tests/stubs/systemctl @@ -6,12 +6,7 @@ printf 'systemctl %q\n' "$*" >>"$calls_file" || true # Minimal behavior for unit tests. if [[ "$1" == "is-active" && "$2" == "--quiet" ]]; then - unit="$3" - case "$unit" in - template-appliance-primary.service) exit "${SYSTEMCTL_ACTIVE_PRIMARY:-1}" ;; - template-appliance-secondary.service) exit "${SYSTEMCTL_ACTIVE_SECONDARY:-1}" ;; - *) exit 1 ;; - esac + exit 1 fi exit 0 diff --git a/tests/stubs/systemd-nspawn b/tests/stubs/systemd-nspawn new file mode 100755 index 0000000..229f12d --- /dev/null +++ b/tests/stubs/systemd-nspawn @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "systemd-nspawn $*" >>"${APPLIANCE_CALLS_FILE:-/dev/null}" || true + +# Pretend to stay alive briefly if invoked in background. +# When used with '&', the caller will manage the PID. +if [[ "${SYSTEMD_NSPAWN_SLEEP_S:-}" != "" ]]; then + sleep "${SYSTEMD_NSPAWN_SLEEP_S}" +fi + +exit 0 diff --git a/tests/stubs/systemd-run b/tests/stubs/systemd-run new file mode 100755 index 0000000..1ac3865 --- /dev/null +++ b/tests/stubs/systemd-run @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "systemd-run $*" >>"${APPLIANCE_CALLS_FILE:-/dev/null}" || true +exit 0 diff --git a/tests/unit/lib-common.bats b/tests/unit/lib-common.bats index a856fd9..23fcb1b 100644 --- a/tests/unit/lib-common.bats +++ b/tests/unit/lib-common.bats @@ -5,7 +5,7 @@ load '../helpers/common.bash' setup() { setup_test_root # Unit tests should not rely on external effects. - write_config_env 'APPLIANCE_LOG_PREFIX="template-appliance"' + write_config_env 'APPLIANCE_LOG_PREFIX="runner"' # shellcheck source=scripts/lib/common.sh source "$APPLIANCE_REPO_ROOT/scripts/lib/common.sh" @@ -47,6 +47,30 @@ EOF [ "$output" = "/" ] } +@test "lib-common: appliance_root rejects relative APPLIANCE_ROOT (no die)" { + run bash -c "set -euo pipefail; export APPLIANCE_REPO_ROOT=\"$APPLIANCE_REPO_ROOT\"; source \"$APPLIANCE_REPO_ROOT/scripts/lib/common.sh\"; APPLIANCE_ROOT=relative; appliance_root" 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"APPLIANCE_ROOT must be an absolute path"* ]] +} + +@test "lib-common: appliance_root rejects relative APPLIANCE_ROOT (with die)" { + run bash -c "set -euo pipefail; export APPLIANCE_REPO_ROOT=\"$APPLIANCE_REPO_ROOT\"; source \"$APPLIANCE_REPO_ROOT/scripts/lib/common.sh\"; source \"$APPLIANCE_REPO_ROOT/scripts/lib/logging.sh\"; APPLIANCE_ROOT=relative; appliance_root" 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"APPLIANCE_ROOT must be an absolute path"* ]] +} + +@test "lib-common: appliance_root rejects quote characters in APPLIANCE_ROOT (no die)" { + run bash -c "set -euo pipefail; export APPLIANCE_REPO_ROOT=\"$APPLIANCE_REPO_ROOT\"; source \"$APPLIANCE_REPO_ROOT/scripts/lib/common.sh\"; APPLIANCE_ROOT='/tmp/\"bad'; appliance_root" 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"APPLIANCE_ROOT must not contain quote characters"* ]] +} + +@test "lib-common: appliance_root rejects quote characters in APPLIANCE_ROOT (with die)" { + run bash -c "set -euo pipefail; export APPLIANCE_REPO_ROOT=\"$APPLIANCE_REPO_ROOT\"; source \"$APPLIANCE_REPO_ROOT/scripts/lib/common.sh\"; source \"$APPLIANCE_REPO_ROOT/scripts/lib/logging.sh\"; APPLIANCE_ROOT='/tmp/\"bad'; appliance_root" 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"APPLIANCE_ROOT must not contain quote characters"* ]] +} + @test "lib-common: appliance_path variants" { APPLIANCE_ROOT="$TEST_ROOT" run appliance_path relative.txt [ "$status" -eq 0 ] diff --git a/tests/unit/lib-config.bats b/tests/unit/lib-config.bats index abe3fd3..431a670 100644 --- a/tests/unit/lib-config.bats +++ b/tests/unit/lib-config.bats @@ -17,28 +17,20 @@ teardown() { } @test "lib-config: env default path" { - unset -v APPLIANCE_CONFIG_ENV run appliance_config_env_path [ "$status" -eq 0 ] - [ "$output" = "$TEST_ROOT/etc/template-appliance/config.env" ] -} - -@test "lib-config: env override path" { - APPLIANCE_CONFIG_ENV="$TEST_ROOT/custom.env" - run appliance_config_env_path - [ "$status" -eq 0 ] - [ "$output" = "$TEST_ROOT/custom.env" ] + [ "$output" = "$TEST_ROOT/etc/runner/config.env" ] } @test "lib-config: load missing is ok" { - APPLIANCE_CONFIG_ENV="$TEST_ROOT/missing.env" + rm -f "$TEST_ROOT/etc/runner/config.env" run load_config_env [ "$status" -eq 0 ] } @test "lib-config: load present" { - write_config_env 'APPLIANCE_PRIMARY_CMD="echo hello"' + write_config_env 'RUNNER_TEST_VAR="hello"' load_config_env [ "$?" -eq 0 ] - [ "${APPLIANCE_PRIMARY_CMD:-}" = "echo hello" ] + [ "${RUNNER_TEST_VAR:-}" = "hello" ] } diff --git a/tests/unit/lib-logging.bats b/tests/unit/lib-logging.bats index aeba195..c7fa798 100644 --- a/tests/unit/lib-logging.bats +++ b/tests/unit/lib-logging.bats @@ -18,7 +18,7 @@ teardown() { unset -v APPLIANCE_LOG_PREFIX run appliance_log_prefix [ "$status" -eq 0 ] - [ "$output" = "template-appliance" ] + [ "$output" = "runner" ] APPLIANCE_LOG_PREFIX="custom" run appliance_log_prefix