1,100+ tests · rocky 10 · selinux enforcing gossip :7300 raft :7302 api :7304 rocky 10 / systemd 257

Workloads

Four kinds of thing to run, one spec, and an honest class for what must never move.

works today

soli-oned supervises workloads as systemd transient units. Point it at a directory of TOML job files with --workloads-dir and it reconciles: starts what is missing, adopts what is already running, stops what is no longer declared.

Twelve integration tests run against a real systemd on the system bus and fifteen more on the user bus — no mocks — and the architecture's central claim has its own test against the real binary (see below). CI runs all of them, on Rocky 10 and Rocky 9, with skips promoted to failures so a suite that cannot reach a system bus fails rather than passing quietly.

The claim, and its test

Process tree: the agent is a sibling of the workloads, not their parent PID 1 (systemd) has two kinds of child: soli-oned, and one transient unit per workload. The agent reaches the workloads only through the D-Bus API of PID 1, never by having forked them — so killing the agent leaves every workload running. systemd PID 1 soli-oned the agent one-acme-web transient unit one-acme-api transient unit D-Bus kill -9 agent dies still serving still serving systemd restarts the agent; it re-adopts by name, same PIDs
The agent asks PID 1 to start things; it never forks them itself. That is the whole architecture in one picture — and why kill -9 on the agent is a non-event.

The agent does not fork its workloads. It asks PID 1 to. Everything awkward on this page is the price of that one property, so it is tested against the real soli-oned binary rather than a library harness — and with a workload that increments a counter rather than a sleep, because sleep proves presence and a counter proves it is still executing.

1. start the agent; it starts the workload
2. assert the agent is NOT in the workload's parent chain
     ← without this the test could pass for the wrong reason
3. kill -9 the agent, assert it died on signal 9
4. assert the counter keeps advancing
5. assert MainPID is unchanged — not restarted, never interrupted
6. start a replacement agent
7. assert it ADOPTS: same MainPID, same InvocationID

Step 2 is the one people forget. Step 7 checks InvocationID rather than the PID alone: systemd assigns a fresh one to every unit start, so two consecutive runs of the same unit cannot be confused however PIDs are recycled — a class of bug the proxy's PID comparison is exposed to.

Catching why, before systemd forgets

CollectMode=inactive-or-failed frees the unit object as soon as it stops being active. That is not optional — without it a failed unit holds its name forever and the next start fails with UnitExists, which is exactly the state a crash-looping job is in. The cost is that the exit status cannot be read after the fact: by the time a reconcile pass notices the unit is gone, there is nothing left to ask.

So it is captured from the PropertiesChanged stream as it changes. systemd emits that signal with a null property list, which makes the bus include every emits-change property at once — so one signal carries ActiveState, MainPID, ExecMainCode, ExecMainStatus and Result together, with no follow-up calls.

One match rule for the whole agent, on path_namespace, rather than one per unit: at a few hundred workloads that is the difference between a working bus daemon and a wedged one. And the unit's object path is derived from its name rather than looked up, because looking it up races — a workload that fails instantly is gone before GetUnit answers, and those are precisely the crashes worth diagnosing.

The result is a diagnosis rather than a shrug:

web: exited with status 7; retry 1/3 in 1s
api: OOM-killed — the workload exceeded its memory limit; retry 2/3 in 2s
job: quarantined: exited with status 1 — 4 consecutive failures

The OOM line is the one that pays for the machinery. An OOM kill and a kill -9 both arrive as CLD_KILLED with signal 9; only Result=oom-kill separates "raise the memory limit" from "someone killed it", and only one of those recurs deterministically at 3am.

The counter has to outlive the allocation

A restarted job gets a fresh allocation and a fresh unit name. A failure counter living in the allocation therefore resets on every crash, and "3 consecutive failures" could never be reached — a crash loop would spin forever looking healthy. The counter is keyed on the job, and there is a test that drives a job past its budget and asserts it stops being restarted.

Ports are derived, not counted out

