Skip to content

Converge the runner teardown and a needrestart deferral onto runner hosts - #11063

Merged
briansrls merged 5 commits into
mainfrom
microvm-runners
Sep 11, 2026
Merged

briansrls merged 5 commits into
mainfrom
microvm-runners

Conversation

@briansrls

@briansrls briansrls commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

What this fixes

Most of the "vanished toolchain" CI reds come from one mechanism: two jobs running in the same runner slot. Examples: librustc_driver-*.so: cannot open shared object file, couldn't read …/_temp/cargo/registry/src/…, could not rename 'component' file, and rustc No such file or directory (os error 2).

What happens, from srv1's journal and /var/log/unattended-upgrades/unattended-upgrades-dpkg.log on 2026-09-11:

  1. apt-daily-upgrade installs a libc6 security update at 06:35. needrestart then runs systemctl restart actions-runner@srv1-01.service … actions-runner@srv1-NN.service.
  2. The live runner unit has KillMode=process, so the restart kills only the listener. systemd logs Unit process … (Runner.Worker) / (claim_executor) / (gunbc) remains running after unit stopped, then Found left-over process … Ignoring.
  3. 13 seconds later a new listener takes a new job into the same _work/_temp. The two jobs then delete each other's toolchain and checkout, and they share one slot's memory ceiling.

srv1's dpkg log shows needrestart restarting live runner slots on 10 separate days since June. This also explains a puzzle recorded in gunbc.runner_unit ("four detached generations at NRestarts=0"): NRestarts never counts an explicit systemctl restart.

The teardown fix was already modeled (gunbc.runner_unit gunbc_runner_lifecycle_intent: ExitType=cgroup + KillMode=control-group), but nothing delivered it to a host.

What this adds

  • extdeps.needrestart (new, cited to upstream v3.6 and Ubuntu noble package 3.6-7ubuntu4.5): a typed override_rc entry, keyed by a unit-name prefix escaped the way Perl's quotemeta does.
  • gunbc.runner_unit_file: 70-fleet-teardown.conf, a drop-in rendered from the same runner_unit_teardown_directives the full unit uses. It's a drop-in rather than the whole unit because the live unit depends on a hand-placed depriv.conf (User=root, CTRL_RUNNER_JOB_USER, sccache, docker env) that nothing models yet. Delivering the full unit is its own cut.
  • gunbc.typed_remote_file_write: a RemoteFilePrivilege option, so the stage/chmod/rename/cmp steps can run under sudo -n. The Spark caller keeps session-principal behavior.
  • gunbc.runner_host_file_converge (new): converges both files over the fleet SSH edge as the declared administrator (executor_bootstrap_principal).
    • Probes sudo -n first.
    • Observes each file with test -e plus the same far-side cmp leg the write's read-back uses.
    • Writes only what differs.
    • Runs daemon-reload only when the drop-in changed.
    • Reads systemctl show -p KillMode/-p ExitType back from a live slot unit.
    • Never restarts a slot.
  • Two fleet-converge modes: runner_host_file_observe (no writes) and runner_host_file_converge, plus a receipt upload.

Why the CI job user gets no grant. systemd merges the drop-in and needrestart evals the snippet as Perl, both as root. A sudo grant letting ghrunner install either would let any CI job, PR code included, stage content and become root.

