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 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 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/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/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/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/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/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 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 %> <%- } -%>