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=true is added to syncPolicy.syncOptions whenever Options.ServerSideApply is set — for the large CRD groups case (e.g. Ichor's own CRDs, or CNI CRDs).
  • ignoreDifferences + managedFieldsManagers: any non-empty Options.IgnoreDifferences is written through as-is, keyed by whatever field manager owns the field (the example from the design notes — widget-operator owning a CRD's spec.versions — is exactly ResourceIgnoreDifference{..., ManagedFieldsManagers: []string{"widget-operator"}}). (ADR 0055 narrows this: the pairing applies to entries that name fields, not kind-only ones.) New always pairs this with RespectIgnoreDifferences=true in syncOptions, because ignoreDifferences alone 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.SyncWave becomes the argocd.argoproj.io/sync-wave annotation. The exported constants SyncWaveArgoCD < SyncWaveCNI < SyncWaveIchor < SyncWaveIchorCRs < SyncWaveStorage < SyncWaveOther are the design's documented hand-over order ("Argo CD → CNI → Ichor (including CRDs) → Ichor CRs → storage after TalosStorage is 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: New parses every source's RepoURL and 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 an Application built without going through New is 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:

  1. Nothing to put them in. Skeleton and Options have no field typed to hold a credential or the secrets bundle — there is no parameter a caller could thread one through even by mistake.
  2. The URL guard, twice. New checks every source's RepoURL before building an Application; Write checks again (checkApplication, reusing the same logic) on RootApp and every entry of Apps, in case one reached Skeleton some other way. Both paths are tested (TestNew_RejectsCredentialsInRepoURL, TestSkeleton_Write_RefusesCredentialedRepoURL).
  3. A Secret, whatever its source, is refused. WriteObject — the single function every file in the skeleton goes through — type-switches on *corev1.Secret and 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/gitops has no dependency on argoproj/argo-cd. If a future Argo CD release fixes the gitops-engine resolution 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 Application type 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.TypeMeta and metav1.ObjectMeta together, anonymously, anywhere under this repository's module (not just pkg/gitops) will silently grow a bogus CRD on the next make build unless they already know to check config/crd/bases/ for surprises. There is no lint rule for this; it is only written down here and in pkg/gitops/application.go's comment on Application.
  • ichor init --gitops-dir (ichor gitops export is still separate work) decides which components go in Skeleton.Apps: today, just "cni", from whatever pkg/cni.Preset.ArgoCDApplication returned for the cluster's chosen preset. It calls gitops.New (via pkg/cni) and Skeleton.Write rather 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>, so apps/*/application.yaml (the CNI hand-over Application) was never applied. The root is now path: . with directory.recurse: true and directory.include: {apps/*/application.yaml,clusters/<name>/*.yaml} (ApplicationSourceDir.Include). One Application, no extra file; Ichor's CRs and the apps stay separate by directory, and bootstrap/root-app.yaml and other clusters' directories are not synced. A second app-of-apps for apps/ was rejected as one more file and one more sync to get wrong.
  • ichor init --gitops-dir wrote the live typed objects (no apiVersion/kind, with managedFields, uid, ...), which syncs zero resources. Init now passes its CRs through declareCRs, the same function ichor gitops export uses, so both write identical declared-form files and the same names (talos-machine-<name>.yaml, talos-config-patch-<name>.yaml).

Search the docs

Search pages and headings