Skip to content

designs: propose agent v2 with a pluggable node runtime - #1024

Open
Philip Lombardi (plombardi89) wants to merge 29 commits into
mainfrom
plombardi/agent-v2-design
Open

Philip Lombardi (plombardi89) wants to merge 29 commits into
mainfrom
plombardi/agent-v2-design

Conversation

@plombardi89

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

Copy link
Copy Markdown
Collaborator

Summary

This PR adds designs/agent-v2/, a design for an experimental v2 of the unbounded agent. It is opened for commentary on the design; there are no code changes.

v2 would ship inside the existing binary as unbounded-agent v2 <command>, logging a warning that it is experimental. It has two goals:

  1. A defined interface for hosting kubelet. NodeRuntime has two implementations:
    • host: kubelet and containerd run directly on the machine.
    • nspawn-container: today's behavior, and the parity reference.
  2. Consumers use the binary, not the library. AKS Flex Node would customize the agent binary instead of composing its own agent from pkg/agent. The hook points are:
    • a versioned configuration file;
    • declarative extensions for extra node and host content, ordering, and cleanup;
    • exec plugins for desired state, status, credentials and preflight;
    • lifecycle hooks.

All v2 code would start in internal/agentv2. Moving anything to an exported package requires an explicit, recorded decision.

How to review

Start with the README. It covers the purpose, goals, architecture and terms, and lists the other documents with who should read each one:

Document Covers
01-cli-and-configuration.md The v2 commands, the configuration file, install-time and goal settings, credentials
02-extensions.md Lifecycle hooks, declarative extensions, the plugin protocol, and the AKS Flex Node mapping
03-node-runtime.md The NodeRuntime interface and its rules, the node bundle, slot states, boot units, the two runtimes
04-control-and-state.md Goal sources, status, credentials, the node manager, state files, the daemon's reconcile, MachineOperations
05-lifecycles.md Bootstrap, repave, NodeReboot, AgentUpgrade and reset
06-host-changes.md Everything v2 creates on a host, the host network reset, host preparation and its reversal
07-host-runtime-cleanup.md How the host runtime stops, cleans up and removes a node
08-v1-compatibility.md Parity with v1, intended behavior changes, copied code, converting a v1 configuration
09-implementation.md Package layout, export policy, risks
10-migration-from-v1.md How a v1 host moves to v2 without replacing its node, early or at the cutover at the first official v2 release, with a safe rollback
11-testing.md Running the v1 end-to-end suites on v2, the migration suite, checking reset with a host snapshot, CI

Where review is most needed:

  • The external contract, in documents 01 and 02.
  • The internal interfaces, in documents 03 and 04.
  • host runtime cleanup, in document 07.
  • Migration from v1, in document 10, and testing, in document 11.
  • Behavior changes from v1, in document 08, section 2. v2 should have the same outcomes as v1; each remaining difference states its reason.

Adds a design for an experimental v2 of the unbounded agent, shipped as
`unbounded-agent v2` in the existing binary. v2 defines a NodeRuntime
interface with two implementations, host and nspawn-container, and an
extension model (configuration, declarative extensions, exec plugins)
so downstream consumers such as AKS Flex Node can use the agent binary
instead of composing their own agent from pkg/agent.

The document covers the external contract, internal interfaces,
lifecycles with diagrams, explicit cleanup for the host runtime,
reversible host preparation, parity with v1, an export policy, and the
decisions and open questions for review. No code changes.
@plombardi89
Philip Lombardi (plombardi89) requested a review from a team October 29, 2026 19:39
…e|after>

Lifecycle hook events now follow one lowercase, dot-separated form that
matches the rest of the plugin protocol, for example node.start.after
and hook.node.start.after. Every state has both a before and an after
event, which adds eight events to complete the pairs.

Also defines when events fire (after events carry the step's result,
hooks must be idempotent), the default failure policies, the order of
events in each lifecycle, and whole-segment wildcard subscriptions such
as node.*.after. The sequence diagrams and decision log are updated to
match.
Renames the agent v2 host runtime cleanup levels from Quiesce, Release,
Wipe and Remove to Terminate, Unmount, Wipe and Uninstall. The new names
say what each level does, Terminate matches the machinectl term v1
already uses, and Uninstall no longer collides with the ordinary use of
"remove" throughout the document. Records the change in the decision
log.
Renames terms that clashed with other meanings or needed explaining:

- the host runtime's slot "activation" becomes a slot change with an
  active-slot record, leaving "activation" to the v1 binary activation
- the host preparation "journal" becomes an undo log
- install "admission" becomes the install check
- "orchestrator" becomes the node manager throughout
- Goal.Disposition becomes a boolean delete
- footprint, claims, host exposures, baseline and anchors become owned
  resources, the ownership record, shared host paths, preexisting mounts
  and original sysctl values, and systemd targets
- the mutation lock is called the install lock

Also rewrites smaller jargon (level-triggered, fail closed, seam, funnel,
netns handles, roll forward), notes that the node bundle is unrelated
to v1's offline artifact bundles, and adds a terms section (1.4).

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.

AI-assisted review (Claude via OpenCode). These findings were checked against the code where cited, but please verify each one before acting on it.

Line references like L123 point to designs/agent-v2.md at da8073e4f. Code citations point to the doc's baseline (dca6c964d).

Overall: The goals are clear, the v1 research is thorough, and most of the citations are accurate. The main problems are:

  • several state-machine gaps that would cause real bugs, such as a bootstrap token change triggering an unwanted wipe;
  • a hook and plugin surface that is larger than any stated consumer needs;
  • internal inconsistencies between sections 3, 5.6, 10 and 17.

The doc could also be much shorter. Section 10 alone is about 460 lines of implementation detail.

