From 39b61d30cde919eed329e6943e95636bcec627d2 Mon Sep 17 00:00:00 2001 From: jcardozo-eth <131674798+jcardozo-eth@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:11:21 +0200 Subject: [PATCH 1/4] feat!: regroup the parameter surface into concern structs Five flat keys (concurrent, runners, runner_defaults, runner_tokens, secret_store_path) and six concern structs (configuration_file, runner_account, packages, rootless_docker, runner_service, standalone with self_update nested inside), declared in the order the generated documentation and the example node files preserve. Every default moves to the module data layer behind a deep-merge lookup rule with the '--' knockout prefix, so consumer data holds only deviations; the class signature is types-only and Struct types reject unknown subkeys at compile time. The apply script installs on any declared-standalone host, enabling the self-update loop on a host not declared standalone fails the compile, and healthcheck_interval lives on the standalone struct. BREAKING CHANGE: the flat 1.x parameter names are gone; consumer data must move to the struct keys. --- README.md | 259 +++++---- REFERENCE.md | 539 ++++++++---------- data/common.yaml | 103 +++- examples/data/common.yaml | 9 +- examples/data/nodes/host.example.yaml | 58 +- examples/secrets.example.yaml | 8 +- hiera.yaml | 9 +- manifests/apt_repos.pp | 14 +- manifests/config.pp | 72 +-- manifests/init.pp | 421 ++++++++------ manifests/packages.pp | 14 +- manifests/rootless_docker.pp | 16 +- manifests/self_update.pp | 73 +-- manifests/service.pp | 18 +- manifests/user.pp | 18 +- spec/classes/rootless_gitlab_runner_spec.rb | 390 +++++++++---- spec/fixtures/data_check/README.md | 2 +- .../consumer_clean/data/common.yaml | 8 +- .../consumer_clean/data/nodes/ci-runner.yaml | 7 +- .../consumer_stray/data/common.yaml | 7 +- .../consumer_stray/data/nodes/ci-runner.yaml | 4 +- spec/fixtures/golden/apply.sh.golden | 11 +- spec/fixtures/hiera/deep_merge.yaml | 18 + spec/fixtures/hiera/deep_merge/common.yaml | 21 + spec/fixtures/hiera/deep_merge/node.yaml | 10 + spec/fixtures/hiera/examples.yaml | 18 + spec/fixtures/hiera/plain_token_store.yaml | 14 - .../hiera/plain_token_store/common.yaml | 12 - templates/apply.service.epp | 10 +- templates/apply.sh.epp | 23 +- templates/healthcheck.sh.epp | 20 +- templates/service-dropin.conf.epp | 18 +- 32 files changed, 1303 insertions(+), 921 deletions(-) create mode 100644 spec/fixtures/hiera/deep_merge.yaml create mode 100644 spec/fixtures/hiera/deep_merge/common.yaml create mode 100644 spec/fixtures/hiera/deep_merge/node.yaml create mode 100644 spec/fixtures/hiera/examples.yaml delete mode 100644 spec/fixtures/hiera/plain_token_store.yaml delete mode 100644 spec/fixtures/hiera/plain_token_store/common.yaml diff --git a/README.md b/README.md index 6d5a8b6..bd30dee 100644 --- a/README.md +++ b/README.md @@ -108,8 +108,8 @@ OS to a fully configured one. Each step is detailed in [Installation](#installat the `Puppetfile`, clone it to the host, and fetch its modules with r10k. 3. Create the off-repository secret store and add the runner's `glrt-` token ([Editing the secret store](#editing-the-secret-store)). -4. Rename the example node file to `puppet/data/nodes/.yaml` and set `runner_uid` and - the runner's `token_key`. +4. Rename the example node file to `puppet/data/nodes/.yaml` and set + `runner_account.uid` and the runner's `token_key`. 5. Dry-run, then apply. Once the apply succeeds, the runner connects to GitLab; see @@ -120,7 +120,7 @@ Once the apply succeeds, the runner connects to GitLab; see > [!TIP] > **Use a dedicated host for the GitLab Runner.** Its only job should be running the runners, > ideally on a disposable, rebuildable VM. To harden the runner the module takes over -> host-level state: with `manage_rootless_docker` on it masks the host's rootful Docker and containerd daemons, and +> host-level state: with `rootless_docker.manage` on it masks the host's rootful Docker and containerd daemons, and > it manages the runner user and its rootless daemon. That is safe on a single-purpose host but would > disrupt other workloads, so it is not intended for a server that also runs other applications or > services. Dedicating a host to the runner is the common, best-practice approach and the cleaner @@ -146,18 +146,18 @@ for the exact supported version range). Before an apply does anything useful, th - **Puppet (or OpenVox) 8** installed and `puppet` on `PATH`. - The **`gitlab-runner` system user and home**, plus its **rootless-Docker** user daemon, - unless the module manages them: with `manage_runner_user` off the user is an external + unless the module manages them: with `runner_account.manage` off the user is an external prerequisite (owned elsewhere, for example by a central Puppet), and with - `manage_rootless_docker` off the daemon and its host requirements are. + `rootless_docker.manage` off the daemon and its host requirements are. What the module owns on a host is configuration too; the ownership toggles and their parameters live in the [Configuration contract](#configuration-contract). ### Host requirements -A rootless GitLab Runner host needs the following in place. With `manage_runner_user` and -`manage_rootless_docker` on, the module establishes and keeps converging them (apt packages by -listing them in `packages`); with the toggles off they are external prerequisites the host must +A rootless GitLab Runner host needs the following in place. With `runner_account.manage` and +`rootless_docker.manage` on, the module establishes and keeps converging them (apt packages by +listing them under `packages.install`); with the toggles off they are external prerequisites the host must provide, and the preflight is not enforced — so a missing prerequisite on the toggle-off path surfaces as a raw Puppet or host error rather than the module's clear preflight message. On Ubuntu 22.04 these are the current, verified requirements: @@ -195,7 +195,7 @@ module never writes system-wide configuration): a host-side drop-in in `/etc/systemd/system/user@.service.d/`. - `ping` inside containers needs the `net.ipv4.ping_group_range` sysctl; binding ports below 1024 needs a sysctl or capability grant. Build jobs rarely need either. -- **Package versions are neither pinned nor held** — `packages` uses `ensure => installed`. This +- **Package versions are neither pinned nor held** — `packages.install` uses `ensure => installed`. This is deliberate: the module exists to make routine upgrades *safe* (the `no-detach-netns` fix survives them), not to freeze them, so per-apply convergence — not a version lock — is the defense against a bad upgrade. Pin or hold at the apt layer where a specific host needs it. @@ -223,57 +223,84 @@ below. ### Opt-in concerns -Each remaining concern is a Hiera toggle, opt-in by default: +The parameter surface groups each remaining concern into a struct whose `manage` key is the +ownership toggle, opt-in by default: -| Concern | Hiera parameter | Default | +| Concern | Hiera key | Default | |---|---|---| -| apt packages | [`rootless_gitlab_runner::packages`](#packages) | `[]` | -| apt repositories serving those packages | [`rootless_gitlab_runner::manage_apt_repos`](#manage_apt_repos) | `false` | -| Runner user, home | [`rootless_gitlab_runner::manage_runner_user`](#manage_runner_user) | `false` | -| Rootless-Docker daemon bring-up, subordinate IDs | [`rootless_gitlab_runner::manage_rootless_docker`](#manage_rootless_docker) | `false` | -| Runner service + its systemd drop-in | [`rootless_gitlab_runner::manage_runner_service`](#manage_runner_service) | `false` | -| Standalone self-update loop + healthcheck | [`rootless_gitlab_runner::manage_standalone_self_update`](#manage_standalone_self_update) | `false` | - -#### `packages` - -The apt packages to ensure installed; the default empty list installs nothing. A rootless-runner -host on Ubuntu 22.04 needs the user-namespace helper `uidmap` [\[13\]](#ref-13) and -`dbus-user-session` [\[29\]](#ref-29); the Docker Engine package `docker-ce` [\[30\]](#ref-30), its -CLI `docker-ce-cli` [\[31\]](#ref-31) and the rootless extras `docker-ce-rootless-extras` (rootless -mode [\[4\]](#ref-4)); `containerd.io`, which packages the containerd runtime [\[32\]](#ref-32); -and `gitlab-runner` [\[5\]](#ref-5). The standalone -[example host data](examples/data/nodes/host.example.yaml) lists the full set. Their apt source is -managed by [`manage_apt_repos`](#manage_apt_repos) or provided externally. - -The `manage_*` parameters are **persistent ownership switches**, not one-shot bootstrap flags: +| apt packages | [`packages.install`](#packages) | `[]` | +| apt sources serving those packages | [`packages.sources.manage`](#packagessources) | `false` | +| Runner account (group, user, home) | [`runner_account.manage`](#runner_account) | `false` | +| Rootless-Docker daemon bring-up, subordinate IDs | [`rootless_docker.manage`](#rootless_docker) | `false` | +| Runner service + its systemd drop-in | [`runner_service.manage`](#runner_service) | `false` | +| Standalone topology (the apply script) | [`standalone.manage`](#standalone) | `false` | +| Self-update loop + healthcheck | [`standalone.self_update.manage`](#standaloneself_update) | `false` | + +The `manage` keys are **persistent ownership switches**, not one-shot bootstrap flags: set once in the host's Hiera and left on, so every apply keeps owning and drift-correcting that -concern. `false` means hands-off, not ensure-off. - -#### `manage_apt_repos` - -Adds the Docker and GitLab Runner apt repositories (with their signing -keys) via `puppetlabs/apt`, so the `packages` list installs on stock Ubuntu. Keep it off where -apt sources are owned elsewhere. `puppetlabs/apt` is needed only by this toggle; -`puppetlabs/stdlib` is needed unconditionally. Consumers add both to their Puppetfile (r10k -does not resolve module metadata dependencies; the example skeleton carries the lines). - -#### `manage_runner_user` +concern. `false` means hands-off, not ensure-off. A `manage` key decides whether the module +creates and enforces its concern's *resources*; most struct keys are consumed only by their own +concern, so they are inert while its `manage` is false, while `runner_account`'s identity keys +are shared inputs every concern reads. + +#### Struct parameters and deep merge + +Every subkey has a module-supplied default, and each struct parameter carries a deep-merge +lookup rule, so consumer data holds only deviations: a node file setting +`rootless_docker.manage: true` inherits the rest of that struct from the module defaults, and +different Hiera layers can each own different subkeys. Within a merged struct, a scalar subkey +set at a higher-priority layer wins and hashes merge recursively; array subkeys are **unioned** +across layers, and an element prefixed with `--` (the deep-merge knockout prefix) removes the +matching element a lower layer contributed. Removing a whole subkey through data is not +expressible: override it with the intended value instead. A mistyped subkey fails the compile +instead of being silently ignored. The documented consumption pattern is Hiera plus +`include rootless_gitlab_runner`; a resource-style class declaration bypasses Hiera merging and +must pass complete struct hashes. -Owns the runner group, user, and home. Keep it off -where another configuration-management system owns the user; two owners would fight over it. -The subordinate UID/GID ranges rootless Docker needs are owned by -[`manage_rootless_docker`](#manage_rootless_docker), not this toggle. -Home internals (`.ssh`, `.config`) are never managed, beyond the no-detach-netns drop-in the -module places under `~/.config/systemd/user/`. - -#### `manage_rootless_docker` +#### `packages` -Brings up the rootless-Docker user daemon: provisions the subordinate UID/GID ranges the -daemon needs (`subid_start`/`subid_count`, default `231072`/`165536`; an existing entry is -never overwritten), enables lingering and runs +`packages.install` lists the apt packages to ensure installed; the default empty list installs +nothing, and the module only installs — it never removes, pins, or upgrades packages. A +rootless-runner host on Ubuntu 22.04 needs the user-namespace helper `uidmap` +[\[13\]](#ref-13) and `dbus-user-session` [\[29\]](#ref-29); the Docker Engine package +`docker-ce` [\[30\]](#ref-30), its CLI `docker-ce-cli` [\[31\]](#ref-31) and the rootless +extras `docker-ce-rootless-extras` (rootless mode [\[4\]](#ref-4)); `containerd.io`, which +packages the containerd runtime [\[32\]](#ref-32); and `gitlab-runner` [\[5\]](#ref-5). The +standalone [example host data](examples/data/nodes/host.example.yaml) lists the full set. +Their apt source is managed by [`packages.sources`](#packagessources) or provided externally. + +#### `packages.sources` + +With `packages.sources.manage` on, the module adds the Docker and GitLab Runner apt +repositories (with their signing keys) via `puppetlabs/apt`, so the `packages.install` list +installs on stock Ubuntu. Keep it off where apt sources are owned elsewhere. The +`packages.sources.docker` and `packages.sources.gitlab_runner` sub-hashes each carry +`location` and `key_source` (verbatim `apt::source` parameter names), overridden only to point +at a mirror; the defaults are the vendors' repositories and their rolling signing-key +endpoints. `puppetlabs/apt` is needed only by this toggle; `puppetlabs/stdlib` is needed +unconditionally. Consumers add both to their Puppetfile (r10k does not resolve module metadata +dependencies; the example skeleton carries the lines). + +#### `runner_account` + +The OS account the runner manager and the rootless daemon run as: `name`, `uid`, and `home`, +with `manage` deciding ownership. The identity keys are read by every concern even when +`manage: false` (socket derivation, file ownership, service `ExecStart`); the toggle only +decides whether the module creates and enforces the group, user, and home. Keep `manage` off +where another configuration-management system owns the account; two owners would fight over +it. The subordinate UID/GID ranges rootless Docker needs are owned by +[`rootless_docker`](#rootless_docker), not this toggle. Home internals (`.ssh`, `.config`) are +never managed, beyond the no-detach-netns drop-in the module places under +`~/.config/systemd/user/`. + +#### `rootless_docker` + +With `rootless_docker.manage` on, the module brings up the rootless-Docker user daemon: +provisions the subordinate UID/GID ranges the daemon needs (`subid_start`/`subid_count`, +default `231072`/`165536`; an existing entry is never overwritten), enables lingering and runs `dockerd-rootless-setuptool.sh install` [\[4\]](#ref-4) as the runner user (guarded on installed state, so it runs only until the rootless daemon's user unit exists). The ranges are provisioned for the -runner user whether the module owns the account ([`manage_runner_user`](#manage_runner_user)) +runner user whether the module owns the account ([`runner_account`](#runner_account)) or another system does. The setuptool is upstream's supported installer and ships in `docker-ce-rootless-extras`, version-locked to the daemon it configures — the module invokes it rather than re-rendering its output, so the generated @@ -284,7 +311,7 @@ the other changing. The whole chain sits behind a fail-loud preflight that asserts the prerequisites (`newuidmap` present, subordinate IDs of at least 65,536, lingering enabled, cgroup v2) and aborts with a clear -message — only active when `manage_rootless_docker` is `true`. On a host where everything is already in place the +message — only active when `rootless_docker.manage` is `true`. On a host where everything is already in place the chain is a no-op. The toggle also **stops and masks the rootful system @@ -292,50 +319,68 @@ The toggle also **stops and masks the rootful system `docker-ce`/`containerd.io` starts as root), so the only container daemon on the host is the unprivileged one — see [Security](#security). -#### `manage_runner_service` +#### `runner_service` -With `manage_runner_service` on, the module owns the `gitlab-runner` system service, a +With `runner_service.manage` on, the module owns the `gitlab-runner` system service, a systemd drop-in for it (`/etc/systemd/system/gitlab-runner.service.d/10-rootless.conf`), and the mode on `/etc/gitlab-runner` so the privilege-dropped manager can read its own config. The -drop-in runs the manager privilege-dropped as the runner user — that is the module's posture, -not a knob — with `DOCKER_HOST` pointed at the derived rootless docker socket; -`rootless_gitlab_runner::service_environment` overrides the environment lines rendered into -the drop-in, and `rootless_gitlab_runner::service_timeout_stop_sec` sets the graceful-drain -window ([Restarts and graceful shutdown](#restarts-and-graceful-shutdown)). - -#### `manage_standalone_self_update` - -Installs the self-update loop, two units each on its own timer: - -- An apply script (`/usr/local/sbin/rootless-gitlab-runner-apply`), a oneshot systemd service + - timer (default every `5min`) that fetch the control-repository checkout (`repo_path`), run - `git verify-commit` on the remote branch (only signed commits are applied), reset to it, install - Puppetfile-pinned modules via `r10k puppetfile install` (a no-op without a Puppetfile), and - re-apply. -- A healthcheck script + timer (default every `15min`) that verifies the manager service, the - rootless daemon (`docker info` as the runner user, from a non-login context), and that the - checkout is not stale against the remote (a dead pull credential fails loud instead of leaving - the host applying old code behind a green timer). - -The service sets `HOME=/root` (git/SSH need it) and an explicit `TimeoutStartSec`; Puppet exit -code 2 ("changes applied") counts as success. +drop-in runs the manager privilege-dropped as the runner account — that is the module's +posture, not a knob — with `DOCKER_HOST` pointed at the derived rootless docker socket; +`runner_service.environment` overrides the environment lines rendered into the drop-in, and +`runner_service.timeout_stop_sec` sets the graceful-drain window +([Restarts and graceful shutdown](#restarts-and-graceful-shutdown)). + +#### `standalone` + +`standalone.manage` declares the host standalone — a host that applies itself with +`puppet apply` from a control-repository checkout (`standalone.control_repository_path`), +rather than being deployed by a Puppet server. It installs the apply script +(`/usr/local/sbin/rootless-gitlab-runner-apply`), the single definition of the apply command +for manual runs and the self-update loop alike +([Applying the configuration](#applying-the-configuration)). The apply's manifest, module +directory and Hiera configuration derive from the documented control-repository layout +(`puppet/manifests/site.pp`, `puppet/modules`, `puppet/hiera.yaml` beneath the checkout); the +isolated Puppet state directories are `standalone.puppet_confdir`/`standalone.puppet_vardir`, +and `standalone.puppet_bindir` locates the `puppet`/`r10k` executables for the timer-driven, +non-login apply. + +#### `standalone.self_update` + +With `standalone.self_update.manage` on — only valid on a standalone host: enabling it with +`standalone.manage` off fails at compile time — the module installs the self-update loop, two +units each on its own timer: + +- A oneshot systemd service + timer (default every `5min`, `standalone.self_update.apply_interval`) + that fetch the control-repository checkout, run `git verify-commit` on the remote branch + (only signed commits are applied), reset to it, install Puppetfile-pinned modules via + `r10k puppetfile install` (a no-op without a Puppetfile), and re-apply through the apply + script above. +- A healthcheck script + timer (default every `15min`, `standalone.healthcheck_interval`) that + verifies the manager service, the rootless daemon (`docker info` as the runner user, from a + non-login context), and that the checkout is not stale against the remote (a dead pull + credential fails loud instead of leaving the host applying old code behind a green timer). + +The service sets `HOME=/root` (git/SSH need it) and an explicit `TimeoutStartSec` +(`standalone.self_update.apply_timeout`); Puppet exit code 2 ("changes applied") counts as +success. Never enable it where a Puppet server or r10k already deploys the host: one deploy agent per host. ### Host-specific values Beyond the toggles, two contract values are **host data** — set per host in the Hiera node -file ([`host.example.yaml`](examples/data/nodes/host.example.yaml) shows both). `runner_uid` -has no default and the apply **fails at compile time with a clear message** when it is unset -but needed; `repo_path` is defaulted but host-specific: +file ([`host.example.yaml`](examples/data/nodes/host.example.yaml) shows both). +`runner_account.uid` has no default and the apply **fails at compile time with a clear +message** when it is unset but needed; `standalone.control_repository_path` is defaulted but +host-specific: -| Parameter | Description | Default | +| Hiera key | Description | Default | |---|---|---| -| `runner_uid` | Numeric uid of the runner user; the rootless runtime paths (`/run/user/`, the docker socket) derive from it | none — required when `manage_runner_user`, `manage_rootless_docker` or `manage_standalone_self_update` is on, or for a `socket_mount` runner | -| `repo_path` | Checkout of the control repository on the host (the self-update target) | `/opt/gitlab-runner-infra` | +| `runner_account.uid` | Numeric uid of the runner account; the rootless runtime paths (`/run/user/`, the docker socket) derive from it | none — required when `runner_account.manage`, `rootless_docker.manage` or `standalone.self_update.manage` is on, or for a `socket_mount` runner | +| `standalone.control_repository_path` | Checkout of the control repository on the host (the apply and self-update target) | `/opt/gitlab-runner-infra` | -A wrong `repo_path` is caught at runtime — by the self-update service's fetch and the -healthcheck's staleness assertion — not at compile time. +A wrong `standalone.control_repository_path` is caught at runtime — by the self-update +service's fetch and the healthcheck's staleness assertion — not at compile time. ### File layout @@ -421,9 +466,11 @@ pipeline (parser validation, YAML lint, this check) for a standalone control rep same command also works as a pre-commit hook for local feedback. Two behaviors to know: - **Advisory, non-failing:** subkeys set under a hash parameter whose effective `manage` - toggle resolves to `false` are recognized but inert (the module is hands-off that concern). - The check reports them as an advisory rather than a failure — declaring the state of an - externally owned concern can be intentional — and a human judges intent. + toggle resolves to `false` are recognized but not enforced as resources (the module is + hands-off that concern, though it may still read some of them as shared inputs; see + the `manage`-key rule above). The check reports them as an advisory rather than a + failure — declaring the state of an externally owned concern can be intentional — and a + human judges intent. - **Stated limits:** the check validates key names, not values (types are the compiler's job, enforced at compile time), and it cannot see data layers outside the repository, such as the off-repository secret store. Hierarchy levels addressed by `glob`/`globs` or `mapped_paths` @@ -456,7 +503,7 @@ kernel's user namespace enforces it on every container, with no operator action masked, so the only container daemon on the host is the rootless one. - Only the runner user (with its `DOCKER_HOST`) reaches the daemon; the system-wide `docker` CLI is inert for everyone else. -- The runner **manager service** always drops to the runner user, so not even the manager +- The runner **manager service** always drops to the runner account, so not even the manager runs as root. - Runner **tokens** are handled as `Sensitive` values and stay file-private to the runner uid (see [Secrets](#secrets)). @@ -649,13 +696,14 @@ repository. Bootstrapping a host (run as root): sudo /opt/puppetlabs/bin/puppet apply --noop --confdir /etc/gitlab-runner-infra/puppet --vardir /var/lib/grunner-puppet --modulepath puppet/modules --hiera_config puppet/hiera.yaml puppet/manifests/site.pp ``` -7. If the preview looks right, apply for real (same command without `--noop`). -8. Optionally set `manage_standalone_self_update: true` to have the module install the apply - script and timers that automate future applies (see - [Automating with systemd](#automating-with-systemd)). +7. If the preview looks right, apply for real (same command without `--noop`). With + `standalone.manage: true` (as in the example node data), this apply also installs the apply + script that carries every later manual run. +8. Optionally set `standalone.self_update.manage: true` to have the module install the timers + that automate future applies (see [Automating with systemd](#automating-with-systemd)). 9. Check the result (see [Verifying the host](#verifying-the-host)). -Each `manage_*` toggle decides whether the module owns a concern and keeps it converged (on) or +Each `manage` toggle decides whether the module owns a concern and keeps it converged (on) or treats it as an external prerequisite the host must provide (off). The semantics and the full toggle table are in the [Configuration contract](#configuration-contract). @@ -669,7 +717,7 @@ instead of the apply script and service below.* Run as root on the host. -With `manage_standalone_self_update` on, the module installs +With `standalone.manage` on, the module installs `/usr/local/sbin/rootless-gitlab-runner-apply` — the **single definition of the apply command**. It runs `puppet apply` with an isolated `--confdir`/`--vardir` so it never collides with a central Puppet agent, installs Puppetfile-pinned modules via r10k first (a no-op without a @@ -696,7 +744,7 @@ sudo /usr/local/sbin/rootless-gitlab-runner-apply The script uses `--detailed-exitcodes` [\[1\]](#ref-1): exit code 0 means no changes, 2 means changes were applied (success, not failure), 4 or 6 mean failures. -With the self-update units installed, prefer triggering a run through the apply service +With the self-update units installed (`standalone.self_update.manage`), prefer triggering a run through the apply service rather than the script directly. It goes through the same fetch + signature-verify chain the timer uses and serialises against it — the oneshot never overlaps a scheduled run: @@ -707,7 +755,7 @@ sudo systemctl start gitlab-runner-apply.service Running the script directly bypasses that serialisation and the fetch/verify step; keep it for `--noop` previews and ad-hoc local runs. -Before the script exists (the first apply on a fresh host, or with the self-update toggle off), +Before the script exists (the first apply on a fresh host, or with `standalone.manage` off), use the plain `puppet apply` invocation from step 6 of [Installation](#installation). ### Restarts and graceful shutdown @@ -718,7 +766,7 @@ unit files (for example the module's privilege-drop drop-in). Where the module m service, that restart sends **SIGQUIT** [\[20\]](#ref-20), which GitLab Runner treats as a graceful shutdown: it stops taking new jobs and lets running ones finish instead of aborting them, which systemd's default SIGTERM would do. The graceful-drain signal is fixed; the drain window is data — set -`service_timeout_stop_sec` (systemd's `TimeoutStopSec` [\[21\]](#ref-21)) to the longest job a drain should +`runner_service.timeout_stop_sec` (systemd's `TimeoutStopSec` [\[21\]](#ref-21)) to the longest job a drain should wait for before systemd escalates to SIGKILL (GitLab's documented example is `7200`; unset, systemd's default of roughly 90s applies). @@ -727,9 +775,10 @@ wait for before systemd escalates to SIGKILL *Standalone only. In a fleet, your Puppet server (or existing apply pipeline) already provides continuous convergence.* -With `manage_standalone_self_update` on, the module installs and keeps converged the full -self-update loop: the apply script above, `gitlab-runner-apply.service` + `.timer` (fetch, -verify the commit signature, reset to the remote branch, apply, default every 5 minutes), and +With `standalone.self_update.manage` on, the module installs and keeps converged the full +self-update loop: `gitlab-runner-apply.service` + `.timer` (fetch, +verify the commit signature, reset to the remote branch, apply through the apply script above, +default every 5 minutes), and `gitlab-runner-healthcheck.service` + `.timer`. Nothing needs to be copied or enabled by hand; the timers are started and enabled by the apply that installs them. @@ -749,7 +798,7 @@ depends on the trust chain in [Self-update prerequisites](#self-update-prerequis ### Self-update prerequisites *Standalone only — these prerequisites (the pull credential and the whole commit-signing trust -chain) matter only with `manage_standalone_self_update` on. A host without the self-update loop +chain) matter only with `standalone.self_update.manage` on. A host without the self-update loop (a fleet host, or standalone without the timers) needs none of them.* The self-update loop fetches and verifies the control repository before it applies, so three @@ -758,8 +807,8 @@ missing the loop fails loud on its first tick, by design: a broken trust chain m silently apply. Signature verification is non-optional within the loop, by design: it is what makes unattended -auto-apply safer. To run without it, leave `manage_standalone_self_update` off and apply another -way (plain `puppet apply`, or an operator's own timer). +auto-apply safer. To run without it, leave `standalone.self_update.manage` off and apply another +way (the apply script by hand, or an operator's own timer). 1. **A pull credential:** The apply service fetches `origin` as root. Provision a **read-only, project-scoped SSH deploy key** [\[26\]](#ref-26) in root's `~/.ssh` (with the matching `known_hosts`) so the diff --git a/REFERENCE.md b/REFERENCE.md index 9aefd15..a6a341a 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -12,11 +12,11 @@ #### Private Classes -* `rootless_gitlab_runner::apt_repos`: Adds the apt repositories the `packages` list installs from. -* `rootless_gitlab_runner::config`: Renders the runner config, the no-detach-netns drop-in and the secret-store directory. -* `rootless_gitlab_runner::packages`: Installs the packages listed in the `packages` parameter. +* `rootless_gitlab_runner::apt_repos`: Adds the apt repositories the `packages.install` list installs from. +* `rootless_gitlab_runner::config`: Renders the runner configuration, the no-detach-netns drop-in and the secret-store directory. +* `rootless_gitlab_runner::packages`: Installs the packages listed under `packages.install`. * `rootless_gitlab_runner::rootless_docker`: Brings up the rootless docker user daemon behind a fail-loud preflight. -* `rootless_gitlab_runner::self_update`: Installs the standalone self-update units and the healthcheck. +* `rootless_gitlab_runner::self_update`: Installs the standalone apply script and the optional self-update loop. * `rootless_gitlab_runner::service`: Manages the runner system service and its privilege-drop drop-in. * `rootless_gitlab_runner::user`: Owns the runner group, user and home. @@ -32,47 +32,61 @@ ### `rootless_gitlab_runner` -Renders the runner config (config.toml) from a Hiera-driven list of runners, -manages the rootless-docker "no-detach-netns" systemd user drop-in and the -secret-store directory, installs the apt packages listed in `packages`, and -optionally: owns the runner user, brings up the rootless docker user daemon -(with the subordinate ID ranges it needs) behind a fail-loud -preflight, manages the runner service together with its privilege-drop -drop-in, and installs the standalone self-update units. Designed to run -standalone via `puppet apply` with an isolated confdir/vardir so it never -collides with a central Puppet agent. +Renders the runner configuration file (config.toml) from a Hiera-driven list +of runners, manages the rootless-docker "no-detach-netns" systemd user +drop-in and the secret-store directory, installs the apt packages listed in +`packages.install`, and optionally: owns the runner account, brings up the +rootless docker user daemon (with the subordinate ID ranges it needs) behind +a fail-loud preflight, manages the runner service together with its +privilege-drop drop-in, and installs the standalone apply script with the +optional self-update loop. Designed to run standalone via `puppet apply` +with an isolated confdir/vardir so it never collides with a central Puppet +agent. Runner tokens are never stored in this module or in version control. They are looked up at apply time from an off-repository secret store via the `runner_tokens` parameter, keyed by each runner's `token_key`. -The `manage_*` parameters are persistent ownership switches, not one-shot -bootstrap flags: set once per host and left on, so every apply keeps owning -and drift-correcting that concern. `false` means hands-off, not ensure-off. +Every parameter default lives in the module data layer (`data/common.yaml`), +and each struct parameter carries a deep-merge lookup rule, so consumer data +holds only deviations: a partial hash is merged over the module defaults on +lookup. The documented consumption pattern is Hiera plus +`include rootless_gitlab_runner`; a resource-style declaration bypasses +Hiera merging and must pass complete struct hashes. + +The `manage` keys are persistent ownership switches, not one-shot bootstrap +flags: set once per host and left on, so every apply keeps owning and +drift-correcting that concern. `false` means hands-off, not ensure-off. A +`manage` key decides whether the module creates and enforces its concern's +resources; most struct keys are consumed only by their own concern (so they +are inert while its `manage` is false), while `runner_account`'s identity +keys are shared inputs every concern reads. #### Examples -##### Greenfield standalone host (everything on) +##### Greenfield standalone host (everything on), as Hiera node data ```puppet -class { 'rootless_gitlab_runner': - packages => ['uidmap', 'dbus-user-session', 'docker-ce', - 'docker-ce-cli', 'docker-ce-rootless-extras', - 'containerd.io', 'gitlab-runner'], - manage_apt_repos => true, - runner_uid => 2000, - manage_runner_user => true, - manage_rootless_docker => true, - manage_runner_service => true, - runners => [ - { 'name' => 'docker-rootless', - 'url' => 'https://gitlab.example.org/', - 'executor' => 'docker', - 'image' => 'ubuntu:22.04', - 'token_key' => 'runner_a', - }, - ], -} +rootless_gitlab_runner::runners: + - name: 'docker-rootless' + url: 'https://gitlab.example.org/' + executor: 'docker' + image: 'ubuntu:22.04' + token_key: 'runner_a' +rootless_gitlab_runner::runner_account: + manage: true + uid: 2000 +rootless_gitlab_runner::packages: + install: ['uidmap', 'dbus-user-session', 'docker-ce', 'docker-ce-cli', + 'docker-ce-rootless-extras', 'containerd.io', 'gitlab-runner'] + sources: + manage: true +rootless_gitlab_runner::rootless_docker: + manage: true +rootless_gitlab_runner::runner_service: + manage: true +rootless_gitlab_runner::standalone: + manage: true ``` #### Parameters @@ -83,47 +97,28 @@ The following parameters are available in the `rootless_gitlab_runner` class: * [`runners`](#-rootless_gitlab_runner--runners) * [`runner_defaults`](#-rootless_gitlab_runner--runner_defaults) * [`runner_tokens`](#-rootless_gitlab_runner--runner_tokens) -* [`runner_user`](#-rootless_gitlab_runner--runner_user) -* [`runner_uid`](#-rootless_gitlab_runner--runner_uid) -* [`runner_home`](#-rootless_gitlab_runner--runner_home) -* [`config_path`](#-rootless_gitlab_runner--config_path) * [`secret_store_path`](#-rootless_gitlab_runner--secret_store_path) +* [`configuration_file`](#-rootless_gitlab_runner--configuration_file) +* [`runner_account`](#-rootless_gitlab_runner--runner_account) * [`packages`](#-rootless_gitlab_runner--packages) -* [`manage_apt_repos`](#-rootless_gitlab_runner--manage_apt_repos) -* [`docker_repo_location`](#-rootless_gitlab_runner--docker_repo_location) -* [`docker_repo_key_source`](#-rootless_gitlab_runner--docker_repo_key_source) -* [`gitlab_runner_repo_location`](#-rootless_gitlab_runner--gitlab_runner_repo_location) -* [`gitlab_runner_repo_key_source`](#-rootless_gitlab_runner--gitlab_runner_repo_key_source) -* [`manage_runner_user`](#-rootless_gitlab_runner--manage_runner_user) -* [`subid_start`](#-rootless_gitlab_runner--subid_start) -* [`subid_count`](#-rootless_gitlab_runner--subid_count) -* [`manage_rootless_docker`](#-rootless_gitlab_runner--manage_rootless_docker) -* [`manage_runner_service`](#-rootless_gitlab_runner--manage_runner_service) -* [`service_environment`](#-rootless_gitlab_runner--service_environment) -* [`service_timeout_stop_sec`](#-rootless_gitlab_runner--service_timeout_stop_sec) -* [`manage_standalone_self_update`](#-rootless_gitlab_runner--manage_standalone_self_update) -* [`repo_path`](#-rootless_gitlab_runner--repo_path) -* [`repo_branch`](#-rootless_gitlab_runner--repo_branch) -* [`apply_confdir`](#-rootless_gitlab_runner--apply_confdir) -* [`apply_vardir`](#-rootless_gitlab_runner--apply_vardir) -* [`apply_interval`](#-rootless_gitlab_runner--apply_interval) -* [`apply_timeout`](#-rootless_gitlab_runner--apply_timeout) -* [`puppet_bindir`](#-rootless_gitlab_runner--puppet_bindir) -* [`healthcheck_interval`](#-rootless_gitlab_runner--healthcheck_interval) +* [`rootless_docker`](#-rootless_gitlab_runner--rootless_docker) +* [`runner_service`](#-rootless_gitlab_runner--runner_service) +* [`standalone`](#-rootless_gitlab_runner--standalone) ##### `concurrent` Data type: `Integer[1]` -Global `concurrent` value written to the runner config. - -Default value: `1` +Global `concurrent` value written to the runner configuration file: the +name is GitLab Runner's own `config.toml` global key, written unchanged. +Default 1. ##### `runners` Data type: `Array[Hash]` -Ordered list of runner definitions. Each entry is a hash; recognised keys: +Ordered list of runner definitions, rendered as the `[[runners]]` sections +of `config.toml`. Each entry is a hash; recognised keys: `name`, `url`, `id` (Integer), `executor`, `token_key`, `host`, `image`, `privileged` (Boolean), `tls_verify` (Boolean), `socket_mount` (Boolean), `volumes` (Array[String]), `security_opt` (Array[String]), `environment` @@ -134,9 +129,9 @@ Ordered list of runner definitions. Each entry is a hash; recognised keys: key `MaxUploadedArchiveSize` (Integer, default 0)), `allowed_images` (Array[String]), `allowed_pull_policies` (Array[String]). Tokens are merged in from `runner_tokens[token_key]` and must not appear -here. - -Default value: `[]` +here. `host` is only for an externally managed daemon at a non-derived +path: where the module manages the runner service, the drop-in's +`DOCKER_HOST` already points the executor at the derived rootless socket. ##### `runner_defaults` @@ -146,8 +141,6 @@ Hash merged under every `runners` entry (`$runner_defaults + $runner`, keys set on the entry win), so multi-runner data does not repeat `url`, `image`, `executor` and friends. Recognised keys as in `runners`. -Default value: `{}` - ##### `runner_tokens` Data type: `Sensitive[Hash[String, String]]` @@ -163,277 +156,225 @@ apply fails (typo/missed-provisioning guard). The rendered configuration content is `Sensitive` too, so tokens never reach the compiled catalog, reports, or `--show_diff` output. -Default value: `Sensitive({})` - -##### `runner_user` - -Data type: `Rootless_gitlab_runner::Username` - -System user the runner manager and rootless docker run as. - -Default value: `'gitlab-runner'` - -##### `runner_uid` - -Data type: `Optional[Integer[1]]` - -Numeric uid of the runner user. No default: the uid is host data, not -something a module can sensibly invent. It derives the rootless runtime -paths (`/run/user/`, the docker socket) and is enforced on the user -when `manage_runner_user` is on; the apply fails loud when it is unset but -needed — with `manage_runner_user`, `manage_rootless_docker` or -`manage_standalone_self_update` on, or to derive the docker socket path -for a `socket_mount` runner. - -Default value: `undef` - -##### `runner_home` - -Data type: `Stdlib::Absolutepath` - -Home directory of the runner user. - -Default value: `'/home/gitlab-runner'` - -##### `config_path` - -Data type: `Stdlib::Absolutepath` - -Path of the rendered runner config file. Owner and group derive from -`runner_user`; the mode is fixed 0600 (the file carries the runner -tokens). - -Default value: `'/etc/gitlab-runner/config.toml'` - ##### `secret_store_path` Data type: `Stdlib::Absolutepath` -Directory of the off-repo secret store. The directory itself is managed -(root-owned, 0700); the secret file inside it is host-provisioned and -never managed. - -Default value: `'/etc/gitlab-runner-infra'` +Path of the off-repository secret store directory. The directory itself is +managed (root-owned, 0700); the secret file inside it is host-provisioned +and never managed. Default `/etc/gitlab-runner-infra`. -##### `packages` +##### `configuration_file` -Data type: `Array[String[1]]` +Data type: -Packages to ensure installed. Empty list (the default) installs nothing. -The apt repositories serving them can be managed via `manage_apt_repos`; -with that off, repo setup is an external prerequisite. - -Default value: `[]` - -##### `manage_apt_repos` - -Data type: `Boolean` - -Whether to manage the apt repositories the `packages` list installs from -(Docker's and GitLab Runner's, via `puppetlabs/apt`). Keep off where apt -sources are owned elsewhere (central config management, e.g. Foreman/Katello). -Default false. - -Default value: `false` - -##### `docker_repo_location` - -Data type: `Stdlib::HTTPUrl` - -Docker apt repository URL (suite = OS codename, component `stable`). - -Default value: `'https://download.docker.com/linux/ubuntu'` - -##### `docker_repo_key_source` - -Data type: `Stdlib::HTTPUrl` - -URL of Docker's armored signing key (stored as an apt keyring). - -Default value: `'https://download.docker.com/linux/ubuntu/gpg'` - -##### `gitlab_runner_repo_location` - -Data type: `Stdlib::HTTPUrl` +```puppet +Struct[{ + path => Stdlib::Absolutepath, + }] +``` -GitLab Runner apt repository URL (suite = OS codename, component `main`). +The rendered runner configuration file (GitLab Runner's `config.toml`). +Owner and group derive from `runner_account.name`; the mode is fixed 0600 +(the file carries the runner tokens). -Default value: `'https://packages.gitlab.com/runner/gitlab-runner/ubuntu'` +Options: -##### `gitlab_runner_repo_key_source` +* **:path** `Stdlib::Absolutepath`: Where the file is written. Default `/etc/gitlab-runner/config.toml`. -Data type: `Stdlib::HTTPUrl` +##### `runner_account` -URL of GitLab Runner's armored signing key (stored as an apt keyring). +Data type: -Default value: `'https://packages.gitlab.com/runner/gitlab-runner/gpgkey'` +```puppet +Struct[{ + manage => Boolean, + name => Rootless_gitlab_runner::Username, + uid => Optional[Integer[1]], + home => Stdlib::Absolutepath, + }] +``` -##### `manage_runner_user` +The OS account the runner manager and the rootless docker daemon run as. +The identity keys are read by every concern even when `manage: false` +(socket derivation, file ownership, service `ExecStart`); the toggle only +decides whether the module creates and enforces the group, user, and home. -Data type: `Boolean` +Options: -Whether to manage the runner group, user, and home. Keep off where another -configuration-management system owns the user. The subordinate UID/GID -ranges rootless docker needs are owned by `manage_rootless_docker`. +* **:manage** `Boolean`: Whether to manage the runner group, user, and home. Keep off where another +configuration-management system owns the account. The subordinate UID/GID +ranges rootless docker needs are owned by `rootless_docker.manage`. Default false. +* **:name** `Rootless_gitlab_runner::Username`: Username of the runner account. Default `gitlab-runner`. +* **:uid** `Optional[Integer[1]]`: Numeric uid of the runner account. No default: the uid is host data, not +something a module can sensibly invent. It derives the rootless runtime +paths (`/run/user/`, the docker socket) and is enforced on the user +when `runner_account.manage` is on; the apply fails loud when it is unset +but needed — with `runner_account.manage`, `rootless_docker.manage` or +`standalone.self_update.manage` on, or to derive the docker socket path +for a `socket_mount` runner. +* **:home** `Stdlib::Absolutepath`: Home directory of the runner account. Default `/home/gitlab-runner`. -Default value: `false` - -##### `subid_start` - -Data type: `Integer[1]` - -First subordinate UID/GID allocated to the runner user (written by -`manage_rootless_docker`). - -Default value: `231072` - -##### `subid_count` - -Data type: `Integer[65536]` - -Number of subordinate UIDs/GIDs allocated (rootless docker needs at -least 65536). The 165536 default fits nested rootless BuildKit builds: -the rootless BuildKit image maps IDs 100000-165535 inside the build -container. - -Default value: `165536` - -##### `manage_rootless_docker` - -Data type: `Boolean` +##### `packages` -Whether to bring up the rootless docker user daemon: provision the -subordinate UID/GID ranges (`subid_start`/`subid_count`; an existing entry -is never overwritten, and the runner user may be owned elsewhere), enable -lingering and run `dockerd-rootless-setuptool.sh install` (guarded, as the -runner user), behind a fail-loud preflight that asserts the prerequisites -instead of half-installing. Also stops and masks the rootful system -`docker.service`/`docker.socket`, which a fresh `docker-ce` install starts -as root, so the only Docker daemon on the host is the unprivileged one. -cgroup-v2 controller delegation is deliberately not managed -(it would be the module's only system-wide write); without it CPU/IO job -limits are silently unenforced — see the README Limitations. Default false. +Data type: -Default value: `false` +```puppet +Struct[{ + install => Array[String[1]], + sources => Struct[{ + manage => Boolean, + docker => Struct[{ + location => Stdlib::HTTPUrl, + key_source => Stdlib::HTTPUrl, + }], + gitlab_runner => Struct[{ + location => Stdlib::HTTPUrl, + key_source => Stdlib::HTTPUrl, + }], + }], + }] +``` -##### `manage_runner_service` +The apt-packages concern: what to install, and the apt sources serving it. -Data type: `Boolean` +Options: -Whether to manage the runner system service, its privilege-drop systemd -drop-in, and the config directory's mode so a privilege-dropped manager can -traverse to its config. The manager always runs privilege-dropped as -`runner_user`. Default false. +* **:install** `Array[String[1]]`: Packages to ensure installed. The empty default installs nothing. Install +only: the module never removes packages absent from the list and never +pins or upgrades. +* **:sources** `Hash`: The apt sources the `install` list installs from (Docker's and GitLab +Runner's, via `puppetlabs/apt`). `sources.manage` (default false) decides +ownership: keep it off where apt sources are owned elsewhere (central +configuration management, e.g. Foreman/Katello, or a mirror). The +`docker` and `gitlab_runner` sub-hashes each carry `location` and +`key_source` — verbatim `apt::source` parameter names — overridden only +for a mirror; the defaults point at the vendors' repositories and their +rolling signing-key endpoints. -Default value: `false` +##### `rootless_docker` -##### `service_environment` +Data type: -Data type: `Optional[Array[Pattern[/\A[^\r\n]+\z/]]]` +```puppet +Struct[{ + manage => Boolean, + subid_start => Integer[1], + subid_count => Integer[65536], + }] +``` -Environment lines (KEY=value) rendered into the service drop-in. When -unset, defaults to pointing DOCKER_HOST at the rootless docker socket. Each -line must be a single line — a value containing a newline is rejected, so it -cannot inject an extra systemd directive into the drop-in. +The rootless docker runtime: "rootless mode" is Docker's own name for +running the daemon unprivileged. + +Options: + +* **:manage** `Boolean`: Whether to bring up the rootless docker user daemon: provision the +subordinate UID/GID ranges (an existing entry is never overwritten, and +the runner account may be owned elsewhere), enable lingering and run +`dockerd-rootless-setuptool.sh install` (guarded, as the runner user), +behind a fail-loud preflight that asserts the prerequisites instead of +half-installing. Also stops and masks the rootful system +`docker.service`/`docker.socket` and `containerd.service`, which a fresh +`docker-ce` install starts as root, so the only Docker daemon on the host +is the unprivileged one. cgroup-v2 controller delegation is deliberately +not managed (it would be the module's only system-wide write); without it +CPU/IO job limits are silently unenforced — see the README Limitations. +Default false. +* **:subid_start** `Integer[1]`: First subordinate UID/GID allocated to the runner account (field two of +the `/etc/subuid` entry format, per `subid(5)`). Default 231072. +* **:subid_count** `Integer[65536]`: Number of subordinate UIDs/GIDs allocated (field three of the entry; +rootless docker needs at least 65536). Default 165536, which also covers +BuildKit's rootless image layout. -Default value: `undef` +##### `runner_service` -##### `service_timeout_stop_sec` +Data type: -Data type: `Optional[Variant[Integer[0], String[1]]]` +```puppet +Struct[{ + manage => Boolean, + environment => Optional[Array[Pattern[/\A[^\r\n]+\z/]]], + timeout_stop_sec => Optional[Variant[Integer[0], String[1]]], + }] +``` -`TimeoutStopSec=` written into the manager service drop-in — how long -systemd waits for a graceful drain before escalating to `SIGKILL`. Unset by -default, so systemd's `DefaultTimeoutStopSec` (typically 90s) applies; set -it to the longest job a drain should wait for (GitLab's documented example -is `7200`). Accepts a seconds integer or a systemd time span (e.g. `2h`). +The systemd system service running the runner manager, privilege-dropped +to the runner account. -Default value: `undef` +Options: -##### `manage_standalone_self_update` +* **:manage** `Boolean`: Whether to manage the runner system service, its privilege-drop systemd +drop-in, and the configuration directory's mode so a privilege-dropped +manager can traverse to its configuration file. Default false. +* **:environment** `Optional[Array[String]]`: `Environment=` lines (KEY=value) rendered into the service drop-in. When +unset, defaults to pointing DOCKER_HOST at the rootless docker socket +derived from `runner_account.uid`. Each line must be a single line — a +value containing a newline is rejected, so it cannot inject an extra +systemd directive into the drop-in. +* **:timeout_stop_sec** `Optional[Variant[Integer[0], String[1]]]`: `TimeoutStopSec=` written into the manager service drop-in — how long +systemd waits for a graceful drain before escalating to `SIGKILL`. Unset +by default, so systemd's `DefaultTimeoutStopSec` (typically 90s) applies; +set it to the longest job a drain should wait for (GitLab's documented +example is `7200`). Accepts a seconds integer or a systemd time span +(e.g. `2h`). -Data type: `Boolean` +##### `standalone` -Whether to install the standalone self-update loop: an apply script, a -oneshot service + timer that fetch the control repo, verify the commit -signature, reset to the remote branch, run `r10k puppetfile install` and -re-apply — plus a healthcheck script + timer (manager service, rootless -docker daemon health as the runner user, checkout SHA staleness). Never -enable this where a Puppet server/r10k already deploys the host. Default -false. +Data type: -Default value: `false` +```puppet +Struct[{ + manage => Boolean, + control_repository_path => Stdlib::Absolutepath, + control_repository_branch => String[1], + puppet_confdir => Stdlib::Absolutepath, + puppet_vardir => Stdlib::Absolutepath, + puppet_bindir => Stdlib::Absolutepath, + healthcheck_interval => String[1], + self_update => Struct[{ + manage => Boolean, + apply_interval => String[1], + apply_timeout => String[1], + }], + }] +``` -##### `repo_path` +The standalone topology: a host that applies itself via `puppet apply`, +as opposed to a fleet host deployed by a Puppet server — with the +optional self-update loop nested inside. -Data type: `Stdlib::Absolutepath` +Options: -Root-owned checkout of the control repository the self-update loop -applies. The apply's manifest, module directory and Hiera configuration -derive strictly from the documented layout beneath it +* **:manage** `Boolean`: Declares the host standalone: installs the apply script +(`/usr/local/sbin/rootless-gitlab-runner-apply`), the single definition +of the apply command for timer-driven and manual runs. Default false. +* **:control_repository_path** `Stdlib::Absolutepath`: Root-owned checkout of the control repository on the host (the apply and +self-update target). The apply's manifest, module directory and Hiera +configuration derive strictly from the documented layout beneath it (`puppet/manifests/site.pp`, `puppet/modules`, `puppet/hiera.yaml`). - -Default value: `'/opt/gitlab-runner-infra'` - -##### `repo_branch` - -Data type: `String[1]` - -Branch the self-update loop follows (protected, signed). - -Default value: `'main'` - -##### `apply_confdir` - -Data type: `Stdlib::Absolutepath` - -Isolated Puppet confdir for the apply (never the central agent's). - -Default value: `'/etc/gitlab-runner-infra/puppet'` - -##### `apply_vardir` - -Data type: `Stdlib::Absolutepath` - -Isolated Puppet vardir for the apply (never the central agent's). - -Default value: `'/var/lib/grunner-puppet'` - -##### `apply_interval` - -Data type: `String[1]` - -systemd time span between self-update runs. - -Default value: `'5min'` - -##### `apply_timeout` - -Data type: `String[1]` - -TimeoutStartSec of the apply service (a oneshot unit has no default start -timeout). - -Default value: `'15min'` - -##### `puppet_bindir` - -Data type: `Stdlib::Absolutepath` - -Directory holding the `puppet` (and typically `r10k`) executables, +Default `/opt/gitlab-runner-infra`. +* **:control_repository_branch** `String[1]`: Branch the self-update loop follows (protected, signed). Default `main`. +* **:puppet_confdir** `Stdlib::Absolutepath`: Isolated Puppet confdir for the apply — never the central agent's. +Default `/etc/gitlab-runner-infra/puppet`. +* **:puppet_vardir** `Stdlib::Absolutepath`: Isolated Puppet vardir for the apply — never the central agent's. +Default `/var/lib/grunner-puppet`. +* **:puppet_bindir** `Stdlib::Absolutepath`: Directory holding the `puppet` (and typically `r10k`) executables, prepended to the self-update service's PATH so the timer-driven, -non-login apply can find them. Override for a non-standard install. - -Default value: `'/opt/puppetlabs/bin'` - -##### `healthcheck_interval` - -Data type: `String[1]` - -systemd time span between healthcheck runs. - -Default value: `'15min'` +non-login apply can find them. Default `/opt/puppetlabs/bin`; the +AIO-reuse topology sets `/opt/puppetlabs/puppet/bin` (gem executables +live there, not in the symlink farm). +* **:healthcheck_interval** `String[1]`: systemd time span between healthcheck runs. Default `15min`. +* **:self_update** `Hash`: The self-update loop: `manage` (default false) installs a oneshot +service + timer that fetch the control repository, verify the commit +signature, reset to the remote branch, run `r10k puppetfile install` and +re-apply — plus the healthcheck script + timer. Only valid on a +standalone host: enabling it with `standalone.manage` off fails at +compile time. Never enable it where a Puppet server or r10k already +deploys the host. `apply_interval` (default `5min`) is the timer's +cadence; `apply_timeout` (default `15min`) the apply service's +`TimeoutStartSec=` (a oneshot unit has no default start timeout). ## Functions diff --git a/data/common.yaml b/data/common.yaml index 91e7cac..8d5035a 100644 --- a/data/common.yaml +++ b/data/common.yaml @@ -1,9 +1,102 @@ --- -# Force the runner-token store to Sensitive on automatic parameter lookup, so a -# plain-YAML or hiera-eyaml secret store is wrapped without the consumer typing -# anything special. convert_to wraps the whole hash (=> Sensitive[Hash]); the -# module unwraps it once when rendering and re-wraps the rendered config as -# Sensitive, so tokens stay out of the catalog, reports, and diffs. +# Module data layer: the complete default surface. Every parameter default +# lives here (the class signature carries only the types), so consumer data +# holds nothing but deviations: Hiera's automatic parameter lookup finds these +# values in the module layer whenever no consumer layer overrides them. +# +# lookup_options: +# - runner_tokens is forced to Sensitive on lookup, so a plain-YAML or +# hiera-eyaml secret store is wrapped without the consumer typing anything +# special. convert_to wraps the whole hash (=> Sensitive[Hash]); the module +# unwraps it once when rendering and re-wraps the rendered configuration as +# Sensitive, so tokens stay out of the catalog, reports, and diffs. +# - every struct parameter merges deep, so Hiera layers can each own different +# subkeys and a consumer hash contains only its deviations. A scalar subkey +# set at a higher-priority layer wins and hashes merge recursively; array +# subkeys union across layers, and an element prefixed with '--' (the +# knockout prefix) removes the matching element a lower layer contributed. +# Removing a whole subkey through data is not expressible: override it with +# the intended value instead. lookup_options: rootless_gitlab_runner::runner_tokens: convert_to: 'Sensitive' + rootless_gitlab_runner::configuration_file: + merge: + strategy: deep + knockout_prefix: '--' + rootless_gitlab_runner::runner_account: + merge: + strategy: deep + knockout_prefix: '--' + rootless_gitlab_runner::packages: + merge: + strategy: deep + knockout_prefix: '--' + rootless_gitlab_runner::rootless_docker: + merge: + strategy: deep + knockout_prefix: '--' + rootless_gitlab_runner::runner_service: + merge: + strategy: deep + knockout_prefix: '--' + rootless_gitlab_runner::standalone: + merge: + strategy: deep + knockout_prefix: '--' + +# ── the runner configuration: config.toml content ── +rootless_gitlab_runner::concurrent: 1 +rootless_gitlab_runner::runners: [] +rootless_gitlab_runner::runner_defaults: {} +rootless_gitlab_runner::runner_tokens: {} + +# ── …its secrets source, and the rendered artifact ── +rootless_gitlab_runner::secret_store_path: '/etc/gitlab-runner-infra' +rootless_gitlab_runner::configuration_file: + path: '/etc/gitlab-runner/config.toml' + +# ── the runner account (identity read by every concern; manage = create/enforce it) ── +rootless_gitlab_runner::runner_account: + manage: false + name: 'gitlab-runner' + uid: ~ + home: '/home/gitlab-runner' + +# ── packages and where they install from ── +rootless_gitlab_runner::packages: + install: [] + sources: + manage: false + docker: + location: 'https://download.docker.com/linux/ubuntu' + key_source: 'https://download.docker.com/linux/ubuntu/gpg' + gitlab_runner: + location: 'https://packages.gitlab.com/runner/gitlab-runner/ubuntu' + key_source: 'https://packages.gitlab.com/runner/gitlab-runner/gpgkey' + +# ── rootless docker runtime ── +rootless_gitlab_runner::rootless_docker: + manage: false + subid_start: 231072 + subid_count: 165536 + +# ── runner manager service ── +rootless_gitlab_runner::runner_service: + manage: false + environment: ~ + timeout_stop_sec: ~ + +# ── standalone topology, with the optional self-update loop inside ── +rootless_gitlab_runner::standalone: + manage: false + control_repository_path: '/opt/gitlab-runner-infra' + control_repository_branch: 'main' + puppet_confdir: '/etc/gitlab-runner-infra/puppet' + puppet_vardir: '/var/lib/grunner-puppet' + puppet_bindir: '/opt/puppetlabs/bin' + healthcheck_interval: '15min' + self_update: + manage: false + apply_interval: '5min' + apply_timeout: '15min' diff --git a/examples/data/common.yaml b/examples/data/common.yaml index d720d98..0fe3a81 100644 --- a/examples/data/common.yaml +++ b/examples/data/common.yaml @@ -1,7 +1,10 @@ --- -# Values shared by every host; per-node files override. `runner_defaults` is -# merged under every `runners` entry (keys set on the entry win), so per-host -# runner lists stop repeating url/executor/image. +# Values shared by every host; per-node files override (struct parameters +# merge deep, so a node file carries only its deviations). + +# ── the runner configuration: config.toml content ── +# `runner_defaults` is merged under every `runners` entry (keys set on the +# entry win), so per-host runner lists stop repeating url/executor/image. rootless_gitlab_runner::runner_defaults: url: 'https://gitlab.example.org/' executor: 'docker' diff --git a/examples/data/nodes/host.example.yaml b/examples/data/nodes/host.example.yaml index dd19f82..6fdcaf8 100644 --- a/examples/data/nodes/host.example.yaml +++ b/examples/data/nodes/host.example.yaml @@ -4,25 +4,10 @@ # first (see the README). The other toggles bring the host up from zero and keep it converged. # Copy to nodes/.yaml and adjust. No tokens here; runner tokens live in the # off-repository secret store (/etc/gitlab-runner-infra/secrets.yaml, root 0600) and are merged in -# at apply time via each runner's token_key. -rootless_gitlab_runner::runner_uid: 2000 -rootless_gitlab_runner::manage_apt_repos: true -rootless_gitlab_runner::packages: - - 'uidmap' - - 'dbus-user-session' - - 'docker-ce' - - 'docker-ce-cli' - - 'docker-ce-rootless-extras' - - 'containerd.io' - - 'gitlab-runner' -rootless_gitlab_runner::manage_runner_user: true -rootless_gitlab_runner::manage_rootless_docker: true -rootless_gitlab_runner::manage_runner_service: true -# Opt-in: needs the Self-update prerequisites (see the README) provisioned first; never enable -# on a fleet host. -rootless_gitlab_runner::manage_standalone_self_update: false -# Checkout of this wrapper repo on the host (the self-update loop's target). -rootless_gitlab_runner::repo_path: '/opt/runner-infra' +# at apply time via each runner's token_key. Struct parameters merge deep with the module +# defaults, so each hash below carries only its deviations. + +# ── the runner configuration: config.toml content ── rootless_gitlab_runner::runners: - name: 'docker-rootless' # Reference into the secret store's tokens hash — not the token itself. @@ -35,3 +20,38 @@ rootless_gitlab_runner::runners: # socket_mount: true # allowed_images: ['registry.example.org/ci/*'] # allowed_pull_policies: ['always'] + +# ── the runner account (identity read by every concern; manage = create/enforce it) ── +rootless_gitlab_runner::runner_account: + manage: true + uid: 2000 + +# ── packages and where they install from ── +rootless_gitlab_runner::packages: + install: + - 'uidmap' + - 'dbus-user-session' + - 'docker-ce' + - 'docker-ce-cli' + - 'docker-ce-rootless-extras' + - 'containerd.io' + - 'gitlab-runner' + sources: + manage: true + +# ── rootless docker runtime ── +rootless_gitlab_runner::rootless_docker: + manage: true + +# ── runner manager service ── +rootless_gitlab_runner::runner_service: + manage: true + +# ── standalone topology, with the optional self-update loop inside ── +rootless_gitlab_runner::standalone: + manage: true + # Checkout of this control repository on the host (the apply target). + control_repository_path: '/opt/runner-infra' + # The loop stays opt-in: self_update manage true needs the Self-update + # prerequisites (see the README) provisioned first; never enable it on a + # fleet host. diff --git a/examples/secrets.example.yaml b/examples/secrets.example.yaml index 64f6c06..c66675b 100644 --- a/examples/secrets.example.yaml +++ b/examples/secrets.example.yaml @@ -1,8 +1,8 @@ --- -# Off-repo secret store: copy to /etc/gitlab-runner-infra/secrets.yaml on the host, -# root-owned mode 0600. It is the hierarchy's off-repo layer — never commit it. -# Encrypt it at rest: point a hiera-eyaml backend at this layer (an ordinary Hiera -# backend, read by `puppet apply` too). +# Off-repository secret store: copy to /etc/gitlab-runner-infra/secrets.yaml on +# the host, root-owned mode 0600. It is the hierarchy's off-repository layer — +# never commit it. Encrypt it at rest: point a hiera-eyaml backend at this +# layer (an ordinary Hiera backend, read by `puppet apply` too). # # Runner tokens live under rootless_gitlab_runner::runner_tokens, keyed by each # runner's token_key; further secrets the store grows can join as sibling keys. diff --git a/hiera.yaml b/hiera.yaml index 65c6bdf..faec0be 100644 --- a/hiera.yaml +++ b/hiera.yaml @@ -1,8 +1,9 @@ --- -# Module data layer. Carries the module's own lookup_options — notably the -# convert_to that wraps the runner-token store in Sensitive on lookup, so a -# plain-YAML or hiera-eyaml secret store needs no special handling from the -# consumer. See data/common.yaml. +# Module data layer: every parameter default (the class signature carries +# only the types) and the module's lookup_options — the deep-merge rules for +# the struct parameters, and the convert_to that wraps the runner-token store +# in Sensitive on lookup, so a plain-YAML or hiera-eyaml secret store needs +# no special handling from the consumer. See data/common.yaml. version: 5 defaults: diff --git a/manifests/apt_repos.pp b/manifests/apt_repos.pp index 2541032..efebc9b 100644 --- a/manifests/apt_repos.pp +++ b/manifests/apt_repos.pp @@ -1,9 +1,11 @@ -# @summary Adds the apt repositories the `packages` list installs from. +# @summary Adds the apt repositories the `packages.install` list installs from. # @api private class rootless_gitlab_runner::apt_repos { assert_private() - if $rootless_gitlab_runner::manage_apt_repos { + $sources = $rootless_gitlab_runner::packages['sources'] + + if $sources['manage'] { include apt # Fetch each repo's rolling gpgkey endpoint into /etc/apt/keyrings via a @@ -43,11 +45,11 @@ $apt_keyrings = { 'docker' => { 'keyring' => $docker_keyring, - 'source' => $rootless_gitlab_runner::docker_repo_key_source, + 'source' => $sources['docker']['key_source'], }, 'gitlab-runner' => { 'keyring' => $gitlab_runner_keyring, - 'source' => $rootless_gitlab_runner::gitlab_runner_repo_key_source, + 'source' => $sources['gitlab_runner']['key_source'], }, } @@ -85,14 +87,14 @@ # the managed keyring above; requiring the refresh exec makes the fetched # key precede the source it signs. apt::source { 'docker': - location => $rootless_gitlab_runner::docker_repo_location, + location => $sources['docker']['location'], repos => 'stable', keyring => $docker_keyring, require => Exec['rootless_gitlab_runner docker keyring refresh'], } apt::source { 'gitlab-runner': - location => $rootless_gitlab_runner::gitlab_runner_repo_location, + location => $sources['gitlab_runner']['location'], repos => 'main', keyring => $gitlab_runner_keyring, require => Exec['rootless_gitlab_runner gitlab-runner keyring refresh'], diff --git a/manifests/config.pp b/manifests/config.pp index 0f00f58..6742a70 100644 --- a/manifests/config.pp +++ b/manifests/config.pp @@ -1,16 +1,17 @@ -# @summary Renders the runner config, the no-detach-netns drop-in and the secret-store directory. +# @summary Renders the runner configuration, the no-detach-netns drop-in and the secret-store directory. # @api private class rootless_gitlab_runner::config { assert_private() - $runner_user = $rootless_gitlab_runner::runner_user - $runner_home = $rootless_gitlab_runner::runner_home + $runner_name = $rootless_gitlab_runner::runner_account['name'] + $runner_home = $rootless_gitlab_runner::runner_account['home'] + $configuration_file_path = $rootless_gitlab_runner::configuration_file['path'] $socket_path = $rootless_gitlab_runner::socket_path $socket_mount_runners = $rootless_gitlab_runner::effective_runners.filter |$r| { $r['socket_mount'] == true } if $socket_path =~ Undef and !$socket_mount_runners.empty { - fail('rootless_gitlab_runner: a runner sets socket_mount but the socket path is unknown — set runner_uid') + fail('rootless_gitlab_runner: a runner sets socket_mount but the socket path is unknown — set runner_account.uid') } # Recognised per-runner keys. An unrecognised key is almost always a typo, @@ -70,22 +71,23 @@ mode => '0700', } - # Where this module owns the privilege-dropped manager service, own its config - # directory too: the gitlab-runner package creates /etc/gitlab-runner as 0700 - # root:root, which the privilege-dropped manager can neither traverse to reach - # its own config.toml nor write to. The privilege-dropped manager both reads - # config.toml and *writes* /etc/gitlab-runner/.runner_system_id on startup, so - # the directory is root-owned but group-writable by the runner group (0770). - # This adds no real exposure: the runner user already owns config.toml (0600), - # so it can rewrite its own config regardless of the directory mode, and + # Where this module owns the privilege-dropped manager service, own its + # configuration directory too: the gitlab-runner package creates + # /etc/gitlab-runner as 0700 root:root, which the privilege-dropped manager + # can neither traverse to reach its own config.toml nor write to. The + # manager both reads config.toml and *writes* + # /etc/gitlab-runner/.runner_system_id on startup, so the directory is + # root-owned but group-writable by the runner group (0770). This adds no + # real exposure: the runner account already owns config.toml (0600), so it + # can rewrite its own configuration regardless of the directory mode, and # `other` has no access. Managed here (not in service.pp) so it stays in the - # same class as the config file that autorequires it as its parent. - if $rootless_gitlab_runner::manage_runner_service { - $config_dir = dirname($rootless_gitlab_runner::config_path) + # same class as the configuration file that autorequires it as its parent. + if $rootless_gitlab_runner::runner_service['manage'] { + $config_dir = dirname($configuration_file_path) file { $config_dir: ensure => directory, owner => 'root', - group => $runner_user, + group => $runner_name, mode => '0770', } @@ -94,29 +96,29 @@ # before this module's privilege-drop drop-in is in effect — so the file is # created root-owned 0600, which the privilege-dropped manager then cannot # read (it fails loud: "reading from runner system ID file: permission - # denied"). Own it as the runner user so the dropped manager can read the - # root-created file; the content is the runner's to generate, so manage + # denied"). Own it as the runner account so the dropped manager can read + # the root-created file; the content is the runner's to generate, so manage # existence + ownership only, never the content. Applied before the service # restart (config precedes service in the class order), so the first apply # converges in one run. file { "${config_dir}/.runner_system_id": ensure => file, - owner => $runner_user, - group => $runner_user, + owner => $runner_name, + group => $runner_name, mode => '0600', } } - # Always managed, no toggle: rendering the runner config is this module's - # core purpose (same rubric as the no-detach-netns drop-in). Ownership - # derives from the runner user (a differently named runner user gets a - # correctly owned file), and the mode is fixed 0600: the content is + # Always managed, no toggle: rendering the runner configuration is this + # module's core purpose (same rubric as the no-detach-netns drop-in). + # Ownership derives from the runner account (a differently named account + # gets a correctly owned file), and the mode is fixed 0600: the content is # Sensitive because it carries the runner tokens, keeping them out of the # compiled catalog, reports/PuppetDB, and --show_diff output. - file { $rootless_gitlab_runner::config_path: + file { $configuration_file_path: ensure => file, - owner => $runner_user, - group => $runner_user, + owner => $runner_name, + group => $runner_name, mode => '0600', content => Sensitive(epp('rootless_gitlab_runner/config.toml.epp', { 'concurrent' => $rootless_gitlab_runner::concurrent, @@ -127,7 +129,7 @@ # The no-detach-netns drop-in lives deep in the runner user's systemd tree # (~/.config/systemd/user/docker.service.d/) — the one place the systemd - # user manager reads docker drop-ins, so the path derives from the runner + # user manager reads docker drop-ins, so the path derives from the account # home rather than being configurable. The rootless-docker bring-up creates # ~/.config/systemd/user, but relying on that couples us to the setuptool's # side effects — and Puppet's file type never creates parents. Own the whole @@ -142,8 +144,8 @@ $user_config_dir = dirname($user_systemd_dir) file { [$user_config_dir, $user_systemd_dir, $user_units_dir, $dropin_dir]: ensure => directory, - owner => $runner_user, - group => $runner_user, + owner => $runner_name, + group => $runner_name, mode => '0755', } @@ -152,8 +154,8 @@ # DOCKERD_ROOTLESS_ROOTLESSKIT_DETACH_NETNS=false. file { $dropin_path: ensure => file, - owner => $runner_user, - group => $runner_user, + owner => $runner_name, + group => $runner_name, mode => '0644', source => 'puppet:///modules/rootless_gitlab_runner/no-detach-netns.conf', require => File[$dropin_dir], @@ -165,11 +167,11 @@ # upstream DETACH_NETNS default until an unrelated reboot — the exact failure # mode the drop-in exists to prevent. refreshonly, so steady-state applies # (drop-in unchanged) neither reload nor restart. Where the daemon is owned - # elsewhere (manage_rootless_docker off), restarts stay the operator's call. - if $rootless_gitlab_runner::manage_rootless_docker { + # elsewhere (rootless_docker.manage off), restarts stay the operator's call. + if $rootless_gitlab_runner::rootless_docker['manage'] { exec { 'rootless_gitlab_runner docker daemon-reload (no-detach-netns)': command => 'systemctl --user daemon-reload && systemctl --user try-restart docker', - user => $runner_user, + user => $runner_name, environment => $rootless_gitlab_runner::runner_user_env, path => ['/usr/bin', '/bin'], provider => 'shell', diff --git a/manifests/init.pp b/manifests/init.pp index cf8aa9e..09184d4 100644 --- a/manifests/init.pp +++ b/manifests/init.pp @@ -1,47 +1,64 @@ # @summary Declaratively manage a rootless GitLab Runner on a single host. # -# Renders the runner config (config.toml) from a Hiera-driven list of runners, -# manages the rootless-docker "no-detach-netns" systemd user drop-in and the -# secret-store directory, installs the apt packages listed in `packages`, and -# optionally: owns the runner user, brings up the rootless docker user daemon -# (with the subordinate ID ranges it needs) behind a fail-loud -# preflight, manages the runner service together with its privilege-drop -# drop-in, and installs the standalone self-update units. Designed to run -# standalone via `puppet apply` with an isolated confdir/vardir so it never -# collides with a central Puppet agent. +# Renders the runner configuration file (config.toml) from a Hiera-driven list +# of runners, manages the rootless-docker "no-detach-netns" systemd user +# drop-in and the secret-store directory, installs the apt packages listed in +# `packages.install`, and optionally: owns the runner account, brings up the +# rootless docker user daemon (with the subordinate ID ranges it needs) behind +# a fail-loud preflight, manages the runner service together with its +# privilege-drop drop-in, and installs the standalone apply script with the +# optional self-update loop. Designed to run standalone via `puppet apply` +# with an isolated confdir/vardir so it never collides with a central Puppet +# agent. # # Runner tokens are never stored in this module or in version control. They # are looked up at apply time from an off-repository secret store via the # `runner_tokens` parameter, keyed by each runner's `token_key`. # -# The `manage_*` parameters are persistent ownership switches, not one-shot -# bootstrap flags: set once per host and left on, so every apply keeps owning -# and drift-correcting that concern. `false` means hands-off, not ensure-off. +# Every parameter default lives in the module data layer (`data/common.yaml`), +# and each struct parameter carries a deep-merge lookup rule, so consumer data +# holds only deviations: a partial hash is merged over the module defaults on +# lookup. The documented consumption pattern is Hiera plus +# `include rootless_gitlab_runner`; a resource-style declaration bypasses +# Hiera merging and must pass complete struct hashes. # -# @example Greenfield standalone host (everything on) -# class { 'rootless_gitlab_runner': -# packages => ['uidmap', 'dbus-user-session', 'docker-ce', -# 'docker-ce-cli', 'docker-ce-rootless-extras', -# 'containerd.io', 'gitlab-runner'], -# manage_apt_repos => true, -# runner_uid => 2000, -# manage_runner_user => true, -# manage_rootless_docker => true, -# manage_runner_service => true, -# runners => [ -# { 'name' => 'docker-rootless', -# 'url' => 'https://gitlab.example.org/', -# 'executor' => 'docker', -# 'image' => 'ubuntu:22.04', -# 'token_key' => 'runner_a', -# }, -# ], -# } +# The `manage` keys are persistent ownership switches, not one-shot bootstrap +# flags: set once per host and left on, so every apply keeps owning and +# drift-correcting that concern. `false` means hands-off, not ensure-off. A +# `manage` key decides whether the module creates and enforces its concern's +# resources; most struct keys are consumed only by their own concern (so they +# are inert while its `manage` is false), while `runner_account`'s identity +# keys are shared inputs every concern reads. +# +# @example Greenfield standalone host (everything on), as Hiera node data +# rootless_gitlab_runner::runners: +# - name: 'docker-rootless' +# url: 'https://gitlab.example.org/' +# executor: 'docker' +# image: 'ubuntu:22.04' +# token_key: 'runner_a' +# rootless_gitlab_runner::runner_account: +# manage: true +# uid: 2000 +# rootless_gitlab_runner::packages: +# install: ['uidmap', 'dbus-user-session', 'docker-ce', 'docker-ce-cli', +# 'docker-ce-rootless-extras', 'containerd.io', 'gitlab-runner'] +# sources: +# manage: true +# rootless_gitlab_runner::rootless_docker: +# manage: true +# rootless_gitlab_runner::runner_service: +# manage: true +# rootless_gitlab_runner::standalone: +# manage: true # # @param concurrent -# Global `concurrent` value written to the runner config. +# Global `concurrent` value written to the runner configuration file: the +# name is GitLab Runner's own `config.toml` global key, written unchanged. +# Default 1. # @param runners -# Ordered list of runner definitions. Each entry is a hash; recognised keys: +# Ordered list of runner definitions, rendered as the `[[runners]]` sections +# of `config.toml`. Each entry is a hash; recognised keys: # `name`, `url`, `id` (Integer), `executor`, `token_key`, `host`, `image`, # `privileged` (Boolean), `tls_verify` (Boolean), `socket_mount` (Boolean), # `volumes` (Array[String]), `security_opt` (Array[String]), `environment` @@ -52,7 +69,9 @@ # key `MaxUploadedArchiveSize` (Integer, default 0)), # `allowed_images` (Array[String]), `allowed_pull_policies` (Array[String]). # Tokens are merged in from `runner_tokens[token_key]` and must not appear -# here. +# here. `host` is only for an externally managed daemon at a non-derived +# path: where the module manages the runner service, the drop-in's +# `DOCKER_HOST` already points the executor at the derived rootless socket. # @param runner_defaults # Hash merged under every `runners` entry (`$runner_defaults + $runner`, # keys set on the entry win), so multi-runner data does not repeat `url`, @@ -68,155 +87,207 @@ # apply fails (typo/missed-provisioning guard). The rendered configuration # content is `Sensitive` too, so tokens never reach the compiled catalog, # reports, or `--show_diff` output. -# @param runner_user -# System user the runner manager and rootless docker run as. -# @param runner_uid -# Numeric uid of the runner user. No default: the uid is host data, not +# @param secret_store_path +# Path of the off-repository secret store directory. The directory itself is +# managed (root-owned, 0700); the secret file inside it is host-provisioned +# and never managed. Default `/etc/gitlab-runner-infra`. +# @param configuration_file +# The rendered runner configuration file (GitLab Runner's `config.toml`). +# Owner and group derive from `runner_account.name`; the mode is fixed 0600 +# (the file carries the runner tokens). +# @option configuration_file [Stdlib::Absolutepath] :path +# Where the file is written. Default `/etc/gitlab-runner/config.toml`. +# @param runner_account +# The OS account the runner manager and the rootless docker daemon run as. +# The identity keys are read by every concern even when `manage: false` +# (socket derivation, file ownership, service `ExecStart`); the toggle only +# decides whether the module creates and enforces the group, user, and home. +# @option runner_account [Boolean] :manage +# Whether to manage the runner group, user, and home. Keep off where another +# configuration-management system owns the account. The subordinate UID/GID +# ranges rootless docker needs are owned by `rootless_docker.manage`. +# Default false. +# @option runner_account [Rootless_gitlab_runner::Username] :name +# Username of the runner account. Default `gitlab-runner`. +# @option runner_account [Optional[Integer[1]]] :uid +# Numeric uid of the runner account. No default: the uid is host data, not # something a module can sensibly invent. It derives the rootless runtime # paths (`/run/user/`, the docker socket) and is enforced on the user -# when `manage_runner_user` is on; the apply fails loud when it is unset but -# needed — with `manage_runner_user`, `manage_rootless_docker` or -# `manage_standalone_self_update` on, or to derive the docker socket path +# when `runner_account.manage` is on; the apply fails loud when it is unset +# but needed — with `runner_account.manage`, `rootless_docker.manage` or +# `standalone.self_update.manage` on, or to derive the docker socket path # for a `socket_mount` runner. -# @param runner_home -# Home directory of the runner user. -# @param config_path -# Path of the rendered runner config file. Owner and group derive from -# `runner_user`; the mode is fixed 0600 (the file carries the runner -# tokens). -# @param secret_store_path -# Directory of the off-repo secret store. The directory itself is managed -# (root-owned, 0700); the secret file inside it is host-provisioned and -# never managed. +# @option runner_account [Stdlib::Absolutepath] :home +# Home directory of the runner account. Default `/home/gitlab-runner`. # @param packages -# Packages to ensure installed. Empty list (the default) installs nothing. -# The apt repositories serving them can be managed via `manage_apt_repos`; -# with that off, repo setup is an external prerequisite. -# @param manage_apt_repos -# Whether to manage the apt repositories the `packages` list installs from -# (Docker's and GitLab Runner's, via `puppetlabs/apt`). Keep off where apt -# sources are owned elsewhere (central config management, e.g. Foreman/Katello). -# Default false. -# @param docker_repo_location -# Docker apt repository URL (suite = OS codename, component `stable`). -# @param docker_repo_key_source -# URL of Docker's armored signing key (stored as an apt keyring). -# @param gitlab_runner_repo_location -# GitLab Runner apt repository URL (suite = OS codename, component `main`). -# @param gitlab_runner_repo_key_source -# URL of GitLab Runner's armored signing key (stored as an apt keyring). -# @param manage_runner_user -# Whether to manage the runner group, user, and home. Keep off where another -# configuration-management system owns the user. The subordinate UID/GID -# ranges rootless docker needs are owned by `manage_rootless_docker`. -# Default false. -# @param subid_start -# First subordinate UID/GID allocated to the runner user (written by -# `manage_rootless_docker`). -# @param subid_count -# Number of subordinate UIDs/GIDs allocated (rootless docker needs at -# least 65536). The 165536 default fits nested rootless BuildKit builds: -# the rootless BuildKit image maps IDs 100000-165535 inside the build -# container. -# @param manage_rootless_docker +# The apt-packages concern: what to install, and the apt sources serving it. +# @option packages [Array[String[1]]] :install +# Packages to ensure installed. The empty default installs nothing. Install +# only: the module never removes packages absent from the list and never +# pins or upgrades. +# @option packages [Hash] :sources +# The apt sources the `install` list installs from (Docker's and GitLab +# Runner's, via `puppetlabs/apt`). `sources.manage` (default false) decides +# ownership: keep it off where apt sources are owned elsewhere (central +# configuration management, e.g. Foreman/Katello, or a mirror). The +# `docker` and `gitlab_runner` sub-hashes each carry `location` and +# `key_source` — verbatim `apt::source` parameter names — overridden only +# for a mirror; the defaults point at the vendors' repositories and their +# rolling signing-key endpoints. +# @param rootless_docker +# The rootless docker runtime: "rootless mode" is Docker's own name for +# running the daemon unprivileged. +# @option rootless_docker [Boolean] :manage # Whether to bring up the rootless docker user daemon: provision the -# subordinate UID/GID ranges (`subid_start`/`subid_count`; an existing entry -# is never overwritten, and the runner user may be owned elsewhere), enable -# lingering and run `dockerd-rootless-setuptool.sh install` (guarded, as the -# runner user), behind a fail-loud preflight that asserts the prerequisites -# instead of half-installing. Also stops and masks the rootful system -# `docker.service`/`docker.socket`, which a fresh `docker-ce` install starts -# as root, so the only Docker daemon on the host is the unprivileged one. -# cgroup-v2 controller delegation is deliberately not managed -# (it would be the module's only system-wide write); without it CPU/IO job -# limits are silently unenforced — see the README Limitations. Default false. -# @param manage_runner_service +# subordinate UID/GID ranges (an existing entry is never overwritten, and +# the runner account may be owned elsewhere), enable lingering and run +# `dockerd-rootless-setuptool.sh install` (guarded, as the runner user), +# behind a fail-loud preflight that asserts the prerequisites instead of +# half-installing. Also stops and masks the rootful system +# `docker.service`/`docker.socket` and `containerd.service`, which a fresh +# `docker-ce` install starts as root, so the only Docker daemon on the host +# is the unprivileged one. cgroup-v2 controller delegation is deliberately +# not managed (it would be the module's only system-wide write); without it +# CPU/IO job limits are silently unenforced — see the README Limitations. +# Default false. +# @option rootless_docker [Integer[1]] :subid_start +# First subordinate UID/GID allocated to the runner account (field two of +# the `/etc/subuid` entry format, per `subid(5)`). Default 231072. +# @option rootless_docker [Integer[65536]] :subid_count +# Number of subordinate UIDs/GIDs allocated (field three of the entry; +# rootless docker needs at least 65536). Default 165536, which also covers +# BuildKit's rootless image layout. +# @param runner_service +# The systemd system service running the runner manager, privilege-dropped +# to the runner account. +# @option runner_service [Boolean] :manage # Whether to manage the runner system service, its privilege-drop systemd -# drop-in, and the config directory's mode so a privilege-dropped manager can -# traverse to its config. The manager always runs privilege-dropped as -# `runner_user`. Default false. -# @param service_environment -# Environment lines (KEY=value) rendered into the service drop-in. When -# unset, defaults to pointing DOCKER_HOST at the rootless docker socket. Each -# line must be a single line — a value containing a newline is rejected, so it -# cannot inject an extra systemd directive into the drop-in. -# @param service_timeout_stop_sec +# drop-in, and the configuration directory's mode so a privilege-dropped +# manager can traverse to its configuration file. Default false. +# @option runner_service [Optional[Array[String]]] :environment +# `Environment=` lines (KEY=value) rendered into the service drop-in. When +# unset, defaults to pointing DOCKER_HOST at the rootless docker socket +# derived from `runner_account.uid`. Each line must be a single line — a +# value containing a newline is rejected, so it cannot inject an extra +# systemd directive into the drop-in. +# @option runner_service [Optional[Variant[Integer[0], String[1]]]] :timeout_stop_sec # `TimeoutStopSec=` written into the manager service drop-in — how long -# systemd waits for a graceful drain before escalating to `SIGKILL`. Unset by -# default, so systemd's `DefaultTimeoutStopSec` (typically 90s) applies; set -# it to the longest job a drain should wait for (GitLab's documented example -# is `7200`). Accepts a seconds integer or a systemd time span (e.g. `2h`). -# @param manage_standalone_self_update -# Whether to install the standalone self-update loop: an apply script, a -# oneshot service + timer that fetch the control repo, verify the commit -# signature, reset to the remote branch, run `r10k puppetfile install` and -# re-apply — plus a healthcheck script + timer (manager service, rootless -# docker daemon health as the runner user, checkout SHA staleness). Never -# enable this where a Puppet server/r10k already deploys the host. Default -# false. -# @param repo_path -# Root-owned checkout of the control repository the self-update loop -# applies. The apply's manifest, module directory and Hiera configuration -# derive strictly from the documented layout beneath it +# systemd waits for a graceful drain before escalating to `SIGKILL`. Unset +# by default, so systemd's `DefaultTimeoutStopSec` (typically 90s) applies; +# set it to the longest job a drain should wait for (GitLab's documented +# example is `7200`). Accepts a seconds integer or a systemd time span +# (e.g. `2h`). +# @param standalone +# The standalone topology: a host that applies itself via `puppet apply`, +# as opposed to a fleet host deployed by a Puppet server — with the +# optional self-update loop nested inside. +# @option standalone [Boolean] :manage +# Declares the host standalone: installs the apply script +# (`/usr/local/sbin/rootless-gitlab-runner-apply`), the single definition +# of the apply command for timer-driven and manual runs. Default false. +# @option standalone [Stdlib::Absolutepath] :control_repository_path +# Root-owned checkout of the control repository on the host (the apply and +# self-update target). The apply's manifest, module directory and Hiera +# configuration derive strictly from the documented layout beneath it # (`puppet/manifests/site.pp`, `puppet/modules`, `puppet/hiera.yaml`). -# @param repo_branch -# Branch the self-update loop follows (protected, signed). -# @param apply_confdir -# Isolated Puppet confdir for the apply (never the central agent's). -# @param apply_vardir -# Isolated Puppet vardir for the apply (never the central agent's). -# @param apply_interval -# systemd time span between self-update runs. -# @param apply_timeout -# TimeoutStartSec of the apply service (a oneshot unit has no default start -# timeout). -# @param puppet_bindir +# Default `/opt/gitlab-runner-infra`. +# @option standalone [String[1]] :control_repository_branch +# Branch the self-update loop follows (protected, signed). Default `main`. +# @option standalone [Stdlib::Absolutepath] :puppet_confdir +# Isolated Puppet confdir for the apply — never the central agent's. +# Default `/etc/gitlab-runner-infra/puppet`. +# @option standalone [Stdlib::Absolutepath] :puppet_vardir +# Isolated Puppet vardir for the apply — never the central agent's. +# Default `/var/lib/grunner-puppet`. +# @option standalone [Stdlib::Absolutepath] :puppet_bindir # Directory holding the `puppet` (and typically `r10k`) executables, # prepended to the self-update service's PATH so the timer-driven, -# non-login apply can find them. Override for a non-standard install. -# @param healthcheck_interval -# systemd time span between healthcheck runs. +# non-login apply can find them. Default `/opt/puppetlabs/bin`; the +# AIO-reuse topology sets `/opt/puppetlabs/puppet/bin` (gem executables +# live there, not in the symlink farm). +# @option standalone [String[1]] :healthcheck_interval +# systemd time span between healthcheck runs. Default `15min`. +# @option standalone [Hash] :self_update +# The self-update loop: `manage` (default false) installs a oneshot +# service + timer that fetch the control repository, verify the commit +# signature, reset to the remote branch, run `r10k puppetfile install` and +# re-apply — plus the healthcheck script + timer. Only valid on a +# standalone host: enabling it with `standalone.manage` off fails at +# compile time. Never enable it where a Puppet server or r10k already +# deploys the host. `apply_interval` (default `5min`) is the timer's +# cadence; `apply_timeout` (default `15min`) the apply service's +# `TimeoutStartSec=` (a oneshot unit has no default start timeout). class rootless_gitlab_runner ( - Integer[1] $concurrent = 1, - Array[Hash] $runners = [], - Hash $runner_defaults = {}, - Sensitive[Hash[String, String]] $runner_tokens = Sensitive({}), - Rootless_gitlab_runner::Username $runner_user = 'gitlab-runner', - Optional[Integer[1]] $runner_uid = undef, - Stdlib::Absolutepath $runner_home = '/home/gitlab-runner', - Stdlib::Absolutepath $config_path = '/etc/gitlab-runner/config.toml', - Stdlib::Absolutepath $secret_store_path = '/etc/gitlab-runner-infra', - Array[String[1]] $packages = [], - Boolean $manage_apt_repos = false, - Stdlib::HTTPUrl $docker_repo_location = 'https://download.docker.com/linux/ubuntu', - Stdlib::HTTPUrl $docker_repo_key_source = 'https://download.docker.com/linux/ubuntu/gpg', - Stdlib::HTTPUrl $gitlab_runner_repo_location = 'https://packages.gitlab.com/runner/gitlab-runner/ubuntu', - Stdlib::HTTPUrl $gitlab_runner_repo_key_source = 'https://packages.gitlab.com/runner/gitlab-runner/gpgkey', - Boolean $manage_runner_user = false, - Integer[1] $subid_start = 231072, - Integer[65536] $subid_count = 165536, - Boolean $manage_rootless_docker = false, - Boolean $manage_runner_service = false, - Optional[Array[Pattern[/\A[^\r\n]+\z/]]] $service_environment = undef, - Optional[Variant[Integer[0], String[1]]] $service_timeout_stop_sec = undef, - Boolean $manage_standalone_self_update = false, - Stdlib::Absolutepath $repo_path = '/opt/gitlab-runner-infra', - String[1] $repo_branch = 'main', - Stdlib::Absolutepath $apply_confdir = '/etc/gitlab-runner-infra/puppet', - Stdlib::Absolutepath $apply_vardir = '/var/lib/grunner-puppet', - String[1] $apply_interval = '5min', - String[1] $apply_timeout = '15min', - Stdlib::Absolutepath $puppet_bindir = '/opt/puppetlabs/bin', - String[1] $healthcheck_interval = '15min', + Integer[1] $concurrent, + Array[Hash] $runners, + Hash $runner_defaults, + Sensitive[Hash[String, String]] $runner_tokens, + Stdlib::Absolutepath $secret_store_path, + Struct[{ + path => Stdlib::Absolutepath, + }] $configuration_file, + Struct[{ + manage => Boolean, + name => Rootless_gitlab_runner::Username, + uid => Optional[Integer[1]], + home => Stdlib::Absolutepath, + }] $runner_account, + Struct[{ + install => Array[String[1]], + sources => Struct[{ + manage => Boolean, + docker => Struct[{ + location => Stdlib::HTTPUrl, + key_source => Stdlib::HTTPUrl, + }], + gitlab_runner => Struct[{ + location => Stdlib::HTTPUrl, + key_source => Stdlib::HTTPUrl, + }], + }], + }] $packages, + Struct[{ + manage => Boolean, + subid_start => Integer[1], + subid_count => Integer[65536], + }] $rootless_docker, + Struct[{ + manage => Boolean, + environment => Optional[Array[Pattern[/\A[^\r\n]+\z/]]], + timeout_stop_sec => Optional[Variant[Integer[0], String[1]]], + }] $runner_service, + Struct[{ + manage => Boolean, + control_repository_path => Stdlib::Absolutepath, + control_repository_branch => String[1], + puppet_confdir => Stdlib::Absolutepath, + puppet_vardir => Stdlib::Absolutepath, + puppet_bindir => Stdlib::Absolutepath, + healthcheck_interval => String[1], + self_update => Struct[{ + manage => Boolean, + apply_interval => String[1], + apply_timeout => String[1], + }], + }] $standalone, ) { + # Containment is enforced, not just documented: the self-update loop only + # exists on a standalone host, and the hierarchy expresses that. + if $standalone['self_update']['manage'] and !$standalone['manage'] { + fail(join([ + 'rootless_gitlab_runner: standalone.self_update.manage requires ', + 'standalone.manage — the self-update loop only runs on a standalone host', + ])) + } + # The uid is host data with no sensible module default. Fail loud where it # is needed but unset, instead of inventing one. The self-update loop needs # it too: its healthcheck probes the rootless daemon as the runner user. - if $runner_uid =~ Undef and ($manage_runner_user or $manage_rootless_docker or $manage_standalone_self_update) { + if $runner_account['uid'] =~ Undef and ($runner_account['manage'] or $rootless_docker['manage'] or $standalone['self_update']['manage']) { fail(join([ - 'rootless_gitlab_runner: runner_uid must be set when manage_runner_user, ', - 'manage_rootless_docker or manage_standalone_self_update is enabled', + 'rootless_gitlab_runner: runner_account.uid must be set when runner_account.manage, ', + 'rootless_docker.manage or standalone.self_update.manage is enabled', ])) } @@ -226,9 +297,9 @@ # Rootless runtime paths derived from the uid where known: the module # itself installs the daemon socket at /run/user//docker.sock, so the # path is derivation, not configuration. An exotic external socket is - # expressible via service_environment. - if $runner_uid =~ Integer { - $runtime_dir = "/run/user/${runner_uid}" + # expressible via runner_service.environment. + if $runner_account['uid'] =~ Integer { + $runtime_dir = "/run/user/${runner_account['uid']}" $socket_path = "${runtime_dir}/docker.sock" } else { $runtime_dir = undef @@ -239,25 +310,25 @@ # root Puppet provides neither XDG_RUNTIME_DIR nor DBUS_SESSION_BUS_ADDRESS, # and systemctl --user / the setuptool fail without them. $runner_user_env = [ - "HOME=${runner_home}", - "USER=${runner_user}", + "HOME=${runner_account['home']}", + "USER=${runner_account['name']}", "XDG_RUNTIME_DIR=${runtime_dir}", "DBUS_SESSION_BUS_ADDRESS=unix:path=${runtime_dir}/bus", ] - if $manage_runner_service and $socket_path =~ Undef and $service_environment =~ Undef { + if $runner_service['manage'] and $socket_path =~ Undef and $runner_service['environment'] =~ Undef { fail(join([ 'rootless_gitlab_runner: the privilege-dropped runner service needs DOCKER_HOST — ', - 'set runner_uid or service_environment', + 'set runner_account.uid or runner_service.environment', ])) } - $real_service_environment = $service_environment ? { + $real_service_environment = $runner_service['environment'] ? { undef => $socket_path ? { undef => [], default => ["DOCKER_HOST=unix://${socket_path}"], }, - default => $service_environment, + default => $runner_service['environment'], } contain rootless_gitlab_runner::apt_repos diff --git a/manifests/packages.pp b/manifests/packages.pp index 5169cbb..932eaec 100644 --- a/manifests/packages.pp +++ b/manifests/packages.pp @@ -1,14 +1,16 @@ -# @summary Installs the packages listed in the `packages` parameter. +# @summary Installs the packages listed under `packages.install`. # @api private class rootless_gitlab_runner::packages { assert_private() # The package list is data: which packages (and whether any) is per-host - # Hiera. The apt repositories they come from can be managed behind - # manage_apt_repos (apt_repos.pp); with the toggle off, repo setup is an - # external prerequisite (central config management, e.g. Foreman/Katello). - unless empty($rootless_gitlab_runner::packages) { - package { $rootless_gitlab_runner::packages: + # Hiera. The apt sources they come from can be managed behind + # packages.sources.manage (apt_repos.pp); with the toggle off, source setup + # is an external prerequisite (central configuration management, e.g. + # Foreman/Katello). Install only: the module never removes packages absent + # from the list and never pins or upgrades. + unless empty($rootless_gitlab_runner::packages['install']) { + package { $rootless_gitlab_runner::packages['install']: ensure => installed, } } diff --git a/manifests/rootless_docker.pp b/manifests/rootless_docker.pp index 380bdf7..e0008dc 100644 --- a/manifests/rootless_docker.pp +++ b/manifests/rootless_docker.pp @@ -3,10 +3,10 @@ class rootless_gitlab_runner::rootless_docker { assert_private() - if $rootless_gitlab_runner::manage_rootless_docker { - $runner_user = $rootless_gitlab_runner::runner_user - $runner_home = $rootless_gitlab_runner::runner_home - $runner_uid = $rootless_gitlab_runner::runner_uid + if $rootless_gitlab_runner::rootless_docker['manage'] { + $runner_user = $rootless_gitlab_runner::runner_account['name'] + $runner_home = $rootless_gitlab_runner::runner_account['home'] + $runner_uid = $rootless_gitlab_runner::runner_account['uid'] $runtime_dir = $rootless_gitlab_runner::runtime_dir $user_env = $rootless_gitlab_runner::runner_user_env # The setuptool ships at the path the docker-ce-rootless-extras package @@ -45,14 +45,14 @@ # Rootless docker needs >= 65536 subordinate IDs per user, and `useradd # --system` allocates none, so the ranges are provisioned here with the - # rest of the rootless runtime: also when the runner user itself is owned - # externally (manage_runner_user off). usermod (--add-subuids / + # rest of the rootless runtime: also when the runner account itself is + # owned externally (runner_account.manage off). usermod (--add-subuids / # --add-subgids, shadow 4.8.1) takes an inclusive range and writes the # entry under the shadow file lock; it fails loud when the user does not # exist yet. Guarded per file: an existing entry for the user is left # alone (no drift war over externally chosen ranges). - $subid_first = $rootless_gitlab_runner::subid_start - $subid_last = $subid_first + $rootless_gitlab_runner::subid_count - 1 + $subid_first = $rootless_gitlab_runner::rootless_docker['subid_start'] + $subid_last = $subid_first + $rootless_gitlab_runner::rootless_docker['subid_count'] - 1 $subid_flags = { 'subuid' => '--add-subuids', 'subgid' => '--add-subgids' } $subid_flags.each |$f, $flag| { exec { "rootless_gitlab_runner ${f} entry": diff --git a/manifests/self_update.pp b/manifests/self_update.pp index e415d03..d04e76e 100644 --- a/manifests/self_update.pp +++ b/manifests/self_update.pp @@ -1,23 +1,16 @@ -# @summary Installs the standalone self-update units and the healthcheck. +# @summary Installs the standalone apply script and the optional self-update loop. # @api private class rootless_gitlab_runner::self_update { assert_private() - if $rootless_gitlab_runner::manage_standalone_self_update { - $apply_script = '/usr/local/sbin/rootless-gitlab-runner-apply' - $healthcheck_script = '/usr/local/sbin/rootless-gitlab-runner-healthcheck' - $repo_path = $rootless_gitlab_runner::repo_path - - # This class's own reload point; refreshonly. Deliberately separate from - # service.pp's exec: sharing one would order it both before the runner - # service (its subscriber) and after these unit files (its notifiers) — - # a dependency cycle under the service -> self_update class ordering. - exec { 'rootless_gitlab_runner daemon-reload (self-update)': - command => 'systemctl daemon-reload', - path => ['/usr/bin', '/bin'], - refreshonly => true, - } + $standalone = $rootless_gitlab_runner::standalone + $apply_script = '/usr/local/sbin/rootless-gitlab-runner-apply' + $control_repository_path = $standalone['control_repository_path'] + # The apply script installs on any standalone host, not only with the + # self-update loop: it is the single definition of the apply command for + # manual runs and for the loop alike. + if $standalone['manage'] { # The manifest, module directory and Hiera configuration derive strictly # from the documented control-repository layout — the layout is the # contract, not a parameter. @@ -27,14 +20,28 @@ group => 'root', mode => '0755', content => epp('rootless_gitlab_runner/apply.sh.epp', { - 'repo_path' => $repo_path, - 'manifest_path' => "${repo_path}/puppet/manifests/site.pp", - 'module_dir' => "${repo_path}/puppet/modules", - 'hiera_config' => "${repo_path}/puppet/hiera.yaml", - 'confdir' => $rootless_gitlab_runner::apply_confdir, - 'vardir' => $rootless_gitlab_runner::apply_vardir, + 'control_repository_path' => $control_repository_path, + 'manifest_path' => "${control_repository_path}/puppet/manifests/site.pp", + 'module_directory' => "${control_repository_path}/puppet/modules", + 'hiera_config' => "${control_repository_path}/puppet/hiera.yaml", + 'confdir' => $standalone['puppet_confdir'], + 'vardir' => $standalone['puppet_vardir'], }), } + } + + if $standalone['self_update']['manage'] { + $healthcheck_script = '/usr/local/sbin/rootless-gitlab-runner-healthcheck' + + # This class's own reload point; refreshonly. Deliberately separate from + # service.pp's exec: sharing one would order it both before the runner + # service (its subscriber) and after these unit files (its notifiers) — + # a dependency cycle under the service -> self_update class ordering. + exec { 'rootless_gitlab_runner daemon-reload (self-update)': + command => 'systemctl daemon-reload', + path => ['/usr/bin', '/bin'], + refreshonly => true, + } file { $healthcheck_script: ensure => file, @@ -42,11 +49,11 @@ group => 'root', mode => '0755', content => epp('rootless_gitlab_runner/healthcheck.sh.epp', { - 'runner_user' => $rootless_gitlab_runner::runner_user, - 'runtime_dir' => $rootless_gitlab_runner::runtime_dir, - 'socket_path' => $rootless_gitlab_runner::socket_path, - 'repo_path' => $repo_path, - 'repo_branch' => $rootless_gitlab_runner::repo_branch, + 'runner_name' => $rootless_gitlab_runner::runner_account['name'], + 'runtime_dir' => $rootless_gitlab_runner::runtime_dir, + 'socket_path' => $rootless_gitlab_runner::socket_path, + 'control_repository_path' => $control_repository_path, + 'control_repository_branch' => $standalone['control_repository_branch'], }), } @@ -56,11 +63,11 @@ group => 'root', mode => '0644', content => epp('rootless_gitlab_runner/apply.service.epp', { - 'apply_script' => $apply_script, - 'repo_path' => $repo_path, - 'repo_branch' => $rootless_gitlab_runner::repo_branch, - 'apply_timeout' => $rootless_gitlab_runner::apply_timeout, - 'puppet_bindir' => $rootless_gitlab_runner::puppet_bindir, + 'apply_script' => $apply_script, + 'control_repository_path' => $control_repository_path, + 'control_repository_branch' => $standalone['control_repository_branch'], + 'apply_timeout' => $standalone['self_update']['apply_timeout'], + 'puppet_bindir' => $standalone['puppet_bindir'], }), notify => Exec['rootless_gitlab_runner daemon-reload (self-update)'], } @@ -71,7 +78,7 @@ group => 'root', mode => '0644', content => epp('rootless_gitlab_runner/apply.timer.epp', { - 'apply_interval' => $rootless_gitlab_runner::apply_interval, + 'apply_interval' => $standalone['self_update']['apply_interval'], }), notify => Exec['rootless_gitlab_runner daemon-reload (self-update)'], } @@ -93,7 +100,7 @@ group => 'root', mode => '0644', content => epp('rootless_gitlab_runner/healthcheck.timer.epp', { - 'healthcheck_interval' => $rootless_gitlab_runner::healthcheck_interval, + 'healthcheck_interval' => $standalone['healthcheck_interval'], }), notify => Exec['rootless_gitlab_runner daemon-reload (self-update)'], } diff --git a/manifests/service.pp b/manifests/service.pp index 0b9f8a2..4de3bf3 100644 --- a/manifests/service.pp +++ b/manifests/service.pp @@ -4,10 +4,10 @@ assert_private() # One owner for the manager service and its privilege-drop drop-in. The - # manager always runs privilege-dropped as the runner user — running it as - # root would contradict the module's identity — and the service name is the - # package-defined unit, not configuration. - if $rootless_gitlab_runner::manage_runner_service { + # manager always runs privilege-dropped as the runner account — running it + # as root would contradict the module's identity — and the service name is + # the package-defined unit, not configuration. + if $rootless_gitlab_runner::runner_service['manage'] { $service_name = 'gitlab-runner' $service_dropin_dir = "/etc/systemd/system/${service_name}.service.d" @@ -34,11 +34,11 @@ group => 'root', mode => '0644', content => epp('rootless_gitlab_runner/service-dropin.conf.epp', { - 'runner_user' => $rootless_gitlab_runner::runner_user, - 'runner_home' => $rootless_gitlab_runner::runner_home, - 'config_path' => $rootless_gitlab_runner::config_path, - 'service_environment' => $rootless_gitlab_runner::real_service_environment, - 'service_timeout_stop_sec' => $rootless_gitlab_runner::service_timeout_stop_sec, + 'runner_name' => $rootless_gitlab_runner::runner_account['name'], + 'runner_home' => $rootless_gitlab_runner::runner_account['home'], + 'configuration_file_path' => $rootless_gitlab_runner::configuration_file['path'], + 'environment' => $rootless_gitlab_runner::real_service_environment, + 'timeout_stop_sec' => $rootless_gitlab_runner::runner_service['timeout_stop_sec'], }), require => File[$service_dropin_dir], notify => Exec['rootless_gitlab_runner daemon-reload'], diff --git a/manifests/user.pp b/manifests/user.pp index 387e1cb..b026dad 100644 --- a/manifests/user.pp +++ b/manifests/user.pp @@ -3,25 +3,27 @@ class rootless_gitlab_runner::user { assert_private() - if $rootless_gitlab_runner::manage_runner_user { - $runner_user = $rootless_gitlab_runner::runner_user + $account = $rootless_gitlab_runner::runner_account - group { $runner_user: + if $account['manage'] { + $runner_name = $account['name'] + + group { $runner_name: ensure => present, system => true, } # Home internals (.ssh, .config, ...) are never managed; managehome only # creates the directory on first apply. - user { $runner_user: + user { $runner_name: ensure => present, system => true, - uid => $rootless_gitlab_runner::runner_uid, - gid => $runner_user, - home => $rootless_gitlab_runner::runner_home, + uid => $account['uid'], + gid => $runner_name, + home => $account['home'], managehome => true, shell => '/bin/bash', - require => Group[$runner_user], + require => Group[$runner_name], } } } diff --git a/spec/classes/rootless_gitlab_runner_spec.rb b/spec/classes/rootless_gitlab_runner_spec.rb index dc4388e..26bceb8 100644 --- a/spec/classes/rootless_gitlab_runner_spec.rb +++ b/spec/classes/rootless_gitlab_runner_spec.rb @@ -7,11 +7,18 @@ # fact source, not a hand-maintained hash), so the fact set follows metadata. UBUNTU_FACTS = on_supported_os.values.first.freeze +# Every parameter default lives in the module data layer; rspec params are +# resource-style and bypass Hiera's deep merge, so struct params built here +# merge their overrides over the module defaults the same way Hiera would. +MODULE_DATA = YAML.safe_load( + File.read(File.expand_path(File.join(__dir__, '..', '..', 'data', 'common.yaml'))), +).freeze + describe 'rootless_gitlab_runner' do # config.toml content is Sensitive (it carries tokens); unwrap it for byte # and pattern assertions. - def rendered_config - content = catalogue.resource('File', '/etc/gitlab-runner/config.toml')[:content] + def rendered_config(path = '/etc/gitlab-runner/config.toml') + content = catalogue.resource('File', path)[:content] content.respond_to?(:unwrap) ? content.unwrap : content end @@ -26,13 +33,26 @@ def example_yaml(*path) YAML.safe_load(File.read(File.expand_path(File.join(__dir__, '..', '..', 'examples', *path)))) end - # The example common + node data as class params (module prefix stripped). - def example_data_params - prefix = 'rootless_gitlab_runner::' - [example_yaml('data', 'common.yaml'), example_yaml('data', 'nodes', 'host.example.yaml')] - .reduce(:merge) - .select { |k, _| k.start_with?(prefix) } - .transform_keys { |k| k.delete_prefix(prefix) } + def deep_merge(base, override) + base.merge(override) do |_k, old, new| + old.is_a?(Hash) && new.is_a?(Hash) ? deep_merge(old, new) : new + end + end + + # rspec-puppet serializes a Ruby nil param value as the literal string + # "nil"; a YAML `~` default must reach Puppet as undef instead. + def undefize(value) + case value + when Hash then value.transform_values { |v| undefize(v) } + when nil then :undef + else value + end + end + + # A struct parameter's module-data default with overrides merged deep over + # it, mirroring the deep-merge lookup_options a Hiera consumer gets. + def struct_param(name, overrides = {}) + undefize(deep_merge(MODULE_DATA.fetch("rootless_gitlab_runner::#{name}"), overrides)) end context 'with defaults' do @@ -66,7 +86,7 @@ def example_data_params .that_requires('File[/home/gitlab-runner/.config/systemd/user/docker.service.d]') end - it 'manages the runner config owned by the runner user, fixed mode 0600' do + it 'manages the runner configuration file owned by the runner account, fixed mode 0600' do is_expected.to contain_file('/etc/gitlab-runner/config.toml').with( 'ensure' => 'file', 'owner' => 'gitlab-runner', @@ -104,6 +124,7 @@ def example_data_params is_expected.not_to contain_service('docker.service') is_expected.not_to contain_service('docker.socket') is_expected.not_to contain_service('containerd.service') + is_expected.not_to contain_file('/usr/local/sbin/rootless-gitlab-runner-apply') is_expected.not_to contain_file('/etc/systemd/system/gitlab-runner-apply.timer') # No user-scoped docker restart where the module does not own the daemon. is_expected.not_to contain_exec('rootless_gitlab_runner docker daemon-reload (no-detach-netns)') @@ -119,11 +140,29 @@ def example_data_params end end - context 'with manage_apt_repos' do + context 'with an unknown struct subkey' do + let(:params) { { 'runner_account' => struct_param('runner_account', 'nmae' => 'typo') } } + + it 'fails the compile naming the unrecognised key' do + is_expected.to compile.and_raise_error(%r{runner_account.*nmae}m) + end + end + + context 'with an unknown nested struct subkey' do + let(:params) do + { 'packages' => struct_param('packages', 'sources' => { 'dokcer' => { 'location' => 'https://x/' } }) } + end + + it 'fails the compile naming the unrecognised key' do + is_expected.to compile.and_raise_error(%r{packages.*dokcer}m) + end + end + + context 'with packages.sources.manage' do # puppetlabs-apt compiles only on Debian-family facts; the suite otherwise # runs factless, so this context pins the supported OS fact set (reused). let(:facts) { UBUNTU_FACTS } - let(:params) { { 'manage_apt_repos' => true } } + let(:params) { { 'packages' => struct_param('packages', 'sources' => { 'manage' => true }) } } it { is_expected.to compile.with_all_deps } @@ -193,14 +232,17 @@ def example_data_params .without_checksum end - context 'with custom repo locations and key sources' do + context 'with mirror locations and key sources' do let(:params) do - super().merge( - 'docker_repo_location' => 'https://mirror.example.org/docker/ubuntu', - 'docker_repo_key_source' => 'https://mirror.example.org/docker/gpg', - 'gitlab_runner_repo_location' => 'https://mirror.example.org/runner/ubuntu', - 'gitlab_runner_repo_key_source' => 'https://mirror.example.org/runner/gpgkey', - ) + { + 'packages' => struct_param('packages', 'sources' => { + 'manage' => true, + 'docker' => { 'location' => 'https://mirror.example.org/docker/ubuntu', + 'key_source' => 'https://mirror.example.org/docker/gpg' }, + 'gitlab_runner' => { 'location' => 'https://mirror.example.org/runner/ubuntu', + 'key_source' => 'https://mirror.example.org/runner/gpgkey' }, + }), + } end it 'threads the overrides into the sources and keyring refreshes' do @@ -217,45 +259,38 @@ def example_data_params end # Drift gates: the committed examples must keep compiling against the real - # parameter surface — a renamed or removed parameter fails here, not in a - # consumer's first apply. - context 'examples/data stays compilable' do + # parameter surface, resolved through Hiera itself (automatic parameter + # lookup + the module's deep-merge lookup_options) — the consumption path a + # control repository uses, which resource-style params would bypass. + context 'examples/data resolves through Hiera' do let(:facts) { UBUNTU_FACTS } - let(:params) { example_data_params } + let(:hiera_config) { File.expand_path(File.join(__dir__, '..', 'fixtures', 'hiera', 'examples.yaml')) } it { is_expected.to compile.with_all_deps } - end - context 'examples/data with examples/secrets.example.yaml resolves tokens' do - let(:facts) { UBUNTU_FACTS } - let(:params) do - tokens = example_yaml('secrets.example.yaml')['rootless_gitlab_runner::runner_tokens'] - example_data_params.merge('runner_tokens' => sensitive(tokens)) + it 'deep-merges the partial example structs over the module defaults' do + # The example node sets only sources.manage; the vendor locations come + # from the module data layer underneath. + is_expected.to contain_apt__source('docker').with_location('https://download.docker.com/linux/ubuntu') + # The example sets only manage + uid on runner_account; name and home + # come from the module defaults. + is_expected.to contain_user('gitlab-runner').with('uid' => 2000, 'home' => '/home/gitlab-runner') end - it { is_expected.to compile.with_all_deps } - - it 'renders the referenced runner token into the config' do - expect(rendered_config).to match(%r{glrt-REPLACE-WITH-RUNNER-TOKEN}) + it 'installs the apply script for the declared-standalone example host' do + is_expected.to contain_file('/usr/local/sbin/rootless-gitlab-runner-apply') + .with_content(%r{"/opt/runner-infra/puppet/manifests/site\.pp"}) end - end - - # The lookup leg of the Sensitive contract: a plain-YAML (unwrapped) secret - # store resolved through Hiera is wrapped by the module's convert_to lookup - # rule — without the rule on exactly this key, the typed parameter rejects - # the bare hash and compilation fails. - context 'with a plain-YAML token store resolved through Hiera' do - let(:hiera_config) { File.expand_path(File.join(__dir__, '..', 'fixtures', 'hiera', 'plain_token_store.yaml')) } - - it { is_expected.to compile.with_all_deps } - it 'wraps the plain store Sensitive on lookup and renders the referenced token' do - expect(rendered_config).to match(%r{glrt-PLAIN-STORE-TOKEN}) - end + context 'with examples/secrets.example.yaml tokens' do + let(:params) do + tokens = example_yaml('secrets.example.yaml')['rootless_gitlab_runner::runner_tokens'] + { 'runner_tokens' => sensitive(tokens) } + end - it 'keeps the rendered configuration content Sensitive with a populated store' do - expect(catalogue.resource('File', '/etc/gitlab-runner/config.toml').sensitive_parameters) - .to include(:content) + it 'renders the referenced runner token into the configuration' do + expect(rendered_config).to match(%r{glrt-REPLACE-WITH-RUNNER-TOKEN}) + end end end @@ -266,12 +301,7 @@ def example_data_params # other key, so a stale example would teach consumers an inert key. context 'examples/data keys match the declared parameter surface' do let(:facts) { UBUNTU_FACTS } - # Compiled with the example data itself: a stray key in the two skeleton - # files aborts the compile outright ("no parameter named ..."), and every - # accepted key lands in the class resource's parameter set. The catalog - # omits parameters left at an undef default, so the walk below compares - # against the compiled set, which includes every key the examples set. - let(:params) { example_data_params } + let(:hiera_config) { File.expand_path(File.join(__dir__, '..', 'fixtures', 'hiera', 'examples.yaml')) } it 'declares every rootless_gitlab_runner:: key set under examples/data' do declared = catalogue.resource('Class', 'rootless_gitlab_runner').parameters.keys.map(&:to_s) @@ -286,19 +316,74 @@ def example_data_params end end + # The deep-merge contract end to end, through a consumer-shaped hierarchy: + # partial consumer hashes pick up the rest of each struct from the module + # data layer; a scalar subkey set higher in the hierarchy wins; the knockout + # prefix removes a subkey a lower layer set; and a plain-YAML token store is + # wrapped Sensitive by the module's convert_to rule. + context 'a partial consumer hash through a Hiera hierarchy' do + let(:node) { 'deep-merge.example.org' } + let(:hiera_config) { File.expand_path(File.join(__dir__, '..', 'fixtures', 'hiera', 'deep_merge.yaml')) } + + it { is_expected.to compile.with_all_deps } + + it 'fills the unset struct subkeys from the module defaults' do + # The consumer layer sets only rootless_docker.manage and the account + # uid: the subid range comes from the module defaults (231072 wide + # 165536), the account name from the module default. + is_expected.to contain_exec('rootless_gitlab_runner subuid entry') + .with_command('usermod --add-subuids 231072-396607 gitlab-runner') + end + + it 'lets the node layer override a common-layer scalar subkey' do + # The common layer sets uid 2000; the node layer's 4242 wins. + is_expected.to contain_exec('rootless_gitlab_runner await user session') + .with_unless('test -S /run/user/4242/bus') + end + + it 'unions array subkeys across layers and removes an element via the knockout prefix' do + # environment is set in both layers: the common layer's DOCKER_HOST line + # survives the merge (arrays union under deep merge), while the node + # layer's '--DEBUG=1' knocks the common DEBUG=1 element out. + is_expected.to contain_file('/etc/systemd/system/gitlab-runner.service.d/10-rootless.conf') + .with_content(%r{^Environment=DOCKER_HOST=unix:///run/user/2000/docker\.sock$}) + .without_content(%r{DEBUG=1}) + end + + it 'wraps a plain-YAML token store Sensitive on lookup and renders the token' do + expect(catalogue.resource('File', '/etc/gitlab-runner/config.toml').sensitive_parameters) + .to include(:content) + expect(rendered_config).to match(%r{token = "glrt-DEEP-MERGE-TOKEN"}) + end + end + context 'with packages listed' do - let(:params) { { 'packages' => %w[uidmap dbus-user-session] } } + let(:params) { { 'packages' => struct_param('packages', 'install' => %w[uidmap dbus-user-session]) } } it { is_expected.to contain_package('uidmap').with_ensure('installed') } it { is_expected.to contain_package('dbus-user-session').with_ensure('installed') } end - context 'without runner_uid' do - %w[manage_runner_user manage_rootless_docker manage_standalone_self_update].each do |toggle| + context 'with self_update.manage but standalone.manage off' do + let(:params) do + { 'standalone' => struct_param('standalone', 'self_update' => { 'manage' => true }) } + end + + it 'fails the compile: the loop is contained in the standalone topology' do + is_expected.to compile.and_raise_error(%r{standalone\.self_update\.manage requires\s+standalone\.manage}) + end + end + + context 'without runner_account.uid' do + { + 'runner_account.manage' => { 'runner_account' => { 'manage' => true } }, + 'rootless_docker.manage' => { 'rootless_docker' => { 'manage' => true } }, + 'self_update.manage' => { 'standalone' => { 'manage' => true, 'self_update' => { 'manage' => true } } }, + }.each do |toggle, overrides| context "with #{toggle} enabled" do - let(:params) { { toggle => true } } + let(:params) { overrides.to_h { |name, over| [name, struct_param(name, over)] } } - it { is_expected.to compile.and_raise_error(%r{runner_uid must be set}) } + it { is_expected.to compile.and_raise_error(%r{runner_account\.uid must be set}) } end end @@ -308,11 +393,11 @@ def example_data_params 'image' => 'i', 'socket_mount' => true }] } end - it { is_expected.to compile.and_raise_error(%r{set runner_uid}) } + it { is_expected.to compile.and_raise_error(%r{set runner_account\.uid}) } end - context 'with a privilege-dropped service and no derivable DOCKER_HOST' do - let(:params) { { 'manage_runner_service' => true } } + context 'with the runner service and no derivable DOCKER_HOST' do + let(:params) { { 'runner_service' => struct_param('runner_service', 'manage' => true) } } it { is_expected.to compile.and_raise_error(%r{needs DOCKER_HOST}) } end @@ -347,8 +432,8 @@ def example_data_params end end - context 'with manage_runner_user' do - let(:params) { { 'manage_runner_user' => true, 'runner_uid' => 4242 } } + context 'with runner_account.manage' do + let(:params) { { 'runner_account' => struct_param('runner_account', 'manage' => true, 'uid' => 4242) } } it { is_expected.to compile.with_all_deps } it { is_expected.to contain_group('gitlab-runner').with('ensure' => 'present', 'system' => true) } @@ -364,14 +449,17 @@ def example_data_params end %w[subuid subgid].each do |f| - it "writes no #{f} entry (subids belong to manage_rootless_docker)" do + it "writes no #{f} entry (subids belong to rootless_docker.manage)" do is_expected.not_to contain_exec("rootless_gitlab_runner #{f} entry") end end end - context 'with manage_rootless_docker' do - let(:params) { { 'manage_rootless_docker' => true, 'runner_uid' => 4242 } } + context 'with rootless_docker.manage' do + let(:params) do + { 'rootless_docker' => struct_param('rootless_docker', 'manage' => true), + 'runner_account' => struct_param('runner_account', 'uid' => 4242) } + end it { is_expected.to compile.with_all_deps } @@ -382,8 +470,9 @@ def example_data_params ) end - # manage_runner_user stays off here, so this is the externally-owned-user - # shape: the module provisions subids without owning the account. + # runner_account.manage stays off here, so this is the externally-owned + # account shape: the module provisions subids without owning the account. + # The range is the module default: start 231072, width 165536. { 'subuid' => '--add-subuids', 'subgid' => '--add-subgids' }.each do |f, flag| it "provisions the #{f} range for the (possibly external) runner user, guarded by an existing entry" do is_expected.to contain_exec("rootless_gitlab_runner #{f} entry").with( @@ -473,10 +562,11 @@ def example_data_params end end - context 'with manage_rootless_docker and manage_runner_user and a custom subid range' do + context 'with rootless_docker.manage, runner_account.manage and a custom subid range' do let(:params) do - { 'manage_rootless_docker' => true, 'manage_runner_user' => true, - 'runner_uid' => 4242, 'subid_start' => 300_000, 'subid_count' => 131_072 } + { 'rootless_docker' => struct_param('rootless_docker', 'manage' => true, + 'subid_start' => 300_000, 'subid_count' => 131_072), + 'runner_account' => struct_param('runner_account', 'manage' => true, 'uid' => 4242) } end it { is_expected.to compile.with_all_deps } @@ -495,17 +585,17 @@ def example_data_params # (`command -v`, `which`, `type`) on a missing binary, unlike bash's 1. # Guards must test host state (`test`, `grep`) and exit 0/1 only. context 'exec guard hygiene (all toggles on)' do - # Facts pinned so manage_apt_repos (puppetlabs-apt needs Debian-family - # facts) can join the sweep and its keyring-refresh guards are covered. + # Facts pinned so packages.sources.manage (puppetlabs-apt needs + # Debian-family facts) can join the sweep and its keyring-refresh guards + # are covered. let(:facts) { UBUNTU_FACTS } let(:params) do { - 'manage_runner_user' => true, - 'manage_rootless_docker' => true, - 'manage_runner_service' => true, - 'manage_standalone_self_update' => true, - 'manage_apt_repos' => true, - 'runner_uid' => 4242, + 'runner_account' => struct_param('runner_account', 'manage' => true, 'uid' => 4242), + 'rootless_docker' => struct_param('rootless_docker', 'manage' => true), + 'runner_service' => struct_param('runner_service', 'manage' => true), + 'packages' => struct_param('packages', 'sources' => { 'manage' => true }), + 'standalone' => struct_param('standalone', 'manage' => true, 'self_update' => { 'manage' => true }), } end @@ -521,8 +611,11 @@ def example_data_params end end - context 'with manage_runner_service' do - let(:params) { { 'manage_runner_service' => true, 'runner_uid' => 4242 } } + context 'with runner_service.manage' do + let(:params) do + { 'runner_service' => struct_param('runner_service', 'manage' => true), + 'runner_account' => struct_param('runner_account', 'uid' => 4242) } + end it { is_expected.to compile.with_all_deps } it { is_expected.to contain_service('gitlab-runner').with('ensure' => 'running', 'enable' => true) } @@ -536,7 +629,7 @@ def example_data_params ) end - it 'owns .runner_system_id as the service user so the dropped manager can read the root-created file' do + it 'owns .runner_system_id as the runner account so the dropped manager can read the root-created file' do is_expected.to contain_file('/etc/gitlab-runner/.runner_system_id').with( 'ensure' => 'file', 'owner' => 'gitlab-runner', @@ -566,7 +659,10 @@ def example_data_params end context 'with a stop timeout for long drains' do - let(:params) { super().merge('service_timeout_stop_sec' => 7200) } + let(:params) do + super().merge('runner_service' => struct_param('runner_service', 'manage' => true, + 'timeout_stop_sec' => 7200)) + end it 'renders the configured TimeoutStopSec and keeps the fixed KillSignal' do is_expected.to contain_file('/etc/systemd/system/gitlab-runner.service.d/10-rootless.conf') @@ -577,37 +673,36 @@ def example_data_params end # The ownership-derivation contract (the 1.x latent wrong-owner bug): a - # non-default runner user and home must flow into every derived resource — + # non-default account name and home must flow into every derived resource — # file ownership, the drop-in path, the service ExecStart, the socket path. - context 'with a non-default runner user and home' do + context 'with a non-default runner account name and home' do let(:params) do { - 'runner_user' => 'ci-worker', - 'runner_uid' => 5000, - 'runner_home' => '/srv/ci-worker', - 'manage_runner_service' => true, + 'runner_account' => struct_param('runner_account', + 'name' => 'ci-worker', 'uid' => 5000, 'home' => '/srv/ci-worker'), + 'runner_service' => struct_param('runner_service', 'manage' => true), } end it { is_expected.to compile.with_all_deps } - it 'derives the config file ownership from the runner user' do + it 'derives the configuration file ownership from the account name' do is_expected.to contain_file('/etc/gitlab-runner/config.toml') .with('owner' => 'ci-worker', 'group' => 'ci-worker') end - it 'derives the config directory group and system-id ownership from the runner user' do + it 'derives the configuration directory group and system-id ownership from the account name' do is_expected.to contain_file('/etc/gitlab-runner').with('group' => 'ci-worker') is_expected.to contain_file('/etc/gitlab-runner/.runner_system_id') .with('owner' => 'ci-worker', 'group' => 'ci-worker') end - it 'derives the no-detach-netns drop-in path and ownership from the runner home and user' do + it 'derives the no-detach-netns drop-in path and ownership from the account home and name' do is_expected.to contain_file('/srv/ci-worker/.config/systemd/user/docker.service.d/no-detach-netns.conf') .with('owner' => 'ci-worker', 'group' => 'ci-worker') end - it 'derives the privilege drop, working directory and socket from the runner identity' do + it 'derives the privilege drop, working directory and socket from the account' do is_expected.to contain_file('/etc/systemd/system/gitlab-runner.service.d/10-rootless.conf') .with_content(%r{^User=ci-worker$}) .with_content(%r{^ExecStart=/usr/bin/gitlab-runner run --working-directory /srv/ci-worker --config /etc/gitlab-runner/config\.toml --service gitlab-runner$}) @@ -615,32 +710,53 @@ def example_data_params end end - context 'with manage_standalone_self_update' do - let(:params) { { 'manage_standalone_self_update' => true, 'runner_uid' => 4242 } } + context 'with standalone.manage alone (no self-update loop)' do + let(:params) do + { 'standalone' => struct_param('standalone', 'manage' => true, + 'control_repository_path' => '/opt/infra') } + end it { is_expected.to compile.with_all_deps } - it 'ships the apply script with isolated state and the r10k tick' do + it 'installs the apply script with every path derived from the control repository layout' do is_expected.to contain_file('/usr/local/sbin/rootless-gitlab-runner-apply') .with_mode('0755') .with_content(%r{r10k puppetfile install}) + .with_content(%r{--puppetfile "/opt/infra/Puppetfile"}) + .with_content(%r{--moduledir "/opt/infra/puppet/modules"}) + .with_content(%r{--modulepath "/opt/infra/puppet/modules"}) + .with_content(%r{--hiera_config "/opt/infra/puppet/hiera\.yaml"}) .with_content(%r{--confdir "/etc/gitlab-runner-infra/puppet"}) .with_content(%r{--vardir "/var/lib/grunner-puppet"}) .with_content(%r{--detailed-exitcodes}) - .with_content(%r{"/opt/gitlab-runner-infra/puppet/manifests/site\.pp"}) + .with_content(%r{"/opt/infra/puppet/manifests/site\.pp"}) + end + + it 'installs no self-update units and no healthcheck without the loop' do + is_expected.not_to contain_file('/usr/local/sbin/rootless-gitlab-runner-healthcheck') + is_expected.not_to contain_file('/etc/systemd/system/gitlab-runner-apply.service') + is_expected.not_to contain_file('/etc/systemd/system/gitlab-runner-apply.timer') + is_expected.not_to contain_service('gitlab-runner-apply.timer') + is_expected.not_to contain_exec('rootless_gitlab_runner daemon-reload (self-update)') end + end - context 'with a non-default repo_path' do - let(:params) { super().merge('repo_path' => '/opt/infra') } + context 'with standalone.self_update.manage' do + let(:params) do + { 'standalone' => struct_param('standalone', 'manage' => true, 'self_update' => { 'manage' => true }), + 'runner_account' => struct_param('runner_account', 'uid' => 4242) } + end - it 'derives every apply path from the control repository layout' do - is_expected.to contain_file('/usr/local/sbin/rootless-gitlab-runner-apply') - .with_content(%r{--puppetfile "/opt/infra/Puppetfile"}) - .with_content(%r{--moduledir "/opt/infra/puppet/modules"}) - .with_content(%r{--modulepath "/opt/infra/puppet/modules"}) - .with_content(%r{--hiera_config "/opt/infra/puppet/hiera\.yaml"}) - .with_content(%r{"/opt/infra/puppet/manifests/site\.pp"}) - end + it { is_expected.to compile.with_all_deps } + + it 'ships the apply script with isolated state and the r10k tick' do + is_expected.to contain_file('/usr/local/sbin/rootless-gitlab-runner-apply') + .with_mode('0755') + .with_content(%r{r10k puppetfile install}) + .with_content(%r{--confdir "/etc/gitlab-runner-infra/puppet"}) + .with_content(%r{--vardir "/var/lib/grunner-puppet"}) + .with_content(%r{--detailed-exitcodes}) + .with_content(%r{"/opt/gitlab-runner-infra/puppet/manifests/site\.pp"}) end it 'renders the apply service: HOME=/root, PATH to the AIO bindir, timeout, verify-commit, exit 2 = success' do @@ -697,6 +813,32 @@ def example_data_params .that_subscribes_to("File[/etc/systemd/system/#{t}]") end end + + context 'with a non-default branch and intervals' do + let(:params) do + super().merge('standalone' => struct_param('standalone', + 'manage' => true, + 'control_repository_branch' => 'deploy', + 'healthcheck_interval' => '30min', + 'self_update' => { + 'manage' => true, + 'apply_interval' => '10min', + 'apply_timeout' => '20min', + })) + end + + it 'threads the overrides into the units and the healthcheck' do + is_expected.to contain_file('/etc/systemd/system/gitlab-runner-apply.service') + .with_content(%r{verify-commit origin/deploy$}) + .with_content(%r{^TimeoutStartSec=20min$}) + is_expected.to contain_file('/etc/systemd/system/gitlab-runner-apply.timer') + .with_content(%r{^OnUnitActiveSec=10min$}) + is_expected.to contain_file('/etc/systemd/system/gitlab-runner-healthcheck.timer') + .with_content(%r{^OnUnitActiveSec=30min$}) + expect(rendered_file('/usr/local/sbin/rootless-gitlab-runner-healthcheck')) + .to match(%r{ls-remote origin 'refs/heads/deploy'}) + end + end end context 'private classes' do @@ -740,16 +882,16 @@ def example_data_params context 'golden file: full two-runner config' do let(:params) do { - 'runner_uid' => 4242, - 'runner_tokens' => sensitive({ 'runner_a' => 'glrt-GOLDEN-TOKEN-A', - 'runner_b' => 'glrt-GOLDEN-TOKEN-B' }), + 'runner_account' => struct_param('runner_account', 'uid' => 4242), + 'runner_tokens' => sensitive({ 'runner_a' => 'glrt-GOLDEN-TOKEN-A', + 'runner_b' => 'glrt-GOLDEN-TOKEN-B' }), # url + executor deliberately live in runner_defaults: the golden file # must render byte-identical, proving the merge changes nothing. 'runner_defaults' => { 'url' => 'https://gitlab.example.org/', 'executor' => 'docker' }, # Every documented runner key set to a non-default value across the two # runners, so a mutation to any exercised template line (e.g. hard-wiring # privileged to false) breaks the byte-exact render. - 'runners' => [ + 'runners' => [ { 'name' => 'socket-runner', 'id' => 42, @@ -790,7 +932,10 @@ def example_data_params end context 'rendered shell scripts (golden + shellcheck)' do - let(:params) { { 'manage_standalone_self_update' => true, 'runner_uid' => 4242 } } + let(:params) do + { 'standalone' => struct_param('standalone', 'manage' => true, 'self_update' => { 'manage' => true }), + 'runner_account' => struct_param('runner_account', 'uid' => 4242) } + end { '/usr/local/sbin/rootless-gitlab-runner-apply' => 'apply.sh.golden', @@ -842,22 +987,23 @@ def example_data_params end end - context 'a runner_user with shell-hostile characters' do - let(:params) { { 'runner_user' => 'ev il"; rm -rf' } } + context 'a runner account name with shell-hostile characters' do + let(:params) { { 'runner_account' => struct_param('runner_account', 'name' => 'ev il"; rm -rf') } } - it { is_expected.to compile.and_raise_error(%r{runner_user}) } + it { is_expected.to compile.and_raise_error(%r{runner_account}) } end - context 'a service_environment line containing a newline' do + context 'a runner_service.environment line containing a newline' do let(:params) do { - 'manage_runner_service' => true, - 'runner_uid' => 2000, - 'service_environment' => ["DOCKER_HOST=unix:///run/x\nExecStartPre=/bin/evil"], + 'runner_service' => struct_param('runner_service', + 'manage' => true, + 'environment' => ["DOCKER_HOST=unix:///run/x\nExecStartPre=/bin/evil"]), + 'runner_account' => struct_param('runner_account', 'uid' => 2000), } end - it { is_expected.to compile.and_raise_error(%r{service_environment}) } + it { is_expected.to compile.and_raise_error(%r{runner_service}) } end end end diff --git a/spec/fixtures/data_check/README.md b/spec/fixtures/data_check/README.md index e098110..c826c3c 100644 --- a/spec/fixtures/data_check/README.md +++ b/spec/fixtures/data_check/README.md @@ -12,4 +12,4 @@ because the hierarchy resolves them via `%{facts.networking.hostname}`. | `eyaml_stray` | A stray key inside an in-repository encrypted-secrets level | Failure detection inside `*.eyaml` files | | `advisory` | Struct subkeys set under `manage: false` in the common layer | The non-failing advisory fires | | `advisory_on` | The same, with a higher-priority node layer setting `manage: true` | Hierarchy priority suppresses the advisory | -| `demo_modules/demo` | A minimal module whose class has a struct parameter with a `manage` subkey | The surface the advisory fixtures resolve against (the real module has no struct parameters yet) | +| `demo_modules/demo` | A minimal module whose class has a struct parameter with a `manage` subkey | The surface the advisory fixtures resolve against, kept module-independent | diff --git a/spec/fixtures/data_check/consumer_clean/data/common.yaml b/spec/fixtures/data_check/consumer_clean/data/common.yaml index 23fd90c..f9c3b66 100644 --- a/spec/fixtures/data_check/consumer_clean/data/common.yaml +++ b/spec/fixtures/data_check/consumer_clean/data/common.yaml @@ -5,9 +5,7 @@ lookup_options: rootless_gitlab_runner::runner_tokens: convert_to: 'Sensitive' rootless_gitlab_runner::concurrent: 1 -rootless_gitlab_runner::manage_runner_user: false -rootless_gitlab_runner::manage_rootless_docker: false -rootless_gitlab_runner::manage_runner_service: false -rootless_gitlab_runner::manage_standalone_self_update: false -rootless_gitlab_runner::packages: [] +rootless_gitlab_runner::runner_defaults: {} +rootless_gitlab_runner::packages: + install: [] rootless_gitlab_runner::runners: [] diff --git a/spec/fixtures/data_check/consumer_clean/data/nodes/ci-runner.yaml b/spec/fixtures/data_check/consumer_clean/data/nodes/ci-runner.yaml index 61b0e4c..c14c22e 100644 --- a/spec/fixtures/data_check/consumer_clean/data/nodes/ci-runner.yaml +++ b/spec/fixtures/data_check/consumer_clean/data/nodes/ci-runner.yaml @@ -1,3 +1,6 @@ --- -rootless_gitlab_runner::runner_uid: 1111 -rootless_gitlab_runner::manage_rootless_docker: true +rootless_gitlab_runner::runner_account: + manage: true + uid: 1111 +rootless_gitlab_runner::rootless_docker: + manage: true diff --git a/spec/fixtures/data_check/consumer_stray/data/common.yaml b/spec/fixtures/data_check/consumer_stray/data/common.yaml index e29020e..9592796 100644 --- a/spec/fixtures/data_check/consumer_stray/data/common.yaml +++ b/spec/fixtures/data_check/consumer_stray/data/common.yaml @@ -3,9 +3,6 @@ # been a parameter of the class, and Hiera ignored it without a whisper. rootless_gitlab_runner::concurrent: 1 rootless_gitlab_runner::session_timeout: 1800 -rootless_gitlab_runner::manage_runner_user: false -rootless_gitlab_runner::manage_rootless_docker: false -rootless_gitlab_runner::manage_runner_service: false -rootless_gitlab_runner::manage_standalone_self_update: false -rootless_gitlab_runner::packages: [] +rootless_gitlab_runner::packages: + install: [] rootless_gitlab_runner::runners: [] diff --git a/spec/fixtures/data_check/consumer_stray/data/nodes/ci-runner.yaml b/spec/fixtures/data_check/consumer_stray/data/nodes/ci-runner.yaml index 989d258..a4369f5 100644 --- a/spec/fixtures/data_check/consumer_stray/data/nodes/ci-runner.yaml +++ b/spec/fixtures/data_check/consumer_stray/data/nodes/ci-runner.yaml @@ -2,4 +2,6 @@ # A key whose class is not deployed at all fails with the missing-class # message, not the missing-parameter one. no_such_module::thing: 1 -rootless_gitlab_runner::runner_uid: 1111 +rootless_gitlab_runner::runner_account: + manage: true + uid: 1111 diff --git a/spec/fixtures/golden/apply.sh.golden b/spec/fixtures/golden/apply.sh.golden index 457555b..907396d 100644 --- a/spec/fixtures/golden/apply.sh.golden +++ b/spec/fixtures/golden/apply.sh.golden @@ -3,11 +3,12 @@ # Managed by Puppet (rootless_gitlab_runner). Do not edit by hand — this file # is overwritten on the next apply. # -# Applies the control-repo checkout with isolated Puppet state so it never -# collides with a central Puppet agent. Used by gitlab-runner-apply.service and -# for manual runs; extra arguments (e.g. --noop) are forwarded to puppet apply. -# With --detailed-exitcodes the exit status is 0 (no changes), 2 (changes -# applied) or 4/6 (failures); the service treats 2 as success. +# Applies the control-repository checkout with isolated Puppet state so it +# never collides with a central Puppet agent. Used by +# gitlab-runner-apply.service and for manual runs; extra arguments (e.g. +# --noop) are forwarded to puppet apply. With --detailed-exitcodes the exit +# status is 0 (no changes), 2 (changes applied) or 4/6 (failures); the +# service treats 2 as success. set -euo pipefail if [[ "${EUID}" -ne 0 ]]; then diff --git a/spec/fixtures/hiera/deep_merge.yaml b/spec/fixtures/hiera/deep_merge.yaml new file mode 100644 index 0000000..475aa63 --- /dev/null +++ b/spec/fixtures/hiera/deep_merge.yaml @@ -0,0 +1,18 @@ +--- +# Hiera config for the deep-merge contract test: a two-layer consumer-shaped +# hierarchy (node over common) whose data sets only partial struct hashes. +# The module data layer underneath supplies every unset subkey; the node +# layer exercises scalar override and the knockout prefix. datadir is +# relative to this file's directory. +version: 5 + +defaults: + data_hash: yaml_data + +hierarchy: + - name: 'Consumer per-node data' + path: 'node.yaml' + datadir: 'deep_merge' + - name: 'Consumer common data' + path: 'common.yaml' + datadir: 'deep_merge' diff --git a/spec/fixtures/hiera/deep_merge/common.yaml b/spec/fixtures/hiera/deep_merge/common.yaml new file mode 100644 index 0000000..57c4a4c --- /dev/null +++ b/spec/fixtures/hiera/deep_merge/common.yaml @@ -0,0 +1,21 @@ +--- +# Shared consumer layer: partial structs only — every unset subkey must come +# from the module data layer via the deep-merge lookup_options. The plain +# (unwrapped) token store exercises the convert_to Sensitive rule. +rootless_gitlab_runner::runner_account: + uid: 2000 +rootless_gitlab_runner::rootless_docker: + manage: true +rootless_gitlab_runner::runner_service: + manage: true + environment: + - 'DOCKER_HOST=unix:///run/user/2000/docker.sock' + - 'DEBUG=1' +rootless_gitlab_runner::runner_tokens: + runner_a: 'glrt-DEEP-MERGE-TOKEN' +rootless_gitlab_runner::runners: + - name: 'r' + url: 'https://gitlab.example.org/' + executor: 'docker' + image: 'ubuntu:22.04' + token_key: 'runner_a' diff --git a/spec/fixtures/hiera/deep_merge/node.yaml b/spec/fixtures/hiera/deep_merge/node.yaml new file mode 100644 index 0000000..fa93141 --- /dev/null +++ b/spec/fixtures/hiera/deep_merge/node.yaml @@ -0,0 +1,10 @@ +--- +# Node layer over the common layer: a scalar subkey set here wins (uid), and +# an array subkey unions with the lower layer, where a '--'-prefixed element +# knocks the matching common-layer element out (DEBUG=1 disappears while the +# common DOCKER_HOST line survives). +rootless_gitlab_runner::runner_account: + uid: 4242 +rootless_gitlab_runner::runner_service: + environment: + - '--DEBUG=1' diff --git a/spec/fixtures/hiera/examples.yaml b/spec/fixtures/hiera/examples.yaml new file mode 100644 index 0000000..99be19c --- /dev/null +++ b/spec/fixtures/hiera/examples.yaml @@ -0,0 +1,18 @@ +--- +# Hiera config for the examples drift gate: layers the shipped example data +# (node file over common) exactly as the examples/hiera.yaml hierarchy would, +# so the suite compiles the real files through automatic parameter lookup and +# the module's deep-merge lookup_options. datadir is relative to this file's +# directory. +version: 5 + +defaults: + data_hash: yaml_data + +hierarchy: + - name: 'Example per-node data' + path: 'nodes/host.example.yaml' + datadir: '../../../examples/data' + - name: 'Example common defaults' + path: 'common.yaml' + datadir: '../../../examples/data' diff --git a/spec/fixtures/hiera/plain_token_store.yaml b/spec/fixtures/hiera/plain_token_store.yaml deleted file mode 100644 index 6c68e2e..0000000 --- a/spec/fixtures/hiera/plain_token_store.yaml +++ /dev/null @@ -1,14 +0,0 @@ ---- -# Hiera config for the plain-store contract test: a single consumer layer -# whose token store is deliberately unwrapped, so compilation succeeds only -# through the module's convert_to Sensitive lookup rule. datadir is relative -# to this file's directory. -version: 5 - -defaults: - data_hash: yaml_data - -hierarchy: - - name: 'Consumer data' - path: 'common.yaml' - datadir: 'plain_token_store' diff --git a/spec/fixtures/hiera/plain_token_store/common.yaml b/spec/fixtures/hiera/plain_token_store/common.yaml deleted file mode 100644 index ac73c4c..0000000 --- a/spec/fixtures/hiera/plain_token_store/common.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -# Consumer layer with a plain (unwrapped) token store: the module's -# convert_to lookup rule must wrap it Sensitive on lookup, or the typed -# runner_tokens parameter rejects the bare hash. -rootless_gitlab_runner::runner_tokens: - runner_a: 'glrt-PLAIN-STORE-TOKEN' -rootless_gitlab_runner::runners: - - name: 'plain-store-runner' - url: 'https://gitlab.example.org/' - executor: 'docker' - image: 'ubuntu:22.04' - token_key: 'runner_a' diff --git a/templates/apply.service.epp b/templates/apply.service.epp index a6160f9..cdebe40 100644 --- a/templates/apply.service.epp +++ b/templates/apply.service.epp @@ -1,6 +1,6 @@ <%- | String $apply_script, - String $repo_path, - String $repo_branch, + String $control_repository_path, + String $control_repository_branch, String $apply_timeout, String $puppet_bindir, | -%> @@ -23,10 +23,10 @@ Environment="PATH=<%= $puppet_bindir %>:/usr/bin:/bin" # A oneshot unit has no default start timeout; without this a hung fetch would # wedge silently. TimeoutStartSec=<%= $apply_timeout %> -ExecStartPre=/usr/bin/git -C <%= $repo_path %> fetch --quiet origin <%= $repo_branch %> +ExecStartPre=/usr/bin/git -C <%= $control_repository_path %> fetch --quiet origin <%= $control_repository_branch %> # Only apply commits carrying a valid signature (protected, signed main). -ExecStartPre=/usr/bin/git -C <%= $repo_path %> verify-commit origin/<%= $repo_branch %> -ExecStartPre=/usr/bin/git -C <%= $repo_path %> reset --hard --quiet origin/<%= $repo_branch %> +ExecStartPre=/usr/bin/git -C <%= $control_repository_path %> verify-commit origin/<%= $control_repository_branch %> +ExecStartPre=/usr/bin/git -C <%= $control_repository_path %> reset --hard --quiet origin/<%= $control_repository_branch %> ExecStart=<%= $apply_script %> # puppet apply --detailed-exitcodes returns 2 when changes were applied; that # is a successful run, not a failure. diff --git a/templates/apply.sh.epp b/templates/apply.sh.epp index 7bea30e..30101d9 100644 --- a/templates/apply.sh.epp +++ b/templates/apply.sh.epp @@ -1,6 +1,6 @@ -<%- | String $repo_path, +<%- | String $control_repository_path, String $manifest_path, - String $module_dir, + String $module_directory, String $hiera_config, String $confdir, String $vardir, @@ -10,11 +10,12 @@ # Managed by Puppet (rootless_gitlab_runner). Do not edit by hand — this file # is overwritten on the next apply. # -# Applies the control-repo checkout with isolated Puppet state so it never -# collides with a central Puppet agent. Used by gitlab-runner-apply.service and -# for manual runs; extra arguments (e.g. --noop) are forwarded to puppet apply. -# With --detailed-exitcodes the exit status is 0 (no changes), 2 (changes -# applied) or 4/6 (failures); the service treats 2 as success. +# Applies the control-repository checkout with isolated Puppet state so it +# never collides with a central Puppet agent. Used by +# gitlab-runner-apply.service and for manual runs; extra arguments (e.g. +# --noop) are forwarded to puppet apply. With --detailed-exitcodes the exit +# status is 0 (no changes), 2 (changes applied) or 4/6 (failures); the +# service treats 2 as success. set -euo pipefail if [[ "${EUID}" -ne 0 ]]; then @@ -24,16 +25,16 @@ fi # Deliver module updates pinned in the consumer's Puppetfile each tick. # Without a Puppetfile (module vendored in the checkout) this is a no-op. -if [[ -f "<%= $repo_path %>/Puppetfile" ]]; then +if [[ -f "<%= $control_repository_path %>/Puppetfile" ]]; then r10k puppetfile install \ - --puppetfile "<%= $repo_path %>/Puppetfile" \ - --moduledir "<%= $module_dir %>" + --puppetfile "<%= $control_repository_path %>/Puppetfile" \ + --moduledir "<%= $module_directory %>" fi exec puppet apply \ --confdir "<%= $confdir %>" \ --vardir "<%= $vardir %>" \ - --modulepath "<%= $module_dir %>" \ + --modulepath "<%= $module_directory %>" \ --hiera_config "<%= $hiera_config %>" \ --detailed-exitcodes \ "$@" \ diff --git a/templates/healthcheck.sh.epp b/templates/healthcheck.sh.epp index a846d22..6921806 100644 --- a/templates/healthcheck.sh.epp +++ b/templates/healthcheck.sh.epp @@ -1,8 +1,8 @@ -<%- | String $runner_user, +<%- | String $runner_name, String $runtime_dir, String $socket_path, - String $repo_path, - String $repo_branch, + String $control_repository_path, + String $control_repository_branch, | -%> #!/usr/bin/env bash # @@ -13,7 +13,7 @@ # on stderr, which lands in the journal). Checks the manager service, the # rootless docker daemon (as the runner user, from a non-login context), and # checkout staleness — a dead pull credential must not leave the host applying -# stale <%= $repo_branch %> behind a green apply timer. +# stale <%= $control_repository_branch %> behind a green apply timer. set -uo pipefail status=0 @@ -23,7 +23,7 @@ err() { } as_runner() { - runuser -u '<%= $runner_user %>' -- env \ + runuser -u '<%= $runner_name %>' -- env \ "XDG_RUNTIME_DIR=<%= $runtime_dir %>" \ "DBUS_SESSION_BUS_ADDRESS=unix:path=<%= $runtime_dir %>/bus" \ "DOCKER_HOST=unix://<%= $socket_path %>" \ @@ -46,18 +46,18 @@ as_runner systemctl --user is-active --quiet docker \ # setuptool check is prerequisites-only; docker info is the daemon health probe. as_runner docker info >/dev/null 2>&1 \ - || err "docker info failed as <%= $runner_user %>" + || err "docker info failed as <%= $runner_name %>" # SHA staleness (mandatory): compare the local checkout against the remote # head without mutating the checkout. An unreachable origin is itself a # failure — that is exactly the dead-credential case. -local_sha="$(git -C '<%= $repo_path %>' rev-parse HEAD 2>/dev/null)" \ +local_sha="$(git -C '<%= $control_repository_path %>' rev-parse HEAD 2>/dev/null)" \ || err "cannot read local checkout SHA" -remote_sha="$(git -C '<%= $repo_path %>' ls-remote origin 'refs/heads/<%= $repo_branch %>' 2>/dev/null | cut -f1)" +remote_sha="$(git -C '<%= $control_repository_path %>' ls-remote origin 'refs/heads/<%= $control_repository_branch %>' 2>/dev/null | cut -f1)" if [[ -z "${remote_sha}" ]]; then - err "cannot reach origin to compare <%= $repo_branch %> (dead pull credential?)" + err "cannot reach origin to compare <%= $control_repository_branch %> (dead pull credential?)" elif [[ "${local_sha}" != "${remote_sha}" ]]; then - err "checkout is stale: local ${local_sha} != origin/<%= $repo_branch %> ${remote_sha}" + err "checkout is stale: local ${local_sha} != origin/<%= $control_repository_branch %> ${remote_sha}" fi exit "${status}" diff --git a/templates/service-dropin.conf.epp b/templates/service-dropin.conf.epp index cb21be5..8b9e23e 100644 --- a/templates/service-dropin.conf.epp +++ b/templates/service-dropin.conf.epp @@ -1,26 +1,26 @@ -<%- | String $runner_user, +<%- | String $runner_name, String $runner_home, - String $config_path, - Array[String] $service_environment, - Optional[Variant[Integer, String]] $service_timeout_stop_sec, + String $configuration_file_path, + Array[String] $environment, + Optional[Variant[Integer, String]] $timeout_stop_sec, | -%> # Managed by Puppet (rootless_gitlab_runner). Do not edit by hand — this file # is overwritten on the next apply. [Service] -User=<%= $runner_user %> +User=<%= $runner_name %> # The packaged unit passes --user to gitlab-runner, which is only accepted # when the process runs as root. Reset ExecStart without it so the manager can # run privilege-dropped. ExecStart= -ExecStart=/usr/bin/gitlab-runner run --working-directory <%= $runner_home %> --config <%= $config_path %> --service gitlab-runner +ExecStart=/usr/bin/gitlab-runner run --working-directory <%= $runner_home %> --config <%= $configuration_file_path %> --service gitlab-runner # Graceful shutdown: SIGQUIT lets GitLab Runner finish running jobs before # exiting, so a legitimate restart drains instead of aborting builds (systemd's # default SIGTERM aborts them). KillSignal=SIGQUIT -<%- if $service_timeout_stop_sec =~ NotUndef { -%> +<%- if $timeout_stop_sec =~ NotUndef { -%> # How long systemd waits for the graceful drain before escalating to SIGKILL. -TimeoutStopSec=<%= $service_timeout_stop_sec %> +TimeoutStopSec=<%= $timeout_stop_sec %> <%- } -%> -<%- $service_environment.each |$e| { -%> +<%- $environment.each |$e| { -%> Environment=<%= $e %> <%- } -%> From 73b3c24561beb91edf239a330957ddfab3d60ce5 Mon Sep 17 00:00:00 2001 From: jcardozo-eth <131674798+jcardozo-eth@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:11:40 +0200 Subject: [PATCH 2/4] fix(check): stop calling manage-off subkeys inert in the advisory The runner account's identity keys are read by every concern even when its manage is false (socket derivation, file ownership, the service ExecStart), so the advisory's claim that subkeys under an effective manage: false are inert was wrong for shared-input keys. The advisory now says the module does not enforce resources from them and, staying module-neutral, that the module may still read some of them as shared inputs; the unit spec pins the wording and rejects the inert phrasing. --- scripts/check_hiera_data.rb | 22 +++++++++++-------- .../data_check/advisory/data/common.yaml | 2 +- .../advisory_on/data/nodes/ci-runner.yaml | 2 +- spec/unit/check_hiera_data_spec.rb | 7 ++++-- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/scripts/check_hiera_data.rb b/scripts/check_hiera_data.rb index 1480a8b..23fac3c 100755 --- a/scripts/check_hiera_data.rb +++ b/scripts/check_hiera_data.rb @@ -30,10 +30,13 @@ # without a `::` are not automatic-parameter-lookup keys and are ignored. # # Advisory (non-failing): a declared hash-valued parameter can carry a -# `manage` toggle ("false means hands-off"). Subkeys set under a struct whose -# effective `manage` resolves to false are recognized but inert; that can be -# legitimate declared-state documentation of an externally owned concern, so -# the check reports it as an advisory and a human judges intent. The +# `manage` toggle ("false means hands-off"). A `manage` that resolves to +# false means the module does not create or enforce that concern's resources, +# so subkeys set under it are not enforced — though a module may still read +# some of them as shared inputs (identity keys other concerns derive from). +# Setting them can be legitimate declared-state documentation of an +# externally owned concern, so the check reports an advisory and a human +# judges intent. The # effective `manage` is resolved from the repository's own data layers: the # hierarchy levels of the given hiera.yaml that resolve inside the data # directory, in hierarchy order, highest-priority `manage` subkey wins (the @@ -233,16 +236,17 @@ def context_advisories(context) .find { |v| v.is_a?(Hash) && v.key?('manage') } next unless managed && managed['manage'] == false - inert = files.filter_map do |file| + unenforced = files.filter_map do |file| value = parsed_data[file][key] subkeys = value.is_a?(Hash) ? value.keys - ['manage'] : [] "#{subkeys.sort.join(', ')} (#{file})" unless subkeys.empty? end - next if inert.empty? + next if unenforced.empty? - "'#{key}': effective 'manage' is false, so these subkeys are set but " \ - "inert: #{inert.join('; ')} — legitimate as declared-state " \ - 'documentation of an externally owned concern; a human judges intent' + "'#{key}': effective 'manage' is false, so the module does not " \ + "enforce resources from these subkeys: #{unenforced.join('; ')} — " \ + 'the module may still read some of them as shared inputs; legitimate ' \ + 'as declared state of an externally owned concern; a human judges intent' end end diff --git a/spec/fixtures/data_check/advisory/data/common.yaml b/spec/fixtures/data_check/advisory/data/common.yaml index 8238f6a..b023dce 100644 --- a/spec/fixtures/data_check/advisory/data/common.yaml +++ b/spec/fixtures/data_check/advisory/data/common.yaml @@ -1,6 +1,6 @@ --- # The struct's toggle is off in the common layer and no node layer overrides -# it: subkeys set under it are recognized but inert — advisory, not failure. +# it: subkeys set under it are not enforced as resources — advisory, not failure. demo::alpha: 2 demo::standalone: manage: false diff --git a/spec/fixtures/data_check/advisory_on/data/nodes/ci-runner.yaml b/spec/fixtures/data_check/advisory_on/data/nodes/ci-runner.yaml index d8f258a..5a97f37 100644 --- a/spec/fixtures/data_check/advisory_on/data/nodes/ci-runner.yaml +++ b/spec/fixtures/data_check/advisory_on/data/nodes/ci-runner.yaml @@ -1,6 +1,6 @@ --- # The node layer owns the concern (manage: true), so its subkeys are live — -# nothing inert to report. +# nothing unenforced to report. demo::standalone: manage: true self_update: diff --git a/spec/unit/check_hiera_data_spec.rb b/spec/unit/check_hiera_data_spec.rb index 7e0634b..eeeddd3 100644 --- a/spec/unit/check_hiera_data_spec.rb +++ b/spec/unit/check_hiera_data_spec.rb @@ -42,7 +42,7 @@ def run_check(fixture_dir, modulepath, data_dir: File.join(fixture_dir, 'data')) expect(output).to match(/FAIL .*ci-runner\.yaml: 'no_such_module::thing' — class 'no_such_module' is not in the deployed modules/) # Declared keys from the same files are not flagged. expect(output).not_to match(/FAIL .*'rootless_gitlab_runner::concurrent'/) - expect(output).not_to match(/FAIL .*'rootless_gitlab_runner::runner_uid'/) + expect(output).not_to match(/FAIL .*'rootless_gitlab_runner::runner_account'/) end it 'passes the same shape without the stray keys, skipping lookup_options and walking eyaml' do @@ -72,8 +72,11 @@ def run_check(fixture_dir, modulepath, data_dir: File.join(fixture_dir, 'data')) it 'emits a non-failing advisory for subkeys under an effective manage: false' do output, status = run_check(File.join(CHECK_FIXTURES, 'advisory'), DEMO_MODULES) expect(status.exitstatus).to eq(0) - expect(output).to match(/advisory \(non-failing\): 'demo::standalone': effective 'manage' is false/) + expect(output).to match(/advisory \(non-failing\): 'demo::standalone': effective 'manage' is false, so the module does not enforce resources from these subkeys/) expect(output).to match(/self_update.*ci-runner\.yaml/) + # The advisory must not call the subkeys inert: shared-input keys + # (account identity) are read by every concern regardless of the toggle. + expect(output).not_to match(/inert/) end it 'emits no advisory when a higher-priority layer turns manage on' do From 9033e7c0d0966731e831a0003242dd8980685fdf Mon Sep 17 00:00:00 2001 From: jcardozo-eth <131674798+jcardozo-eth@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:18:58 +0200 Subject: [PATCH 3/4] docs(changelog): consolidate the unreleased surface entries One coherent description of the new surface: separate entries for the regrouping, the module-data defaults with deep merge, and the tokens rename; the Removed bullets read in struct-era key names and the data-check and apt-fix entries reference the new toggle paths. --- CHANGELOG.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54296fd..fcf3768 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,24 +9,27 @@ module follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added -- A Hiera data check ships with the module: it fails on any data key that no deployed class declares, instead of Hiera silently ignoring it, and flags set-but-inert subkeys under a disabled `manage` toggle with a non-failing advisory. +- A Hiera data check ships with the module: it fails on any data key that no deployed class declares, instead of Hiera silently ignoring it, and flags subkeys set under a disabled `manage` toggle with a non-failing advisory. ### Changed -- The runner-token store now reads as part of the runner family: `tokens` is renamed to `runner_tokens`, alongside `runners` and `runner_defaults`. The automatic `Sensitive` wrap on lookup moves with the new key; the host's secret store file must follow the rename, since a stale store key renders blank tokens rather than failing. +- The parameter surface is regrouped: five flat keys plus six parameter groups replace the 49 flat parameters. Related settings now live together, one hash per concern: `runner_account`, `configuration_file`, `packages` with its apt `sources`, `rootless_docker`, `runner_service`, and `standalone` with the nested `self_update`. +- Every subkey ships a module default and the groups merge deep across Hiera layers, so node data holds only its deviations, and a mistyped subkey now fails the compile instead of being silently ignored. +- The runner-token store reads as part of the runner family: `tokens` is now `runner_tokens`, alongside `runners` and `runner_defaults`. The automatic `Sensitive` wrap on lookup moves with the new key; the host's secret store file must follow the rename. +- The apply script now installs on any declared-standalone host (`standalone.manage: true`), not only with the self-update loop, so manual applies and the loop share the same single apply command; enabling the loop on a host not declared standalone fails at compile time. - Freshly provisioned hosts now get a 165536-wide subordinate-ID range, which satisfies the ID-mapping requirement of nested rootless BuildKit builds. -- Subordinate UID/GID ranges are now provisioned by `manage_rootless_docker`, together with the other rootless-Docker prerequisites, instead of by `manage_runner_user`: rootless Docker can now be brought up on a host whose runner user is owned by another system. An existing range entry is never overwritten, and `manage_runner_user` keeps owning the group, user, and home. +- Subordinate UID/GID ranges are now provisioned by `rootless_docker.manage`, together with the other rootless-Docker prerequisites, instead of by the runner-account toggle: rootless Docker can now be brought up on a host whose runner account is owned by another system. An existing range entry is never overwritten, and `runner_account.manage` keeps owning the group, user, and home. ### Removed -- Every parameter the module can derive is now automatic: the rendered configuration's owner and group follow `runner_user` (which also fixes the file staying owned by the default account name when a different runner user is declared), the docker socket path follows `runner_uid`, the no-detach-netns drop-in location follows `runner_home`, and the apply's manifest, module directory and Hiera configuration follow the documented control-repository layout under `repo_path`. `service_user` goes with them: the runner manager service always runs privilege-dropped as the runner user. +- Every parameter the module can derive is now automatic: the rendered configuration's owner and group follow `runner_account.name` (which also fixes the file staying owned by the default account name when a different account is declared), the docker socket path follows `runner_account.uid`, the no-detach-netns drop-in location follows `runner_account.home`, and the apply's manifest, module directory and Hiera configuration follow the documented control-repository layout under `standalone.control_repository_path`. `service_user` goes with them: the runner manager service always runs privilege-dropped as the runner account. - Deliberate choices are no longer parameters: `runner_binary`, `service_name` and `setuptool_path` take the values their packages define, `service_kill_signal` is always `SIGQUIT` (the graceful drain), and `config_mode` and `dropin_mode` keep their former defaults. - `check_interval`, `connection_max_age` and `shutdown_timeout` are no longer rendered. The removed lines matched GitLab Runner's own defaults, so existing hosts behave the same. - `on_failure_unit` is removed; a push alert attaches as a host-side `OnFailure=` drop-in on the apply or healthcheck service. ### Fixed -- With `manage_apt_repos` on, repeated applies stop churning apt: an unchanged repository signing key is now a true no-op (no keyring rewrite, no needless `apt-get update`), while a genuine key rotation is still picked up from the vendor's rolling key endpoint and refreshes the apt index. +- With `packages.sources.manage` on, repeated applies stop churning apt: an unchanged repository signing key is now a true no-op (no keyring rewrite, no needless `apt-get update`), while a genuine key rotation is still picked up from the vendor's rolling key endpoint and refreshes the apt index. ## [1.0.0] - 2026-07-09 From bf4f2d9c2907a8da61d3fce2d12cefcd26083ff5 Mon Sep 17 00:00:00 2001 From: jcardozo-eth <131674798+jcardozo-eth@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:19:08 +0200 Subject: [PATCH 4/4] docs(contributing): update directory tree and toggle references The directory-tree annotations, the dependency note, and the spec-suite description now name the struct-era toggles (packages.sources.manage, the manage subkeys) instead of the flat manage_* parameters. --- CONTRIBUTING.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fb13efd..1a85196 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -213,8 +213,8 @@ the public class is the only entry point. . ├── manifests/ # Puppet classes (the logic) │ ├── init.pp # public class: parameters, ordering, containment -│ ├── apt_repos.pp # apt repositories behind manage_apt_repos -│ ├── packages.pp # apt packages from the `packages` list +│ ├── apt_repos.pp # apt sources behind packages.sources.manage +│ ├── packages.pp # apt packages from the packages.install list │ ├── user.pp # runner group, user, home │ ├── rootless_docker.pp # subids + rootless-docker bring-up behind the preflight │ ├── config.pp # renders config.toml; secret-store directory @@ -261,7 +261,7 @@ the public class is the only entry point. The module keeps its **dependency surface deliberately small**: built-in Puppet resource types wherever possible, plus two Forge modules — `puppetlabs/stdlib` (the `assert_private()` guard in the internal classes) and `puppetlabs/apt` (the -apt sources behind `manage_apt_repos`). +apt sources behind `packages.sources.manage`). ### Walkthrough: adding a configuration option @@ -284,8 +284,8 @@ The unit tests are rspec-puppet [\[5\]](#ref-5): each example compiles the class a given set of parameters on the supported OS, into a *catalog* (Puppet's resolved list of resources) and asserts on it. No host is involved; a test failure means the module would have declared the wrong state. The suite covers the parameter guards (the -fail-loud cases), the resources behind each `manage_*` toggle, and the service -postures. +fail-loud cases), the resources behind each `manage` toggle, and the service +posture. A few pieces are worth knowing beyond the plain parameter-and-guard examples: @@ -406,7 +406,7 @@ Repository-wide rules; they apply to every commit on every branch. Scopes track the private classes and templates (`config`, `service`, `self_update`, `apt_repos`, `user`, `rootless_docker`), e.g. `feat(config): render allowed_images`, - `fix(self_update): quote repo_path in apply script`. + `fix(self_update): quote checkout path in apply script`. ### Breaking changes Breaking changes are marked explicitly: append `!` after the type/scope