Skip to content

[WIP] Add Ignition provisioning for image-based hosts - #823

Closed
Philip Lombardi (plombardi89) wants to merge 46 commits into
acl-extract/immutable-host-pathsfrom
acl-extract/ignition-acl
Closed

Philip Lombardi (plombardi89) wants to merge 46 commits into
acl-extract/immutable-host-pathsfrom
acl-extract/ignition-acl

Conversation

@plombardi89

Copy link
Copy Markdown
Collaborator

Status: WIP. Do not merge. Stacked on #815, which is stacked on #742.

This PR targets acl-extract/immutable-host-paths (#815), not main. It needs
the installation prefix that #815 introduces and the ownership record from #742,
so it cannot build against main until both land. Basing it on the branch keeps
this diff to its own commits rather than re-proposing theirs.

It will be rebased onto main and retargeted once the stack merges.

Why

Azure Container Linux ships no cloud-init. The binary is absent, the services
report not-found, and /var/lib/cloud does not exist, so a cloud-init payload
passed as user data is never acted on and nothing reports an error. Ignition is
the only provisioning mechanism such an image consumes.

Ignition also runs from the initramfs, so everything it writes is in place
before any service starts.

What is here

An Ignition config encoder. Spec 3.4.0, hand-written rather than pulled from
github.com/coreos/ignition, which would carry the whole specification for the
handful of fields used here.

--variant ignition. Writes the agent config, fetches the agent binary to
its final location under the configured prefix, and installs a oneshot unit that
bootstraps on boot.

Reset removes the first-boot unit, so a reset host does not bootstrap itself
again on the next boot.

The record is written only when a repair changed something, because the unit
below reaches that path on every boot.

Decisions worth reviewing

Everything this variant needs is required, not defaulted. Ignition declares
state: it cannot resolve a version, detect an architecture, or extract an archive
at boot. --agent-url must name the bare binary, --agent-sha256 is required
because an unattended host that accepts whatever a URL returns is worse than a
bootstrap that refuses to render, and --host-prefix is required because
Ignition places the binary itself and the default /usr/local is read-only on
exactly the hosts this serves. All three are refused at render time, where the
message reaches a person rather than a machine with no shell.

The unit carries no completion condition, so it runs on every boot. A
condition needs a marker file, and a marker is a second record of completion that
can disagree with the ownership record the agent already keeps. Both commands the
unit runs return immediately once that record says the installation is complete:
preflight reports an empty result and start verifies the daemon, repairing it
only if it is not running. Neither resolves artifacts nor touches the network.
The cost is two short-lived processes per boot; the benefit is that a node whose
daemon was stopped or damaged comes back on reboot.

Two unit settings come from failures seen on hardware, not reasoned about.
network-online.target means a link is configured, not that DNS resolves: a unit
can start in the same second that target is reached, while systemd-resolved is
still coming up, and fail on an unresolved host. And bootstrap has no later
opportunity to run, so StartLimitIntervalSec=0 keeps a burst of early failures
from permanently disabling it.

The unit name lives in goalstates because the command that writes it and
the reset that removes it are in packages that cannot import each other. A name
that drifted between them would leave the unit enabled on a host that had just
been reset.

Compatibility

Additive. The script and cloud-init variants are untouched, and the new flags are
only required by the new variant. Hosts that set no prefix resolve exactly the
paths they did before.

Not in this change

No UKI/OVMF disposable-overlay harness, no ACL boot or same-disk reinstall
coverage. Those are a test-infrastructure body of work and belong on their own.
The system extension that supplies systemd-nspawn is also not here: Azure
Container Linux is expected to ship systemd-container usable on the first boot,
which removes the need for it.

pkg/agent was extracted in #76 so external consumers could reuse the agent
as a library, and that same change deliberately moved host-lifecycle
operations such as EnableDaemon, StopDaemon and PersistAppliedConfig back
out of the public tree into cmd/agent/internal/daemon.

installstate and bootstrap belong on that side of the line. They manage
this agent's own record and lock at its own paths, and every consumer is
already under cmd/agent. Keeping them private also avoids publishing an
on-disk schema and admission policy that is about to change: Record
validation currently accepts only the default install prefix, which the
configurable-prefix work alters.

Promoting an internal package later is not a breaking change, so this can
be revisited if an external consumer needs resumable bootstrap.
A reviewer had to ask what this package is for, so document it: the record
and lock answer whether an installation exists and is ours, how far it got,
and whether anything else is mutating the host.

Now that nothing outside cmd/agent can reach it, drop the accumulated
surface. Checkpoint.NodeMayBeRunning was dead once bind-address preflight
became unconditionally ownership-aware. StatePath, AcquireLockAt and
DefaultLockPath had no callers outside the package. Decide is folded into
Admit so admission has a single entry point; the fixture test now admits
through a store, which also proves the record survives a load round-trip.
Sweeping the exported symbols this branch adds turned up several with no
caller outside their own package, including two in the public pkg/ tree.

CheckOwnedBindAddress was exported so the command layer could substitute
checkers by name. That substitution is gone now that nodestart.Preflight is
unconditionally ownership-aware, leaving Preflight as the only caller.
CheckBindAddress became unreachable at the same time, so remove it and
rename the three tests that were named after it; they exercise the checker
type directly rather than the constructor.

daemon.ResetAgentResources had no production caller either: the node
operator calls resetUnderLock directly because the MachineOperation already
holds the lock. Only a test still referenced it, so drop it and have that
test compose resetResources itself. The identically named nodeOperator
interface method is unaffected.

Also unexport fsutil.WriteFile, which only WriteFileDurable uses, and the
installstate schemaVersion and defaultHostPrefix constants. The exported
Record.SchemaVersion field stays because the JSON format needs it.
Two problems with what a retry is allowed to change and what it records.

An HTTPS rootfs reference can carry an expiring signed query, so hashing it
whole made a refreshed signature look like a different installation and
stranded the retry. Hash the scheme, host and path instead, which is what
actually selects the artifact; the reference is also trailing-slash
normalized to match how it gets resolved before being fetched.

The applied config was persisted in the daemon stage, so a retry that
resumed there recorded the new attempt's configuration even though the
node had already been started from the old one. Drift is measured against
that file, so the node kept the superseded labels, taints and kubelet
configuration with nothing left to reconcile them. Persist it in the stage
that starts the node, after kubelet bootstraps, and sync the directory
holding it before that stage is checkpointed.

The stage composition is now asserted by name so the placement cannot
regress silently, and the recovery e2e retries with a changed label and
requires the recorded config to stay untouched.
Three places reported success while leaving the host in a state that
contradicts the report.

Dropping the ownership record is itself a durable step. The record was
unlinked and the error from the directory sync returned, so a failure left
the removal in page cache only: reset told the operator it failed, while
the next start was admitted as a fresh install onto a host that was only
partially torn down. Restore the record when the removal cannot be made
durable, so a failed reset always leaves the host visibly owned.

The bootstrap binary was considered present whenever anything existed at
its path, including a dangling symlink or one aimed at a non-executable.
That is precisely the state that sends a completed install into repair,
and repair cannot replace a bad link either, so the host was stranded.
Resolve the link and require a regular executable.

Ignoring every machinectl disable failure could leave the nspawn unit
enabled. The enablement symlink outlives the config and rootfs that reset
deletes, so the host would try to start a machine that no longer exists on
the next boot. A failed disable is now tolerated only when systemd
positively reports a state that cannot start the unit, and an inspection
that does not answer counts as unconfirmed.
The record carried a host prefix that nothing in this change reads. It was
written on every install and then rejected by validation unless it held the
one value this release can produce, purely to reserve the field for the
configurable-prefix work that follows. Recording something we refuse is a
confusing contract to ship, and the follow-up does not need it reserved:
an absent prefix already means the default, so a record written here reads
correctly once the field exists.

Validation still refuses a record whose schema version it does not know,
which is the same protection by a mechanism that is already there.

Nothing else changes. The fingerprint never covered the prefix, so
installation identity is unaffected and the compatibility fixtures keep
their recorded value.
The installer stopped writing /usr/local/bin/unbounded-agent and delegated
it to InstallBootstrapBinary, which this same change introduces inside
start. That coupled two components that are versioned independently. The
agent version comes from AGENT_VERSION, from AGENT_URL, or from the default
of tracking the latest published release, while the installer is embedded in
and served by metalman. Any agent older than this change therefore never
writes the binary, and bootstrap fails at enable-daemon with "no executable
agent binary found for daemon link initialization", naming neither the
installer nor the version skew.

That is not a corner case. A Machine that sets no agent URL or version, the
documented default, downloads the latest release, so every existing user
breaks the moment a metalman carrying this installer is deployed, and stays
broken until a release containing it exists. The metalman layered HTTP smoke
test has been failing on this branch since its first commit for exactly this
reason: it supplies its own cloud-init user data without the install
environment, so it exercises the released agent.

Seed the binary again, but only when the path holds nothing usable. The test
follows symlinks, so a host this installation already owns resolves through
the compatibility symlink to a live blue-green slot and is skipped, which
keeps the property the original change wanted: admission still runs from the
staged executable and a retry cannot overwrite a live link before its intent
has been accepted. A dangling link resolves to nothing and is replaced,
matching how the daemon decides whether an existing binary is usable, since
install would otherwise write through it to a stale location.

The uninstall script already removed this path, so the two are symmetric
again. The existing script test only asserted that the download overrides
are honored, which is why the install could disappear unnoticed; it now
pins both the install and the guard.
Review asked why the status reporter is optional and created on demand
rather than held from construction. It cannot move: the reporter captures
credentials when it is built. An empty bootstrap token makes it a permanent
no-op, and it registers the Machine over the API as part of construction. On
an attested host the token does not exist until ApplyAttestation has run, so
building it in the constructor would silently disable status reporting for
the whole bootstrap and issue the registration call before admission.

Say so at the construction site so the next reader does not have to work it
out.

The nil checks around the reporting calls were doing nothing, though. The
reporter already reports through a nil-receiver check, so the two guarded
call sites now call it directly. That property is what the callers depend on
now, so it is pinned by a test rather than left incidental.

The coordinator keeps its checks: its reporter is an interface, which cannot
absorb a call when nil, and its tests supply nil deliberately.
Review suggested using a maintained library rather than a hand-rolled
flock wrapper. It is a good trade here: the module is already in the
dependency graph, pulled in indirectly by the OCI SDK, so this promotes an
existing entry to a direct requirement rather than adding one. go.sum is
unchanged.

The swap preserves the behavior the callers depend on. The library opens the
lock file with the same 0600 mode, takes the same non-blocking exclusive
flock, and documents that closing does not remove the file, which is the
contract reset relies on: a leftover lock file must never be read as a held
lock. TryLock reports contention as a false return rather than EWOULDBLOCK,
so that is mapped onto the existing sentinel error, and the parent directory
is still created here because the library does not do it.

Behavior gained: the library reopens and retries on a stale file handle,
which the previous implementation treated as a hard failure.

NOTICE is regenerated, and gains a BSD 3-Clause entry for the new direct
dependency. That is a licensing surface change riding in this PR rather than
an incidental one, so it is called out here.
ConfigureNFTables starts nftables-flush.service, whose unit applies
`flush ruleset` and erases every nftables rule on the host. That is the
point on a fresh host, and it is safe at boot because the unit is ordered
before the nspawn machine and unbounded-localdns-network.service re-adds
its NOTRACK table after it.

Starting it imperatively mid-run is a different thing. The nspawn container
shares the host network namespace, so a running node's kube-proxy and CNI
rules are in the ruleset being erased, along with LocalDNS's table. Nothing
puts LocalDNS back: systemd ordering only sequences units within a single
transaction, so starting this unit alone does not pull in the LocalDNS unit,
and that unit otherwise runs only when the machine starts. kube-proxy
resyncs on its own; LocalDNS does not, so it stays broken until the machine
restarts.

Only start the unit when no machine is registered. The flush exists to hand
a clean slate to a node that has not started yet, so once one is registered
it has already served its purpose. Installing and enabling the unit is
unchanged, so the next boot still gets its clean slate in the right order.

Inspection failure is not read as "nothing registered", since that would
flush a ruleset a running node may depend on.

This makes the task safe to re-run against any host state, which is a
prerequisite for a retry that reapplies work rather than trusting a record
of what was already done.
Reapplying node configuration writes files that a running service has
already read. When this sequence boots the machine that is harmless, because
the services start afterwards and read the new files. When it runs against a
machine that is already up it is not: the files on disk and the running
services would disagree with nothing to reconcile them, which is worse than
not reapplying at all.

Track whether each configuration file actually differed, and restart the
service that reads it only when something did and the machine was already
running. An identical reapply, which is the ordinary case when bootstrap is
rerun after a failure, leaves the node completely alone.

WriteFileIfChanged reports whether it had to write. Only content is
compared: WriteFile preserves an existing file's permissions rather than
resetting them, so a drifted mode cannot be corrected there, and reporting
it as a change would restart the reader on every call forever.

containerd restarts before kubelet, since kubelet talks to it and would
otherwise just retry against a runtime that is coming back up.

The exported ConfigureContainerd and ConfigureKubelet keep their signatures.
StartNode builds the tasks concretely instead, because it is the only place
that knows both whether the configuration changed and whether the machine
was already running.

This makes the node-start sequence safe to reapply against a live node,
which is a prerequisite for a retry that reapplies work rather than trusting
a record of what was already done.
ProvisionOwned rebuilds a rootfs in place and must never be pointed at a
slot that has started a node; its own doc says so, because doing that pulls
the filesystem out from under a running one. This stage guarded that by
refusing outright when a machine was registered.

Refusing is the wrong answer to the question being asked. A registered
machine means the rootfs this stage would build is already built and in use,
so the requirement is met and there is nothing to do. Skip it and continue.

The condition is a property of the host rather than of how far a previous
attempt got, so it holds whether the machine was started by an earlier
attempt of this installation or independently afterwards.

This makes the stage safe to reapply against any host state, which is a
prerequisite for a retry that reapplies work rather than trusting a record
of what was already done.
The record tracked which stage bootstrap had completed, and a retry resumed
from there. That made it a claim about the host: "host preparation is
finished" can stop being true without anyone noticing, and a retry that
trusted it skipped work the host no longer had. The failure surfaced later,
at the first stage that needed the missing thing, naming the symptom rather
than the cause.

Reapply every stage instead. Each already decides what to do by looking at
the host: host preparation leaves a live nftables ruleset alone, the rootfs
is left in place when a machine is registered from it, an already running
machine is not restarted, and node services are restarted only when their
configuration actually changed. A host that drifted between attempts is now
repaired rather than skipped past.

What is left worth persisting is which mode we are in, not how far we got.
Installing says only that an installation is under way, so there is nothing
in it that can go stale. Complete routes a later start to verify and repair
from the applied config, which matters because after an ordinary repave the
bootstrap inputs describe a retired slot. Resetting is the one thing the
host cannot be asked: a half-removed installation and a half-built one look
identical, because direction of travel is not observable.

Stage names survive as a label for status reporting and logs, but are not
written down. Recording the stage is precisely what let the record disagree
with the host; reporting it costs nothing and keeps the Machine condition
specific.

That reporting also had a gap. wait-for-kubelet-bootstrap is its own task
inside the node-start stage, and the classifier only matched start-kubelet,
so the most common real failure - a rejected token, an unreachable API
server, a CA mismatch - was reported as a generic failure. Main reports
KubeletBootstrapFailed for it, so this restores parity.

The record format changes and the fixtures change with it. Nothing has
shipped, so no migration is owed.
The recovery scenario asserted the record had reached a named stage before
the retry, and reached complete after. The record no longer says how far an
attempt got, because that was a claim about the host rather than a fact
about it.

Assert what the host shows instead. The failure landing late is proved by
the node being up, and the retry converging around it is proved by the
nspawn PID being unchanged, the installation ID being the same, and the
applied config staying byte-identical while the retry carries a changed
label. That is the same evidence as before, taken from the thing it is
actually about.
The guide said completed stages are skipped and unfinished ones replayed,
which described the record as the source of truth for what had been done.
Every stage now runs on every attempt and decides from the host what it
still has to do, so say that, and say what each stage looks at: packages
already present, a rootfs a machine is registered from, a running machine,
configuration that did not change, a live nftables ruleset.

That is also the answer to the question this section invited, which is what
happens when the host changed between attempts. It is repaired rather than
skipped past, because nothing is assumed from how far a previous attempt
got.
…de up

Every stage reapplies now, so the node stage runs again even when the node is
already running, and it was rewriting the applied config on the way through.
The old code could not do this: a retry resumed past the node stage, so the
comment justifying the write said it was unreachable on a retry. That is no
longer true, and the write is wrong.

The applied config records what the running node was built from, and the
daemon diffs it against the desired config to decide whether to repave. An
attempt that finds a machine already registered did not build that node and
has nothing to say about how it was built.

The case that shows the harm is a changed node label. Labels sit outside the
installation fingerprint, so a retry carrying a new one is admitted, but
kubelet takes --node-labels at registration and a restart under an existing
node does not revise them. Recording the new label would make applied match
desired, which reads as no drift, which suppresses the repave that is the
only thing that would have delivered it. The label would be lost silently.
Leaving the record alone keeps the difference visible and lets the daemon
resolve it.

Gate the write on whether a machine was registered when the stage began,
asked before the stage runs because afterwards the answer is always yes.
PrepareRootFS already asked the same question, so both now share one helper.
Repave is unaffected; it persists explicitly, outside this composition.

Also sweep the vocabulary the phase change left behind. The installstate
package doc still offered "how far did the last attempt get" as a question it
answers, which is now precisely the question it refuses to answer.
Four small things, no behavior change.

The kubelet and containerd tasks had grown identical change-tracking: same
field, same comment, and a byte-identical write method. They share an embedded
changeTracker now.

Both the nftables task and bootstrap had grown their own loop over the two node
slots calling RegisteredMachine, which inventories machinectl on every call, so
each scan spawned two processes and a bootstrap attempt six. reset now offers
FirstRegisteredMachine, which inventories once and returns the occupied slot,
and both callers use it.

restartReconfigured reached its three collaborators three different ways: it
held the two configure tasks and read a field off each, but took a *bool aimed
at a local in StartNode. It holds the start task now and reads wasRunning the
same way, which drops the pointer, the local, and four nil checks no caller
could reach.

The coordinator converted its stage labels to string to build the loop and back
to Stage twice to report them. The field is typed.

Two of these turned out to be untested rather than merely undertested, which is
why they are here rather than left alone. Nothing failed when
FirstRegisteredMachine was made to never find a slot, and nothing failed when
the machine-was-running observation was hardcoded either way, so the handoff
that decides whether a reapply restarts a reconfigured service was resting on
nothing. Both are now pinned, including that the scan inventories once, which
is the reason it exists.
An earlier commit on this branch swept exported symbols with no caller and
removed this one. The sweep was right that nothing calls it: Preflight is
unconditionally ownership-aware and uses checkOwnedBindAddress. It was wrong
that this made it dead, because the symbol is on main in a pkg/ package, so
removing it breaks anyone outside this repository who composes their own
preflight set.

Restore it with main's signature and behavior. The checker already guards
c.owned != nil, so leaving owned unset gives the original meaning: any
listener fails, including one that does belong to this installation.

Comparing the exported surface of pkg/... against main, this was the only
removal on the branch; everything else there is additive.

The reason the sweep could not see it was that the tests exercised the
checker type directly, so the constructor had no caller of any kind. Test it
through the exported constructor instead, which both documents why it stays
and fails the build if it is removed again.
The daemon refuses to run while an installation owns the host, which is
right: it must not reconfigure a machine a bootstrap is still changing. It
refused by returning an error, which is wrong, because systemd cannot tell
that apart from the binary being broken.

What followed was Restart=always, three starts inside StartLimitBurst, the
unit in failed state, and OnFailure running the last-resort binary rollback
for a problem the binary does not have. Where a previous AgentUpgrade had
left a last-good binary, that rollback silently downgraded the agent.

Two ways in, and the second is the common one. A bootstrap can die between
starting the daemon and marking the record complete. More often, an install
interrupted after the daemon was enabled means every later boot starts the
daemon alongside a retry bootstrap that holds ownership for minutes while it
downloads a rootfs; the daemon gave up after thirty seconds and failed. So
each attempt to recover the host re-armed the rollback, and the rollback ran
while the retry was still writing binaries, since recovery deliberately does
not wait on lifecycle locks.

Exiting zero would not have helped. Restart=always restarts a clean exit too,
and the start limit counts starts rather than failures, so the unit reaches
the same place.

Say it in a way systemd understands instead. Standing down exits 69, and the
unit names that code in SuccessExitStatus and RestartPreventExitStatus, so
the unit goes inactive: not restarted, never approaching the start limit,
never reaching OnFailure. Restart=always still covers real crashes. The
daemon comes back the next time anything runs bootstrap, because both the
install path and the repair path end by starting it.

The wait for a held lock stays at thirty seconds. Only the giving up changed.

Also reset-failed before start. A unit that already exhausted its start limit
cannot be started until the failure is cleared, manual starts included, so
without this a retry could not repair the hosts this bug has already broken.
Reset now asks machinectl and nft what is on the host, and fails when it
cannot ask. That is the right answer for bootstrap and the wrong one for
reset, because the tools it asks with are the ones bootstrap installs.

A bootstrap that dies inside host preparation leaves an ownership record on a
host with no systemd-container and no nftables. Reset cannot finish there, so
the record stays; start is refused against an installation that nothing can
clear. Before this branch those inspections were tolerant and the host stayed
recoverable.

The two callers want opposite answers to the same question, so they get
different functions rather than a shared one with a flag. Admission keeps
failing closed: a host it cannot inspect is not a host it can prove is clean,
and assuming otherwise risks building over a running node. Cleanup treats a
tool that is not installed as proof of absence, because nothing of ours can
be running if the things that run it were never there.

The tolerance is only for a missing executable. A tool that is present and
fails still stops reset, since it may be reporting a machine that really is
there and reset must not delete around it.

The test drives the reset tasks rather than the helper, and its PATH keeps
systemctl and ip while dropping machinectl and nft. Hiding everything would
have been the easier fixture and a meaningless one: systemctl always exists
on a systemd host, and a first version of this test passed while the tasks
still called the strict predicate.
Reset tolerated a missing ownership record and refused any other read error.
decide refuses the same record, so a file that cannot be parsed took away
both exits at once: start would not run, and reset could not clear the thing
stopping it. The guide tells operators to keep this file intact, so the
documented advice leads straight into the trap.

Reset deletes the record moments later regardless. Reading it is a courtesy
that keeps the machine name and fingerprint accurate through teardown, not a
prerequisite for tearing down, so an unreadable one is replaced with the same
synthetic record an absent one already produced, and the reason is logged.

The decision moved into a helper because resetUnderLock syncs real host
filesystems and needs root, so the behavior was otherwise only reachable from
an e2e. Putting the sole store.Load behind that helper also means
reintroducing a direct read leaves it uncalled, which staticcheck reports.
VerifyDaemonInstalled resolves the current binary link and fails when its
target is gone. Link initialization stat'd the link itself, and Lstat
succeeds on a dangling symlink, so it saw a healthy link and left it alone.

That combination made a dangling link the one fault verify could report and
repair could not fix. start on a completed installation verified, repaired
nothing, verified again, and returned the same stat error on every run, with
no path back short of editing the link by hand.

Resolve it instead, which is what the last-good link two lines down already
did. A link that cannot resolve is now replaced the same way a missing one
is, and the repair is checked by resolving it rather than by its name.
The installer stages the agent in a temporary directory and then runs it:
admission runs from the staged binary rather than the installed one, so that
a retry cannot overwrite a live binary link before its intent is accepted.

That made the default temporary directory the wrong place for it. A host that
mounts /tmp noexec cannot execute what was just staged there, and bootstrap
fails before it starts. Hardened, image-based hosts are both the ones most
likely to mount it that way and the ones this work is aimed at.

Stage under /var/lib/unbounded instead, which the agent already owns, and
keep the existing cleanup. Before this branch the staged binary was only
copied, never run, so the placement did not matter.

The mount options of any specific image are unverified; this removes the
dependency on them rather than accommodating a measured one.
…re not true

Three small corrections, all to work added on this branch.

The rootfs and node stages asked whether any slot was registered, to decide
whether to rebuild kube1's rootfs and whether this attempt built the node.
Bootstrap only ever manages gs.NodeStart.MachineName, so a machine in the
other slot answers a question neither stage asked. It is unreachable today
because the second slot cannot exist before an installation completes, which
is why consolidating the two predicates looked free when it was not. They ask
about their own slot now. FirstRegisteredMachine keeps its one caller, the
nftables flush, where either slot occupied is genuinely what matters because
the ruleset is shared across the netns, and its doc says which question it
answers.

A store test fed a record carrying a stale "checkpoint" field, implying the
format rejects fields it does not know. It does not: the decoder ignores
them, and the case passed because the record had no phase. Ignoring unknown
fields is deliberate and load-bearing, since it is what lets a record written
by a newer agent stay readable by an older one, so the misleading case is now
the missing-phase case it always was and a new test pins the tolerance. The
intuitive hardening, DisallowUnknownFields, would take that guarantee away.

Fingerprint claimed omitted optional fields stay omitted across releases. The
fingerprinted struct has no omitempty tags, so they do not. Adding a field
there rehashes every host that lacked it and each reads as a different
installation demanding a reset. The comment now says what is actually
required, and points at the fixture test that enforces it; confirmed by
adding a field and watching that test fail.

Also narrow restartReconfigured's doc, which claimed node services generally.
It covers containerd and kubelet. LocalDNS and the NVIDIA drop-in write
directly and are not tracked, and the reason that is survivable is that a
retry does not rewrite the applied config, so the daemon still sees drift and
repaves.
Standing down for an unfinished installation is reported as a warning and
then returned as an error so the command layer can map it to the exit code
systemd expects. Cobra printed that return as "Error:" and followed it with
the full flag listing, so the journal showed a fault and an apparent misuse
of the command for what is an ordinary state.

That is the confusion this path exists to remove, in the one place the
behavior is observed, so the fix was undone where it counted. Silence the
usage block for the daemon command, which systemd invokes with fixed
arguments and never misuses, and silence the error only for the deferred
sentinel. Real failures are still reported.

Found by reading the journal on a live host rather than from a test; the
exit code, the unit state and the absent rollback were all already correct.

ErrDeferred is exported in place of the IsDeferred predicate so the wrapping
is testable with errors.Is, which is also how it reaches the command layer:
Run adds context before returning it.
An operator who finds the daemon inactive after an interrupted install has no
way to tell that from a broken one, and the obvious reaction, resetting the
host, is the wrong one: the install only needs finishing. Say what the state
means, what to look for, and what to do.

Record the two cases where reset now proceeds rather than failing, since both
look like reset ignoring a problem unless the reason is stated.
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.
Ignition is the only provisioning mechanism Azure Container Linux consumes;
it has no cloud-init, so a cloud-init payload passed as customData is never
acted on and nothing reports an error.

This is the encoding layer on its own, before anything emits a document. The
types are hand-written rather than pulled from github.com/coreos/ignition,
which would bring the whole specification along for the handful of fields
used here.

Three things carry a cost that is only visible on a host that has already
failed to provision, so each is pinned by a test:

The spec version. Ignition refuses a config whose version it does not
implement, on first boot, with no shell and no agent yet installed. There is
nothing there to report the mismatch.

Which schemes Ignition can fetch. This decides whether a file lands before
dbus starts or has to wait for the agent, which is after. oci is the one that
matters, because it is the agent's own artifact scheme and Ignition has no
idea what to do with it.

File modes, which Ignition serializes as decimal. A mode written 600 rather
than 0o600 is 0o1130 on disk, and for the agent config that means credentials
readable by everyone. The test asserts the decimal the emitted document would
actually contain.
Adds --variant ignition, which writes the agent config, fetches the agent
binary to its final location, and installs a oneshot unit that bootstraps on
boot. Everything Ignition writes is in place before any service starts,
because it runs from the initramfs.

Every input this variant needs is required rather than defaulted. Ignition
declares state: it cannot resolve a version, detect an architecture, or
extract an archive at boot, so the artifact has to be named exactly. The
digest is required rather than optional because an unattended host that
silently accepts whatever a URL returns is worse than a bootstrap that
refuses to render. The prefix is required because Ignition places the binary
itself, and the default /usr/local is read-only on exactly the hosts this
variant exists to serve. All three are refused at render time, where the
message reaches a person, rather than on a machine with no shell.

The unit carries no completion condition and so runs on every boot. A
condition needs a marker file, and a marker is a second record of completion
that can disagree with the ownership record the agent already keeps. Both
commands the unit runs return immediately once that record says the
installation is complete: preflight reports an empty result and start
verifies the daemon, repairing it only if it is not running, and neither
resolves artifacts or touches the network. The cost is two short-lived
processes per boot; the benefit is that a node whose daemon was stopped or
damaged comes back on reboot.

Two settings come from failures seen on real hardware rather than reasoned
about. network-online.target means a link is configured, not that DNS
resolves, so the unit retries instead of ordering against a guarantee that
target does not carry. And bootstrap has no later opportunity to run, so
StartLimitIntervalSec=0 keeps a burst of early failures from permanently
disabling it.

The prefix is carried in the agent config, not only in the generated output,
because the daemon and the nspawn lifecycle hooks are started by systemd
later and cannot inherit it from the environment that provisioned the host.
The Ignition unit carries no completion condition and runs on every boot,
deciding there is nothing to do from the agent's ownership record. Reset
deletes that record. A unit left behind would find an uninstalled host on the
next boot and bootstrap it, quietly undoing the reset.

Removal runs before the artifacts are deleted, so a failure stops the reset
while the host is still recognizably installed rather than half torn down
with something that will rebuild it.

Disabling as well as deleting, because the file and the enablement symlink in
multi-user.target.wants are separate: removing only the file leaves systemd
with a dangling want. Absent on every host not provisioned through Ignition,
which is the common case, so a missing unit is success.

The unit name moved to goalstates. The command that writes it and the reset
that removes it live in packages that cannot import each other, and a name
that drifted between them would leave the unit enabled on a host that had
just been reset.

It is a named task rather than a step inside another one so the reset
composition can be asserted. A first version tested the removal in isolation
and passed while nothing called it, which is the failure this arrangement
makes visible.
Re-running start on a completed installation verified the daemon and then
rewrote the record regardless. Harmless when that happened once per manual
rerun. The Ignition unit carries no completion condition and runs on every
boot, so it becomes a durable write per boot on every node, and a write is a
chance to fail: an entirely healthy host would be taking one for no reason.

Only a repair can have changed anything, so only a repair is committed.

Also stop discarding the verify error that triggered the repair. The first
verify says what is broken; the repair failure says only that fixing it did
not work. Reporting the second alone sends an operator after the wrong thing,
so both are now wrapped together.

The tests for this were wrong twice before they were right, both times
passing against code that did the opposite. Comparing the record's contents
cannot see a rewrite, because MarkComplete on an already-complete record
writes identical bytes; the test now compares the inode, which changes on any
write because the store replaces the file atomically. And asserting the
reported error matched the injected one proved nothing while verify and
repair failed with the same error, so they now fail differently.
@plombardi89

Copy link
Copy Markdown
Collaborator Author

Superseded by #815, which now carries these four commits.

The stack these sat in is gone: #742 merged, so #815 targets main directly and no longer needs a branch beneath it. The prefix and the Ignition path were never separately useful anyway - a prefix with nothing consuming it, and an Ignition variant that cannot place files anywhere writable - so they are better reviewed as one change.

Commits carried over unchanged, verified content-identical across the rebase:

  • kubectl-unbounded: add the Ignition config encoder
  • kubectl-unbounded: emit an Ignition bootstrap config
  • agent: remove the first-boot bootstrap unit on reset
  • agent: write the ownership record only when a repair changed something

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