A job's port comes from a hash of (job, slot), so it gets the same port every time it is placed. Firewall rules stay valid across a restart, dashboards keep meaning the same thing, and an operator who remembers "billing is on 24107" stays right. Sequential allocation gives none of that and reshuffles on every agent restart.

The range is 20000-29999, matching what the proxy actually uses today — which is what makes a migration cost zero port churn, and that matters when ~36 apps sit behind ACME certificates and firewall rules keyed to those ports. one import takes existing leases verbatim.

The node keeps a veto. The hash says which port should be free; only a real bind says whether it is. Something outside the cluster's model can be holding it, and starting a workload that cannot bind turns a clear allocation error into a confusing health-check failure several seconds later. A port that fails to bind is blacklisted for a day, so the allocator does not pick the same occupied one on every pass forever.

$PORT and $WORKERS are substituted into the command — the same two placeholders the proxy supports, and with no shell involved, so nothing here becomes command injection. One fix along the way: the proxy's naive replace turns $PORTAL into 8080AL; a placeholder only substitutes here when what follows cannot continue an identifier.

a bug this caught

Substituting $PORT means the spec that runs differs from the spec that was declared. Recording the hash of the substituted one made the drift check fire on every pass, so the workload was replaced every two seconds forever — a restart storm that looks exactly like a flapping app. The hash is now taken before substitution, and a test asserts that five consecutive passes leave a healthy workload untouched.

all three exist now

This note listed one run, one exec and podman containers as what was coming. All three shipped, and so did the operator half of the credential delivery described above: a workload names a cluster secret with secrets = ["db-password"], and the agent unseals it onto a tmpfs path that LoadCredential= reads. A control node unseals from its own replica. A worker holds no key that could, and is sent the secrets of the jobs placed on it, sealed to its own node key — a path that carries nothing yet, because workers do not yet run cluster-placed jobs. A file-declared job on a worker that names a secret fails to start with an error naming the secret, rather than coming up without it. Before that, the unit builder could deliver a credential and nothing could put one into the cluster to deliver.

Batch and scheduled jobs

works today

A job that runs to completion, optionally on a calendar. Verified on the Rocky bench.

[[job]]
id       = "nightly-backup"
command  = ["/usr/bin/backup.sh"]
schedule = "*-*-* 03:00:00"
timeout_secs = 3600

A schedule alone makes it a batch job — nobody writes a cron expression for something meant to stay up. The unit becomes Type=oneshot, and that difference is not cosmetic: under Type=exec a process that exits is a workload that died, so a batch job declared as a service looks like a crash to the restart policy on every successful run.

The timeout is not optional. A batch job with none that hangs holds its slot forever and every scheduled run piles up behind it — which is how a nightly job becomes an outage three days later.

The timer and the service it drives are created in one call, with the service riding in StartTransientUnit's aux argument, so there is never a moment where one exists without the other. And the allocation id is derived from the spec rather than drawn at random — unlike a service, where two starts are two different runs. A random id would make every reconcile pass create a new timer beside the old one, and within an hour the node would hold hundreds of timers for one job, all firing.

Secrets

works today

Delivered as systemd credentials: a tmpfs at $CREDENTIALS_DIRECTORY, mode 0400, owned by the workload's user, unmounted when the unit stops.

Never the environment — that leaks through podman inspect and /proc/<pid>/environ, both readable by anything that can see the process. Never baked into the artifact — every backup of that artifact would then contain the secret. systemd does the delivery, which means no cleanup path of ours can leak it.

the failure that is not silent

systemd puts the credentials directory on a ramfs. Where that mount cannot be made — a container without the privilege, notably — the unit still starts, the directory still exists, and it is simply empty. The workload then runs without its secret and reports itself healthy.

