This guide sets up a dedicated Linux build server to run motor-runner, and a
Mac to reach it with ck-motor. It covers the host, the sandbox accounts, the
tools jobs use, the compile cache, the systemd units, optional job networking,
and the first start. Later upgrades go through
drain-and-rollback.md; the Windows instance has its own
guide, windows-image.md.
The scripts under script/runner/ encode one host layout: shared state under
/srv/build, a single operator account that owns the runner's systemd user
units, and Mac paths under /Users. Read each script's first lines before
running it; paths such as the operator's home and the network interface name
are fixed in the scripts and the unit files, and must match your host.
- A dedicated Ubuntu server. The runner and its isolation tests start and stop system services; do not share the machine with unrelated workloads.
- An XFS filesystem with reflink support mounted at
/srv/build. The runner clones workspaces and targets with reflinks, which work only within one filesystem, so caller paths (/Users) must live on the same filesystem.setup.shbind-mounts/srv/build/Usersat/Usersand probes a reflink copy across them. - systemd with
PrivatePIDs=support (systemd 257 or newer; the runner does not check the version). The CPU accounting parser is tested against output captured from systemd 259. Also POSIX ACL tools (setfacl), and Linux namespaces and mounts. - An operator account with
sudo. The runner callssudo -nfor the operations that need root (starting job services, ACL repair, network attachment). The repository does not ship a sudoers file: grant only the command forms the runner uses.script/runner/network-sudo-probes.jsonlists the network forms; the rest are thesudocalls incrates/motor-runner/src/isolation.rs,systemd.rsandnetwork_linux.rs.
From a Mac with an SSH alias for the server (the scripts use alfonso-runner
unless RUNNER_HOST names another):
script/runner/setup.shsetup.sh refuses to run unless /srv/build is a mounted XFS filesystem. It:
- installs the build packages (C toolchain, clang, lld, Git, Node, npm and others), and Rust, cargo-nextest and Bun at the versions the Mac has;
- creates
/srv/build/{src,target,cargo-home,tools-target,mirrors,bundles}; - bind-mounts
/srv/build/Usersat/Usersthrough/etc/fstaband proves a reflink copy between them; it refuses if/Usersalready holds data; - sets a firewall that denies inbound traffic except SSH, and an SSH policy of keys only, no root login, no agent or TCP forwarding; it fails if any other service listens on a non-loopback address;
- enables security-only unattended upgrades;
- installs pnpm 11.1.2 globally and writes
/srv/build/runner.env; - finishes by running
script/runner/sync.sh, which proves a second SSH connection still works under the new policy.
On the server:
sh crates/motor-runner/setup-accounts.shIt creates motor-job and motor-cache as system accounts with no home and
/usr/sbin/nologin, if they do not exist, and prints their entries. It never
changes an existing account. At startup the runner checks both accounts and
refuses unsafe ones (for example one that is root, the runner's own account, or
administrative); an invalid motor-cache only disables compile-cache fills.
See job-isolation.md.
Jobs run offline and see the shared Cargo home's bin read-only, so every
tool a repository needs must be installed beforehand:
bash script/runner/install-tools.shIt installs each crate pinned in script/runner/tools.txt into
/srv/build/cargo-home/bin. A Git-sourced tool is installed once per revision
as <bin>-<first 8 hex of rev>, and the line marked default also owns the
plain name. tools.txt also lists the prerequisites it does not manage: Rust
toolchains and targets (install every pinned toolchain with rustup), Node,
pnpm and Bun under /usr, and system libraries.
Build the patched sccache. Stock sccache 0.18.0 updates an entry's file times on every read, which fails on the read-only cache mount jobs get, so every lookup would miss:
CARGO_HOME=/srv/build/cargo-home sh script/runner/build-sccache.shbuild-sccache.sh builds crates.io sccache 0.18.0 with
script/runner/patches/sccache-0.18.0-read-only-hits.patch, keeps the stock
binary it replaces as sccache-0.18.0-stock, and prints both hashes. See
compile-cache.md.
Build on the server, or on another x86_64 Linux host with the same Rust version:
cargo build --release -p motor-runner --locked
target/release/motor-runner --versionThe main instance's unit runs ~/.cargo/bin/motor-runner; another instance
<name> runs ~/.cargo/bin/motor-runner-<name>. For the first install, copy
the binary there with install -m 755. After that, always use
script/runner/deploy.sh, which drains the running daemon first.
bash script/runner/install-units.shinstall-units.sh copies script/runner/units/ into ~/.config/systemd/user/, showing a diff
and asking before replacing a file that differs, keeps a timestamped backup,
and reloads systemd. It never restarts a runner. The units are:
| Unit | Runs |
|---|---|
motor-runner.service |
The main instance, with its capacity flags (--max-weight) and --job-network veth |
motor-runner@.service |
Any other instance, serve --instance %i |
motor-runner@e2e.service.d/binary.conf |
The e2e test instance, from its own binary |
motor-runner@windows.service.d/windows.conf |
The Windows instance; see windows-image.md |
Edit capacity flags in the repository copy of the unit, not on the server: the repository is the source of truth. Start the main instance the first time with:
systemctl --user enable --now motor-runner.serviceThe user manager must keep running without a login session for the units to
stay up (loginctl enable-linger <operator>).
| Option | Default | Meaning |
|---|---|---|
--instance NAME |
main |
Instance name; state lives in <state-root>/<instance>. |
--state-root PATH |
$XDG_DATA_HOME/cortexkit/motor-runner |
State root. |
--max-weight N |
28 | vCPU budget for running jobs and setups. See scheduling.md. |
--max-queue-depth N |
64 | Accepted jobs waiting to run. |
--rustc-slots N |
24 | Machine-wide limit on concurrent compiler processes, enforced by the build-slot supervisor (crates/prefrontal-build-slot). |
--sccache-size-gib N |
200 | Compile cache cap. |
--free-space-floor-bytes N |
137438953472 (128 GiB) | Disk admission floor; 0 disables. |
--workspace-root PATH |
/Users |
Adds a root under which idle workspaces may be evicted. |
--owned-workspace-root PATH |
/Users |
Adds a root under which caller workspaces may be created. |
--backend host|windows-vm |
host |
Job backend. A third value, vm, is parsed but refused at startup in this build. |
--vm-config FILE |
absent | Windows instance configuration. |
--windows-cpus LIST |
absent | Linux placement avoidance: Windows VM host CPUs in CPU-list syntax; their online SMT siblings are included. Requires --windows-slot-lock. |
--windows-slot-lock PATH |
absent | Absolute path to the shared Windows QEMU slot lock; requires --windows-cpus. Read-only observation, never acquires the lock. |
--job-network veth |
absent | Allow outbound networking for jobs that request it. |
--drain-timeout-s N |
1800 | Deadline for a SIGUSR1-initiated drain. |
motor-runner --version prints the version. Other subcommands are connect
(the SSH bridge ck-motor runs), drain and resume
(drain-and-rollback.md).
The main runner unit configures Windows CPU avoidance. Each new placement,
including background cache fills, stats the slot-lock file and reads /proc/locks
to check for its exclusive flock. This requires the host PID namespace's lock
table; acquiring or releasing a flock does not generate an inotify event. While
the slot is held, new jobs overlap only within the other allocatable CPUs. A
claim larger than that set gets the whole remaining set, without changing its
admission weight or resource quotas. The maximum claim and daemon-only core do
not change, and existing jobs keep their recorded CPU sets. After the slot is
released, new placements can use those cores again.
The test capped_cpu_placement_keeps_requested_memory_limit verifies that
MemoryMax follows the requested vCPU count even when AllowedCPUs is smaller.
A missing lock file is treated as no active Windows VM. Observation errors,
malformed lock entries, or a configuration that leaves no eligible CPUs fail
open to ordinary placement, with at most one warning per minute. Placement logs
include the observed Windows activity, excluded CPUs, selected CPUs, job ID, and
whether the job is a cache fill; health and job-record wire formats are unchanged.
Observation is a snapshot,
not a reservation against a Windows VM starting concurrently. The portable tests
active_windows_excludes_siblings_and_overlaps_only_remaining_cpus and
windows_start_does_not_move_existing_job_or_exempt_cache_fills cover placement;
isolated_windows_slot_observation_tracks_flock_and_recreated_inode checks the
real host lock table on an operator host.
The ignored test windows_vm_running_linux_placement_avoids_its_cores (run by hand, not by the operator lane) checks
that the Linux runner can see QEMU's lock, not just a lock created by the test
process. Leave a Windows job VM running on the dedicated server and run the test
as the Linux runner's user in its PID namespace; the test checks real systemd
activity before checking placement. Set MOTOR_TEST_WINDOWS_SLOT_LOCK and
MOTOR_TEST_WINDOWS_CPUS to the Linux runner's flag values before running it.
Outbound networking for jobs needs the motor_net nftables table, IPv4
forwarding, a narrow firewall route allowance, and the sudo forms in
network-sudo-probes.json. Install them with the operator-only installer,
which shows a diff by default:
python3 script/runner/install-job-network.py # show what would change
python3 script/runner/install-job-network.py --apply # install
python3 script/runner/install-job-network.py --check # verifyIt installs /etc/nftables.d/motor-net.nft from script/runner/motor-net.nft,
/etc/sysctl.d/90-motor-net.conf, and a motor-net-rules.service that loads
the table at boot. It changes no other table. The rules name the host's uplink
interface and the job address range literally; edit motor-net.nft for your
host before applying.
Then add --job-network veth to the instance's serve line. At startup the
runner checks the table and prerequisites; when they fail it keeps running but
refuses network requests with network_unsupported. Prove the isolation with
the operator tests named in job-isolation.md.
script/runner/prove-job-network.py deliberately weakens the rules to show the
network tests can fail. Run it only on dedicated test infrastructure.
To remove networking, drop --job-network veth from the unit and redeploy;
then stop motor-net-rules.service and delete the files the installer wrote.
bash script/runner/install-review-reaper.shIt installs a root-owned timer that deletes checkouts under
/srv/build/review idle for six hours. Remove it with
sudo systemctl disable --now motor-review-reaper.timer and delete
/usr/local/libexec/motor-review-reaper.py,
/etc/systemd/system/motor-review-reaper.service and
/etc/systemd/system/motor-review-reaper.timer.
Install ck-motor as described in module.md: build it, place it
as a reserved module, enroll its signing key in the vault, add the public key
to the operator account's authorized_keys, and define the alfonso-runner
SSH alias.
systemctl --user is-active motor-runner.service
journalctl --user -u motor-runner.service -n 50 # "motor-runner serving" with its versionThen run a job from the Mac, for example ck motor run -- uname -a (with
admit_direct_exec enabled; save its one-time secret for
ck motor attach JOB_ID), and the server gate from a
checkout on the server.
- Drain:
motor-runner drain --instance main --exit-after-drain false. systemctl --user disable --now motor-runner.serviceand remove the unit files from~/.config/systemd/user/.- Remove the binaries from
~/.cargo/bin/and the state root. - Remove optional components as described in their sections.
setup.sh's host changes (packages, firewall, SSH policy, /Users bind mount)
are not reversed by any script.