Skip to content

fix: generate required object-typed fields as non-pointer values - #447

Open
GenerikUser wants to merge 1 commit into
crossplane:mainfrom
GenerikUser:erik.miller--required-object-fields
Open

GenerikUser wants to merge 1 commit into
crossplane:mainfrom
GenerikUser:erik.miller--required-object-fields

Conversation

@GenerikUser

Copy link
Copy Markdown

Description of your changes

The problem. Generated Go models make every field optional: every nested object becomes a pointer with omitempty. That holds even when the CRD marks the object as required. For a managed resource this means that if a function never sets spec.forProvider, the key is dropped from the output. The API server then rejects the resource:

# Generated model, forProvider never set     # Provider's own Go type, forProvider never set
spec:                                        spec:
  managementPolicies: ["Observe"]              managementPolicies: ["Observe"]
                                               forProvider: {}
# -> spec.forProvider: Required value        # -> accepted

Provider types don't hit this problem. Upjet generates ForProvider <Kind>Parameters with tag json:"forProvider": a value struct with no omitempty, which marshals as {} when nobody sets it. We ran into this when we switched a function from importing a provider's Go types to these generated models.

An empty forProvider is legitimate and common in upjet providers. Their required parameters aren't schema-required inside forProvider. They are CEL rules such as !('*' in self.managementPolicies || 'Create' in ...) || has(self.forProvider.location) || (has(self.initProvider) && has(self.initProvider.location)). So forProvider: {} passes validation when:

  • the resource is observe-only, or
  • its required parameters are set through initProvider, or
  • it has no required parameters at all.

internal/schemas/generator/testdata/azure_linux_function_app.yaml is a real example: spec requires forProvider, and location, name and siteConfig are CEL rules.

The change. There is a new flag, off by default:

crossplane config set features.generateGoRequiredObjectFields true

When it's on, required properties with an object type are generated as non-pointer value structs, so they marshal as {}. Everything else keeps the current all-pointer convention.

The flag is opt-in because it breaks consumers that write Spec: &FooSpec{...} or nil-check those fields.

A required field stays a pointer when making it a value would break compilation or DeepCopy:

  • k8s meta types that live in the shared package,
  • anonymous merged allOf structs,
  • references that would make a struct contain itself by value.

Supporting changes:

  • Generated DeepCopyInto now deep-copies value-struct fields instead of shallow-copying them.
  • collectStructTypes resolves type A = B aliases.
  • Accessors stay pointer-shaped (GetX() *T, SetX(*T)), so chained getters keep compiling in both flag states.
  • With runtimeObjects on, the union json.RawMessage bytes are copied rather than aliased. This change also applies when the flag is off.

Testing: go test ./..., go test -tags compilegate ./internal/schemas/generator/... and ./nix.sh flake check all pass locally.

I have:

  • Read and followed Crossplane's contribution process.
  • Run ./nix.sh flake check to ensure this PR is ready for review.
  • Added or updated unit tests.
  • Linked a PR or a docs tracking issue to document this change. The flag is documented in the CLI's own config help (cmd/crossplane/config/help/config.md).
  • Added backport release-x.y labels to auto-backport this PR. New opt-in feature flag, so it shouldn't be backported.

Need help with this checklist? See the cheat sheet.

A required, object-typed CRD property (e.g. a managed resource's
spec.forProvider) generates as a pointer field with omitempty,
regardless of being marked required in the source schema.
goRemoveRequired unconditionally cleared every schema's required list
before oapi-codegen ran, so oapi-codegen's "optional -> pointer +
omitempty" rule applied uniformly.

A hand/controller-gen-written Kubernetes or crossplane-runtime type
declares a required nested object as a non-pointer value struct
instead. Its zero value marshals as {}, satisfying the CRD's
required-key admission check even when a caller never sets it. The
generated pointer form fails that check either way: omitted from the
JSON with omitempty, or a literal null without it.

goRemoveRequired now keeps required-ness for object-typed
(struct-shaped) properties only, so oapi-codegen generates those as
non-pointer values with no omitempty. Every other field (scalars,
arrays, map-shaped objects) keeps the existing all-pointer/all-optional
convention, since the rest of this generator's Go output assumes it.

isStructShapedProperty decides which properties qualify:

- Struct-shaped means "has named properties," whether or not the
  schema also allows additionalProperties: oapi-codegen v2 emits a
  struct (with an extra AdditionalProperties map field) once any named
  properties are present, not a bare map.
- A direct $ref, or an allOf with exactly one element that is itself a
  $ref (the shape Kubernetes' own OpenAPI uses for a required nested
  object, e.g. DeviceClass.spec in resource.k8s.io/v1), is resolved
  against components.schemas before judging its shape, since it has no
  inline properties of its own.
