Skip to content

Reach clusters through a kubeconfig, and ship a Windows .exe - #1

Merged
lam0819 merged 6 commits into
mainfrom
feat/kubeconfig-transport
Sep 10, 2026
Merged

lam0819 merged 6 commits into
mainfrom
feat/kubeconfig-transport

Conversation

@lam0819

@lam0819 lam0819 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Clients running managed Kubernetes could not use k3helper at all, and clients on Windows had no binary. Both are fixed here.

Managed clusters

Every layer below the cluster assumed SSH into a node and a kubectl living there. That covers k3s and kubeadm and excludes every managed cluster — EKS/GKE/AKS hand out a kubeconfig and keep the machines. The cluster layer never needed the machines; only the transport did.

A targets entry now carries either nodes: or kubeconfig::

cluster: client-prod
kubeconfig: ~/.kube/config
kube_context: prod-admin      # optional; not called "context", which is already taken

doctor, deploy, verify --dry-run-server, gen and the TUI (logs, describe, port-forward) all work there. internal/transport is the one place that opens connections, so a second way to reach a cluster cannot drift between the fourteen call sites that used to dial for themselves.

The local transport parses rather than shells out. The codebase builds shell command strings, and internal/ssh/local.go runs them through /bin/sh, which Windows does not have. So kubectl.Local.Run asserts the command is one of its own kubectl invocations, strips the trailing redirection, splits arguments honouring the quoting kube.shellQuote produces, and execs kubectl directly. Anything else — df, systemctl, the k3s probes — is refused with exit 127. That refusal is the feature: a host probe fails where it is written, with a reason.

Layers are gated, not attempted. Evidence.HostLayerUnavailable is deliberately neither a ProbeError nor an UnreachableNode. Both of those mean "we tried and failed", which is a fault; this means "there was never anything to try", which is how managed clusters work. Conflating them would report every healthy EKS cluster as degraded. vm setup and registry apply refuse outright rather than doing half the job.

Windows

k3helper-windows-amd64.exe and -arm64.exe are built by make release, listed in checksums.txt, and published with every tag. One file, no installer. install.sh recognises Git Bash/MSYS/Cygwin and prints the .exe URL instead of installing somewhere that is on nobody's PATH.

CI

scripts/portable-check.sh runs the binary from a directory it has never seen — it must start, explain a missing targets file, refuse a kubeconfig that is not there without writing anything, complete both init flows, generate and verify YAML with no cluster or network, and leave nothing in $HOME. Linux, macOS and Windows all run that same script. macOS had nothing in CI before this. Windows also runs a second pass under PowerShell with native paths and no POSIX shell involved.

Bugs this surfaced

  • doctor missed crash loops it sampled between restarts. A container in backoff reads as CrashLoopBackOff only while waiting; the moment kubelet restarts it the pod is Running with no reason attached, and the diagnosis saw nothing worse than an unready pod. Which half of the cycle doctor landed in was chance, so the finding was chance too — on real clusters as much as in the fault matrix, which is where two CI runs disagreed and gave it away. Restart counts were already parsed and dropped; they are kept now.
  • doctor exited 2 for every healthy managed cluster, because the scope note counted as a fault.
  • kyaml.RemotePath hardcoded /tmp and would have built \tmp\... once joined from a Windows client deploying to a Linux node.
  • expandHome concatenated instead of joining, producing C:\Users\you/.kube/config.
  • internal/ssh local mode had an unstated boundary — it needs /bin/sh. It says so now instead of failing with "executable file not found in %PATH%".

Verification

All five CI jobs green on 78ddc05; fault matrix 12/12. Integration tests read the sandbox twice, over SSH and through its kubeconfig, and assert the two agree.

No breaking changes — existing targets files keep working. Version bumped to 0.5.0; tagging v0.5.0 is what publishes the assets.

…y SSH

Every layer below the cluster assumed SSH into a node and a kubectl living
there. That covers k3s and kubeadm and excludes every managed cluster, which
is what most clients run: EKS/GKE/AKS hand out a kubeconfig and keep the
machines. The cluster layer never needed the machines — only the transport
did.

A targets entry now carries either `nodes:` or `kubeconfig:`, and
`Targets.Mode()` says which. `internal/transport` is the one place that opens
connections, so a second way to reach a cluster cannot drift between the
fourteen call sites that used to dial for themselves.

The local transport parses rather than shells out. The codebase builds shell
command strings, and internal/ssh/local.go runs them through /bin/sh, which
Windows does not have. So kubectl.Local.Run asserts the command is one of its
own kubectl invocations, strips the trailing redirection, splits the arguments
honouring the single quoting kube.shellQuote produces, and execs kubectl
directly. Anything else — df, systemctl, free, the k3s probes — is refused
with exit 127. That refusal is the feature: a host probe fails where it is
written, with a reason, instead of somewhere further down the stack.

Layers are gated rather than attempted. check.HostChecks is the single list
both `check` and the TUI build from. Evidence.HostLayerUnavailable is
deliberately neither a ProbeError nor an UnreachableNode: both of those mean
"we tried and failed", which is a fault, while this means "there was never
anything to try", which is how managed clusters work. Conflating them would
report every healthy EKS cluster as degraded. `vm setup` and `registry apply`
refuse outright — a registry configured on some nodes and not others is the
exact failure the cluster-level registry block exists to prevent.

Two bugs this exposed, both fixed here:

  - doctor exited 2 for every healthy managed cluster, because the scope note
    counted as a fault. Findings that describe what could not be looked at now
    print under "Notes on what was looked at" and do not set the exit code.
  - kyaml.RemotePath hardcoded /tmp and would have built "\tmp\..." once
    joined with filepath.Join from a Windows client deploying to a Linux node.
    The separator now follows the target, not the host.

Proof: unit tests parse the real command strings from gather.go and deploy.go;
exec_test.go runs the whole path against a stub kubectl on PATH; the
integration suite reads the sandbox twice, once over SSH and once through its
kubeconfig, and asserts the two agree.
… three

The binary cross-compiled for Windows already; nothing released it, and
nothing had ever run it. Both halves are fixed here.

Release: windows/amd64 and windows/arm64 join PLATFORMS. Those two assets
carry .exe, because Windows will not execute a downloaded file without the
extension — everything else stays extensionless, and install.sh builds the
asset name the same way. The release workflow now asserts that every platform
asset exists, is listed in checksums.txt, and that the Windows one is really a
PE binary; a missing or misnamed asset otherwise surfaces as somebody's 404.

install.sh recognises Git Bash, MSYS and Cygwin and prints the .exe URL rather
than installing into a /usr/local/bin that means something different under
each of them and is on none of their PATHs.

CI: "single portable binary" is a claim about the runtime, not the build, so
scripts/portable-check.sh runs the binary from a directory it has never seen
and asserts it starts, explains a missing targets file, refuses a kubeconfig
that is not there without writing anything, completes both init flows,
generates and verifies YAML with no cluster and no network, and leaves nothing
behind in $HOME. Linux, macOS and Windows all run that same script, so no
platform is held to whatever its own job happened to test. `make
portable-check` runs it locally.

macOS had nothing in CI at all before this, which is awkward for the platform
most operators drive a cluster from; it now gets vet, race tests and the
portable check on every push. Windows gets the same, plus a second pass under
PowerShell with native paths and no POSIX shell anywhere in the picture —
that being the environment a client actually double-clicks into, and where a
path check tends to quietly pass while being wrong.
Two asks from live use, both about reaching people the tool could not reach
before: clients running managed Kubernetes, and clients on Windows.

README gains a support column for managed clusters, a section on describing
one by its kubeconfig, and Windows install instructions. The limitations list
says plainly that a kubeconfig cluster has no host layer — that being the
nature of a managed cluster rather than a gap here — and that kubeconfig mode
needs kubectl on PATH.

The roadmap loses its Windows entry, which this release closes.
…ssertion

The Windows CI job added in the previous commit did its job immediately: it
ran tests that had never run on that platform. Four failures, three kinds.

A real fix: expandHome concatenated rather than joined, so "~/.kube/config"
became "C:\Users\you/.kube/config". Go accepts the mixed form and kubectl
would have too, but the path is printed in errors, and half-and-half reads
like a bug.

A test assumption: file modes are POSIX. Windows maps a mode onto the
read-only attribute and reports 0666 for anything writable, so there is
nothing to assert there — the manifest is still written under the user's own
profile directory, which is where the protection comes from on that platform.

A design boundary that had never been stated: internal/ssh's local transport
runs commands through /bin/sh, which Windows does not have. Local mode means
"manage the machine k3helper is on", and every command it runs is a Linux one
aimed at a host running k3s — so it is Unix-only by design rather than
unimplemented. It now says that, instead of failing with "exec: /bin/sh:
executable file not found in %PATH%", which reads like a broken install. The
tests that need the shell skip with the same explanation.

Separately, `ctx` grew a REACHED column in the kubeconfig work and the E2E
still matched the old two-column shape.

The sandbox E2E also reported pod.crashloop undiagnosed within its 30s budget.
Nothing here touches that signature or its evidence, and every other fault in
the sweep was caught, so this push re-runs it before anything is changed on
that account — a timing budget is not worth widening until it is clear it is
timing.
CI caught it twice on this branch: doctor reported pod.not-ready where the
sweep expected pod.crashloop. That is doctor being right. A crashlooping pod
has to pull its image, run, exit, and be restarted enough times for kubelet to
give up and back off before the status says CrashLoopBackOff; until then the
pod really is Running and unready, and pod.not-ready is the honest finding.

30s was enough on a developer's machine and on a quiet runner, and not enough
on a loaded one — it passed in one sweep and failed in the other within the
same CI run. The oom fault is the same shape, a pod that keeps dying, and has
had 45s all along.

Nothing on this branch touches that signature, its evidence, or this budget;
the kubeconfig work only made the flake show up twice in an afternoon.
…ring one

Two CI runs disagreed about the same fault, which was the tell. When the sweep
injected a crashlooping pod, doctor sometimes reported pod.crashloop and
sometimes only pod.not-ready — and raising the settle budget from 30s to 45s
did not fix it, because the problem was never how long we waited.

A container in backoff is reported as CrashLoopBackOff only while it is
waiting between attempts. The moment kubelet starts it again the pod is
Running with no reason attached, and a diagnosis taken in that window saw an
unready pod and nothing else. Which half of the cycle doctor lands in is
chance, so the finding was chance too — on a real cluster as much as in the
fault matrix.

The restart count was already parsed and then dropped on the floor. It is now
kept for pods that are Running and not ready, and pod.crashloop fires on
either the waiting status or an unready pod that has restarted three or more
times. Three, not one: a pod that fell over once and came back is not this
finding, and kubelet's backoff has reached 40s by the third restart, so a pod
at that count has been failing for the best part of a minute.

A pod seen both ways counts once, or the confidence would double for a single
crashlooping pod.

The 45s budget from the previous commit stays. It was not what was wrong, but
the oom fault is the same shape and has always had 45.
@lam0819
lam0819 merged commit fa9cbfe into main Sep 10, 2026
10 checks passed
@lam0819
lam0819 deleted the feat/kubeconfig-transport branch September 10, 2026 15:48
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