diff --git a/.vscode/settings.json b/.vscode/settings.json index 2b41451..e48b8ea 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,7 +15,7 @@ "[shellscript]": { "editor.defaultFormatter": "mkhl.shfmt" }, - "shfmt.executablePath": "shfmt", + "shfmt.executablePath": "${workspaceFolder}/scripts/dev/shfmt.sh", "shfmt.flags": [ "-i", "2", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ebdd162..d8dd363 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,13 +10,15 @@ This repo treats the devcontainer as the canonical “clean room” environment. -If you haven’t used devcontainers before: it’s just a Docker image that contains all the lint/test tools this repo -expects. Running CI inside it means we all see the same results (instead of “works on my machine”). +If you haven’t used devcontainers before, don’t worry: it’s a Docker image with all the tools this repo expects +(shellcheck, shfmt, bats, kcov, markdownlint, systemd-analyze, etc.). +Running CI inside it means we all see the same results, instead of “works on my machine”. -Before opening (or updating) a PR, you must run the full pipeline in the devcontainer and ensure it passes. +Before opening (or updating) a PR, please run the full pipeline in the devcontainer and make sure it’s green. -Why so strict? Most of the code here is Bash + systemd glue, and tiny differences in tool versions (shellcheck, -kcov, markdownlint, systemd-analyze) can change results. The devcontainer keeps reviews and CI predictable. +Why so strict? Most of the code here is Bash + systemd glue. +Small differences in tool versions can change lint results and even test behavior. +The devcontainer keeps reviews and CI predictable. ### CI pipeline at a glance @@ -36,7 +38,7 @@ flowchart LR The pipeline is split into stages so you can run the part you’re working on without waiting for everything. - `lint-sh`: sanity checks for shell scripts (syntax, shellcheck, formatting). - This catches common Bash footguns before you even boot a Pi. + This catches common Bash footguns before you deploy it onto a host. - `lint-yaml`: lints YAML files (cloud-init examples and GitHub workflow config). - `lint-systemd`: verifies `systemd` unit files. This doesn’t start services; it checks the unit files are valid and consistent. @@ -56,7 +58,7 @@ The pipeline is split into stages so you can run the part you’re working on wi ./scripts/ci.sh ``` -That is the same pipeline GitHub CI uses. +That runs the same pipeline GitHub Actions runs for this repo. ### Option B: Docker CLI (no VS Code) @@ -91,11 +93,11 @@ You can run individual stages by name: ### Local runs (non-devcontainer) -You can run locally with: +If you already have the toolchain installed on your machine, you can also run: ```bash make ci ``` -Local environments can drift (tool versions, missing dependencies). If local results differ from CI, trust the -devcontainer result and treat local runs as “best effort”. +Local environments can drift (tool versions, missing dependencies). +If local results differ from CI, trust the devcontainer/CI result and treat local runs as “best effort”. diff --git a/Makefile b/Makefile index f0e66fe..cf05dce 100644 --- a/Makefile +++ b/Makefile @@ -123,4 +123,4 @@ format-shell: exit 0; \ fi; \ echo "Formatting shell scripts with shfmt..."; \ - shfmt -w -i 2 -ci -sr "$${files[@]}" + ./scripts/dev/shfmt.sh -w -i 2 -ci -sr "$${files[@]}" diff --git a/README.md b/README.md index bc3e134..ef773e0 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,23 @@ # runner -Bash + systemd appliance for running a single GitHub Actions self-hosted runner on Linux (including Raspberry Pi). +This repo is a small “appliance” (a handful of Bash scripts + systemd unit files) that runs a single +GitHub Actions self-hosted runner on a Linux machine. -Goal: +If you’re new to self-hosted runners: it’s GitHub’s runner program, but running on your own machine +instead of GitHub’s hosted runners. -- 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. +Linux notes: + +- This works on general Linux (Raspberry Pi is supported, but it’s not a requirement). +- systemd is used to start/stop things reliably at boot. + +What it aims to do: + +- Keep a single host runner process managed by systemd. +- When a workflow uses job containers, run the job steps inside a short-lived `systemd-nspawn` guest + instead of using Docker. + (If `systemd-nspawn` is new to you: it’s a lightweight container that boots a small Linux userspace + with systemd inside it.) ## Documentation @@ -16,6 +27,9 @@ Goal: ## Quick start (dev + CI) +If you want to run the repo’s checks locally, the easiest path is to use the devcontainer. +It’s a Docker image with the exact lint/test tools CI uses. + Build the devcontainer image: ```bash @@ -42,14 +56,14 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the required pre-PR checks. ## Runtime model -At a glance, systemd manages two key units: +At runtime, systemd manages two services: - `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: +The normal install flow is designed for “first boot” setups (cloud-init or Pi Imager): 1. cloud-init writes `/etc/runner/config.env`. 2. cloud-init installs a one-time installer unit + bootstrap script. @@ -64,10 +78,12 @@ Examples: Runtime configuration lives in `/etc/runner/config.env`. -Required (first-boot bootstrap): +Bootstrap repo pin (used when the host needs to fetch this repo to install/update itself): + +- `RUNNER_BOOTSTRAP_REPO_URL` (a `git clone` URL for this repo or your fork) +- `RUNNER_BOOTSTRAP_REPO_REF` (branch/tag/commit; pinning to a tag/commit is recommended) -- `APPLIANCE_REPO_URL` -- `APPLIANCE_REPO_REF` (branch/tag/commit; pinning to a tag/commit is recommended) +If you omit these, bootstrap defaults to this repo on `main`. Optional: @@ -76,12 +92,12 @@ Optional: - `APPLIANCE_APT_PACKAGES` (space-separated extra packages for install) - `APPLIANCE_DRY_RUN=1` (do not modify system; record intended actions) -Runner: +Runner paths: - `RUNNER_ACTIONS_RUNNER_DIR` (default: `/opt/runner/actions-runner`) - `RUNNER_HOOKS_DIR` (default: `/usr/local/lib/runner`) -Job isolation (`systemd-nspawn`): +Job isolation (`systemd-nspawn`) settings: - `RUNNER_NSPAWN_BASE_ROOTFS` (default: `/var/lib/runner/nspawn/base-rootfs`) - `RUNNER_NSPAWN_READY_TIMEOUT_S` (default: `20`) @@ -89,6 +105,8 @@ Job isolation (`systemd-nspawn`): ## Day-2 operations +These are the “what’s running?” commands you’ll use most often. + Inspect service status: ```bash @@ -104,7 +122,7 @@ ls -l /var/lib/runner/installed || true ## Manual install (no cloud-init) -If you cannot use cloud-init, you can install via SSH. +If you can’t use cloud-init, you can still install over SSH. 1. Install prerequisites: diff --git a/cloud-init/user-data.example.yml b/cloud-init/user-data.example.yml index b8dbdfc..10bca90 100644 --- a/cloud-init/user-data.example.yml +++ b/cloud-init/user-data.example.yml @@ -9,8 +9,8 @@ write_files: content: | # Core config (required) # Pin to a tag/commit for deterministic builds. - APPLIANCE_REPO_URL=https://github.com/your-org/github-runner.git - APPLIANCE_REPO_REF=main + RUNNER_BOOTSTRAP_REPO_URL=https://github.com/your-org/github-runner.git + RUNNER_BOOTSTRAP_REPO_REF=main # Optional: where to clone to # APPLIANCE_CHECKOUT_DIR=/opt/runner @@ -48,10 +48,8 @@ write_files: die "Network not ready yet" fi - local repo_url="${APPLIANCE_REPO_URL:-}" - local repo_ref="${APPLIANCE_REPO_REF:-}" - [[ -n "$repo_url" ]] || die "APPLIANCE_REPO_URL is required" - [[ -n "$repo_ref" ]] || die "APPLIANCE_REPO_REF is required" + local repo_url="${RUNNER_BOOTSTRAP_REPO_URL:-https://github.com/theaussiepom/github-runner.git}" + local repo_ref="${RUNNER_BOOTSTRAP_REPO_REF:-main}" local checkout_dir="${APPLIANCE_CHECKOUT_DIR:-/opt/runner}" if [[ ! -d "$checkout_dir/.git" ]]; then diff --git a/docs/architecture.md b/docs/architecture.md index 44e2aae..c243b95 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,9 +1,16 @@ # Architecture +This document explains how runner fits together, in plain language. + runner is a systemd-managed appliance for running a single GitHub Actions self-hosted runner. -The runner process lives on the host, but job execution is intended to run inside an ephemeral -`systemd-nspawn` guest (systemd PID1 semantics). +The runner process lives on the host. +When a workflow runs job steps “in a container”, those steps are intended to run inside an ephemeral +`systemd-nspawn` guest. + +If `systemd-nspawn` is new to you: it’s a lightweight way to boot a small Linux userspace +with systemd as PID 1. +In this repo we create the guest for a job, run the step(s), then throw the guest away. ## Glossary @@ -15,6 +22,11 @@ See [Glossary](glossary.md). - Make first-boot installs idempotent and retryable. - Avoid Docker at runtime by routing containerized jobs through `systemd-nspawn`. +Non-goals (helpful framing): + +- This is not a multi-runner fleet manager. +- This is not trying to replace GitHub Actions; it’s a runner installation with a strict runtime model. + ## High-level component map ```mermaid @@ -79,7 +91,7 @@ sequenceDiagram alt already installed BOOT-->>SD: exit 0 else not installed - BOOT->>REPO: clone/fetch APPLIANCE_REPO_URL@APPLIANCE_REPO_REF + BOOT->>REPO: clone/fetch RUNNER_BOOTSTRAP_REPO_URL@RUNNER_BOOTSTRAP_REPO_REF BOOT->>INST: exec installer from checkout INST-->>SD: enable units, write installed marker end @@ -96,8 +108,9 @@ sudo systemctl restart runner-install.service `runner-service.sh` sets `ACTIONS_RUNNER_CONTAINER_HOOKS` when `container-hooks.sh` is present. -This is intended to allow the runner to execute containerized jobs without Docker by routing the hook -callbacks through `ci-nspawn-run`. +This is intended to allow the runner to execute containerized jobs without Docker. +The runner calls the hook script with a small JSON payload; our hook implementation translates that +into a `ci-nspawn-run` invocation. Limitations: diff --git a/docs/config-examples.md b/docs/config-examples.md index d234449..9f6b8d4 100644 --- a/docs/config-examples.md +++ b/docs/config-examples.md @@ -4,26 +4,39 @@ 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). +- `RUNNER_BOOTSTRAP_REPO_URL` + `RUNNER_BOOTSTRAP_REPO_REF` tell the host where to `git clone` this installer from. + Use your fork URL if you maintain one. + If you omit them, bootstrap defaults to this repo on `main`. - A line like `FOO=` means “set but empty”. +If you’re not sure what to put in here: + +- Start with the “Minimal” example. +- Then only add the options you actually need. + --- ## 1) Minimal: pin the repo ```bash -# Required: bootstrap pin -APPLIANCE_REPO_URL=https://github.com/your-org/github-runner.git -APPLIANCE_REPO_REF=main +# Preferred: bootstrap pin +RUNNER_BOOTSTRAP_REPO_URL=https://github.com/your-org/github-runner.git +RUNNER_BOOTSTRAP_REPO_REF=main ``` +What this does: + +- On first boot, runner’s bootstrap script clones `RUNNER_BOOTSTRAP_REPO_URL` at `RUNNER_BOOTSTRAP_REPO_REF` and + runs the installer from that checkout. +- Pinning to a tag or commit is the easiest way to make installs repeatable. + --- ## 2) Deterministic installs: pin to a tag or commit SHA ```bash -APPLIANCE_REPO_URL=https://github.com/your-org/github-runner.git -APPLIANCE_REPO_REF=v0.1.0 +RUNNER_BOOTSTRAP_REPO_URL=https://github.com/your-org/github-runner.git +RUNNER_BOOTSTRAP_REPO_REF=v0.1.0 ``` --- @@ -31,8 +44,8 @@ APPLIANCE_REPO_REF=v0.1.0 ## 3) Customize checkout, install packages, and nspawn settings ```bash -APPLIANCE_REPO_URL=https://github.com/your-org/github-runner.git -APPLIANCE_REPO_REF=main +RUNNER_BOOTSTRAP_REPO_URL=https://github.com/your-org/github-runner.git +RUNNER_BOOTSTRAP_REPO_REF=main # Where bootstrap clones the repo APPLIANCE_CHECKOUT_DIR=/opt/runner @@ -50,3 +63,8 @@ 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" ``` + +Tips: + +- `APPLIANCE_APT_PACKAGES` is for anything you want available on the host (for example `jq`). +- The `RUNNER_NSPAWN_BIND*` settings are useful when your jobs need access to a specific device or host file. diff --git a/docs/glossary.md b/docs/glossary.md index 5db062e..af277c9 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -4,9 +4,9 @@ Short definitions for terms used across the docs. ## Appliance concepts -- **Bootstrap**: The first-boot script that fetches/clones a pinned repo ref and runs the installer. +- **Bootstrap**: The “first boot” script. It 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`. +- **Repo pinning**: Installing from a specific branch/tag/commit via `RUNNER_BOOTSTRAP_REPO_URL` + `RUNNER_BOOTSTRAP_REPO_REF`. ## Linux + systemd diff --git a/examples/config.env b/examples/config.env index ec07f33..d862110 100644 --- a/examples/config.env +++ b/examples/config.env @@ -1,9 +1,9 @@ # 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/github-runner.git -APPLIANCE_REPO_REF=main +# Repo pinning for the one-time installer bootstrap +RUNNER_BOOTSTRAP_REPO_URL=https://github.com/your-org/github-runner.git +RUNNER_BOOTSTRAP_REPO_REF=main # Optional: install extra packages # APPLIANCE_APT_PACKAGES="jq" diff --git a/examples/config.env.example b/examples/config.env.example index 05af00a..d717c3f 100644 --- a/examples/config.env.example +++ b/examples/config.env.example @@ -1,8 +1,8 @@ # Example configuration for /etc/runner/config.env -# Repo pinning for the one-time installer bootstrap (required) -APPLIANCE_REPO_URL=https://github.com/your-org/github-runner.git -APPLIANCE_REPO_REF=main +# Repo pinning for the one-time installer bootstrap +RUNNER_BOOTSTRAP_REPO_URL=https://github.com/your-org/github-runner.git +RUNNER_BOOTSTRAP_REPO_REF=main # Optional: where bootstrap clones to # APPLIANCE_CHECKOUT_DIR=/opt/runner diff --git a/examples/pi-imager/user-data.example.yml b/examples/pi-imager/user-data.example.yml index c49783e..645bd31 100644 --- a/examples/pi-imager/user-data.example.yml +++ b/examples/pi-imager/user-data.example.yml @@ -8,8 +8,8 @@ write_files: permissions: "0644" content: | # Core config (required) - APPLIANCE_REPO_URL=https://github.com/your-org/github-runner.git - APPLIANCE_REPO_REF=main + RUNNER_BOOTSTRAP_REPO_URL=https://github.com/your-org/github-runner.git + RUNNER_BOOTSTRAP_REPO_REF=main # Optional: where to clone to # APPLIANCE_CHECKOUT_DIR=/opt/runner @@ -41,10 +41,8 @@ write_files: die "Network not ready yet" fi - local repo_url="${APPLIANCE_REPO_URL:-}" - local repo_ref="${APPLIANCE_REPO_REF:-}" - [[ -n "$repo_url" ]] || die "APPLIANCE_REPO_URL is required" - [[ -n "$repo_ref" ]] || die "APPLIANCE_REPO_REF is required" + local repo_url="${RUNNER_BOOTSTRAP_REPO_URL:-https://github.com/theaussiepom/github-runner.git}" + local repo_ref="${RUNNER_BOOTSTRAP_REPO_REF:-main}" local checkout_dir="${APPLIANCE_CHECKOUT_DIR:-/opt/runner}" if [[ ! -d "$checkout_dir/.git" ]]; then diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 1767b39..4ab11e8 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -32,6 +32,34 @@ network_ok() { getent hosts github.com > /dev/null 2>&1 && curl -fsS https://github.com > /dev/null 2>&1 } +default_bootstrap_repo_url() { + echo "https://github.com/theaussiepom/github-runner.git" +} + +default_bootstrap_repo_ref() { + echo "main" +} + +resolve_bootstrap_repo_url() { + if [[ -n "${RUNNER_BOOTSTRAP_REPO_URL:-}" ]]; then + cover_path "bootstrap:repo-url-runner-bootstrap" + echo "$RUNNER_BOOTSTRAP_REPO_URL" + return 0 + fi + cover_path "bootstrap:repo-url-default" + default_bootstrap_repo_url +} + +resolve_bootstrap_repo_ref() { + if [[ -n "${RUNNER_BOOTSTRAP_REPO_REF:-}" ]]; then + cover_path "bootstrap:repo-ref-runner-bootstrap" + echo "$RUNNER_BOOTSTRAP_REPO_REF" + return 0 + fi + cover_path "bootstrap:repo-ref-default" + default_bootstrap_repo_ref +} + main() { export APPLIANCE_LOG_PREFIX="runner bootstrap" @@ -55,16 +83,16 @@ main() { fi cover_path "bootstrap:network-ok" - local repo_url="${APPLIANCE_REPO_URL:-}" - local repo_ref="${APPLIANCE_REPO_REF:-}" - - if [[ -z "$repo_url" ]]; then - cover_path "bootstrap:missing-repo-url" - die "APPLIANCE_REPO_URL is required (set in /etc/runner/config.env)" + local repo_url + repo_url="$(resolve_bootstrap_repo_url)" + if [[ -z "${RUNNER_BOOTSTRAP_REPO_URL:-}" ]]; then + log "Repo URL not set; defaulting to $repo_url (set RUNNER_BOOTSTRAP_REPO_URL to override)" fi - if [[ -z "$repo_ref" ]]; then - cover_path "bootstrap:missing-repo-ref" - die "APPLIANCE_REPO_REF is required (branch/tag/commit)" + + local repo_ref + repo_ref="$(resolve_bootstrap_repo_ref)" + if [[ -z "${RUNNER_BOOTSTRAP_REPO_REF:-}" ]]; then + log "Repo ref not set; defaulting to $repo_ref (set RUNNER_BOOTSTRAP_REPO_REF to override; pinning is recommended)" fi local checkout_dir="${APPLIANCE_CHECKOUT_DIR:-$(appliance_path /opt/runner)}" diff --git a/scripts/dev/shfmt.sh b/scripts/dev/shfmt.sh new file mode 100755 index 0000000..fd1822f --- /dev/null +++ b/scripts/dev/shfmt.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +if command -v shfmt > /dev/null 2>&1; then + exec shfmt "$@" +fi + +if ! command -v docker > /dev/null 2>&1; then + echo "shfmt: command not found" >&2 + echo "Either install shfmt locally, or use the devcontainer (Docker) toolchain." >&2 + exit 127 +fi + +image="${SHFMT_DOCKER_IMAGE:-runner-devcontainer:local}" + +if ! docker image inspect "$image" > /dev/null 2>&1; then + echo "Building devcontainer image ($image) to provide shfmt..." >&2 + docker build -t "$image" -f "$repo_root/.devcontainer/Dockerfile" "$repo_root" >&2 +fi + +translated_args=() +for arg in "$@"; do + if [[ "$arg" == "$repo_root" ]]; then + translated_args+=("/work") + elif [[ "$arg" == "$repo_root"/* ]]; then + translated_args+=("/work/${arg#"$repo_root"/}") + else + translated_args+=("$arg") + fi +done + +exec docker run --rm -i \ + -v "$repo_root:/work" \ + -w /work \ + "$image" \ + shfmt "${translated_args[@]}" diff --git a/scripts/install.sh b/scripts/install.sh index 4b59c67..b0504a1 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -52,6 +52,36 @@ install_packages() { # Keep the base set minimal. Extend your appliance by adding packages as needed. run_cmd apt-get update + apt_pkg_available() { + local pkg="$1" + if ! command -v apt-cache > /dev/null 2>&1; then + return 1 + fi + if apt-cache show "$pkg" > /dev/null 2>&1; then + cover_path "install:apt-pkg-available" + return 0 + fi + cover_path "install:apt-pkg-unavailable" + return 1 + } + + local -a base_pkgs=(ca-certificates curl git jq) + + # If systemd-nspawn isn't present, try to install it when it's installable. + # This avoids surprising runtime failures when workflows use job containers. + local -a auto_pkgs=() + if ! command -v systemd-nspawn > /dev/null 2>&1; then + cover_path "install:missing-systemd-nspawn" + if apt_pkg_available systemd-container; then + cover_path "install:auto-install-systemd-container" + auto_pkgs+=(systemd-container) + else + cover_path "install:auto-install-systemd-container-unavailable" + fi + else + cover_path "install:has-systemd-nspawn" + fi + extra_pkgs=() if [[ -n "${APPLIANCE_APT_PACKAGES:-}" ]]; then # Space-separated list; intended for simple usage (e.g. "jq mosquitto-clients"). @@ -59,10 +89,8 @@ install_packages() { fi run_cmd apt-get install -y --no-install-recommends \ - ca-certificates \ - curl \ - git \ - jq \ + "${base_pkgs[@]}" \ + "${auto_pkgs[@]}" \ "${extra_pkgs[@]}" } diff --git a/tests/bin/kcov-line-coverage.sh b/tests/bin/kcov-line-coverage.sh index dbe1386..6b9d683 100755 --- a/tests/bin/kcov-line-coverage.sh +++ b/tests/bin/kcov-line-coverage.sh @@ -106,7 +106,7 @@ kcov_wrap_run() { 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" + --exclude-pattern="$REPO_ROOT/tests,$REPO_ROOT/tests/vendor,$REPO_ROOT/scripts/dev,$REPO_ROOT/scripts/ci.sh,$REPO_ROOT/scripts/ci" ) local arg_order="${KCOV_ARG_ORDER:-opts_first}" @@ -152,7 +152,7 @@ kcov_wrap_run_stdin() { 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" + --exclude-pattern="$REPO_ROOT/tests,$REPO_ROOT/tests/vendor,$REPO_ROOT/scripts/dev,$REPO_ROOT/scripts/ci.sh,$REPO_ROOT/scripts/ci" ) local arg_order="${KCOV_ARG_ORDER:-opts_first}" @@ -305,8 +305,6 @@ ln -sf "$REPO_ROOT/scripts/uninstall.sh" "$no_lib_dir/uninstall.sh" # bootstrap.sh: hit key branches without touching the network. export APPLIANCE_DRY_RUN=1 -export APPLIANCE_REPO_URL="https://example.invalid/repo.git" -export APPLIANCE_REPO_REF="deadbeef" export APPLIANCE_CHECKOUT_DIR="$tmp_dir/checkout" # installed marker early return @@ -317,12 +315,17 @@ kcov_wrap_run "bootstrap-installed" "$REPO_ROOT/scripts/bootstrap.sh" >/dev/null rm -f "$marker" # missing repo url / ref -unset -v APPLIANCE_REPO_URL -( set +e; kcov_wrap_run "bootstrap-missing-url" "$REPO_ROOT/scripts/bootstrap.sh"; exit 0 ) >/dev/null 2>&1 || true -export APPLIANCE_REPO_URL="https://example.invalid/repo.git" -unset -v APPLIANCE_REPO_REF -( set +e; kcov_wrap_run "bootstrap-missing-ref" "$REPO_ROOT/scripts/bootstrap.sh"; exit 0 ) >/dev/null 2>&1 || true -export APPLIANCE_REPO_REF="deadbeef" +unset -v RUNNER_BOOTSTRAP_REPO_URL RUNNER_BOOTSTRAP_REPO_REF + +# Default repo url/ref branch (will proceed until installer is missing). +( set +e; kcov_wrap_run "bootstrap-default-repo" "$REPO_ROOT/scripts/bootstrap.sh"; exit 0 ) >/dev/null 2>&1 || true + +# Preferred env var names. +export RUNNER_BOOTSTRAP_REPO_URL="https://example.invalid/repo.git" +export RUNNER_BOOTSTRAP_REPO_REF="deadbeef" +( set +e; kcov_wrap_run "bootstrap-runner-bootstrap-repo" "$REPO_ROOT/scripts/bootstrap.sh"; exit 0 ) >/dev/null 2>&1 || true + +unset -v RUNNER_BOOTSTRAP_REPO_URL RUNNER_BOOTSTRAP_REPO_REF # Force network failure via stubs by overriding curl for this one run. old_path="$PATH" @@ -370,11 +373,76 @@ export APPLIANCE_DRY_RUN=1 # install.sh: cover key branches without side effects (DRY_RUN + stubs). export APPLIANCE_DRY_RUN=1 + +# Ensure install.sh dependency logic is deterministic by controlling PATH. +# We cannot include /usr/bin or /bin because that would leak real systemd-nspawn, +# so build an isolated PATH with a minimal set of core tools + stubs. +make_isolated_path_dir() { + local dir="$1" + shift + mkdir -p "$dir" + + local core_tools=( + env bash sh + cat rm mkdir rmdir mv cp ln chmod touch + cut grep sed awk tr sort + date mktemp head tail sleep + ) + + local t src + for t in "${core_tools[@]}"; do + src="" + if [[ -x "/bin/$t" ]]; then + src="/bin/$t" + elif [[ -x "/usr/bin/$t" ]]; then + src="/usr/bin/$t" + fi + if [[ -n "$src" ]]; then + ln -sf "$src" "$dir/$t" + fi + done + + local stub + for stub in "$@"; do + ln -sf "$REPO_ROOT/tests/stubs/$stub" "$dir/$stub" + done +} + +install_path_no_nspawn="$tmp_dir/install-path-no-nspawn" +make_isolated_path_dir "$install_path_no_nspawn" apt-cache dirname flock id getent + +install_path_no_apt_cache="$tmp_dir/install-path-no-apt-cache" +make_isolated_path_dir "$install_path_no_apt_cache" dirname flock id getent + +install_path_with_nspawn="$tmp_dir/install-path-with-nspawn" +make_isolated_path_dir "$install_path_with_nspawn" apt-cache dirname flock id getent systemd-nspawn export APPLIANCE_ALLOW_NON_ROOT=1 export APPLIANCE_INSTALLED_MARKER="$tmp_dir/install.marker" export APPLIANCE_INSTALL_LOCK="$tmp_dir/install.lock" rm -f "$APPLIANCE_INSTALLED_MARKER" "$APPLIANCE_INSTALL_LOCK" -kcov_wrap_run "install-full-dry-run" "$REPO_ROOT/scripts/install.sh" >/dev/null 2>&1 || true + +install_full_dry_run_helper="$tmp_dir/install-full-dry-run-helper.sh" +cat >"$install_full_dry_run_helper" </dev/null 2>&1 || true + +install_no_apt_cache_helper="$tmp_dir/install-no-apt-cache-helper.sh" +cat >"$install_no_apt_cache_helper" </dev/null 2>&1 || true # marker present early printf '%s\n' ok >"$APPLIANCE_INSTALLED_MARKER" @@ -397,7 +465,32 @@ unset -v APPLIANCE_ALLOW_NON_ROOT export APPLIANCE_EUID_OVERRIDE=0 export ID_APPLIANCE_EXISTS=0 export APPLIANCE_APT_PACKAGES="jq mosquitto-clients" -kcov_wrap_run "install-root-ok-user-created" "$REPO_ROOT/scripts/install.sh" >/dev/null 2>&1 || true + +# Cover systemd-nspawn present branch. +install_root_ok_with_nspawn_helper="$tmp_dir/install-root-ok-with-nspawn-helper.sh" +cat >"$install_root_ok_with_nspawn_helper" </dev/null 2>&1 || true + +# Cover systemd-container unavailable branch when systemd-nspawn is missing. +install_root_ok_no_nspawn_helper="$tmp_dir/install-root-ok-no-nspawn-helper.sh" +cat >"$install_root_ok_no_nspawn_helper" </dev/null 2>&1 || true unset -v APPLIANCE_EUID_OVERRIDE ID_APPLIANCE_EXISTS APPLIANCE_APT_PACKAGES # root-required branch diff --git a/tests/bin/run-bats-kcov.sh b/tests/bin/run-bats-kcov.sh index 2efef8d..32da24f 100755 --- a/tests/bin/run-bats-kcov.sh +++ b/tests/bin/run-bats-kcov.sh @@ -71,7 +71,7 @@ fi common_args+=( "${report_type_args[@]}" --include-path="$ROOT_DIR/scripts" - --exclude-pattern="$ROOT_DIR/tests,$ROOT_DIR/tests/vendor,$ROOT_DIR/scripts/ci.sh,$ROOT_DIR/scripts/ci" + --exclude-pattern="$ROOT_DIR/tests,$ROOT_DIR/tests/vendor,$ROOT_DIR/scripts/dev,$ROOT_DIR/scripts/ci.sh,$ROOT_DIR/scripts/ci" ) run_kcov() { diff --git a/tests/coverage/required-paths.txt b/tests/coverage/required-paths.txt index b0c8b8e..1ab5e69 100644 --- a/tests/coverage/required-paths.txt +++ b/tests/coverage/required-paths.txt @@ -14,10 +14,12 @@ 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:repo-ref-default +bootstrap:repo-ref-runner-bootstrap +bootstrap:repo-url-default +bootstrap:repo-url-runner-bootstrap bootstrap:reuse-checkout ci-nspawn-run:base-missing ci-nspawn-run:bind-extra @@ -41,6 +43,12 @@ install:lock-busy install:marker-after-lock install:marker-present-early install:optional-features-none +install:apt-pkg-available +install:apt-pkg-unavailable +install:auto-install-systemd-container +install:auto-install-systemd-container-unavailable +install:has-systemd-nspawn +install:missing-systemd-nspawn install:root-ok install:root-required install:user-created diff --git a/tests/integration/bootstrap.bats b/tests/integration/bootstrap.bats index ad20be5..c9d2bdf 100644 --- a/tests/integration/bootstrap.bats +++ b/tests/integration/bootstrap.bats @@ -23,25 +23,27 @@ bootstrap_script() { } @test "bootstrap: network not ready" { - write_config_env $'APPLIANCE_REPO_URL="https://example.invalid/repo"\nAPPLIANCE_REPO_REF="main"' + write_config_env $'RUNNER_BOOTSTRAP_REPO_URL="https://example.invalid/repo"\nRUNNER_BOOTSTRAP_REPO_REF="main"' GETENT_HOSTS_EXIT_CODE=1 CURL_EXIT_CODE=0 run env APPLIANCE_DRY_RUN=1 bash "$(bootstrap_script)" [ "$status" -ne 0 ] } -@test "bootstrap: missing repo url" { - write_config_env 'APPLIANCE_REPO_REF="main"' - GETENT_HOSTS_EXIT_CODE=0 CURL_EXIT_CODE=0 run env APPLIANCE_DRY_RUN=1 bash "$(bootstrap_script)" - [ "$status" -ne 0 ] -} +@test "bootstrap: default repo url/ref works" { + # With no repo pin configured, bootstrap uses a repo URL+ref default. + checkout_dir="$TEST_ROOT/opt/runner" + mkdir -p "$checkout_dir/scripts" + cat >"$checkout_dir/scripts/install.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + chmod +x "$checkout_dir/scripts/install.sh" -@test "bootstrap: missing repo ref" { - write_config_env 'APPLIANCE_REPO_URL="https://example.invalid/repo"' - GETENT_HOSTS_EXIT_CODE=0 CURL_EXIT_CODE=0 run env APPLIANCE_DRY_RUN=1 bash "$(bootstrap_script)" - [ "$status" -ne 0 ] + GETENT_HOSTS_EXIT_CODE=0 CURL_EXIT_CODE=0 run env APPLIANCE_DRY_RUN=1 APPLIANCE_CHECKOUT_DIR="$checkout_dir" bash "$(bootstrap_script)" + [ "$status" -eq 0 ] } @test "bootstrap: clone path and installer dry-run" { - write_config_env $'APPLIANCE_REPO_URL="https://example.invalid/repo"\nAPPLIANCE_REPO_REF="main"' + write_config_env $'RUNNER_BOOTSTRAP_REPO_URL="https://example.invalid/repo"\nRUNNER_BOOTSTRAP_REPO_REF="main"' checkout_dir="$TEST_ROOT/opt/runner" mkdir -p "$checkout_dir/scripts" cat >"$checkout_dir/scripts/install.sh" <<'EOF' @@ -55,7 +57,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"' + write_config_env $'RUNNER_BOOTSTRAP_REPO_URL="https://example.invalid/repo"\nRUNNER_BOOTSTRAP_REPO_REF="main"' checkout_dir="$TEST_ROOT/opt/runner" mkdir -p "$checkout_dir/.git" "$checkout_dir/scripts" cat >"$checkout_dir/scripts/install.sh" <<'EOF' @@ -69,7 +71,7 @@ EOF } @test "bootstrap: installer missing" { - write_config_env $'APPLIANCE_REPO_URL="https://example.invalid/repo"\nAPPLIANCE_REPO_REF="main"' + write_config_env $'RUNNER_BOOTSTRAP_REPO_URL="https://example.invalid/repo"\nRUNNER_BOOTSTRAP_REPO_REF="main"' checkout_dir="$TEST_ROOT/opt/runner" mkdir -p "$checkout_dir/.git" @@ -78,7 +80,7 @@ EOF } @test "bootstrap: installer exec" { - write_config_env $'APPLIANCE_REPO_URL="https://example.invalid/repo"\nAPPLIANCE_REPO_REF="main"' + write_config_env $'RUNNER_BOOTSTRAP_REPO_URL="https://example.invalid/repo"\nRUNNER_BOOTSTRAP_REPO_REF="main"' checkout_dir="$TEST_ROOT/opt/runner" mkdir -p "$checkout_dir/.git" "$checkout_dir/scripts" cat >"$checkout_dir/scripts/install.sh" <<'EOF' diff --git a/tests/integration/install.bats b/tests/integration/install.bats index 8aad1cd..712509c 100644 --- a/tests/integration/install.bats +++ b/tests/integration/install.bats @@ -48,6 +48,32 @@ teardown() { [ "$status" -eq 0 ] } +@test "install: auto-installs systemd-container when systemd-nspawn missing and installable" { + write_config_env '' + + # Use an isolated PATH that includes apt-cache but *not* systemd-nspawn, + # so install.sh takes the auto-install branch. + make_isolated_path_with_stubs apt-cache dirname flock id getent + + ID_APPLIANCE_EXISTS=1 \ + APPLIANCE_STUB_FLOCK_EXIT_CODE=0 \ + APT_CACHE_HAS_SYSTEMD_CONTAINER=1 \ + run env APPLIANCE_ALLOW_NON_ROOT=1 APPLIANCE_DRY_RUN=1 bash "$APPLIANCE_REPO_ROOT/scripts/install.sh" + [ "$status" -eq 0 ] +} + +@test "install: does not auto-install systemd-container when systemd-nspawn missing but not installable" { + write_config_env '' + + make_isolated_path_with_stubs apt-cache dirname flock id getent + + ID_APPLIANCE_EXISTS=1 \ + APPLIANCE_STUB_FLOCK_EXIT_CODE=0 \ + APT_CACHE_HAS_SYSTEMD_CONTAINER=0 \ + run env APPLIANCE_ALLOW_NON_ROOT=1 APPLIANCE_DRY_RUN=1 bash "$APPLIANCE_REPO_ROOT/scripts/install.sh" + [ "$status" -eq 0 ] +} + @test "install: ensure_user branches" { run bash -c "set -euo pipefail; export APPLIANCE_ROOT=\"$TEST_ROOT\"; export APPLIANCE_DRY_RUN=1; export ID_APPLIANCE_EXISTS=1; source \"$APPLIANCE_REPO_ROOT/scripts/install.sh\"; ensure_user" [ "$status" -eq 0 ] diff --git a/tests/stubs/apt-cache b/tests/stubs/apt-cache index 300db74..adff748 100755 --- a/tests/stubs/apt-cache +++ b/tests/stubs/apt-cache @@ -11,6 +11,13 @@ fi pkg="${2:-}" case "$pkg" in + systemd-container) + if [[ "${APT_CACHE_HAS_SYSTEMD_CONTAINER:-0}" == "1" ]]; then + echo "Package: systemd-container" + exit 0 + fi + exit 1 + ;; chromium-browser) if [[ "${APT_CACHE_HAS_CHROMIUM_BROWSER:-0}" == "1" ]]; then echo "Package: chromium-browser" diff --git a/tests/unit/lib-common.bats b/tests/unit/lib-common.bats index 23fcb1b..76a6985 100644 --- a/tests/unit/lib-common.bats +++ b/tests/unit/lib-common.bats @@ -1,5 +1,6 @@ #!/usr/bin/env bats +# shellcheck disable=SC1091 load '../helpers/common.bash' setup() { @@ -109,8 +110,8 @@ EOF @test "lib-common: record_call primary/append present and absent" { # Present - APPLIANCE_CALLS_FILE="$TEST_ROOT/calls.primary.log" - APPLIANCE_CALLS_FILE_APPEND="$TEST_ROOT/calls.append.log" + export APPLIANCE_CALLS_FILE="$TEST_ROOT/calls.primary.log" + export APPLIANCE_CALLS_FILE_APPEND="$TEST_ROOT/calls.append.log" record_call "hello" assert_file_contains "$TEST_ROOT/calls.primary.log" "hello" assert_file_contains "$TEST_ROOT/calls.append.log" "hello" @@ -121,12 +122,12 @@ EOF } @test "lib-common: run_cmd dry-run vs exec" { - APPLIANCE_DRY_RUN=1 + export APPLIANCE_DRY_RUN=1 run run_cmd echo hi [ "$status" -eq 0 ] assert_file_contains "$APPLIANCE_CALLS_FILE" "echo hi" - APPLIANCE_DRY_RUN=0 + export APPLIANCE_DRY_RUN=0 run run_cmd bash -c 'exit 0' [ "$status" -eq 0 ] } diff --git a/tests/unit/lib-config.bats b/tests/unit/lib-config.bats index 431a670..318da75 100644 --- a/tests/unit/lib-config.bats +++ b/tests/unit/lib-config.bats @@ -1,5 +1,6 @@ #!/usr/bin/env bats +# shellcheck disable=SC1091 load '../helpers/common.bash' setup() { diff --git a/tests/unit/lib-logging.bats b/tests/unit/lib-logging.bats index c7fa798..454b2a1 100644 --- a/tests/unit/lib-logging.bats +++ b/tests/unit/lib-logging.bats @@ -1,5 +1,6 @@ #!/usr/bin/env bats +# shellcheck disable=SC1091 load '../helpers/common.bash' setup() { @@ -20,14 +21,14 @@ teardown() { [ "$status" -eq 0 ] [ "$output" = "runner" ] - APPLIANCE_LOG_PREFIX="custom" + export APPLIANCE_LOG_PREFIX="custom" run appliance_log_prefix [ "$status" -eq 0 ] [ "$output" = "custom" ] } @test "lib-logging: log/warn" { - APPLIANCE_LOG_PREFIX="t" + export APPLIANCE_LOG_PREFIX="t" run log "hello" [ "$status" -eq 0 ] @@ -36,7 +37,7 @@ teardown() { } @test "lib-logging: die exits non-zero" { - APPLIANCE_LOG_PREFIX="t" + export APPLIANCE_LOG_PREFIX="t" run bash -c "source \"$APPLIANCE_REPO_ROOT/scripts/lib/common.sh\"; source \"$APPLIANCE_REPO_ROOT/scripts/lib/logging.sh\"; die boom" [ "$status" -ne 0 ] } diff --git a/tests/unit/lib-path.bats b/tests/unit/lib-path.bats index 69a37d0..8667db7 100644 --- a/tests/unit/lib-path.bats +++ b/tests/unit/lib-path.bats @@ -1,5 +1,6 @@ #!/usr/bin/env bats +# shellcheck disable=SC1091 load '../helpers/common.bash' setup() { diff --git a/tests/unit/zz-path-coverage.bats b/tests/unit/zz-path-coverage.bats index 18f9d3e..a196a66 100644 --- a/tests/unit/zz-path-coverage.bats +++ b/tests/unit/zz-path-coverage.bats @@ -1,5 +1,6 @@ #!/usr/bin/env bats +# shellcheck disable=SC1091 load '../helpers/common.bash' setup() {