Skip to content

Ship the rollback supervisor unit text as data (#64) - #65

Merged
sehkone merged 5 commits into
mainfrom
sehkone/issue-64
Aug 22, 2026
Merged

Ship the rollback supervisor unit text as data (#64)#65
sehkone merged 5 commits into
mainfrom
sehkone/issue-64

Conversation

@sehkone

@sehkone sehkone commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Adds the roxyd self-update rollback supervisor units to this crate as data, under a new roxyd_selfupdate module. Four unit files ship as assets under assets/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's join embed 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 .previous sibling of the roxyd binary's canonical path, and exactly one ConditionPathExists= 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 .path watch 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 .timer is 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 .previous suffix the units exec is not spelled twice: apply::PREVIOUS_ARTIFACT_SUFFIX is now pub(crate) (no change to the public API) and a test composes the canonical binary path with it and asserts that is exactly what each ExecStart= 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:

constant value why this one
contract directory /var/lib/roxyd/selfupdate/ bootler#203's selfupdate-contract-root decision, whose recommended-and-marked-implement-this option is the namespace-free, roxyd-owned path.
arm record <contract dir>/arm.json bootler#203 item 14 fixes the encoding as JSON.
roxyd binary /opt/roxyd/bin/roxyd, exec'd as /opt/roxyd/bin/roxyd.previous Byte-identity forbids the namespaced /opt/clumit-<product>/bin/roxyd an 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.
decision subcommand selfupdate-decide, with --reason boot|crash|deadline bootler#203 item 12 fixes the three activations and that the reason is passed for the journal line and the status record only; the names are this PR's.

If 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= naming roxyd-selfupdate-crash.service (paired with StartLimitIntervalSec=/StartLimitBurst= values a crash loop can actually reach at the daemon's RestartSec), and Before= naming roxyd-selfupdate-boot.service for 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=StdExternalCrate passes.
  • cargo clippy --all-targets -- -D warnings and cargo clippy --all-targets --features test-support -- -D warnings pass.
  • cargo test and cargo test --features test-support pass — 439 tests, 8 of them new in roxyd_selfupdate.
  • The unit text is exported by the crate: units() returns all four name/text pairs, and each has its own accessor.
  • Every shipped unit parses as a systemd unit — a hand-rolled parser asserts section/key shape only, never semantics — and each opens [Unit] and carries the [Service] or [Timer] section its file-name suffix implies.
  • Each activation's .previous ExecStart= line and its arm-record ConditionPathExists= line are pinned verbatim, each appearing exactly once, and each service is Type=oneshot.
  • Each activation's exec'd binary is the canonical roxyd path composed with the apply path's own .previous suffix, so the suffix stays one decision.
  • The timer triggers the deadline service, fires on a calendar schedule, and carries no arm-record gate of its own.
  • Only the boot activation and the timer are enabled; the crash and deadline activations are triggered and carry no [Install].
  • No shipped unit names a product-namespaced path or a template specifier, so the text is byte-identical across both host populations.
  • units() membership is pinned against the four constants and the four accessors, and every file name and every text is distinct.
  • Rustdoc on the module names both consumers — bootler and roxyd's join — and the single-owner rule.
  • No existing API changed: the module and its items are new, and PREVIOUS_ARTIFACT_SUFFIX widened only to pub(crate). Consumers gain the text by advancing their pin.

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
@sehkone sehkone changed the title Ship the rollback supervisor unit text as data Ship the rollback supervisor unit text as data (#64) Aug 22, 2026
@sehkone

sehkone commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

[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 units() (src/roxyd_selfupdate.rs:83-185). The module rustdoc explicitly identifies bootler and roxyd join as the consumers and records the single-owner rule (src/roxyd_selfupdate.rs:3-18), while the assets put the arm-record gate and the .previous decider invocation in every activation service.

The tests are meaningful for this API: they pin each exact activation line and gate (src/roxyd_selfupdate.rs:316-362), ensure units() cannot silently omit a file (src/roxyd_selfupdate.rs:442-465), and keep the deadline timer triggering the service rather than being prematurely gated (src/roxyd_selfupdate.rs:385-403). The PR body also has the expected Closes #64 linkage and test plan; there are no review-thread comments or newly created issues to flag.

@sehkone

sehkone commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

[Review Verdict Round 1: APPROVED]

@sehkone

sehkone commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Suggested squash commit

Title

Ship the rollback supervisor unit text as data

Body

Two consumers install the same roxyd self-update rollback supervisor:
bootler, onto the hosts it provisions over SSH, and roxyd's own `join`,
onto the hosts it onboards itself, where bootler never runs. The two
host populations must roll back under identical rules, so the text has
to be byte-identical in both — and roxyd cannot depend on bootler, so
this crate is the only home both can read.

Byte-identity is what makes shipping it as data rather than as a
renderer possible: 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 every one is gated on
`ConditionPathExists=` naming the arm record. A consumer substitutes
nothing, so there is deliberately no templating here; parameterization,
should it ever be needed, is a decision to be taken then rather than a
hook left open now.

The timer carries no arm-record gate of its own, and that asymmetry is
deliberate. A condition on a `.timer` is evaluated when the timer
starts, which is at boot, so a record written hours later by a
self-update would find the timer already skipped — and the one
activation that catches a build which runs but never reconnects would
never fire. The gate sits on the service the timer triggers, where
every pass re-reads it.

The `.previous` suffix is not spelled twice. `PREVIOUS_ARTIFACT_SUFFIX`
widens to `pub(crate)` and a test composes the canonical binary path
with it, so changing the suffix on the apply path without changing the
units fails here rather than on a host.

The subcommand name, the binary path and the arm record path are
contract constants frozen against bootler's exported contract module.
That module does not exist yet, so this text pins the concrete values
first and the installer side is written against them; the checked-in
contract document is the tie-breaker should the two ever differ.

No existing API changes — consumers gain the text by advancing their
pin.

Closes #64

@sehkone
sehkone merged commit e925178 into main Aug 22, 2026
4 checks passed
@sehkone
sehkone deleted the sehkone/issue-64 branch August 22, 2026 07:57
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.

Ship the rollback supervisor unit text as data

1 participant