Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
a6194c9
agent: add the host installation prefix and resolve paths from it
plombardi89 Sep 17, 2026
74ec9f8
agent: resolve the daemon binaries under the installation prefix
plombardi89 Sep 19, 2026
9d64c92
agent: record the installation prefix before the first host mutation
plombardi89 Sep 19, 2026
b6a5156
agent: make the installation prefix part of bootstrap identity
plombardi89 Sep 19, 2026
a13ec36
kubectl-unbounded: add the Ignition config encoder
plombardi89 Sep 21, 2026
37d49c9
kubectl-unbounded: emit an Ignition bootstrap config
plombardi89 Sep 21, 2026
60d715e
agent: remove the first-boot bootstrap unit on reset
plombardi89 Sep 21, 2026
42d90af
agent: write the ownership record only when a repair changed something
plombardi89 Sep 21, 2026
10c4153
agent: resolve the host prefix from the record, and fix the tests tha…
plombardi89 Sep 22, 2026
374309a
agent: install and render the daemon assets under the configured prefix
plombardi89 Sep 23, 2026
4746672
agent: resolve the nspawn lifecycle helper from the installation prefix
plombardi89 Sep 23, 2026
7d10fe3
agent: resolve the LocalDNS network helper from the installation prefix
plombardi89 Sep 23, 2026
4200219
agent: sync the filesystem the agent wrote to, not a fixed /usr/local
plombardi89 Sep 23, 2026
ec71303
agent: sweep and detect the agent's files under every known prefix
plombardi89 Sep 23, 2026
58a23f0
agent: stage the install script's binary under the configured prefix
plombardi89 Sep 23, 2026
9f0eb11
kubectl-unbounded: make a failed Ignition bootstrap visible and back …
plombardi89 Sep 23, 2026
4027d4c
kubectl-unbounded: validate Ignition input before cluster contact, an…
plombardi89 Sep 23, 2026
1ff44ec
e2e: add a UKI boot helper for Ignition-provisioned images
plombardi89 Sep 23, 2026
15a3606
e2e: add an Azure Container Linux host image resolved from its manifest
plombardi89 Sep 23, 2026
4693ee1
e2e: boot and bootstrap the Azure Container Linux host through Ignition
plombardi89 Sep 23, 2026
ca963c9
e2e: run the Azure Container Linux host in CI, and check what reset r…
plombardi89 Sep 23, 2026
e5e7195
e2e: tolerate a refused reset-failed on a SELinux-enforcing host
plombardi89 Sep 23, 2026
5f40e81
agent: pin the nftables flush ordering that keeps kubelet reachable
plombardi89 Sep 23, 2026
cb929f3
e2e: stop the previous VM before provisioning an Ignition host
plombardi89 Sep 23, 2026
eb858d7
agent: do not unlink an artifact that is not there
plombardi89 Sep 23, 2026
3b5671a
e2e: leave Azure Container Linux out of the matrix until its login ex…
plombardi89 Sep 23, 2026
56a543e
e2e: fetch the Azure Container Linux manifest over REST
plombardi89 Sep 23, 2026
ee9126c
agent: probe the configured prefix in the install directory preflight
plombardi89 Sep 23, 2026
c04fa36
e2e: reinstall an Ignition host on the disk it already has
plombardi89 Sep 23, 2026
7bc0382
e2e: resolve the image location only when the image is needed
plombardi89 Sep 23, 2026
f6f2fb8
e2e: restore the Ignition boundaries dropped while narrowing the suite
plombardi89 Sep 23, 2026
00e40d2
agent: stop the first-boot bootstrap unit on reset, not just disable it
plombardi89 Sep 23, 2026
331a73f
agent: check for a path before removing it in the shared reset helpers
plombardi89 Sep 23, 2026
1c17c1e
agent: keep the pre-prefix preflight and lifecycle entry points
plombardi89 Sep 23, 2026
0d6ab1f
agent: do not repair a healthy Ignition host on every reboot
plombardi89 Sep 24, 2026
8343f08
agent: order the nspawn machines after the nftables flush
plombardi89 Sep 24, 2026
a296d7a
agent: take the reset prefix once, under the lock
plombardi89 Sep 24, 2026
b7a73af
kubectl: check the Ignition digest before contacting the cluster
plombardi89 Sep 24, 2026
08ef542
docs: fix the checksum example and the comments about preflight
plombardi89 Sep 24, 2026
62918ff
agent: check the applied config's checksum before taking its prefix
plombardi89 Sep 24, 2026
2c563c3
test: drop checks that restate constants or cannot fail
plombardi89 Sep 24, 2026
f8e1c21
e2e: verify every host image, keep the storage token out of logs
plombardi89 Sep 24, 2026
c52aac3
e2e: resolve the Azure Container Linux image once per job
plombardi89 Sep 24, 2026
e87af05
e2e: tighten the Ignition reinstall and host boundaries
plombardi89 Sep 24, 2026
35a416a
e2e: harden the UKI command line patch
plombardi89 Sep 24, 2026
12f0206
e2e: keep the image resolution tests off the job's pinned build
plombardi89 Sep 24, 2026
d01744c
agent: install under /opt/unbounded and link older hosts to /usr/local
plombardi89 Sep 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/actions/agent-e2e-kind-control-plane/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ runs:
shell: bash
run: |
sudo apt-get update
# ovmf is the UEFI firmware an Ignition host boots through; qemu-utils
# also provides the qemu-nbd used to patch its boot command line.
sudo apt-get install -y --no-install-recommends \
qemu-system-x86 qemu-utils genisoimage \
qemu-system-x86 qemu-utils genisoimage ovmf \
iptables

