Skip to content

[WIP] Support immutable hosts and configurable installation paths - #815

Draft
Philip Lombardi (plombardi89) wants to merge 4 commits into
acl-extract/bootstrap-recoveryfrom
acl-extract/immutable-host-paths
Draft

Philip Lombardi (plombardi89) wants to merge 4 commits into
acl-extract/bootstrap-recoveryfrom
acl-extract/immutable-host-paths

Conversation

@plombardi89

@plombardi89 Philip Lombardi (plombardi89) commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Status: WIP. Do not merge. Depends on #742.

This PR targets acl-extract/bootstrap-recovery (#742), not main, and that is
deliberate. It needs the installation ownership package #742 introduces, so it
cannot build against main until #742 lands. Basing it on the branch keeps this
diff to its own commit rather than re-proposing #742's.

After #742 merges this PR will look wrong until it is rebased. The repository
squash-merges, so #742 will land as a single commit that does not match the
commits on its branch. GitHub will auto-retarget this PR to main, and the diff
will then re-propose all of #742's changes against a main that already contains
them. The fix is a rebase onto main from #742's final head, not a merge:

git rebase --onto origin/main <final #742 head> acl-extract/immutable-host-paths

Why

The agent writes its own host-side files to hard-coded paths under /usr/local:
the daemon binaries and their blue-green links, the nspawn lifecycle helper, the
daemon recovery script, and the LocalDNS network helper. On a host with a
read-only /usr none of those writes can succeed, so the agent cannot be
installed at all.

What is here so far

AgentConfig.HostPrefix, and goalstates.ResolveHostPaths deriving the agent's
host-side layout from it. Paths inside the nspawn machine are untouched: they are
relative and joined with the machine directory, and conflating the two would
break every host.

The prefix is declared, never inferred. Where the agent may write is a property
of the filesystem, not of the distribution, so keying on distro identity would
misclassify a hardened host with a read-only /usr and would silently relocate
files on any host whose os-release changed. A wrong guess is expensive to recover
from, because the lifecycle helper path is baked as an absolute path into the
nspawn drop-in and the config regeneration unit.

The accepted syntax is narrow on purpose. The prefix is interpolated into
generated systemd units and into a shell script, neither of which quotes it, so
rather than adding two kinds of escaping that every consumer must keep correct,
the value is constrained to be inert in both.

The daemon binaries resolve under it. ResolvedAgentUpgradePathsFor places the
blue-green binaries and their links beneath the prefix; the original entry point
stays, deprecated, delegating to an empty prefix, and every caller in this
repository moved in the same commit because staticcheck reports SA1019 against
our own calls. The AgentUpgrade signal path is deliberately not prefixed: it is
state about an upgrade rather than part of the installed layout.

The ownership record carries the resolved prefix. It is written before the first
host mutation, which makes it the only thing that knows where this installation
put its files after a bootstrap that failed before the node started; the applied
config carries the same value but does not exist until then. The field is
optional and the schema version does not move, so a record written by an agent
that knows about the prefix stays readable by one that does not.

Bootstrap identity includes it, but only when it resolves somewhere other than
the default. Changing the prefix is not a retry of the same installation, so it
must be refused rather than continued; leaving it unset must hash exactly as it
did before the field existed, or every host already installed would demand a
reset on upgrade over a field it never set. Identity follows where the files
land rather than how the prefix was spelled, so naming /usr/local explicitly
and leaving it unset agree.

The remaining consumers are listed below.

Compatibility

Hosts that set no prefix resolve to exactly the paths they had before, pinned by
a regression test against the existing constants. That matters because the
lifecycle helper path is baked as an absolute path into generated systemd units
that are already on disk.

Still to come

  • Propagation through the Machine CRD, the installer script, daemon assets, the
    nspawn lifecycle helper and LocalDNS. The installer still hard-codes
    /usr/local/bin/unbounded-agent as its install target.
  • Teardown and existing-deployment detection sweeping both the configured prefix
    and the default, so changing the prefix cannot orphan files or let a dirty host
    be silently reprovisioned. KnownHostPrefixes and MergeHostPrefixes exist
    for this and have no callers yet.

Until those land the prefix is accepted and validated but not honoured
everywhere, which is why this stays a draft: a host that sets it would still
have some files placed under /usr/local.

Not in this change

No Ignition delivery and no persistent repave redesign. Script and manual
installation must remain a complete supported path on an image that has the
required prerequisites.

The agent writes its own host-side files to hard-coded paths under
/usr/local: the daemon binaries and their blue-green links, the nspawn
lifecycle helper, the daemon recovery script, and the LocalDNS network
helper. On a host with a read-only /usr none of those writes can succeed,
so the agent cannot be installed at all.

Add AgentConfig.HostPrefix and a resolver that derives the host-side layout
from it. Paths inside the nspawn machine are untouched: they are relative
and joined with the machine directory, and conflating the two would break
every host.

The prefix is declared, never inferred. Where the agent may write is a
property of the filesystem, not of the distribution, so keying on distro
identity would misclassify a hardened host with a read-only /usr and would
silently relocate files on any host whose os-release changed. A wrong guess
is expensive to recover from, because the lifecycle helper path is baked as
an absolute path into the nspawn drop-in and the config regeneration unit.

The accepted syntax is narrow on purpose. The prefix is interpolated into
generated systemd units and into a shell script, neither of which quotes it,
so rather than adding two kinds of escaping that every consumer must keep
correct, the value is constrained to be inert in both.

Teardown and existing-deployment detection need to sweep both the configured
prefix and the default, so that changing the prefix cannot orphan files or
let a dirty host be silently reprovisioned; KnownHostPrefixes and
MergeHostPrefixes exist for that and are used by the callers that follow.

Nothing consumes the resolver yet. This is the model and its validation, so
the changes that convert each caller can be read on their own. Hosts that do
not set a prefix resolve to exactly the paths they had before, pinned by a
regression test against the existing constants.
The blue-green agent binaries were absolute constants under /usr/local/bin.
A host whose /usr is read-only cannot hold them there, which is the whole
reason the prefix exists.

ResolvedAgentUpgradePathsFor resolves them under a prefix instead. An empty
prefix selects the default, and a test pins that the result is exactly the
constants this package used before, because those paths are baked into
generated units and into the blue-green symlinks of every host already
installed. If the default drifted, an upgraded agent would look for its
binaries where the host does not have them.

The original entry point stays, deprecated, delegating to an empty prefix. It
is published from pkg/ and callers outside this repository compose their own
phases from it, so removing it would break them at compile time.

Every caller inside the repository moves to the new one in this commit,
because staticcheck's SA1019 is enabled and a split would not lint. All of
them run under systemd or on the host with no config in hand, so they take
the prefix from the applied config, which is what that lookup exists for. On
a host that configures no prefix this resolves the default and nothing
changes.

The AgentUpgrade signal path is deliberately not prefixed: it is state about
an upgrade rather than part of the installed layout, and it already lives
under the agent config directory, which stays writable on such hosts.

One caller passed the function as a value rather than calling it, so a search
for call sites missed it and only the linter found it. It is now wrapped, so
the prefix is read when the command runs rather than when it is constructed.
Teardown has to find the agent's own files. On a host that configures a
prefix they are not under /usr/local, and after a bootstrap that failed
before the node started there is nothing on the host that says where they
are: the applied config carries the prefix but is not written until the node
runs.

The ownership record is written before any mutation, which makes it the only
source that covers that window, so it carries the resolved prefix.

Optional, and the schema version does not move. A record written by an agent
that knows about the prefix stays readable by one that does not, because
unknown fields are ignored, and a default installation writes no field at all
so its record is byte-identical to one written before this existed. A test
pins that, since the value of the compatibility is entirely in the absence.

Resolved rather than configured, so the record names a real directory instead
of an empty string meaning whatever the default happened to be.

NewRecord takes it as a parameter rather than leaving it a field to set
afterwards. Forgetting it would be silent and would only surface at teardown,
on a host whose files are somewhere reset does not look.

Also corrects a comment in the prefix lookup that pointed at this field
before it existed.
The agent's own binaries live under the prefix, so starting with a different
one is not a retry of the same installation. Continuing would leave the first
installation's files where they are and build a second one beside them.
Admission has to refuse and ask for a reset, which is what a changed
fingerprint does.

The delicate half is the other one. Every host already installed was
fingerprinted without this input. If the default contributed a value, all of
them would hash differently under an agent carrying this change, read as a
different installation, and demand an explicit reset on upgrade over a field
they never set. So the prefix enters the hash only when it resolves somewhere
other than the default, and carries omitempty so that at the default it
contributes nothing rather than an empty string.

It is the resolved prefix that counts, not how it was written. Leaving it
unset and naming /usr/local explicitly put the files in the same place, so
they hash alike; telling an operator who wrote down what was already true
that they must reset the host would be a poor trade for the precision.

Verified by mutation, since all three ways to get this wrong are silent and
affect every host in the field rather than the one under test: dropping
omitempty, hashing the default instead of eliding it, and never hashing the
prefix at all each fail a test. The fixtures carry a literal fingerprint,
which is what makes the first two detectable at all.
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