So the agent probes it at startup by starting a unit with a credential and checking the credential arrived, and a workload declaring a secret that cannot be delivered is refused rather than started secretless. That probe found two things, both by being run for real rather than in cargo test:

  • The packaged agent could not deliver any credential. The probe left its marker in /tmp; soli-oned.service runs under PrivateTmp=, so the unit PID 1 started wrote to the real /tmp and the agent looked in its own. Every secret was refused, correctly by the probe's own rule, and no unsandboxed test could see it. The marker lives in the agent's data directory now.
  • The bench could not deliver one either, ever. Docker gives a container a private root mount; systemd builds a unit's credentials in a child mount namespace and MS_MOVEs them into place, which only propagates back through a shared mount tree. The directory was always empty, and the test suite — written to accept “unavailable” — took its refusal branch for months and proved nothing. bench.sh makes the root shared, the suite asserts delivery on the bench instead of tolerating its absence, and it runs in a second instead of sixty-six: the difference was thirteen five-second waits for a marker that could never arrive.

The lesson is the one this site keeps relearning: a test that accepts both outcomes is a test that passes while the feature is broken.

Suspend and resume

works today

Freeze and thaw through cgroup.freeze, verified on the bench.

The economics of the product live here. Soli Cloud sells unlimited preview environments, and a Soli deployment is a long-lived process — an idle preview holds its memory twenty-four hours a day. Without a way to reclaim that, either previews get capped or the margin goes.

FreezeStop
Resumemicrosecondsa process start
Process noticesnoit is a new one
Returns memorynoyes

Only Stop is scale-to-zero. Freeze holds every page exactly where it was; it is a latency optimisation for a paid warm tier, and treating it as a way to fit more previews on a node makes a capacity plan wrong by however much the frozen tenants were using.

The frozen state is read back from FreezerState, not assumed from the request. Freezing can fail, and an agent that believes its own request has a workload it thinks is asleep and is not.

Why not reuse the proxy's app config

soli-proxy already supervises processes well — blue/green slots, health-gated promotion, quarantine after three crashes, port allocation verified by a real bind. But its AppConfig is shaped for web apps: domain, health_check, port_range_start. There is no free-form command, no restart policy, no notion of a job that finishes.

So the workload spec is a strict superset of it. That is not aesthetic: if it were not a superset, the roughly thirty apps in production could not be migrated without rewriting each one.

The shape

WorkloadSpec {
  kind:        SoliApp | Process | Container | Batch
  replicas:    Fixed(n) | PerNode | Singleton
  resources:   cpu_millis, memory_mb, disk_mb
  placement:   constraints, affinity, dc/rack spread
  restart:     Always{backoff} | OnFailure{max} | Never
  update:      BlueGreen{health_gate} | Recreate | Never
  health:      Http{path} | Tcp | Exec | None
  ports:       Dynamic | Static(u16) | Host
  mounts:      Artifact{digest} | Tmpfs{secrets} | Pinned{path}
  relocatable: bool
}

Supervision through systemd

The agent does not fork its workloads. It asks systemd to, over D-Bus, as transient units named soli-one-<workload>-<alloc>.service. That buys several things outright:

  • cgroups v2 — MemoryMax, CPUQuota, TasksMax. The per-tenant quotas the PaaS needs are expressible directly.
  • journald — log capture and rotation, with indexed per-allocation lookup.
  • Per-workload hardening — ProtectSystem=strict, PrivateTmp, NoNewPrivileges, a dropped capability set.
  • Workloads outlive the agent. systemd owns the units, so restarting or upgrading soli-oned does not touch running work. On start the agent lists units by prefix and re-adopts them.

That last point is the strongest argument for the whole approach, and it is testable: kill the agent with -9, and the workload keeps serving.

Who a workload runs as

changed: no user is no longer root

A spec that names no user runs as a dynamic user — DynamicUser=yes, a uid systemd allocates for that job alone. It comes with a read-only /usr and /etc, a private /tmp, and no setuid, and two tenants' jobs never share it. user = "root" is the explicit opt-in to root; user = "soli-one" (or any account on the node) keeps a fixed identity. A workload that has been writing into its working directory now has to declare a user that owns it.