- name: Create Kind cluster
Expand Down
161 changes: 141 additions & 20 deletions .github/workflows/agent-e2e-kind.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,31 +57,78 @@ permissions:
packages: read

jobs:
# The Azure Container Linux image is not on a public mirror. It lives in a
# storage account that disables anonymous access and shared keys alike, so it
# is reachable only with a federated Azure login, and GitHub withholds secrets
# from workflows triggered by a fork. Every other host downloads from a public
# mirror and is unaffected.
#
# A job-level `if` would skip the whole matrix, and a static matrix cannot
# drop one entry conditionally, so the list is built here instead. On a fork
# the ACL entry is absent rather than failing, which is the difference between
# a contributor seeing their PR pass and seeing a red check they cannot fix.
select-hosts:
name: select host matrix
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
matrix: ${{ steps.select.outputs.matrix }}
steps:
- name: Select hosts
id: select
env:
# Absent for non-pull_request events, where secrets are available.
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
# Presence only. The image needs a federated login, and the entry is
# left out entirely until one is configured, rather than added and
# failed. GitHub masks the value; nothing here reads it.
ACL_CREDENTIAL: ${{ secrets.ACL_IMAGE_CLIENT_ID }}
Comment on lines +82 to +85
run: |
set -euo pipefail

# Fedora, AlmaLinux, and CentOS Stream cover RPM hosts with Azure
# Linux nspawn. Azure Linux 3 does not currently publish a QEMU-ready
# VHD that this e2e can boot directly as the host VM.
hosts='[
{"host-base-os":"ubuntu2404","nspawn-base-os":"ubuntu2404","timeout":60},
{"host-base-os":"fedora","nspawn-base-os":"azlinux3","timeout":60},
{"host-base-os":"almalinux9","nspawn-base-os":"azlinux3","timeout":60},
{"host-base-os":"almalinux10","nspawn-base-os":"azlinux3","timeout":60},
{"host-base-os":"centosstream9","nspawn-base-os":"azlinux3","timeout":60},
{"host-base-os":"centosstream10","nspawn-base-os":"azlinux3","timeout":60},
{"host-base-os":"ubuntu2604","nspawn-base-os":"ubuntu2604","timeout":60}
]'

# Azure Container Linux gets longer: it downloads a 630 MiB image and
# boots a 31 GiB sparse overlay through OVMF.
acl='{"host-base-os":"acl","nspawn-base-os":"azlinux3","timeout":75}'

if [ -n "${HEAD_REPO}" ] && [ "${HEAD_REPO}" != "${GITHUB_REPOSITORY}" ]; then
echo "::notice::Azure Container Linux is skipped for forks: its image needs Azure credentials"
elif [ -z "${ACL_CREDENTIAL}" ]; then
echo "::notice::Azure Container Linux is skipped: ACL_IMAGE_CLIENT_ID is not configured"
else
hosts="$(printf '%s' "${hosts}" | jq -c ". + [${acl}]")"
fi

hosts="$(printf '%s' "${hosts}" | jq -c .)"

printf 'matrix=%s\n' "$(printf '%s' "${hosts}" | jq -c '{include: .}')" >> "${GITHUB_OUTPUT}"

