0015. GitOps: a hand-rolled Application type, and a policy-free repo skeleton
Status: Superseded by ADR 0101 · Date: 2026-10-25 · Design section: "GitOps and Argo CD" (and "Bootstrap")
Decided: Built GitOps Application as a hand-rolled type (pkg/gitops) behind one constructor, plus a policy-free repo skeleton
Context
Phase 4 needed two things out of pkg/gitops: a way to build Argo CD
Application manifests that already carry the fixes the design notes call
out (server-side apply for large CRD groups, ignoreDifferences keyed by
managedFieldsManagers, sync-wave ordering), and a way to write the
repository skeleton ichor gitops export lays out (bootstrap/, apps/,
clusters/<name>/). Neither ichor init nor ichor gitops export is built
yet; this package only needs to give them something to call.
Should Argo CD's own Go types be a dependency?
Ponytail's ladder says: before writing a struct, check whether a dependency
already provides it. Argo CD publishes its Application CRD type at
github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1. It was tried
directly, not assumed:
go get github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1@latest
This fails outright. The package imports util/git and util/exec, which
pull in github.com/argoproj/argo-cd/gitops-engine through a path that has
no go.mod at the revision argo-cd's own go.mod pins (go: ... invalid version: missing .../gitops-engine/go.mod at revision 97ad5b59a627) — a
replace-directive-shaped dependency that only resolves when building argo-cd
itself, not when importing it as a library. Even granting that this could be
worked around (an explicit replace, or an older release), the same import
chain also pulls in go-git, an SSH agent client (xanzy/ssh-agent), a
Redis cache client, Google Cloud SDK packages, and more — none of which
pkg/gitops has any use for, since it never shells out to git or talks to a
cluster.
Decision: hand-roll the subset of the schema this package needs. It
is small (apiVersion/kind, project, source(s), destination, syncPolicy,
ignoreDifferences) and stable enough to maintain against the public docs
(checked against argo-cd.readthedocs.io/en/stable/, which resolved to the
v3.5.6 docs as of this writing) without a parallel catalogue drifting the
way pkg/typedconfig worried about for machinery. pkg/gitops/doc.go
records the specific failure so a future upgrade attempt doesn't have to
re-discover it.
A landmine found building it: controller-gen scans the whole repo
The first version of Application embedded metav1.TypeMeta and
metav1.ObjectMeta anonymously, the obvious shape for a Kubernetes-ish
type. make build then silently generated
config/crd/bases/_applications.yaml — a CRD named applications. with an
empty group and an empty version, picked up by dist/chart's own invariant
test. The cause: controller-gen's CRD generator
(sigs.k8s.io/controller-tools/pkg/crd/gen.go, FindKubeKinds) treats
any type in any package under paths="./..." as a candidate Kubernetes
kind if it has both fields embedded, with no marker required to opt in —
there is no marker to opt out either, short of not shaping the type that
way. Since Application is never registered with a runtime.Scheme, sent
to an API server, or type-asserted as runtime.Object, it never needed
TypeMeta as a real type in the first place.
Decision: Application carries plain APIVersion/Kind string fields
instead of an embedded metav1.TypeMeta. metav1.ObjectMeta is still
embedded (that alone doesn't trigger the generator — both fields must be
present), which also means Go 1.27's embedded-literal elision applies:
New builds one with Name, Namespace and Annotations set directly on
the struct literal, no nested metav1.ObjectMeta{...} needed.
Decision
Application, Options and New (pkg/gitops/application.go)
New(Options) (*Application, error) is the one constructor. It bakes in
every pitfall the design notes name, so a caller cannot build a
correctly-shaped-but-broken Application by omission:
ServerSideApply=trueis added tosyncPolicy.syncOptionswheneverOptions.ServerSideApplyis set — for the large CRD groups case (e.g. Ichor's own CRDs, or CNI CRDs).ignoreDifferences+managedFieldsManagers: any non-emptyOptions.IgnoreDifferencesis written through as-is, keyed by whatever field manager owns the field (the example from the design notes —widget-operatorowning a CRD'sspec.versions— is exactlyResourceIgnoreDifference{..., ManagedFieldsManagers: []string{"widget-operator"}}). (ADR 0055 narrows this: the pairing applies to entries that name fields, not kind-only ones.)Newalways pairs this withRespectIgnoreDifferences=trueinsyncOptions, becauseignoreDifferencesalone only suppresses the field from the displayed diff — without the sync option, Argo CD still reconsiders the field a sync target. This pairing is not left to the caller to remember.- Sync waves:
Options.SyncWavebecomes theargocd.argoproj.io/sync-waveannotation. The exported constantsSyncWaveArgoCD < SyncWaveCNI < SyncWaveIchor < SyncWaveIchorCRs < SyncWaveStorage < SyncWaveOtherare the design's documented hand-over order ("Argo CD → CNI → Ichor (including CRDs) → Ichor CRs → storage afterTalosStorageis healthy → everything else"), as typed values instead of a magic number a caller has to get right by reading the prose again. - Credential leak guard:
Newparses every source'sRepoURLand rejects one carrying embedded userinfo (https://user:pass@host/repo.git). The design is explicit that repo credentials are a Secret the CLI writes directly, never a value that travels through a file in Git — including inside a URL in a tracked Application manifest. This is enforced again, independently, at write time (next section), so anApplicationbuilt without going throughNewis still caught.
Options does not force opinions this package has none on:
ExtraSyncOptions lets a caller add e.g. CreateNamespace=true without
New guessing whether a given component wants it.
Skeleton and Write (pkg/gitops/skeleton.go)
Skeleton.Write(dir) lays out exactly the structure the design specifies:
bootstrap/root-app.yaml <- Skeleton.RootApp
apps/<name>/application.yaml <- one per Skeleton.Apps entry
clusters/<cluster>/<file> <- one per Skeleton.CRs entry
Which component directories exist under apps/ is not this package's
decision. The design says apps/argocd/, apps/cni/ and apps/ichor/
are always present, apps/storage/ and apps/monitoring/ only when
selected — but deciding which components a given cluster has is ichor init/ichor gitops export's job, built in a different unit of work.
Skeleton.Apps is a plain map[string]*Application; Write renders
whatever keys it's given, in sorted order, and no key is privileged.
Determinism. Every map on Skeleton (Apps, CRs) is walked through
sortedKeys, which sorts before iterating, so the written files never
depend on Go's deliberately-randomized map iteration order. YAML encoding
goes through sigs.k8s.io/yaml, which round-trips through
encoding/json (whose map-key ordering is already sorted) and then through
go-yaml's own map marshaling (also sorted) — so even values nested inside
CRs (e.g. a raw map[string]any CR) marshal to the same bytes regardless
of how they were built. pkg/gitops/skeleton_test.go's
TestSkeleton_Write_Deterministic writes the same Skeleton value 22
times (two named runs plus 20 more in a loop, since Go's map randomization
is probabilistic per-process) and diffs every file byte-for-byte.
Credentials never reach a file. Three independent layers, each tested:
- Nothing to put them in.
SkeletonandOptionshave no field typed to hold a credential or the secrets bundle — there is no parameter a caller could thread one through even by mistake. - The URL guard, twice.
Newchecks every source'sRepoURLbefore building anApplication;Writechecks again (checkApplication, reusing the same logic) onRootAppand every entry ofApps, in case one reachedSkeletonsome other way. Both paths are tested (TestNew_RejectsCredentialsInRepoURL,TestSkeleton_Write_RefusesCredentialedRepoURL). - A
Secret, whatever its source, is refused.WriteObject— the single function every file in the skeleton goes through — type-switches on*corev1.Secretand errors before marshaling anything (TestSkeleton_Write_RefusesSecret). This is where the design's "repo credentials go in as a Secret from the CLI, never into Git" lands as code: the one object type that is a credential-bearing thing by definition cannot be written by this package, full stop.
Boundary with pkg/cni
pkg/cni (a different unit of work) owns the CNI preset's own Application
content — which Helm chart, which values, which release name so hand-over
produces no diff. This package provides the mechanism it builds on top of:
gitops.New for the Application shape and its baked-in fixes, SyncWaveCNI
for where it sits in the hand-over order, and Skeleton.Apps["cni"] as
where its output lands in the repo tree. Nothing CNI-specific — not a preset
name, not a chart reference — lives in pkg/gitops.
Update (ADR 0019's ichor init work): pkg/cni.Preset.ArgoCDApplication
now returns *gitops.Application directly, built through gitops.New,
rather than *unstructured.Unstructured with its own hand-assembled map
(ADR 0013's update records the pkg/cni side of this). pkg/cni depends
on pkg/gitops as a result — the direction this ADR's "nothing CNI-specific
lives in pkg/gitops" sentence above did not rule out; it is pkg/gitops
depending on pkg/cni, or CNI-specific content living in pkg/gitops,
that would be backwards. The practical effect is that every Application
pkg/cni builds now goes through this package's own guards (the RepoURL
credential check, the ServerSideApply/ignoreDifferences pairing) instead
of a caller having to notice an unstructured.Unstructured carries none of
them, and ichor init --gitops-dir writes pkg/cni/cilium's Application
straight into Skeleton.Apps["cni"] with no conversion step.
This did need one addition here: ApplicationSourceHelm gained a Values
field (spec.source.helm.values, inline Helm values as a YAML string —
argo-cd.readthedocs.io's Helm page, retrieved 2026-10-25) to carry what
Cilium's unstructured map previously put at the same path. ReleaseName
and ValueFiles were already there for a --gitops-dir-authored
ApplicationSource; Values is for a preset that already rendered its
own values document and has nothing to reference by path.
One pitfall from the task brief is explicitly not addressed here: Cilium
generating its Hubble certificates through a CronJob rather than Helm (so
sync doesn't see a diff every time). That is Cilium-preset content, i.e.
pkg/cni's manifests, not anything about how an Application or the repo
skeleton is shaped — pkg/gitops has nothing to add there beyond what's
already written down here for pkg/cni to read.
Consequences
pkg/gitopshas no dependency onargoproj/argo-cd. If a future Argo CD release fixes thegitops-engineresolution problem, re-evaluate — but the heavy transitive imports (go-git, cloud SDKs, Redis) would still be there, so the bar for switching is high.- The hand-rolled
Applicationtype covers the fields this phase needs. Adding a field Argo CD supports but this package doesn't yet cover (e.g.revisionHistoryLimit,info) is a small, additive change against the public schema — not a redesign. - Anyone embedding
metav1.TypeMetaandmetav1.ObjectMetatogether, anonymously, anywhere under this repository's module (not justpkg/gitops) will silently grow a bogus CRD on the nextmake buildunless they already know to checkconfig/crd/bases/for surprises. There is no lint rule for this; it is only written down here and inpkg/gitops/application.go's comment onApplication. ichor init --gitops-dir(ichor gitops exportis still separate work) decides which components go inSkeleton.Apps: today, just"cni", from whateverpkg/cni.Preset.ArgoCDApplicationreturned for the cluster's chosen preset. It callsgitops.New(viapkg/cni) andSkeleton.Writerather than reimplementing either.
Amendment: one root Application over apps/ and the cluster CRs
Hardware run against Argo CD v3.5.6 found two defects in the skeleton:
- The root Application had
path: clusters/<name>, soapps/*/application.yaml(the CNI hand-over Application) was never applied. The root is nowpath: .withdirectory.recurse: trueanddirectory.include: {apps/*/application.yaml,clusters/<name>/*.yaml}(ApplicationSourceDir.Include). One Application, no extra file; Ichor's CRs and the apps stay separate by directory, andbootstrap/root-app.yamland other clusters' directories are not synced. A second app-of-apps forapps/was rejected as one more file and one more sync to get wrong. ichor init --gitops-dirwrote the live typed objects (noapiVersion/kind, withmanagedFields,uid, ...), which syncs zero resources. Init now passes its CRs throughdeclareCRs, the same functionichor gitops exportuses, so both write identical declared-form files and the same names (talos-machine-<name>.yaml,talos-config-patch-<name>.yaml).