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 8803afb0..36370153 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 you can register generated types with 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..416cd5e7 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 } @@ -156,7 +157,10 @@ func (c *generateCmd) Run(sp terminal.SpinnerPrinter) 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/cmd/crossplane/function/generate_test.go b/cmd/crossplane/function/generate_test.go index d23dade4..94e3f18c 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" ) @@ -292,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 }{ @@ -309,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 { @@ -319,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)) + 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/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..674f2021 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" @@ -69,15 +70,37 @@ 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 +go 1.24.0 -require github.com/oapi-codegen/runtime v1.1.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/google/uuid v1.4.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 ) ` @@ -90,9 +113,28 @@ github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvF 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/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= @@ -100,10 +142,62 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN 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= +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, @@ -138,14 +232,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 @@ -181,66 +279,7 @@ func (goGenerator) GenerateFromCRD(_ context.Context, fromFS afero.Fs, _ runner. // 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 - } - - if err := writeGoCode(schemaFS, group, kind, version, code); err != nil { + if err := g.generateSharedK8sPackage(schemaFS, pkg, schemas); err != nil { return nil, err } } @@ -260,7 +299,13 @@ func (goGenerator) GenerateFromCRD(_ context.Context, fromFS afero.Fs, _ runner. return nil, err } - if err := writeGoCode(schemaFS, oapi.crd.Spec.Group, oapi.crd.Spec.Names.Kind, oapi.version, code); err != nil { + code, err = applyRuntimeObjects(code, g.runtimeObjects) + if err != nil { + return nil, err + } + + crdPkg := packageInGroup(oapi.crd.Spec.Group, oapi.crd.Spec.Names.Kind, oapi.version) + if err := writeGoCode(schemaFS, crdPkg, code); err != nil { return nil, err } } @@ -268,6 +313,82 @@ func (goGenerator) GenerateFromCRD(_ context.Context, fromFS afero.Fs, _ runner. 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 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: + goPkg = goPackage{group: "meta.k8s.io", kind: "meta", version: "v1"} + case k8sPkgAutoscalingV1: + goPkg = goPackage{ + group: k8sPkgNameAutoscaling, + apiGroup: 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, goPkg.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, goPkg, code) +} + type goOpenAPI struct { crd *extv1.CustomResourceDefinition version string @@ -486,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") @@ -502,6 +646,63 @@ 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, pkg.apiGroup, pkg.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. +// 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 { + 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 +1289,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 { @@ -1107,12 +1308,12 @@ func (goGenerator) GenerateFromOpenAPI(_ context.Context, fromFS afero.Fs, _ run } // 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,7 +1363,8 @@ func collectOpenAPISpecs(fromFS afero.Fs) ([]*spec3.OpenAPI, error) { return openAPISpecs, err } -// initializeSchemaFS creates and initializes the schema filesystem with go.mod and go.sum. +// initializeSchemaFS creates and initializes the schema filesystem with go.mod +// and go.sum. func initializeSchemaFS() (afero.Fs, error) { schemaFS := afero.NewMemMapFs() if err := schemaFS.Mkdir("models", 0o755); err != nil { @@ -1189,7 +1391,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 +1411,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 +1420,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", @@ -1227,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, @@ -1253,36 +1455,52 @@ func generateK8sPackageCode(pkg string, schemas map[string]*spec.Schema, schemaF return errors.Wrap(err, "failed to remove self imports") } - return writeGoCode(schemaFS, group, kind, version, code) + // 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, 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 "", "", "" + return goPackage{} } } // 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 +1563,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 +1604,11 @@ func generateGVKGroupCode(gvkKey string, schemas map[string]*spec.Schema, openAP return err } - return writeGoCode(schemaFS, group, kind, version, code) + // 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, packageInGroup(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/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.go b/internal/schemas/generator/runtimeobject.go new file mode 100644 index 00000000..1acf5c6e --- /dev/null +++ b/internal/schemas/generator/runtimeobject.go @@ -0,0 +1,442 @@ +/* +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) + 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, 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 +} + +// 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 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 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, 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 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, declType, p, structs) + case *ast.MapType: + writeMapCopy(b, fset, declType, p, structs) + default: + fmt.Fprintf(b, "\t\t*out = new(%s)\n", declType) + if classifyElem(pointee, structs) == 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, 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) == 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 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) == 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_compilegate_test.go b/internal/schemas/generator/runtimeobject_compilegate_test.go new file mode 100644 index 00000000..967a1265 --- /dev/null +++ b/internal/schemas/generator/runtimeobject_compilegate_test.go @@ -0,0 +1,300 @@ +//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, 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) + if err != nil { + t.Fatal(err) + } + + 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", "test", "./...") + cmd.Dir = modelsDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("generated OpenAPI runtime.Object models failed to build/test: %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 new file mode 100644 index 00000000..7b1c26d3 --- /dev/null +++ b/internal/schemas/generator/runtimeobject_integration_test.go @@ -0,0 +1,153 @@ +/* +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 ( + "strings" + "testing" + + "github.com/spf13/afero" +) + +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 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) + } + } +} + +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") + } + + // 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) + } + if string(mod) != goModContents { + t.Errorf("generated go.mod differs from goModContents:\n%s", mod) + } + sum, err := afero.ReadFile(schemaFS, "models/go.sum") + if err != nil { + t.Fatal(err) + } + if string(sum) != goSumContents { + t.Errorf("generated go.sum differs from goSumContents") + } +} + +// 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 goPackage. +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) + } + + 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) + } + }) + } +} diff --git a/internal/schemas/generator/runtimeobject_test.go b/internal/schemas/generator/runtimeobject_test.go new file mode 100644 index 00000000..ad334c82 --- /dev/null +++ b/internal/schemas/generator/runtimeobject_test.go @@ -0,0 +1,150 @@ +/* +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 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\"`" + ` +} + +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\"`" + ` +} +`, + 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 + +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\"`" + ` +} +`, + wantHasRoots: true, + wantMethods: []string{ + "Foo.DeepCopyObject", "Foo.GetObjectKind", "Foo.GroupVersionKind", "Foo.SetGroupVersionKind", + "FooList.DeepCopyObject", "FooList.GetObjectKind", "FooList.GroupVersionKind", "FooList.SetGroupVersionKind", + }, + notMethods: []string{ + "FooSpec.DeepCopyObject", + }, + }, + } + + 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) + } + } + }) + } +}