A container with no user runs under podman as root, in its own user namespace (--userns=auto), so root inside it is an unprivileged uid on the host. That needs a containers range in /etc/subuid and /etc/subgid, which the package adds when none is present; without one, podman refuses the start rather than running the container as host root.

A spec that comes from the cluster — one run --cluster, placed by the scheduler on whichever node it chooses — is refused if it names host paths: file credentials, log.file, container volumes, or raw container.args. Each of those is a path or a flag on a machine the declaring operator did not pick; declare such a workload in the node's own workloads.d instead.

The class that must not move

soli-sfu holds long-lived UDP media sessions. Blue/green deploying it or migrating it drops calls, and no amount of orchestration cleverness fixes that. So it is expressible directly:

relocatable = false
update      = Never
ports       = [ Host(3478/udp) ]

The scheduler never relocates it on its own. On drain it stops being advertised for new sessions while the existing ones finish — and the refusal is enforced in the execution layer, not only in the scheduler, so one buggy reconcile cannot issue a stop.

Naming, and the bug it prevents

Job ids contain dots (acme.soli.app) and systemd slice names use - as their hierarchy separator. Both matter more than they look.

Under the obvious slug rule — lowercase, map everything outside [a-z0-9] to _ — these three collapse to the same string:

acme.soli.app  ┐
acme-soli-app  ├─>  acme_soli_app
acme_soli_app  ┘

Three tenants, one unit name. Deploying the first stops the second's workload, and nothing in the logs says why. So a hash of the original is appended whenever the transformation lost information, which makes the function injective while keeping simple names typeable:

acme           -> acme
acme.soli.app  -> acme_soli_app_5d2e1f04
acme-prod      -> acme_prod_77c0e9f1

Stripping - is the other half: without it, a tenant named acme-prod would land inside acme's slice subtree and inherit its memory limit.

The cgroup slice tree, and where a tenant quota is enforced one.slice contains one slice per tenant, each containing one slice per job, each containing the workload's transient unit. A MemoryMax set on the tenant slice is enforced by the kernel across every job underneath it. soli-oned itself sits in system.slice, outside the tree. soli-oned.service system.slice never under one.slice or a tenant quota would kill it one.slice one-acme.slice MemoryMax=8G — enforced across every job below one-acme-web.slice one-acme-web-blue-3f9c1a20.service twelve jobs at 512 MiB each are also, together, capped at 8 GiB
The tree is not cosmetic: a limit on the tenant slice is enforced by the kernel, with no accounting code in the agent. It is also why - is stripped from slugs — it is systemd's hierarchy separator, so a tenant called acme-prod would otherwise land inside acme's subtree and inherit its quota.

The slot is in the unit name deliberately. The reconciler keys on (job, slot), so it has to recover the slot from a unit it finds running — and the case that matters is exactly when the local state file is gone, which is when the two halves of a cutover would otherwise be indistinguishable.

Twelve jobs capped at 512 MiB each are also collectively capped by their tenant slice, enforced by the kernel's cgroup hierarchy, with no accounting code in the agent at all.

Six D-Bus traps, written down once

TrapWhat it costs, and the fix
CPUQuota Does not exist on D-Bus. The property is CPUQuotaPerSecUSec, in µs of CPU per wall second. 500 millicores → 500000.
MemoryMax=0 Zero bytes, not unlimited. Unlimited is u64::MAX. An unwrap_or(0) OOM-kills the workload on startup — so in the spec, unlimited is a distinct value and that bug cannot be written.
CollectMode The default keeps a failed unit loaded forever, holding its name, so the next start fails with UnitExists — precisely the state a crash-looping job is in. inactive-or-failed is mandatory.
Restart no. If systemd restarted the unit the agent would never see the crash, and NRestarts would become a second failure counter competing with the agent's.
LoadCredential Type a(ss) — pairs of (id, path). Not the as of "id:path" strings the unit-file syntax uses, which most documentation shows. Sending that form gives ENXIO: No such device or address, an error naming neither the property nor the problem.
Unit on a timer Not settable on a transient timer — systemd answers Cannot set property Unit and fails the whole call. It derives the service from the timer's own name, so the pair is joined by naming and nothing else.

Each of these has a test asserting the D-Bus signature the builder produces. Without them the failure is a systemd error naming neither the property nor the type.

Restart and quarantine

The proxy quarantines after three consecutive unexpected exits, and its counter clears only on an explicit deploy. Two consequences, both real: an app that crashes once a week eventually quarantines itself and nobody can reconstruct why; and systemctl restart soli-proxy silently launders every quarantine, so a crash loop survives an incident review by being invisible.

Here the counter resets after a window of stability, and quarantine persists across an agent restart. Two further distinctions the proxy does not make:

  • A host reboot is never a failure. Otherwise a node rebooted four times during maintenance quarantines every job on it.
  • A failed start job quarantines immediately. With Type=exec it means execve() failed — a missing or unexecutable binary. Retrying three times spends a minute reaching the same answer.

Mobility, stated plainly

ClassMeaningOn node loss
mobileStateless, artifact-backedRescheduled automatically
pinnedHolds a local volumeNot restarted elsewhere. Waits, or an operator restores it
replicatedDoes its own failover (solidb, es)The cluster keeps N instances on N nodes; the app handles the rest

This column is in one ps because it is the single fact that determines what happens when a machine dies. Every system that fudges it burns its operators exactly once.

Using every core for one build

works today, and the number is measured

The question that comes up first: if I run cargo build on the cluster, does it use all 48 cores? No. A build is one process tree, and a process tree lives on one machine. one places processes; it does not make three kernels look like one to a process. The cores one top adds up are real, but no rustc reaches across the gossip bus to use a core on another box.

What does put every core behind a compile is the same thing that works anywhere: make the build many processes, and let something farm them out. For Rust that is sccache-dist — cargo on your laptop hands each rustc invocation to a scheduler, which sends it to a build server, which compiles it inside a bubblewrap sandbox and returns the object. What one contributes is what it is for: placing a build server on every node, and delivering their credentials.

one secret put sccache-scheduler-toml --from-file scheduler.toml
one secret put sccache-server-toml    --from-file server.toml

one run --cluster --name sccache-scheduler --secret sccache-scheduler-toml -- \
    /bin/sh -c 'SCCACHE_NO_DAEMON=1 exec sccache-dist scheduler --config "$CREDENTIALS_DIRECTORY/sccache-scheduler-toml"'
one run --cluster --replicas 3 --name sccache-server --secret sccache-server-toml -- \
    /usr/local/bin/sccache-server

# on the laptop
export RUSTC_WRAPPER=sccache
cargo build --release
sccache --show-stats          # "Successful distributed compiles", per server

The configs travel as one secrets because they hold the tokens that admit clients and servers; they reach each unit as a credential and are never written to a node's disk in the clear. The server's config leaves its own address unfilled and a five-line wrapper fills it in at start, so one secret serves every replica. The build server is the one workload that runs as root with hardening = "none" — it mounts overlays and writes its sandboxes, which a read-only system forbids — and what makes that acceptable is that every compile runs inside bubblewrap with the toolchain read-only and nothing of the host visible: the sandbox is bwrap's, not systemd's. All of it is in packaging/examples/sccache-dist/.

Measured, on the bench: packaging/rocky/distbuild.sh seals the configs, declares both workloads through the agent, and runs a cold cargo build of a crate with twenty-odd dependencies. Every cacheable rustc invocation ran on the build server, none fell back to local. On one node that is one server with eight cores; on three nodes the client reports “48 cores across 3 servers” and the wide parts of the dependency graph fill all of them.

What never distributes, and this is not one's doing: build scripts and proc-macros run where cargo runs; anything compiled with -C incremental is refused — which is the workspace's own crates in the dev profile, so a release build distributes more than a debug one, and CARGO_INCREMENTAL=0 sends the rest; and linking stays local. For this repository's release build, which is one thin-LTO link per binary, the link is the wall clock and no number of servers shortens it.