Skip to content

pkg/kube: port cluster-init.sh to Go daemon - #5971

Merged
eriknordmark merged 35 commits into
lf-edge:masterfrom
rucoder:rucoder/kube-init-go
Aug 9, 2026
Merged

pkg/kube: port cluster-init.sh to Go daemon#5971
eriknordmark merged 35 commits into
lf-edge:masterfrom
rucoder:rucoder/kube-init-go

Conversation

@rucoder

@rucoder rucoder commented May 18, 2026

Copy link
Copy Markdown
Contributor

Draft while re-testing is in progress. @eriknordmark has pushed six commits
to this branch addressing review comments and problems found while testing —
KubeVirt image-catalog/version consolidation, the legacy K3sBase marker
migration ordering, finishing a cluster-update status the k3s step left open,
wiring the kube-init module into make test, and a guard on the multus
CRD-before-instance precondition (tip 904168119). The evetest node-cluster
suite is being re-run against the updated branch; this stays a draft until that
comes back.

Description

This is the first of two PRs that lay the groundwork for proper Real-Time workload support in EVE-K. It replaces pkg/kube/cluster-init.sh (and its eleven shell library files) with a Go daemon at pkg/kube/kube-init/
that supervises the k3s lifecycle through an event-driven finite state machine. The follow-up PR will add a new facility for managing the pre-built upstream image tarballs that live under pkg/kube-images/ — splitting that change out keeps each PR independently reviewable.

Why now — the RT motivation

We could not integrate RT support cleanly on top of cluster-init.sh because the shell had no proper state machine for the k3s lifecycle. The shell loop body was a sequence of conditional touchpoints driven by
flag files; it had no notion of states, no atomic transitions, and no hookable pre-restart phase.

That breaks the RT operator's restart flow specifically. RT support requires plan-k3s-rt-restart.yaml (a Rancher SUC plan) to drive a clean k3s restart whenever a PerformanceProfile CR changes — for example, when CPU isolation, RDT cache partitioning, or kubelet RT scheduling arguments need new values. The restart MUST run pre-restart hooks between stop and start (write the new isolated-CPU set, repartition L3 cache, install the new kubelet args, etc.) before k3s comes back. In the shell, there was nowhere to plug those hooks in: the supervisor loop
just respawned k3s whenever it died, and SUC's force-kill was indistinguishable from a crash. We could not reliably distinguish "SUC asked us to cycle k3s for an RT config change" from "k3s segfaulted", which meant the RT-specific pre-restart work either got skipped or got duplicated across every crash recovery.

The Go FSM in this PR fixes that at the foundation. The state graph distinguishes crash recovery (BACKOFF — no hooks) from operator-driven restart (STOPPING_K3S → RUNNING_HOOKS → STARTING_K3S — hooks fire). A typed restartReason carries the cause through the transition so the right hooks run for the right reason. /etc/k3s-supervisor/hooks.d/ is owned by the FSM, and the RT operator can drop scripts in that directory knowing they will fire exactly once between stop and start. The control socket at /run/k3s-supervisor.sock lets the RT operator
trigger a restart synchronously and observe progress (e.g., transition-step=…).

This PR does not ship the RT operator manifests or any RT code path — those land in a later PR once the FSM foundation is in place. What this PR delivers is the foundation that unblocks the RT integration.

What's in this PR

The stack is 26 incremental commits scoped one Go package per commit, followed by 16 backport commits that bring across master-side bug fixes that landed after our branch point. Each commit builds, vets, and tests
green in isolation. Architectural overview and package map live in pkg/kube/kube-init/README.md.

Port (26 commits):

  • state — marker primitives, atomic-write, var-lib save/restore, reboot
  • kubectlx — kubectl/ctr/crictl wrappers + apply-with-backoff classifier
  • k3s — config rendering, token rotation, install + symlink rebuild,
    supervisor (Setpgid + orphan sweep + port wait), readiness probes,
    node-password persist/restore across reboots
  • prereqs — kernel modules, cgroup, iSCSI, vault wait, EdgeNodeInfo wait,
    containerd
  • images — pre-packaged tarball import with kubelet pull-on-first-use
    fallback
  • vnc — VNC proxy + caller-PID watchdog
  • tiebreaker — HA tie-breaker configuration
  • deploy — declarative deploy DAG runner with BestEffort + WaitReady
  • components — Multus, KubeVirt, CDI, Longhorn (deployed BestEffort
    so the FSM enters RUNNING without waiting up to 10 minutes for CR
    convergence; steady-state reconciler picks up the final settling),
    descheduler, debug RBAC, kube-vip, storage classes, SR-IOV manifest
    • binary staging, stale-mount-cleanup daemon launcher
  • mgmtproxy — cni0 anchor IP + CDI ImportProxy patch + containerd-launch
    env injection so CRI image pulls route through pillar's cost-aware proxy
  • monitor — running-state watchers (containerd, kubeconfig sync, log
    rotation, cluster-config watch, user-override watch)
  • clustermode — single↔HA transitions
  • update — k3s + cluster-component upgrade flow (k3s download with
    SHA-256 verify, atomic install)
  • pubsubclient — Manager modelled on pkg/pillar/cmd/monitor/subscriptions.go;
    single map of label → Subscription, deferred Activate, one goroutine
    pumping all topics through pubsub.MultiChannelWatch
  • edgenodeinfoEdgeNodeInfo subscription (DeviceName/DeviceID),
    blocking WaitForFirst for the boot-time identity read
  • kubeconfigKubeConfig subscription with K3sVersion accessor
  • kcusKubeClusterUpdateStatus subscription gating upgrade retries
  • encconfigEdgeNodeClusterConfig subscription (cluster shape,
    TieBreakerNodeID)
  • encstatusEdgeNodeClusterStatus subscription with Present()
    folding file-deletion and zero-UUID-delete-sentinel into a single check
  • main — FSM entry point (14 states, 27 events, control socket on
    /run/k3s-supervisor.sock)
  • k3s-sctl — operator CLI client for the control socket (replaces the
    flag-file shell wrappers)
  • Final integration commit switches the Dockerfile entrypoint and
    removes the 12 shell scripts the daemon replaces

The pubsub subscriber packages replace the JSON-file polling the original port used against /run/zedagent/... and /persist/status/zedkube/..., eliminating ~400 lines of local JSON-shape duplication. The migration incidentally closes three latent bugs that came from JSON-tag mismatches the local *JSON structs had against the canonical pillar types: (1) KubeConfig was tagged json:"k3sVersion" while pillar publishes K3sVersion (no tag, capital K), so every device was silently using the compile-time k3s default; (2) KubeClusterUpdateStatus.DestinationKubeUpdateVersion was locally typed string while pillar uses uint32, so the failed-upgrade retry gate never fired; (3) EdgeNodeClusterStatus zero-UUID delete sentinel was missed by os.Stat-based detection in waitForBootstrapServer.

config.go:waitForBootstrapServer dials the bootstrap node's self-signed cert with InsecureSkipVerify: true; the cluster-UUID equality check below it is the actual authentication boundary, because we cannot have the cluster CA in the trust store before we have joined the cluster. The architectural justification is now load-bearing in the comment block; the line carries //nolint:gosec and lgtm[go/disabled-certificate-check]. A long-term fix pinning the bootstrap cert fingerprint via EdgeNodeClusterStatus is noted in README.md's future-work list (requires pillar's zedkube to publish the fingerprint, out of scope here).

Master backports (16 commits, each cross-references its upstream SHA):

  • monitor: restart containerd if it diesea71f1b76
  • update: bump KubeVersion + block downgradesa67d55ce9
  • k3s,monitor: treat zero-UUID ENC status as no-cluster158981334
  • prereqs: clean stale cpu_manager_state on boot1927e2f28 + 6719f918c
  • k3s: remove stale flannel.1 around supervisor cycles2c417d5fe
  • k3s: symlink host-local into /usr/bin for k3s v1.34+75fe3cd94
  • state: relocate kube-save-var-lib under vault647a03b2d
  • components: bump KubeVirt CR URL to v1.7.3849b4cd7e + 5041cb83c
  • clustermode: stagger k3s startup by control-plane rankbe1537e68
  • clustermode: clean stale etcd masterleases post single→clusterd5664c079
  • k3s: persist + restore node-password across reboots91b9589c1
  • mgmtproxy: containerd CRI env injection7ec6f2a64
  • mgmtproxy: cni0 anchor IP + CDI ImportProxy patch7ec6f2a64
  • components: SR-IOV manifest + binary staginga2bb3a52c
  • components: stale-mount-cleanup daemon launcherb036179da
  • monitor,update: route component-presence checks through kubectlx — the kube container ships only the k3s multi-call binary; bare kubectl is not on PATH, so update.checkComponentInstalled and monitor.countReadyNodes were silently failing on every tick

Test coverage: the FSM transition graph is covered by a 60-row table-driven test in pkg/kube/kube-init/main_test.go; per-package unit tests cover the non-trivial parsers, state machines, and recovery paths
(supervisor process tree handling, deploy DAG wave ordering, hash-file parsing, version comparison, transition-marker corruption tolerance, pubsub-subscriber cache state machines, mgmtproxy env builder and NO_PROXY assembly, k3s node-password brownfield-first-boot/steady-state/missing-runtime/permissions, …). All packages pass go test -race.

v1 → v2: from shell-outs to typed Kubernetes APIs

Two rounds of code-review feedback drove architectural rewrites of two subsystems on top of the initial port. What ships here is v2.

Kubernetes and containerd in-process, no more shelling out. The v1 daemon still called k3s kubectl, k3s ctr -n k8s.io, and k3s crictl and parsed their stdout — the shell control-flow was rewritten in Go but the outward interface was unchanged. v2 replaces every one of those with a typed API call behind pkg/kube/kube-init/kubectlx/:

  • Apply / ApplyFile / ApplyURL — dynamic client + server-side apply, with a RESTMapper discovery-cache reset + retry on NoKindMatchError (closes the CRD-then-CR race manifests that bundle a CRD and a CR of that kind in one file hit).
  • WaitCRDEstablished, WaitDeploymentReady, WaitDaemonSetReady, WaitJobComplete, WaitForConditionwatchtools.UntilWithSync on typed and dynamic informers.
  • ContainerdClient — containerd v2 Go client (github.com/containerd/containerd/v2/client) for image import / exists / tag / list; the k8s.io namespace is baked in.
  • remotecommand.NewSPDYExecutor for the one remaining kubectl exec callsite (the Longhorn nsmounter marker).
  • Drain via typed policy/v1.Eviction; cordon via node patch.

Stdout-parsing helpers and their regexes are gone. Every operation is now a typed call with structured errors, so silent stdout-parse failures like the checkComponentInstalled and countReadyNodes regressions we backported (see "What's in this PR") cannot recur.

Event-driven deploy DAG. v1 ran the deploy graph as a wave-based scheduler: every component in wave N had to complete before any wave N+1 started, so a fast component was blocked by an unrelated slow peer, and a BestEffort component that failed apply was never revisited. v2 replaces this with a work-queue scheduler in pkg/kube/kube-init/deploy/: components declare typed dependencies and start as soon as their predecessors are done. BestEffort components (KubeVirt, CDI, Longhorn) that fail apply are handed to a background retry loop with exponential backoff, scoped to the daemon's root context — a transient failure at deploy time no longer leaves the component permanently un-reconciled.

Informer-driven readiness. WaitLonghornReady uses per-call filtered informer factories on DaemonSets in longhorn-system and the longhorn.io/Node CR — no periodic polling. Times out cleanly after 10 minutes.

Consolidation. Duplicated schema.GroupVersionResource literals, namespace strings, and error/patch idioms (IgnoreNotFound, BuildMergeLabelPatch, IsNodeReady) live in pkg/kube/kube-init/kubectlx/gvrs.go. images.ImportAll shares one containerd client across the batch; the monitor caches its containerd client across health-tick invocations rather than re-dialling.

v2 → v3: validated on a three-node cluster

v3 is what came back from running the daemon on real hardware. The full evetest node-cluster suite — single node, three-node HA, and a new cluster→single conversion test — passes on a three-node libvirt cluster (TestSingleNodeCluster 911s, TestThreeNodesCluster 1060s, TestClusterToSingleConversion 984s). Everything below is a behaviour the suite forced out; each is folded into the subsystem commit it belongs to rather than appearing as a fix on top.

A kubeconfig must not outlive the CA it describes. Joining a cluster retires the node's whole PKI, but the kubeconfigs derived from it were being left on disk. Since the readiness wait only ever waited for the file to be present, a leftover satisfied it instantly and the client was built against an authority that no longer existed — so every call failed the TLS handshake against a perfectly healthy apiserver. The symptom is indistinguishable from an unresponsive node: on edge-dev2 the pre-join kubeconfig was adopted at 12:48:55, k3s wrote the real one at 12:48:57, and the join then burned 5m13s before the watchdog rebooted a node that had joined correctly. The same stale copy is what pillar reads (kubeapi.EVEkubeConfigFile is /run/.kube/k3s/k3s.yaml), so zedkube's node drain-and-delete failed the same handshake and left the departed node in the survivors' cluster — one cause behind two unrelated-looking failures. The kubeconfigs are now retired together with the TLS material, and the readiness wait rebuilds its client if the file rotates underneath it anyway.

Readiness says why, not just "not yet". Every distinct cause of "no answer from the apiserver" previously presented as the same hanging call. The wait now records what the client is aimed at and the fingerprint of the CA it trusts, then separates the layers on each failure: whether anything is accepting on the API port, what /readyz?verbose says is outstanding (etcd, informer sync, a poststarthook), and whether the kubeconfig moved. Every call is individually bounded — rest.Config.Timeout is deliberately left unset because it would cut informer watches, so a per-call context is what stops one unanswered request from consuming the whole readiness budget. Pod readiness is counted with a field selector for the local node, so an unhealthy or still-joining peer cannot hold up a node that is itself ready.

Cluster-scoped objects are not collateral for a single node's reconfiguration. NetworkAttachmentDefinition is a cluster-scoped CRD, so tearing it down to re-address one node takes the definition away from every other node in the cluster; the node re-applies its own configuration and restarts the Multus DaemonSet instead, and a failure there is logged rather than failing a transition that has already passed the point of no return. CRs are also gated on their CRD being genuinely established — a terminating CRD still reports Established=True, so a deletion timestamp disqualifies it.

The join watchdog is driven by progress, not by a deadline. How long a join takes is unknowable: it covers a k3s restart, an image import, registration and every kube-system pod, so any fixed budget is a guess that some slower device will fail. How long a join may make no progress at all is stable, so every advance to a stage this join has not reached before kicks the timer — a slow-but-advancing join is never rebooted, while a wedged one trips promptly. A crash loop does not count as progress, because it revisits stages rather than advancing them.

A withdrawal is observed in every state. A controller can withdraw a node's cluster configuration at any moment, including seconds after a join completes while the node is still working through readiness, so the cluster-config watch is no longer confined to RUNNING. A zero-UUID or half-written status is treated as "no live cluster" only when it really is one, so a status that arrives late cannot be mistaken for a withdrawal.

The /var/lib snapshot and its rollback are now their own commit, with the reasoning in one place: what is captured is node identity rather than the ~419MB of re-derivable binaries, the datastore is captured with SQLite's VACUUM INTO so an online-consistent copy needs nothing stopped, and because the restore is an overlay copy the cluster-mode leftovers have to be removed explicitly — server/db/etcd, the state.db-wal/-shm sidecars (SQLite replays a stray -wal as the restored database's own journal and k3s then dies with "database disk image is malformed" on every start), and Longhorn replicas belonging to volumes the restored cluster knows nothing about.

The node publishes what it is actually doing. FSM state and phase, derived cluster membership, the current transition step, whether the join watchdog is armed, and the last error, so pillar and the controller can read the daemon's own view instead of inferring it from files.

Commit history. The port is now one commit per subsystem, each carrying its final content, so there are no fix-ups to read through — 27 commits. The pre-packaged-image (EROFS) work that builds on top of this stays on a separate branch and will come as its own PR.

How to test and validate this PR

PR was validated e2e against evetest make evetest NAME=TestNodeClusterSuite

  1. Build the kube package and live image:
    make pkg/kube
    make live HV=kvm ZARCH=amd64
  2. Single-node bring-up:
    • make run-live HV=k or flash to hardware
    • Watch /persist/kubelog/k3s-install.log — the daemon's structured
      log replaces the shell's noisier output
    • Expect transition path: INIT → INSTALLING → STARTING_CTRD → CONFIGURING → STARTING_K3S → IMPORTING → WAIT_K3S_READY → DEPLOYING → RUNNING
    • All system pods Ready; kubectl get nodes shows the node Ready
    • SSH access to an un-onboarded device requires PR zedagent: fall back to /config/authorized_keys when GlobalConfig is missing #6039 (unrelated
      regression in pillar's GlobalConfig handling)
  3. Operator control surface:
    eve enter kube
    k3s-status         # human-readable status from the socket
    k3s-sctl status    # same, raw
    k3s-sctl restart   # graceful restart through the FSM (hooks fire)
  4. Single→HA transition:
    • Join a second + third control-plane node
    • Confirm the clustermode runner steps fire in order (visible via
      k3s-sctl status transition-step=…)
    • Confirm cleanup_stale_masterleases removes the pre-conversion
      single-node entry from etcd:
      etcdctl get /registry/masterleases/ --prefix --keys-only
  5. HA simultaneous power-cycle staggers correctly:
    • Power-cycle all three control-plane nodes at once
    • Each node should sleep rank * 25 s before launching k3s; check
      kube-init log for STARTING_K3S: applying staggered delay
  6. Cluster delete:
    • From the controller, delete the cluster config
    • Confirm the device detects the zero-UUID status payload and
      signals cluster→single (the regression 158981334 addressed)
  7. k3s version override via pubsub:
    • Controller pushes a KubeConfig with a non-default K3sVersion
    • Confirm update.getDesiredK3sVersion picks up the override
      (previously silently ignored due to the JSON-tag mismatch above)
  8. Unit tests:
    cd pkg/kube/kube-init && go test -race -count=1 ./...

Changelog notes

pkg/kube/cluster-init.sh and its eleven shell library files are
replaced by a Go daemon (/usr/bin/kube-init) that supervises the k3s
lifecycle through an event-driven finite state machine. The control
socket at /run/k3s-supervisor.sock exposes status/restart/stop
commands; the operator-facing k3s-start / k3s-stop / k3s-status
aliases now dispatch through this socket rather than touching flag
files. No user-visible behaviour change in the steady state. Recovery
from k3s crashes, containerd death, single↔HA cluster transitions, and
controller-driven cluster delete is now bounded, instrumented, and
unblocks RT support landing in a later PR. The k3s version override and
failed-upgrade retry gate now actually fire — both were silently broken
in the file-polling path the pubsub migration replaces.

PR Backports

  • 16.0-stable: No, master-only architectural change
  • 14.5-stable: No, master-only architectural change
  • 13.4-stable: No, master-only architectural change

Checklist

  • I've provided a proper description

  • I've added the proper documentation (pkg/kube/kube-init/README.md)

  • I've tested my PR on amd64 device

  • I've tested my PR on arm64 device

  • I've written the test verification instructions

  • I've set the proper labels to this PR

  • I've checked the boxes above, or I've provided a good reason why I didn't
    check them.

@rucoder
rucoder requested a review from zedi-pramodh as a code owner May 18, 2026 19:17
@rucoder
rucoder requested a review from rene May 18, 2026 19:18
@rucoder
rucoder marked this pull request as draft May 18, 2026 19:19
@rucoder
rucoder force-pushed the rucoder/kube-init-go branch 4 times, most recently from 9f4c996 to cf7b773 Compare May 18, 2026 19:53
Comment thread pkg/kube/kube-init/k3s/config.go Fixed
@rucoder
rucoder force-pushed the rucoder/kube-init-go branch 3 times, most recently from be90fd2 to 7efadd7 Compare May 18, 2026 23:47

@andrewd-zededa andrewd-zededa 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.

Just took a quick look, a few notes. I need to look closer at this sometime this week.

return false, nil
}
if _, err := kubectl("get", "nodes.longhorn.io", nodeName,
"-n", "longhorn-system"); err != nil {

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.

Can the repeated use of the longhorn-system namespace string be moved to a const?

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.

Done — this is now the single const kubectlx.LonghornNamespace (kubectlx/gvrs.go:39), and that definition is the only occurrence of the literal left in the package.

return nil
}

out, err := kubectl("get", "kubevirt", "kubevirt", "-n", "kubevirt",

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.

Can the "kubevirt" namespace string be moved to a const across the file?

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.

Done for the namespace — kubectlx.KubeVirtNamespace (kubectlx/gvrs.go:40).

The "kubevirt" literals that remain are the CR name, and kubevirtCRName (components.go:106) is referenced at only one of them: components.go:651/654, components/kubevirt.go:47/69 and tiebreaker/tiebreaker.go:220 still inline it. Worth a follow-up sweep.

Comment on lines +23 to +78
// RunDeschedulerOnBoot applies the descheduler job exactly once per
// boot. The descheduler reshuffles pods to honour spread policies
// that may have been violated while a node was offline; we run it
// after the node has finished joining and required DaemonSets are
// ready so it doesn't fight an in-progress rollout.
//
// Idempotent within a single boot cycle (guarded by
// deschedulerBootMarker, which lives in /tmp and clears on reboot).
//
// Errors wrapping ErrDeschedulerNotReady mean "try again later";
// any other error is a hard failure.
func RunDeschedulerOnBoot(ctx context.Context) error {
if _, err := os.Stat(deschedulerBootMarker); err == nil {
log.Printf("update: descheduler already ran this boot, skipping")
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("stat descheduler marker: %w", err)
}

if ready, err := nodeReadyAndSchedulable(); err != nil {
return fmt.Errorf("check node ready: %w", err)
} else if !ready {
return fmt.Errorf("%w: node not Ready or scheduling disabled",
ErrDeschedulerNotReady)
}
if err := requireDaemonSetsReady("longhorn-system"); err != nil {
return err
}
if err := requireDaemonSetsReady("kubevirt"); err != nil {
return err
}

// A previous boot may have left a completed Job behind; the
// `apply -f` below would error on the immutable selector
// fields of the old Job. `--ignore-not-found` makes the call
// safe when nothing is there. We log but tolerate any other
// delete failure: the subsequent apply will surface a real
// problem with a clearer message.
if out, err := kubectlx.Run("delete", "job", "descheduler-job",
"-n", "kube-system", "--ignore-not-found"); err != nil {
log.Printf("update: delete stale descheduler job: %v (output: %s)",
err, truncateForLog(out, 1024))
}

if out, err := kubectlx.Run("apply", "-f", deschedulerJobYAML); err != nil {
return fmt.Errorf("apply descheduler job: %w (output: %s)",
err, truncateForLog(out, 1024))
}

if err := os.WriteFile(deschedulerBootMarker, []byte("1"), 0644); err != nil {
log.Printf("update: write boot marker %s: %v (descheduler will re-run on next call this boot)",
deschedulerBootMarker, err)
}
log.Printf("update: descheduler job applied")
return nil
}

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.

Latest cleanup on master removed this from the kube container, its initiated from pillar now.

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.

Resolved — update/descheduler.go no longer exists. What remains is the RBAC + policy-configmap apply/delete (components.go:915, uninstall.go:101), which matches master's descheduler-utils.sh exactly; the CronJob side stays in pillar.

@rucoder

rucoder commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

@andrewd-zededa , @eriknordmark had a cool idea to use actual PubSub. I'm going to implement it and rebase the PR on top of master and also integrate the latest scenarios/fixes that were implemented in shell scripts

rucoder added a commit to rucoder/eve that referenced this pull request Jun 11, 2026
…obe TLS bypass

The bootstrap-discovery probe in waitForBootstrapServer dials a
k3s self-signed cert before we have the cluster CA in our trust
store — we cannot get the cluster CA until we have joined the
cluster, and we cannot join until this probe succeeds. So the
probe runs with InsecureSkipVerify=true.

That triggered a github-advanced-security finding on PR lf-edge#5971
("InsecureSkipVerify should not be used in production code").
The bypass is intentional and the bot does not see why. This
commit makes the justification load-bearing in the source:

  - The comment block above the http.Client construction now
    explains the chicken-and-egg explicitly, calls out the
    cluster-UUID match a few lines below as the actual
    authentication boundary, and notes that the bypass is
    scoped to this single probe.
  - The line itself carries both `//nolint:gosec` (existing —
    suppresses the gosec linter) and `lgtm[go/disabled-
    certificate-check]` (new — suppresses CodeQL inline).
  - The comment references the PR conversation so reviewers
    have one place to confirm the suppression is acceptable.

No behaviour change.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
@rucoder
rucoder force-pushed the rucoder/kube-init-go branch from 7efadd7 to a548717 Compare June 11, 2026 22:29
rucoder added a commit to rucoder/eve that referenced this pull request Jun 11, 2026
…obe TLS bypass

The bootstrap-discovery probe in waitForBootstrapServer dials a
k3s self-signed cert before we have the cluster CA in our trust
store — we cannot get the cluster CA until we have joined the
cluster, and we cannot join until this probe succeeds. So the
probe runs with InsecureSkipVerify=true.

That triggered a github-advanced-security finding on PR lf-edge#5971
("InsecureSkipVerify should not be used in production code").
The bypass is intentional and the bot does not see why. This
commit makes the justification load-bearing in the source:

  - The comment block above the http.Client construction now
    explains the chicken-and-egg explicitly, calls out the
    cluster-UUID match a few lines below as the actual
    authentication boundary, and notes that the bypass is
    scoped to this single probe.
  - The line itself carries both `//nolint:gosec` (existing —
    suppresses the gosec linter) and `lgtm[go/disabled-
    certificate-check]` (new — suppresses CodeQL inline).
  - The comment references the PR conversation so reviewers
    have one place to confirm the suppression is acceptable.

No behaviour change.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
@rucoder
rucoder force-pushed the rucoder/kube-init-go branch from a548717 to 8454c9b Compare June 11, 2026 22:34
@rucoder
rucoder requested a review from andrewd-zededa June 11, 2026 22:40
@rucoder
rucoder marked this pull request as ready for review June 11, 2026 22:41
// https://github.com/lf-edge/eve/pull/5971.
httpsClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // lgtm[go/disabled-certificate-check]
@codecov

codecov Bot commented Jun 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 24.48%. Comparing base (75e4099) to head (d6eff73).
⚠️ Report is 12 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #5971      +/-   ##
==========================================
+ Coverage   24.12%   24.48%   +0.35%     
==========================================
  Files         512      522      +10     
  Lines       93653    95369    +1716     
==========================================
+ Hits        22598    23352     +754     
- Misses      69267    70045     +778     
- Partials     1788     1972     +184     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@andrewd-zededa

Copy link
Copy Markdown
Contributor

@rucoder I'm working to test this on my local dev cluster, will report back any issues I see

@andrewd-zededa andrewd-zededa 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.

Just a couple of quick finds, there are more repeated strings which should be consts for future maintenance.

debugRoleBinding = "/etc/debuguser-role-binding.yaml"
kubevirtOperator = "/etc/kubevirt-operator.yaml"
kubevirtFeatures = "/etc/kubevirt-features.yaml"
longhornCfg = "/etc/lh-cfg-v1.9.1.yaml"

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.

This string should be built off the longhorn version defined in uninstall.go

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.

Agreed. longhornCfg (components.go:61) bakes v1.9.1 into the filename while uninstall.go:29 carries it as a const, and images.go repeats it six more times.

Folding all of them into one versions package, with the path built as "/etc/lh-cfg-" + versions.Longhorn + ".yaml" — same treatment for kubevirtCRURL, cdiVersion, k3s.K3sVersion and the images.go tags. All const expressions, so nothing moves to runtime.

// app instance workloads and is referenced by the multus
// NetworkAttachmentDefinition and the debug-user RoleBinding.
func ensureEveKubeAppNamespace() error {
if _, err := kubectl("get", "namespace", "eve-kube-app"); err == nil {

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.

eve-kube-app should be a const

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.

Done here — eveKubeAppNamespace (components.go:102). Two stragglers elsewhere: monitor/monitor.go:887/892 still use the literal, and vnc/vnc.go:40 defines a separate vncNamespace const for the same namespace.

@rucoder

rucoder commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

@andrewd-zededa I think I did not push the latest version

@milan-zededa

Copy link
Copy Markdown
Contributor

@rucoder Are you actively working on this? If not, I'd be interested in picking it up, if you're okay with that. I would cherry-pick your commits and continue the work on my fork.

My plan is to finish the 2-node HA solution that @zedi-pramodh started. I'd also like to implement it in Go rather than adding more shell script complexity. And more broadly, I'm planning to become more involved in the Kubernetes side of the EVE project.

Regarding this:

The follow-up PR will add a new facility for managing the pre-built upstream image tarballs that live under pkg/kube-images/ — splitting that change out keeps each PR independently reviewable.

Have you already implemented this locally and just haven't opened the PR yet, or is it still in the planning stage?

@milan-zededa

Copy link
Copy Markdown
Contributor

@rucoder Are you actively working on this? If not, I'd be interested in picking it up, if you're okay with that. I would cherry-pick your commits and continue the work on my fork.

My plan is to finish the 2-node HA solution that @zedi-pramodh started. I'd also like to implement it in Go rather than adding more shell script complexity. And more broadly, I'm planning to become more involved in the Kubernetes side of the EVE project.

@rucoder Any updates? I'm still waiting to hear back on whether you're actively working on this. If not, I'd like to pick it up, if you're okay with that.

Regarding this:

The follow-up PR will add a new facility for managing the pre-built upstream image tarballs that live under pkg/kube-images/ — splitting that change out keeps each PR independently reviewable.

Have you already implemented this locally and just haven't opened the PR yet, or is it still in the planning stage?

Even if you can't get to it right away, it'd help if you could at least open a draft PR for the follow-up part so the work in progress is visible.

@eriknordmark

Copy link
Copy Markdown
Contributor

@rucoder Can you rebase this on master? I'd like to take it for a spin on the soak test setup I have which exercises the first-boot of EVE-k a lot.

rucoder added 12 commits August 6, 2026 16:59
The steady-state loop the FSM enters after DEPLOYING. Every 15s
health tick runs a fixed sequence of housekeeping + reconcile
tasks; separate goroutines watch containerd exit, kubeconfig
drift, log rotation, cluster-config-changed events, and the
user-override drop-in for k3s config.

Tasks the health tick runs (in order):
 - DHCP CNI daemon liveness (respawn on exit).
 - CNI plugin binary drift (copy from stage dir when needed).
 - Multus re-render + apply if the initialised marker is absent.
 - Node label + annotation reapply (node-uuid, longhorn disk
   config) when the labels-initialised marker is absent — one
   merge-patch does labels and annotations together, then a
   readback verifies both landed.
 - Debug-user RBAC re-application.
 - Kubeconfig sync (copy /etc/rancher/k3s/k3s.yaml to the
   kube-init-visible location, atomic).
 - Image reimport (external-boot-image via kubectlx.
   ContainerdClient — ImageExists check, tarball import only if
   the image is genuinely absent).
 - Longhorn post-install config (via components.
   LonghornPostInstallConfig).
 - Registration manifest reconcile.

transition.go — countReadyNodes counts Nodes with Ready=True
via typed clientset. Used by the cluster-mode monitor to detect
"we're on the HA majority" transitions. parseTransitionMarker
covers the FSM's cluster-transition marker file (unix-ts +
reboot-count).

Steady-state ticks are gated on state markers so a tick after a
successful bootstrap is silent — no log line, no API call.

The join watchdog is driven by progress, not by a deadline. How long a
join takes is unknowable — it covers a k3s restart, an image import,
registration and every kube-system pod — so any budget for it is a guess
that some slower device will fail. How long a join may make no progress
at all is a stable quantity, so every advance to a stage this join has
not reached before kicks the timer: a slow-but-advancing join is never
rebooted, while a wedged one trips promptly. A crash loop does not count
as progress, because it revisits stages rather than advancing.

The cluster-config watch runs in every state, not only in RUNNING. A
controller can withdraw a node's cluster configuration at any moment,
including seconds after a join completed while the node is still working
through readiness. A zero-UUID or otherwise empty status is treated as
"no live cluster" only when it really is one, so a status that arrives
late or half-written cannot be mistaken for a withdrawal.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
The tie-breaker is the third control-plane node in an EVE-K HA
cluster whose only job is to give etcd a third vote so single-
node loss doesn't strand the cluster. This package configures
that role.

ConfigApply is the phase entry point; it runs when the cluster
first reaches three nodes AND the local node is the designated
tie-breaker (per encconfig's TieBreakerNodeID). Steps:

 - nodesConfigApply — label every node with tie-breaker-node=
   true/false and cordon/uncordon accordingly. Merge patches
   via Nodes().Patch keep both label and spec.unschedulable
   updates atomic per node.
 - kubevirtConfig — scale virt-operator + KubeVirt CR to 2
   replicas (tie-breaker excluded). Deployment scale via typed
   AppsV1 merge-patch; CR replica field via dynamic Resource
   Patch.
 - kubevirtTieBreakerConfigApply — patch every DaemonSet in the
   kubevirt namespace with a nodeSelector keeping pods off the
   tie-breaker.
 - cdiConfig — same nodeSelector patch on every Deployment in
   the cdi namespace.
 - longhornNodeSetSched — flip allowScheduling +
   evictionRequested on the tie-breaker's longhorn.io Node CR
   and every disk it owns. JSON-patch operations via dynamic
   Resource Patch.
 - longhornRescale — scale Longhorn CSI sidecar Deployments to
   2 replicas; patch every longhorn-system DaemonSet with the
   nodeSelector (retries 5× because longhorn-manager
   occasionally races us during install).
 - drainNode — cordon + evict every non-DaemonSet pod on the
   tie-breaker via policy/v1.Eviction (matches kubectl drain
   --ignore-daemonsets --delete-emptydir-data --force semantics)
   + wait until pods leave the node.
 - StatusSet — stamp every node with tie-breaker-config-applied=1
   as the "phase complete" marker so a subsequent reboot skips
   the whole flow.

Every operation is idempotent — a partial ConfigApply that gets
interrupted mid-way completes on the next FSM entry.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
…eduler, RBAC, SR-IOV, kube-vip

Everything the deploy graph runs during DEPLOYING and everything
the monitor's steady-state ticks reconcile against.

components.go — DeployAll builds a deploy.Graph and runs it. The
graph has ~10 Components; only Longhorn declares a PolicyDep
(longhorn depends on manifests, which stages storage-classes.yaml
into the auto-deploy dir). Every Install* function:
 - Longhorn: applyLonghornDiskConfig labels + annotates the node
   via one merge-patch; the longhorn config YAML applies as a
   drop-in.
 - KubeVirt: InstallKubeVirt applies the operator manifest via
   SSA, waits for virt-operator Ready, applies the CR via SSA,
   applies feature-gate CR via SSA. kubeVirtConfigReplicas
   writes .spec.replicas + .spec.infra.replicas via SSA with a
   distinct field manager ("kube-init-replicas") so subsequent
   applies of the operator manifest don't fight for ownership.
 - CDI: apply operator, wait for cdi-operator Ready, apply CR.
   WaitCDIReady goes through kubectlx.WaitForCondition on
   .status.phase == "Deployed".
 - Multus: template rendering (IP address substitution) then
   applyMultus + linkMultusIntoK3s.
 - descheduler / debug-rbac / manifests / kube-vip / SR-IOV
   manifest staging / stale-mount-cleanup daemon launcher —
   one Component each.
 - WaitLonghornReady is informer-driven (via a wait-scoped
   dynamic factory over longhorn.io/Node and typed DaemonSets in
   longhorn-system). No polling; the compound predicate is
   re-evaluated on every event.

kubevirt.go — MigrateKubeVirtFeatureGates reads the running CR
via dynamic Get, patches with dynamic Patch, marks the migration
done. buildFeatureGatesPatch is table-driven so a new gate is a
one-line addition.

longhorn.go — LonghornIsReady is the compound predicate the
monitor consults on every tick. Uses typed clientsets for
DaemonSet readiness and dynamic client for the Longhorn Node CR
and engine images. longhornEngineDeployedOnNode distinguishes
"status.nodeDeploymentMap absent" (fresh CR — not-ready-yet)
from "map present but this node is false" (recycle engine +
manager pods to force reconcile). CheckOverwriteNsmounter uses
client-go remotecommand.NewSPDYExecutor (via podExec helper) to
copy a fixed nsmounter binary into every longhorn-csi-plugin
pod that doesn't already carry the marker file.

registration.go — RegistrationApplyIfReady stages the
controller-supplied registration manifest into the k3s
server-manifests dir. RegistrationApplied checks the AddOn CR
via dynamic Get.

uninstall.go — the K3sBase conversion flow. Uninstalls
descheduler / Longhorn / CDI / KubeVirt / Multus in reverse
dependency order. Longhorn uninstall is bounded by a 1000-poll
budget (~83 min) covering the worst-case data-shred workload;
timeout aborts the conversion rather than declaring
NativeKubernetesMode with volumes half-shredded.

sriov.go / stale_mount_cleanup.go — hardware-conditional
manifest staging and background cleanup daemon launcher.

Every Install has an idempotence gate on a state.Marker; a
re-entry to DEPLOYING or a re-tick of the monitor is a cheap
noop when the marker is already set.

Longhorn's readiness is taken from its instance managers rather than
from the deployment alone: the CSI plugin can report available while the
per-node instance managers are still coming up, and a volume attach in
that window fails. Multus is restarted through its DaemonSet, so a
re-applied configuration is actually picked up by the pods already
running.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
Launches a virtctl vnc proxy on demand for a KubeVirt VMI and
tears it down when the caller process exits. Caller-PID
watchdog polls /proc/<pid> so an orphaned proxy from a
disconnected debug session doesn't linger.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
14 states, 27 events. The daemon boots into INIT, walks through
INSTALLING → STARTING_CTRD → CONFIGURING → STARTING_K3S →
IMPORTING (first-boot only) → WAIT_K3S_READY → DEPLOYING (first-
boot only) → RUNNING, and handles restart / recycle / cluster-
mode-transition events without dropping back into an ad-hoc
respawn loop.

State entries are `enterStateFn` dispatched from `transition`;
async work runs on a per-attempt workCtx via startAsync so
transition cancellation drains the work goroutine cleanly. The
control socket at /run/k3s-supervisor.sock accepts `status`,
`restart`, `stop`, `graph` commands; every operator-facing k3s-
sctl invocation dispatches through the socket rather than
touching flag files.

initKubeclient wires kubeclient.Default at WAIT_K3S_READY's
transition (test-safe when the kubeconfig file is absent —
transition-table tests exploit this). components.DeployAll takes
a retryCtx that main.go supplies as d.rootCtx, so BestEffort
background retries in the deploy graph survive per-invocation
workCtx while still dying on daemon shutdown.

Restart-reason is a typed field carried through the STOPPING_K3S
→ RUNNING_HOOKS → STARTING_K3S transition so restart hooks
(/etc/k3s-supervisor/hooks.d) fire once for operator-driven
restarts and never for crash recovery. The RT operator's
PerformanceProfile-change flow depends on this — CPU isolation /
RDT partitioning need to be reconfigured between stop and start,
not on every crash.

README.md sketches the architecture, package boundaries, and
the future-work list (bootstrap-cert fingerprint pinning,
config-package retirement, and so on).

Two behaviours in the loop are worth calling out. A cluster transition
that fails retries in place instead of falling through to a state that
assumes it succeeded — falling through leaves a node half-transitioned,
which is not recoverable by pushing new configuration. And images are
imported on every k3s start rather than only on first boot, because a
restart can find the content store missing entries that the running
cluster still references.

The operator breakpoints are plumbed through here: a wait item placed in
the FSM's path lets a test or an engineer hold a node at a chosen point
and release it, which is how the multi-node transitions are inspected on
real hardware.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
One-shot tool that dials /run/k3s-supervisor.sock and prints the
reply. Reads until EOF so multi-line responses (graph) print
naturally alongside single-line ones (status / restart / stop).
K3S_SUPERVISOR_SOCKET overrides the path for tests.

Exit code follows the last line: non-zero when it starts with
ERR.

Subcommands:
 - restart — graceful k3s restart (pre-restart hooks fire).
 - status — one-line status report (state, k3s pid, uptime,
            backoff, restart count, phase, transition-step).
 - stop    — stop the kube-init daemon.
 - graph   — resolved deploy-graph edges (one per line, each
             tagged with its rule source).

Breakpoints are managed from the same CLI (break set / clear / list), so
holding a node at a chosen point does not require reaching into
/persist by hand.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
…helper

pkg/kube/Dockerfile — the kube linuxkit container's entrypoint
is /usr/bin/kube-init. Everything else the container needs to
ship (k3s multi-call binary, longhorn nsmounter, CNI plugin
staging, longhorn/kubevirt/CDI/multus tarball drops under
/images/) is installed via the existing build.yml conventions.

pkg/kube/k3s-control.sh — small wrapper that dispatches to
k3s-sctl. Kept so external tooling (eden / eve exec kube …)
that used to invoke this path continues to work; every
subcommand it takes now goes over the control socket rather
than touching flag files.

pkg/kube/kubevip-delete.sh — minimal helper that KubeVIPDelete
calls to remove leftover kube-vip state files after component
deletion (leftover static-pod manifests, arp cache). Not part
of the deploy graph; runs from the uninstall path.

sqlite is included in the image because the datastore snapshot is taken
with SQLite's VACUUM INTO, which is what makes an online-consistent copy
possible without stopping k3s.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
Eleven files under pkg/kube/ implemented the previous
cluster-init flow in POSIX shell + kubectl. Every operation
they exposed lives in pkg/kube/kube-init/ now — no external
caller depends on them, and keeping them alongside the Go
daemon would leave two divergent implementations of the same
paths that operators could accidentally invoke.

Deleted:
 cluster-init.sh cluster-update.sh cluster-utils.sh
 descheduler-utils.sh kubevirt-utils.sh longhorn-utils.sh
 pubsub.sh registration-utils.sh tie-breaker-utils.sh
 utils.sh vnc-proxy.sh lib/config.sh
Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
…stDevices

The KubeVirt CRD requires spec.configuration.permittedHostDevices.
{pciHostDevices,mediatedDevices,usb} to be arrays. Bare keys
parse as `null` in YAML; server-side apply forwards `null` as an
explicit set-to-null, which the schema rejects with:

  KubeVirt.kubevirt.io "kubevirt" is invalid:
  spec.configuration.permittedHostDevices.mediatedDevices:
  Invalid value: "null": ... must be of type array

Explicit `[]` satisfies the schema.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
The kube linuxkit package now ships a Go daemon in place of the
shell path. Adds a kube-init build rule that DOCKER_GO-invokes
`go build ./cmd/... .` inside pkg/kube/kube-init/, staged into
pkg/kube/dist/ before the linuxkit pkg build. pillar-* targets
are unchanged.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
kube-init's vendor tree is upstream Go modules with well-known
licenses; the SPDX generator's per-file scan blows out on the
tree size without adding value.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
Covers the rollback path behind the /var/lib snapshot: a node that has
joined an HA cluster is converted back to standalone K3s by the controller
withdrawing its EdgeNodeCluster config. Joining overwrites /var/lib with
cluster-mode state, so converting back is a restore rather than a
reconfiguration -- without a usable snapshot the node boots K3s against
cluster-mode state and crash-loops, which is what this catches.

Only one of the three nodes is converted, so the test also covers the
cluster surviving a member leaving. Placed after TestThreeNodesCluster in
the suite: it shares that test's device and network requirements so the
VMs are reused, and it is ordered last because it destructively changes
the topology the preceding test relies on.

(cherry picked from commit e46c515)

Phase 4 — that removing a member leaves the survivors a working cluster
— is asserted from the node that actually reports it. Cluster-wide info
is published by whichever node holds the eve-kube-stats-leader lease and
actively unpublished by the others, so a non-leader survivor never
reports a node list at all. It is also polled from the last reported
state rather than awaited as a fresh message: publication is
change-driven, so the removal is reported once, while the converted node
is still rebooting, and then the survivors fall silent because nothing
further changes.

The conversion reboots the node by design — the pre-cluster /var/lib has
to be restored before k3s starts, which cannot be done underneath a
running k3s — so the test declares that reboot. EdgeDevice.ExpectReboots
exists for exactly this: a reboot EVE performs as a required consequence
of a configuration change, which the close-time audit would otherwise
report as a crash.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>

@andrewd-zededa andrewd-zededa 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.

I have a few concerns and of varying priority. The kubevirt version mismatch needs to be fixed, the others also appear to be regressions.

Comment thread pkg/kube/kube-init/images/images.go Outdated
Comment on lines +87 to +92
// KubeVirt v1.6.0 (5 images — operator + the 4 pods it spawns).
{Tarball: "/images/virt-operator.tar", Name: "quay.io/kubevirt/virt-operator", Tag: "v1.6.0"},
{Tarball: "/images/virt-api.tar", Name: "quay.io/kubevirt/virt-api", Tag: "v1.6.0"},
{Tarball: "/images/virt-controller.tar", Name: "quay.io/kubevirt/virt-controller", Tag: "v1.6.0"},
{Tarball: "/images/virt-handler.tar", Name: "quay.io/kubevirt/virt-handler", Tag: "v1.6.0"},
{Tarball: "/images/virt-launcher.tar", Name: "quay.io/kubevirt/virt-launcher", Tag: "v1.6.0"},

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.

images/images.go:88-92 imports and re-tags virt-operator/virt-api/virt-controller/virt-handler/virt-launcher as v1.6.0, but components/components.go:77 applies the v1.7.3 CR,

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.

Confirmed, and there is a third witness: update-component/expected_versions.yaml declares kubevirt: "v1.7.3", agreeing with kubevirt-operator.yaml (v1.7.3 at both the KUBEVIRT_VERSION env and the virt-operator image) and with components.go's kubevirtCRURL. Only images.go says v1.6.0.

Worth recording that it is inert today: nothing in the tree produces /images/*.tar — grepping the Makefile and all of pkg/ for virt-operator.tar hits only images.go:88 — and importUpstreamImageWith no-ops on a missing tarball, so every UpstreamImages entry short-circuits. The upstream-image-tar-rule that the doc comment at images.go:49 points at does not exist either. @rucoder — I assume the tarballs arrive with the follow-on PR that bundles images via EROFS? That is when this starts to bite: a v1.6.0 pre-load against a v1.7.3 operator is never hit, so the node pulls from quay anyway and the pre-load buys nothing.

Fixing it by hoisting the versions into a single versions package, so images.go, components.go and uninstall.go cannot drift again. One thing that shapes that: only k3s has a runtime override (k3s.versionKubeConfig.K3sVersiongetDesiredK3sVersion, which falls back to the compile-time const). kubevirt/cdi/longhorn/multus have no config item, so for those the const is the sole authority and a cross-check against expected_versions.yaml is the only guard available.

// KubeVIPApply applies the Kube-VIP service account, configmap, and
// daemonset. Order matters: SA + CM before DS so pod startup finds
// its config.
func KubeVIPApply(ctx context.Context) error {

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.

It looks like nothing calls this. The shell ran check_kubevip_lb once per main-loop iteration (cluster-init.sh:1088), diffing LBInterfaces[0] against /var/lib/kubevip-applied — that marker is also absent from the new code.

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.

Confirmed, for both this and KubeVIPDelete below: neither has a caller anywhere in the tree.

The reconciliation around them is missing too — grepping the PR's pkg/kube for kubevip-applied and LBInterfaces returns nothing, so neither the marker (cluster-init.sh:1080) nor the per-iteration diff (check_kubevip_lb, called at cluster-init.sh:1809) has a Go counterpart. And the shell invoked kubevip-apply.sh <iface> <cidr>, templated per interface, whereas KubeVIPApply applies three static files with no interface or CIDR input.

That makes it a feature port rather than a fix, so I am leaving it out of the batch I am pushing.

// (daemonset first so pods drain before the SA/CM disappear).
// Per-file delete failures are warnings, not errors — uninstall
// proceeds across stale state.
func KubeVIPDelete(ctx context.Context) error {

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.

It looks like nothing calls this. The shell ran check_kubevip_lb once per main-loop iteration (cluster-init.sh:1088), diffing LBInterfaces[0] against /var/lib/kubevip-applied

Comment thread pkg/kube/kube-init/main.go Outdated
// code would treat an already-converted device as un-converted
// (re-enabling KubeVirt install). Must run before any consumer
// of state.NativeKubernetesMode reads the marker.
if err := state.MigrateLegacyBaseK3sMode(); err != nil {

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.

MigrateLegacyBaseK3sMode runs before /var/lib is mounted:

  • the base-k3s-mode check always reads empty and the rename never happens
  • workInit then reads NativeKubernetesMode (main.go:1559) as false and leaves installKubevirt = true, re-installing KubeVirt/CDI on a node that deliberately had them removed by the K3S_BASE conversion.

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.

Confirmed, both halves. state.MigrateLegacyBaseK3sMode() runs from main(), but /var/lib is bind-mounted by prereqs.MountKubeRoot (prereqs.go:502), reached only via RunAll (prereqs.go:155) from workInit (main.go:1541) — and both marker paths are under /var/lib (state/markers.go:58, :73). So the legacy check reads the container's own empty /var/lib and the rename never happens.

The consequence you describe follows: workInit reads the marker at main.go:1559, sees false, and leaves installKubevirt = true on a node the K3S_BASE conversion deliberately stripped.

Fixing by moving the migrate call into workInit, immediately after RunAll returns.

return nil
}

func upgradeComponent(ctx context.Context, comp string) error {

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.

CheckClusterComponents dropped the pre-upgrade readiness gate. The shell bailed out of the whole update pass if a component wasn't healthy on its current version.

The failure mode here can lead to components stuck mid version and then the health worker may be parked forever blocking k3s drift checks, node-label reapply, multus reapply, SR-IOV staging, CDI proxy patching.

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.

Confirmed. cluster-update.sh:196-200 gates each component on update_Component_CheckReady against its current version and bails the whole pass if it fails; upgradeComponent goes straight from componentIsInstalled to the version compare, so an unhealthy component gets upgraded on top of a bad state.

compCheckReady already exists, so it is a short change, but it introduces a new abort path and I would rather not guess at the shape: the shell returned non-zero specifically so the main loop would restart k3s. @rucoder — should the Go version return an error (matching that), or log and skip the component and carry on? I will add it once you have a preference.

Comment on lines +62 to +66
if appliedVersionGEQ(appliedVersion, KubeVersion) {
log.Printf("update: cluster components at applied=%s, target=%d — no update",
appliedVersion, KubeVersion)
return nil
}

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.

The existing cluster-update.sh path had a "push KCUS status forward" path here, this can now lead to a system reporting BaseOsUpdating forever.

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.

Confirmed. cluster-update.sh:170-177 publishes longhorn completed when the existing KCUS reports Component==2 && Status==5; the Go early return does not, so a node that took a k3s update and rebooted reports BaseOsUpdating indefinitely.

The pieces are already there — kcus.Get(), and types.CompK3s/types.CompStatusCompleted (values 2 and 5, clusterupdatetypes.go:66, :20), used in exactly this shape by updateFailed() at version.go:76-82. Fixing it in the batch I am pushing.

@eriknordmark

Copy link
Copy Markdown
Contributor

I've pushed five commits to this branch (maintainer edit — nothing of @rucoder's history was rewritten, they append to ea4e1c402):

commit what
1bf8c73e1 pre-load KubeVirt v1.7.3, not v1.6.0
f2b2bea91 one versions package as the single declaration per component version
bbca978af run the legacy K3sBase marker migration after /var/lib is mounted
9262c867f finish a cluster update the k3s step left parked, so the device stops reporting BaseOsUpdating
f07d2edfa run the kube-init tests in make test

The last one is worth separate attention: a full make test on this branch passed while executing no kube-init test at all. The target enumerates its Go modules by hand and kube-init, being its own module, was never added — so the suite in this PR has never run anywhere. It is green under -race in ~75s across all 20 packages now that it does run.

Marking this draft while re-testing is in progress. The changes above want a full pass before this goes back in the merge queue, and I'd rather the draft state say so than have it look ready. The code itself is ready to look at.

@andrewd-zededa — could I get a re-review? I've replied to all of your threads individually. Summarising where each landed:

  • Fixed here: the KubeVirt version mismatch (your must-fix), the Longhorn version string, MigrateLegacyBaseK3sMode running before the mount, and the KCUS status push-forward.
  • Confirmed, deliberately not in this batch: the mgmtproxy bypass (three chokepoints, not seven call sites — it needs a decision on how proxy config reaches kubectlx), the missing kube-VIP LB reconciliation (a feature port: the marker, the LBInterfaces diff and the per-interface templating are all absent, and both Go functions are uncalled), and the pre-upgrade readiness gate (needs @rucoder's call on error-vs-skip).
  • Already addressed before your comments: all five client-go suggestions — kubectlx is a client-go wrapper now and no kubectl process remains — plus the three const requests and the descheduler note. I've listed the residual literals in each thread.
  • Open on the vnc test: your point stands and gets sharper now that the suite actually runs.

@eriknordmark

eriknordmark commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Pushed 904168119, which adds one unit test — TestNADCRDNameMatchesManifest in pkg/kube/kube-init/components/.

multus-daemonset.yaml declares the NetworkAttachmentDefinition CRD and, in the same file, an instance of that kind, so applying it works only because kubectlx.ApplyFile waits for each CRD document to report Established before continuing to the next. Nothing asserted the manifest still ships both halves: drop the instance, or rename the CRD, and that ordering either silently stops mattering or silently starts failing — with the failure surfacing on a device as a lost NAD rather than in CI.

The test reads the manifest and asserts both are present, so a future manifest bump that removes either one fails the suite and the ordering assumption gets revisited deliberately instead of changing by accident. It only reads a file — no API server, no cluster.

eriknordmark and others added 8 commits August 7, 2026 18:55
The KubeVirt image catalog pinned the five virt-* images at v1.6.0
while every other declaration of the version says v1.7.3: the CR URL
applied by InstallKubeVirt, the operator manifest that names the
image and sets KUBEVIRT_VERSION, and expected_versions.yaml, which
drives the update-component drift check. A node pre-loading v1.6.0
would never satisfy a v1.7.3 pod spec, so the operator's pods would
pull from quay anyway and the pre-load would buy nothing.

The mismatch is currently latent: no rule produces the /images/*.tar
tarballs yet, and a missing tarball is a logged no-op that defers to
kubelet's pull-on-first-use. Correcting it now keeps the catalog
honest for whenever the tarballs start shipping.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Component versions were spread across five files: the Longhorn config
path baked the version into a filename, the KubeVirt CR URL inlined
it, CDI and k3s each had their own const, and the pre-loaded image
catalog repeated all of them once per image. Nothing tied those
copies together, which is how the KubeVirt tags came to disagree with
the operator manifest.

Introduce a leaf versions package holding one const per component and
derive every other site from it. All const expressions, so this is a
compile-time rearrangement with no runtime change. The k3s const stays
a default rather than an authority: the controller's k3s.version
config item still overrides it at runtime.

Two declarations remain outside the Go tree and cannot reference the
package — expected_versions.yaml and the vendored kubevirt-operator
manifest — so the package doc names them as sites a bump must also
touch.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A device converted to native Kubernetes under an older EVE image
records that in a marker whose name has since changed. The rename ran
from main(), before the prereqs pass bind-mounts /var/lib, so it
inspected the container's own empty directory, found nothing, and
returned successfully having done nothing. The real marker was still
under its legacy name once the mount appeared, so the daemon read the
device as un-converted and reinstalled KubeVirt and CDI on a node that
had deliberately had them removed.

Run the migration inside the init work step instead, right after the
prereqs pass returns and well before the marker is read. A failure now
returns rather than calling log.Fatalf: at this point the daemon has a
state machine that can back off and retry, which it did not when the
call ran from main().

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
zedagent holds the device in BaseOsUpdating for as long as the cluster
update status names an unfinished component. A k3s update reports its
own step complete and then reboots; on the way back up the component
pass finds everything already converged, returns early, and publishes
nothing. The status stays parked on the k3s step and the device reports
BaseOsUpdating indefinitely, which the shell avoided by advancing the
status itself on that path.

Advance it the same way: on the converged path, a status sitting on a
completed k3s step is retroactively finished. Both halves of that
condition matter — a status naming another component, or naming k3s at
any stage short of completed, belongs to a pass still in flight and is
left alone.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The kube-init daemon is its own Go module, and make test enumerates
its modules explicitly, so none of kube-init's tests have ever been
executed by the target — the package ships a test suite that no CI
path runs. The container build only compiles the binary, and no
workflow names the module either, so the gap is invisible short of
noticing the absence in the log.

Add it alongside the other per-module invocations. The suite needs
./... because kube-init's tests live in its subpackages rather than
at the module root, unlike the newlog and edgeview entries above.
Runs green under -race in about 75 seconds.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The multus manifest declares the NetworkAttachmentDefinition CRD and an instance
of that kind in the same file, so applying it depends on ApplyFile waiting for
establishment between documents. Nothing checked that the manifest still ships
both halves: drop the instance or rename the CRD and the ordering silently stops
mattering, or silently starts failing. Read the manifest and assert both.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codespell rejects "unparseable" and "COPYs"; spell the first the way
the rest of the module already does and the second as plain English.

Revive's exported rule wants a doc comment to open with the identifier
name followed by a verb, which the WaitFromContext and OnProgress
comments did not. The waitSystemPodsReady paragraph had also drifted
onto var OnProgress, so move it back to the function it describes.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A container app migrated to EVE-k never leaves BOOTING: its virt-launcher
pod fails with ErrImageNeverPull for eve-external-boot-image. The import
that should place that image into containerd looks for the tarball under
/images/, but the kube package ships it at /etc/ (Dockerfile COPY), which
is also where the shell cluster-init.sh this daemon replaces read it from.
A missing tarball is treated as a silent no-op, so the import reports
success, the image is never tagged, and since pillar references it with
PullNever and it exists on no registry, the device cannot recover.

Point the constant at /etc/, and have the restart-time re-import use the
images package constants rather than repeating the literals, so the two
sites cannot drift apart again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: eriknordmark <erik@zededa.com>
@eriknordmark

Copy link
Copy Markdown
Contributor

The external-boot-image import is a no-op as written: ExternalBootImageTar is /images/external-boot-image.tar, but the kube package installs the tarball at /etc/ (pkg/kube/Dockerfile: COPY external-boot-image.tar /etc/), which is also where the shell cluster-init.sh this daemon replaces reads it from (boot_img_path="/etc/external-boot-image.tar").

Since ImportExternalBootImage treats a missing tarball as a silent no-op, the import reports success, no eve-external-boot-image tag ever enters containerd, and pillar references that image with PullNever — so a container app on EVE-k never leaves BOOTING, with no way to recover.

Seen on a kvm→k conversion run today. Everything else comes up healthy, which is what makes it easy to misread: k3s Ready, KubeVirt and Longhorn Running, StorageClass ready — only the app is stuck.

kube-init: external-boot-image tarball not found at /images/external-boot-image.tar, skipping
Warning  ErrImageNeverPull  pod/virt-launcher-xhv-app-402b6-0qkkl4-pgqgq
  Container image "docker.io/lfedge/eve-external-boot-image:latest" is not present with pull policy of Never

while /etc/external-boot-image.tar is present on the device (17 MB) and crictl images has no external-boot entry.

There are two sites, from two commits:

  • the constant in pkg/kube/kube-init/images/images.go (pkg/kube/kube-init/images: image tarball import into containerd)
  • a duplicated literal at the reimportImages call site in pkg/kube/kube-init/monitor/monitor.go (pkg/kube/kube-init/monitor: RUNNING-state watchers)

A fix on top of the current PR head, pointing the constant at /etc/ and making the re-import use the images constants so the two sites cannot drift again: eriknordmark@d6eff73

One caveat for whoever applies it: the ~30 /images/*.tar upstream tarball paths are correct as they are — those fall back to "will pull on first use", which works whenever the device has network. Only the PullNever boot image is fatal, so that one path is the only one that should change.

@eriknordmark

Copy link
Copy Markdown
Contributor

Pushed to this branch as d6eff7335 — no action needed on your side, just flagging so the fix isn't applied twice.

@rucoder

rucoder commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

@eriknordmark yes, I forgot to fix ExternalBootImage bug. it is fixed on a follow-up branch with EROFS. thanks for finding this!

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.

6 participants