From 56a98a217206f94e4736898cf8a3d0c585e5f313 Mon Sep 17 00:00:00 2001 From: Erik Miller Date: Fri, 26 Jun 2026 14:21:51 -0700 Subject: [PATCH 1/7] generator: make generated Go types implement runtime.Object (#143) Add opt-in generation of controller-gen-style DeepCopy methods, runtime.Object and schema.ObjectKind methods on root types, and a per-package groupversion_info.go (GroupVersion/SchemeBuilder/AddToScheme), so generated Go models can be registered in a runtime.Scheme and used with k8s ecosystem libraries. This lets users register types instead of setting apiVersion/kind by hand. Gated behind a new features.generateGoRuntimeObjects config flag (off by default), threaded from config through the project build/run and function generate commands. When enabled, the generated models module additionally depends on k8s.io/apimachinery (v0.33.0, matching the function go template so a generated function that consumes the models still builds). Root types are detected structurally (APIVersion+Kind+Metadata fields). The runtime.Object pass runs after the k8s type-name fixups so it sees final type names, and copies known stdlib-backed alias types (Time, MicroTime, FieldsV1, RawExtension) by value rather than calling DeepCopyInto. Verified by compile gates that build the generated module (CRD and OpenAPI paths) plus a behavioral test asserting deep-copy independence for scalar, slice-of-struct and map fields and AddToScheme GVK round-tripping. Signed-off-by: Erik Miller --- cmd/crossplane/config/help/config.md | 8 + cmd/crossplane/config/set.go | 5 +- cmd/crossplane/function/generate.go | 3 +- cmd/crossplane/function/generate_test.go | 3 +- cmd/crossplane/main.go | 2 + cmd/crossplane/project/build.go | 5 +- cmd/crossplane/project/run.go | 5 +- internal/config/config.go | 5 + internal/schemas/generator/go.go | 252 +++++++++- internal/schemas/generator/interface.go | 24 +- internal/schemas/generator/runtimeobject.go | 468 ++++++++++++++++++ .../runtimeobject_integration_test.go | 283 +++++++++++ .../schemas/generator/runtimeobject_test.go | 143 ++++++ 13 files changed, 1179 insertions(+), 27 deletions(-) create mode 100644 internal/schemas/generator/runtimeobject.go create mode 100644 internal/schemas/generator/runtimeobject_integration_test.go create mode 100644 internal/schemas/generator/runtimeobject_test.go diff --git a/cmd/crossplane/config/help/config.md b/cmd/crossplane/config/help/config.md index 8803afb0..485a668c 100644 --- a/cmd/crossplane/config/help/config.md +++ b/cmd/crossplane/config/help/config.md @@ -18,3 +18,11 @@ Enable alpha commands: ```shell crossplane config set features.enableAlpha true ``` + +Generate runtime.Object methods and per-package AddToScheme helpers on generated +Go models (off by default), so generated types can be registered in a +runtime.Scheme: + +```shell +crossplane config set features.generateGoRuntimeObjects true +``` diff --git a/cmd/crossplane/config/set.go b/cmd/crossplane/config/set.go index b85fc5ce..948ec78f 100644 --- a/cmd/crossplane/config/set.go +++ b/cmd/crossplane/config/set.go @@ -42,8 +42,9 @@ type boolSetter func(c *config.Config, v bool) // //nolint:gochecknoglobals // This is a constant. var boolKeys = map[string]boolSetter{ - "features.enableAlpha": func(c *config.Config, v bool) { c.Features.EnableAlpha = v }, - "features.disableBeta": func(c *config.Config, v bool) { c.Features.DisableBeta = v }, + "features.enableAlpha": func(c *config.Config, v bool) { c.Features.EnableAlpha = v }, + "features.disableBeta": func(c *config.Config, v bool) { c.Features.DisableBeta = v }, + "features.generateGoRuntimeObjects": func(c *config.Config, v bool) { c.Features.GenerateGoRuntimeObjects = v }, } func (c *setCmd) AfterApply() error { diff --git a/cmd/crossplane/function/generate.go b/cmd/crossplane/function/generate.go index 52c57734..c059ceaa 100644 --- a/cmd/crossplane/function/generate.go +++ b/cmd/crossplane/function/generate.go @@ -39,6 +39,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" v1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/internal/config" "github.com/crossplane/cli/v2/internal/filesystem" "github.com/crossplane/cli/v2/internal/kcl" "github.com/crossplane/cli/v2/internal/project/projectfile" @@ -144,7 +145,7 @@ func functionSchemaLanguage(functionLang string) string { } // Run generates a function scaffold. -func (c *generateCmd) Run(sp terminal.SpinnerPrinter) error { +func (c *generateCmd) Run(sp terminal.SpinnerPrinter, cfg *config.Config) error { if err := c.validatePaths(); err != nil { return err } diff --git a/cmd/crossplane/function/generate_test.go b/cmd/crossplane/function/generate_test.go index d23dade4..e7da277b 100644 --- a/cmd/crossplane/function/generate_test.go +++ b/cmd/crossplane/function/generate_test.go @@ -30,6 +30,7 @@ import ( apiextv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" v1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/internal/config" "github.com/crossplane/cli/v2/internal/terminal" ) @@ -324,7 +325,7 @@ func TestRunErrors(t *testing.T) { case "afterApply": err = c.AfterApply() case "run": - err = c.Run(terminal.NewSpinnerPrinter(io.Discard, false)) + err = c.Run(terminal.NewSpinnerPrinter(io.Discard, false), &config.Config{}) } if err == nil { t.Fatalf("expected error containing %q, got nil", tc.wantErrSubstring) diff --git a/cmd/crossplane/main.go b/cmd/crossplane/main.go index ce415f1b..1184cdec 100644 --- a/cmd/crossplane/main.go +++ b/cmd/crossplane/main.go @@ -126,6 +126,8 @@ func main() { // at runtime. kong.BindTo(logger, (*logging.Logger)(nil)), kong.BindTo(configcmd.ConfigPath(cfgPath), (*configcmd.ConfigPath)(nil)), + // Bind the loaded config so commands can read feature flags at runtime. + kong.Bind(cfg), kong.Help(helpPrinter), kong.UsageOnError()) diff --git a/cmd/crossplane/project/build.go b/cmd/crossplane/project/build.go index 825283d6..4c0ba85e 100644 --- a/cmd/crossplane/project/build.go +++ b/cmd/crossplane/project/build.go @@ -31,6 +31,7 @@ import ( devv1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1" "github.com/crossplane/cli/v2/internal/async" + "github.com/crossplane/cli/v2/internal/config" "github.com/crossplane/cli/v2/internal/dependency" "github.com/crossplane/cli/v2/internal/project" "github.com/crossplane/cli/v2/internal/project/functions" @@ -83,7 +84,7 @@ func (c *buildCmd) AfterApply() error { } // Run executes the build command. -func (c *buildCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter) error { +func (c *buildCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config) error { ctx := context.Background() if c.Repository != "" { @@ -97,7 +98,7 @@ func (c *buildCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter) error concurrency := max(1, c.MaxConcurrency) schemasFS := afero.NewBasePathFs(c.projFS, c.proj.Spec.Paths.Schemas) - generators := generator.Filter(generator.AllLanguages(), c.proj.Spec.Schemas.GetLanguages()) + generators := generator.Filter(generator.AllLanguages(generator.WithGoRuntimeObjects(cfg.Features.GenerateGoRuntimeObjects)), c.proj.Spec.Schemas.GetLanguages()) schemaRunner := runner.NewRealSchemaRunner(runner.WithImageConfig(c.proj.Spec.ImageConfigs)) schemaMgr := manager.New(schemasFS, generators, schemaRunner) cacheDir := c.CacheDir diff --git a/cmd/crossplane/project/run.go b/cmd/crossplane/project/run.go index 8395e137..aa88ce3a 100644 --- a/cmd/crossplane/project/run.go +++ b/cmd/crossplane/project/run.go @@ -42,6 +42,7 @@ import ( devv1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1" "github.com/crossplane/cli/v2/cmd/crossplane/render" "github.com/crossplane/cli/v2/internal/async" + "github.com/crossplane/cli/v2/internal/config" "github.com/crossplane/cli/v2/internal/dependency" "github.com/crossplane/cli/v2/internal/project" "github.com/crossplane/cli/v2/internal/project/controlplane" @@ -132,7 +133,7 @@ func (c *runCmd) AfterApply() error { } // Run executes the run command. -func (c *runCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter) error { //nolint:gocyclo // Main command orchestration. +func (c *runCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config) error { //nolint:gocyclo // Main command orchestration. ctx := context.Background() if c.Repository != "" { @@ -150,7 +151,7 @@ func (c *runCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter) error { concurrency := max(1, c.MaxConcurrency) schemasFS := afero.NewBasePathFs(c.projFS, c.proj.Spec.Paths.Schemas) - generators := generator.Filter(generator.AllLanguages(), c.proj.Spec.Schemas.GetLanguages()) + generators := generator.Filter(generator.AllLanguages(generator.WithGoRuntimeObjects(cfg.Features.GenerateGoRuntimeObjects)), c.proj.Spec.Schemas.GetLanguages()) schemaRunner := runner.NewRealSchemaRunner(runner.WithImageConfig(c.proj.Spec.ImageConfigs)) schemaMgr := manager.New(schemasFS, generators, schemaRunner) cacheDir := c.CacheDir diff --git a/internal/config/config.go b/internal/config/config.go index a3a5b238..3d86ffbf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,6 +42,11 @@ type Config struct { type Features struct { EnableAlpha bool `json:"enableAlpha,omitempty"` DisableBeta bool `json:"disableBeta,omitempty"` + + // GenerateGoRuntimeObjects enables generation of runtime.Object methods and + // per-package AddToScheme helpers on generated Go models. Disabled by + // default; opt in to register generated types with a runtime.Scheme. + GenerateGoRuntimeObjects bool `json:"generateGoRuntimeObjects,omitempty"` } // Load reads a Config from path. A missing file is not an error; the zero diff --git a/internal/schemas/generator/go.go b/internal/schemas/generator/go.go index 129f76ea..52c0686f 100644 --- a/internal/schemas/generator/go.go +++ b/internal/schemas/generator/go.go @@ -20,6 +20,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "go/ast" "go/format" "go/parser" @@ -106,6 +107,133 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ` +// goModContentsRuntimeObjects is the go.mod variant written when the +// generateGoRuntimeObjects feature is enabled. It additionally requires +// k8s.io/apimachinery (used by the generated runtime.Object and AddToScheme +// code) and its transitive dependencies. +const goModContentsRuntimeObjects = `module dev.crossplane.io/models + +go 1.24.0 + +require ( + github.com/oapi-codegen/runtime v1.1.0 + k8s.io/apimachinery v0.33.0 +) + +require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/x448/float16 v0.8.4 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) +` + +// goSumContentsRuntimeObjects is the go.sum variant for the +// generateGoRuntimeObjects feature, covering k8s.io/apimachinery and its +// transitive dependencies. +const goSumContentsRuntimeObjects = `github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/oapi-codegen/runtime v1.1.0 h1:rJpoNUawn5XTvekgfkvSZr0RqEnoYpFkyvrzfWeFKWM= +github.com/oapi-codegen/runtime v1.1.0/go.mod h1:BeSfBkWWWnAnGdyS+S/GnlbmHKzf8/hwkvelJZDeKA8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/apimachinery v0.33.0 h1:1a6kHrJxb2hs4t8EE5wuR/WxKDwGN1FKH3JvDtA0CIQ= +k8s.io/apimachinery v0.33.0/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +` + // goImportsTemplate replaces the default import template for oapi-codegen, // since it contains many imports we don't use and will thus result in code that // doesn't compile. @@ -138,14 +266,18 @@ var ( ) ` -type goGenerator struct{} +// goGenerator generates Go models. runtimeObjects controls whether DeepCopy / +// runtime.Object methods and per-package AddToScheme helpers are emitted. +type goGenerator struct { + runtimeObjects bool +} func (goGenerator) Language() string { return devv1alpha1.SchemaLanguageGo } // GenerateFromCRD generates Go schemas for the CRDs in the given filesystem. -func (goGenerator) GenerateFromCRD(_ context.Context, fromFS afero.Fs, _ runner.SchemaRunner) (afero.Fs, error) { +func (g goGenerator) GenerateFromCRD(_ context.Context, fromFS afero.Fs, _ runner.SchemaRunner) (afero.Fs, error) { openAPIs, err := goCollectOpenAPIs(fromFS) if err != nil { return nil, err @@ -157,7 +289,7 @@ func (goGenerator) GenerateFromCRD(_ context.Context, fromFS afero.Fs, _ runner. } // Initialize the schema filesystem - schemaFS, err := initializeSchemaFS() + schemaFS, err := initializeSchemaFS(g.runtimeObjects) if err != nil { return nil, err } @@ -240,6 +372,12 @@ func (goGenerator) GenerateFromCRD(_ context.Context, fromFS afero.Fs, _ runner. return nil, err } + // Add runtime.Object/DeepCopy code last, so it sees final type names. + code, err = applyRuntimeObjects(code, g.runtimeObjects) + if err != nil { + return nil, err + } + if err := writeGoCode(schemaFS, group, kind, version, code); err != nil { return nil, err } @@ -260,6 +398,11 @@ func (goGenerator) GenerateFromCRD(_ context.Context, fromFS afero.Fs, _ runner. return nil, err } + code, err = applyRuntimeObjects(code, g.runtimeObjects) + if err != nil { + return nil, err + } + if err := writeGoCode(schemaFS, oapi.crd.Spec.Group, oapi.crd.Spec.Names.Kind, oapi.version, code); err != nil { return nil, err } @@ -502,6 +645,62 @@ func writeGoCode(schemaFS afero.Fs, group, kind, version, code string) error { } _ = f.Close() + // When the generated code registers types with a scheme (only when the + // runtime.Object feature is on), emit a groupversion_info.go for the package + // defining GroupVersion/SchemeBuilder/AddToScheme. Written once per dir. + if strings.Contains(code, "SchemeBuilder.Register(") { + if err := writeGroupVersionInfo(schemaFS, dir, group, version); err != nil { + return err + } + } + + return nil +} + +// writeGroupVersionInfo writes a groupversion_info.go into dir defining the +// package's GroupVersion, SchemeBuilder and AddToScheme. It is a no-op if the +// file already exists, so a package with multiple kinds gets a single copy. +func writeGroupVersionInfo(schemaFS afero.Fs, dir, group, version string) error { + path := filepath.Join(dir, "groupversion_info.go") + if exists, err := afero.Exists(schemaFS, path); err != nil { + return errors.Wrap(err, "failed to stat groupversion_info.go") + } else if exists { + return nil + } + + code := fmt.Sprintf(`// Code generated by github.com/crossplane/cli/v2 DO NOT EDIT. +package %s + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// GroupVersion is the API group and version for the types in this package. +var GroupVersion = schema.GroupVersion{Group: %q, Version: %q} + +// SchemeBuilder collects the functions that register this package's types with +// a runtime.Scheme. Each type registers itself via an init function. +var SchemeBuilder = &runtime.SchemeBuilder{} + +// AddToScheme registers this package's types with the given runtime.Scheme. +var AddToScheme = SchemeBuilder.AddToScheme +`, version, group, version) + + formatted, err := format.Source([]byte(code)) + if err != nil { + return errors.Wrap(err, "failed to format groupversion_info.go") + } + + f, err := schemaFS.Create(path) + if err != nil { + return errors.Wrap(err, "failed to create groupversion_info.go") + } + if _, err := f.WriteString(string(formatted)); err != nil { + return errors.Wrap(err, "failed to write groupversion_info.go") + } + _ = f.Close() + return nil } @@ -1088,7 +1287,7 @@ func fixK8sTypeNames(code string) (string, error) { } // GenerateFromOpenAPI generates Go schemas for the OpenAPI docs in the given filesystem. -func (goGenerator) GenerateFromOpenAPI(_ context.Context, fromFS afero.Fs, _ runner.SchemaRunner) (afero.Fs, error) { +func (g goGenerator) GenerateFromOpenAPI(_ context.Context, fromFS afero.Fs, _ runner.SchemaRunner) (afero.Fs, error) { // Walk through filesystem to collect OpenAPI specs openAPISpecs, err := collectOpenAPISpecs(fromFS) if err != nil { @@ -1101,18 +1300,18 @@ func (goGenerator) GenerateFromOpenAPI(_ context.Context, fromFS afero.Fs, _ run } // Initialize the schema filesystem - schemaFS, err := initializeSchemaFS() + schemaFS, err := initializeSchemaFS(g.runtimeObjects) if err != nil { return nil, err } // Generate K8s shared schemas - if err := generateK8sSharedSchemas(openAPISpecs, schemaFS); err != nil { + if err := generateK8sSharedSchemas(openAPISpecs, schemaFS, g.runtimeObjects); err != nil { return nil, err } // Generate models for the rest - if err := generateModelsWithGVK(openAPISpecs, schemaFS); err != nil { + if err := generateModelsWithGVK(openAPISpecs, schemaFS, g.runtimeObjects); err != nil { return nil, err } @@ -1162,18 +1361,25 @@ func collectOpenAPISpecs(fromFS afero.Fs) ([]*spec3.OpenAPI, error) { return openAPISpecs, err } -// initializeSchemaFS creates and initializes the schema filesystem with go.mod and go.sum. -func initializeSchemaFS() (afero.Fs, error) { +// initializeSchemaFS creates and initializes the schema filesystem with go.mod +// and go.sum. When runtimeObjects is set, it writes the variant that depends on +// k8s.io/apimachinery, which the generated runtime.Object code requires. +func initializeSchemaFS(runtimeObjects bool) (afero.Fs, error) { schemaFS := afero.NewMemMapFs() if err := schemaFS.Mkdir("models", 0o755); err != nil { return nil, errors.Wrap(err, "failed to create models directory") } + modContents, sumContents := goModContents, goSumContents + if runtimeObjects { + modContents, sumContents = goModContentsRuntimeObjects, goSumContentsRuntimeObjects + } + modf, err := schemaFS.Create("models/go.mod") if err != nil { return nil, errors.Wrap(err, "failed to create go.mod") } - if _, err := modf.WriteString(goModContents); err != nil { + if _, err := modf.WriteString(modContents); err != nil { return nil, errors.Wrap(err, "failed to write go.mod") } @@ -1181,7 +1387,7 @@ func initializeSchemaFS() (afero.Fs, error) { if err != nil { return nil, errors.Wrap(err, "failed to create go.sum") } - if _, err := sumf.WriteString(goSumContents); err != nil { + if _, err := sumf.WriteString(sumContents); err != nil { return nil, errors.Wrap(err, "failed to write go.sum") } @@ -1189,7 +1395,7 @@ func initializeSchemaFS() (afero.Fs, error) { } // generateK8sSharedSchemas extracts and generates shared K8s schemas. -func generateK8sSharedSchemas(openAPISpecs []*spec3.OpenAPI, schemaFS afero.Fs) error { +func generateK8sSharedSchemas(openAPISpecs []*spec3.OpenAPI, schemaFS afero.Fs, runtimeObjects bool) error { k8sSchemasByPackage := make(map[string]map[string]*spec.Schema) // Collect all K8s schemas from all OpenAPI specs, grouped by package @@ -1209,7 +1415,7 @@ func generateK8sSharedSchemas(openAPISpecs []*spec3.OpenAPI, schemaFS afero.Fs) continue } - if err := generateK8sPackageCode(pkg, schemas, schemaFS); err != nil { + if err := generateK8sPackageCode(pkg, schemas, schemaFS, runtimeObjects); err != nil { return err } } @@ -1218,7 +1424,7 @@ func generateK8sSharedSchemas(openAPISpecs []*spec3.OpenAPI, schemaFS afero.Fs) } // generateK8sPackageCode generates code for a single K8s package. -func generateK8sPackageCode(pkg string, schemas map[string]*spec.Schema, schemaFS afero.Fs) error { +func generateK8sPackageCode(pkg string, schemas map[string]*spec.Schema, schemaFS afero.Fs, runtimeObjects bool) error { // Create a spec for this package pkgSpec := &spec3.OpenAPI{ Version: "3.0.0", @@ -1253,6 +1459,12 @@ func generateK8sPackageCode(pkg string, schemas map[string]*spec.Schema, schemaF return errors.Wrap(err, "failed to remove self imports") } + // Add runtime.Object/DeepCopy code last, so it sees final type names. + code, err = applyRuntimeObjects(code, runtimeObjects) + if err != nil { + return err + } + return writeGoCode(schemaFS, group, kind, version, code) } @@ -1277,12 +1489,12 @@ func getK8sPackageInfo(pkg string) (group, kind, version string) { } // generateModelsWithGVK generates models for schemas with GVK information. -func generateModelsWithGVK(openAPISpecs []*spec3.OpenAPI, schemaFS afero.Fs) error { +func generateModelsWithGVK(openAPISpecs []*spec3.OpenAPI, schemaFS afero.Fs, runtimeObjects bool) error { for _, openAPISpec := range openAPISpecs { gvkGroups := groupSchemasByGVK(openAPISpec) for gvkKey, schemas := range gvkGroups { - if err := generateGVKGroupCode(gvkKey, schemas, openAPISpec, schemaFS); err != nil { + if err := generateGVKGroupCode(gvkKey, schemas, openAPISpec, schemaFS, runtimeObjects); err != nil { return err } } @@ -1345,7 +1557,7 @@ func extractGVKKey(schema *spec.Schema) string { } // generateGVKGroupCode generates code for a GVK group. -func generateGVKGroupCode(gvkKey string, schemas map[string]*spec.Schema, openAPISpec *spec3.OpenAPI, schemaFS afero.Fs) error { +func generateGVKGroupCode(gvkKey string, schemas map[string]*spec.Schema, openAPISpec *spec3.OpenAPI, schemaFS afero.Fs, runtimeObjects bool) error { parts := strings.Split(gvkKey, "/") group, version := parts[0], parts[1] @@ -1386,5 +1598,11 @@ func generateGVKGroupCode(gvkKey string, schemas map[string]*spec.Schema, openAP return err } + // Add runtime.Object/DeepCopy code last, so it sees final type names. + code, err = applyRuntimeObjects(code, runtimeObjects) + if err != nil { + return err + } + return writeGoCode(schemaFS, group, kind, version, code) } diff --git a/internal/schemas/generator/interface.go b/internal/schemas/generator/interface.go index d41300b9..9cf7bc46 100644 --- a/internal/schemas/generator/interface.go +++ b/internal/schemas/generator/interface.go @@ -34,12 +34,32 @@ type Interface interface { GenerateFromOpenAPI(ctx context.Context, fs afero.Fs, runner runner.SchemaRunner) (afero.Fs, error) } +// options holds configurable behavior shared across generators. +type options struct { + goRuntimeObjects bool +} + +// Option configures the generators returned by AllLanguages. +type Option func(*options) + +// WithGoRuntimeObjects enables generation of runtime.Object methods (DeepCopy, +// GetObjectKind, DeepCopyObject) and per-package AddToScheme helpers on the +// generated Go models. Disabled by default; gated behind the +// features.generateGoRuntimeObjects config flag. +func WithGoRuntimeObjects(enabled bool) Option { + return func(o *options) { o.goRuntimeObjects = enabled } +} + // AllLanguages returns generators for all supported languages. The set of // supported language identifiers is defined by // devv1alpha1.SupportedSchemaLanguages. -func AllLanguages() []Interface { +func AllLanguages(opts ...Option) []Interface { + o := &options{} + for _, opt := range opts { + opt(o) + } return []Interface{ - &goGenerator{}, + &goGenerator{runtimeObjects: o.goRuntimeObjects}, &jsonGenerator{}, &kclGenerator{}, &pythonGenerator{}, diff --git a/internal/schemas/generator/runtimeobject.go b/internal/schemas/generator/runtimeobject.go new file mode 100644 index 00000000..b679e757 --- /dev/null +++ b/internal/schemas/generator/runtimeobject.go @@ -0,0 +1,468 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package generator + +import ( + "fmt" + "go/ast" + "go/format" + "go/parser" + "go/token" + "sort" + "strings" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// Generated root types reference k8s.io/apimachinery. The runtime package is +// imported under the k8sruntime alias to avoid colliding with the +// github.com/oapi-codegen/runtime import the model files already carry. +const ( + roRuntimeAlias = "k8sruntime" + roRuntimeImport = "k8s.io/apimachinery/pkg/runtime" + roSchemaImport = "k8s.io/apimachinery/pkg/runtime/schema" +) + +// roScalarSelectorTypes are k8s types referenced via a package selector that are +// aliases to non-struct types (time.Time, maps) and therefore have no +// DeepCopyInto method; they must be copied by value, not deep-copied. +var roScalarSelectorTypes = map[string]bool{ //nolint:gochecknoglobals // Lookup table. + "Time": true, // metav1.Time / MicroTime alias time.Time + "MicroTime": true, + "FieldsV1": true, // alias for map[string]interface{} + "RawExtension": true, // alias for a JSON-ish type, no DeepCopyInto +} + +// applyRuntimeObjects runs addRuntimeObjects when enabled, discarding the +// root-types flag. It is called by the generation loops after all other Go +// post-processing so it operates on the final type names. +func applyRuntimeObjects(code string, enabled bool) (string, error) { + if !enabled { + return code, nil + } + out, _, err := addRuntimeObjects(code) + return out, err +} + +// addRuntimeObjects generates controller-gen-style DeepCopy methods for every +// struct in the given Go source, plus runtime.Object + schema.ObjectKind +// methods and a scheme-registering init() for every root type (a struct with +// both APIVersion and Kind fields). It returns the augmented source and whether +// any root types were found (so the caller can emit a groupversion_info.go for +// the package). +func addRuntimeObjects(code string) (string, bool, error) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "", code, parser.ParseComments) + if err != nil { + return "", false, errors.Wrap(err, "failed to parse Go code for runtime.Object generation") + } + + structs := collectStructTypes(f) + scalars := collectScalarTypes(f) + aliases := collectCollectionAliases(f) + + // Deterministic order: walk declarations in source order. + var b strings.Builder + hasRoots := false + for _, decl := range f.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.TYPE { + continue + } + for _, spec := range gen.Specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok || ts.Assign.IsValid() { + continue + } + st, ok := ts.Type.(*ast.StructType) + if !ok || st.Fields == nil { + continue + } + name := ts.Name.Name + writeDeepCopy(&b, fset, name, st, structs, scalars, aliases) + if isRootStruct(st) { + hasRoots = true + writeRuntimeObject(&b, name, st) + } + } + } + + if b.Len() == 0 { + return code, false, nil + } + + combined := code + "\n" + b.String() + // DeepCopy methods need no imports; only root types reference runtime and + // schema. Add the imports only when roots are present so DeepCopy-only files + // (e.g. the shared k8s packages) don't get unused imports. + if hasRoots { + combined, err = ensureImports(combined, []importSpec{ + {alias: roRuntimeAlias, path: roRuntimeImport}, + {path: roSchemaImport}, + }) + if err != nil { + return "", false, err + } + } + + formatted, err := format.Source([]byte(combined)) + if err != nil { + return "", false, errors.Wrap(err, "failed to format generated runtime.Object code") + } + return string(formatted), hasRoots, nil +} + +// collectStructTypes returns the set of struct type names declared in the file. +func collectStructTypes(f *ast.File) map[string]bool { + out := map[string]bool{} + for _, decl := range f.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.TYPE { + continue + } + for _, spec := range gen.Specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok || ts.Assign.IsValid() { + continue + } + if _, ok := ts.Type.(*ast.StructType); ok { + out[ts.Name.Name] = true + } + } + } + return out +} + +// collectScalarTypes returns the set of named non-struct type definitions in the +// file (e.g. `type FooKind string`). These are copied by value. +func collectScalarTypes(f *ast.File) map[string]bool { + out := map[string]bool{} + for _, decl := range f.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.TYPE { + continue + } + for _, spec := range gen.Specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok || ts.Assign.IsValid() { + continue + } + switch ts.Type.(type) { + case *ast.StructType: + // not a scalar + default: + out[ts.Name.Name] = true + } + } + } + return out +} + +// collectCollectionAliases returns local named types whose underlying type is a +// map or slice (both `type X map[..]` and `type X = map[..]`). Fields of these +// types must be deep-copied like a literal map/slice, not shallow-copied as a +// scalar. +func collectCollectionAliases(f *ast.File) map[string]ast.Expr { + out := map[string]ast.Expr{} + for _, decl := range f.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.TYPE { + continue + } + for _, spec := range gen.Specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + switch ts.Type.(type) { + case *ast.MapType, *ast.ArrayType: + out[ts.Name.Name] = ts.Type + } + } + } + return out +} + +// isRootStruct reports whether the struct is a top-level Kubernetes object: it +// has APIVersion, Kind and Metadata fields. The Metadata requirement excludes +// object references (claimRef, resourceRefs, ownerReferences, …) which also +// carry apiVersion/kind but are not registrable kinds. +func isRootStruct(st *ast.StructType) bool { + hasAPIVersion, hasKind, hasMetadata := false, false, false + for _, field := range st.Fields.List { + for _, n := range field.Names { + switch n.Name { + case "APIVersion": + hasAPIVersion = true + case "Kind": + hasKind = true + case "Metadata": + hasMetadata = true + } + } + } + return hasAPIVersion && hasKind && hasMetadata +} + +// fieldKind classifies how a field's element type must be deep-copied. +type fieldKind int + +const ( + // fkScalar: copy by value (basic types, named string enums, time.Time). + fkScalar fieldKind = iota + // fkStruct: a struct with a DeepCopyInto method. + fkStruct +) + +// classifyElem classifies the element type expr (the type with any leading +// pointer/slice/map already stripped) as scalar or struct. +func classifyElem(e ast.Expr, structs, scalars map[string]bool) fieldKind { + switch x := e.(type) { + case *ast.Ident: + if structs[x.Name] { + return fkStruct + } + // Basic types and named scalar (enum) types copy by value. + return fkScalar + case *ast.SelectorExpr: + // pkg.Type — time.* and known alias types (Time, MicroTime, FieldsV1) + // are scalars; every other referenced package type is a generated struct + // with a DeepCopyInto method. + if pkg, ok := x.X.(*ast.Ident); ok && pkg.Name == "time" { + return fkScalar + } + if roScalarSelectorTypes[x.Sel.Name] { + return fkScalar + } + return fkStruct + default: + return fkScalar + } +} + +// writeDeepCopy appends DeepCopyInto and DeepCopy methods for the struct. +func writeDeepCopy(b *strings.Builder, fset *token.FileSet, name string, st *ast.StructType, structs, scalars map[string]bool, aliases map[string]ast.Expr) { + fmt.Fprintf(b, "\n// DeepCopyInto copies the receiver into out.\n") + fmt.Fprintf(b, "func (in *%s) DeepCopyInto(out *%s) {\n", name, name) + b.WriteString("\t*out = *in\n") + for _, field := range st.Fields.List { + if len(field.Names) == 0 { + continue + } + for _, n := range field.Names { + writeFieldCopy(b, fset, n.Name, field.Type, structs, scalars, aliases) + } + } + b.WriteString("}\n") + + fmt.Fprintf(b, "\n// DeepCopy returns a deep copy of the receiver.\n") + fmt.Fprintf(b, "func (in *%s) DeepCopy() *%s {\n", name, name) + b.WriteString("\tif in == nil {\n\t\treturn nil\n\t}\n") + fmt.Fprintf(b, "\tout := new(%s)\n", name) + b.WriteString("\tin.DeepCopyInto(out)\n\treturn out\n}\n") +} + +// writeFieldCopy appends the deep-copy snippet for a single field. All generated +// fields are pointers; the leading pointer is handled here, then the pointee +// (scalar, struct, slice or map) is copied appropriately. Named aliases to a +// map or slice are deep-copied like their literal form. +func writeFieldCopy(b *strings.Builder, fset *token.FileSet, field string, typ ast.Expr, structs, scalars map[string]bool, aliases map[string]ast.Expr) { + star, ok := typ.(*ast.StarExpr) + if !ok { + // Non-pointer fields are copied by the `*out = *in` shallow assignment. + // Generated models use pointers throughout, but guard defensively. + return + } + + fmt.Fprintf(b, "\tif in.%s != nil {\n", field) + fmt.Fprintf(b, "\t\tin, out := &in.%s, &out.%s\n", field, field) + + pointee := star.X + declType := renderType(fset, pointee) + // Resolve a named alias (e.g. `type Patch = map[string]interface{}`) to its + // underlying map/slice so it is deep-copied rather than shallow-copied. + if id, ok := pointee.(*ast.Ident); ok { + if underlying, isAlias := aliases[id.Name]; isAlias { + pointee = underlying + } + } + + switch p := pointee.(type) { + case *ast.ArrayType: + writeSliceCopy(b, fset, declType, p, structs, scalars) + case *ast.MapType: + writeMapCopy(b, fset, declType, p, structs, scalars) + default: + fmt.Fprintf(b, "\t\t*out = new(%s)\n", declType) + if classifyElem(pointee, structs, scalars) == fkStruct { + b.WriteString("\t\t(*in).DeepCopyInto(*out)\n") + } else { + b.WriteString("\t\t**out = **in\n") + } + } + + b.WriteString("\t}\n") +} + +// writeSliceCopy handles a *[]Elem field. declType is the type to allocate (the +// literal slice type or a named alias). On entry in/out are *(*declType). +func writeSliceCopy(b *strings.Builder, fset *token.FileSet, declType string, arr *ast.ArrayType, structs, scalars map[string]bool) { + fmt.Fprintf(b, "\t\t*out = new(%s)\n", declType) + b.WriteString("\t\tif *in != nil {\n") + b.WriteString("\t\t\tin, out := *in, *out\n") + fmt.Fprintf(b, "\t\t\t*out = make(%s, len(*in))\n", declType) + if classifyElem(arr.Elt, structs, scalars) == fkStruct { + b.WriteString("\t\t\tfor i := range *in {\n") + b.WriteString("\t\t\t\t(*in)[i].DeepCopyInto(&(*out)[i])\n") + b.WriteString("\t\t\t}\n") + } else { + b.WriteString("\t\t\tcopy(*out, *in)\n") + } + b.WriteString("\t\t}\n") +} + +// writeMapCopy handles a *map[K]V field. declType is the type to allocate (the +// literal map type or a named alias). On entry in/out are *(*declType). +func writeMapCopy(b *strings.Builder, fset *token.FileSet, declType string, m *ast.MapType, structs, scalars map[string]bool) { + valType := renderType(fset, m.Value) + fmt.Fprintf(b, "\t\t*out = new(%s)\n", declType) + b.WriteString("\t\tif *in != nil {\n") + b.WriteString("\t\t\tin, out := *in, *out\n") + fmt.Fprintf(b, "\t\t\t*out = make(%s, len(*in))\n", declType) + if classifyElem(m.Value, structs, scalars) == fkStruct { + fmt.Fprintf(b, "\t\t\tfor key, val := range *in {\n") + fmt.Fprintf(b, "\t\t\t\tvar v %s\n", valType) + b.WriteString("\t\t\t\tval.DeepCopyInto(&v)\n") + b.WriteString("\t\t\t\t(*out)[key] = v\n") + b.WriteString("\t\t\t}\n") + } else { + b.WriteString("\t\t\tfor key, val := range *in {\n") + b.WriteString("\t\t\t\t(*out)[key] = val\n") + b.WriteString("\t\t\t}\n") + } + b.WriteString("\t\t}\n") +} + +// writeRuntimeObject appends runtime.Object + schema.ObjectKind methods and a +// scheme-registering init() for a root type. +func writeRuntimeObject(b *strings.Builder, name string, st *ast.StructType) { + // DeepCopyObject. + fmt.Fprintf(b, "\n// DeepCopyObject returns a deep copy of the receiver as a runtime.Object.\n") + fmt.Fprintf(b, "func (in *%s) DeepCopyObject() %s.Object {\n", name, roRuntimeAlias) + b.WriteString("\tif c := in.DeepCopy(); c != nil {\n\t\treturn c\n\t}\n\treturn nil\n}\n") + + // GetObjectKind returns the object itself, which implements schema.ObjectKind. + fmt.Fprintf(b, "\n// GetObjectKind implements runtime.Object.\n") + fmt.Fprintf(b, "func (in *%s) GetObjectKind() schema.ObjectKind { return in }\n", name) + + apiVersionType := fieldElemTypeName(st, "APIVersion") + kindType := fieldElemTypeName(st, "Kind") + + // GroupVersionKind reads the typed APIVersion/Kind fields. + fmt.Fprintf(b, "\n// GroupVersionKind implements schema.ObjectKind.\n") + fmt.Fprintf(b, "func (in *%s) GroupVersionKind() schema.GroupVersionKind {\n", name) + b.WriteString("\tvar apiVersion, kind string\n") + b.WriteString("\tif in.APIVersion != nil {\n\t\tapiVersion = string(*in.APIVersion)\n\t}\n") + b.WriteString("\tif in.Kind != nil {\n\t\tkind = string(*in.Kind)\n\t}\n") + b.WriteString("\treturn schema.FromAPIVersionAndKind(apiVersion, kind)\n}\n") + + // SetGroupVersionKind writes the typed APIVersion/Kind fields. + fmt.Fprintf(b, "\n// SetGroupVersionKind implements schema.ObjectKind.\n") + fmt.Fprintf(b, "func (in *%s) SetGroupVersionKind(gvk schema.GroupVersionKind) {\n", name) + fmt.Fprintf(b, "\tapiVersion := %s(gvk.GroupVersion().String())\n", apiVersionType) + b.WriteString("\tin.APIVersion = &apiVersion\n") + fmt.Fprintf(b, "\tkind := %s(gvk.Kind)\n", kindType) + b.WriteString("\tin.Kind = &kind\n}\n") + + // Register the type with the package SchemeBuilder (defined in + // groupversion_info.go) so AddToScheme makes it known to a runtime.Scheme. + fmt.Fprintf(b, "\nfunc init() {\n") + fmt.Fprintf(b, "\tSchemeBuilder.Register(func(s *%s.Scheme) error {\n", roRuntimeAlias) + fmt.Fprintf(b, "\t\ts.AddKnownTypes(GroupVersion, &%s{})\n", name) + b.WriteString("\t\treturn nil\n\t})\n}\n") +} + +// fieldElemTypeName returns the element type name of a pointer field (e.g. for +// `APIVersion *FooAPIVersion` it returns "FooAPIVersion"; for `*string` it +// returns "string"). Used to cast when setting the typed fields. +func fieldElemTypeName(st *ast.StructType, field string) string { + for _, f := range st.Fields.List { + for _, n := range f.Names { + if n.Name != field { + continue + } + if star, ok := f.Type.(*ast.StarExpr); ok { + if id, ok := star.X.(*ast.Ident); ok { + return id.Name + } + } + } + } + return "string" +} + +// renderType renders a type expression to its Go source string. +func renderType(fset *token.FileSet, e ast.Expr) string { + var sb strings.Builder + if err := format.Node(&sb, fset, e); err != nil { + return "" + } + return sb.String() +} + +// importSpec describes an import to add: an optional alias and the package path. +type importSpec struct { + alias string + path string +} + +// ensureImports adds the given imports to the file if not already present, +// inserting them as a new import declaration after the package clause. +// format.Source tidies the result afterward. +func ensureImports(code string, specs []importSpec) (string, error) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "", code, parser.ParseComments) + if err != nil { + return "", errors.Wrap(err, "failed to parse code for import injection") + } + + existing := map[string]bool{} + for _, imp := range f.Imports { + existing[strings.Trim(imp.Path.Value, `"`)] = true + } + + var lines []string + for _, s := range specs { + if existing[s.path] { + continue + } + if s.alias != "" { + lines = append(lines, fmt.Sprintf("\t%s %q", s.alias, s.path)) + } else { + lines = append(lines, fmt.Sprintf("\t%q", s.path)) + } + } + if len(lines) == 0 { + return code, nil + } + sort.Strings(lines) + + imports := "import (\n" + strings.Join(lines, "\n") + "\n)\n" + + pkgEnd := fset.Position(f.Name.End()).Offset + return code[:pkgEnd] + "\n\n" + imports + code[pkgEnd:], nil +} diff --git a/internal/schemas/generator/runtimeobject_integration_test.go b/internal/schemas/generator/runtimeobject_integration_test.go new file mode 100644 index 00000000..01f7e8aa --- /dev/null +++ b/internal/schemas/generator/runtimeobject_integration_test.go @@ -0,0 +1,283 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package generator + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/spf13/afero" +) + +// resolveGeneratedModuleDeps runs `go mod download` in the generated module so +// the compile gate fails (rather than silently passing) when generated module +// dependencies are broken or drifting. Set CROSSPLANE_SKIP_COMPILE_GATE=1 to +// opt out for offline/local development; CI leaves it unset so broken deps fail. +func resolveGeneratedModuleDeps(t *testing.T, modelsDir string) { + t.Helper() + if os.Getenv("CROSSPLANE_SKIP_COMPILE_GATE") != "" { + t.Skip("CROSSPLANE_SKIP_COMPILE_GATE set; skipping compile gate") + } + cmd := exec.CommandContext(t.Context(), "go", "mod", "download") + cmd.Dir = modelsDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to resolve generated module dependencies: %v\n%s", err, out) + } +} + +// roMaterialize writes every file in fs out to dir on disk. +func roMaterialize(t *testing.T, fs afero.Fs, dir string) { + t.Helper() + err := afero.Walk(fs, "", func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + bs, err := afero.ReadFile(fs, path) + if err != nil { + return err + } + dst := filepath.Join(dir, path) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + return os.WriteFile(dst, bs, 0o644) + }) + if err != nil { + t.Fatalf("failed to materialize generated FS: %v", err) + } +} + +func TestGenerateFromCRDRuntimeObjectsArtifacts(t *testing.T) { + inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataFS}, "testdata") + schemaFS, err := goGenerator{runtimeObjects: true}.GenerateFromCRD(t.Context(), inputFS, nil) + if err != nil { + t.Fatal(err) + } + + // Root types implement runtime.Object + schema.ObjectKind. + crd, err := afero.ReadFile(schemaFS, "models/co/acme/platform/v1alpha1/xaccountscaffold.go") + if err != nil { + t.Fatal(err) + } + methods := roMethods(t, string(crd)) + for _, m := range []string{ + "XAccountScaffold.DeepCopyInto", "XAccountScaffold.DeepCopy", "XAccountScaffold.DeepCopyObject", + "XAccountScaffold.GetObjectKind", "XAccountScaffold.SetGroupVersionKind", + "XAccountScaffoldSpec.DeepCopyInto", "XAccountScaffoldSpec.DeepCopy", + } { + if !methods[m] { + t.Errorf("expected %s in generated CRD model", m) + } + } + // Nested non-root struct must not be a runtime.Object. + if methods["XAccountScaffoldSpec.DeepCopyObject"] { + t.Error("nested struct should not implement runtime.Object") + } + + // A groupversion_info.go is generated for the package. + if exists, _ := afero.Exists(schemaFS, "models/co/acme/platform/v1alpha1/groupversion_info.go"); !exists { + t.Error("expected groupversion_info.go for the package") + } + + // go.mod gains the apimachinery dependency. + mod, err := afero.ReadFile(schemaFS, "models/go.mod") + if err != nil { + t.Fatal(err) + } + if !contains(string(mod), "k8s.io/apimachinery") { + t.Error("expected k8s.io/apimachinery in generated go.mod when feature is on") + } +} + +func TestGenerateFromCRDNoRuntimeObjectsByDefault(t *testing.T) { + inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataFS}, "testdata") + schemaFS, err := goGenerator{}.GenerateFromCRD(t.Context(), inputFS, nil) + if err != nil { + t.Fatal(err) + } + + crd, err := afero.ReadFile(schemaFS, "models/co/acme/platform/v1alpha1/xaccountscaffold.go") + if err != nil { + t.Fatal(err) + } + if roMethods(t, string(crd))["XAccountScaffold.DeepCopyObject"] { + t.Error("runtime.Object methods must not be generated when feature is disabled") + } + if exists, _ := afero.Exists(schemaFS, "models/co/acme/platform/v1alpha1/groupversion_info.go"); exists { + t.Error("groupversion_info.go must not exist when feature is disabled") + } + mod, err := afero.ReadFile(schemaFS, "models/go.mod") + if err != nil { + t.Fatal(err) + } + if contains(string(mod), "k8s.io/apimachinery") { + t.Error("go.mod must not reference apimachinery when feature is disabled") + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (func() bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + })() +} + +// TestGeneratedRuntimeObjectsCompile is the real correctness gate: it +// materializes the generated module (flag on), adds a consumer that registers +// the types in a runtime.Scheme and exercises an accessor through the +// runtime.Object interface, and compiles the whole module. This is what catches +// DeepCopyInto codegen bugs that parse cleanly but don't type-check. +func TestGeneratedRuntimeObjectsCompile(t *testing.T) { + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go toolchain not available; skipping compile gate") + } + + inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataFS}, "testdata") + schemaFS, err := goGenerator{runtimeObjects: true}.GenerateFromCRD(t.Context(), inputFS, nil) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + roMaterialize(t, schemaFS, dir) + + // A behavioral test inside the generated module: it compiles the whole + // module (build gate) and asserts runtime.Object satisfaction, AddToScheme + // GVK round-tripping, SetGroupVersionKind writing the typed fields, and + // DeepCopy independence. + consumer := `package consumer + +import ( + "testing" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + v1alpha1 "dev.crossplane.io/models/co/acme/platform/v1alpha1" +) + +func TestGeneratedRuntimeObject(t *testing.T) { + var _ runtime.Object = &v1alpha1.XAccountScaffold{} + var _ runtime.Object = &v1alpha1.XAccountScaffoldList{} + + s := runtime.NewScheme() + if err := v1alpha1.AddToScheme(s); err != nil { + t.Fatal(err) + } + gvks, _, err := s.ObjectKinds(&v1alpha1.XAccountScaffold{}) + if err != nil { + t.Fatalf("ObjectKinds: %v", err) + } + if len(gvks) == 0 || gvks[0].Kind != "XAccountScaffold" { + t.Fatalf("unexpected GVKs: %v", gvks) + } + + o := &v1alpha1.XAccountScaffold{} + o.GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{ + Group: "platform.acme.co", Version: "v1alpha1", Kind: "XAccountScaffold", + }) + if o.APIVersion == nil || string(*o.APIVersion) != "platform.acme.co/v1alpha1" { + t.Fatalf("APIVersion not set by SetGroupVersionKind: %v", o.APIVersion) + } + + ptr := func(s string) *string { return &s } + + // Scalar pointer independence. + orig := &v1alpha1.XAccountScaffoldSpecParameters{Name: ptr("a")} + cp := orig.DeepCopy() + *cp.Name = "b" + if *orig.Name != "a" { + t.Fatalf("DeepCopy (*string) not independent: original mutated to %q", *orig.Name) + } + + // Slice-of-structs independence (exercises writeSliceCopy). + spec := &v1alpha1.XAccountScaffoldSpec{ + ResourceRefs: &[]v1alpha1.XAccountScaffoldSpecResourceRefsItem{{Name: ptr("a")}}, + } + specCopy := spec.DeepCopy() + *(*specCopy.ResourceRefs)[0].Name = "b" + if *(*spec.ResourceRefs)[0].Name != "a" { + t.Fatalf("DeepCopy (*[]struct) not independent: original mutated to %q", *(*spec.ResourceRefs)[0].Name) + } + + // Map independence (exercises writeMapCopy). + sel := &v1alpha1.XAccountScaffoldSpecCompositionSelector{ + MatchLabels: &map[string]string{"k": "v"}, + } + selCopy := sel.DeepCopy() + (*selCopy.MatchLabels)["k"] = "x" + if (*sel.MatchLabels)["k"] != "v" { + t.Fatalf("DeepCopy (*map) not independent: original mutated to %q", (*sel.MatchLabels)["k"]) + } +} +` + consumerDir := filepath.Join(dir, "models", "consumer") + if err := os.MkdirAll(consumerDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(consumerDir, "consumer_test.go"), []byte(consumer), 0o644); err != nil { + t.Fatal(err) + } + + modelsDir := filepath.Join(dir, "models") + + resolveGeneratedModuleDeps(t, modelsDir) + + // `go test ./...` builds every generated package and runs the behavioral + // test above. + cmd := exec.CommandContext(t.Context(), "go", "test", "./...") + cmd.Dir = modelsDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("generated runtime.Object models failed to build/test: %v\n%s", err, out) + } +} + +// TestGenerateFromOpenAPIRuntimeObjectsCompile exercises the OpenAPI generation +// path (the shared k8s and GVK packages, which include union and intstr types) +// with the feature on, and compiles the result. +func TestGenerateFromOpenAPIRuntimeObjectsCompile(t *testing.T) { + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go toolchain not available; skipping compile gate") + } + + inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataJSONFS}, "testdata") + schemaFS, err := goGenerator{runtimeObjects: true}.GenerateFromOpenAPI(t.Context(), inputFS, nil) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + roMaterialize(t, schemaFS, dir) + modelsDir := filepath.Join(dir, "models") + + resolveGeneratedModuleDeps(t, modelsDir) + + cmd := exec.CommandContext(t.Context(), "go", "build", "./...") + cmd.Dir = modelsDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("generated OpenAPI runtime.Object models failed to compile: %v\n%s", err, out) + } +} diff --git a/internal/schemas/generator/runtimeobject_test.go b/internal/schemas/generator/runtimeobject_test.go new file mode 100644 index 00000000..614af433 --- /dev/null +++ b/internal/schemas/generator/runtimeobject_test.go @@ -0,0 +1,143 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package generator + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" +) + +// roMethods parses src and returns the set of "recv.method" names declared. +func roMethods(t *testing.T, src string) map[string]bool { + t.Helper() + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "", src, parser.ParseComments) + if err != nil { + t.Fatalf("failed to parse generated source: %v\n%s", err, src) + } + out := map[string]bool{} + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv == nil || len(fn.Recv.List) != 1 { + continue + } + recv := fn.Recv.List[0].Type + if star, ok := recv.(*ast.StarExpr); ok { + recv = star.X + } + id, ok := recv.(*ast.Ident) + if !ok { + continue + } + out[id.Name+"."+fn.Name.Name] = true + } + return out +} + +func TestAddRuntimeObjectsDeepCopy(t *testing.T) { + input := `package v1alpha1 + +type Bar struct { + Count *int64 ` + "`json:\"count,omitempty\"`" + ` +} + +type Foo struct { + Name *string ` + "`json:\"name,omitempty\"`" + ` + Items *[]string ` + "`json:\"items,omitempty\"`" + ` + Bars *[]Bar ` + "`json:\"bars,omitempty\"`" + ` + Labels *map[string]string ` + "`json:\"labels,omitempty\"`" + ` + Bar *Bar ` + "`json:\"bar,omitempty\"`" + ` +} +` + got, _, err := addRuntimeObjects(input) + if err != nil { + t.Fatalf("addRuntimeObjects returned error: %v", err) + } + + methods := roMethods(t, got) + for _, m := range []string{ + "Foo.DeepCopyInto", "Foo.DeepCopy", + "Bar.DeepCopyInto", "Bar.DeepCopy", + } { + if !methods[m] { + t.Errorf("expected method %s to be generated", m) + } + } + + // Non-root structs must NOT get runtime.Object methods. + for _, m := range []string{ + "Foo.DeepCopyObject", "Foo.GetObjectKind", + "Bar.DeepCopyObject", "Bar.GetObjectKind", + } { + if methods[m] { + t.Errorf("non-root struct should not declare %s", m) + } + } +} + +func TestAddRuntimeObjectsRootType(t *testing.T) { + input := `package v1alpha1 + +type FooAPIVersion string +type FooKind string + +type FooSpec struct { + Replicas *int64 ` + "`json:\"replicas,omitempty\"`" + ` +} + +type ObjectMeta struct { + Name *string ` + "`json:\"name,omitempty\"`" + ` +} + +type Foo struct { + APIVersion *FooAPIVersion ` + "`json:\"apiVersion,omitempty\"`" + ` + Kind *FooKind ` + "`json:\"kind,omitempty\"`" + ` + Metadata *ObjectMeta ` + "`json:\"metadata,omitempty\"`" + ` + Spec *FooSpec ` + "`json:\"spec,omitempty\"`" + ` +} + +type FooList struct { + APIVersion *string ` + "`json:\"apiVersion,omitempty\"`" + ` + Kind *string ` + "`json:\"kind,omitempty\"`" + ` + Metadata *ObjectMeta ` + "`json:\"metadata,omitempty\"`" + ` + Items *[]Foo ` + "`json:\"items,omitempty\"`" + ` +} +` + got, hasRoots, err := addRuntimeObjects(input) + if err != nil { + t.Fatalf("addRuntimeObjects returned error: %v", err) + } + if !hasRoots { + t.Error("expected addRuntimeObjects to report root types present") + } + + methods := roMethods(t, got) + for _, m := range []string{ + "Foo.DeepCopyObject", "Foo.GetObjectKind", "Foo.GroupVersionKind", "Foo.SetGroupVersionKind", + "FooList.DeepCopyObject", "FooList.GetObjectKind", "FooList.GroupVersionKind", "FooList.SetGroupVersionKind", + } { + if !methods[m] { + t.Errorf("expected root-type method %s to be generated", m) + } + } + // FooSpec is not a root type. + if methods["FooSpec.DeepCopyObject"] { + t.Error("FooSpec should not be a runtime.Object") + } +} From 0a783bed8f2452f3cce1dcb8e05dda70fe4b9ae4 Mon Sep 17 00:00:00 2001 From: Erik Miller Date: Tue, 21 Jul 2026 08:35:25 -0700 Subject: [PATCH 2/7] generator: address review feedback on runtime.Object generation Honor features.generateGoRuntimeObjects in `crossplane function generate`. Run built the schema manager with a bare generator.AllLanguages(), so the command ignored the feature flag and always emitted the default Go models even when the user opted in. Thread the flag through as project build/run already do. Fold the two addRuntimeObjects tests into a single table-driven test with named cases and reason fields, matching the repo's test conventions. Signed-off-by: Erik Miller --- cmd/crossplane/function/generate.go | 5 +- .../schemas/generator/runtimeobject_test.go | 107 ++++++++++-------- 2 files changed, 61 insertions(+), 51 deletions(-) diff --git a/cmd/crossplane/function/generate.go b/cmd/crossplane/function/generate.go index c059ceaa..416cd5e7 100644 --- a/cmd/crossplane/function/generate.go +++ b/cmd/crossplane/function/generate.go @@ -157,7 +157,10 @@ func (c *generateCmd) Run(sp terminal.SpinnerPrinter, cfg *config.Config) error } schemaMgr := manager.New( c.schemasFS, - generator.Filter(generator.AllLanguages(), c.proj.Spec.Schemas.GetLanguages()), + generator.Filter( + generator.AllLanguages(generator.WithGoRuntimeObjects(cfg.Features.GenerateGoRuntimeObjects)), + c.proj.Spec.Schemas.GetLanguages(), + ), runner.NewRealSchemaRunner(runner.WithImageConfig(c.proj.Spec.ImageConfigs)), ) diff --git a/internal/schemas/generator/runtimeobject_test.go b/internal/schemas/generator/runtimeobject_test.go index 614af433..ad334c82 100644 --- a/internal/schemas/generator/runtimeobject_test.go +++ b/internal/schemas/generator/runtimeobject_test.go @@ -50,8 +50,17 @@ func roMethods(t *testing.T, src string) map[string]bool { return out } -func TestAddRuntimeObjectsDeepCopy(t *testing.T) { - input := `package v1alpha1 +func TestAddRuntimeObjects(t *testing.T) { + cases := map[string]struct { + reason string + input string + wantHasRoots bool + wantMethods []string + notMethods []string + }{ + "DeepCopyOnly": { + reason: "non-root structs get DeepCopy methods but not runtime.Object methods", + input: `package v1alpha1 type Bar struct { Count *int64 ` + "`json:\"count,omitempty\"`" + ` @@ -64,35 +73,20 @@ type Foo struct { Labels *map[string]string ` + "`json:\"labels,omitempty\"`" + ` Bar *Bar ` + "`json:\"bar,omitempty\"`" + ` } -` - got, _, err := addRuntimeObjects(input) - if err != nil { - t.Fatalf("addRuntimeObjects returned error: %v", err) - } - - methods := roMethods(t, got) - for _, m := range []string{ - "Foo.DeepCopyInto", "Foo.DeepCopy", - "Bar.DeepCopyInto", "Bar.DeepCopy", - } { - if !methods[m] { - t.Errorf("expected method %s to be generated", m) - } - } - - // Non-root structs must NOT get runtime.Object methods. - for _, m := range []string{ - "Foo.DeepCopyObject", "Foo.GetObjectKind", - "Bar.DeepCopyObject", "Bar.GetObjectKind", - } { - if methods[m] { - t.Errorf("non-root struct should not declare %s", m) - } - } -} - -func TestAddRuntimeObjectsRootType(t *testing.T) { - input := `package v1alpha1 +`, + wantHasRoots: false, + wantMethods: []string{ + "Foo.DeepCopyInto", "Foo.DeepCopy", + "Bar.DeepCopyInto", "Bar.DeepCopy", + }, + notMethods: []string{ + "Foo.DeepCopyObject", "Foo.GetObjectKind", + "Bar.DeepCopyObject", "Bar.GetObjectKind", + }, + }, + "RootType": { + reason: "structs with APIVersion+Kind+Metadata get runtime.Object methods; supporting structs do not", + input: `package v1alpha1 type FooAPIVersion string type FooKind string @@ -118,26 +112,39 @@ type FooList struct { Metadata *ObjectMeta ` + "`json:\"metadata,omitempty\"`" + ` Items *[]Foo ` + "`json:\"items,omitempty\"`" + ` } -` - got, hasRoots, err := addRuntimeObjects(input) - if err != nil { - t.Fatalf("addRuntimeObjects returned error: %v", err) - } - if !hasRoots { - t.Error("expected addRuntimeObjects to report root types present") +`, + wantHasRoots: true, + wantMethods: []string{ + "Foo.DeepCopyObject", "Foo.GetObjectKind", "Foo.GroupVersionKind", "Foo.SetGroupVersionKind", + "FooList.DeepCopyObject", "FooList.GetObjectKind", "FooList.GroupVersionKind", "FooList.SetGroupVersionKind", + }, + notMethods: []string{ + "FooSpec.DeepCopyObject", + }, + }, } - methods := roMethods(t, got) - for _, m := range []string{ - "Foo.DeepCopyObject", "Foo.GetObjectKind", "Foo.GroupVersionKind", "Foo.SetGroupVersionKind", - "FooList.DeepCopyObject", "FooList.GetObjectKind", "FooList.GroupVersionKind", "FooList.SetGroupVersionKind", - } { - if !methods[m] { - t.Errorf("expected root-type method %s to be generated", m) - } - } - // FooSpec is not a root type. - if methods["FooSpec.DeepCopyObject"] { - t.Error("FooSpec should not be a runtime.Object") + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, hasRoots, err := addRuntimeObjects(tc.input) + if err != nil { + t.Fatalf("addRuntimeObjects returned error: %v", err) + } + if hasRoots != tc.wantHasRoots { + t.Errorf("hasRoots = %v, want %v (%s)", hasRoots, tc.wantHasRoots, tc.reason) + } + + methods := roMethods(t, got) + for _, m := range tc.wantMethods { + if !methods[m] { + t.Errorf("expected method %s to be generated (%s)", m, tc.reason) + } + } + for _, m := range tc.notMethods { + if methods[m] { + t.Errorf("did not expect method %s (%s)", m, tc.reason) + } + } + }) } } From d3af83edcca0771c7624bc721b1fcc8cb7ca1fd9 Mon Sep 17 00:00:00 2001 From: Erik Miller Date: Tue, 21 Jul 2026 08:52:07 -0700 Subject: [PATCH 3/7] generator: satisfy the linter for runtime.Object generation Extract the shared-K8s-package generation loop body into a generateSharedK8sPackage helper so GenerateFromCRD stays under the gocognit complexity limit, matching the accessors PR. Drop the dead `scalars`/collectScalarTypes plumbing: classifyElem treats every non-struct as scalar and never consulted the set, so it was threaded through the whole deep-copy chain unused (flagged by revive). Also drop the unused fset parameter from writeSliceCopy. No behavior change. Signed-off-by: Erik Miller --- internal/schemas/generator/go.go | 138 ++++++++++---------- internal/schemas/generator/runtimeobject.go | 50 ++----- 2 files changed, 84 insertions(+), 104 deletions(-) diff --git a/internal/schemas/generator/go.go b/internal/schemas/generator/go.go index 52c0686f..ec1583d9 100644 --- a/internal/schemas/generator/go.go +++ b/internal/schemas/generator/go.go @@ -313,72 +313,7 @@ func (g goGenerator) GenerateFromCRD(_ context.Context, fromFS afero.Fs, _ runne // Generate separate files for each K8s package for pkg, schemas := range k8sSchemasByPackage { - if len(schemas) == 0 { - continue - } - - // Create a spec for this package - pkgSpec := &spec3.OpenAPI{ - Version: "3.0.0", - Components: &spec3.Components{ - Schemas: schemas, - }, - } - - // Determine the group, kind, and version from the package name - var group, kind, version string - switch pkg { - case k8sPkgMetaV1: - group = "meta.k8s.io" - kind = "meta" - version = "v1" - case k8sPkgAutoscalingV1: - group = k8sPkgNameAutoscaling - kind = k8sPkgNameAutoscaling - version = "v1" - } - - // For K8s packages that reference meta.v1, we need to use the correct - // meta import path. The meta.v1 package uses goReferenceK8sTypes (core - // path) because self-references get stripped. Other packages like - // autoscaling use goReferenceK8sTypesForCRDs (non-core path) to - // reference the CRD meta.v1 package at - // dev.crossplane.io/models/io/k8s/meta/v1. - refMutator := goReferenceK8sTypes - if pkg != k8sPkgMetaV1 { - refMutator = goReferenceK8sTypesForCRDs - } - - code, err := generateGo(pkgSpec, version, - goRenameTypes, - goRenameEnums, - goReplaceNumberWithInt, - goRemoveRequired, - refMutator, - ) - if err != nil { - return nil, err - } - - // shorten the auto‑generated K8s type names - code, err = fixK8sTypeNames(code) - if err != nil { - return nil, err - } - - // remove the self‑import (e.g. meta/v1 importing itself) - code, err = removeSelfImports(code, pkg) - if err != nil { - return nil, err - } - - // Add runtime.Object/DeepCopy code last, so it sees final type names. - code, err = applyRuntimeObjects(code, g.runtimeObjects) - if err != nil { - return nil, err - } - - if err := writeGoCode(schemaFS, group, kind, version, code); err != nil { + if err := g.generateSharedK8sPackage(schemaFS, pkg, schemas); err != nil { return nil, err } } @@ -411,6 +346,77 @@ func (g goGenerator) GenerateFromCRD(_ context.Context, fromFS afero.Fs, _ runne return schemaFS, nil } +// generateSharedK8sPackage generates the Go model file for a single shared K8s +// package (e.g. meta/v1) into schemaFS. +func (g goGenerator) generateSharedK8sPackage(schemaFS afero.Fs, pkg string, schemas map[string]*spec.Schema) error { + if len(schemas) == 0 { + return nil + } + + // Create a spec for this package + pkgSpec := &spec3.OpenAPI{ + Version: "3.0.0", + Components: &spec3.Components{ + Schemas: schemas, + }, + } + + // Determine the group, kind, and version from the package name + var group, kind, version string + switch pkg { + case k8sPkgMetaV1: + group = "meta.k8s.io" + kind = "meta" + version = "v1" + case k8sPkgAutoscalingV1: + group = k8sPkgNameAutoscaling + kind = k8sPkgNameAutoscaling + version = "v1" + } + + // For K8s packages that reference meta.v1, we need to use the correct + // meta import path. The meta.v1 package uses goReferenceK8sTypes (core + // path) because self-references get stripped. Other packages like + // autoscaling use goReferenceK8sTypesForCRDs (non-core path) to + // reference the CRD meta.v1 package at + // dev.crossplane.io/models/io/k8s/meta/v1. + refMutator := goReferenceK8sTypes + if pkg != k8sPkgMetaV1 { + refMutator = goReferenceK8sTypesForCRDs + } + + code, err := generateGo(pkgSpec, version, + goRenameTypes, + goRenameEnums, + goReplaceNumberWithInt, + goRemoveRequired, + refMutator, + ) + if err != nil { + return err + } + + // shorten the auto‑generated K8s type names + code, err = fixK8sTypeNames(code) + if err != nil { + return err + } + + // remove the self‑import (e.g. meta/v1 importing itself) + code, err = removeSelfImports(code, pkg) + if err != nil { + return err + } + + // Add runtime.Object/DeepCopy code last, so it sees final type names. + code, err = applyRuntimeObjects(code, g.runtimeObjects) + if err != nil { + return err + } + + return writeGoCode(schemaFS, group, kind, version, code) +} + type goOpenAPI struct { crd *extv1.CustomResourceDefinition version string diff --git a/internal/schemas/generator/runtimeobject.go b/internal/schemas/generator/runtimeobject.go index b679e757..1acf5c6e 100644 --- a/internal/schemas/generator/runtimeobject.go +++ b/internal/schemas/generator/runtimeobject.go @@ -72,7 +72,6 @@ func addRuntimeObjects(code string) (string, bool, error) { } structs := collectStructTypes(f) - scalars := collectScalarTypes(f) aliases := collectCollectionAliases(f) // Deterministic order: walk declarations in source order. @@ -93,7 +92,7 @@ func addRuntimeObjects(code string) (string, bool, error) { continue } name := ts.Name.Name - writeDeepCopy(&b, fset, name, st, structs, scalars, aliases) + writeDeepCopy(&b, fset, name, st, structs, aliases) if isRootStruct(st) { hasRoots = true writeRuntimeObject(&b, name, st) @@ -147,31 +146,6 @@ func collectStructTypes(f *ast.File) map[string]bool { return out } -// collectScalarTypes returns the set of named non-struct type definitions in the -// file (e.g. `type FooKind string`). These are copied by value. -func collectScalarTypes(f *ast.File) map[string]bool { - out := map[string]bool{} - for _, decl := range f.Decls { - gen, ok := decl.(*ast.GenDecl) - if !ok || gen.Tok != token.TYPE { - continue - } - for _, spec := range gen.Specs { - ts, ok := spec.(*ast.TypeSpec) - if !ok || ts.Assign.IsValid() { - continue - } - switch ts.Type.(type) { - case *ast.StructType: - // not a scalar - default: - out[ts.Name.Name] = true - } - } - } - return out -} - // collectCollectionAliases returns local named types whose underlying type is a // map or slice (both `type X map[..]` and `type X = map[..]`). Fields of these // types must be deep-copied like a literal map/slice, not shallow-copied as a @@ -230,7 +204,7 @@ const ( // classifyElem classifies the element type expr (the type with any leading // pointer/slice/map already stripped) as scalar or struct. -func classifyElem(e ast.Expr, structs, scalars map[string]bool) fieldKind { +func classifyElem(e ast.Expr, structs map[string]bool) fieldKind { switch x := e.(type) { case *ast.Ident: if structs[x.Name] { @@ -255,7 +229,7 @@ func classifyElem(e ast.Expr, structs, scalars map[string]bool) fieldKind { } // writeDeepCopy appends DeepCopyInto and DeepCopy methods for the struct. -func writeDeepCopy(b *strings.Builder, fset *token.FileSet, name string, st *ast.StructType, structs, scalars map[string]bool, aliases map[string]ast.Expr) { +func writeDeepCopy(b *strings.Builder, fset *token.FileSet, name string, st *ast.StructType, structs map[string]bool, aliases map[string]ast.Expr) { fmt.Fprintf(b, "\n// DeepCopyInto copies the receiver into out.\n") fmt.Fprintf(b, "func (in *%s) DeepCopyInto(out *%s) {\n", name, name) b.WriteString("\t*out = *in\n") @@ -264,7 +238,7 @@ func writeDeepCopy(b *strings.Builder, fset *token.FileSet, name string, st *ast continue } for _, n := range field.Names { - writeFieldCopy(b, fset, n.Name, field.Type, structs, scalars, aliases) + writeFieldCopy(b, fset, n.Name, field.Type, structs, aliases) } } b.WriteString("}\n") @@ -280,7 +254,7 @@ func writeDeepCopy(b *strings.Builder, fset *token.FileSet, name string, st *ast // fields are pointers; the leading pointer is handled here, then the pointee // (scalar, struct, slice or map) is copied appropriately. Named aliases to a // map or slice are deep-copied like their literal form. -func writeFieldCopy(b *strings.Builder, fset *token.FileSet, field string, typ ast.Expr, structs, scalars map[string]bool, aliases map[string]ast.Expr) { +func writeFieldCopy(b *strings.Builder, fset *token.FileSet, field string, typ ast.Expr, structs map[string]bool, aliases map[string]ast.Expr) { star, ok := typ.(*ast.StarExpr) if !ok { // Non-pointer fields are copied by the `*out = *in` shallow assignment. @@ -303,12 +277,12 @@ func writeFieldCopy(b *strings.Builder, fset *token.FileSet, field string, typ a switch p := pointee.(type) { case *ast.ArrayType: - writeSliceCopy(b, fset, declType, p, structs, scalars) + writeSliceCopy(b, declType, p, structs) case *ast.MapType: - writeMapCopy(b, fset, declType, p, structs, scalars) + writeMapCopy(b, fset, declType, p, structs) default: fmt.Fprintf(b, "\t\t*out = new(%s)\n", declType) - if classifyElem(pointee, structs, scalars) == fkStruct { + if classifyElem(pointee, structs) == fkStruct { b.WriteString("\t\t(*in).DeepCopyInto(*out)\n") } else { b.WriteString("\t\t**out = **in\n") @@ -320,12 +294,12 @@ func writeFieldCopy(b *strings.Builder, fset *token.FileSet, field string, typ a // writeSliceCopy handles a *[]Elem field. declType is the type to allocate (the // literal slice type or a named alias). On entry in/out are *(*declType). -func writeSliceCopy(b *strings.Builder, fset *token.FileSet, declType string, arr *ast.ArrayType, structs, scalars map[string]bool) { +func writeSliceCopy(b *strings.Builder, declType string, arr *ast.ArrayType, structs map[string]bool) { fmt.Fprintf(b, "\t\t*out = new(%s)\n", declType) b.WriteString("\t\tif *in != nil {\n") b.WriteString("\t\t\tin, out := *in, *out\n") fmt.Fprintf(b, "\t\t\t*out = make(%s, len(*in))\n", declType) - if classifyElem(arr.Elt, structs, scalars) == fkStruct { + if classifyElem(arr.Elt, structs) == fkStruct { b.WriteString("\t\t\tfor i := range *in {\n") b.WriteString("\t\t\t\t(*in)[i].DeepCopyInto(&(*out)[i])\n") b.WriteString("\t\t\t}\n") @@ -337,13 +311,13 @@ func writeSliceCopy(b *strings.Builder, fset *token.FileSet, declType string, ar // writeMapCopy handles a *map[K]V field. declType is the type to allocate (the // literal map type or a named alias). On entry in/out are *(*declType). -func writeMapCopy(b *strings.Builder, fset *token.FileSet, declType string, m *ast.MapType, structs, scalars map[string]bool) { +func writeMapCopy(b *strings.Builder, fset *token.FileSet, declType string, m *ast.MapType, structs map[string]bool) { valType := renderType(fset, m.Value) fmt.Fprintf(b, "\t\t*out = new(%s)\n", declType) b.WriteString("\t\tif *in != nil {\n") b.WriteString("\t\t\tin, out := *in, *out\n") fmt.Fprintf(b, "\t\t\t*out = make(%s, len(*in))\n", declType) - if classifyElem(m.Value, structs, scalars) == fkStruct { + if classifyElem(m.Value, structs) == fkStruct { fmt.Fprintf(b, "\t\t\tfor key, val := range *in {\n") fmt.Fprintf(b, "\t\t\t\tvar v %s\n", valType) b.WriteString("\t\t\t\tval.DeepCopyInto(&v)\n") From f8f39d0709268e893ca2906003e32e999f962da3 Mon Sep 17 00:00:00 2001 From: Erik Miller Date: Tue, 28 Jul 2026 07:55:13 -0700 Subject: [PATCH 4/7] generator: fix CI and address review feedback on runtime.Object generation Write a single go.mod and go.sum for the generated models module rather than one variant per feature-flag state. The module now always requires k8s.io/apimachinery: an unused requirement is harmless to `go build`, and one set of module files is one thing fewer to maintain and test. Generated output with the feature off is therefore no longer byte-for-byte identical to before - only the module files differ, and they resolve the same way against the Go function template's go.sum. Register built-in Kubernetes types under their real API group. The group labels the generator uses for built-in packages are synthetic and only drive the directory layout, so core/v1 and meta/v1 were getting `GroupVersion{Group: "core.k8s.io"}` and `{Group: "meta.core.k8s.io"}` in their groupversion_info.go. Those types are all in the core (empty) group, so AddToScheme registered them under a GVK that disagreed with the GVK the type's own GroupVersionKind() reports. Real groups from CRDs, and the built-in labels that are also real groups (autoscaling), pass through unchanged. Put the compile gates behind a `compilegate` build tag. They shell out to the Go toolchain to build the generated module, so they need network access and a writable module cache; the Nix sandbox our unit tests run in has neither, and the generated module pins the apimachinery version the function template uses rather than the one this repository depends on, so it can't resolve from gomod2nix either. Failing on a broken `go mod download` (the previous behaviour) therefore broke `nix build .#checks..test`. A build tag is more honest than a runtime skip, which still reports as a test that ran, and .golangci.yml now lints the tagged file so it can't rot. Run them with: go test -tags compilegate ./internal/schemas/generator/... The gate also covers the flag-off module now, since both states share go.mod. Mark `runtime.Object`, `runtime.Scheme` and `AddToScheme` as inline code in the config help text. Vale reads the generated command reference as prose and flags the dotted identifiers as sentences missing a space, failing validate-docs. Cover the feature-flag plumbing: AllLanguages threading the option into the Go generator, and function generate's Run with the flag on. Signed-off-by: Erik Miller --- .golangci.yml | 7 + cmd/crossplane/config/help/config.md | 6 +- cmd/crossplane/function/generate_test.go | 23 +- internal/schemas/generator/go.go | 103 ++++---- internal/schemas/generator/interface_test.go | 44 ++++ .../runtimeobject_compilegate_test.go | 233 +++++++++++++++++ .../runtimeobject_integration_test.go | 243 ++++-------------- 7 files changed, 407 insertions(+), 252 deletions(-) create mode 100644 internal/schemas/generator/runtimeobject_compilegate_test.go diff --git a/.golangci.yml b/.golangci.yml index 91563e48..c945a3f8 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,5 +1,12 @@ version: "2" +run: + # Tests behind the compilegate tag shell out to the Go toolchain to build the + # generated models module, so they can't run in the hermetic Nix sandbox that + # runs our unit tests. Lint them anyway, so they don't rot. + build-tags: + - compilegate + output: formats: text: diff --git a/cmd/crossplane/config/help/config.md b/cmd/crossplane/config/help/config.md index 485a668c..e875dacc 100644 --- a/cmd/crossplane/config/help/config.md +++ b/cmd/crossplane/config/help/config.md @@ -19,9 +19,9 @@ Enable alpha commands: crossplane config set features.enableAlpha true ``` -Generate runtime.Object methods and per-package AddToScheme helpers on generated -Go models (off by default), so generated types can be registered in a -runtime.Scheme: +Generate `runtime.Object` methods and per-package `AddToScheme` helpers on +generated Go models (off by default), so generated types can be registered in a +`runtime.Scheme`: ```shell crossplane config set features.generateGoRuntimeObjects true diff --git a/cmd/crossplane/function/generate_test.go b/cmd/crossplane/function/generate_test.go index e7da277b..94e3f18c 100644 --- a/cmd/crossplane/function/generate_test.go +++ b/cmd/crossplane/function/generate_test.go @@ -293,6 +293,7 @@ func TestRunErrors(t *testing.T) { name string language string seedFunctionsFS map[string][]byte + cfg *config.Config stage string // "afterApply" or "run" wantErrSubstring string }{ @@ -310,6 +311,22 @@ func TestRunErrors(t *testing.T) { stage: "run", wantErrSubstring: "already exists and is not empty", }, + "DirectoryNotEmptyWithGoRuntimeObjects": { + // Run reads the feature flag out of the config it's handed, so + // exercise the flag-on path too. Asserting on the generated + // artifacts isn't possible here: that needs the real schema + // runner, which runs a container. + name: "my-func", + language: "go-templating", + seedFunctionsFS: map[string][]byte{ + "my-func/existing.txt": []byte("data"), + }, + cfg: &config.Config{ + Features: config.Features{GenerateGoRuntimeObjects: true}, + }, + stage: "run", + wantErrSubstring: "already exists and is not empty", + }, } for name, tc := range cases { @@ -320,12 +337,16 @@ func TestRunErrors(t *testing.T) { functionsFS: seedFS(t, tc.seedFunctionsFS), projFS: afero.NewMemMapFs(), } + cfg := tc.cfg + if cfg == nil { + cfg = &config.Config{} + } var err error switch tc.stage { case "afterApply": err = c.AfterApply() case "run": - err = c.Run(terminal.NewSpinnerPrinter(io.Discard, false), &config.Config{}) + err = c.Run(terminal.NewSpinnerPrinter(io.Discard, false), cfg) } if err == nil { t.Fatalf("expected error containing %q, got nil", tc.wantErrSubstring) diff --git a/internal/schemas/generator/go.go b/internal/schemas/generator/go.go index ec1583d9..4e4de443 100644 --- a/internal/schemas/generator/go.go +++ b/internal/schemas/generator/go.go @@ -70,49 +70,16 @@ const ( // single dependency from embedded Go functions. We always resolve this // dependency via a replace statement, so `dev.crossplane.io/models` is never // actually used as a URL, just an identifier. +// +// It requires k8s.io/apimachinery, which the generated runtime.Object and +// AddToScheme code needs. We write it unconditionally, even when the +// generateGoRuntimeObjects feature is off: an unused requirement is harmless to +// `go build`, and one set of module files is one thing fewer to maintain and +// test. apimachinery is pinned to the version the Go function template uses, so +// a function consuming the models via a replace statement still resolves +// everything from the template's go.sum. const goModContents = `module dev.crossplane.io/models -go 1.23 - -require github.com/oapi-codegen/runtime v1.1.0 - -require ( - github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/google/uuid v1.4.0 // indirect -) -` - -// goSumContents is the contents of the go.sum we write for our generated models -// module alongside the go.mod. -const goSumContents = `github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= -github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= -github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= -github.com/oapi-codegen/runtime v1.1.0 h1:rJpoNUawn5XTvekgfkvSZr0RqEnoYpFkyvrzfWeFKWM= -github.com/oapi-codegen/runtime v1.1.0/go.mod h1:BeSfBkWWWnAnGdyS+S/GnlbmHKzf8/hwkvelJZDeKA8= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -` - -// goModContentsRuntimeObjects is the go.mod variant written when the -// generateGoRuntimeObjects feature is enabled. It additionally requires -// k8s.io/apimachinery (used by the generated runtime.Object and AddToScheme -// code) and its transitive dependencies. -const goModContentsRuntimeObjects = `module dev.crossplane.io/models - go 1.24.0 require ( @@ -137,10 +104,9 @@ require ( ) ` -// goSumContentsRuntimeObjects is the go.sum variant for the -// generateGoRuntimeObjects feature, covering k8s.io/apimachinery and its -// transitive dependencies. -const goSumContentsRuntimeObjects = `github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +// goSumContents is the contents of the go.sum we write for our generated models +// module alongside the go.mod. +const goSumContents = `github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= @@ -289,7 +255,7 @@ func (g goGenerator) GenerateFromCRD(_ context.Context, fromFS afero.Fs, _ runne } // Initialize the schema filesystem - schemaFS, err := initializeSchemaFS(g.runtimeObjects) + schemaFS, err := initializeSchemaFS() if err != nil { return nil, err } @@ -655,7 +621,7 @@ func writeGoCode(schemaFS afero.Fs, group, kind, version, code string) error { // runtime.Object feature is on), emit a groupversion_info.go for the package // defining GroupVersion/SchemeBuilder/AddToScheme. Written once per dir. if strings.Contains(code, "SchemeBuilder.Register(") { - if err := writeGroupVersionInfo(schemaFS, dir, group, version); err != nil { + if err := writeGroupVersionInfo(schemaFS, dir, apiGroup(group), version); err != nil { return err } } @@ -666,6 +632,7 @@ func writeGoCode(schemaFS afero.Fs, group, kind, version, code string) error { // writeGroupVersionInfo writes a groupversion_info.go into dir defining the // package's GroupVersion, SchemeBuilder and AddToScheme. It is a no-op if the // file already exists, so a package with multiple kinds gets a single copy. +// group must be the real API group; see apiGroup. func writeGroupVersionInfo(schemaFS afero.Fs, dir, group, version string) error { path := filepath.Join(dir, "groupversion_info.go") if exists, err := afero.Exists(schemaFS, path); err != nil { @@ -1306,7 +1273,7 @@ func (g goGenerator) GenerateFromOpenAPI(_ context.Context, fromFS afero.Fs, _ r } // Initialize the schema filesystem - schemaFS, err := initializeSchemaFS(g.runtimeObjects) + schemaFS, err := initializeSchemaFS() if err != nil { return nil, err } @@ -1368,24 +1335,18 @@ func collectOpenAPISpecs(fromFS afero.Fs) ([]*spec3.OpenAPI, error) { } // initializeSchemaFS creates and initializes the schema filesystem with go.mod -// and go.sum. When runtimeObjects is set, it writes the variant that depends on -// k8s.io/apimachinery, which the generated runtime.Object code requires. -func initializeSchemaFS(runtimeObjects bool) (afero.Fs, error) { +// and go.sum. +func initializeSchemaFS() (afero.Fs, error) { schemaFS := afero.NewMemMapFs() if err := schemaFS.Mkdir("models", 0o755); err != nil { return nil, errors.Wrap(err, "failed to create models directory") } - modContents, sumContents := goModContents, goSumContents - if runtimeObjects { - modContents, sumContents = goModContentsRuntimeObjects, goSumContentsRuntimeObjects - } - modf, err := schemaFS.Create("models/go.mod") if err != nil { return nil, errors.Wrap(err, "failed to create go.mod") } - if _, err := modf.WriteString(modContents); err != nil { + if _, err := modf.WriteString(goModContents); err != nil { return nil, errors.Wrap(err, "failed to write go.mod") } @@ -1393,7 +1354,7 @@ func initializeSchemaFS(runtimeObjects bool) (afero.Fs, error) { if err != nil { return nil, errors.Wrap(err, "failed to create go.sum") } - if _, err := sumf.WriteString(sumContents); err != nil { + if _, err := sumf.WriteString(goSumContents); err != nil { return nil, errors.Wrap(err, "failed to write go.sum") } @@ -1494,6 +1455,32 @@ func getK8sPackageInfo(pkg string) (group, kind, version string) { } } +// k8sSyntheticGroups maps the synthetic group labels getK8sPackageInfo returns +// for built-in Kubernetes packages to the real API group their types belong to. +// The labels only drive the generated directory layout; the types themselves are +// all in the core (empty) group, so registering them with a scheme under the +// label would produce a GVK that disagrees with the type's own apiVersion. Only +// autoscaling has a label that is also a real group, so it is absent here. +// +//nolint:gochecknoglobals // This is a constant. +var k8sSyntheticGroups = map[string]string{ + "meta.core.k8s.io": "", + "runtime.k8s.io": "", + "core.k8s.io": "", + "util.k8s.io": "", + "resource.k8s.io": "", +} + +// apiGroup returns the Kubernetes API group to register a package's types +// under. Groups that came from a CRD are already real API groups and pass +// through unchanged. +func apiGroup(group string) string { + if actual, ok := k8sSyntheticGroups[group]; ok { + return actual + } + return group +} + // generateModelsWithGVK generates models for schemas with GVK information. func generateModelsWithGVK(openAPISpecs []*spec3.OpenAPI, schemaFS afero.Fs, runtimeObjects bool) error { for _, openAPISpec := range openAPISpecs { diff --git a/internal/schemas/generator/interface_test.go b/internal/schemas/generator/interface_test.go index adf864f6..8f19e1e5 100644 --- a/internal/schemas/generator/interface_test.go +++ b/internal/schemas/generator/interface_test.go @@ -39,6 +39,50 @@ func TestAllLanguagesMatchesAPI(t *testing.T) { } } +func TestAllLanguagesWithGoRuntimeObjects(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + reason string + opts []Option + want bool + }{ + "OffByDefault": { + reason: "runtime.Object generation is opt-in", + want: false, + }, + "Disabled": { + reason: "an explicitly disabled flag leaves the Go generator alone", + opts: []Option{WithGoRuntimeObjects(false)}, + want: false, + }, + "Enabled": { + reason: "the option reaches the Go generator, which is what emits the code", + opts: []Option{WithGoRuntimeObjects(true)}, + want: true, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + var got *goGenerator + for _, g := range AllLanguages(tc.opts...) { + if gg, ok := g.(*goGenerator); ok { + got = gg + } + } + if got == nil { + t.Fatal("AllLanguages did not return a Go generator") + } + if got.runtimeObjects != tc.want { + t.Errorf("runtimeObjects = %v, want %v (%s)", got.runtimeObjects, tc.want, tc.reason) + } + }) + } +} + func TestFilter(t *testing.T) { t.Parallel() diff --git a/internal/schemas/generator/runtimeobject_compilegate_test.go b/internal/schemas/generator/runtimeobject_compilegate_test.go new file mode 100644 index 00000000..39430da4 --- /dev/null +++ b/internal/schemas/generator/runtimeobject_compilegate_test.go @@ -0,0 +1,233 @@ +//go:build compilegate + +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// The tests in this file are the real correctness gate for the generated +// DeepCopy / runtime.Object code: they materialize the generated models module +// on disk and shell out to the Go toolchain to build and test it, which catches +// codegen bugs that parse cleanly but don't type-check. +// +// They need to resolve the generated module's dependencies, so they need network +// access and a writable module cache. Our unit tests run inside the Nix sandbox +// (see nix/checks.nix), which has neither: modules come from gomod2nix, and the +// generated module pins the apimachinery version the Go function template uses, +// which is not the version this repository depends on. Rather than skip at +// runtime — which reports as a test that ran — they are behind a build tag, so +// it's explicit that a plain `go test ./...` does not cover them: +// +// go test -tags compilegate ./internal/schemas/generator/... +// +// golangci-lint builds this file (see build-tags in .golangci.yml) so it can't +// rot silently. + +package generator + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/spf13/afero" +) + +// roMaterialize writes every file in fs out to dir on disk. +func roMaterialize(t *testing.T, fs afero.Fs, dir string) { + t.Helper() + err := afero.Walk(fs, "", func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + bs, err := afero.ReadFile(fs, path) + if err != nil { + return err + } + dst := filepath.Join(dir, path) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + return os.WriteFile(dst, bs, 0o644) + }) + if err != nil { + t.Fatalf("failed to materialize generated FS: %v", err) + } +} + +// resolveGeneratedModuleDeps runs `go mod download` in the generated module, so +// the compile gate fails rather than silently passing when the go.mod and go.sum +// we generate are broken or have drifted. +func resolveGeneratedModuleDeps(t *testing.T, modelsDir string) { + t.Helper() + cmd := exec.CommandContext(t.Context(), "go", "mod", "download") + cmd.Dir = modelsDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to resolve generated module dependencies: %v\n%s", err, out) + } +} + +// TestGeneratedRuntimeObjectsCompile materializes the generated module (flag on), +// adds a consumer that registers the types in a runtime.Scheme and exercises an +// accessor through the runtime.Object interface, and compiles the whole module. +func TestGeneratedRuntimeObjectsCompile(t *testing.T) { + inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataFS}, "testdata") + schemaFS, err := goGenerator{runtimeObjects: true}.GenerateFromCRD(t.Context(), inputFS, nil) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + roMaterialize(t, schemaFS, dir) + + // A behavioral test inside the generated module: it compiles the whole + // module (build gate) and asserts runtime.Object satisfaction, AddToScheme + // GVK round-tripping, SetGroupVersionKind writing the typed fields, and + // DeepCopy independence. + consumer := `package consumer + +import ( + "testing" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + v1alpha1 "dev.crossplane.io/models/co/acme/platform/v1alpha1" +) + +func TestGeneratedRuntimeObject(t *testing.T) { + var _ runtime.Object = &v1alpha1.XAccountScaffold{} + var _ runtime.Object = &v1alpha1.XAccountScaffoldList{} + + s := runtime.NewScheme() + if err := v1alpha1.AddToScheme(s); err != nil { + t.Fatal(err) + } + gvks, _, err := s.ObjectKinds(&v1alpha1.XAccountScaffold{}) + if err != nil { + t.Fatalf("ObjectKinds: %v", err) + } + if len(gvks) == 0 || gvks[0].Kind != "XAccountScaffold" { + t.Fatalf("unexpected GVKs: %v", gvks) + } + + o := &v1alpha1.XAccountScaffold{} + o.GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{ + Group: "platform.acme.co", Version: "v1alpha1", Kind: "XAccountScaffold", + }) + if o.APIVersion == nil || string(*o.APIVersion) != "platform.acme.co/v1alpha1" { + t.Fatalf("APIVersion not set by SetGroupVersionKind: %v", o.APIVersion) + } + + ptr := func(s string) *string { return &s } + + // Scalar pointer independence. + orig := &v1alpha1.XAccountScaffoldSpecParameters{Name: ptr("a")} + cp := orig.DeepCopy() + *cp.Name = "b" + if *orig.Name != "a" { + t.Fatalf("DeepCopy (*string) not independent: original mutated to %q", *orig.Name) + } + + // Slice-of-structs independence (exercises writeSliceCopy). + spec := &v1alpha1.XAccountScaffoldSpec{ + ResourceRefs: &[]v1alpha1.XAccountScaffoldSpecResourceRefsItem{{Name: ptr("a")}}, + } + specCopy := spec.DeepCopy() + *(*specCopy.ResourceRefs)[0].Name = "b" + if *(*spec.ResourceRefs)[0].Name != "a" { + t.Fatalf("DeepCopy (*[]struct) not independent: original mutated to %q", *(*spec.ResourceRefs)[0].Name) + } + + // Map independence (exercises writeMapCopy). + sel := &v1alpha1.XAccountScaffoldSpecCompositionSelector{ + MatchLabels: &map[string]string{"k": "v"}, + } + selCopy := sel.DeepCopy() + (*selCopy.MatchLabels)["k"] = "x" + if (*sel.MatchLabels)["k"] != "v" { + t.Fatalf("DeepCopy (*map) not independent: original mutated to %q", (*sel.MatchLabels)["k"]) + } +} +` + consumerDir := filepath.Join(dir, "models", "consumer") + if err := os.MkdirAll(consumerDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(consumerDir, "consumer_test.go"), []byte(consumer), 0o644); err != nil { + t.Fatal(err) + } + + modelsDir := filepath.Join(dir, "models") + + resolveGeneratedModuleDeps(t, modelsDir) + + // `go test ./...` builds every generated package and runs the behavioral + // test above. + cmd := exec.CommandContext(t.Context(), "go", "test", "./...") + cmd.Dir = modelsDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("generated runtime.Object models failed to build/test: %v\n%s", err, out) + } +} + +// TestGenerateFromOpenAPIRuntimeObjectsCompile exercises the OpenAPI generation +// path (the shared k8s and GVK packages, which include union and intstr types) +// with the feature on, and compiles the result. +func TestGenerateFromOpenAPIRuntimeObjectsCompile(t *testing.T) { + inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataJSONFS}, "testdata") + schemaFS, err := goGenerator{runtimeObjects: true}.GenerateFromOpenAPI(t.Context(), inputFS, nil) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + roMaterialize(t, schemaFS, dir) + modelsDir := filepath.Join(dir, "models") + + resolveGeneratedModuleDeps(t, modelsDir) + + cmd := exec.CommandContext(t.Context(), "go", "build", "./...") + cmd.Dir = modelsDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("generated OpenAPI runtime.Object models failed to compile: %v\n%s", err, out) + } +} + +// TestGeneratedModelsCompileWithoutRuntimeObjects is the flag-off counterpart: +// we write the same go.mod and go.sum either way, so the generated module must +// still build when the runtime.Object code isn't there to use apimachinery. +func TestGeneratedModelsCompileWithoutRuntimeObjects(t *testing.T) { + inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataFS}, "testdata") + schemaFS, err := goGenerator{}.GenerateFromCRD(t.Context(), inputFS, nil) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + roMaterialize(t, schemaFS, dir) + modelsDir := filepath.Join(dir, "models") + + resolveGeneratedModuleDeps(t, modelsDir) + + cmd := exec.CommandContext(t.Context(), "go", "build", "./...") + cmd.Dir = modelsDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("generated models failed to compile with the feature off: %v\n%s", err, out) + } +} diff --git a/internal/schemas/generator/runtimeobject_integration_test.go b/internal/schemas/generator/runtimeobject_integration_test.go index 01f7e8aa..733001db 100644 --- a/internal/schemas/generator/runtimeobject_integration_test.go +++ b/internal/schemas/generator/runtimeobject_integration_test.go @@ -17,55 +17,12 @@ limitations under the License. package generator import ( - "os" - "os/exec" - "path/filepath" + "strings" "testing" "github.com/spf13/afero" ) -// resolveGeneratedModuleDeps runs `go mod download` in the generated module so -// the compile gate fails (rather than silently passing) when generated module -// dependencies are broken or drifting. Set CROSSPLANE_SKIP_COMPILE_GATE=1 to -// opt out for offline/local development; CI leaves it unset so broken deps fail. -func resolveGeneratedModuleDeps(t *testing.T, modelsDir string) { - t.Helper() - if os.Getenv("CROSSPLANE_SKIP_COMPILE_GATE") != "" { - t.Skip("CROSSPLANE_SKIP_COMPILE_GATE set; skipping compile gate") - } - cmd := exec.CommandContext(t.Context(), "go", "mod", "download") - cmd.Dir = modelsDir - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to resolve generated module dependencies: %v\n%s", err, out) - } -} - -// roMaterialize writes every file in fs out to dir on disk. -func roMaterialize(t *testing.T, fs afero.Fs, dir string) { - t.Helper() - err := afero.Walk(fs, "", func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - return nil - } - bs, err := afero.ReadFile(fs, path) - if err != nil { - return err - } - dst := filepath.Join(dir, path) - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { - return err - } - return os.WriteFile(dst, bs, 0o644) - }) - if err != nil { - t.Fatalf("failed to materialize generated FS: %v", err) - } -} - func TestGenerateFromCRDRuntimeObjectsArtifacts(t *testing.T) { inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataFS}, "testdata") schemaFS, err := goGenerator{runtimeObjects: true}.GenerateFromCRD(t.Context(), inputFS, nil) @@ -93,18 +50,14 @@ func TestGenerateFromCRDRuntimeObjectsArtifacts(t *testing.T) { t.Error("nested struct should not implement runtime.Object") } - // A groupversion_info.go is generated for the package. - if exists, _ := afero.Exists(schemaFS, "models/co/acme/platform/v1alpha1/groupversion_info.go"); !exists { - t.Error("expected groupversion_info.go for the package") - } - - // go.mod gains the apimachinery dependency. - mod, err := afero.ReadFile(schemaFS, "models/go.mod") + // A groupversion_info.go is generated for the package, carrying the CRD's + // real API group. + gvi, err := afero.ReadFile(schemaFS, "models/co/acme/platform/v1alpha1/groupversion_info.go") if err != nil { t.Fatal(err) } - if !contains(string(mod), "k8s.io/apimachinery") { - t.Error("expected k8s.io/apimachinery in generated go.mod when feature is on") + if want := `GroupVersion = schema.GroupVersion{Group: "platform.acme.co", Version: "v1alpha1"}`; !strings.Contains(string(gvi), want) { + t.Errorf("expected %q in generated groupversion_info.go, got:\n%s", want, gvi) } } @@ -125,159 +78,69 @@ func TestGenerateFromCRDNoRuntimeObjectsByDefault(t *testing.T) { if exists, _ := afero.Exists(schemaFS, "models/co/acme/platform/v1alpha1/groupversion_info.go"); exists { t.Error("groupversion_info.go must not exist when feature is disabled") } - mod, err := afero.ReadFile(schemaFS, "models/go.mod") - if err != nil { - t.Fatal(err) - } - if contains(string(mod), "k8s.io/apimachinery") { - t.Error("go.mod must not reference apimachinery when feature is disabled") - } -} - -func contains(s, sub string) bool { - return len(s) >= len(sub) && (func() bool { - for i := 0; i+len(sub) <= len(s); i++ { - if s[i:i+len(sub)] == sub { - return true - } - } - return false - })() -} -// TestGeneratedRuntimeObjectsCompile is the real correctness gate: it -// materializes the generated module (flag on), adds a consumer that registers -// the types in a runtime.Scheme and exercises an accessor through the -// runtime.Object interface, and compiles the whole module. This is what catches -// DeepCopyInto codegen bugs that parse cleanly but don't type-check. -func TestGeneratedRuntimeObjectsCompile(t *testing.T) { - if _, err := exec.LookPath("go"); err != nil { - t.Skip("go toolchain not available; skipping compile gate") - } - - inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataFS}, "testdata") - schemaFS, err := goGenerator{runtimeObjects: true}.GenerateFromCRD(t.Context(), inputFS, nil) + // The module files don't depend on the feature flag: we write one go.mod and + // go.sum either way, so a generated function resolves the same dependency + // graph whether or not runtime.Object generation is on. + mod, err := afero.ReadFile(schemaFS, "models/go.mod") if err != nil { t.Fatal(err) } - - dir := t.TempDir() - roMaterialize(t, schemaFS, dir) - - // A behavioral test inside the generated module: it compiles the whole - // module (build gate) and asserts runtime.Object satisfaction, AddToScheme - // GVK round-tripping, SetGroupVersionKind writing the typed fields, and - // DeepCopy independence. - consumer := `package consumer - -import ( - "testing" - - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - - v1alpha1 "dev.crossplane.io/models/co/acme/platform/v1alpha1" -) - -func TestGeneratedRuntimeObject(t *testing.T) { - var _ runtime.Object = &v1alpha1.XAccountScaffold{} - var _ runtime.Object = &v1alpha1.XAccountScaffoldList{} - - s := runtime.NewScheme() - if err := v1alpha1.AddToScheme(s); err != nil { - t.Fatal(err) + if string(mod) != goModContents { + t.Errorf("generated go.mod differs from goModContents:\n%s", mod) } - gvks, _, err := s.ObjectKinds(&v1alpha1.XAccountScaffold{}) + sum, err := afero.ReadFile(schemaFS, "models/go.sum") if err != nil { - t.Fatalf("ObjectKinds: %v", err) - } - if len(gvks) == 0 || gvks[0].Kind != "XAccountScaffold" { - t.Fatalf("unexpected GVKs: %v", gvks) - } - - o := &v1alpha1.XAccountScaffold{} - o.GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{ - Group: "platform.acme.co", Version: "v1alpha1", Kind: "XAccountScaffold", - }) - if o.APIVersion == nil || string(*o.APIVersion) != "platform.acme.co/v1alpha1" { - t.Fatalf("APIVersion not set by SetGroupVersionKind: %v", o.APIVersion) - } - - ptr := func(s string) *string { return &s } - - // Scalar pointer independence. - orig := &v1alpha1.XAccountScaffoldSpecParameters{Name: ptr("a")} - cp := orig.DeepCopy() - *cp.Name = "b" - if *orig.Name != "a" { - t.Fatalf("DeepCopy (*string) not independent: original mutated to %q", *orig.Name) - } - - // Slice-of-structs independence (exercises writeSliceCopy). - spec := &v1alpha1.XAccountScaffoldSpec{ - ResourceRefs: &[]v1alpha1.XAccountScaffoldSpecResourceRefsItem{{Name: ptr("a")}}, - } - specCopy := spec.DeepCopy() - *(*specCopy.ResourceRefs)[0].Name = "b" - if *(*spec.ResourceRefs)[0].Name != "a" { - t.Fatalf("DeepCopy (*[]struct) not independent: original mutated to %q", *(*spec.ResourceRefs)[0].Name) - } - - // Map independence (exercises writeMapCopy). - sel := &v1alpha1.XAccountScaffoldSpecCompositionSelector{ - MatchLabels: &map[string]string{"k": "v"}, - } - selCopy := sel.DeepCopy() - (*selCopy.MatchLabels)["k"] = "x" - if (*sel.MatchLabels)["k"] != "v" { - t.Fatalf("DeepCopy (*map) not independent: original mutated to %q", (*sel.MatchLabels)["k"]) - } -} -` - consumerDir := filepath.Join(dir, "models", "consumer") - if err := os.MkdirAll(consumerDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(consumerDir, "consumer_test.go"), []byte(consumer), 0o644); err != nil { t.Fatal(err) } - - modelsDir := filepath.Join(dir, "models") - - resolveGeneratedModuleDeps(t, modelsDir) - - // `go test ./...` builds every generated package and runs the behavioral - // test above. - cmd := exec.CommandContext(t.Context(), "go", "test", "./...") - cmd.Dir = modelsDir - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("generated runtime.Object models failed to build/test: %v\n%s", err, out) + if string(sum) != goSumContents { + t.Errorf("generated go.sum differs from goSumContents") } } -// TestGenerateFromOpenAPIRuntimeObjectsCompile exercises the OpenAPI generation -// path (the shared k8s and GVK packages, which include union and intstr types) -// with the feature on, and compiles the result. -func TestGenerateFromOpenAPIRuntimeObjectsCompile(t *testing.T) { - if _, err := exec.LookPath("go"); err != nil { - t.Skip("go toolchain not available; skipping compile gate") - } - +// TestGenerateFromOpenAPIBuiltInGroupVersions checks the API groups we register +// built-in Kubernetes types under. The group labels the generator uses for these +// packages are synthetic — they only drive the directory layout — so registering +// a type under the label would produce a GVK that disagrees with the type's own +// apiVersion. See apiGroup. +func TestGenerateFromOpenAPIBuiltInGroupVersions(t *testing.T) { inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataJSONFS}, "testdata") schemaFS, err := goGenerator{runtimeObjects: true}.GenerateFromOpenAPI(t.Context(), inputFS, nil) if err != nil { t.Fatal(err) } - dir := t.TempDir() - roMaterialize(t, schemaFS, dir) - modelsDir := filepath.Join(dir, "models") - - resolveGeneratedModuleDeps(t, modelsDir) - - cmd := exec.CommandContext(t.Context(), "go", "build", "./...") - cmd.Dir = modelsDir - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("generated OpenAPI runtime.Object models failed to compile: %v\n%s", err, out) + cases := map[string]struct { + path string + want string + }{ + "CoreV1": { + path: "models/io/k8s/core/v1/groupversion_info.go", + want: `GroupVersion = schema.GroupVersion{Group: "", Version: "v1"}`, + }, + "MetaV1": { + path: "models/io/k8s/core/meta/v1/groupversion_info.go", + want: `GroupVersion = schema.GroupVersion{Group: "", Version: "v1"}`, + }, + "RealGroupIsUnchanged": { + path: "models/io/k8s/authentication/v1/groupversion_info.go", + want: `GroupVersion = schema.GroupVersion{Group: "authentication.k8s.io", Version: "v1"}`, + }, + "UnsuffixedRealGroupIsUnchanged": { + path: "models/io/k8s/autoscaling/v1/groupversion_info.go", + want: `GroupVersion = schema.GroupVersion{Group: "autoscaling", Version: "v1"}`, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + bs, err := afero.ReadFile(schemaFS, tc.path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(bs), tc.want) { + t.Errorf("expected %q in %s, got:\n%s", tc.want, tc.path, bs) + } + }) } } From a304b3ea601f61015eba556d3805fc0db17ca235 Mon Sep 17 00:00:00 2001 From: Erik Miller Date: Tue, 28 Jul 2026 08:02:21 -0700 Subject: [PATCH 5/7] generator: assert built-in group versions in one scheme The compile gate for the OpenAPI path only built the generated module, so it couldn't catch the class of bug the API group fix is about: several built-in packages now share the core (empty) group, and runtime.Scheme.AddKnownTypes panics if two Go types claim the same GVK. Register every generated built-in package in a single scheme and assert each type is known by the GVK its own apiVersion reports. Reverting the group mapping fails this test. Signed-off-by: Erik Miller --- .../runtimeobject_compilegate_test.go | 73 ++++++++++++++++++- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/internal/schemas/generator/runtimeobject_compilegate_test.go b/internal/schemas/generator/runtimeobject_compilegate_test.go index 39430da4..967a1265 100644 --- a/internal/schemas/generator/runtimeobject_compilegate_test.go +++ b/internal/schemas/generator/runtimeobject_compilegate_test.go @@ -188,7 +188,8 @@ func TestGeneratedRuntimeObject(t *testing.T) { // TestGenerateFromOpenAPIRuntimeObjectsCompile exercises the OpenAPI generation // path (the shared k8s and GVK packages, which include union and intstr types) -// with the feature on, and compiles the result. +// with the feature on, compiles the result, and registers every generated +// built-in package in one scheme. func TestGenerateFromOpenAPIRuntimeObjectsCompile(t *testing.T) { inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataJSONFS}, "testdata") schemaFS, err := goGenerator{runtimeObjects: true}.GenerateFromOpenAPI(t.Context(), inputFS, nil) @@ -198,14 +199,80 @@ func TestGenerateFromOpenAPIRuntimeObjectsCompile(t *testing.T) { dir := t.TempDir() roMaterialize(t, schemaFS, dir) + + // Registering every built-in package in a single scheme is the check that + // the API groups we write into groupversion_info.go are right: + // AddKnownTypes panics if two Go types claim the same GVK, which is what + // would happen if two packages sharing the core (empty) group also shared a + // kind name. + consumer := `package consumer + +import ( + "testing" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + authnv1 "dev.crossplane.io/models/io/k8s/authentication/v1" + autoscalingv1 "dev.crossplane.io/models/io/k8s/autoscaling/v1" + metav1 "dev.crossplane.io/models/io/k8s/core/meta/v1" + corev1 "dev.crossplane.io/models/io/k8s/core/v1" + policyv1 "dev.crossplane.io/models/io/k8s/policy/v1" +) + +func TestBuiltInGroupVersions(t *testing.T) { + s := runtime.NewScheme() + for _, add := range []func(*runtime.Scheme) error{ + corev1.AddToScheme, + metav1.AddToScheme, + authnv1.AddToScheme, + autoscalingv1.AddToScheme, + policyv1.AddToScheme, + } { + if err := add(s); err != nil { + t.Fatal(err) + } + } + + // Built-in types must be known by the GVK their own apiVersion reports, not + // by the synthetic group label the generator uses for the directory layout. + cases := map[string]struct { + obj runtime.Object + want schema.GroupVersionKind + }{ + "CoreV1": {obj: &corev1.Pod{}, want: schema.GroupVersionKind{Version: "v1", Kind: "Pod"}}, + "MetaV1": {obj: &metav1.Status{}, want: schema.GroupVersionKind{Version: "v1", Kind: "Status"}}, + "Autoscaling": {obj: &autoscalingv1.TokenRequest{}, want: schema.GroupVersionKind{Group: "autoscaling", Version: "v1", Kind: "TokenRequest"}}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + gvks, _, err := s.ObjectKinds(tc.obj) + if err != nil { + t.Fatalf("ObjectKinds: %v", err) + } + if len(gvks) != 1 || gvks[0] != tc.want { + t.Errorf("ObjectKinds = %v, want [%v]", gvks, tc.want) + } + }) + } +} +` + consumerDir := filepath.Join(dir, "models", "consumer") + if err := os.MkdirAll(consumerDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(consumerDir, "consumer_test.go"), []byte(consumer), 0o644); err != nil { + t.Fatal(err) + } + modelsDir := filepath.Join(dir, "models") resolveGeneratedModuleDeps(t, modelsDir) - cmd := exec.CommandContext(t.Context(), "go", "build", "./...") + cmd := exec.CommandContext(t.Context(), "go", "test", "./...") cmd.Dir = modelsDir if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("generated OpenAPI runtime.Object models failed to compile: %v\n%s", err, out) + t.Fatalf("generated OpenAPI runtime.Object models failed to build/test: %v\n%s", err, out) } } From a462ba6c428b36f7dddccaab138d15355fbc1b3d Mon Sep 17 00:00:00 2001 From: Erik Miller Date: Tue, 28 Jul 2026 08:10:21 -0700 Subject: [PATCH 6/7] generator: carry the API group alongside the package layout group Replaces the group label -> API group lookup added in the previous commit. The labels the generator uses for built-in Kubernetes packages only drive the directory layout, so reverse-mapping them was fragile in both directions: - It missed the CRD path, which labels the built-in metav1 package `meta.k8s.io` rather than the OpenAPI path's `meta.core.k8s.io` (the two paths deliberately emit it to different directories), so that package was registering its types under `meta.k8s.io/v1`. - `meta.k8s.io` and `resource.k8s.io` are also real Kubernetes API groups, so a CRD in either would have had its group rewritten to "". Thread a goPackage through writeGoCode instead, holding the layout group and the API group separately. Every producer states which it has: CRDs and OpenAPI schemas with an x-kubernetes-group-version-kind extension have a real group and use packageInGroup; the built-in packages set a synthetic layout label and the real API group (core, i.e. empty) side by side. Signed-off-by: Erik Miller --- internal/schemas/generator/go.go | 123 ++++++++++-------- .../runtimeobject_integration_test.go | 25 ++-- 2 files changed, 84 insertions(+), 64 deletions(-) diff --git a/internal/schemas/generator/go.go b/internal/schemas/generator/go.go index 4e4de443..674f2021 100644 --- a/internal/schemas/generator/go.go +++ b/internal/schemas/generator/go.go @@ -304,7 +304,8 @@ func (g goGenerator) GenerateFromCRD(_ context.Context, fromFS afero.Fs, _ runne return nil, err } - if err := writeGoCode(schemaFS, oapi.crd.Spec.Group, oapi.crd.Spec.Names.Kind, oapi.version, code); err != nil { + crdPkg := packageInGroup(oapi.crd.Spec.Group, oapi.crd.Spec.Names.Kind, oapi.version) + if err := writeGoCode(schemaFS, crdPkg, code); err != nil { return nil, err } } @@ -327,17 +328,22 @@ func (g goGenerator) generateSharedK8sPackage(schemaFS afero.Fs, pkg string, sch }, } - // Determine the group, kind, and version from the package name - var group, kind, version string + // Determine the package layout, API group and version from the package name. + // These layout labels differ from the ones getK8sPackageInfo returns: the CRD + // path puts meta/v1 at io/k8s/meta/v1, the OpenAPI path at + // io/k8s/core/meta/v1. The API group is the same either way — metav1 types + // are in the core (empty) group. + var goPkg goPackage switch pkg { case k8sPkgMetaV1: - group = "meta.k8s.io" - kind = "meta" - version = "v1" + goPkg = goPackage{group: "meta.k8s.io", kind: "meta", version: "v1"} case k8sPkgAutoscalingV1: - group = k8sPkgNameAutoscaling - kind = k8sPkgNameAutoscaling - version = "v1" + goPkg = goPackage{ + group: k8sPkgNameAutoscaling, + apiGroup: k8sPkgNameAutoscaling, + kind: k8sPkgNameAutoscaling, + version: "v1", + } } // For K8s packages that reference meta.v1, we need to use the correct @@ -351,7 +357,7 @@ func (g goGenerator) generateSharedK8sPackage(schemaFS afero.Fs, pkg string, sch refMutator = goReferenceK8sTypesForCRDs } - code, err := generateGo(pkgSpec, version, + code, err := generateGo(pkgSpec, goPkg.version, goRenameTypes, goRenameEnums, goReplaceNumberWithInt, @@ -380,7 +386,7 @@ func (g goGenerator) generateSharedK8sPackage(schemaFS afero.Fs, pkg string, sch return err } - return writeGoCode(schemaFS, group, kind, version, code) + return writeGoCode(schemaFS, goPkg, code) } type goOpenAPI struct { @@ -601,8 +607,31 @@ func goExtractK8sSchemas(s *spec3.OpenAPI) map[string]map[string]*spec.Schema { return ret } -func writeGoCode(schemaFS afero.Fs, group, kind, version, code string) error { - goPath := filepath.Join("models", goSchemaPath(group, kind, version)) +// goPackage identifies a generated models package. +type goPackage struct { + // group drives the generated directory layout, see goSchemaPath. For the + // built-in Kubernetes packages it is a synthetic label rather than a real + // API group. + group string + + // apiGroup is the Kubernetes API group the package's types belong to, i.e. + // the group in their apiVersion. It is what we register them under in a + // runtime.Scheme, so it must not be a synthetic label. + apiGroup string + + kind string + version string +} + +// packageInGroup returns the goPackage for types in a real API group, where the +// group serves as both the layout group and the API group. That covers CRDs, and +// the OpenAPI schemas carrying an x-kubernetes-group-version-kind extension. +func packageInGroup(group, kind, version string) goPackage { + return goPackage{group: group, apiGroup: group, kind: kind, version: version} +} + +func writeGoCode(schemaFS afero.Fs, pkg goPackage, code string) error { + goPath := filepath.Join("models", goSchemaPath(pkg.group, pkg.kind, pkg.version)) dir := filepath.Dir(goPath) if err := schemaFS.MkdirAll(dir, 0o755); err != nil { return errors.Wrap(err, "failed to create directory for schemas") @@ -621,7 +650,7 @@ func writeGoCode(schemaFS afero.Fs, group, kind, version, code string) error { // runtime.Object feature is on), emit a groupversion_info.go for the package // defining GroupVersion/SchemeBuilder/AddToScheme. Written once per dir. if strings.Contains(code, "SchemeBuilder.Register(") { - if err := writeGroupVersionInfo(schemaFS, dir, apiGroup(group), version); err != nil { + if err := writeGroupVersionInfo(schemaFS, dir, pkg.apiGroup, pkg.version); err != nil { return err } } @@ -632,7 +661,7 @@ func writeGoCode(schemaFS afero.Fs, group, kind, version, code string) error { // writeGroupVersionInfo writes a groupversion_info.go into dir defining the // package's GroupVersion, SchemeBuilder and AddToScheme. It is a no-op if the // file already exists, so a package with multiple kinds gets a single copy. -// group must be the real API group; see apiGroup. +// group must be a real API group, not a layout label; see goPackage. func writeGroupVersionInfo(schemaFS afero.Fs, dir, group, version string) error { path := filepath.Join(dir, "groupversion_info.go") if exists, err := afero.Exists(schemaFS, path); err != nil { @@ -1400,10 +1429,10 @@ func generateK8sPackageCode(pkg string, schemas map[string]*spec.Schema, schemaF }, } - // Determine the group, kind, and version from the package name - group, kind, version := getK8sPackageInfo(pkg) + // Determine the package layout, API group and version from the package name + goPkg := getK8sPackageInfo(pkg) - code, err := generateGo(pkgSpec, version, + code, err := generateGo(pkgSpec, goPkg.version, goRenameTypes, goRenameEnums, goReplaceNumberWithInt, @@ -1432,53 +1461,37 @@ func generateK8sPackageCode(pkg string, schemas map[string]*spec.Schema, schemaF return err } - return writeGoCode(schemaFS, group, kind, version, code) + return writeGoCode(schemaFS, goPkg, code) } -// getK8sPackageInfo returns group, kind, and version for a K8s package. -func getK8sPackageInfo(pkg string) (group, kind, version string) { +// getK8sPackageInfo returns the generated package for a built-in Kubernetes +// package. The group labels below only drive the directory layout; apimachinery +// and core/v1 types are all in the core (empty) API group, so registering them +// under their label would produce a GVK that disagrees with the GVK the type's +// own apiVersion reports. Only autoscaling has a label that is also its API +// group. +func getK8sPackageInfo(pkg string) goPackage { switch pkg { case k8sPkgMetaV1: - return "meta.core.k8s.io", "meta", "v1" + return goPackage{group: "meta.core.k8s.io", kind: "meta", version: "v1"} case k8sPkgRuntime: - return "runtime.k8s.io", "runtime", "v1" + return goPackage{group: "runtime.k8s.io", kind: "runtime", version: "v1"} case k8sPkgCoreV1: - return "core.k8s.io", "core", "v1" + return goPackage{group: "core.k8s.io", kind: "core", version: "v1"} case k8sPkgIntStr: - return "util.k8s.io", "intstr", "v1" + return goPackage{group: "util.k8s.io", kind: "intstr", version: "v1"} case k8sPkgResource: - return "resource.k8s.io", "resource", "v1" + return goPackage{group: "resource.k8s.io", kind: "resource", version: "v1"} case k8sPkgAutoscalingV1: - return k8sPkgNameAutoscaling, k8sPkgNameAutoscaling, "v1" + return goPackage{ + group: k8sPkgNameAutoscaling, + apiGroup: k8sPkgNameAutoscaling, + kind: k8sPkgNameAutoscaling, + version: "v1", + } default: - return "", "", "" - } -} - -// k8sSyntheticGroups maps the synthetic group labels getK8sPackageInfo returns -// for built-in Kubernetes packages to the real API group their types belong to. -// The labels only drive the generated directory layout; the types themselves are -// all in the core (empty) group, so registering them with a scheme under the -// label would produce a GVK that disagrees with the type's own apiVersion. Only -// autoscaling has a label that is also a real group, so it is absent here. -// -//nolint:gochecknoglobals // This is a constant. -var k8sSyntheticGroups = map[string]string{ - "meta.core.k8s.io": "", - "runtime.k8s.io": "", - "core.k8s.io": "", - "util.k8s.io": "", - "resource.k8s.io": "", -} - -// apiGroup returns the Kubernetes API group to register a package's types -// under. Groups that came from a CRD are already real API groups and pass -// through unchanged. -func apiGroup(group string) string { - if actual, ok := k8sSyntheticGroups[group]; ok { - return actual + return goPackage{} } - return group } // generateModelsWithGVK generates models for schemas with GVK information. @@ -1597,5 +1610,5 @@ func generateGVKGroupCode(gvkKey string, schemas map[string]*spec.Schema, openAP return err } - return writeGoCode(schemaFS, group, kind, version, code) + return writeGoCode(schemaFS, packageInGroup(group, kind, version), code) } diff --git a/internal/schemas/generator/runtimeobject_integration_test.go b/internal/schemas/generator/runtimeobject_integration_test.go index 733001db..7b1c26d3 100644 --- a/internal/schemas/generator/runtimeobject_integration_test.go +++ b/internal/schemas/generator/runtimeobject_integration_test.go @@ -50,14 +50,21 @@ func TestGenerateFromCRDRuntimeObjectsArtifacts(t *testing.T) { t.Error("nested struct should not implement runtime.Object") } - // A groupversion_info.go is generated for the package, carrying the CRD's - // real API group. - gvi, err := afero.ReadFile(schemaFS, "models/co/acme/platform/v1alpha1/groupversion_info.go") - if err != nil { - t.Fatal(err) - } - if want := `GroupVersion = schema.GroupVersion{Group: "platform.acme.co", Version: "v1alpha1"}`; !strings.Contains(string(gvi), want) { - t.Errorf("expected %q in generated groupversion_info.go, got:\n%s", want, gvi) + // A groupversion_info.go is generated for each package, carrying the real API + // group: the CRD's own group, and the core (empty) group for the built-in + // metav1 package the CRD path emits alongside it. + gvis := map[string]string{ + "models/co/acme/platform/v1alpha1/groupversion_info.go": `GroupVersion = schema.GroupVersion{Group: "platform.acme.co", Version: "v1alpha1"}`, + "models/io/k8s/meta/v1/groupversion_info.go": `GroupVersion = schema.GroupVersion{Group: "", Version: "v1"}`, + } + for path, want := range gvis { + gvi, err := afero.ReadFile(schemaFS, path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(gvi), want) { + t.Errorf("expected %q in %s, got:\n%s", want, path, gvi) + } } } @@ -102,7 +109,7 @@ func TestGenerateFromCRDNoRuntimeObjectsByDefault(t *testing.T) { // built-in Kubernetes types under. The group labels the generator uses for these // packages are synthetic — they only drive the directory layout — so registering // a type under the label would produce a GVK that disagrees with the type's own -// apiVersion. See apiGroup. +// apiVersion. See goPackage. func TestGenerateFromOpenAPIBuiltInGroupVersions(t *testing.T) { inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataJSONFS}, "testdata") schemaFS, err := goGenerator{runtimeObjects: true}.GenerateFromOpenAPI(t.Context(), inputFS, nil) From 9f91e88cf72427809e4c0270a7f2efa39bf084b7 Mon Sep 17 00:00:00 2001 From: Erik Miller Date: Tue, 28 Jul 2026 11:08:58 -0700 Subject: [PATCH 7/7] config: use active voice in the runtime.Object help text Upstream main dropped the `level: error` input from the Vale step, so reviewdog now runs at `-level=info` and Vale warnings fail validate-docs. "so generated types can be registered in a runtime.Scheme" trips write-good.Passive. Verified with Vale 3.14.2 against the crossplane/docs config at the alert level the action uses: 0 errors, 0 warnings. Signed-off-by: Erik Miller --- cmd/crossplane/config/help/config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/crossplane/config/help/config.md b/cmd/crossplane/config/help/config.md index e875dacc..36370153 100644 --- a/cmd/crossplane/config/help/config.md +++ b/cmd/crossplane/config/help/config.md @@ -20,7 +20,7 @@ crossplane config set features.enableAlpha true ``` Generate `runtime.Object` methods and per-package `AddToScheme` helpers on -generated Go models (off by default), so generated types can be registered in a +generated Go models (off by default), so you can register generated types with a `runtime.Scheme`: ```shell