- A ref that resolves to one of the k8s API machinery types this
  generator moves into a separately generated shared package
  (metav1.LabelSelector and similar) stays a pointer even though it
  resolves to a struct: it would become a cross-package non-pointer
  value, and this generator's accessors and DeepCopy machinery only
  special-case a non-pointer field that's a locally declared struct.
- A $ref/allOf alongside sibling inline properties, or a multi-element
  allOf, stays a pointer too: oapi-codegen v2.8 merges those into an
  anonymous inline struct literal rather than a reference to a named
  local type, and an anonymous struct can't be given a DeepCopyInto
  method or be recognized by the accessors code. Only a lone $ref
  (direct, or the sole allOf member) with no sibling properties
  resolves to a named type and can safely become non-pointer.
- A required $ref that would make the generated struct contain itself
  by value is also kept a pointer. goRequiredValueReachability
  precomputes, for every named component schema, which other schemas
  it would reach by value if every eligible required field were kept
  non-pointer, following nested inline structs but not slices or maps
  (those already break value containment). filterRequiredObjectFields
  uses that to reject a $ref whose target is, or can reach back to,
  the schema the field is on directly or through another schema's own
  required fields (e.g. two schemas requiring each other) - Go rejects
  a struct that contains itself by value, however indirectly.

Two related fixes to writeFieldCopy's generated DeepCopyInto:

- It now calls DeepCopyInto on a non-pointer local-struct field
  instead of relying on the top-level shallow *out = *in, which would
  alias any pointers nested inside it between the original and the
  copy.
- It also copies the backing bytes of oapi-codegen's unexported
  `union json.RawMessage` field (its oneOf/anyOf plumbing) instead of
  aliasing them, since that type's MarshalJSON exposes the backing
  slice directly. This fix is independent of the flag below: it
  applies to the existing runtime.Object/DeepCopy generation whenever
  a oneOf/anyOf union is present, whether or not a required
  object-typed field is involved.

collectStructTypes (shared by writeFieldCopy and the accessors
generator) now also resolves a component-name type alias to the
struct it names. oapi-codegen emits
`type IoK8SApiResourceV1DeviceClassSpec = DeviceClassSpec` for a
schema whose derived name differs from its customized one, and a
$ref-resolved required field like DeviceClass.spec is typed by that
alias, not the struct name directly. Without resolving it, the field
wouldn't be recognized as a locally declared struct at all. This also
fixes the same shallow-copy gap for 26 existing optional pointer
fields in this repo's built-in OpenAPI testdata that are typed by such
an alias, independent of requiredObjectFields.

The generated GetX/SetX accessors for required object-typed fields
stay pointer-shaped (address on Get, dereference on Set), so chained
getters and SetX(GetX()) round-trips keep compiling regardless of
which fields happen to be required. The per-field getter/setter logic
is factored into writeFieldAccessors so writeStructAccessors stays
under golangci-lint's gocognit threshold with the new value-struct
branch.

Feature-flagged, off by default: making a required object-typed field
a non-pointer value is a breaking change for any consumer that
constructs it as a pointer (Spec: &BucketSpec{...} no longer
type-checks) or checks it for nil. It's gated behind a new
features.generateGoRequiredObjectFields config flag, following the
same WithGoModelAccessors/WithGoRuntimeObjects pattern already used
for generateGoModelAccessors and generateGoRuntimeObjects:

	crossplane config set features.generateGoRequiredObjectFields true

With the flag disabled (the default), no field's pointer/optional
shape changes. The union json.RawMessage byte copy and the alias
resolution for an ordinary optional pointer field are independent of
this flag and take effect whenever runtimeObjects is on; the
non-pointer alias case only arises when the flag is also on, since
that's what makes a field non-pointer in the first place.

Tested with new/updated unit tests (TestGenerateFromOpenAPIGoRequiredObjectFields,
TestGenerateFromOpenAPIGoRequiredObjectFieldsAlias, TestIsStructShapedProperty,
TestGoRemoveRequiredAllOfShapes, TestGoRemoveRequiredValueCycles,
TestWriteFieldCopy, TestAddAccessorsValueStructField, TestAllLanguagesGoOptions)
plus the existing and extended compile-gate suite
(go test -tags compilegate ./internal/schemas/generator/...), all passing.

The compile gate also marshals a zero XAccountScaffoldSpec in both flag
states: with the flag on, the unset required parameters field must
serialize as {}; with it off, it must be omitted, as before.

Signed-off-by: Erik Miller <erik.miller@gusto.com>
Signed-off-by: GenerikUser <generikuser@users.noreply.github.com>
@GenerikUser
GenerikUser requested review from a team, jcogilvie and tampakrap as code owners October 23, 2026 01:06
@GenerikUser
GenerikUser requested review from adamwg and removed request for a team October 23, 2026 01:06
@coderabbitai

