diff --git a/CHANGELOG.md b/CHANGELOG.md index 20e1683..29603b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,8 @@ module follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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. - The liveness healthcheck now installs on any declared-standalone host (`standalone.manage: true`), not only with the self-update loop, so a standalone host without the loop still has its manager service and rootless Docker daemon monitored (cadence `standalone.healthcheck_interval`); the loop's own supervision checks (apply-timer state, checkout staleness, bootstrap-gem presence) layer on only when the loop is enabled. -- Freshly provisioned hosts now get a 165536-wide subordinate-ID range, which satisfies the ID-mapping requirement of nested rootless BuildKit builds. +- Freshly provisioned hosts get a 165536-wide subordinate-ID range out of the box, so nested rootless BuildKit builds work without tuning. +- `rootless_docker.subid_count` is now a declared minimum width the module enforces. A managed host narrower than declared is widened in place and the rootless daemon restarted so the change takes effect; a range too narrow to work fails the apply with guidance instead of failing later inside a job; and a range that does not mirror the host, or overlaps another user's allocation, is flagged on every apply. - 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. - The managed runner service's `DOCKER_HOST` is now module-owned and derived from `runner_account.uid`; `runner_service.environment` carries additional variables only and rejects a `DOCKER_HOST` line, and a managed service requires `runner_account.uid`. diff --git a/README.md b/README.md index 2413632..c0e137e 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,13 @@ by construction where the module creates the account. 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 +default `231072`/`165536`) and enforces `subid_count` as a grow-only minimum width. A +module-owned range at the declared start narrower than `subid_count` is widened in place (a +single locked `usermod` del-before-add) and the rootless daemon restarted so its user namespace +picks up the new width; a foreign or already-wider range is never rewritten (a too-narrow +foreign range fails the preflight, a wide-enough one warns). Widening an in-service host +interrupts running jobs (rootless mode has no live-restore), so it is best scheduled around +them; CI-side job `retry` covers a job caught by the restart. It then 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 ([`runner_account`](#runner_account)) @@ -318,8 +324,8 @@ it is the `no-detach-netns` drop-in, layered on top as a systemd override so bot 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 `rootless_docker.manage` is `true`. On a host where everything is already in place the +the runner user's subordinate IDs totalling at least the declared `subid_count`, lingering enabled, +cgroup v2) and aborts with a clear 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 diff --git a/REFERENCE.md b/REFERENCE.md index 4fb3994..2802dc0 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -276,9 +276,11 @@ 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), +subordinate UID/GID ranges and enforce `subid_count` as a grow-only minimum +width (a module-owned range narrower than declared is widened in place and +the rootless daemon restarted; a foreign or already-wider range is never +rewritten, 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 @@ -288,10 +290,13 @@ 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. +the `/etc/subuid` entry format, per `subid(5)`). Default 231072. shadow's +own auto-allocation hands out 65536-wide ranges from 100000, so the third +auto-allocated user on a host starts at 231072; a range overlapping another +user's is warned about at compile time. * **: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. +rootless docker needs at least 65536), enforced as a grow-only minimum +width. Default 165536, which also covers BuildKit's rootless image layout. ##### `runner_service` diff --git a/lib/facter/rootless_gitlab_runner_subids.rb b/lib/facter/rootless_gitlab_runner_subids.rb new file mode 100644 index 0000000..765bb3f --- /dev/null +++ b/lib/facter/rootless_gitlab_runner_subids.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'puppet_x/rootless_gitlab_runner/subids' + +# Structured view of the host's subordinate UID/GID allocations, backing the +# compile-time width and overlap advisories in rootless_gitlab_runner. rspec +# compiles a catalog and cannot see host state, so the class reads this fact to +# compare the declared range against what /etc/subuid and /etc/subgid grant. +# Value shape: { 'subuid' => { '' => [ { 'start', 'count' } ] }, 'subgid' => {…} }. +# Parsing lives in PuppetX::RootlessGitlabRunner::Subids so it is testable +# without Facter; this fact is the thin IO adapter. Puppet puts each module's +# lib/ on $LOAD_PATH after pluginsync, so the require resolves on the agent. +Facter.add(:rootless_gitlab_runner_subids) do + setcode { PuppetX::RootlessGitlabRunner::Subids.read } +end diff --git a/lib/puppet_x/rootless_gitlab_runner/subids.rb b/lib/puppet_x/rootless_gitlab_runner/subids.rb new file mode 100644 index 0000000..9cda18d --- /dev/null +++ b/lib/puppet_x/rootless_gitlab_runner/subids.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module PuppetX + module RootlessGitlabRunner + # Pure parsing of the subordinate UID/GID files (subuid(5)), split from the + # fact that reads them so the logic is unit-testable without Facter or the + # filesystem. The fact (and any future consumer) share this one parser. + module Subids + FILES = { 'subuid' => '/etc/subuid', 'subgid' => '/etc/subgid' }.freeze + + module_function + + # Parse subuid/subgid lines into { owner => [ { 'start', 'count' } ] }. + # 'owner' is field one verbatim (the name usermod writes); malformed and + # non-numeric lines are skipped. + def parse(lines) + lines.each_with_object({}) do |line, owners| + owner, start, count = line.strip.split(':') + next if owner.nil? || start !~ /\A\d+\z/ || count !~ /\A\d+\z/ + + (owners[owner] ||= []) << { 'start' => start.to_i, 'count' => count.to_i } + end + end + + # Read and parse each file, keyed by kind. An unreadable file yields an + # empty map, so a host without it simply carries no advisory input. + def read(files = FILES) + files.transform_values do |path| + File.readable?(path) ? parse(File.foreach(path)) : {} + end + end + end + end +end diff --git a/manifests/init.pp b/manifests/init.pp index 95ea755..c4d68a2 100644 --- a/manifests/init.pp +++ b/manifests/init.pp @@ -149,9 +149,11 @@ # running the daemon unprivileged. # @option rootless_docker [Boolean] :manage # 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), +# subordinate UID/GID ranges and enforce `subid_count` as a grow-only minimum +# width (a module-owned range narrower than declared is widened in place and +# the rootless daemon restarted; a foreign or already-wider range is never +# rewritten, 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 @@ -162,11 +164,14 @@ # 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. +# the `/etc/subuid` entry format, per `subid(5)`). Default 231072. shadow's +# own auto-allocation hands out 65536-wide ranges from 100000, so the third +# auto-allocated user on a host starts at 231072; a range overlapping another +# user's is warned about at compile time. # @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. +# rootless docker needs at least 65536), enforced as a grow-only minimum +# width. 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. diff --git a/manifests/rootless_docker.pp b/manifests/rootless_docker.pp index ee0f66c..6b3999b 100644 --- a/manifests/rootless_docker.pp +++ b/manifests/rootless_docker.pp @@ -46,24 +46,106 @@ } # 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 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::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| { + # --system` allocates none, so the ranges are provisioned here with the rest + # of the rootless runtime: also when the runner account itself is owned + # externally (runner_account.manage off). usermod (shadow 4.8.1) writes the + # entry under the shadow file lock and fails loud when the user is absent. + # + # subid_count is a declared minimum width, enforced grow-only per file + # (/etc/subuid, /etc/subgid). The structured subid fact reports each file's + # current entries, so the class reasons about real host state that rspec and + # --noop cannot see, and drives every outcome from that one source: + # create — no entry yet: write start:count (the greenfield path). Always + # declared and grep-guarded, so it is correct even without the fact. + # widen — one entry, at the declared start, narrower than declared: rewrite + # it wider. The old width comes from the fact, so the command is + # fully literal — no shell arithmetic — and the guard re-asserts + # that exact line still exists, so a host changed since fact + # collection is skipped, not mis-edited. shadow 4.8.1 deletes + # before adding in the one locked invocation (never momentarily + # absent), and widening at an unchanged start preserves every + # existing inner->outer mapping (a prefix superset), so container + # and image ownership is untouched. + # advise — everything else is neither created nor widened: a wider-than- + # declared entry, or a foreign/multi-entry range wide enough in + # sum, warns (declared data must mirror the host); a too-narrow + # foreign range fails loud at the preflight below. + # warning() is a log line, not a resource, so the apply stays change-free and + # --noop clean (an exec/notify warning would report a change every apply); + # only the exact-match state is silent. Guards test host state and exit 0/1 — + # no PATH probe. + $subid_first = $rootless_gitlab_runner::rootless_docker['subid_start'] + $subid_count = $rootless_gitlab_runner::rootless_docker['subid_count'] + $subid_last = $subid_first + $subid_count - 1 + $subid_report = $facts['rootless_gitlab_runner_subids'] + $subid_files = { + 'subuid' => { 'add' => '--add-subuids', 'del' => '--del-subuids' }, + 'subgid' => { 'add' => '--add-subgids', 'del' => '--del-subgids' }, + } + $subid_files.each |$f, $flag| { + # This file's entries, from the fact: all owners, and the runner user's own + # ranges (empty when the fact has not run, e.g. the factless specs). + $owners = $subid_report =~ Hash ? { true => pick_default($subid_report[$f], {}), default => {} } + $ranges = pick_default($owners[$runner_user], []) + + # create: no entry yet for the runner user. exec { "rootless_gitlab_runner ${f} entry": - command => "usermod ${flag} ${subid_first}-${subid_last} ${runner_user}", + command => "usermod ${flag['add']} ${subid_first}-${subid_last} ${runner_user}", unless => "grep -q '^${runner_user}:' /etc/${f}", path => ['/usr/sbin', '/usr/bin', '/bin'], provider => 'shell', before => Exec['rootless_gitlab_runner preflight'], } + + # widen / advise on the runner user's own entry. + if $ranges.length == 1 and $ranges[0]['start'] == $subid_first { + $have = $ranges[0]['count'] + if $have < $subid_count { + $old_last = $subid_first + $have - 1 + exec { "rootless_gitlab_runner ${f} widen": + command => "usermod ${flag['del']} ${subid_first}-${old_last} ${flag['add']} ${subid_first}-${subid_last} ${runner_user}", + onlyif => "grep -qxF '${runner_user}:${subid_first}:${have}' /etc/${f}", + path => ['/usr/sbin', '/usr/bin', '/bin'], + provider => 'shell', + before => Exec['rootless_gitlab_runner preflight'], + notify => Exec['rootless_gitlab_runner rootless docker restart (subid widen)'], + } + } elsif $have > $subid_count { + warning(join([ + "rootless_gitlab_runner: /etc/${f} grants ${runner_user} ${subid_first}:${have}, wider than the", + "declared subid_count ${subid_count}; the module never shrinks a range. Raise subid_count to", + 'match the host, or narrow the entry by hand.', + ], ' ')) + } + } elsif $ranges.length > 0 { + # Foreign start, or multiple entries: never rewritten. Wide enough in sum + # warns; too narrow is caught loud by the preflight, not here. + $summed = $ranges.reduce(0) |$m, $r| { $m + $r['count'] } + if $summed >= $subid_count { + warning(join([ + "rootless_gitlab_runner: /etc/${f} for ${runner_user} does not mirror the declared range", + "${subid_first}:${subid_count} and is left untouched. Align subid_start/subid_count with the", + 'host allocation to silence this.', + ], ' ')) + } + } + + # Any other user's range overlapping the declared one: usermod adds no + # overlap check, so an overlap means two users share container UIDs. + $owners.each |$owner, $oranges| { + unless $owner == $runner_user { + $oranges.each |$r| { + $o_last = $r['start'] + $r['count'] - 1 + if $r['start'] <= $subid_last and $o_last >= $subid_first { + warning(join([ + "rootless_gitlab_runner: the declared range ${subid_first}-${subid_last} overlaps", + "${owner}'s ${r['start']}-${o_last} in /etc/${f}; the two users would share container", + 'UIDs. Choose a non-overlapping subid_start.', + ], ' ')) + } + } + } + } } # Fail-loud preflight, silent when healthy: the success condition lives in @@ -76,10 +158,16 @@ # it into a raised error, and dash (Ubuntu's /bin/sh) exits 127 from a PATH # probe (`command -v`) on a missing binary. So test the packaged file, not # the PATH: /usr/bin/newuidmap is what jammy's uidmap package installs. + # + # The subuid/subgid clauses sum the runner user's entries and compare the + # total against the declared subid_count (rootlesskit maps every range, so + # the sum is the effective width). A module-shaped host is widened above + # before this runs; a foreign range wide enough in sum passes; a foreign + # range too narrow fails here with the declared count and the remedies. $preflight_ok = join([ 'test -x /usr/bin/newuidmap', - "awk -F: -v u='${runner_user}' '\$1 == u && \$3 >= 65536' /etc/subuid | grep -q .", - "awk -F: -v u='${runner_user}' '\$1 == u && \$3 >= 65536' /etc/subgid | grep -q .", + "awk -F: -v u='${runner_user}' -v need=${subid_count} '\$1==u{sum+=\$3} END{exit !(sum>=need)}' /etc/subuid", + "awk -F: -v u='${runner_user}' -v need=${subid_count} '\$1==u{sum+=\$3} END{exit !(sum>=need)}' /etc/subgid", "test -e /var/lib/systemd/linger/${runner_user}", 'test -f /sys/fs/cgroup/cgroup.controllers', ], ' && ') @@ -88,7 +176,8 @@ command => join([ 'echo "rootless_gitlab_runner preflight failed:', 'need newuidmap,', - "subuid/subgid >= 65536 for ${runner_user},", + "subuid+subgid totalling >= ${subid_count} for ${runner_user}", + '(widen the foreign range, or align subid_start/subid_count with it),', 'lingering enabled, and cgroup v2" >&2; exit 1', ], ' '), unless => $preflight_ok, @@ -139,5 +228,23 @@ provider => 'shell', require => Exec['rootless_gitlab_runner await user session'], } + + # A widened range only takes effect when rootlesskit re-creates the user + # namespace, i.e. a rootless-daemon restart — which drops running job + # containers (rootless mode has no live-restore; the containers are children + # of the rootlesskit namespace). The netns drop-in already accepts this same + # trade; CI-side job retry is the documented mitigation, and no drain is + # orchestrated (a bounded wait-until-empty would risk the apply timing out). + # Notified only by an actual widen above, so it fires only when the width + # changed; a subid widen alters no unit file, so try-restart alone (no + # daemon-reload). try-restart no-ops on greenfield where no daemon runs yet. + exec { 'rootless_gitlab_runner rootless docker restart (subid widen)': + command => 'systemctl --user try-restart docker', + user => $runner_user, + environment => $user_env, + path => ['/usr/bin', '/bin'], + refreshonly => true, + require => Exec['rootless_gitlab_runner setuptool install'], + } } } diff --git a/spec/classes/rootless_gitlab_runner_spec.rb b/spec/classes/rootless_gitlab_runner_spec.rb index 674ce54..575d35e 100644 --- a/spec/classes/rootless_gitlab_runner_spec.rb +++ b/spec/classes/rootless_gitlab_runner_spec.rb @@ -2,6 +2,17 @@ require 'yaml' require 'deep_merge' +# rspec-puppet exposes no matcher for compile-time warning() output, so a +# registered Puppet log destination collects warning messages for the sub-ID +# advisory tests (see the module_warnings helper). Module-global by Puppet's +# design; the helper clears it before each capture. +RGR_WARNINGS = [] # rubocop:disable Style/MutableConstant +Puppet::Util::Log.newdesttype(:rgr_capture) do + def handle(msg) + RGR_WARNINGS << msg.message if msg.level == :warning + end +end + # puppetlabs-apt compiles only on Debian-family facts; the suite otherwise runs # factless, so the apt-enabled and example contexts pin the single supported # OS's facts. Derived from metadata.json via rspec-puppet-facts / facterdb (one @@ -62,6 +73,27 @@ def account_with_uid(uid, extra = {}) struct_param('runner_account', { 'uid' => uid }.merge(extra)) end + # A structured subid fact for the runner user (and optionally foreign owners), + # the shape lib/puppet_x/rootless_gitlab_runner/subids.rb produces. Both files + # carry the runner ranges; `others` adds foreign owners for the overlap case. + def subid_facts(runner_ranges, others = {}) + owners = { 'gitlab-runner' => runner_ranges }.merge(others) + { 'rootless_gitlab_runner_subids' => { 'subuid' => owners, 'subgid' => owners } } + end + + # This module's compile-time warning() lines. Attach the capture destination, + # force the one memoized compile, then filter to the module's own messages. + # Call before any other catalogue access in the example, so the destination is + # live when warnings fire (rspec-puppet caches the catalog, compiling once). + def module_warnings + RGR_WARNINGS.clear + Puppet::Util::Log.newdestination(:rgr_capture) + catalogue + RGR_WARNINGS.grep(/\Arootless_gitlab_runner:/) + ensure + Puppet::Util::Log.close(:rgr_capture) + end + context 'with defaults' do it { is_expected.to compile.with_all_deps } @@ -631,6 +663,143 @@ def account_with_uid(uid, extra = {}) end end + # sub-ID minimum-width enforcement. The structured subid fact makes /etc/subuid + # and /etc/subgid state visible to the catalog, so the class decides create / + # widen / advise from real host state that rspec and --noop cannot see. Each + # case supplies the fact directly. + context 'with rootless_docker.manage and a fact-visible subid state' do + let(:base_params) do + { 'rootless_docker' => struct_param('rootless_docker', 'manage' => true), + 'runner_account' => account_with_uid(4242) } + end + + context 'a module-owned range narrower than declared' do + let(:params) { base_params } + let(:facts) { subid_facts([{ 'start' => 231_072, 'count' => 65_536 }]) } + + { 'subuid' => ['--del-subuids', '--add-subuids'], + 'subgid' => ['--del-subgids', '--add-subgids'] }.each do |f, (del, add)| + it "widens #{f} in place with a literal usermod, guarded on the exact current line" do + is_expected.to contain_exec("rootless_gitlab_runner #{f} widen").with( + 'command' => "usermod #{del} 231072-296607 #{add} 231072-396607 gitlab-runner", + 'onlyif' => "grep -qxF 'gitlab-runner:231072:65536' /etc/#{f}", + 'provider' => 'shell', + ).that_comes_before('Exec[rootless_gitlab_runner preflight]') + end + + it "fires the rootless-daemon restart from the #{f} widen" do + is_expected.to contain_exec("rootless_gitlab_runner #{f} widen") + .that_notifies('Exec[rootless_gitlab_runner rootless docker restart (subid widen)]') + end + end + + it 'restarts the rootless daemon refreshonly as the runner user, after bring-up' do + is_expected.to contain_exec('rootless_gitlab_runner rootless docker restart (subid widen)').with( + 'command' => 'systemctl --user try-restart docker', + 'user' => 'gitlab-runner', + 'refreshonly' => true, + 'environment' => [ + 'HOME=/home/gitlab-runner', + 'USER=gitlab-runner', + 'XDG_RUNTIME_DIR=/run/user/4242', + 'DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/4242/bus', + ], + ).that_requires('Exec[rootless_gitlab_runner setuptool install]') + end + + it 'sums against the declared count in the preflight' do + is_expected.to contain_exec('rootless_gitlab_runner preflight') + .with_unless(%r{-v need=165536 '\$1==u\{sum\+=\$3\} END\{exit !\(sum>=need\)\}' /etc/subuid}) + .with_command(%r{subuid\+subgid totalling >= 165536 for gitlab-runner}) + end + + it 'emits no advisory while it converges the range' do + expect(module_warnings).to be_empty + end + end + + context 'an exact-match range' do + let(:params) { base_params } + let(:facts) { subid_facts([{ 'start' => 231_072, 'count' => 165_536 }]) } + + it 'declares no widen and stays silent' do + expect(module_warnings).to be_empty + %w[subuid subgid].each do |f| + is_expected.not_to contain_exec("rootless_gitlab_runner #{f} widen") + end + end + end + + context 'a module-owned range wider than declared' do + let(:params) { base_params } + let(:facts) { subid_facts([{ 'start' => 231_072, 'count' => 262_144 }]) } + + it 'never shrinks it: no widen, and a wider-than-declared warning' do + expect(module_warnings).to include( + match(%r{/etc/subuid grants gitlab-runner 231072:262144, wider than the declared subid_count 165536}), + ) + is_expected.not_to contain_exec('rootless_gitlab_runner subuid widen') + end + end + + context 'a foreign-start range wide enough in sum' do + let(:params) { base_params } + let(:facts) { subid_facts([{ 'start' => 500_000, 'count' => 200_000 }]) } + + it 'leaves it untouched and warns that data does not mirror the host' do + expect(module_warnings).to include( + match(%r{/etc/subuid for gitlab-runner does not mirror the declared range 231072:165536}), + ) + is_expected.not_to contain_exec('rootless_gitlab_runner subuid widen') + end + end + + context 'a foreign-start range too narrow' do + let(:params) { base_params } + let(:facts) { subid_facts([{ 'start' => 500_000, 'count' => 1000 }]) } + + it 'stays silent at compile time: the preflight fails loud instead' do + expect(module_warnings).to be_empty + is_expected.not_to contain_exec('rootless_gitlab_runner subuid widen') + end + end + + context 'the declared range overlapping another user' do + let(:params) { base_params } + let(:facts) do + subid_facts([{ 'start' => 231_072, 'count' => 165_536 }], + 'sophie' => [{ 'start' => 300_000, 'count' => 65_536 }]) + end + + it 'warns that the two users would share container UIDs' do + expect(module_warnings).to include( + match(%r{declared range 231072-396607 overlaps sophie's 300000-365535 in /etc/subuid}), + ) + end + end + end + + context 'with rootless_docker.manage, a custom declared range and a narrower host range' do + let(:params) do + { 'rootless_docker' => struct_param('rootless_docker', 'manage' => true, + 'subid_start' => 300_000, 'subid_count' => 200_000), + 'runner_account' => account_with_uid(4242, 'manage' => true) } + end + let(:facts) { subid_facts([{ 'start' => 300_000, 'count' => 70_000 }]) } + + it 'computes both inclusive bounds from the fact width and the declared width' do + is_expected.to contain_exec('rootless_gitlab_runner subuid widen') + .with_command('usermod --del-subuids 300000-369999 --add-subuids 300000-499999 gitlab-runner') + .with_onlyif("grep -qxF 'gitlab-runner:300000:70000' /etc/subuid") + end + + it 'carries the declared non-default count into the summed preflight' do + is_expected.to contain_exec('rootless_gitlab_runner preflight') + .with_unless(%r{-v need=200000 '\$1==u\{sum\+=\$3\} END\{exit !\(sum>=need\)\}' /etc/subuid}) + .with_command(%r{subuid\+subgid totalling >= 200000}) + end + end + # Puppet turns exit 127 from an exec guard (`unless`/`onlyif`) into a raised # "Could not evaluate" error rather than a false condition, and dash — the # /bin/sh behind the shell provider on Ubuntu — exits 127 from a PATH probe diff --git a/spec/unit/puppet_x/subids_spec.rb b/spec/unit/puppet_x/subids_spec.rb new file mode 100644 index 0000000..08df9a0 --- /dev/null +++ b/spec/unit/puppet_x/subids_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' +require File.expand_path('../../../lib/puppet_x/rootless_gitlab_runner/subids', __dir__) + +describe PuppetX::RootlessGitlabRunner::Subids do + describe '.parse' do + it 'parses one entry into start/count integers' do + expect(described_class.parse(['gitlab-runner:231072:165536'])) + .to eq('gitlab-runner' => [{ 'start' => 231_072, 'count' => 165_536 }]) + end + + it 'keeps every range when a user owns more than one' do + expect(described_class.parse(['ci:100000:65536', 'ci:300000:1000'])['ci']) + .to eq([{ 'start' => 100_000, 'count' => 65_536 }, { 'start' => 300_000, 'count' => 1000 }]) + end + + it 'keeps distinct owners apart (the overlap advisory reads foreign rows)' do + parsed = described_class.parse(['gitlab-runner:231072:165536', 'sophie:231072:65536']) + expect(parsed.keys).to contain_exactly('gitlab-runner', 'sophie') + expect(parsed['sophie']).to eq([{ 'start' => 231_072, 'count' => 65_536 }]) + end + + it 'skips blank, short, and non-numeric lines' do + lines = ['', 'gitlab-runner', 'foo:bar:65536', 'gitlab-runner:231072:165536'] + expect(described_class.parse(lines)).to eq('gitlab-runner' => [{ 'start' => 231_072, 'count' => 165_536 }]) + end + + it 'tolerates trailing newlines from a real file read' do + expect(described_class.parse(["gitlab-runner:231072:165536\n"])) + .to eq('gitlab-runner' => [{ 'start' => 231_072, 'count' => 165_536 }]) + end + end + + describe '.read' do + it 'yields an empty map for an unreadable file and parses a readable one' do + allow(File).to receive(:readable?).with('/x/subuid').and_return(false) + allow(File).to receive(:readable?).with('/x/subgid').and_return(true) + allow(File).to receive(:foreach).with('/x/subgid').and_return(['gitlab-runner:231072:165536']) + result = described_class.read('subuid' => '/x/subuid', 'subgid' => '/x/subgid') + expect(result['subuid']).to eq({}) + expect(result['subgid']).to eq('gitlab-runner' => [{ 'start' => 231_072, 'count' => 165_536 }]) + end + end +end