agent-e2e:
name: agent e2e (host ${{ matrix.host-base-os }}, nspawn ${{ matrix.nspawn-base-os }})
needs: select-hosts
runs-on: ubuntu-24.04
timeout-minutes: 60
timeout-minutes: ${{ matrix.timeout }}
permissions:
contents: read
packages: read
# Federated login for the Azure Container Linux image. Requested on this
# job alone rather than for the workflow, because no other step needs it.
id-token: write
strategy:
fail-fast: false
matrix:
include:
- host-base-os: ubuntu2404
nspawn-base-os: ubuntu2404
# Fedora, AlmaLinux, and CentOS Stream cover RPM hosts with Azure Linux nspawn.
# Azure Linux 3 does not currently publish a QEMU-ready VHD that this
# e2e can boot directly as the host VM.
- host-base-os: fedora
nspawn-base-os: azlinux3
- host-base-os: almalinux9
nspawn-base-os: azlinux3
- host-base-os: almalinux10
nspawn-base-os: azlinux3
- host-base-os: centosstream9
nspawn-base-os: azlinux3
- host-base-os: centosstream10
nspawn-base-os: azlinux3
- host-base-os: ubuntu2604
nspawn-base-os: ubuntu2604
matrix: ${{ fromJSON(needs.select-hosts.outputs.matrix) }}
env:
KIND_CLUSTER_NAME: agent-e2e-${{ matrix.host-base-os }}-${{ matrix.nspawn-base-os }}
VM_NAME: agent-e2e-${{ matrix.host-base-os }}-${{ matrix.nspawn-base-os }}
Expand All @@ -93,12 +140,50 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Azure login
if: matrix.host-base-os == 'acl'
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ secrets.ACL_IMAGE_CLIENT_ID }}
tenant-id: ${{ secrets.ACL_IMAGE_TENANT_ID }}
subscription-id: ${{ secrets.ACL_IMAGE_SUBSCRIPTION_ID }}

# Resolved once for the job. The image URL, digest and build are
# exported to the environment, so later e2e.py processes use this build
# rather than reading the manifest again, which may have moved on.
- name: Resolve the Azure Container Linux build
if: matrix.host-base-os == 'acl'
id: acl-image
run: python3 ./hack/agent/e2e-kind/e2e.py resolve-host-image

# Keyed on the build, so a refreshed image misses and is downloaded once
# rather than every run re-fetching 630 MiB from the storage account.
# Restore and save are separate steps because Cleanup deletes .vm-e2e
# before a combined action's post step would save it.
- name: Restore the host image
if: matrix.host-base-os == 'acl'
id: acl-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .vm-e2e/acl-${{ steps.acl-image.outputs.build }}.qcow2
key: acl-image-${{ steps.acl-image.outputs.build }}
Comment on lines +163 to +169

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The image is never saved to the cache. actions/cache saves in a post step, and post steps run after every main step, including the if: always() Cleanup step. e2e.py cleanup runs shutil.rmtree(VM_DIR) (e2e.py:5621), so .vm-e2e/acl-<build>.qcow2 is already gone when the save runs, and every run downloads the image again. This pinned commit also declares post-if: success(), so a failed job never saves either.

Suggested fix: use actions/cache/restore here, and add an explicit actions/cache/save step before Cleanup (if: always() && steps.<id>.outputs.cache-hit != 'true', and only if the file exists). Alternatively, keep base images outside VM_DIR. Either way, do this only once the reuse path verifies the digest (see the comment on acquire_host_image).

Pins:

  • 640a1c25... is not the v4.2.3 tag. That tag is 5a3ec84e...; this commit is a later README-only merge.
  • The rest of the repo uses actions/cache@55cc8345... # v6.1.0 and azure/login@a641126d... # v3.1.0.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f8e1c21: split into actions/cache/restore and actions/cache/save on the v6.1.0 pin, with the save right after the control-plane step that downloads and verifies the image, and azure/login moved to v3.1.0. The reuse path now verifies the digest (see the acquire_host_image thread). The CI run on 12f0206 saved acl-image-2026091817.

You're right that 640a1c25 was mislabeled; its code matches v4.2.3 and only README.md differs.


- name: Set up test control plane
uses: ./.github/actions/agent-e2e-kind-control-plane
with:
cluster-name: ${{ env.KIND_CLUSTER_NAME }}
vm-subnet: ${{ env.VM_SUBNET }}

# create-vm, in the step above, has downloaded and verified the image.
# Saving here rather than at the end means a later test failure does not
# stop the next run reusing it.
- name: Save the host image
if: matrix.host-base-os == 'acl' && steps.acl-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .vm-e2e/acl-${{ steps.acl-image.outputs.build }}.qcow2
key: acl-image-${{ steps.acl-image.outputs.build }}

- name: Set up machina resources
uses: ./.github/actions/agent-e2e-machina-setup

Expand Down Expand Up @@ -155,6 +240,42 @@ jobs:
if: always()
run: python3 ./hack/agent/e2e-kind/e2e.py --verbose cleanup

