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") + } +}