Skip to content

Support Azure Container Linux: install under /opt/unbounded and Ignition bootstrap - #323

Draft
Philip Lombardi (plombardi89) wants to merge 20 commits into
mainfrom
phlombar/acl-support
Draft

Philip Lombardi (plombardi89) wants to merge 20 commits into
mainfrom
phlombar/acl-support

Conversation

@plombardi89

@plombardi89 Philip Lombardi (plombardi89) commented Sep 21, 2026 •

Copy link
Copy Markdown

Azure Container Linux mounts /usr read-only and is provisioned only by Ignition. This moves AKS Flex Node's host-side files to /opt/unbounded and adds an Ignition bootstrap path so it can run there.

Depends on Azure/unbounded#815. go.mod pins a commit from that PR; it has to become a release before this merges.

Host root

  • The agent's host-side files go under /opt/unbounded on every host: bin/aks-flex-node, the blue/green layout and recovery script under lib/aks-flex-node, and the agent library's helpers. The config, state, logs and units don't move. /opt/unbounded/bin isn't on the default PATH.
  • A host installed by an earlier release keeps its layout under /usr/local. start, the daemon, agent-upgrade, recover-agent-upgrade and the nspawn hooks first link /opt/unbounded to it, so the units and links that release wrote stay valid and the host can go back to it. Reset and uninstall.sh sweep both roots.
  • A hidden host-root command prints the root. install.sh and bootstrap.sh ask the downloaded binary for it and install a release without it under /usr/local/bin as before. An AgentUpgrade to such a release is refused on a host installed under /opt/unbounded.
  • Clients on the host ran the kubelet's exec credential, which names the binary inside the machine. They now run the host's.
  • Reset now also removes LocalDNS state and the nspawn lifecycle helper.

Ignition

aks-flex-node ignition [flags] -- BOOTSTRAP_ARGS renders an Ignition config. It writes bootstrap.sh, with the base config embedded, plus any service principal credential, and enables aks-flex-node-bootstrap.service to run it on first boot. The unit retries until the agent is installed and doesn't run after that. It deletes the token-bearing script once bootstrap succeeds, and reset disables and removes it. The arguments are checked against bootstrap.sh's options before anything is rendered.

Tests

bootstrap_test.sh now runs in make test-install and PR checks, isolated from the host's /usr/local and /var/lib. The Azure e2e upgrades a node installed by v0.2.0 to the build under test, and checks that an AgentUpgrade to a release without host-root is refused. hack/acl is a local harness that boots Azure Container Linux from aks-flex-node ignition output and joins it to kind.

The agent library places its own binaries and helper scripts under a fixed
/usr/local prefix. Azure Container Linux mounts /usr read-only, so nothing
can be installed there and a Flex Node cannot run on it at all.

The version that makes the prefix configurable is not released yet, so this
pins the commit under review upstream rather than a tag. That is temporary
and has to become a release version before this merges.

The same build also reports host package capability truthfully instead of
assuming a package manager exists, which matters here because the image has
none.
Hosts that mount /usr read-only cannot take the agent's own binaries and
helper scripts in the default location. A config field lets them name a
writable one.

The prefix is declared, never inferred. Keying off distribution identity
would misclassify a hardened host whose /usr happens to be read-only, and
would silently relocate files on any host whose os-release changed. A wrong
guess is expensive to recover from, because generated systemd units reference
these paths absolutely.

Validation is delegated to the agent library rather than duplicated. The
prefix is interpolated into generated units and into a shell script, neither
of which quotes it, so the accepted syntax is deliberately narrow; keeping
that rule in one place stops the two projects from disagreeing about what is
accepted.
defaultAgentUpgradePaths hardcoded /usr/local/bin and
/usr/local/lib/aks-flex-node, both under a read-only /usr on Azure Container
Linux, and it is called with no override, so the location could not be
changed.

The prefix is read from this project's own config rather than the agent
library's applied config. That distinction was found the hard way: the
library exposes a lookup for exactly this, but bootstrap here never writes
that file, so on a real host it is absent, the lookup silently returns the
default, and the failure surfaces later as

    initialize agent binary layout: no executable agent binary found

which names neither the prefix nor the path it looked in.

An unreadable or absent config yields the empty prefix, which selects the
default. That is what a host installed before this existed actually has on
disk, so nothing changes for it.

Callers that already hold a config pass it directly; the lookup exists for
entry points started by systemd, which cannot inherit the prefix from the
environment that ran bootstrap.
recoveryScriptPath was a second hardcoded location under /usr/local/lib. On
Azure Container Linux this fails bootstrap after the node has already joined:

    install-service: write /usr/local/lib/aks-flex-node/aks-flex-node-recovery.sh:
    mkdir /usr/local/lib/aks-flex-node: read-only file system

which is a worse failure than refusing up front, because the cluster has a
node in it by then.

The constant stays, because it is also the substitution placeholder inside
the embedded recovery unit. Only the install location becomes prefix
relative; the literal in the unit template has to keep matching the asset.

systemdSystemDir is deliberately not prefix relative. Units have to live where
systemd looks for them, and /etc is writable even when /usr is not.
The prefix work it depends on was consolidated upstream and rebased onto
main after the bootstrap ownership change merged, so the previously pinned
commit no longer exists on any branch.

Still an unreleased commit rather than a tag, and still has to become a
release version before this merges.
The recovery script ships with the default-prefix paths as literals, and
installation rewrites them for the host's layout. The rewrite looked for the
paths resolved from the installed config instead of the literals. On a host
with a custom prefix those are the prefixed paths, which the script does not
contain, so nothing was replaced and the script kept reading last-good from
/usr/local. A failed upgrade could then not be rolled back.

The replacement now keys on the default layout, which is what the embedded
script contains, and a test pins that the asset and the default layout agree.
UninstallService read the host prefix from /etc/aks-flex-node/config.json when
it ran. The AgentReset MachineOperation and the RP delete path both run
ResetNode first, which removes that directory, and only then uninstall the
service. By then the prefix read as the default, so on a host with a custom
prefix the recovery script was left behind.

The prefix is now a parameter. The daemon passes the one from its loaded
config, and the reset command reads the installed config before anything is
removed. The recovery script is removed under that prefix and under the
default, so a host that changed prefix is also cleaned up.

Removal checks for the file first. On a read-only /usr, unlinking a file that
does not exist returns EROFS rather than ENOENT, which would fail the
default-prefix sweep on a file that was never there.
Reset called the library's interface and route cleanups directly and skipped
its LocalDNS cleanup, so every reset of a node with LocalDNS enabled left the
unit, the nft table, the dummy interface and the helper script on the host. It
also never removed the nspawn lifecycle helper.

Reset now runs the library's CleanupNetwork, which includes the LocalDNS
cleanup, and removes the nspawn lifecycle helper. Both helpers live under the
host prefix, so reset takes the prefix and removes them under it and under the
default. The managed agent binaries are still kept.

The e2e RP delete check now also asserts the LocalDNS state, the nspawn helper
and the recovery script are gone. The MSI node enables LocalDNS, so this runs
against a real LocalDNS setup.
Repave cleans up the network with the library's CleanupNetwork, which removes
the LocalDNS helper. Without the prefix it only looks under /usr/local, so on a
host with a custom prefix the old helper stayed until StartNode wrote a new
one. That was harmless because StartNode runs right after, but it now uses the
same prefix as the rest of the node.
…t one

install.sh and uninstall.sh always used /usr/local. The agent resolves its
binaries from agent.hostPrefix, so on a host with a custom prefix the installer
put the binary where the agent does not look. On Azure Container Linux it could
not install at all, because /usr is read-only.

Both scripts now take the prefix from agent.hostPrefix in the node config when
it exists, or from AKS_FLEX_NODE_HOST_PREFIX, which covers the usual case where
the config is written after installation. install.sh fails if the two disagree.

install.sh also treated Azure Container Linux as Azure Linux 3, since it
reports ID=azurelinux with a 3.x VERSION_ID. It is now identified by
VARIANT_ID=azurecontainerlinux, or by ID_LIKE=flatcar for an image that drops
VARIANT_ID. Installing there with the default prefix fails and says which
variable and config field to set.

uninstall.sh reads the prefix before running reset, which removes the config.
It now also removes the managed blue/green layout, which reset keeps and which
uninstall previously left behind, and the recovery unit when it falls back to
removing units directly. It can be sourced now, and uninstall_test.sh covers
the prefix handling and binary removal.
bootstrap.sh always installed the agent in /usr/local/bin, or wherever
--install-dir pointed. The agent resolves its binaries from agent.hostPrefix,
so any other combination left it looking in the wrong place, and on Azure
Container Linux /usr is read-only.

--host-prefix, or AKS_FLEX_NODE_HOST_PREFIX, now sets agent.hostPrefix, and
the binary goes in <prefix>/bin. Without the flag the prefix comes from the
base config and config overrides, and defaults to /usr/local as before. The
prefix is resolved before rendering because fetching bootstrap data runs the
installed binary, so bootstrap data cannot move it; a response that tries is
refused. --install-dir is deprecated and only accepted when it names the same
directory.

The test runs every case as root, so a regression that fell back to the
default prefix would have replaced a real /usr/local/bin/aks-flex-node. It now
runs in a private mount namespace with /usr/local overlaid, fails if any
prefixed case writes there, and works from inside its temp dir so a relative
path cannot land in the checkout.
scripts/bootstrap_test.sh existed but nothing ran it, so neither make test nor
PR checks caught a bootstrap.sh regression. make test-install, which both use,
now runs it after the installer and uninstaller tests.
Azure Container Linux is provisioned only by Ignition and has a read-only /usr,
so bootstrap.sh cannot be downloaded and run by hand at first boot.

aks-flex-node ignition renders an Ignition config that writes the embedded
bootstrap.sh, with the base config in place of its placeholder, and any
service principal credential, and enables aks-flex-node-bootstrap.service to
run the script with the arguments given after --. The agent goes under
/opt/aks-flex-node unless --host-prefix or the base config names another
prefix.

The unit retries with a growing delay until the agent unit exists, and is
skipped after that, since bootstrap.sh refuses to run over an existing
deployment. It removes the script, which carries the base config, once
bootstrap succeeds. The arguments are checked against the script's options,
and a test keeps that list in step with the script, so mistakes surface on the
workstation instead of at first boot.

Reset disables, stops, and removes the unit before stopping the agent, since
in the daemon's reset paths stopping the agent ends the reset. uninstall.sh
does the same when there is no binary to run reset with.
hack/acl/acl.py boots Azure Container Linux under QEMU from the config that
aks-flex-node ignition renders and joins it to a kind cluster, with the AKS
Flex controller deployed in the cluster in place of Azure. It checks that the
first-boot unit installs the agent under the prefix and removes the script
that carries the token, that a workload runs, that a reboot skips the unit,
and that reset, through a MachineOperation or the CLI, removes everything it
should. Each removed path is first checked to exist.

It passes on build 3.0.20260818 with both reset paths, and fails as it should
against an agent whose reset does not stop the first-boot unit.

The networking, VM launch, and UKI patching are adapted from the unbounded
harness; ukiboot.py is copied from it unchanged.
configuration.md did not list agent.hostPrefix. It now says what the prefix
moves and what it does not, that hosts with a read-only /usr need it, and to
reset before changing it.

The getting-started list of what bootstrap.sh does put the agent download after
the bootstrap data fetch, but the binary is installed first, and now under the
host prefix. The bootstrap design says why the Ignition wrapper keys on the
agent unit rather than a completion marker, which reset would leave behind.
@plombardi89 Philip Lombardi (plombardi89) changed the title Support Azure Container Linux hosts with a configurable install prefix Support Azure Container Linux: host install prefix and Ignition bootstrap Sep 24, 2026
The agent library build now includes the fixes from its review: reset takes
the host prefix once, the applied config's checksum is checked before its
prefix is used, and the nspawn machines order after the nftables flush.

hack/acl/ukiboot.py is copied unchanged from the same build. It now requires
exactly one UKI, measures the command line in encoded bytes, and cleans up
after a failed qemu-nbd start.
newRootCommand attaches the package-level token.Command, so building two
roots at once writes its parent from both goroutines. The ignition
registration test ran in parallel with the existing root command test, and
the race detector in PR checks caught it. It now runs serially.
The host prefix is replaced by the host root from the agent library,
/opt/unbounded, on every host. The binary layout, the recovery script and unit,
and the library's helpers are all built from the root resolved through symlinks.

A host installed by an earlier release keeps its layout under /usr/local. start,
the daemon, agent-upgrade, recover-agent-upgrade, and the nspawn hooks first
link /opt/unbounded to it, so the paths the older release wrote into links and
units still match the ones built here. Reset and uninstall.sh sweep both roots
and do not depend on the link.

A hidden host-root command prints the root. install.sh and bootstrap.sh ask the
downloaded binary for it, from a copy under /var/lib/aks-flex-node rather than
a possibly noexec /tmp. A release without the command goes to /usr/local/bin as
before, and where that is read-only the scripts say the release is too old. The
agent library refuses an AgentUpgrade to such a release on a host installed
under /opt/unbounded.

Clients that run on the host used the kubelet's exec credential, which names the
binary inside the machine, /usr/local/bin/aks-flex-node. They now run the one
under the host root.

agent.hostPrefix, bootstrap.sh --host-prefix, AKS_FLEX_NODE_HOST_PREFIX,
ignition --host-prefix, and the Azure Container Linux check in install.sh are
removed. The Azure e2e gains an upgrade from v0.2.0, the agent-upgrade e2e
checks that a release before the host root is refused, and the agent library
pin moves to Azure/unbounded#815 at d01744cd.
@plombardi89 Philip Lombardi (plombardi89) changed the title Support Azure Container Linux: host install prefix and Ignition bootstrap Support Azure Container Linux: install under /opt/unbounded and Ignition bootstrap Sep 25, 2026
go.mod keeps the agent library pin from Azure/unbounded#815 and takes main's Azure SDK and kubelogin updates.
A rejoin took the agent-upgrade path whenever a managed layout existed, but reset removes the service and the config directory and keeps the layout, so the config copy that follows failed. Under /usr/local the layout's directory was not readable by the SSH user, so the check never matched and every rejoin used install.sh. Under /opt/unbounded it matches. The upgrade path is now taken only while the service is installed.
@plombardi89

Copy link
Copy Markdown
Author

The host prefix is gone, to match Azure/unbounded#815. 6bec9c5 replaces it with the fixed host root:

  • Everything installs under /opt/unbounded. agent.hostPrefix, bootstrap.sh --host-prefix, AKS_FLEX_NODE_HOST_PREFIX, ignition --host-prefix, and the Azure Container Linux check in install.sh are removed.
  • A host installed by an earlier release is linked to its /usr/local layout rather than moved, so it can still go back. The Azure e2e now upgrades a node installed by v0.2.0.
  • install.sh and bootstrap.sh ask the downloaded binary where to install, through a new hidden host-root command.
  • Clients on the host now run the host's binary for the exec credential, rather than the path inside the machine, which a fresh host doesn't have.

12bdae6 merges main to clear a go.mod conflict. a8d8b98 makes the e2e rejoin use install.sh after a reset: the check that sent it down the upgrade path could never match under /usr/local, and now it does.

This branch was successfully deployed

1 active deployment
e2e-testing — a8d8b981 Deployed Sep 25, 2026 by plombardi89 via E2E Tests #1171
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