0014. CLI skeleton under cmd/ichor, and how ichor adopt guarantees zero drift
Status: Accepted · Date: 2026-10-25 · Design section: CLI and UI; Bootstrap (ichor adopt)
Decided: Settled cmd/ichor's package layout and how ichor adopt guarantees zero drift
Context
Phase 4 is the CLI. docs/design/architecture.md's "CLI and UI" section sets
the rule — parity: the CLI and the UI share the same Go packages for
rendering, diffing and planning, and both write CRs rather than mutating a
Talos node directly. It names three exceptions that predate the cluster:
init, adopt and full-loss recovery. This unit scaffolds cmd/ichor with
cobra and implements the first of those exceptions, ichor adopt:
ichor adoptbrings an existing cluster under management by diffing each node's current config against a fresh render and storing the difference as initial patches, so ownership transfers with zero drift.
Two problems surfaced while building this that are worth recording so the next person touching the CLI doesn't rediscover them the hard way.
cobra-cli's default layout collides with kubebuilder's
cobra-cli init cmd/ichor writes cmd/ichor/main.go importing
<module>/cmd — its built-in assumption that command definitions live in a
cmd package one level up from main.go, resolved against the module root
rather than against the path given to init. In this repo that cmd
package is already taken: cmd/main.go is the controller manager's own
package main, from kubebuilder init. Importing a main package is a
compile error ("is a program, not an importable package"), and it broke
make manifests repo-wide the moment it landed, because controller-gen's
paths="./..." walks into cmd/ichor too.
cobra-cli isn't wrong to assume this; kubebuilder's and cobra-cli's
conventions simply both want to own a top-level cmd package, and only one
can. The fix is to let cobra-cli scaffold normally and then move its output:
the command tree lives in internal/cli (package cli), not
cmd/ichor/cmd, and cmd/ichor/main.go is reduced to:
package main
import "github.com/maeshinshin/ichor/internal/cli"
func main() { cli.Execute() }
cobra-cli add <name> has the same issue in reverse: given no way to target
a subdirectory (--package was removed from the CLI), it always writes
cmd/<name>.go at the module root. Every new subcommand has to be generated
there and moved into internal/cli by hand, same as adopt.go was.
goreleaser's default dist/ collides with the committed chart
dist/chart is a committed Helm chart (ADR tooling elsewhere generates it
from dist/install.yaml); goreleaser also defaults to writing its own build
output to dist/. goreleaser build --clean deletes whatever dist: points
at before writing to it — discovered by running it and watching git status
show the entire chart staged for deletion. .goreleaser.yaml now sets
dist: .goreleaser-dist (ignored in .gitignore) so a release build can
never touch the chart.
Decision
Package layout
cmd/ichor/main.go — package main; nothing but Execute()
internal/cli/root.go — rootCmd, persistent flags (-o/--output, --yes,
--kubeconfig, --talosconfig), Execute()
internal/cli/adopt.go — the `adopt` cobra.Command: flags, RunE wiring
internal/cli/adopt_plan.go — buildAdoptPlan: the diff/verify/capture logic,
no I/O, fully unit-testable
internal/cli/adopt_run.go — runAdopt: orchestration (fetch, confirm,
create, report) against adoptDeps, an interface
bundle a test can fake entirely
internal/cli/talosdial.go — talosConfigReader + dialTalos: the one real
Talos API call adopt makes (CurrentConfig)
internal/cli/kube.go — kubeconfig loading, the controller-runtime
client, scoped to Ichor's own CRDs only
internal/cli/bundlefile.go — reads a local secrets bundle file
internal/cli/output.go — -o yaml|json encoding
Scaffolding commands run (from the repo root, inside nix develop):
cobra-cli init cmd/ichor
cobra-cli add adopt
then moving the four files cobra-cli wrote (cmd/ichor/cmd/{root,adopt}.go
from init, cmd/adopt.go from add) into internal/cli, renaming their
package cmd to package cli, and rewriting cmd/ichor/main.go's import as
above. flake.nix already carried cobra-cli; nothing to add there.
kubeconfig and talosconfig
Both follow the tool whose convention they borrow, rather than inventing a third:
--kubeconfig(persistent, root-level): client-go's ownclientcmd.NewDefaultClientConfigLoadingRules()resolves it — an explicit flag value wins, otherwise$KUBECONFIG, otherwise~/.kube/config— the same resolution kubectl itself uses. This is adopt's only path to the Kubernetes API: the TalosCluster/TalosMachine objects it reads, and the TalosConfigPatch objects it creates, all live behind it.--talosconfig(persistent, root-level): passed through to machinery'sclient.WithConfigFromFile, whose empty-string case already resolves$TALOSCONFIG, then~/.talos/config, then the in-cluster service account mount — exactly talosctl's own resolution (pkg/machinery/client/config/path.go). adopt dials each TalosMachine's Talos API through this, using whatever admin identity the operator's talosconfig already carries for the cluster being adopted.
adopt deliberately does not reuse internal/controller.NewMachineryTalosClient
(the controller's Talos API adapter): that constructor derives its client
certificate from a secrets bundle Ichor already manages. adopt predates that
management — there may be no Ichor-issued credential yet — so it dials the
same way talosctl would, through the operator's own talosconfig. The
secrets bundle still has to come from somewhere, since rendering a baseline
needs one: --secrets-bundle takes a local file in the same format
talosctl gen secrets writes and internal/controller/bundle.go's Secret
convention already documents (secrets.yaml), since adopt runs before
Ichor has a Secret of its own to read it from.
How ichor adopt guarantees zero drift
For each TalosMachine already recorded for --cluster:
- Read the node's live configuration over the Talos API
(
talosConfigReader.CurrentConfig). - Render a baseline from the
TalosCluster/TalosMachine/existingTalosConfigPatchobjects already in the Kubernetes API (pkg/render.Render— not re-implemented). - Diff live vs. baseline with
internal/controller.ChangedPaths(exported from this unit's previously-unexportedchangedPaths, logic unchanged) — not re-implemented either. - If nothing changed, there is nothing to adopt for this machine.
- Otherwise, build a candidate
TalosConfigPatchand verify it before ever proposing it: render baseline+candidate and require the result to be byte-identical to the node's live configuration. Only a passing candidate is ever returned frombuildAdoptPlan; only abuildAdoptPlanresult with a patch is ever shown to the operator or created.
Step 5's self-check, not the diff in step 3, is what the "zero drift" acceptance test actually exercises: it is exactly the comparison the test performs independently (render the created patch again, compare to the current-config fixture).
Step 5 also has to not leak secrets, which drove the scope decision in
adopt_plan.go: only SysctlConfig and KernelModuleConfig documents are
ever captured automatically. The legacy v1alpha1 Config document is
deliberately never captured.
Update, same day: the reason this ADR originally gave no longer holds,
and is recorded here rather than silently deleted so the history is
honest. The original reason was schema-specific: TalosConfigPatchDocument's
config.machine object required type, token and certSANs on any
Config document, sparse or not, and token mirrored the secrets bundle —
there was no way to patch e.g. cluster.network.cni.name without also
restating it. A same-day mirrorgen change (see ADR 0003's update) dropped
machine.token, machine.ca, cluster.ca and six other secret-bearing
fields from the mirror type and the CRD schema entirely, closing a real
hole (configpatcher.Apply's strategic merge meant a Config document
could already overwrite a cluster's CA through a TalosConfigPatch). The
two fields config.machine still requires — type and certSANs — are
not secrets: type is the role already on TalosMachine.Spec.Role,
certSANs the machine's own address. A sparse Config document is now
representable without touching anything sensitive.
Second update, same day, this one final: it still does not capture it,
and now for a design reason rather than a missing-code one. The question
was asked directly: now that a sparse Config document is representable
without the schema forcing a secret into it, should adopt actually build
the sparse-reconstruction path? The answer is no, and the reason is a
specific, checked fact about the Config document's own schema, not
"nobody got to it yet."
The nine fields the same-day mirrorgen change dropped are not the only
secret-shaped fields the Config document can carry. That cleanup
removed exactly the fields that mirror secrets.Bundle — the CAs, the
join/bootstrap tokens, the etcd encryption key, the service-account
signing key — because those are Ichor's own root of trust. It was never
meant to, and did not, sweep the rest of Talos's own config surface for
every other field that happens to be a credential. Reading the generated
mirror type for exactly this (not assumed) turns up several, live in the
schema today, that have nothing to do with Ichor's bundle and so were
never in scope for that pass:
RegistryAuthConfig.{password,auth,identityToken}— private registry pull credentials, reachable frommachine.registries.*andcluster.*registry config.VIPEquinixMetalConfig.apiToken/VIPHCloudConfig.apiToken— cloud provider API tokens, undermachine.network's VIP config.WireguardDevice.privateKey(machine.network's WireGuard device config) — a WireGuard private key.ClusterConfig.secretboxEncryptionSecret— apparently missed by the same-day cleanup entirely: it is the secretbox sibling ofaescbcEncryptionSecret, which was dropped, and is exactly the same kind of etcd encryption secret the other one is. Flagged back to whoever owns that cleanup; not something this unit can fix, sincepkg/typedconfigis out of scope here.
None of these come from secrets.Bundle, so no amount of extending that
one cleanup closes them — they are a different, open-ended category:
whatever credential-shaped field Talos's own schema happens to carry
this version, for a use case (private registries, cloud VIPs, WireGuard
KubeSpan) an adopted cluster may well have configured. A sparse-diff
mechanism that captures "whatever changed" has no way to tell "a harmless
field changed" from "someone's registry password changed" without a
field-by-field allow/deny list over the entire Config document — and
that list would need maintaining forever as Talos's own schema grows,
which is a standing commitment this command should not make unilaterally
just to widen what adopt can automate. SysctlConfig and
KernelModuleConfig don't have this problem because their entire field
surface is kernel parameters and module names — there is no credential
shape anywhere in either one to worry about missing.
The consequence is unchanged in practice, now for the reason that will
actually last: a difference confined to the legacy Config document comes
back from buildAdoptPlan as UnrepresentablePaths — field names
only, safe to print — with no patch. runAdopt reports these and leaves
that machine's drift for the operator to patch by hand, through a
TalosConfigPatch they write themselves and can see the contents of
before it goes anywhere. This is covered by
TestBuildAdoptPlan_LegacyDiffIsNeverAutoPatched and is the one
TestAdoptNeverLeaksSecrets subtest ("legacy diff reported, not patched")
that proves the refusal itself never leaks either.
Confirmation and output
--yes skips the interactive y/N prompt before any TalosConfigPatch is
created, matching the design document's "interactive confirmation with
--yes" for every other dangerous CLI operation. -o yaml|json prints the
patches adopt is about to create (or just created); both encoders only ever
see ichorv1alpha1.TalosConfigPatch values, never the secrets bundle or a
full rendered machine configuration.
Consequences
cmd/ichor/main.gostays a two-line shim forever; all real CLI code lives underinternal/cli, which also means future subcommands (init,ui,restore --recover-cluster) land there too, not undercmd/ichor/cmd.cobra-cli add <name>will keep writing to the wrong place (cmd/<name>.goat the module root) until/unless a future cobra-cli version restores a way to target a subdirectory; every new subcommand needs the same by-hand move this ADR describes foradopt.go.- adopt's zero-drift guarantee has a known, now-permanent-by-design gap:
differences confined to the legacy Config document are reported, not
patched. This is not a missing function waiting to be written; it is a
deliberate refusal, because the Config document's field surface has no
stable boundary between "safe to auto-capture" and "a credential" --
registry passwords, cloud API tokens and a WireGuard private key are
live examples found by reading the schema, not hypothetical.
ichor adoptis complete for sysctl/kernel-module drift (no such fields exist there) and honest about everything else. - A second, unrelated finding from the same reading:
ClusterConfig'ssecretboxEncryptionSecretlooks like it was missed by the same-day cleanup that dropped its siblingaescbcEncryptionSecret-- both are etcd encryption secrets, only one was removed. Flagged for whoever ownspkg/typedconfig; out of this unit's scope to fix directly. .goreleaser.yaml'sdist:override means any future tooling that reads goreleaser's own output has to know to look in.goreleaser-dist, notdist/.