1. Gaps to resolve before implementation

  1. Re-rendering the bundle can trigger an unwanted wipe.

    • The bundle is not saved; it is rendered again when needed, including by the boot hooks (L826-827, L725).
    • Render needs a NodeSpec, and Resolve takes bootstrap credentials and a host probe (L791). The bootstrap credential may come from a plugin "called before every provision" (L586).
    • A new token changes the bundle's content and therefore its digest. The host runtime treats a changed digest at Start as a slot change and wipes node state (L1505-1508). "Same digest is a no-op" (L978) also stops holding during Recover.
    • v1's pre-start hook needs only the applied config, not credentials (pkg/agent/nspawnlifecycle/lifecycle.go:67-93).
    • Suggestion: save the bundle, or a secret-free manifest of it, in the slot at Provision. Keep secrets out of the digest. Have Hook take only the device and NVIDIA inputs it needs.
  2. Two records of the active slot, with no rule for when they disagree.

    • state.Applied.ActiveSlot (L949) and the host runtime's active-slot.json (L1503) both track it. ErrSlotActive (L753) means the runtime also decides what is "active".
    • A crash after Start B writes the runtime's record but before the node manager commits (L1125) leaves the two out of step. Recover (L1133-1137) only looks at Pending and A's phase.
    • The slot phases (L728) and the host runtime's active-slot phases (L1511-1521) are never mapped to each other, so what Inspect returns for host is undefined.
  3. Nothing defines which slot starts after a host reboot.

    • Section 8.3 says systemd starts "the active slot's boot unit" (L1041), but the interface never says that Start enables a slot or that Stop disables it.
    • v1 enables the machine when it starts it (pkg/agent/phases/nodestart/nspawn.go:99) and disables it on reset (pkg/agent/phases/reset/machine.go:39).
    • The doc should also say that lifecycle hooks do not fire when systemd restarts the node after a host reboot.
  4. Repaves and stuck states can fail with no defined outcome.

    • If B never becomes ready, Recover keeps going back to "continue from Start B" (L1135). Nothing defines a backoff, a terminal state, a rollback, or what status is reported.
    • Nothing says how the node manager reacts when Stop(A) fails with ErrNodeBusy or ErrMountsRemain.
    • In the Decide table, the row for a Node never observed is "Wait" (L1089). A node whose kubelet never registers can never be repaved, because there is no Node object to delete.
  5. The Decide table is missing the case it was designed for.

    • Level-triggered observation is meant to catch a Node deleted while the daemon was down (L1096-1099).
    • If kubelet or someone else recreates the Node in that window, the daemon sees it as "Present" with a different UID. No row covers that.
    • "Mark applied" for changes that do not cause drift (L1091, L1094) does not say whether those changes are applied to the running node or wait for the next repave.
  6. The doc does not say which fields a goal may change.

    • goal.get returns a full AgentConfiguration (L536), which includes runtime.type, plugins, hooks, control, credentials and extensions.
    • It is undefined whether a goal can change the runtime (see Applied.Runtime, L948) or remove an extension. If an extension is removed, its host units and files would stay until a reset (L501).
    • Machina goals carry v1 config, and machina API changes are a non-goal (L160). That v1 config converts with the default runtime, nspawn-container (L347). So it is unclear how a machina-managed host would ever choose host.
    • Bootstrap calls goal.register but not goal.get (L1012), so the goal version recorded at bootstrap is undefined.
  7. The new pre target conflicts with how the nftables flush is re-run.

    • In v1 the flush is pulled in directly by the machine unit (nftables-flush.service:21, RequiredBy=systemd-nspawn@.service). The pre-start hook is also a separate one-shot unit pulled in with Requires= (service-override.conf:37-38).
    • v2 puts unbounded-node-pre.target in between (L508), and itself says that starting an active target does not run its members again (L1584-1585). Only the host runtime is said to restart those units explicitly.
    • NodeReboot on nspawn-container is systemctl restart systemd-nspawn@<m> (L1166). Section 10.9 says both that the flush re-runs on each start (L1586-1588) and that starting an active target does not re-run its members (L1584-1585). With the target in between, both cannot hold for nspawn-container. This should become a validation task.
    • Section 8.3 says the pre-start hook is an ExecStartPre (L1042), but v1 runs it as a separate one-shot unit (config-regeneration.service:11). The doc should pick one.

2. Internal contradictions

Where Contradiction
L311-320 vs L359-360 The example config sets both runtime.nspawnContainer and runtime.host, which validation rejects.
D6 (L173) vs 5.6 (L571-573), 9.1 v2 is said to share only the binary layout. It also shares nftables-flush.service, kubelet.service, containerd.service, localdns.service and /var/lib/machines/kube{1,2}. The doc covers v1 detecting v2 (L661) but not v2 detecting v1. On nspawn-container, a v1 host looks the same as a v2 host.
G4 (L151), the pkg/agent non-goal (L162), §14.5 (L1822) vs L661-666 "v1 unchanged" and "pkg/agent frozen", yet pkg/agent/phases/host/preflight_existing_deployment.go changes, and the change reaches Flex. The new NVIDIA Container Toolkit artifact also changes hack/cmd/agent-artifacts-builder.
5.3 (L382) vs L350, L539 Plugins are said to provide "daemon credentials", but credentials.daemon.type has no plugin value. No config field selects the credentials.cluster method.
L366-368 vs L587 The runtime is said to make the kubelet exec command visible inside the node, yet the Flex mapping also needs a sharedHostPaths entry. Section 9 never covers it.
L1478-1480 Preflight requires cgroup.kill, but a fallback for its absence is also described. The fallback can never run.
L1305 vs L1575 "Delete only what is provably owned" vs deleting iptables chains by KUBE-* and CNI-* prefix.
L1338 vs L1757 Original sysctls are recorded in two places: the host runtime's ownership record and the host preparation undo log (which applies to both runtimes).
L1374-1376 vs the table at L1380-1400 Four cleanup classes are defined, but the table also uses process, mount, network namespace, staged and as declared.
L1386 vs L1529-1534 /var/run/cdi/nvidia.yaml goes through the /var/run symlink, which the safe remover refuses (inference: /var/run links to /run on systemd distros). Use /run/cdi/. The path is also classed volatile, but the Wipe step for persistent paths lists it.
Rule 4 (L764) vs L1505-1508 The rule is "the first Start after Provision is fresh", but host wipes only when the slot or digest changes. Re-provisioning with the same digest does not wipe.
L767 vs L1424 "Restart keeps all state", yet the host restart clears volatile paths.
L1742 Shared hostnet returns []host.Leftover, so a package used by both runtimes depends on the host runtime.
L1632 vs L540 Leftovers are said to go to the status sink, but status.report has no field for them.
L534 vs L410-412 A plugin that fails describe fails startup, yet "a broken hook can never block stopping or cleanup". It is unclear whether v2 reset calls describe.
L1893 vs L261 One says the help text shows "(experimental)", the other "Experimental v2 agent (unstable)".
L52-53 v1's agent-upgrade is listed as visible, but it is hidden (cmd/agent/internal/cmd/agentupgrade.go:70).

3. Underspecified areas

  • Hooks:
    • With wildcard subscriptions, it is unclear whether describe must list every concrete hook.<event> method.
    • The example config subscribes aks to node.start.after twice, once directly and once through node.*.after, with different failure policies (L335-338). Is it called twice?
    • When a wildcard entry has no failure policy, does each concrete event use its own default?
    • On a resumed bootstrap, a running A skips its steps and their events (L1017). A failed node.start.after hook with policy Fail is then never retried.
    • During a repave, node.start.after fires after the commit (L1125-1126), so a Fail failure has nothing to fail.
  • Plugins:
    • Timeouts are "configurable" (L545), but the plugin schema has no timeout field.
    • Response-size limits are not given.
    • The path checks do not say whether parent directories are checked.
    • The status.report phase values are not listed, and "versions" in goal.register is not defined.
  • Credentials: if both the config's cluster.apiServer/caCertBase64 and the bootstrap provider's response supply these values, it is not said which wins.
  • State files:
    • Only owned-paths.json has a schemaVersion. The doc says the on-host layout may change without notice, and an upgrade can roll back to last-good (L1191), so compatibility between binary versions is undefined.
    • node-state.json stores Config, which can include credentials.bootstrap.static.token, but its file mode is not stated.
    • What happens when a .sha256 check fails is not stated.
  • Reset:
    • When reset runs from the CLI, it is unclear when the running daemon is stopped (L1225).
    • The diagram leaves out /etc/unbounded/agent-v2/, and does not say whether the shared binary directory is removed.
  • On-host list: Section 5.6 is presented as the complete footprint that replaces Flex's uninstall.sh (L599). It leaves out:
    • the temporary containerd under /run/unbounded-agent-v2/containerd-maint/;
    • /etc/sysctl.d/99-kubernetes.conf;
    • nftables-clear.nft, the apt drop-ins, the docker daemon.json edit, fstab edits and unit masks;
    • the LocalDNS slice and network unit;
    • the CDI file;
    • extension host files;
    • the first-boot unit.
  • Extensions:
    • No rule stops an extension's node or host files from colliding with built-in paths such as /etc/containerd/config.toml.
    • Nothing covers an extension host file that already exists.
    • It is unclear which unit nodeDependencies.requires attaches to on host.
    • sharedHostPaths entries where hostPath and nodePath differ are undefined on host.
  • host runtime pre-start hook: the doc does not say which unit runs it (L1237, L1042). Restart also runs it directly (L1424).
  • Smaller items:
    • What "from the host" means for v2 agent-upgrade, and whether upgrade hooks fire when it is run from the CLI.
    • Where components.* and artifacts.downloads overlap.
    • How v1 config fields map to v2 when converting.
    • Whether adoptPreserveMaxBytes applies per path or in total.
    • adoptExistingPaths[].mode reuses the name of the file mode field. Something like policy would be clearer.

