Skip to content

Pipeline KNE cluster init deploys - #768

Merged
kraney merged 6 commits into
openconfig:mainfrom
kraney:pipeline
Sep 23, 2026
Merged

kraney merged 6 commits into
openconfig:mainfrom
kraney:pipeline

Conversation

@kraney

@kraney kraney commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Fair warning - there's nothing important gated by this, but it addresses something that annoys me when doing KNE development and thus running it repeatedly.

Summary

kne deploy brought the ingress, CNI and controllers up one at a time, deploying
each and blocking until it reported healthy before starting the next. These
components are independent — each applies its own manifests and waits for its own
workloads in its own namespace, and none reads state produced by another — so the
serialization made a deployment cost the sum of every component's rollout rather
than the slowest one.

This deploys them concurrently.

Measured on a from-scratch kind deployment, the component phase went from an
estimated ~4m00s to a measured 1m10s, a ~3.4x improvement. Re-running against
an existing cluster completes in 1.8s.

The "before" figure is an estimate: it is the sum of the per-component durations
observed in the same run, which is what serial execution would have cost. It is
not a separately measured baseline.

Per-component health waits from that run:

Component Time to healthy
MetalLB (ingress) 69.2s
Lemming controller 45.4s
IxiaTG controller 44.2s
CEOSLab controller 40.4s
SRLinux controller 36.5s
Meshnet (CNI) 0.5s

Commits

Each commit builds and passes tests on its own.

  1. exec/fake: make Command safe for concurrent use — fake.Command served
    as both the shared response registry and the exec.Cmd handed to the caller:
    Command() mutated the receiver and returned it. Any code under test running
    commands from more than one goroutine raced. Split the two roles; no exported
    API change.
  2. deploy: inject the k8s client into all components before deploying —
    CNI and controllers had SetKClient called after their Deploy, which
    worked only because their Deploy never touched the client. No behavior
    change; prerequisite for the next commit.
  3. deploy: bring up ingress, CNI and controllers concurrently — the main
    change. Also applies to Deployment.Healthy, which had the same serialized
    shape.
  4. deploy: raise the per-component health budget to 3 minutes — see below.
  5. exec/run, deploy: tag command output with the component that ran it —
    restores attributability of interleaved kubectl output.

Design notes

Timeouts are per component, not shared. Each component's health budget starts
when that component finished deploying. A single shared deadline would charge a
component for a slow sibling, and — the deciding argument — would expire for
everything still pending at the same instant, so a failure would report N
simultaneous timeouts and say nothing about which component was actually stuck.

Errors are classified before being returned. The components share a context,
so the first failure cancels the rest. Without classification the one real error
is buried among indistinguishable context canceled. Failures now report as one
of: exceeded its own health budget, hit the overall deployment timeout, or was
abandoned because something else failed. Controllers, previously anonymous in
both logs and errors, now name themselves by type — SRLinux controller was not healthy within 3m0s rather than failed to check controller is healthy.

New overall timeout, --deploy_timeout, default 10 minutes. Nothing bounded
a hung Deploy before: the per-component budgets only cover the health wait, and
kubectl has no timeout of its own, so a stuck deployment relied on the calling
automation to eventually kill it. It deliberately does not cover cluster
bringup, which happens first and is largely not interruptible; folding that in
would reintroduce the "charged for someone else's slowness" problem the
per-component budgets avoid.

Why the health budget went from 1m to 3m. Operator readiness is mostly a fixed
cost, not noise. Across two from-scratch deployments MetalLB took 69.15s and
69.25s, Lemming 45.36s and 45.43s, IxiaTG 44.12s and 44.18s — three of five
reproduce to within 100ms. MetalLB has therefore never fit inside a one minute
budget; it never failed only because the wait that matters happens inside
MetalLBSpec.Deploy, so the budget applied to Healthy only ever saw an
already-healthy deployment. Concurrency did not create this, it made it visible.
The remaining components sat at 44–46s, clearing 60s but not by much. Being
generous is safe now in a way it was not before: the budget used to be the only
limit on the phase and had to double as the backstop against a stuck deployment,
a role --deploy_timeout now plays.

Behavior changes reviewers should notice

  • healthTimeout 1m → 3m (rationale above).
  • A nil Ingress or CNI is now skipped with a warning rather than
    panicking. Sequentially a nil component was frequently never reached because an
    earlier one failed first; running them together always reaches it. The CLI
    already rejects a config missing these, so this only affects direct library
    users.
  • Log format: output from concurrently deployed components is now prefixed
    (kubectl/<component>). Cluster-level commands are unchanged as (kubectl).
  • New flag --deploy_timeout (duration, 0 = default) and a corresponding
    Deployment.Timeout field.

Testing

  • Deployment.Deploy previously had no test coverage. The concurrent phase is
    extracted as deployComponents, which is testable without a cluster,
    kubeconfig or kubectl, with new tests covering actual overlap, error
    attribution, both timeout paths, nil components and command labelling.
  • New exec/fake concurrency test; verified it reproduces the old data races
    under -race and passes on the fix.
  • go test -race across affected packages, stable over repeated runs.
  • Verified end to end on a real kind cluster, both from scratch and re-run
    against an existing cluster.

Not included

  • MetalLB's internal health wait still has no per-component budget. Making
    the model uniform would mean splitting MetalLBSpec.Deploy in two — the
    IPAddressPool CRs genuinely require the webhook to be up first — and changing
    the Ingress interface. Not worth it for the uniformity alone.
  • The redundant ingress health check is retained. MetalLBSpec.Deploy
    already waits internally, so the subsequent Healthy is near a no-op
    (measured at 3ms). Ingress is an exported interface and an out-of-tree
    implementation may rely on the caller invoking Healthy, so dropping it would
    be a silent contract change.
  • No retry-on-conflict for concurrent kubectl apply. In principle
    simultaneous applies could race on shared cluster-scoped resources, but the
    bundled manifests do not overlap — each operator owns its own namespace and
    CRDs — and no conflict has been observed.

fake.Command served double duty as both the shared response registry and
the exec.Cmd handed back to the caller: Command() mutated cmd/args on the
receiver and returned the receiver itself. Any code under test that ran
commands from more than one goroutine therefore raced on the command name,
args and stdio, and could observe another goroutine's output.

Split the two roles. Command keeps the response bookkeeping and now hands
out an independent invocation per call, and the response matching happens
under a mutex. The lock is held across the LogCommand callback as well, so
test hooks that record commands stay serialized and need no locking of
their own.

Response ordering semantics are unchanged: responses are still matched in
order by default, so tests that issue commands concurrently need to mark
the affected responses OutOfOrder.

No change to the exported API.
CNI and controllers had SetKClient called immediately *after* their
Deploy, not before. That works only because their Deploy is currently a
plain kubectl apply that never touches the client; the moment one of them
needs it, as Ingress already does, it would see a nil client.

Move all of the client injection into a single block before anything is
deployed. No behavior change today, but it removes an ordering trap and
is a prerequisite for deploying the components concurrently, where
"set it just after Deploy" has no well-defined meaning.
Deploy walked the components one at a time, deploying each and then
blocking until it reported healthy before touching the next. The
components are independent: each applies its own manifests and waits for
its own workloads in its own namespace, and none reads state produced by
another. Serializing them made a deployment cost the sum of every
component's rollout instead of the slowest one.

Run them in an errgroup instead. Deployment.Healthy gets the same
treatment, since it had the identical serialized shape.

Timeouts are per component, not shared. Each component's health budget
starts when that component finished deploying, which keeps the existing
meaning of healthTimeout exactly ("one rollout should take under a
minute") rather than silently charging a component for a slow sibling.
The decisive argument is debuggability: under a single shared deadline
everything still pending expires at the same instant, so a failure
reports N simultaneous timeouts and says nothing about which component
was actually stuck.

Because the components share a context, the first failure cancels the
rest, so errors are classified before being returned: exceeded its own
health budget, hit the overall deployment timeout, or was abandoned when
something else failed. Without that the one real error is buried in a
pile of indistinguishable "context canceled". Controllers, which were
previously anonymous in both logs and errors, now name themselves by
type, so a failure says "SRLinux controller" rather than "controller".

Also add an overall timeout for the phase, exposed as --deploy_timeout
and defaulting to 10 minutes. Nothing bounded a hung Deploy before: the
per-component budgets only cover the health wait, and kubectl has no
timeout of its own, so a stuck deployment relied on the calling
automation to eventually kill it. The timeout deliberately does not
cover bringing up the cluster, which happens first and is largely not
interruptible, and folding it in would reintroduce exactly the
"charged for someone else's slowness" problem the per-component budgets
avoid.

One behavior change worth noting: a nil Ingress or CNI is now skipped
with a warning. Sequentially a nil component was frequently never
reached because an earlier one failed first; running them together
always reaches it, and panicking there would be a poor trade.

Deployment.Deploy had no test, so extract the concurrent phase into
deployComponents, which is testable without a cluster, kubeconfig or
kubectl, and cover overlap, error attribution and both timeout paths.
Operator readiness costs are larger than the 60s budget allowed for, and
they are not noise. Measured across two from-scratch deployments, MetalLB
took 69.15s and 69.25s to become healthy, Lemming 45.36s and 45.43s, and
IxiaTG 44.12s and 44.18s: three of five components reproduce to within
100ms, which points at fixed costs like leader election and webhook cert
setup rather than at scheduling variance.

MetalLB has therefore never fit inside a one minute budget. It did not
fail because it was never held to one: the wait that matters happens
inside MetalLBSpec.Deploy, so the budget applied to Healthy only ever saw
an already-healthy deployment. Concurrency did not create this, it just
made it visible. The remaining components sat at 44-46s, which cleared
60s but not by much, and would not survive a slower machine.

Being generous here is safe now in a way it was not before. The budget
used to be the only limit on the phase, so it had to double as the
backstop against a stuck deployment; defaultDeployTimeout now plays that
role, leaving healthTimeout to express what it is actually for, namely
how long one rollout should reasonably take.
Deploying the components concurrently interleaves their kubectl output.
Every line is prefixed only with the binary that produced it, so a real
deployment emits runs like three "(kubectl): ..." lines sharing a
timestamp with nothing to say which component each belongs to. That
undoes much of the debuggability the per-component error attribution
was meant to provide.

Let a context carry a label, and prefix the output of commands run with
it as "(kubectl/SRLinux controller): ...". A context is the right
carrier here: the components already receive one, and a component does
not know its own name, since the name is derived by the deployment that
drives it.

The label is logging only. It deliberately does not tie a command's
lifetime to the context, as nothing today cancels a running kubectl;
that remains the gap defaultDeployTimeout covers from the outside.

LogCommand and the other existing helpers are unchanged, so callers that
do not run commands concurrently, notably cluster bringup, need not care.
@kraney
kraney merged commit bd2fcd3 into openconfig:main Sep 23, 2026
14 of 15 checks passed
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.

2 participants