Evidence

  • gunbc compile of the new module's closure: 0 blocking.
  • New witness module test.claim.runner_host_file_converge_witness_test: 18/18 PASS under claim_batch --hermetic.
    • The drop-in and the needrestart snippet are checked against literal expected strings written by hand in the witness, not derived from the renderer.
    • Discriminating red: the live KillMode=process/ExitType=main pair renders differently and is refused by lifecycle_intent_cannot_orphan.
    • Read-back red: process/main, and each half alone, classify NOT-EFFECTIVE.
    • Hermetic route test: the full apply route over the mocked SSH fixture writes nothing already identical and ends refused because the mock never reports the declared teardown.
  • Touched modules re-run in full: typed_remote_file_converge_witness_test 15/15, typed_remote_file_write_witness_test 12/12, runner_unit_file_witness_test 21/21, executor_privileged_operation_witness_test 14/14.
  • Real Perl: the rendered snippet goes through needrestart's own eval … die loading and its override_rc lookup loop. actions-runner@srvN-NN.service gives restart=0; docker.service still gives 1; xactions-runner@… doesn't match; the distro's dbus entry is preserved.
  • Other touched witnesses: workflow_capability_closure_witness_test 8/8, toolchain_home_standing_witness_test 10/10, workflow_dispatch_input_witness_test 17/17. For the last one, the mode-count literal moved from 16 to 18, as its annotation requires when variants are added.
  • Local required build lane: phases_run=2 phases_failed=0, and the generated-artifact registry shows 40/40 matches with no drift.
  • Local required witnesses lane on the committed diff: namespace-wave-admission ADMITTED (3 modules added, 0 deltas).
    • The floor refused during strict preparation on a pre-existing gap in the edited typed_remote_file_converge_witness_test, which called string_list_contains without importing it. That module is only strictly prepared when it changes. The import is fixed and the module passes 15/15.
    • Two local reruns after that fix were killed by the host for low memory before the floor ran. The CI floor on this PR is the first complete run.

Review repairs (dd66120, merged onto main at 927e42a)

  • A retry finishes an interrupted convergence. Whether a reload is owed comes from the manager's own NeedDaemonReload, read after the writes, not from whether this invocation wrote anything. A run that finds identical bytes after an earlier reload failed now reloads.
  • Every slot, not the first. One systemctl show --property=Id,LoadState,NeedDaemonReload,KillMode,ExitType reads every desired slot unit plus every loaded actions-runner@ unit (from list-units --all). LoadState is checked first, because an unloaded unit reports the default KillMode=control-group. Each block is joined by its Id, and a unit with no block is UNREAD. The host is effective only if every unit is.
  • Ownership is part of convergence. The file and each ancestor up to /etc must be root:root with no group/other write, checked on the host by find. Identical bytes with unsafe file metadata are rewritten; an unsafe ancestor refuses; metadata is read back after every write.
  • Id/KillMode/ExitType join extdeps.systemd's property vocabulary. The sudo prefix comes from extdeps.sudo.elevation. The property/directive classifier gains its SliceProperty arm, missing on main.

CI evidence: witnesses run 34626935244 is green. Floor verdict FloorClean, 3722/3722 executed, 0 failed. All 29 runner_host_file_converge_witness_test witnesses were planned as changed witnesses and passed. namespace-wave-admission ADMITTED the diff (3 modules added, 5 deltas, all auto-admitted).

Measured on srv2 (read-only observe, pre-repair code, run 34617430531): the fleet key reaches ubuntu and sudo -n is available; both files are absent; actions-runner@srv2-01 has KillMode=process ExitType=main loaded.

Not done here

No host has been changed. After merge the rollout is, one host at a time, srv2 first:

  1. Dispatch runner_host_file_observe.
  2. Dispatch runner_host_file_converge.
  3. Watch slots cycle, since ExitType=cgroup holds a slot until its cgroup empties.
  4. Move to the next host.

executor_bootstrap_principal (ubuntu via the fleet key) is measured only on srv3. The observe run is the measurement for the other hosts, and it refuses by name if the principal can't sudo -n.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Vj6JFxcVFjT9KxFmJ6oM7D

…osts

needrestart restarts every actions-runner@ unit after an unattended libc
upgrade; with KillMode=process the restart kills only the listener, the
in-flight job's worker survives, and a new listener takes a new job into the
same _work directory -- the vanished-toolchain CI reds. The teardown intent
was modeled (gunbc.runner_unit) but never delivered.