4. Clarity and terminology

  • Decision IDs:
    • Section 3's D1-D6 and section 17's D1-D5 repeat each other.
    • O1-O11 (no O5) and H-D1 to H-D6 are never defined.
    • O6 is recorded as decided (L1901) and also listed as open (L1921).
  • Event names: the middle part is called a "state", but the values (install, start, repave) are actions, and the image-import example matches no event (L391). <subject>.<action>.<before|after> would be clearer.
  • "A" and "B": the repave diagrams and lifecycle table (L443, L1112-1128) use A and B as roles, but they are also the literal slot names. After the first repave the active slot is b. Use "old" and "new".
  • "Hook": the word means four things: plugin lifecycle hooks, NodeRuntime.Hook, v2 runtime-hook and HookPhase. Renaming the runtime side to "boot hook" would separate them.
  • "Slot" for host: the glossary says a slot "holds a node" (L133). On host, a slot only holds staged binaries and files; the node's state lives in shared paths.
  • G2 "parity": the intended differences from v1 are spread across sections 8.4, 8.5, 10.9, 11, 12 and 13. Collect them in one "Behavior changes from v1" table and reword G2 as "parity except where listed".
  • Upstream behavior: some statements are not marked (inference), such as the target member re-run behavior, kubelet certificates living in /var/lib/kubelet, and cgroup.kill needing Linux 5.14.

5. Simplification

Design:

  1. Defer lifecycle hooks or cut them down. None of the AKS Flex Node mappings in section 5.7 (L581-599) uses a lifecycle hook. The 16 events, wildcards, default failure policies and hook config have no identified consumer. If they stay, use one hook method with the event in the payload. That also solves the describe question above.
  2. Defer Preserve adoption and retainContainerImages. Preserve needs backups with xattrs, SELinux labels and a size cap. retainContainerImages needs a temporary containerd and prototype V2. The defaults, refusing existing content and wiping images, are already the simple behavior.
  3. Use one record for the active slot. Either the runtime owns it and reports it through Inspect, or the node manager does. Drop NeverStarted, which repeats Phase == Provisioned.
  4. Keep the nftables flush as a direct dependency of the boot unit instead of routing it through the pre target.
  5. Let systemd do the stopping. Use systemctl stop unboundednode.slice with a stop timeout. systemd already sends SIGTERM and then SIGKILL across everything in the slice (inference). Keep the cgroup.kill logic as verification and fallback.
  6. Unify credentials. Use one exec or plugin mechanism, with a clear table of which credential each type serves.
  7. Make control.operations a boolean, since its values are only machina and disabled.
  8. Optionally split NodeRuntime (14 methods) into a planning interface (HostRequirements, Preflight, OwnedResources, Layout) and a lifecycle interface.

Document:

  • Move section 10 into a companion designs/agent-v2-host-runtime.md, and keep the main doc to the external contract, interfaces and lifecycles.
  • Merge sections 3 and 17 into one decision table with a status column.
  • Move the host network reset out of section 10, since it applies to both runtimes.

6. Citation accuracy

Of the 93 local citations, 83 are accurate. The other 10 point to the wrong lines or only partly support the claim; none point to the wrong code entirely.

Doc line Problem
L58 coordinator.go:29-38 These lines are only the Stages interface. The lock is at :72, the run classification at :83-115, and the stage loop at :126-135.
L62 phases.go:17-20 Serial is at :62 and Parallel at :87.
L79 bootstrap.go:220 This line is about saving the applied config. The machinectl list call is at pkg/agent/phases/reset/machine.go:256.
L83 lifecycle.go:67-129 This is the Go code. The systemd wiring is service-override.conf:37-38,48 plus config-regeneration.service.
L365 config.go:615-623 authorization.mode is also reserved (:604-612).
L1241 resolve.go:163-166 Applies only when LocalDNS is enabled. It is the resolvConf config field, not a --resolv-conf flag (kubelet.go:129-130).
L1580 nftables-flush.service:20-21 This is RequiredBy=, not ordering. The ordering is service-override.conf:39.
L1759 configure_docker.go:30-66 Masking docker is in disable_systemd_unit.go:30-33,59-75.
L1391 bootstrap_helpers.go:263 The directory is created at :314-319.
L1238 Containerfile:21-31 Off by one; the block is lines 22-32.

Two more code-versus-doc disagreements could go into section 19:

  • api/machina/v1alpha3/machineoperation_types.go:51-53 says "custom operations may be supported", but the enum on the next line is closed. This matters for open question 1.
  • internal/provision/assets/unbounded-agent-uninstall.sh reverses host preparation, but UnboundedAgentUninstallScript (internal/provision/script.go:35) is only called from tests. This is relevant to section 11's claim that v1 never reverses host preparation.

Restructure only, in response to review. No design content changes.

- Move section 10 (host runtime cleanup) to
  designs/agent-v2-host-runtime.md, renumbered 1-14, and leave a short
  summary in the main doc.
- Move the host network reset, which applies to both runtimes, into the
  main doc as section 11, together with the hostnet package sketch.
- Merge the core decisions (section 3) and the decision log (section 17)
  into one decisions table with a status column, numbered D1-D32. Open
  questions from section 18 appear as open rows.
- Renumber sections 11-16 to 12-17 and update cross-references.
- Cite the bootstrap coordinator's lock, classification and stage loop,
  and phases.Serial and phases.Parallel, not only the interfaces.
- Distinguish the bootstrap.go call sites: the rootfs check is :161, the
  applied-config check is :220, and machinectl list is in reset/machine.go.
- Cite the nspawn hook wiring (service-override.conf, the separate
  config-regeneration unit), not only the Go code.
- Include authorization.mode in kubelet's reserved fields, describe
  resolvConf as a config field set only when LocalDNS is enabled, and
  fix the Containerfile, docker masking, nftables-flush ordering and CNI
  directory line numbers.
- List v1's agent-upgrade command as hidden.
- Add three code/doc disagreements: the closed OperationKind enum, the
  v1 uninstall script that only tests call, and v1's repave reusing a
  partially unpacked rootfs.
- Save the bundle manifest in the slot at Provision, with a digest that
  leaves out secret contents. Boot hooks and Recover read it and never
  render again, so a refreshed bootstrap token cannot trigger a wipe.
- Make the runtime the only authority on which slot is enabled and
  running. Drop Applied.ActiveSlot and SlotState.NeverStarted, add
  SlotState.Enabled, and define how Recover reconciles the runtime's
  slot digests with the applied and pending goals.
- Start enables a slot and Stop disables it, as v1 does with machinectl;
  no lifecycle hooks fire when systemd starts a node at boot.
- Rename the runtime's hook to BootHook, BootPhase and v2 boot-hook.
- Clarify rule 4 (identical content is not new content) and Restart
  (volatile state under /run is lost).
- Define repave failures to match v1: indefinite retries with
  controller-runtime's default backoff, no rollback, no restart of the
  old node.
- Add Decide rows for a recreated Node and for a node that never
  registers (reported as NodeNotRegistered after 10 minutes), and define
  what recording a goal version as applied means.
- Split install-time settings from goal settings; goal.get returns goal
  settings only. Bootstrap records an empty goal version.