# A host installed by a release before the host root, moved to this build and
# back. The older release cannot be installed on an immutable host at all,
# and on a migrated host the units still run the same legacy paths, so one
# conventional host covers it.
agent-host-root-migration:
name: agent host root migration (Ubuntu)
runs-on: ubuntu-24.04
timeout-minutes: 45
env:
KIND_CLUSTER_NAME: agent-host-root-migration
VM_NAME: agent-host-root-migration
VM_SUBNET: "192.168.100"
VM_IP: "192.168.100.10"
AGENT_MACHINE_NAME: agent-host-root-migration
HOST_BASE_OS: ubuntu2404
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up test control plane
uses: ./.github/actions/agent-e2e-kind-control-plane
with:
cluster-name: ${{ env.KIND_CLUSTER_NAME }}
vm-subnet: ${{ env.VM_SUBNET }}
- name: Set up machina resources
uses: ./.github/actions/agent-e2e-machina-setup
- name: Upgrade, reboot, downgrade and reset a migrated host
run: python3 ./hack/agent/e2e-kind/e2e.py --verbose run-suite --suite migration
- name: Collect logs
if: always()
uses: ./.github/actions/agent-e2e-kind-logs
with:
artifact-name: agent-host-root-migration-logs
- name: Cleanup
if: always()
run: python3 ./hack/agent/e2e-kind/e2e.py --verbose cleanup

agent-config-e2e:
name: agent config e2e
runs-on: ubuntu-24.04
Expand Down
65 changes: 58 additions & 7 deletions cmd/agent/internal/bootstrap/coordinator.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,22 @@ package bootstrap

import (
"context"
"errors"
"fmt"
"log/slog"
"time"

"github.com/Azure/unbounded/cmd/agent/internal/installstate"
)

// defaultLockWait bounds how long Run waits for another lifecycle operation to
// release the installation lock. On a reboot the daemon holds it briefly while
// it migrates the host on startup, and the first-boot unit runs start then.
const (
defaultLockWait = 30 * time.Second
lockPollInterval = 250 * time.Millisecond
)

type Identity struct{ MachineName, ConfigFingerprint string }

type Stages interface {
Expand Down Expand Up @@ -51,16 +61,25 @@ type Coordinator struct {
store *installstate.Store
stages Stages
reporter Reporter
lockWait time.Duration
lockPoll time.Duration
}

func New(log *slog.Logger, store *installstate.Store, stages Stages, reporter Reporter) *Coordinator {
return &Coordinator{log: log, store: store, stages: stages, reporter: reporter}
return &Coordinator{
log: log,
store: store,
stages: stages,
reporter: reporter,
lockWait: defaultLockWait,
lockPoll: lockPollInterval,
}
}

type Outcome struct{ AlreadyComplete bool }

func (c *Coordinator) Run(ctx context.Context, id Identity) (Outcome, error) {
lock, err := c.store.AcquireLock()
lock, err := c.acquireLock(ctx)
if err != nil {
return Outcome{}, err
}
Expand Down Expand Up @@ -91,18 +110,24 @@ func (c *Coordinator) Run(ctx context.Context, id Identity) (Outcome, error) {
}

if disposition == installstate.AlreadyComplete {
if err := c.stages.VerifyInstalled(ctx); err != nil {
verifyErr := c.stages.VerifyInstalled(ctx)
if verifyErr != nil {
if err := c.stages.RepairDaemon(ctx); err != nil {
return Outcome{}, err
return Outcome{}, fmt.Errorf("repair daemon after %w: %w", verifyErr, err)
}

if err := c.stages.VerifyInstalled(ctx); err != nil {
return Outcome{}, err
}
}

if err := c.store.MarkComplete(r); err != nil {
return Outcome{}, err
// Only a repair can have changed anything, so only a repair needs
// to be committed. The record already says complete: rewriting it
// on a healthy host would be a durable write for no change, on
// every boot of every Ignition-provisioned node, since that unit
// has no completion condition and runs each time.
if err := c.store.MarkComplete(r); err != nil {
return Outcome{}, err
}
}

return Outcome{AlreadyComplete: true}, nil
Expand Down Expand Up @@ -149,3 +174,29 @@ func (c *Coordinator) Run(ctx context.Context, id Identity) (Outcome, error) {

return Outcome{}, nil
}

// acquireLock waits up to lockWait for the installation lock, and returns
// installstate.ErrLockHeld if it is still held after that.
func (c *Coordinator) acquireLock(ctx context.Context) (*installstate.Lock, error) {
deadline := time.Now().Add(c.lockWait)
logged := false

for {
lock, err := c.store.AcquireLock()
if !errors.Is(err, installstate.ErrLockHeld) || !time.Now().Before(deadline) {
return lock, err
}

if !logged {
c.log.Info("waiting for another lifecycle operation to release the installation lock", "timeout", c.lockWait)

logged = true
}

select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(c.lockPoll):
}
}
}
Loading
Loading