coderabbitai Bot commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
📥 Commits

Reviewing files that changed from the base of the PR and between 2ca8fd0 and d59072b.

📒 Files selected for processing: 20
  • cmd/crossplane/composition/generate.go
  • cmd/crossplane/config/help/config.md
  • cmd/crossplane/config/set.go
  • cmd/crossplane/dependency/add.go
  • cmd/crossplane/dependency/cache.go
  • cmd/crossplane/function/generate.go
  • cmd/crossplane/project/build.go
  • cmd/crossplane/project/run.go
  • cmd/crossplane/render/op/cmd.go
  • cmd/crossplane/render/xr/cmd.go
  • internal/config/config.go
  • internal/schemas/generator/accessors.go
  • internal/schemas/generator/accessors_test.go
  • internal/schemas/generator/go.go
  • internal/schemas/generator/go_test.go
  • internal/schemas/generator/interface.go
  • internal/schemas/generator/interface_test.go
  • internal/schemas/generator/runtimeobject.go
  • internal/schemas/generator/runtimeobject_compilegate_test.go
  • internal/schemas/generator/runtimeobject_test.go

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

📝 Walkthrough

The change adds an opt-in configuration feature for generating required object properties as Go values. Schema-generation commands pass the setting to the Go generator, which also updates accessor and deep-copy generation for value fields.

Changes: Required Go Object Fields

Layer / File(s) Summary
Feature setting and command wiring
internal/config/config.go, cmd/crossplane/config/{set.go,help/config.md}, internal/schemas/generator/interface.go, internal/schemas/generator/interface_test.go, cmd/crossplane/{composition/generate.go,dependency/{add.go,cache.go},function/generate.go,project/{build.go,run.go},render/{op/cmd.go,xr/cmd.go}}
Adds the features.generateGoRequiredObjectFields setting and generator option. Schema-generation commands pass the configured value to the generator.
Required property filtering
internal/schemas/generator/go.go, internal/schemas/generator/go_test.go
When enabled, eligible required object properties generate as non-pointer values. Shared Kubernetes references and fields that would create by-value cycles remain pointers. Tests cover schema shapes, aliases, and cycles.
Value-field accessors and deep copies
internal/schemas/generator/{accessors.go,accessors_test.go,runtimeobject.go,runtimeobject_test.go,runtimeobject_compilegate_test.go}
Generated accessors support value struct fields. Deep-copy generation handles local structs, aliases, and json.RawMessage. Compile-gate tests exercise both feature settings.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant Config as Config.Features
  participant Commands as Schema-generation commands
  participant Options as generator.WithGoRequiredObjectFields
  participant Generator as Go schema generator
  participant Models as Generated Go models
  Config->>Commands: Provide GenerateGoRequiredObjectFields
  Commands->>Options: Pass configured feature value
  Options->>Generator: Set requiredObjectFields
  Generator->>Models: Generate required object fields as values when enabled
Loading

Suggested reviewers: adamwg, erikmiller-gusto

Merge Risk: ⚪ Minimal · up to d5907

This change adds an opt-in setting that generates required object fields as Go values. Default behavior is unchanged, and generated accessors and deep copies are covered by compile-gate tests. No merge-blocking risk was identified.

Pre-merge checks: Passed 6
Check name Status Explanation
Title check Passed The 64-character title clearly describes the main change and stays under the 72-character limit.
Description check Passed The description clearly explains the problem, opt-in feature flag, compatibility behavior, supporting changes, and test coverage.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Breaking Changes Passed The pull request does not match the stated failure conditions. The authoritative diff contains no changes under apis/**; under cmd/**, it only adds and wires the optional `features.generateGoRequi…
Feature Gate Requirement Passed The significant generated-model behavior change is gated. The diff adds Features.GenerateGoRequiredObjectFields with a false zero-value default, supports features.generateGoRequiredObjectFields in…
Full details: Breaking Changes

Explanation

The pull request does not match the stated failure conditions. The authoritative diff contains no changes under apis/**; under cmd/**, it only adds and wires the optional features.generateGoRequiredObjectFields configuration key. The config field and generator option are disabled by default, so existing behavior remains unchanged unless explicitly enabled. No public field or flag is removed or renamed, and no new required public field or flag is added.

Full details: Feature Gate Requirement

Explanation

The significant generated-model behavior change is gated. The diff adds Features.GenerateGoRequiredObjectFields with a false zero-value default, supports features.generateGoRequiredObjectFields in crossplane config set, and wires it through WithGoRequiredObjectFields into the Go generator. The generator preserves the existing all-optional behavior when the flag is disabled. The flag is documented in CLI config help, and all command-level generator.AllLanguages paths that use configuration pass it through.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@GenerikUser

Copy link
Copy Markdown
Author

NOTE this is a replacement of #387

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants