Ship the rollback supervisor unit text as data (#64) - #65
Conversation
Two consumers must install the same self-update rollback supervisor: the installer, onto hosts it provisions over SSH, and roxyd's `join`, onto hosts the installer never reaches. The text has to be identical in both — two host populations rolling back under different rules is a difference nothing on either host reports — and roxyd cannot depend on the installer, so this crate is the only place both can read it from. It is shippable as flat data because the units name no host-varying value: every activation execs the decision subcommand from the `.previous` sibling of the roxyd binary's canonical path and gates on the arm record at its canonical path. So there is no renderer here and nothing for a consumer to substitute. The daemon unit's `OnFailure=` and `Before=` edges stay with the consumer, because only that side knows the daemon unit's name — it is product-namespaced on an installer-provisioned host and is not on a join-onboarded one. Naming it here is exactly what would make the text host-varying. Closes #64
The rustdoc said this text spells the same values the installer's contract module holds. That module does not exist yet — bootler#203 is open — so this crate is the first mover on the binary path and the subcommand name, and a reader had no way to tell a pinned value from a confirmed one. Which matters most for the binary path, because it is an obligation rather than a name: an installer-provisioned host runs roxyd from a namespaced path today, and installing these units alongside a binary that is not at the canonical one ships a supervisor whose every activation fails to exec. Nothing here catches that — the gate names the arm record, not the binary — so it is stated where a consumer will read it. Also note that the decider must be safe under *concurrent* invocation and not merely repeated invocation: the three activations are separately named units, so systemd will not serialize them and a timer pass can overlap the boot activation on a host that has just come up. The new test pins what units() returns. Without it an entry dropped from that array failed no test, since every other test either iterates whatever it happens to return or reaches an accessor directly. Part of #64
systemd reads a unit's type off the file-name extension rather than the section the file carries, so a `.service` name on the timer text — or the reverse — installs a file systemd loads as the wrong type or ignores outright, and nothing on the host says why the deadline never fires. The parse test already picked the expected body section from the name; it now asserts the pairing in both directions. Part of #64
The shipped supervisor units exec `/opt/roxyd/bin/roxyd.previous`, and the sibling they name is the one this crate's own apply path copies aside before a swap. Nothing held the two together: the suffix could be changed on the apply side alone and every installed unit would exec a path that is never written, with nothing on the host to say why. Part of #64
The single-owner rule binds two named consumers, and one of them was identified only by role. A reader deciding whether it applies to them should not have to infer which repository "the installer" is, so name it as the sibling modules already do. Part of #64
|
[Reviewer Round 1] No findings. The change keeps the supervisor text as four static, unparameterized assets and makes the installation contract usable through both the individual accessors and The tests are meaningful for this API: they pin each exact activation line and gate ( |
|
[Review Verdict Round 1: APPROVED] |
Suggested squash commitTitle Body |
Adds the roxyd self-update rollback supervisor units to this crate as data, under a new
roxyd_selfupdatemodule. Four unit files ship as assets underassets/units/and are exposed verbatim through public accessors: the boot, crash and deadline activation services and the timer that drives the deadline one. There is no renderer and nothing for a consumer to substitute — the installer and roxyd'sjoinembed the same bytes from their pinned dependency, so the two host populations cannot roll back under different rules. The module rustdoc names both consumers and states the single-owner rule: a consumer embeds these bytes rather than carrying a copy of its own.Closes #64
What is in the text
Each activation service carries exactly one
ExecStart=invoking the decision subcommand from the.previoussibling of the roxyd binary's canonical path, and exactly oneConditionPathExists=gate naming the arm record. The deadline timer fires on a schedule of its own, so the deadline is reached without any file changing — the failure mode a.pathwatch cannot see is a new binary that starts cleanly, stays up, and never reconnects.The timer deliberately carries no
ConditionPathExists=gate. A condition on a.timeris evaluated when the timer starts, which is at boot: an arm record written hours later by a self-update would find the timer already skipped, and the one activation that catches runs-but-disconnected would never fire. The gate sits on the service the timer triggers, where every pass re-reads it, so an unarmed host spends nothing beyond a condition check. A test pins that asymmetry with the reason.The
.previoussuffix the units exec is not spelled twice:apply::PREVIOUS_ARTIFACT_SUFFIXis nowpub(crate)(no change to the public API) and a test composes the canonical binary path with it and asserts that is exactly what eachExecStart=execs, so changing the suffix on the apply path without changing the units fails here rather than on a host.Constants this PR had to pick
The issue names three frozen contract constants and says bootler's exported contract module (bootler#203 item 14) is where they live and that a checked-in contract document is the tie-breaker. That module does not exist yet — bootler#203 is open — so this PR is the first mover on the concrete values and states them here so the bootler side can be written against them rather than guessing:
/var/lib/roxyd/selfupdate/selfupdate-contract-rootdecision, whose recommended-and-marked-implement-this option is the namespace-free, roxyd-owned path.<contract dir>/arm.json/opt/roxyd/bin/roxyd, exec'd as/opt/roxyd/bin/roxyd.previous/opt/clumit-<product>/bin/roxydan installer-provisioned host uses today, and a join-onboarded host has no namespace at all. This is the namespace-free analogue of the same convention, matching the reasoning that chose the contract directory.selfupdate-decide, with--reason boot|crash|deadlineIf a maintainer settles any of these differently, one line of each affected unit changes and the pinning test's constant beside it.
What the consumer still owns
The roxyd daemon unit is the consumer's, and it is the only side that knows its own name — namespaced on an installer-provisioned host, not on a join-onboarded one. So the two edges joining the daemon to the supervisor are expressed there rather than here, which is also what keeps this text free of a host-varying value:
OnFailure=namingroxyd-selfupdate-crash.service(paired withStartLimitIntervalSec=/StartLimitBurst=values a crash loop can actually reach at the daemon'sRestartSec), andBefore=namingroxyd-selfupdate-boot.servicefor the boot ordering. Both are spelled out in the module rustdoc, and both are bootler's work in bootler#203.Test plan
cargo fmt -- --check --config group_imports=StdExternalCratepasses.cargo clippy --all-targets -- -D warningsandcargo clippy --all-targets --features test-support -- -D warningspass.cargo testandcargo test --features test-supportpass — 439 tests, 8 of them new inroxyd_selfupdate.units()returns all four name/text pairs, and each has its own accessor.[Unit]and carries the[Service]or[Timer]section its file-name suffix implies..previousExecStart=line and its arm-recordConditionPathExists=line are pinned verbatim, each appearing exactly once, and each service isType=oneshot..previoussuffix, so the suffix stays one decision.[Install].units()membership is pinned against the four constants and the four accessors, and every file name and every text is distinct.join— and the single-owner rule.PREVIOUS_ARTIFACT_SUFFIXwidened only topub(crate). Consumers gain the text by advancing their pin.