Skip to content

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 spec with as many fields populated as practical so the example doubles as documentation and catches schema drift.
  • When the type has a status subresource, populate representative status fields 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.

5. Scheme registration and gate wiring

When the new kind is a commit-status gate CRD with spec.promotionStrategyRef (same shape as ArgoCDCommitStatus, TimedCommitStatus, and the other built-in gates):

  1. Register the type on the promoter scheme (AddToScheme / SchemeBuilder). That is enough for field indexes, instance-id cache partitioning, and resource-count metrics — you do not maintain separate kind lists for those.
  2. Wire the reconciler in all three setup sites (copy an existing gate’s SetupWithManager block):
  3. cmd/main.go — production manager
  4. internal/controller/suite_test.go — main envtest suite
  5. internal/controller/test_manager_test.go (startPartitionedManager) — instance-id migration / partitioned-manager tests
    Omitting the last site is a common miss: migration tests will time out waiting for child CommitStatus objects that never get created.
  6. In the gate controller, watch PromotionStrategy and list your kind with client.MatchingFields{controller.PromotionStrategyRefField: ps.Name} (do not namespace-list and filter in memory). See Watching PromotionStrategy.

Skip this step for types that do not reference a PromotionStrategy or never use field selectors on the cache client.

6. Dashboard view bundle for commit-status gate kinds

In-tree gate managers are also exposed on the aggregated PromotionStrategyDetails resource. After the CRD is on the scheme, follow Dashboard view bundle (bundle field, buildBundle list, apiserver RBAC, and codegen).

Regenerate and verify

After Go type and marker changes:

  1. make build-installer — CRD bases, deepcopy, applyconfiguration, extension icon styles, and the dist/ install bundles (includes apiserver RBAC from config/apiserver/base/).
  2. make generate-apiserver and make generate-ui-types when api/view/v1alpha1 or embedded promoter types in the view bundle change (see UI TypeScript types).
  3. go mod tidy if module deps changed.
  4. make test-parallel — includes the strict unmarshal tests above.
  5. make lint-docs if you edited docs/.

CI’s Check Codegen job runs make build-installer, make generate-apiserver, and make generate-ui-types and fails on drift; see Continuous Integration.

API design reminders

  • Put validation on Go types with kubebuilder markers (// +kubebuilder:validation:…, CEL XValidation, and so on); do not hand-edit config/crd/bases/ except via generation.
  • Prefer // +k8s:immutable on fields that must not change after set (controller-gen v0.21+) when that field has no other XValidation rules. It emits the same self == oldSelf CEL rule as a hand-written immutability XValidation. If the field also needs custom CEL checks, use explicit XValidation for immutability too — do not mix +k8s: markers with XValidation on one field (controller-tools#1429; see Writing CEL Validation Rules). Example in-tree: PullRequest sourceBranch / targetBranch.
  • Use // +k8s:enum on a type Foo string plus const block only when every field typed Foo shares the same allowed values. Remove redundant field Enum markers only in that case. Do not put +k8s:enum on a type that is also used from status fields allowing "" or a subset of consts (controller-gen applies the type enum to all references; field Enum is not merged for extras like ""). Example in-tree: ContextMode on WebRequestCommitStatus. Plain string fields still use field Enum.
  • Express reconciler RBAC with // +kubebuilder:rbac on the controller that performs each API call; regenerate manifests rather than editing config/rbac/role.yaml by hand. CRDs and built-in types such as Secrets have no writable /finalizers API subresource; update metadata.finalizers via Update on the main resource. For least-privilege RBAC, grant */finalizers: update instead of update on the primary resource when the controller only changes finalizers.
  • For SCM-facing types and commit-status controllers, see Adding an SCM Provider and Developing a CommitStatus.
Topic Page
User-facing CR reference CRD Specs
CEL rules and marker mixing Writing CEL Validation Rules
Status subresource / SSA Maintaining resource status
Dashboard aggregation API Dashboard Aggregation API
UI types from the view API UI TypeScript types
CI codegen checks Continuous Integration
Contributor overview Contributing overview