Maintaining CRDs
Use this checklist whenever you add a new API type or make a breaking or structural change to an existing CRD (new fields, renames, validation changes). It keeps user docs, OpenAPI metadata, and tests aligned.
Checklist for each CRD kind
1. Example manifest in internal/controller/testdata/
Add or update internal/controller/testdata/<Kind>.yaml (PascalCase filename matching the kind, for example PromotionStrategy.yaml).
- Include a realistic
specwith as many fields populated as practical so the example doubles as documentation and catches schema drift. - When the type has a
statussubresource, populate representativestatusfields too (conditions, phase, nested state) so status shape changes break tests early. - Use valid values that pass kubebuilder validation markers on the Go types.
- Keep names, namespaces, and references consistent with how controllers and envtest suites create related objects.
2. Embed the example on the CRD Specs page
Add or update a ### <Kind> section in docs/crd-specs.md with a short narrative, then include the example:
### PromotionStrategy
…description…
```yaml
apiVersion: promoter.argoproj.io/v1alpha1
kind: PromotionStrategy
metadata:
name: example-promotion-strategy
spec:
gitRepositoryRef:
name: example-git-repo
activeCommitStatuses:
- key: argocd-app-health
proposedCommitStatuses:
- key: security-scan
environments:
- branch: environment/dev
- branch: environment/test
- branch: environment/prod
autoMerge: false
activeCommitStatuses:
- key: performance-test
proposedCommitStatuses:
- key: deployment-freeze
status:
observedGeneration: 123 # compare with metadata.generation to detect stale status
conditions:
# The Ready condition indicates that the resource has been successfully reconciled, when there is an error during
# reconciliation, the condition will be False with a reason of ReconciliationError. When we successfully reconcile the resource,
# the condition will be True with a reason of ReconciliationSuccess. The Ready condition is essentially a way to show reconciliation
# errors to the user. This condition exists on all resources that have reconciliation logic.
- type: Ready
lastTransitionTime: 2023-10-01T00:00:00Z
message: Reconciliation succeeded
reason: ReconciliationSuccess # ReconciliationSuccess, ReconciliationError, ChangeTransferPolicyNotReady, or PreviousEnvironmentCommitStatusNotReady
status: "True" # "True," "False," or "Unknown"
observedGeneration: 123
environments:
- branch: environment/dev
# The proposed and active fields are pulled directly from the status of the environment's ChangeTransferPolicy resource.
proposed:
dry:
author: "Author Name <author@example.com>"
body: "Body of the commit message (i.e. excluding the subject line)"
commitTime: 2023-10-01T00:00:00Z
repoURL: "https://git.example.com/org/repo.git"
sha: "abcdef1234567890abcdef1234567890abcdef12"
subject: "chore: Example commit subject line"
references:
# An array of reference commits that where used to create the dry commit. This is used generally via CI systems to add tracing information to the commit.
# For example, in a code/deployment repo setup this could contain the commit from the code repo that triggered the deployment commit in the deployment repo.
- commit:
author: '"Zach Aller" <code@example.com>'
body: |-
Commit message of the code commit
Signed-off-by: Author Name <author@example.com>
date: '2025-07-19T17:50:18Z'
repoURL: https://github.com/org/repo
sha: 9d5ccef278218dea4caa903bb6abb9ed974a1d90
subject: This change fixes a bug in the code
hydrated:
# The hydrated field contains the same fields as proposed.dry, but the contents correspond to a hydrated commit
# instead of a dry commit.
note:
# Dry SHA from the hydrator git note on the proposed hydrated commit (only drySha is populated today)
drySha: "abcdef1234567890abcdef1234567890abcdef12"
commitStatuses:
- key: example-key
phase: pending # pending, success, or failure
url: https://example.com/checks/example-key
description: Waiting for check
active:
# The active field contains the same fields as proposed.
pullRequest:
id: "848"
state: open # open, closed, or merged; empty when externallyMergedOrClosed is true
url: https://github.com/org/repo/pull/848
prCreationTime: 2023-10-01T00:00:00Z
history:
# The history field contains a snapshot of each promotion that has occurred in the environment. The most recent promotion
# is at the front of the list. The fields here are similar to those in proposed and active top level fields. They only differ in
# that the proposed field does not contain a dry field, because in the context of history proposed becomes active when promoted.
- active:
# same as top level active
proposed:
# same as top level proposed but without dry field because in the context of history proposed.dry becomes active when promoted.
pullRequest:
# The pullRequest field contains information about the pull request that was merged to create this history item.
id: '848'
prCreationTime: '2025-08-04T19:50:15Z'
url: https://github.com/org/repo/pull/848
lastHealthyDryShas:
- sha: "abcdef1234567890abcdef1234567890abcdef12"
time: 2023-10-01T00:00:00Z
- branch: environment/test
# same fields as dev
- branch: environment/prod
# same fields as dev
```
MkDocs pulls the file in at build time via markdown_include (see mkdocs.yml). The heading text defines the anchor used by externalDocs (Material slugifies to lowercase, for example ### PromotionStrategy → #promotionstrategy).
3. externalDocs on the root API type
On the //+kubebuilder:object:root=true struct in api/v1alpha1/<kind>_types.go, add a marker pointing at the stable docs URL for that section:
// +kubebuilder:externalDocs:url="https://gitops-promoter.readthedocs.io/en/stable/crd-specs/#promotionstrategy",description="CRD reference (examples and behavior)"
Regenerate CRDs with make manifests (or make build-installer). The URL must be a valid absolute URL; the fragment must match the CRD Specs heading anchor.
On Kubernetes 1.36+, clusters and kubectl can surface this link in kubectl explain as an EXTERNAL DOCS block (see kubernetes/kubernetes#136988). Older clusters still store the metadata in the CRD OpenAPI schema for other tooling.
4. Strict unmarshal test in the controller suite
In internal/controller/<kind>_controller_test.go (or the controller that owns the type), add:
//go:embed testdata/<Kind>.yaml
var test<Kind>YAML string
var _ = Describe("<Kind> Controller", func() {
Context("When unmarshalling the test data", func() {
It("should unmarshal the <Kind> resource", func() {
err := unmarshalYamlStrict(test<Kind>YAML, &promoterv1alpha1.<Kind>{})
Expect(err).ToNot(HaveOccurred())
})
})
// …
})
unmarshalYamlStrict (in internal/controller/suite_test.go) unmarshals YAML and decodes with DisallowUnknownFields, so typos, removed fields, or wrong types in the example fail CI immediately.
Follow an existing controller test as a template, for example promotionstrategy_controller_test.go or commitstatus_controller_test.go.
If the type is reconciled and has status SSA behavior, also follow Maintaining resource status for field owners, observedGeneration, and status apply tests.
Regenerate and verify
After Go type and marker changes:
make build-installer— CRD bases, deepcopy, applyconfiguration, extension icon styles,dist/install.yaml.go mod tidyif module deps changed.make test-parallel— includes the strict unmarshal tests above.make lint-docsif you editeddocs/.
CI’s Check Codegen job runs make build-installer and fails on drift; see Continuous Integration.
API design reminders
- Put validation on Go types with kubebuilder markers (
// +kubebuilder:validation:…, CELXValidation, and so on); do not hand-editconfig/crd/bases/except via generation. - Prefer
// +k8s:immutableon fields that must not change after set (controller-gen v0.21+); it emits the sameself == oldSelfrule as hand-written immutabilityXValidation(seePullRequestsourceBranch/targetBranch). - Use
// +k8s:enumon atype Foo stringplusconstblock only when every field typedFooshares the same allowed values. Remove redundant fieldEnummarkers only in that case. Do not put+k8s:enumon a type that is also used from status fields allowing""or a subset of consts (controller-gen applies the type enum to all references; fieldEnumis not merged for extras like""). Example in-tree:ContextModeonWebRequestCommitStatus. Plainstringfields still use fieldEnum. - Express reconciler RBAC with
// +kubebuilder:rbacon controllers; regenerate manifests rather than editingconfig/rbac/role.yamlby hand. - For SCM-facing types and commit-status controllers, see Adding an SCM Provider and Commit status development best practices.
Related docs
| Topic | Page |
|---|---|
| User-facing CR reference | CRD Specs |
| Status subresource / SSA | Maintaining resource status |
| CI codegen checks | Continuous Integration |
| Contributor overview | Contributing overview |