- Explain that machina-provisioned hosts always get nspawn-container.
- Use old and new instead of A and B in the repave lifecycle.
Review found that routing the nftables flush and other prerequisites
through unbounded-node-pre.target would stop them from running again on
a restart, because starting an active target does not start its members
again.

- Remove unbounded-node-pre.target. Prerequisite oneshots attach
  directly to each runtime's boot unit, as v1's flush does:
  systemd-nspawn@.service for nspawn-container (RequiredBy= plus After=
  in the machine override), and a new unbounded-node-prestart.service
  for host.
- Run the pre-start boot hook as a separate oneshot unit, as v1 does,
  not as ExecStartPre. Post-start stays ExecStartPost on
  nspawn-container; host needs none.
- Keep unbounded-node.target only as the host runtime's internal
  grouping of its node units, not as a consumer contract. Extensions say
  orderBeforeNode and the runtime writes the wiring.
- Add validation task V5: prerequisites and the pre-start hook run again
  on every start of the boot unit.
- Use one plugin method, hook, with the event name in the payload, so
  describe lists it once regardless of wildcard subscriptions.
- Name events <subject>.<action>.<before|after>; the middle segment is
  an action, not a state.
- Reject two subscriptions from the same plugin that can match the same
  event, and fix the example config that did this.
- A wildcard entry without a failure policy uses each event's default.
- On a resumed bootstrap, fire the after event of each step that is
  already done again, with resumed: true, so a failed hook is retried
  before the install completes.
- Fire node.start.after for the new slot before the repave commit, so
  a failing hook leaves the repave pending and it is retried.
- Fire agent.upgrade.before from v2 agent-upgrade as well as from the
  daemon, and use old and new instead of A and B in the hook tables.
Contradictions:
- Show one runtime block in the example config, with the host block
  separately.
- D6: v2 shares some unit and machine names with v1, not only the binary
  layout. Add v2's detection of a v1 install.
- List every change outside internal/agentv2 (the v2 command, the v1
  detection check, the artifacts builder) and reword G4, the pkg/agent
  non-goal and the export policy to match.
- credentials.daemon gains plugin, which selects credentials.cluster.
  Kubelet's exec stays, since kubelet runs it itself. The runtime makes
  the kubelet exec command visible in the node, so Flex needs no shared
  host path for it.
- Drop the cgroup.kill fallback that preflight made unreachable, and let
  systemd stop the slice, with cgroup.kill as a backstop.
- Scope "delete only what is provably owned" to files; network objects
  are deleted by node-only names or declarations.
- Keep original sysctl values only in the host preparation undo log.
- Replace the inconsistent class column with "cleared at".
- Write the CDI spec to /run/cdi, not through the /var/run symlink.
- Move Leftover to the shared runtime package, and report leftovers
  through status.report.
- Reset never fails because of a plugin.

Gaps:
- Plugin timeouts, output limits, parent directory checks, status
  phases and goal.register fields.
- Credential precedence, and components versus artifact sources.
- State file versioning, checksums and modes.
- The order of stopping the daemon on reset, and removal of the agent's
  configuration and binary directories.
- A complete list of what v2 creates on the host.
- Extension collisions, existing host files and shared host paths.
- The v2 agent-upgrade path, control.operations.enabled, the adoption
  policy field, and a v1 configuration mapping (appendix A).
- Reword G2 as parity except for intended changes, and add section 14.1,
  one table of every intended behavior change from v1 with the section
  that explains it.
- Fix the glossary's slot entry: on host a slot holds only staged
  content, and the node's state lives in shared paths.
- Mark the remaining upstream claims as inference: containerd's CDI
  directories and kubelet's certificate directory.
- Rename open question 5 to the boot hook binary and cite v1's helper.
The AgentUpgrade lifecycle said "inactive slot" for the blue or green
agent binary, which reads like a node slot. Say blue or green binary.
@plombardi89

Copy link
Copy Markdown
Collaborator Author

AI-assisted response (Claude via OpenCode), reviewed with the PR author. Section numbers refer to the latest commit. The host runtime cleanup now lives in designs/agent-v2-host-runtime.md, called "host runtime doc" below.

Thanks for the thorough review. I verified each claim against the code before acting on it, and all but one checked out exactly (see citations below). Here is what changed, grouped like the review.

1. Gaps: all fixed

  1. Re-rendering triggers a wipe. Provision now saves the bundle manifest in the slot. The digest covers secret files' paths and modes but not their contents, so a refreshed token cannot change it. Boot hooks and Recover read the saved manifest and never render again. Hook became BootHook(ctx, phase, slot), with no bundle and no credentials (7.1, 7.2).
  2. Two records of the active slot. The runtime is now the only authority on which slot is enabled and running. Applied.ActiveSlot and NeverStarted are gone. Recover reconciles by bundle digest using a table in 7.7. Host runtime doc §8 maps the host runtime's record to the SlotPhase that Inspect returns.
  3. Which slot starts after a reboot. Start enables the slot and disables the other; Stop disables it, as v1 does with machinectl. No lifecycle hooks fire for a boot started by systemd (7.1, rules 6 and 7; section 9 table).
  4. Failed repaves and stuck states. These now match v1, by decision (D33, D34): retries never stop and use controller-runtime's default backoff; there is no rollback and the old node is not restarted. Each attempt reports Failed with the attempt count. A failed Stop(old) ends the attempt before Start(new) (8.5). A node that never registers reports NodeNotRegistered after 10 minutes, but is not repaved without a Node deletion. That matches v1, and recovery stays manual (8.4). We decided against both a final failure state and repaving without a deletion.
  5. Decide gaps. Added rows for a Node that was deleted and recreated, and defined what "record the goal version as applied" means (8.4).
  6. Which fields a goal may change. Settings are now split into install-time settings and goal settings. goal.get returns goal settings only, so a goal cannot change the runtime, plugins, hooks or extensions. Machina-provisioned hosts always get nspawn-container, and the doc now says so. Bootstrap records an empty goal version, which the first reconcile settles, as in v1 (5.2, 7.3; D35).
  7. The pre target breaks re-running the flush. unbounded-node-pre.target is removed. Prerequisites attach directly to each runtime's boot unit, as v1's flush does. The pre-start boot hook is a separate oneshot unit, as in v1, not ExecStartPre. Validation task V5 covers re-running on restart (8.3, 11).

2. Contradictions: all fixed

  • The example config shows one runtime block.
  • D6 now lists the unit and machine names shared with v1. v2 also detects a v1 install (6.3).
  • G4, the pkg/agent non-goal and the export policy list the exceptions: the detection check and the artifacts builder change (6.3, 15).
  • credentials.daemon gains plugin, which selects credentials.cluster. The runtime makes the kubelet exec command visible in the node, so the Flex mapping no longer needs a shared host path.
  • The cgroup.kill fallback is dropped. The principle of deleting only what is owned is scoped to files. Original sysctl values live only in the undo log. The owned-resources table has a "Cleared at" column. The CDI spec moved to /run/cdi.
  • Rule 4 and Restart are reworded.
  • Leftover moved to the shared runtime package, and status.report carries leftovers.
  • Reset never fails because of a plugin.
  • The help text is consistent, and v1's agent-upgrade is listed as hidden.

3. Underspecified areas: all addressed

  • Hooks:
    • one hook method, with the event name in the payload;
    • overlapping subscriptions from one plugin are rejected;
    • wildcard entries use each event's default policy;
    • on a resumed bootstrap, after events fire again with resumed: true;
    • node.start.after fires before the repave commit (5.3).
  • Plugins: timeouts per method, a 1 MiB output limit, checks on parent directories, the status.report phases and the goal.register fields (5.5).
  • Credentials and artifacts:
    • which credential each setting serves, and which values win;
    • components holds versions, artifacts.downloads holds sources (5.2).
  • State files: a schemaVersion on each, only additive changes so a rollback can still read them, StateCorrupt on a bad checksum, and mode 0600 (7.7).
  • Reset: the CLI stops the daemon first, and reset removes /etc/unbounded/agent-v2/ and the binary directory (8.8).
  • What v2 creates on the host: a complete list in four groups (5.6).
  • Extensions: collisions, existing host files, and the rule that hostPath equals nodePath on host (5.4).
  • Smaller items:
    • the v2 agent-upgrade path (8.7);
    • the adoption field is now policy, and the size cap is per path;
    • appendix A maps v1 configuration fields to v2.

4. Clarity: all fixed

  • Sections 3 and 17 are one decisions table (D1-D37) with a status column, and the undefined O and H IDs are gone. O6 is split into D11 (decided) and D12 (open).
  • Events are named <subject>.<action>.<before|after>.
  • Repaves say old and new, not A and B.
  • The runtime side is called "boot hook" (BootHook, v2 boot-hook).
  • The glossary entry for slot is fixed.
  • G2 now says "parity except where listed", backed by a new behavior-changes table (14.1).
  • The missing (inference) marks are added.

5. Simplification

  • Accepted:
    • 3, one record of the active slot;
    • 4, the flush as a direct dependency;
    • 5, systemd stops the slice, with cgroup.kill as a backstop;
    • 7, control.operations.enabled;
    • all three document changes: the companion doc, merged decisions, and the network reset moved to the main doc as section 11.
  • Partly accepted: 6, unify credentials. Daemon credentials are now csr, kubeconfig or plugin, and the doc has a table of which credential each setting serves. Kubelet's exec stays separate: kubelet runs that executable itself, through client-go's exec credential protocol, so it cannot use our plugin protocol.
  • Not adopted:
    • 1, defer hooks. The maintainers decided to keep them in the first implementation (D37). Their semantics are fixed instead; see section 3 above.
    • 2, defer Preserve and retainContainerImages. Kept in the first implementation by decision (D36). Validation task V2 still gates image retention.
    • 8, split NodeRuntime. In Go, the code that uses an interface declares the small one it needs: hostprep can take an interface with just HostRequirements, preflight one with just Preflight. Splitting the implementer's side adds names without adding safety, and the conformance suite tests a runtime as a whole.

6. Citations

  • Nine fixed as described.
  • One correction to the review: bootstrap.go:220 is the machinectl list lookup (reset.RegisteredMachine), but it decides whether to save the applied config, not whether to build the rootfs. The doc now cites :161 for the rootfs decision, :220 for the applied-config decision, and pkg/agent/phases/reset/machine.go:256 for machinectl list.
  • Section 19 gains both suggested disagreements, plus one found while fixing citations: a v1 repave reuses a partially unpacked rootfs after a crash (pkg/agent/phases/rootfs/oci/task.go:61-64). v2 avoids it with a completion marker.

Commits

Commit What
2e2d905 Restructure only: companion doc, merged decisions, network reset moved
91f58bc Citations and factual fixes
b11be46 State model and lifecycles (gaps 1-6)
1f96397 systemd wiring (gap 7)
02464ef Hook semantics
602acc0 Contradictions and underspecified areas
dd42a6a Clarity and behavior-changes table
43959cd Agent binary slots vs node slots wording

Still open for reviewers, in section 18: MachineOperation kind scoping (D12), separate v2 state names (D6), the default runtime (D29), copying rendering templates (D30), the boot hook binary (D31), and observing the Node on every reconcile (D32).

Remove sections 19 (code and documentation disagreements) and 20
(alternatives considered). Keep the two facts other sections rely on:
the v1 partial-rootfs evidence moves inline into section 8.5, and the
closed OperationKind enum moves into open question 1.
The decided rows restated what the body already says. Section 3 now
holds the open questions, moved up from section 18 so reviewers see them
first; section 18 is removed and sections 4-17 keep their numbers.

- Drop decision IDs from the questions and the body. The corrected
  description of shared v1 names moves into question 2.
- Keep the two points only the table made: why node content is a
  declarative bundle (now in section 4) and that there are no in-process
  Go plugins (now in section 5.3).
- Point references to the old section 18 at section 3, and update the
  review guide.
Consistency:
- A hook's timeout is set only by hooks[].timeout (default 60s); drop
  "hook" from plugins[].timeouts.
- The plugin request context uses machineName and nodeName, matching
  goal.register.
- Only the daemon CSR group is configurable; there is no name field.
- Conservative host Teardown no longer lists host preparation units.
- State the containerd shim point once in the host runtime doc.
- Export decisions are recorded in a design doc, not appended here.
- Drop the vague "documentation must say this" sentence.

Cuts, where text repeated other sections:
- The list of Flex additions in 1.3 (see 5.7), a duplicate "why a
  bundle" bullet, the logging implementation note in 5.1, the
  credential rows in the enumerated fields, the before/after naming
  rationale, the exec model paragraph, the bundle naming note in 7.2,
  the v1 comparisons in 7.7, 8.4 and 8.5 (their citations move to
  14.1), the repeated flush wiring in 11, and the uninstall script note
  in 12.

Wording:
- "written durably" becomes "written and synced to disk".
- "status sink" becomes "status reporter" (StatusReporter); the
  control.status field is unchanged.
- Install check: the fingerprint covers install-time settings only. On a
  complete install, v2 start accepts different goal settings and leaves
  them to drift handling. A resumed install fails only if the node
  already started from a different bundle. Listed in 14.1.
- static goal source: the daemon watches its copy at
  /etc/unbounded/agent-v2/config.json. Goal-setting edits are a new goal;
  install-time edits are ignored with a warning.
- Add control.goal.pollInterval for the plugin goal source, default 10m
  with up to 10% jitter.
- A goal change during a repave replaces Pending if the new slot has not
  started; otherwise the repave commits and the new drift needs another
  Node deletion.
- Status phases table with reasons. Ready means the Node object has been
  seen; until then the phase is Provisioning. Failed carries
  StateMismatch, StateCorrupt and RepaveFailed. UnsupportedOperation is
  set on the MachineOperation, not on the node's phase.
- Readiness waits use v1's timeouts: 2 minutes for the kubeconfig at
  bootstrap, 60 seconds for kubelet after a repave or reboot, 1 minute
  for LocalDNS.
- The LocalDNS network unit is node content: the node manager writes it
  from the bundle's HostUnits before every Start, as v1 does, and the
  host network reset removes it. It leaves the host preparation lists.
- The host runtime refreshes observedHostPaths whenever Inspect runs
  while containerd is up; Observe calls Inspect on every reconcile, and
  Terminate uses the last list.
Restructure only, no design changes. The design was hard to review as
one long document, so it moves to designs/agent-v2/:

- README.md: purpose, goals, open questions, architecture, terms, and
  a table of the documents with who should read each one. It replaces
  the review guide.
- 01-cli-and-configuration.md, 02-extensions.md, 03-node-runtime.md,
  04-control-and-state.md, 05-lifecycles.md, 06-host-changes.md,
  08-v1-compatibility.md and 09-implementation.md take the sections of
  the old main doc.
- 07-host-runtime-cleanup.md is the former companion doc. Old section
  10, which only pointed to it, is removed.

Each document numbers its own sections. Cross-references became links
to the target document and section, or plain text inside code blocks
and diagrams.
Review set a rule: v2 should have the same outcomes as v1 unless there is
a very good reason. This changes v2 to match v1 in five places:

- Only a Node deletion the daemon sees as it happens starts a repave, as
  in v1. A Node deleted while the daemon is not running is ignored, so
  stopping the daemon is a safe escape hatch. Node UID tracking is
  removed and open question 6 is settled.
- The goal version and the Machine's RepavePending condition are
  recorded only when the daemon handles a Node deletion, as in v1.
- The nspawn pre-start boot hook finds host devices again at every
  machine start and rewrites the machine's host-side config, as v1 does.
  Device paths are not part of the bundle digest.
- The install check uses v1's fingerprint (Kubernetes version, OCI image
  and API server) plus the install-time settings, and v1's resume
  behavior: a running node is kept, and the settings saved in its
  manifest are recorded as applied.
- On host, kubelet gets a new boot ID at every node start, so a
  NodeReboot shows the same signals as on nspawn-container. Validation
  task V6 covers it.

One new difference, for self-healing: Pending is saved to disk as soon
as the daemon sees a deletion that needs a repave, so a daemon or host
restart before the repave finishes no longer loses it.

The behavior changes table in 08 now states, for each kept difference,
the situation, the v1 outcome, the v2 outcome and the reason, and lists
where v2 matches v1 on purpose.
Existing v1 users need an upgrade path, so in-place migration is no
longer a non-goal. The new document, designs/agent-v2/10-migration-from-v1.md,
specifies it:

- v2 takes over the running nspawn machine as its slot and replaces only
  the agent around it, so pods keep running and the Node and Machine are
  unchanged. v1's daemon certificate is reused, so no bootstrap token is
  needed.
- A migration starts from the host with v2 migrate, or from the cluster
  with an AgentUpgrade that has the parameter agentMode: v2, which a
  machineSelector can apply to a fleet. Older daemons ignore the
  parameter, so one operation works from any v1 release that supports
  AgentUpgrade.
- It runs as a unit with a phased migration record, so a crash or reboot
  resumes it. Any failure before it commits rolls back to v1. Rollback
  stays possible until v2's first repave.
- v1's applied config, credentials, Machine status and host preparation
  are converted. Host preparation changes v1 made without a backup are
  left in place and reported at reset, as a v1 reset would.
- Machines built by other pkg/agent consumers, such as AKS Flex Node,
  can be taken over with --adopt-machine and a supplied configuration,
  after the v1 path.
- Moving to the host runtime stays a reset and a new start.

Also: goal G6, the README documents table, v2 migrate in the CLI, a
migration entry in the lifecycles, the migration paths in the host
changes list, the v1 daemon change in the list of changes outside
internal/agentv2, and the migrate package.
The new document, designs/agent-v2/11-testing.md, takes over the testing
section of 09 and adds:

- The v1 end-to-end suites as v2's acceptance tests. The harness gains
  AGENT_PROFILE (v1, v2) and NODE_RUNTIME (nspawn-container, host); a
  profile object holds the commands, names, state reads and node access
  that differ. Every swapped check is listed against its v1 line, and
  v1 runs must stay identical, including the harness tests that pin
  suite contents.
- Checking reset against a host snapshot taken before install.
- A migration-v2 suite modeled on the existing host-root migration
  suite: no pod restarts, the same Node, v1's lifecycle steps afterwards,
  and variants for rollback, interruption in every phase, failure in
  every phase, refused checks, moving to the host runtime and immutable
  hosts. Interruptions are injected from outside, as the existing
  bootstrap-recovery test does, without failure points in the product.
- End-to-end tests for what v1 never had, including a Node deleted while
  the daemon is stopped and a daemon restart after a seen deletion.
- CI on pull requests and nightly, and validation tasks, with V7 for the
  host runtime on immutable hosts.

To support the harness, v2 status --output json becomes a documented
interface, and a hidden v2 convert-config command converts a v1
configuration. A migration risk is added to the risks.
All five open questions are settled:

- MachineOperation scoping: custom kinds are not a feature; the agent
  owns NodeReboot, AgentUpgrade, AgentReset and HostOSReboot plus future
  Node* and Agent* kinds. The API comments are fixed in #1045.
- Names: contract-level names (units, commands) are final and shared
  with v1: unbounded-agent-daemon.service, its recovery unit, and
  unbounded-agent-migrate.service. Internal state keeps -v2 names. v1 and
  v2 now detect each other by install records, and v1's daemon and reset
  refuse on a v2 host.
- nspawn-container stays the default runtime, and templates are copied.
- Boot hooks run from unbounded-agent-boot-hook, a copy of the binary
  written at every provision that a rollback does not revert, as v1 does.

v1 is not supported after the first official v2 release, so the
migration doc gains a release plan: a last v1 release (N) whose daemon
can undo a migration, and a first official v2 release (N+1) whose daemon
migrates a v1 host on its first start after an ordinary AgentUpgrade.
Rollback goes back to N, upgrades must go through N, and the binary
rollback slot always holds a binary that can undo the migration.

Also:
- The migration health check is v1's (stable for 3 seconds within 30),
  replacing the 5-minute wait.
- --adopt-machine is required for converting AKS Flex Node hosts and is
  part of the first implementation.
- Fleet migrations use one AgentUpgrade per Machine: a machineSelector
  operation reports one combined result (#1040).
- HostOSReboot, added on main after the baseline, is handled as in v1.
- v2 CI jobs do not block merges while v2 is experimental; the testing
  doc adds cutover and adopted-machine variants.
The design cited two commits: dca6c96 as its baseline, and e132dfa
for HostOSReboot, which landed later. Move the baseline to e132dfa so
every citation points at one commit, and drop the "at e132dfa" and
"after this design's baseline" wording.

Between the two commits, the HostReboot split moved code in the v1
daemon. Update the citations that moved:

- nodeoperator.go: the interface, hasDrift, RestartNode, RepaveNode and
  the applied-config lookup
- controller.go: the Node delete watch
- controller_machineoperation.go: AgentUpgrade handling and the startup
  gate
- daemon.go: Machine registration

v1's daemon now handles four MachineOperation kinds through
machineOperations.handlers(), and publishes HostOSReboot results at
startup. Say so in the README, the reconcile diagram and the startup
gate, cite metalman for not executing HostOSReboot, and list
hostosreboot.go and the pkg/agent/daemon helpers it uses among the code
v2 copies or imports.

Citations to files unchanged between the two commits are unchanged.
Wording and consistency fixes, with no change to the design:

- The host runtime's Teardown no longer lists the host network reset and
  the undo log replay, which the node manager runs after it.
- The host runtime enables unbounded-node.target, as section 4 says,
  not the node units.
- The state file mode rule covers state files and secrets, not slot
  binaries or the artifact cache, and the active-slot record has a
  schemaVersion like every other state file.
- nodeDependencies.requires units are required by the boot unit, because
  the agent never edits host units it does not own.
- The approved-repave state field is renamed from Pending to Repave, so
  it no longer shares a name with the Pending status phase.
- Add the NodeChanged trigger, and list v2 reset among the commands
  that call describe.
- A converted v1 configuration enables MachineOperations; the setting is
  a boolean now.
- The LocalDNS template contract lists all six variables, including
  NodeUpstreamIPs.
- The machina goal source says exactly what v1 reads: the applied
  configuration with the referenced version's template overlaid.
- The glossary no longer says a goal version changes whenever the goal
  does, which is not true for machina (#1038), and defines the terms used
  across documents.
- Remove leftover Signal and Sysctl operations from the cleaner's system
  boundary, call v1's nspawn hooks boot hooks, and fix stale section
  references and the CLI list in the architecture diagram.

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.

AI-assisted review (Claude via OpenCode). Findings were checked against the code where cited, but please verify each one before acting on it.

This review covers everything since my first review. Line references like 03-node-runtime.md:247 are to designs/agent-v2/ at 4160c05ed. Code citations are at the design's new baseline, e132dfada.

Pushed to the branch:

  • 56729bd68 moves the baseline from dca6c964d to e132dfada, so every citation points at one commit. It updates the citations that moved and adds HostOSReboot where v1's handler list, the reconcile diagram and the startup gate were missing it. It deliberately stops before #1034, whose kind renames are still in progress.

  • 4160c05ed fixes wording and consistency, with no design change:

    • the Teardown flowchart;
    • which unit host enables;
    • the 0600 rule, and schemaVersion on the active-slot record;
    • nodeDependencies.requires wiring;
    • the Pending state field renamed to Repave;
    • the NodeChanged trigger;
    • the six LocalDNS template variables;
    • glossary terms;
    • stale references.

    Each item is listed in its commit message.

The earlier review: nearly every finding from it is fixed in the docs, not just in the reply. Two notes on the reply:

  • It cites decision IDs (D11-D37) that no longer exist, because the decisions table was removed.
  • It says the first reconcile settles an empty goal version. The docs instead record the goal version only when the daemon handles a Node deletion (04-control-and-state.md §1). The docs are consistent with each other; only the reply is wrong.

1. Migration and cutover (doc 10)

  1. At the first official v2 release (N+1), a migration failure can loop.
    • §6 says any failure before Committed rolls back to v1 automatically (10:203).
    • The rollback's step 3 is "start v1's daemon" (10:246). At N+1 the daemon unit runs <current> daemon, which is N+1's v2 daemon. That daemon would start the migration again.
    • §2 describes the intended path instead (10:53-57): N+1 exits, and the recovery unit restores N, whose daemon undoes the migration.
    • Suggestion: state that N+1 never performs the rollback itself. It should exit with a normal failure code, not the deferred exit code the unit treats as success (unbounded-agent-daemon.service:17-22).
  2. After the rollback window closes, a daemon crash loop can leave the host without an agent.
    • v1's recovery unit restores last-good after any repeated daemon failure, not only during an upgrade (unbounded-agent-daemon.service:8-10, unbounded-agent-daemon-recovery.sh:11,26).
    • After a cutover, last-good is N. Once the first repave deletes the backup, N cannot undo anything, and it refuses a v2 host (08 §4).
    • Suggestion: when the backup is deleted, point last-good at the running v2 binary.
  3. The health check before commit is circular at N+1. 10:198-202 reuses v1's "stable for 3 seconds within 30" check (hostupgrade.go:23-26). At N+1 the daemon running the migration is the daemon being checked.
  4. The early migration from the cluster is underspecified (10:103-134):
    • who publishes the AgentUpgrade result;
    • how the v2 daemon learns the migration committed (10:117) and which operation it belongs to, given v1 and v2 keep separate upgrade signal files;
    • how the v2 daemon's startup gets the install lock while v2 migrate holds it (10:168) (inference).
  5. "Upgrade to N first" cannot reach the operation as written (10:63-66, 11:193). v1's recovery script records a fixed message (unbounded-agent-daemon-recovery.sh:19-20). N+1 would have to record its own reason before exiting.
  6. The check "no other v1 MachineOperation in progress" (10:144) can't be computed for selector operations. They share one status across agents, and agent kinds other than HostOSReboot record no per-Machine targets (#1040). Suggest counting only machineRef operations for this Machine.

2. Node runtime

  1. kubelet and containerd restarts would flush the firewall and look like a reboot.
    • Each host node unit Requires= unbounded-node-prestart.service (03:247).
    • The doc says these oneshots run again on every start of a unit that requires them (03:275-280).
    • kubelet and containerd have Restart=always (pkg/agent/phases/nodestart/assets/kubelet.service:8, containerd.service:12).
    • So each kubelet or containerd crash could re-run flush ruleset on a live node, and write a new boot ID, which gives the Node a false Rebooted event (inference about systemd's handling of Requires= on automatic restarts).
    • v1 never does this, and it isn't in the behavior-changes table (08 §2).
    • Suggestion: have only unbounded-node.target require prestart. Order the node units After= it without Requires=, and extend V5 and V6 to cover a kubelet restart.
  2. A refreshed bootstrap token may never reach the slot.
    • Secret contents are left out of the digest (03:199-201).
    • "Provision, same digest is a no-op" (03:225).
    • A repave retry that fetches a new token would keep the expired one in the slot.
    • Suggestion: Provision always rewrites secret files.
  3. The Recover table can match two rows (04:164-167, inference).
    • On nspawn-container, Layout does not depend on the slot.
    • The image reference is in neither NodeSpec nor NodeBundle.
    • So an image-only repave can give the same digest for old and new.
    • Suggestion: compare Repave.Slot with the enabled slot, or put the image reference in the digest.

3. Interaction with the machina API (cross-checked against #1045's findings and issues #1038-#1044)

  1. The rule for agent-owned kinds misses HostOSReboot itself.
    • 04:268-272 treats the known kinds plus future Node*/Agent* kinds as the agent's. HostOSReboot is a Host* kind the agent owns, so a future agent-owned Host* kind sent to an older agent would stay pending forever, the case this rule exists for.
    • Failing an unknown selector operation would also fail it for every matching agent: status is shared, and agents skip it once it is terminal (pkg/agent/daemon/machineoperation.go:105-107, #1040).
    • Suggestion: an explicit list of agent kinds, and UnsupportedOperation only for machineRef operations.
  2. Which Machine fields the machina status reporter writes is unspecified (02:199-213, 04 §1).
    • machina owns Machine.status.phase and branches on it (cmd/machina/machina/controller/machine_controller.go:276-310). v2 must not write its Pending/Ready/Failed phases there.
    • NodeNotRegistered, RepaveFailed, StateMismatch and StateCorrupt have no field on the Machine without an API change, which is a non-goal.
    • AgentBootstrapped reasons include NSpawnFailed, which the host runtime needs a mapping for.
  3. Gaps v2 inherits but doesn't mention:
    • #1039, provisioning ignores configurationRef: after bootstrap or migration, any Machine whose referenced version differs from what the node was built from will show Pending, since v2 now evaluates drift on every reconcile. That differs from v1, which only checks on a Node deletion. HostReplace and metalman reinstalls also produce v1 configurations, so a host runtime host comes back as nspawn-container (01:210-215 mentions only machina).
    • MachineConfigurationVersion's agent block on host: the block is required, and its image overrides the nspawn image. How it maps onto a host install is unspecified, and the e2e template has no agent block, so tests won't catch it.
    • #1043, TTL: the agent never deletes operations. The fleet migration's one-operation-per-Machine pattern (10:132-134) piles up on clusters without machine-ops-controller or metalman. v2's copy of the reconciler is where #1043's fix would go.
    • #1044, HostOSReboot serialization: v2's handler could wait for older out-of-band operations on the same Machine, or defer to #1044 explicitly (05 §4).
  4. The new agentMode parameter isn't listed among changes outside internal/agentv2 (08 §4). The parameters API comment and the CRD reference list the accepted keys exhaustively, so both need updating.

4. Testing (doc 11)

  1. The cutover variants need builds of N and N+1 before those releases exist (11:190-193). Say how CI produces them, for example a build setting that selects N or N+1 behavior.
  2. The test for a Node deleted while the daemon is stopped (11:206-207) needs kubelet to register again. That only happens on a kubelet restart (inference), so the test should say how it triggers one.
  3. The host snapshot (11:127-133) needs an allowlist for sysctl -a keys that change on their own, such as entropy, inode and dentry counters.
  4. At N+1, v1 commands are removed, so AGENT_PROFILE=v1 jobs stop working. §6 should say what replaces them as required jobs.

5. Optional

  • The reasons for keeping lifecycle hooks and Preserve/retainContainerImages in the first implementation went away with the decisions table. One sentence each, where those features are described, would save reviewers asking again.

- The first official v2 release never rolls a migration back itself. It
  has no v1 commands, so starting v1's daemon would only start it again.
  It exits with an ordinary failure code, which the daemon unit escalates
  to the recovery unit, and release N's daemon undoes the migration.
- When the first repave deletes the migration backup, it points the
  binary rollback slot at the running v2 binary. The recovery unit
  restores that slot after any repeated daemon failure, and after the
  window release N refuses a v2 host.
- The pre-migration check for operations in progress uses only what can
  be tied to this host: v1's install lock, its AgentUpgrade signal, this
  Machine's own operations and its HostOSReboot target entries. The
  status of selector operations is shared by every agent (#1040).
- Provision always rewrites secret files, so a refreshed bootstrap token
  reaches a slot whose digest has not changed.
- The agentMode parameter is listed among the changes outside
  internal/agentv2, as a documentation change to the API comment and
  reference page.
- Testing: the deleted-Node test restarts kubelet with a NodeReboot, the
  host snapshot allows sysctl keys that change on their own, a crash loop
  after the rollback window is a migration variant, and the v2 jobs
  become the required ones at the first official v2 release.
- On host, kubelet and containerd restart themselves after a failure.
  If they required the pre-start unit, each restart would run the
  nftables flush on a live node and give kubelet a new boot ID. The
  pre-start unit now stays active after it succeeds, the node units use
  Requisite= on it, the target pulls it in at boot, and Start and
  Restart restart it explicitly. V5 and V6 cover a kubelet restart.
- Recover checks the enabled slot as well as its digest, and the
  nspawn-container digest covers the OCI image, so an image-only repave
  can no longer look like no repave.
- Whether a runtime can run on a host is decided by capability probes,
  not a list of operating systems. Each runtime declares what it needs;
  preflight probes it, and a failing probe names what is missing. The
  host matrix tests the probes, including the immutable-host entry, and
  V7 is replaced by them. A Machine whose host cannot run its runtime
  fails bootstrap with reason RuntimeUnsupported.
… reporting

- The runtime becomes the Machine field spec.agent.runtime. A reinstall
  wipes the host, so the runtime has to live in the cluster or a
  replaced host could come back as nspawn-container. Every provisioning
  path writes it into the agent configuration, refuses agent versions
  that cannot run host, and runs v2's start for a host Machine; newer
  v1 builds refuse a host configuration. A change takes effect at the
  next reinstall, shown by RuntimeChangePending. The non-goal of no
  machina API changes now excludes this field and the new conditions.
- What v2 writes on the Machine: everything v1 writes, never
  status.phase (machina branches on it), status.agent.runtime, and the
  AgentHealthy and RuntimeChangePending conditions. AgentBootstrapped
  gains NodeStartFailed and RuntimeUnsupported. No Events, so no RBAC
  change.
- MachineOperation kinds without a handler are ignored, as in v1; the
  prefix rule missed HostOSReboot and could fail shared selector
  operations for every agent (#1040).
- The agent deletes expired operations it ran for its own Machine
  (#1043). HostOSReboot keeps v1's behavior and cites #1044.
- A MachineConfigurationVersion's agent image is ignored on host and
  left out of drift; its LocalDNS settings still apply.
- A failing repave keeps v1's retries and is reported on the Machine as
  AgentHealthy=False.
The old plan needed a last v1 release, N, whose daemon could undo a
migration, and forced every upgrade through it. That tied the safety of
the cutover to code in a v1 release and left hosts on older releases
with a confusing "upgrade to N first" failure.

- Before the commit, v2 only adds files under its own agent-v2 paths.
  v1's files, units, boot wiring and install record stay as they were,
  so whichever v1 binary ran the host can run it again.
- The commit first points last-good at v2, backs up v1, installs v2's
  units, swaps the machine override in one rename, removes v1's files
  and writes v2's install record last. Any v2 process that finds it
  unfinished finishes it, so a v1 binary never runs on a changed host.
- Going back to v1 inside the rollback window is done by v2: restore v1
  from the backup, then switch to the v1 binary. v2's daemon unit only
  starts a binary that includes v2, so a crash during the restore
  cannot start v1 on a half-restored host. agentMode: v1 lets an early
  migration roll back from the cluster without changing releases.
- At the cutover, a failure before the commit is reported by v2 itself
  and the daemon switches back to last-good. In an early migration the
  migrate unit records Abandoned or RolledBack, and v1's daemon reports
  the failure instead of migrating again.
- Phases are Preparing, Prepared, Committing, Committed, Abandoned,
  Restoring and RolledBack, with diagrams for each path and a table of
  what a host reboot does in each phase.
- The oldest supported v1 release is set by the migration test matrix,
  which replaces the release N rule and the Skipping-N test.
- The daemon is Type=notify and reports READY=1 when its startup gate
  opens. An AgentUpgrade completes only once the new daemon is ready,
  and the start timeout and start limit make a daemon that never gets
  ready roll back like one that crashes. v1 reports Complete as soon
  as the daemon registers.
Plugins ran as root from any path the configuration named, were never
upgraded by the agent, and could drift from the agent binary. That made
every plugin a root-level trust decision guarded only by file ownership.

- Each plugin has a sha256 in the configuration. Calls run in a
  transient systemd unit whose command is the agent's hidden
  plugin-exec: it hashes the stored plugin through the open descriptor
  and execs that descriptor, so the file checked is the file that runs.
- Only ELF executables run. A #! script cannot be pinned this way, since
  its interpreter reopens it by path and is not pinned itself. Shared
  libraries are not pinned either, so preflight warns about dynamically
  linked plugins.
- By default a plugin runs as a dynamic unprivileged user with no
  capabilities, a read-only OS, no access to the agent's credentials or
  state, and a clean environment. plugins[].permissions declares extra
  paths, capabilities or root. Each call writes an audit line without
  the request or response.
- Plugin executables live in a set beside each blue/green binary and
  change only with an AgentUpgrade, which stages a new set from a plugin
  manifest or copies the running one. Binary and plugins switch and roll
  back together, and the daemon checks every plugin with describe before
  it reports ready. Names, permissions, env, timeouts and hooks stay
  install-time settings, so an upgrade cannot widen what a plugin may do.
- First install takes the executables from the configuration file; the
  file's plugins stay in the install fingerprint, so v2 start with
  changed plugins is refused.
…riteria

The experimental label had been used to let v2 jobs report without
blocking, and nothing said what must hold before v2 replaces v1 on every
host. Operators migrate hosts early onto the experimental releases, so
they need the same bar as the release that follows.

- The v2 jobs, including the migration suite and the cutover variants on
  a test build, are required on agent pull requests from the first v2
  code. At the first official v2 release the v1 jobs are removed and
  AGENT_PROFILE defaults to v2.
- The cutover variants run on a development build with a test-only build
  tag before the release exists, and against the real release builds
  once they do.
- Implementation section 4 lists the exit criteria for the first
  official v2 release: validation tasks V1 to V6, required CI, the
  migration matrix against release builds, the capability probes, and
  public documentation. The README states that "experimental" covers the
  interfaces only, not the quality of the code.

This branch has not been deployed

No deployments
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