gunbc.runner_host_file_converge writes a teardown drop-in rendered from that
intent and a cited needrestart override_rc deferral, over the fleet SSH edge
as the declared administrator with sudo -n, reloads only when the drop-in
changed, and reads KillMode/ExitType back from a live slot. Two fleet-converge
modes: runner_host_file_observe (no writes) and runner_host_file_converge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vj6JFxcVFjT9KxFmJ6oM7D
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-11T15:42:03.913572Z 88947aa PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 88947aa03b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +333 to +337
match typed_remote_file_step(
target: subject.target,
context: context,
raws: typed_remote_file_compare_argv(write: w),
stdin_payload: w.content,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Verify permissions before leaving root-evaluated files untouched

When either existing config already has the desired bytes but is owned by the runner account or is group/world-writable, this content-only cmp classifies it as HostFileIdentical, so apply skips the privileged chmod/write and can report success while leaving CI jobs able to modify a file later evaluated by needrestart or systemd as root. The observation needs to verify the expected root ownership and mode as well as content, treating metadata drift as requiring convergence.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in dd66120 (current head 927e42a). observe_runner_host_file now reads ownership as well as content: the file, and every ancestor directory up to /etc, must be root:root with no group/other write bit. The check is evaluated far-side by find … -maxdepth 0 -type f|d -user root -group root -not -perm /022, which prints only the safe set.

  • Identical bytes with unsafe file metadata now plan a write, and the root-owned staged file is renamed over the old one (runner_host_file_action).
  • An unsafe ancestor refuses, since rewriting the file can't repair it.
  • Metadata is read back after every write (apply_runner_host_file, step metadata-readback).

Witnesses identical_bytes_with_unsafe_file_metadata_are_rewritten_not_left and an_ancestor_that_is_not_root_only_refuses_every_action both ran planned-and-passed as changed witnesses in floor run 34626935244 (FloorClean, 3722/3722).

gunbc-ci-auto-heal and others added 4 commits September 11, 2026 16:26
…nsafe ownership

Review on #11063 found three holes. The reload was owed only by a write in the
same invocation, so a run after a failed reload saw identical bytes and never
reloaded; it is now the manager's own NeedDaemonReload. The teardown was read
from the first desired slot only; every desired and every loaded
actions-runner@ unit is now read in one systemctl show, LoadState first
(an unloaded unit reports the default KillMode=control-group). And identical
bytes were left in place whatever their ownership; the file and each ancestor
up to /etc must now be root:root with no group/other write, checked far-side
by find, rewritten when the file is wrong, refused when an ancestor is, and
read back after every write.

Also: Id/KillMode/ExitType join extdeps.systemd's property vocabulary, the
sudo prefix comes from extdeps.sudo.elevation, and the property/directive
classifier gains its missing SliceProperty arm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vj6JFxcVFjT9KxFmJ6oM7D
# Conflicts:
#	dag/test/claim/typed_remote_file_write_fleet_ssh_fixture.dag
# Conflicts:
#	.github/workflows/fleet-converge.yml
#	dag/gunbc/fleet/fleet_converge_workflow.dag
@briansrls
briansrls merged commit 4c90d65 into main Sep 11, 2026
4 checks passed
@briansrls
briansrls deleted the microvm-runners branch September 11, 2026 18:15
gunbai-bot Bot pushed a commit that referenced this pull request Sep 21, 2026
…ence chain, not the deleted classify_host_file_presence

gunbc.microvm_controller_app_key_converge imported and called
classify_host_file_presence from gunbc.runner_host_file_converge. The
function was real (#11063) and this consumer bound it in #11679; #11845
deleted it because its exit-1 arm minted HostFileAbsent for every stat
failure (GNU `test -e` is stat(path) == 0), and touched only the sibling
module, so this consumer went dangling. Restoring the name would restore
the false-absence arm on the one file the module keeps root:root 0400.

observe_key_content now runs the sibling's stat `%F` probe over this
module's elevated leg and consumes classify_host_file_metadata,
classify_host_file_path and runner_host_file_settled_standing; the parent
listing that decides absence when the read failed runs elevated (the same
stated departure from member_observe's entry_presence that clear_staging
already carries, because the parent is root:root 0700), with the argv and
membership read still member_observe's.

Evidence: per-entry compile of the module (primary-precedence pool) on
#11941's head with the old file reports exactly the three errors (name not
found in module at the import, function not found in scope at the call,
effect summary incomplete); with this file it reports none, both runs
resolving the same 1563-source closure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant