Skip to content

Run the agent as a privileged container on Azure Container Linux - #370

Open
Philip Lombardi (plombardi89) wants to merge 138 commits into
mainfrom
phlombar/acl-container
Open

Philip Lombardi (plombardi89) wants to merge 138 commits into
mainfrom
phlombar/acl-container

Conversation

@plombardi89

@plombardi89 Philip Lombardi (plombardi89) commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Experimental. Azure Container Linux (ACL) support, in containers or on the host, is experimental and not for production nodes. The operator guide, docs/usage/azure-container-linux.md, opens with a warning that says so, and lists what isn't supported yet. The agent's containers needed ACL to ship one SELinux rule, allow spc_t kernel_t:dbus send_msg;, and to label containerd's files, so the agent's containers run as spc_t, where the rule applies (SELinux in DESIGN.md). ACL 3.0.20261026 has both, in a development gallery build, and the harness runs on that build as it ships. The commit "Add an opt-in SELinux workaround for the agent container" and its revert stay in the history as the record. Merging waits until the rule and the labels reach the image AKS will use; see Known gaps.

Summary

This adds a second way to run the AKS Flex Node agent, for Azure Container Linux: in privileged containerd containers of the agent's own image, provisioned by Ignition, rather than installed on the host. main installs the agent on an ACL host, under /opt/unbounded/agent, as an interim. Here, nothing of the agent is installed on the host, and the agent doesn't install host packages, which ACL's read-only /usr doesn't allow.

On ACL, the agent keeps main's config, commands, unit name, MachineOperations, and reset. The new behavior is in how the agent is installed, upgraded, and reaches the host, and it applies only on ACL. Ubuntu hosts behave as on main, apart from the changes in bold under "Changes that apply everywhere" below.

docs/usage/azure-container-linux.md documents it for operators, hack/acl-container/DESIGN.md explains the design, and hack/acl-container/README.md explains how to run it locally.

How ACL behavior is switched on

The agent image for ACL (Dockerfile.aks-flex-node-agent) sets AKS_FLEX_NODE_CONTAINERIZED=true, and daemon.Containerized() (pkg/daemon/container.go) reads it. Nothing else sets it: on Ubuntu, bootstrap.sh installs the release binary, and every switch below takes main's path.

Where On Ubuntu, unchanged On ACL, new
setupHost, in start (pkg/daemon/start.go) Installs packages, then configures the OS Configures the OS only. The package manager on PATH is the image's, and would install into the container.
InstallService, in start (pkg/daemon/lifecycle.go) Installs the unit that runs the binary, with its recovery unit and script Writes units that run the agent's image, then enables and starts them
daemon.Run (pkg/daemon/daemon.go) Converges the binary layout; upgrades by binary Converges units, the binary in the active machine, and images; upgrades by image
RecoverAgentUpgrade (pkg/daemon/agent_upgrade.go) Restores the last good binaries Restores the last good image, and restarts the agent's unit
PreflightChecks, in preflight (pkg/cmd/preflight/preflight.go) Runs every check Leaves out host-packages and containerd-active, which don't apply in a container
Validating an AgentUpgrade (pkg/daemon/agent_upgrade.go, pkg/daemon/agent_upgrade_container.go) Takes an archive's HTTP or HTTPS downloadURL, as on main Takes an image's oci:// downloadURL
CredentialPlugin, for the agent's own Kubernetes clients in start and the daemon, with a managed identity or a service principal and no bootstrap token (pkg/daemon/container.go) The host's agent, config.HostBinaryPath() The image's binary, which is running. The host has no agent.
The nspawn lifecycle units the machines' hooks start, which StartNode writes first and the daemon converges (pkg/daemon/lifecycle.go, pkg/daemon/container.go) Run the binary under /opt/unbounded/agent, with the lifecycle helper linked to it for hooks an earlier release writes Run the phase in a container of the image, with no helper
The host root's steps, in start, the daemon, and nspawn-lifecycle pre-start (pkg/cmd/start/start.go, pkg/daemon/daemon.go, pkg/cmd/nspawnlifecycle/nspawn_lifecycle.go) Migrate, prepare, and populate /opt/unbounded/agent, move it off /usr/local, and remove the binary install scripts left there Skipped: the host keeps nothing of the agent
Direct activation, agent-upgrade (pkg/daemon/host_agent_activation.go) Switches the binary layout Refused: an AgentUpgrade names an image
UninstallService, in reset (pkg/daemon/lifecycle.go) Removes the first-boot unit, aks-flex-node-bootstrap.service, which runs once Keeps it: the unit of that name is the install's bootstrap unit, which joins the node again

The ACL code is in pkg/daemon/container.go and pkg/daemon/agent_upgrade_container.go. The units it installs are in pkg/daemon/assets.

New behavior on ACL

main on Ubuntu ACL
Install bootstrap.sh downloads a release archive, and the agent installs itself under /opt/unbounded/agent Ignition writes a settings file naming the image, bootstrap's inputs, a bootstrap unit, and a reset unit (scripts/aks-flex-node-agent-container.bu). Nothing of the agent is on the host.
Bootstrap bootstrap.sh renders the config, runs preflight, and runs start The bootstrap unit runs the same bootstrap.sh in a container of the image, with --installed-agent, naming the image's agent. Its inputs are AKS_FLEX_NODE_* lines in /etc/aks-flex-node/bootstrap.env, which the Butane config takes, as host mode's does. It retries a failed bootstrap, but not one bootstrap.sh exits with 78 for, as main's first-boot unit.
The agent's unit aks-flex-node-agent.service runs the agent's current binary under /opt/unbounded/agent Same name; it runs the daemon in a container of the image, through ctr run
Machine hooks At every start of a machine, its hooks start the agent's nspawn lifecycle units, which run its binary The same hooks start units that run the phase in a short-lived container of the image the agent's unit runs, which is on the host, so a machine's start pulls nothing
Host access Direct Each container gets the host's root at /host. Its entrypoint mounts the host paths the agent uses at their own paths. The host's systemctl, machinectl, ctr, and other tools run through host-exec: under chroot /host, or, for mounts, in the host's mount namespace.
AgentUpgrade Takes a release archive's downloadURL and sha256, and switches blue/green binaries Takes the same parameters for an image: downloadURL is oci:// and the image's fully qualified repository, with or without a tag, and sha256 is the image's digest, which ctr verifies on pull, as main requires sha256 (#369). A request without the digest, with it in the URL, or naming an archive fails as InvalidParameters. The agent pulls the image, checks that its agent runs, selects it in /etc/aks-flex-node/agent-image.env, and restarts on it. main's handoff judges the result.
Upgrade recovery The recovery unit's script restores the last good binary The recovery unit runs the last good image, which selects itself again and restarts the agent's unit
Daemon start Converges its binary layout and units Rewrites its units from its image, copies its binary into the active machine for kubelet, and removes the images its upgrades pulled that it won't run again.
Reset AgentReset, the deletion signal, or aks-flex-node reset The same code; aks-flex-node reset runs through the reset unit. Afterwards nothing of the agent runs, and the install stays.
Joining again Run bootstrap.sh again, which downloads the agent its inputs name Write new inputs and start the bootstrap unit. agent-image.env may name the image; without it, the install's image runs.
Uninstall scripts/uninstall.sh Reprovision without the agent's Butane config

Repave and NodeReboot run the same code on both.

Changes that apply everywhere

These are in shared code. Only the ones in bold change anything on Ubuntu:

  • A reset empties a runtime directory that's a mount point. RemoveRuntimeDirs, which reset uses, empties a directory it can't remove because it's a mount point, as the agent's directories are in its container. On a host, that changes only a reset where /etc/aks-flex-node or /var/log/aks-flex-node is a mount of its own, which failed before.
  • An AgentUpgrade in progress says it's staging the upgraded agent, not its binary, which in the container is an image.
  • scripts/bootstrap.sh gains --installed-agent PATH (or AKS_FLEX_NODE_INSTALLED_AGENT), which runs the agent at PATH instead of installing one. It's off by default, and an unusable PATH exits with 78, as any setting that can't work does since Stop retrying first-boot failures that can't succeed, and clean up after any uninstall reset #407. With tests.
  • The handoff between an upgrading daemon and its successor now goes through a small interface, with the host's executor as one implementation and the container's as the other. Parsing an AgentUpgrade's parameters checks what every agent requires, a download URL and a SHA-256, and the executor checks the URL: the host's an archive's, as main does, so an oci:// URL fails on a host as on main, and the container's an image's. The host's results are main's, except that a request with both a malformed URL and a malformed digest reports the digest first.
  • aksmachine.MachineClientOptions.CredentialPlugin names the credential plugin start's Machine client runs. Empty, it's the host's agent, as on main.
  • utilexec.Ctr(), and one new direct dependency, github.com/distribution/reference, already in the module graph.
  • Prompt restart after an upgrade. After switching to a new agent, the daemon restarts itself from a transient timer set for 1 s. systemd timers default to AccuracySec=1min, so the restart came up to a minute late; it measured 26–58 s on ACL. The timer now has AccuracySec=100ms, so every AgentUpgrade, on Ubuntu too, finishes up to a minute sooner.

The agent's image

Each release publishes the agent's image, ghcr.io/azure/aks-flex-node-agent, for linux/amd64 and linux/arm64, as it publishes the controller's: release.yml builds it in its dry run, through agent-image-build.yml, and publishes it once the release is approved.

  • Tags: the release's version and sha-<commit>. The image is pushed by digest and its build provenance attested first, so a tag only ever names an attested image.
  • Release notes: the image pinned by digest, for the install's settings, and the AgentUpgrade parameters that name it.
  • Checks: ACL Container Checks builds it for both platforms, without pushing, when its files change.

Testing

hack/acl-container/harness.py boots ACL under QEMU/KVM, creates a local kind cluster running main's in-cluster controller, and bootstraps the VM into it:

hack/acl-container/harness.py up      # exports and boots ACL 3.0.20261026
hack/acl-container/harness.py check

check runs its checks in 7 to 10 minutes, mostly downloads, and covers:

  • a pod on the node
  • repave from kube1 to kube2
  • the agent's binary, which kubelet runs as its credential plugin, running in the machine
  • no binary of the agent on the host, and the machines' hooks starting units that run the agent's phases in containers of the image
  • NodeReboot, whose restart of the machine runs both phases in containers
  • stopping the agent's unit while its daemon ignores SIGTERM: the container still ends, within the unit's stop, and the agent starts again
  • AgentUpgrade: success, a broken candidate that rolls back, requests without the image's digest, with it in the URL, or naming an archive, which are rejected, back again, and removal of the broken image
  • a VM reboot
  • AgentReset, reset, and the deletion signal, each leaving no unit or link behind, and each followed by a re-join, once with a given image and once with the agent's registry unreachable
  • a bootstrap with a setting that can't work, which fails with 78 and isn't retried

It passes on ACL 3.0.20261026 as that build ships, with the agent's containers in spc_t, where the rule applies, and no SELinux denials.

Go tests, also with the race detector, golangci-lint, scripts/bootstrap_test.sh, scripts/install_test.sh, scripts/uninstall_test.sh, ShellCheck, actionlint on the release and image workflows, and the docs checks pass. The arm64 image was built and run under QEMU, but not on ACL.

Known gaps

Before merging: ACL's SELinux rule and its labels on containerd's files have to reach the image AKS uses. Today they're in a development gallery build.

Follow-ups:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initial review of the draft. I read TODO.md and DESIGN.md first and left out anything they already track.

I found no critical bugs in the Go changes. I traced the image-based upgrade through success, a broken candidate, abort, recovery, and the kernel_t fallback where reset-failed is denied, and the handoff holds up. The Containerized() switches leave Ubuntu on main's path. At df70b9c, go build ./..., go vet, go test ./pkg/daemon/ ./pkg/config/ ./pkg/cmd/..., and golangci-lint run ./pkg/... all pass.

Suggested order: the Stage timeouts, the ctr cleanup race in the units, the bootstrap unit's retry, the selinux-repro.sh policy restore, and the unit write order. Details are inline.

Not inline

  • The PR description's Known gaps still says ctr places the agent's container outside its unit's cgroup, but 6c1095d moved it into a scope in system-aksflex.slice. DESIGN.md has the same leftover; see inline.
  • release.yml builds the agent with upstream Go through setup-go. The image builds it with Microsoft Go and ms_nocgo_opensslcrypto. The image's binary is also copied into the machines, where kubelet runs it as its credential plugin, and onto the host as the nspawn lifecycle helper. It's worth confirming that check exercises the credential plugin; with bootstrap-token auth it may not.

Harness (dev-only, low)

  • harness.py:1594-1600: config.ign holds the bootstrap token. It's written with the default umask in a 0755 state directory and only changed to 0600 afterwards. It's also served over plain HTTP on the bridge, and the local registry accepts pushes from anyone while the VM pulls the agent by tag. Create it with os.open(..., 0o600) and make the state directory 0700.
  • harness.py:813-838: vm_running and stop_vm trust the PID file, so after PID reuse they can signal an unrelated process. Check /proc/<pid>/cmdline for qemu-system first.
  • harness.py:1733: down runs shutil.rmtree(STATE) on whatever ACLC_STATE_DIR names. Require a marker file first.
  • cmd_up (harness.py:1564): if up fails partway, the bridge, iptables rules, ip_forward=1, the kind cluster, and the registry stay until down.
  • Machine names read from the VM go unquoted into remote shell strings, such as harness.py:1033, :1037, and :1189. Use shlex.quote. At :1189, an empty before makes machinectl show-image succeed, so that check can never pass.
  • selinux-verify.sh:32 and :116-123: root runs a script from a fixed /tmp/acl-selinux-verify on the host, which another local user could create first. Use mktemp -d on the host.
  • selinux-repro.sh:244: swapoff -a is never undone, though the header says the host is put back.
  • The Containerfile's base images, and the harness's registry, Butane, Fedora, and kind images, are pulled by tag. That matters most for the published preview image.

Comment thread pkg/daemon/agent_upgrade_container.go Outdated
Comment thread pkg/daemon/assets/aks-flex-node-agent-container.service Outdated
Comment thread hack/acl-container/agent-container.bu Outdated
Comment thread hack/acl-container/selinux-repro.sh Outdated
Comment thread pkg/daemon/agent_upgrade_container.go Outdated
Comment thread pkg/daemon/agent_upgrade.go Outdated
Comment thread pkg/daemon/agent_upgrade.go
}

checks := preflight.Flatten(
checks := daemon.PreflightChecks(preflight.Flatten(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: pkg/cmd/preflight now imports all of pkg/daemon for one filter. Moving Containerized() and PreflightChecks into a small package that both import would keep the dependency graph shallow and avoid future import cycles.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't fix. It's the pattern the command packages already follow: pkg/cmd/start, daemon, reset, and nspawnlifecycle import pkg/daemon too, and pkg/daemon doesn't import pkg/cmd, so there's no cycle to avoid. If Containerized() moves to a package of its own later, this can follow it.

Comment thread hack/acl-container/DESIGN.md Outdated
Comment thread hack/acl-container/DESIGN.md Outdated
Philip Lombardi (plombardi89) added a commit that referenced this pull request Oct 20, 2026
DESIGN.md now describes the image's upstream Go build and pinned base
images, the bootstrap unit's condition on the agent's unit being
enabled and its backoff, the entrypoint resolving host paths within
the host's root, the bootstrap wrapper ignoring what the image sets,
the time limits on an upgrade's pull and check, the recovery unit's
condition on a last good image, removing only the images upgrades
pulled, and the install's settings being 0600. Its open questions no
longer list the cgroup, which the systemd scopes settled, or whether
ctr stays the launcher, which it does.

check now has 68 checks, with the agent's binary running in its
machine, and takes 7 to 10 minutes: 9m42s and 9m30s today, as each
rejoin pulls the machine's image and binaries again, and downloads
were slower.

TODO.md: the upgraded daemon rewrites its recovery unit before it
judges the upgrade, as main's does on a host; raise it with main.
Philip Lombardi (plombardi89) added a commit that referenced this pull request Oct 20, 2026
DESIGN.md now describes the image's upstream Go build and pinned base
images, the bootstrap unit's condition on the agent's unit being
enabled and its backoff, the entrypoint resolving host paths within
the host's root, the bootstrap wrapper ignoring what the image sets,
the time limits on an upgrade's pull and check, the recovery unit's
condition on a last good image, removing only the images upgrades
pulled, and the install's settings being 0600. Its open questions no
longer list the cgroup, which the systemd scopes settled, or whether
ctr stays the launcher, which it does.

check now has 68 checks, with the agent's binary running in its
machine, and takes 7 to 10 minutes: 9m42s and 9m30s today, as each
rejoin pulls the machine's image and binaries again, and downloads
were slower.

TODO.md: the upgraded daemon rewrites its recovery unit before it
judges the upgrade, as main's does on a host; raise it with main.
Philip Lombardi (plombardi89) added a commit that referenced this pull request Oct 20, 2026
DESIGN.md now describes the image's upstream Go build and pinned base
images, the bootstrap unit's condition on the agent's unit being
enabled and its backoff, the entrypoint resolving host paths within
the host's root, the bootstrap wrapper ignoring what the image sets,
the time limits on an upgrade's pull and check, the recovery unit's
condition on a last good image, removing only the images upgrades
pulled, and the install's settings being 0600. Its open questions no
longer list the cgroup, which the systemd scopes settled, or whether
ctr stays the launcher, which it does.

check now has 68 checks, with the agent's binary running in its
machine, and takes 7 to 10 minutes: 9m42s and 9m30s today, as each
rejoin pulls the machine's image and binaries again, and downloads
were slower.

TODO.md: the upgraded daemon rewrites its recovery unit before it
judges the upgrade, as main's does on a host; raise it with main.
Philip Lombardi (plombardi89) added a commit that referenced this pull request Oct 21, 2026
DESIGN.md now describes the image's upstream Go build and pinned base
images, the bootstrap unit's condition on the agent's unit being
enabled and its backoff, the entrypoint resolving host paths within
the host's root, the bootstrap wrapper ignoring what the image sets,
the time limits on an upgrade's pull and check, the recovery unit's
condition on a last good image, removing only the images upgrades
pulled, and the install's settings being 0600. Its open questions no
longer list the cgroup, which the systemd scopes settled, or whether
ctr stays the launcher, which it does.

check now has 68 checks, with the agent's binary running in its
machine, and takes 7 to 10 minutes: 9m42s and 9m30s today, as each
rejoin pulls the machine's image and binaries again, and downloads
were slower.

TODO.md: the upgraded daemon rewrites its recovery unit before it
judges the upgrade, as main's does on a host; raise it with main.
Releases publish the agent's image instead, from main, with the
release's version; see "Publish the agent's image for Azure Container
Linux with each release". The preview package,
ghcr.io/azure/aks-flex-node-agent-acl-preview, keeps its images until
it's deleted.

This reverts commit 5e91041.
A release publishes the agent's image, ghcr.io/azure/aks-flex-node-agent,
tagged with the release's version and its commit, for linux/amd64 and
linux/arm64, as it publishes the controller's: a reusable workflow,
agent-image-build.yml, builds it in the release's dry run and publishes
it once the release is approved. It's pushed by digest, its build
provenance attested, and only then tagged, so a tag only ever names an
attested image, as the preview's. The release notes give it pinned by
digest, for the install's settings, and the AgentUpgrade parameters that
name it. ACL Container Checks builds it, without pushing, when its files
change.

What the release builds moves out of the harness, beside its
counterparts: hack/acl-container/image/Containerfile to
Dockerfile.aks-flex-node-agent, the image's scripts to
scripts/agent-image, and the install's Butane config to
scripts/aks-flex-node-agent-container.bu. The Go build runs on the build
machine's platform and cross-compiles for each of the image's, as the
controller's does; the image's packages install under QEMU for arm64.
The harness runs the image on linux/amd64 only.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Current validation is limited to QEMU/kind with a development ACL image, leaving privileged host access, lifecycle recovery, SELinux behavior, and identity-based bootstrap unverified on the intended AKS Azure VM image.

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

Medium severity Prevent credential leakage in invalid reference errors

pkg/​daemon/​agent_upgrade_container.go:184

reference.ParseNamed can include the supplied repository in its error, so an invalid reference such as oci://registry.example/agent?token=SecretValue leaks the token into the MachineOperation failure message. Return a fixed validation error without wrapping the parser error. Add a regression test for credential-bearing invalid references.

Review effort: Balanced

scripts/aks-flex-node-agent-container.bu renders bootstrap.env into
/etc/aks-flex-node/bootstrap.env, which runs the bootstrap unit, as
host mode's Butane config takes its settings, so an operator renders
both from the files in a directory. A base config and a service
principal's credential are commented entries, as in host mode's. The
harness renders it the same way, and adds its base config as an
operator would.
docs/usage/azure-container-linux.md describes the agent in its containers
as the way to run on Azure Container Linux: the ACL build and release it
needs, joining with the Butane config, upgrading by image, reset and
rejoining, and checking the agent. Installing the agent on the host
stays, as an interim, under its own heading. Getting started and
operations point to it, and the changelog records it, and the upgrade
restart that no longer comes up to a minute late.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Malformed references can expose query credentials in failure messages, and configured local offline artifact sources are not mounted where container preflight and bootstrap expect them.

2 open issues

Review effort: Balanced

Comment thread pkg/daemon/agent_upgrade_container.go Outdated
Comment on lines +24 to +28
/var/lib/machines
/var/lib/unbounded
/var/log/aks-flex-node
/run
/opt/unbounded
DESIGN.md, the harness's README and code, and the comments in the
agent's container code compared it with main's agent, as a branch did.
They compare it with the agent installed on a host now, and say what
the shared code does rather than how it changed. DESIGN.md's note points
operators to docs/usage/azure-container-linux.md, and its open questions
link their issues. The harness is no longer a prototype, and runs
checks, not feasibility checks.
Its items are done, tracked in issues (#416, #417, #399, #379), merge
gates in the PR's description, or dropped.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

SELinux policies and containerd labels remain unconfirmed in the production ACL image, while privileged host access and rollback have only QEMU validation and Azure identity bootstrap is untested in container mode.

2 open issues

Review effort: Balanced

The recovery unit's work in the container returned as soon as
reset-failed failed, so it never restarted the agent's unit on the
restored image. A host's recovery script, and an upgrade's restart,
warn and restart anyway: on systemd 254 and later, recovery runs at the
first failure, before the unit hits its start limit, so the restart
works without the reset. It does the same now, and reports only the
restart's failure.
The container's AgentUpgrade wrapped the reference parser's error,
which repeats the repository when it has capitals in it, so a
downloadURL such as oci://registry.example/agent?Sig=Secret put the
query, and any credential in it, in the MachineOperation's failure and
the agent's log. The failure is a fixed message now, as a host's for an
archive's URL is, and the tests check that neither a query's credential
nor user information comes back in it.
…eload

The daemon in its container rewrote its units and copied its binary
into the active machine only after it had built its credentials and its
manager, so a daemon whose credentials failed left them as an earlier
image had them. A host's daemon converges its install before either. The
container's now converges right after the host's steps would, with only
the state store it needs.

It also reloaded systemd only when it changed a unit. A start that wrote
the units and ended before its reload, or whose reload failed, left
systemd on the old ones, and the next start, finding the files current,
never reloaded. Each start reloads now; writing the units an upgrade
held back still reloads only when one changes. writeUnits writes them
itself, and the comments say which unit goes last and why.
newClusterEndpointClientFromBootstrapConfig's comment had slid onto
bootstrapRESTConfig above it; each function has its own again. The
recovery's comment said it runs once the agent's unit hits its start
limit, which is so only before systemd 254; from 254, OnFailure= runs
it at every failure, as the unit's comment says. Reading the install's
settings failed as reading "the agent's image selection"; the message
names neither now, and the error's path says which. An AgentUpgrade in
progress says it's staging the upgraded agent, not its binary, which is
an image in the container.
The base images were pinned, but the build pulled three more by tag,
each of which shapes the published image while its provenance attests
it all the same: setup-qemu-action's binfmt emulators, at :latest and
privileged on the runner, which run every arm64 step; the BuildKit that
buildx creates, at buildx-stable-1; and the Dockerfile's syntax
frontend. Each is pinned by digest now, at what its tag names today:
binfmt qemu-v10.2.3-68, BuildKit v0.33.1, and dockerfile 1.7. The
emulators are pulled rather than restored from the Actions cache.
DESIGN.md said the install's settings are 0600 because pull flags could
put credentials in them, as if that kept them private. The flags are on
ctr's command lines, the pulls' and the agent container's run, which
any local user can read through /proc. DESIGN.md and the Butane
config's header now say never to put credentials in them, and that a
registry that needs credentials isn't supported yet.
up marked any ACLC_STATE_DIR as its own, and set it to 0700, before
anything else, so down, which removes a marked directory, would remove
one that held a user's files, say ACLC_STATE_DIR=~/vms. up now takes
the default directory, one it marked, or a new or empty one, and stops
before changing anything otherwise. The README says so.
DESIGN.md, the Butane config, the daemon's image removal, and the
harness said bootstrap runs the install's image as it is when the pull
fails, "offline too". That's a fallback for a registry that can't be
reached, not an offline install, which Azure Container Linux doesn't
support yet, and the word suggested it does. The fallback and the
harness's check of it are unchanged.
Nothing an operator reads said that running on Azure Container Linux is
experimental, though the agent's containers need an ACL build that isn't
in the image AKS uses yet, and have run only on local VMs. The operator
guide opens with a warning: experimental and not for production nodes,
in containers or on the host, with settings, units, and AgentUpgrade
parameters that may change without a migration path; the ACL build it
needs; Azure VMs, and so managed identity and service principal
bootstrap, untested (#399); and no offline install, GPU hosts (#416),
or registry that needs credentials yet.

Getting started, operations, the usage index, both Butane configs, the
changelog's two entries, the release notes, and the harness's README
and DESIGN.md say so, and point to it. Getting started's Ignition
section no longer says to run the agent in containers "instead" in the
middle of describing the host install, which made "a Butane config that
does this" name the wrong thing: a warning before it points to the
container install. The changelog also records bootstrap.sh's
--installed-agent.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Merging depends on confirming the required SELinux rule and containerd labels in AKS’s ACL image, while development-image QEMU coverage leaves Azure identity bootstrap unverified.

1 open issue
1 resolved since last review

Review effort: Balanced

parseAgentUpgradeRequest checked a release archive's URL and SHA-256,
and let any oci:// URL through for the container's executor, whose
Validate checked it, while the host's Validate checked the archive's
again. Shared code knew about images only to step around them.

Now the parse checks what every agent requires, a download URL and a
SHA-256 of 64 hexadecimal characters, the latter in agentUpgradeDigest,
which the container's check of an image uses too. The executor alone
checks which URLs it takes: an archive's on a host, an image's in the
container. The results are the same, but a host given a malformed URL
and a malformed digest reports the digest first. The scheme and its
helper move to the container's file, the only one that uses them.
The daemon in its container rewrote the active machine's hooks and
removed the nspawn lifecycle helper that a preview of the agent's image,
published from this branch and since removed, left on the host. Only
hosts that joined with that preview had one, and none remain: a host
that did is reprovisioned rather than upgraded. With it go the
convergence's hook for it, its tests, the paragraph in DESIGN.md, and
the test that the agent's unit no longer mounts /usr/local, another
leftover. hostroot_move.go is main's again: it resolved the active
machine's config in a function of its own only for the migration.
hack/acl-container/ukiboot.py was byte for byte hack/acl/ukiboot.py,
which boots ACL the same way. The harness imports hack/acl's now, and
ACL Container Checks compiles it and runs when it changes.
agent_task_pid was never called, while two checks found the agent
task's PID again with awk; they call it now. die never returns, so its
callers' unreachable raise AssertionError went, with die marked
NoReturn. push_agent_image's digest was never used, so its callers push
the image directly. The reboot check in the machines group tested for a
cluster that the group's skip already requires, and base64 is imported
with the rest.
DESIGN.md had grown a test report, a snapshot of the test environment,
measurements, the branch's history, and a dozen restatements of the
same facts: the AgentUpgrade parameters, rejoining after a reset, which
image runs. It's down from 516 lines to 370, about how the agent runs
in containers and why:

- It names the two installs once, the binary install and the container
  install, rather than "on a host", which an ACL host also is.
- One table of units, now all six, with the nspawn lifecycle units; one
  of the files on the host; one of the bootstrap unit's behavior.
- The ctr run flags' reasons are a line each, the upgrade's steps say
  what happens rather than what it's like on a host, and SELinux says
  what's true of ACL, not how it came to be.

The harness's README takes the tested environment and the measurements,
points to DESIGN.md for the units and files and to the operator guide
for the upgrade parameters, says that Podman is always needed, and
counts no checks, a number that drifted.

The operator guide introduces the two installs, lists the bootstrap
unit's status 78 cases with what to do for each, gives the upgrade
parameters as a table with what's rejected, adds a way to follow the
node's join and the upgrade, runs version, not a status command that
doesn't exist, in the agent's container, and says the start limit
plainly. The URL contract protects its anchors, which other pages link
to, and lists the README's new one.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The required SELinux rule and containerd labels remain unconfirmed in the actual AKS ACL image, where privileged execution and recovery still require runtime validation beyond the development-gallery QEMU build.

1 open issue

Review effort: Balanced

The entrypoint exited when the host's machinectl didn't answer, for
every command. That kept aks-flex-node reset from running on a host
whose machined or D-Bus is broken, though reset is how such a node is
brought back, and kept upgrade recovery from restoring the last good
image. Both now warn and run, doing what they can; every other command
still exits, the agent's daemon with the same message as before.
LocalDNS, which a node pool's bootstrap data can turn on, runs
resolvectl domain when the host's resolv.conf points at the
systemd-resolved stub. The image had no resolvectl, so bootstrap would
fail there. It runs the host's now, through host-exec, as the other
host tools do.
The release notes told users to run gh attestation verify with --owner,
which accepts provenance that any repository of the owner attested.
They give --repo now, so only an attestation this repository's
workflows made verifies.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Human assessment is needed to evaluate potential cached-disk corruption from unchecked partial writes and Arc preflight failures caused by a missing host-agent shim.

1 open issue

Review effort: Balanced

This branch was successfully deployed

1 active deployment
e2e-testing — 8cdb0cdc Deployed Oct 28, 2026 by plombardi89 via E2E Tests #1288
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.

3 participants