diff --git a/cmd/crossplane/config/help/config.md b/cmd/crossplane/config/help/config.md index d6dbe368..a56ae713 100644 --- a/cmd/crossplane/config/help/config.md +++ b/cmd/crossplane/config/help/config.md @@ -26,10 +26,34 @@ you can reach generated resources through interfaces and generics: crossplane config set features.generateGoModelAccessors 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`: +Generate `runtime.Object`, `metav1.Object`, and `metav1.ListInterface` +methods on generated Go models, plus per-package `AddToScheme` helpers (off +by default). These methods let you register generated types with a +`runtime.Scheme` and use them with `sigs.k8s.io/controller-runtime` as +`client.Object` and `client.ObjectList` values, for example with +`client.Client.Get`, `List`, and `Create`: ```shell crossplane config set features.generateGoRuntimeObjects true ``` + +**Breaking change:** This flag changes two things in the generated models. +The `Metadata` field's Go type changes from a mirror struct this tool +generates to the real `k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta` or +`ListMeta` type. A `List` type's `Items` field changes from `*[]Kind` +to `[]Kind`. Both changes let the generated types implement `client.Object` +and `client.ObjectList`. + +Turning on this flag also stops the CLI from generating the local +`io/k8s/meta/v1` (or `io/k8s/core/meta/v1`) mirror package. Every reference +to a `meta/v1` type, including `Time`, `OwnerReference`, `LabelSelector`, +`Condition`, `DeleteOptions`, and `MicroTime`, now points to the real +`k8s.io/apimachinery` package instead. Code that imports the mirror package +directly won't build after you turn this flag on. + +This flag was off by default and wasn't usable as a `client.Object` before +this change, so we don't provide a migration path. Regenerate your models +after upgrading. The schema manager skips regeneration when your +dependency's version hasn't changed, so run +`crossplane dependency clean-cache` first if regenerating doesn't pick up +the new output. diff --git a/internal/config/config.go b/internal/config/config.go index 7c096468..3d33714f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -48,9 +48,11 @@ type Features struct { // interfaces over generated resources. GenerateGoModelAccessors bool `json:"generateGoModelAccessors,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 enables generation of runtime.Object, + // metav1.Object, and metav1.ListInterface methods, plus per-package + // AddToScheme helpers, on generated Go models. Disabled by default; opt + // in to register generated types with a runtime.Scheme and use them as + // sigs.k8s.io/controller-runtime client.Object/client.ObjectList values. GenerateGoRuntimeObjects bool `json:"generateGoRuntimeObjects,omitempty"` } diff --git a/internal/schemas/generator/go.go b/internal/schemas/generator/go.go index c57784e8..e1067ec8 100644 --- a/internal/schemas/generator/go.go +++ b/internal/schemas/generator/go.go @@ -97,8 +97,13 @@ require ( 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 + golang.org/x/net v0.38.0 // indirect + golang.org/x/text v0.23.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect sigs.k8s.io/yaml v1.4.0 // indirect ) @@ -139,6 +144,8 @@ github.com/oapi-codegen/runtime v1.1.0 h1:rJpoNUawn5XTvekgfkvSZr0RqEnoYpFkyvrzfW 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/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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= @@ -280,8 +287,13 @@ func (g goGenerator) GenerateFromCRD(_ context.Context, fromFS afero.Fs, _ runne } } - // Generate separate files for each K8s package + // Generate separate files for each K8s package. meta/v1 resolves to the + // real apimachinery package when runtimeObjects is on (see + // tryReplaceK8sTypeWithMetaPath), so the local mirror would be unused. for pkg, schemas := range k8sSchemasByPackage { + if pkg == k8sPkgMetaV1 && g.runtimeObjects { + continue + } if err := g.generateSharedK8sPackage(schemaFS, pkg, schemas); err != nil { return nil, err } @@ -295,7 +307,7 @@ func (g goGenerator) GenerateFromCRD(_ context.Context, fromFS afero.Fs, _ runne goRenameEnums, goReplaceNumberWithInt, goRemoveRequired, - goReferenceK8sTypesForCRDs, + goReferenceK8sTypesForCRDs(g.runtimeObjects), goRemoveK8s, goKeepOnlyComponents, ) @@ -364,9 +376,9 @@ func (g goGenerator) generateSharedK8sPackage(schemaFS afero.Fs, pkg string, sch // autoscaling use goReferenceK8sTypesForCRDs (non-core path) to // reference the CRD meta.v1 package at // dev.crossplane.io/models/io/k8s/meta/v1. - refMutator := goReferenceK8sTypes + refMutator := goReferenceK8sTypes(g.runtimeObjects) if pkg != k8sPkgMetaV1 { - refMutator = goReferenceK8sTypesForCRDs + refMutator = goReferenceK8sTypesForCRDs(g.runtimeObjects) } code, err := generateGo(pkgSpec, goPkg.version, @@ -696,6 +708,8 @@ package %s import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + + k8smetav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // GroupVersion is the API group and version for the types in this package. @@ -707,6 +721,16 @@ var SchemeBuilder = &runtime.SchemeBuilder{} // AddToScheme registers this package's types with the given runtime.Scheme. var AddToScheme = SchemeBuilder.AddToScheme + +func init() { + // Needed for runtime.NewParameterCodec to encode ListOptions for this + // GroupVersion; without it, client.List with query options and + // client.Watch fail. + SchemeBuilder.Register(func(s *runtime.Scheme) error { + k8smetav1.AddToGroupVersion(s, GroupVersion) + return nil + }) +} `, version, group, version) formatted, err := format.Source([]byte(code)) @@ -985,31 +1009,36 @@ func goRemovePropertiesRequired(props map[string]spec.Schema) { } } -// goReferenceK8sTypes converts all references to k8s meta/v1 schemas in the -// given spec to references to the shared Go models we generate for the k8s -// schemas. -func goReferenceK8sTypes(s *spec3.OpenAPI) { - for _, schema := range s.Components.Schemas { - goReferenceK8sType(schema) - goReferenceK8sTypesProperties(schema.Properties) +// goReferenceK8sTypes returns a mutator that converts all references to k8s +// meta/v1 schemas in the given spec to references to the shared Go models we +// generate for the k8s schemas. When useRealMeta is true, meta/v1 resolves to +// the real k8s.io/apimachinery package instead, which client.Object requires. +func goReferenceK8sTypes(useRealMeta bool) func(*spec3.OpenAPI) { + return func(s *spec3.OpenAPI) { + for _, schema := range s.Components.Schemas { + goReferenceK8sType(schema, useRealMeta) + goReferenceK8sTypesProperties(schema.Properties, useRealMeta) + } } } // goReferenceK8sTypesForCRDs is like goReferenceK8sTypes but uses different // import paths appropriate for CRDs. For CRDs, we only need to handle meta.v1 // differently since CRDs might use meta.k8s.io group. -func goReferenceK8sTypesForCRDs(s *spec3.OpenAPI) { - for _, schema := range s.Components.Schemas { - goReferenceK8sTypeWithMetaPath(schema, false) - goReferenceK8sTypesPropertiesWithMetaPath(schema.Properties, false) +func goReferenceK8sTypesForCRDs(useRealMeta bool) func(*spec3.OpenAPI) { + return func(s *spec3.OpenAPI) { + for _, schema := range s.Components.Schemas { + goReferenceK8sTypeWithMetaPath(schema, false, useRealMeta) + goReferenceK8sTypesPropertiesWithMetaPath(schema.Properties, false, useRealMeta) + } } } -func goReferenceK8sType(schema *spec.Schema) { - goReferenceK8sTypeWithMetaPath(schema, true) +func goReferenceK8sType(schema *spec.Schema, useRealMeta bool) { + goReferenceK8sTypeWithMetaPath(schema, true, useRealMeta) } -func goReferenceK8sTypeWithMetaPath(schema *spec.Schema, useCorePath bool) { +func goReferenceK8sTypeWithMetaPath(schema *spec.Schema, useCorePath, useRealMeta bool) { // Helper function to check if a reference is a k8s type isK8sRef := func(ref string) bool { return strings.Contains(ref, k8sPkgMetaV1) || @@ -1023,7 +1052,7 @@ func goReferenceK8sTypeWithMetaPath(schema *spec.Schema, useCorePath bool) { // Handle direct reference ref := schema.Ref.String() if isK8sRef(ref) { - tryReplaceK8sTypeWithMetaPath(schema, ref, useCorePath) + tryReplaceK8sTypeWithMetaPath(schema, ref, useCorePath, useRealMeta) // Clear the original reference after replacement schema.Ref = spec.Ref{} } @@ -1040,56 +1069,61 @@ func goReferenceK8sTypeWithMetaPath(schema *spec.Schema, useCorePath bool) { if allK8s && len(schema.AllOf) > 0 { // Use the first AllOf ref for the replacement ref := schema.AllOf[0].Ref.String() - tryReplaceK8sTypeWithMetaPath(schema, ref, useCorePath) + tryReplaceK8sTypeWithMetaPath(schema, ref, useCorePath, useRealMeta) schema.AllOf = nil } else { // Process each AllOf individually for i := range schema.AllOf { - goReferenceK8sTypeWithMetaPath(&schema.AllOf[i], useCorePath) + goReferenceK8sTypeWithMetaPath(&schema.AllOf[i], useCorePath, useRealMeta) } } // Also check OneOf and AnyOf for i := range schema.OneOf { - goReferenceK8sTypeWithMetaPath(&schema.OneOf[i], useCorePath) + goReferenceK8sTypeWithMetaPath(&schema.OneOf[i], useCorePath, useRealMeta) } for i := range schema.AnyOf { - goReferenceK8sTypeWithMetaPath(&schema.AnyOf[i], useCorePath) + goReferenceK8sTypeWithMetaPath(&schema.AnyOf[i], useCorePath, useRealMeta) } } -func goReferenceK8sTypesProperties(props map[string]spec.Schema) { - goReferenceK8sTypesPropertiesWithMetaPath(props, true) +func goReferenceK8sTypesProperties(props map[string]spec.Schema, useRealMeta bool) { + goReferenceK8sTypesPropertiesWithMetaPath(props, true, useRealMeta) } -func goReferenceK8sTypesPropertiesWithMetaPath(props map[string]spec.Schema, useCorePath bool) { +func goReferenceK8sTypesPropertiesWithMetaPath(props map[string]spec.Schema, useCorePath, useRealMeta bool) { for name, prop := range props { - goReferenceK8sTypeWithMetaPath(&prop, useCorePath) - goReferenceK8sTypesPropertiesWithMetaPath(prop.Properties, useCorePath) + goReferenceK8sTypeWithMetaPath(&prop, useCorePath, useRealMeta) + goReferenceK8sTypesPropertiesWithMetaPath(prop.Properties, useCorePath, useRealMeta) if prop.Items != nil { - goReferenceK8sTypeWithMetaPath(prop.Items.Schema, useCorePath) - goReferenceK8sTypesPropertiesWithMetaPath(prop.Items.Schema.Properties, useCorePath) + goReferenceK8sTypeWithMetaPath(prop.Items.Schema, useCorePath, useRealMeta) + goReferenceK8sTypesPropertiesWithMetaPath(prop.Items.Schema.Properties, useCorePath, useRealMeta) } if prop.AdditionalProperties != nil && prop.AdditionalProperties.Schema != nil { - goReferenceK8sTypeWithMetaPath(prop.AdditionalProperties.Schema, useCorePath) - goReferenceK8sTypesPropertiesWithMetaPath(prop.AdditionalProperties.Schema.Properties, useCorePath) + goReferenceK8sTypeWithMetaPath(prop.AdditionalProperties.Schema, useCorePath, useRealMeta) + goReferenceK8sTypesPropertiesWithMetaPath(prop.AdditionalProperties.Schema.Properties, useCorePath, useRealMeta) } props[name] = prop } } -func tryReplaceK8sTypeWithMetaPath(schema *spec.Schema, ref string, useCorePath bool) { +func tryReplaceK8sTypeWithMetaPath(schema *spec.Schema, ref string, useCorePath, useRealMeta bool) { lastDot := strings.LastIndex(ref, ".") if lastDot == -1 { return } t := ref[lastDot+1:] - // Determine the correct alias and path for meta.v1 + // useRealMeta always resolves meta.v1 to the real apimachinery package, + // since client.Object needs its exact types, not a similar mirror. metaAlias := "metacorev1" metaPath := "dev.crossplane.io/models/io/k8s/core/meta/v1" - if !useCorePath { + switch { + case useRealMeta: + metaAlias = "k8smetav1" + metaPath = "k8s.io/apimachinery/pkg/apis/meta/v1" + case !useCorePath: metaAlias = "metav1" metaPath = "dev.crossplane.io/models/io/k8s/meta/v1" } @@ -1520,11 +1554,16 @@ func generateK8sSharedSchemas(openAPISpecs []*spec3.OpenAPI, schemaFS afero.Fs, } } - // Generate separate files for each K8s package + // Generate separate files for each K8s package. meta/v1 resolves to the + // real apimachinery package when runtimeObjects is on (see + // tryReplaceK8sTypeWithMetaPath), so the local mirror would be unused. for pkg, schemas := range k8sSchemasByPackage { if len(schemas) == 0 { continue } + if pkg == k8sPkgMetaV1 && g.runtimeObjects { + continue + } if err := generateK8sPackageCode(pkg, schemas, schemaFS, g); err != nil { return err @@ -1553,7 +1592,7 @@ func generateK8sPackageCode(pkg string, schemas map[string]*spec.Schema, schemaF goRenameEnums, goReplaceNumberWithInt, goRemoveRequired, - goReferenceK8sTypes, + goReferenceK8sTypes(g.runtimeObjects), goAddDefaults, ) if err != nil { @@ -1737,7 +1776,7 @@ func generateGVKGroupCode(gvkKey string, schemas map[string]*spec.Schema, openAP goRenameEnums, goReplaceNumberWithInt, goRemoveRequired, - goReferenceK8sTypes, + goReferenceK8sTypes(g.runtimeObjects), goRemoveK8s, goKeepOnlyComponents, goAddDefaults, diff --git a/internal/schemas/generator/go_test.go b/internal/schemas/generator/go_test.go index 358f84a1..996f8b4c 100644 --- a/internal/schemas/generator/go_test.go +++ b/internal/schemas/generator/go_test.go @@ -307,3 +307,49 @@ func TestGenerateFromOpenAPIGo(t *testing.T) { } } } + +func TestGenerateFromCRDGoRealMetaWhenRuntimeObjectsOn(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) + } + + // The local shared meta/v1 package must not be generated at all. + if exists, _ := afero.Exists(schemaFS, "models/io/k8s/meta/v1/meta.go"); exists { + t.Error("local meta/v1 mirror package must not be generated when runtimeObjects is on") + } + + // The resource's Metadata field must reference the real apimachinery type. + contents, err := afero.ReadFile(schemaFS, "models/co/acme/platform/v1alpha1/accountscaffold.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(contents), "Metadata *k8smetav1.ObjectMeta") { + t.Errorf("expected Metadata *k8smetav1.ObjectMeta in accountscaffold.go, got:\n%s", contents) + } + if !strings.Contains(string(contents), `k8smetav1 "k8s.io/apimachinery/pkg/apis/meta/v1"`) { + t.Errorf("expected real apimachinery meta/v1 import in accountscaffold.go, got:\n%s", contents) + } + + // The List type's Metadata field must also reference the real type. + listContents, err := afero.ReadFile(schemaFS, "models/co/acme/platform/v1alpha1/accountscaffold.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(listContents), "Metadata *k8smetav1.ListMeta") { + t.Errorf("expected Metadata *k8smetav1.ListMeta for AccountScaffoldList, got:\n%s", listContents) + } +} + +func TestGenerateFromCRDGoLocalMetaWhenRuntimeObjectsOff(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) + } + + if exists, _ := afero.Exists(schemaFS, "models/io/k8s/meta/v1/meta.go"); !exists { + t.Error("local meta/v1 mirror package must still be generated when runtimeObjects is off") + } +} diff --git a/internal/schemas/generator/interface.go b/internal/schemas/generator/interface.go index d26519d2..74f34474 100644 --- a/internal/schemas/generator/interface.go +++ b/internal/schemas/generator/interface.go @@ -50,10 +50,13 @@ func WithGoModelAccessors(enabled bool) Option { return func(o *options) { o.goModelAccessors = enabled } } -// 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. +// WithGoRuntimeObjects enables generation of runtime.Object methods +// (DeepCopy, GetObjectKind, DeepCopyObject), metav1.Object methods on root +// types, metav1.ListInterface methods on List types, and per-package +// AddToScheme helpers on the generated Go models. Together these satisfy +// sigs.k8s.io/controller-runtime's client.Object and client.ObjectList. +// Disabled by default; gated behind the features.generateGoRuntimeObjects +// config flag. func WithGoRuntimeObjects(enabled bool) Option { return func(o *options) { o.goRuntimeObjects = enabled } } diff --git a/internal/schemas/generator/runtimeobject.go b/internal/schemas/generator/runtimeobject.go index 1acf5c6e..0d21b51b 100644 --- a/internal/schemas/generator/runtimeobject.go +++ b/internal/schemas/generator/runtimeobject.go @@ -54,10 +54,80 @@ func applyRuntimeObjects(code string, enabled bool) (string, error) { if !enabled { return code, nil } + code, err := fixListItemsFields(code) + if err != nil { + return "", err + } out, _, err := addRuntimeObjects(code) return out, err } +// fixListItemsFields rewrites a List's Items field from *[]Kind to +// []Kind. goRemoveRequired clears "required" from every schema before +// codegen, so oapi-codegen makes Items a pointer like everything else, but +// k8s.io/apimachinery/pkg/api/meta's GetItemsPtr (used by client.List, the +// fake client, and the informer cache) requires a literal slice. Runs before +// addRuntimeObjects parses the code, so writeDeepCopy sees the fixed type. +func fixListItemsFields(code string) (string, error) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "", code, parser.ParseComments) + if err != nil { + return "", errors.Wrap(err, "failed to parse Go code for Items field fix") + } + + type edit struct { + start, end int + newText string + } + var edits []edit + + 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 + } + if metadataKind(st) != "ListMeta" { + continue + } + for _, field := range st.Fields.List { + if !hasFieldName(field, "Items") { + continue + } + star, ok := field.Type.(*ast.StarExpr) + if !ok { + continue + } + arr, ok := star.X.(*ast.ArrayType) + if !ok { + continue + } + edits = append(edits, edit{ + start: fset.Position(field.Type.Pos()).Offset, + end: fset.Position(field.Type.End()).Offset, + newText: "[]" + renderType(fset, arr.Elt), + }) + } + } + } + + // Apply in descending offset order so earlier edits don't invalidate the + // byte offsets computed for later ones. + sort.Slice(edits, func(i, j int) bool { return edits[i].start > edits[j].start }) + for _, e := range edits { + code = code[:e.start] + e.newText + code[e.end:] + } + return code, nil +} + // 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 @@ -77,6 +147,7 @@ func addRuntimeObjects(code string) (string, bool, error) { // Deterministic order: walk declarations in source order. var b strings.Builder hasRoots := false + usesObjectMetaAccessors := false for _, decl := range f.Decls { gen, ok := decl.(*ast.GenDecl) if !ok || gen.Tok != token.TYPE { @@ -95,7 +166,11 @@ func addRuntimeObjects(code string) (string, bool, error) { writeDeepCopy(&b, fset, name, st, structs, aliases) if isRootStruct(st) { hasRoots = true - writeRuntimeObject(&b, name, st) + kind := metadataKind(st) + writeRuntimeObject(&b, name, st, kind) + if kind == "ObjectMeta" { + usesObjectMetaAccessors = true + } } } } @@ -105,14 +180,23 @@ func addRuntimeObjects(code string) (string, bool, error) { } 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. + // Add each import only if something generated needs it, so DeepCopy-only + // files (e.g. shared k8s packages that aren't roots) don't get unused + // imports. + var extraImports []importSpec if hasRoots { - combined, err = ensureImports(combined, []importSpec{ - {alias: roRuntimeAlias, path: roRuntimeImport}, - {path: roSchemaImport}, - }) + extraImports = append(extraImports, + importSpec{alias: roRuntimeAlias, path: roRuntimeImport}, + importSpec{path: roSchemaImport}, + ) + } + if usesObjectMetaAccessors { + extraImports = append(extraImports, + importSpec{alias: "k8stypes", path: "k8s.io/apimachinery/pkg/types"}, + ) + } + if len(extraImports) > 0 { + combined, err = ensureImports(combined, extraImports) if err != nil { return "", false, err } @@ -128,6 +212,12 @@ func addRuntimeObjects(code string) (string, bool, error) { // collectStructTypes returns the set of struct type names declared in the file. func collectStructTypes(f *ast.File) map[string]bool { out := map[string]bool{} + // aliases maps a type-alias name to its target ("type A = B" records + // aliases["A"] = "B"). oapi-codegen aliases every root type to a + // qualified name, and fields like a List's Items use that alias. + // It must resolve to a struct too, or classifyElem treats it as a + // scalar and DeepCopy degrades to a shallow copy that shares pointers. + aliases := map[string]string{} for _, decl := range f.Decls { gen, ok := decl.(*ast.GenDecl) if !ok || gen.Tok != token.TYPE { @@ -135,7 +225,13 @@ func collectStructTypes(f *ast.File) map[string]bool { } for _, spec := range gen.Specs { ts, ok := spec.(*ast.TypeSpec) - if !ok || ts.Assign.IsValid() { + if !ok { + continue + } + if ts.Assign.IsValid() { + if id, ok := ts.Type.(*ast.Ident); ok { + aliases[ts.Name.Name] = id.Name + } continue } if _, ok := ts.Type.(*ast.StructType); ok { @@ -143,6 +239,17 @@ func collectStructTypes(f *ast.File) map[string]bool { } } } + // Resolve alias chains to a fixed point: an alias may point at another + // alias before reaching the underlying struct name. + for changed := true; changed; { + changed = false + for name, target := range aliases { + if !out[name] && out[target] { + out[name] = true + changed = true + } + } + } return out } @@ -192,6 +299,46 @@ func isRootStruct(st *ast.StructType) bool { return hasAPIVersion && hasKind && hasMetadata } +// hasFieldName reports whether field declares name among its identifiers. +func hasFieldName(field *ast.Field, name string) bool { + for _, n := range field.Names { + if n.Name == name { + return true + } + } + return false +} + +// metadataKind reports whether st's Metadata field is an ObjectMeta or a +// ListMeta, matched by the field type's base name (a bare identifier or a +// package-qualified selector). Returns "" if neither matches. isRootStruct +// only checks field names, so a Kind and its KindList both match it; this +// is what lets writeRuntimeObject tell them apart. +func metadataKind(st *ast.StructType) string { + for _, field := range st.Fields.List { + if !hasFieldName(field, "Metadata") { + continue + } + star, ok := field.Type.(*ast.StarExpr) + if !ok { + continue + } + var typeName string + switch x := star.X.(type) { + case *ast.Ident: + typeName = x.Name + case *ast.SelectorExpr: + typeName = x.Sel.Name + default: + continue + } + if typeName == "ObjectMeta" || typeName == "ListMeta" { + return typeName + } + } + return "" +} + // fieldKind classifies how a field's element type must be deep-copied. type fieldKind int @@ -238,6 +385,12 @@ func writeDeepCopy(b *strings.Builder, fset *token.FileSet, name string, st *ast continue } for _, n := range field.Names { + if n.Name == "Items" { + if arr, ok := field.Type.(*ast.ArrayType); ok { + writeItemsFieldCopy(b, fset, arr, structs) + continue + } + } writeFieldCopy(b, fset, n.Name, field.Type, structs, aliases) } } @@ -250,6 +403,23 @@ func writeDeepCopy(b *strings.Builder, fset *token.FileSet, name string, st *ast b.WriteString("\tin.DeepCopyInto(out)\n\treturn out\n}\n") } +// writeItemsFieldCopy deep-copies a List's Items field. Unlike every +// other field, Items is a plain slice (see fixListItemsFields), so it needs +// its own copy path instead of writeFieldCopy's pointer-unwrap logic. +func writeItemsFieldCopy(b *strings.Builder, fset *token.FileSet, arr *ast.ArrayType, structs map[string]bool) { + declType := renderType(fset, arr) + b.WriteString("\tif in.Items != nil {\n") + fmt.Fprintf(b, "\t\tout.Items = make(%s, len(in.Items))\n", declType) + if classifyElem(arr.Elt, structs) == fkStruct { + b.WriteString("\t\tfor i := range in.Items {\n") + b.WriteString("\t\t\tin.Items[i].DeepCopyInto(&out.Items[i])\n") + b.WriteString("\t\t}\n") + } else { + b.WriteString("\t\tcopy(out.Items, in.Items)\n") + } + b.WriteString("\t}\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 @@ -333,7 +503,7 @@ func writeMapCopy(b *strings.Builder, fset *token.FileSet, declType string, m *a // 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) { +func writeRuntimeObject(b *strings.Builder, name string, st *ast.StructType, metaKind string) { // 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) @@ -368,6 +538,96 @@ func writeRuntimeObject(b *strings.Builder, name string, st *ast.StructType) { 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") + + switch metaKind { + case "ObjectMeta": + writeObjectMetaAccessors(b, name) + case "ListMeta": + writeListInterfaceAccessors(b, name) + } +} + +// objectMetaField describes one metav1.Object getter/setter pair: the field +// name (used as both the JSON-schema-derived method name and the delegated +// call on the real metav1.ObjectMeta), its exact Go type per the interface, +// and the literal expression to return from the getter when Metadata is nil. +type objectMetaField struct { + name string + goType string + zero string +} + +// objectMetaFields is every getter/setter pair sigs.k8s.io/apimachinery's +// metav1.Object interface declares, in its exact declared types. This table +// drives writeObjectMetaAccessors below. +var objectMetaFields = []objectMetaField{ //nolint:gochecknoglobals // Lookup table. + {"Namespace", "string", `""`}, + {"Name", "string", `""`}, + {"GenerateName", "string", `""`}, + {"UID", "k8stypes.UID", `""`}, + {"ResourceVersion", "string", `""`}, + {"Generation", "int64", "0"}, + {"SelfLink", "string", `""`}, + {"CreationTimestamp", "k8smetav1.Time", "k8smetav1.Time{}"}, + {"DeletionTimestamp", "*k8smetav1.Time", "nil"}, + {"DeletionGracePeriodSeconds", "*int64", "nil"}, + {"Labels", "map[string]string", "nil"}, + {"Annotations", "map[string]string", "nil"}, + {"Finalizers", "[]string", "nil"}, + {"OwnerReferences", "[]k8smetav1.OwnerReference", "nil"}, + {"ManagedFields", "[]k8smetav1.ManagedFieldsEntry", "nil"}, +} + +// writeObjectMetaAccessors appends the metav1.Object getter/setter pairs for +// name, delegating to its Metadata field. Getters return the zero value when +// Metadata is nil; setters allocate it lazily. +func writeObjectMetaAccessors(b *strings.Builder, name string) { + for _, f := range objectMetaFields { + fmt.Fprintf(b, "\nfunc (in *%s) Get%s() %s {\n", name, f.name, f.goType) + b.WriteString("\tif in.Metadata == nil {\n") + fmt.Fprintf(b, "\t\treturn %s\n", f.zero) + b.WriteString("\t}\n") + fmt.Fprintf(b, "\treturn in.Metadata.Get%s()\n}\n", f.name) + + fmt.Fprintf(b, "\nfunc (in *%s) Set%s(v %s) {\n", name, f.name, f.goType) + b.WriteString("\tif in.Metadata == nil {\n") + b.WriteString("\t\tin.Metadata = &k8smetav1.ObjectMeta{}\n") + b.WriteString("\t}\n") + fmt.Fprintf(b, "\tin.Metadata.Set%s(v)\n}\n", f.name) + } +} + +// listMetaField is objectMetaField's counterpart for metav1.ListInterface. +type listMetaField struct { + name string + goType string + zero string +} + +// listMetaFields is every getter/setter pair metav1.ListInterface declares. +var listMetaFields = []listMetaField{ //nolint:gochecknoglobals // Lookup table. + {"ResourceVersion", "string", `""`}, + {"SelfLink", "string", `""`}, + {"Continue", "string", `""`}, + {"RemainingItemCount", "*int64", "nil"}, +} + +// writeListInterfaceAccessors is writeObjectMetaAccessors' counterpart for +// metav1.ListInterface. +func writeListInterfaceAccessors(b *strings.Builder, name string) { + for _, f := range listMetaFields { + fmt.Fprintf(b, "\nfunc (in *%s) Get%s() %s {\n", name, f.name, f.goType) + b.WriteString("\tif in.Metadata == nil {\n") + fmt.Fprintf(b, "\t\treturn %s\n", f.zero) + b.WriteString("\t}\n") + fmt.Fprintf(b, "\treturn in.Metadata.Get%s()\n}\n", f.name) + + fmt.Fprintf(b, "\nfunc (in *%s) Set%s(v %s) {\n", name, f.name, f.goType) + b.WriteString("\tif in.Metadata == nil {\n") + b.WriteString("\t\tin.Metadata = &k8smetav1.ListMeta{}\n") + b.WriteString("\t}\n") + fmt.Fprintf(b, "\tin.Metadata.Set%s(v)\n}\n", f.name) + } } // fieldElemTypeName returns the element type name of a pointer field (e.g. for diff --git a/internal/schemas/generator/runtimeobject_compilegate_test.go b/internal/schemas/generator/runtimeobject_compilegate_test.go index 954d2ef2..076a1702 100644 --- a/internal/schemas/generator/runtimeobject_compilegate_test.go +++ b/internal/schemas/generator/runtimeobject_compilegate_test.go @@ -215,7 +215,6 @@ import ( 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" ) @@ -224,7 +223,6 @@ 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, @@ -236,12 +234,13 @@ func TestBuiltInGroupVersions(t *testing.T) { // 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. + // meta/v1 no longer registers here; it resolves to the real + // k8s.io/apimachinery package, not a locally-generated kind. 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.Scale{}, want: schema.GroupVersionKind{Group: "autoscaling", Version: "v1", Kind: "Scale"}}, "Authn": {obj: &authnv1.TokenRequest{}, want: schema.GroupVersionKind{Group: "authentication.k8s.io", Version: "v1", Kind: "TokenRequest"}}, } @@ -277,6 +276,143 @@ func TestBuiltInGroupVersions(t *testing.T) { } } +// TestGeneratedModelsSatisfyClientObject proves the generated types work +// with a real client.Client. It builds a separate temporary consumer module +// that requires sigs.k8s.io/controller-runtime and replaces +// dev.crossplane.io/models with the materialized models directory, so +// controller-runtime never touches the generated models' own go.mod. +func TestGeneratedModelsSatisfyClientObject(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) + } + + tmpDir := t.TempDir() + roMaterialize(t, schemaFS, tmpDir) + generatedModelsDir := filepath.Join(tmpDir, "models") + + consumerDir := t.TempDir() + + goMod := `module client-object-consumer + +go 1.24.0 + +require ( + dev.crossplane.io/models v0.0.0 + sigs.k8s.io/controller-runtime v0.21.0 +) + +replace dev.crossplane.io/models => ` + generatedModelsDir + ` +` + if err := os.WriteFile(filepath.Join(consumerDir, "go.mod"), []byte(goMod), 0o644); err != nil { + t.Fatal(err) + } + + consumer := `package consumer + +import ( + "context" + "testing" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + v1alpha1 "dev.crossplane.io/models/co/acme/platform/v1alpha1" +) + +// Compile-time proof: the generated types satisfy client.Object/client.ObjectList. +var ( + _ client.Object = &v1alpha1.XAccountScaffold{} + _ client.ObjectList = &v1alpha1.XAccountScaffoldList{} +) + +func TestFakeClientCreateGetList(t *testing.T) { + s := runtime.NewScheme() + if err := v1alpha1.AddToScheme(s); err != nil { + t.Fatal(err) + } + + obj := &v1alpha1.XAccountScaffold{} + obj.SetGroupVersionKind(schemaGVK()) + obj.SetName("test-resource") + obj.SetNamespace("default") + obj.SetLabels(map[string]string{"team": "platform"}) + + c := fake.NewClientBuilder().WithScheme(s).WithObjects(obj).Build() + ctx := context.Background() + + got := &v1alpha1.XAccountScaffold{} + if err := c.Get(ctx, client.ObjectKey{Name: "test-resource", Namespace: "default"}, got); err != nil { + t.Fatalf("Get: %v", err) + } + if got.GetName() != "test-resource" { + t.Fatalf("GetName() = %q, want %q", got.GetName(), "test-resource") + } + if got.GetLabels()["team"] != "platform" { + t.Fatalf("GetLabels()[\"team\"] = %q, want %q", got.GetLabels()["team"], "platform") + } + + list := &v1alpha1.XAccountScaffoldList{} + if err := c.List(ctx, list, client.InNamespace("default")); err != nil { + t.Fatalf("List: %v", err) + } + if len(list.Items) != 1 { + t.Fatalf("len(list.Items) = %d, want 1", len(list.Items)) + } + if list.Items[0].GetName() != "test-resource" { + t.Fatalf("list.Items[0].GetName() = %q, want %q", list.Items[0].GetName(), "test-resource") + } + + // Items is typed with oapi-codegen's qualified-name alias, the shape + // that used to fall back to a shallow copy before collectStructTypes + // resolved aliases. + listCopy := list.DeepCopy() + listCopy.Items[0].SetName("mutated") + if list.Items[0].GetName() != "test-resource" { + t.Fatalf("DeepCopy is not independent: original mutated to %q", list.Items[0].GetName()) + } +} + +// TestParameterCodecEncodesListOptions proves AddToGroupVersion is +// registered. A fake-client test alone wouldn't catch a missing +// registration, since the fake client never encodes parameters. +func TestParameterCodecEncodesListOptions(t *testing.T) { + s := runtime.NewScheme() + if err := v1alpha1.AddToScheme(s); err != nil { + t.Fatal(err) + } + codec := runtime.NewParameterCodec(s) + limit := int64(5) + if _, err := codec.EncodeParameters(&metav1.ListOptions{Limit: limit}, v1alpha1.GroupVersion); err != nil { + t.Fatalf("EncodeParameters: %v", err) + } +} + +func schemaGVK() schema.GroupVersionKind { + return schema.GroupVersionKind{Group: "platform.acme.co", Version: "v1alpha1", Kind: "XAccountScaffold"} +} +` + if err := os.WriteFile(filepath.Join(consumerDir, "consumer_test.go"), []byte(consumer), 0o644); err != nil { + t.Fatal(err) + } + + tidy := exec.CommandContext(t.Context(), "go", "mod", "tidy") + tidy.Dir = consumerDir + if out, err := tidy.CombinedOutput(); err != nil { + t.Fatalf("go mod tidy failed: %v\n%s", err, out) + } + + test := exec.CommandContext(t.Context(), "go", "test", "./...") + test.Dir = consumerDir + if out, err := test.CombinedOutput(); err != nil { + t.Fatalf("consumer module failed to build/test against client.Object: %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. diff --git a/internal/schemas/generator/runtimeobject_integration_test.go b/internal/schemas/generator/runtimeobject_integration_test.go index 7b1c26d3..6924f9e3 100644 --- a/internal/schemas/generator/runtimeobject_integration_test.go +++ b/internal/schemas/generator/runtimeobject_integration_test.go @@ -23,6 +23,25 @@ import ( "github.com/spf13/afero" ) +func TestGroupVersionInfoRegistersAddToGroupVersion(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) + } + + gvi, err := afero.ReadFile(schemaFS, "models/co/acme/platform/v1alpha1/groupversion_info.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(gvi), "k8smetav1.AddToGroupVersion(s, GroupVersion)") { + t.Errorf("expected groupversion_info.go to register AddToGroupVersion, got:\n%s", gvi) + } + if !strings.Contains(string(gvi), `k8smetav1 "k8s.io/apimachinery/pkg/apis/meta/v1"`) { + t.Errorf("expected groupversion_info.go to import real apimachinery meta/v1, got:\n%s", gvi) + } +} + func TestGenerateFromCRDRuntimeObjectsArtifacts(t *testing.T) { inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataFS}, "testdata") schemaFS, err := goGenerator{runtimeObjects: true}.GenerateFromCRD(t.Context(), inputFS, nil) @@ -50,12 +69,11 @@ func TestGenerateFromCRDRuntimeObjectsArtifacts(t *testing.T) { 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. + // The local meta/v1 mirror package no longer exists. It resolves to the + // real k8s.io/apimachinery package, which nothing here registers into a + // scheme. 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) @@ -125,10 +143,6 @@ func TestGenerateFromOpenAPIBuiltInGroupVersions(t *testing.T) { 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"}`, diff --git a/internal/schemas/generator/runtimeobject_test.go b/internal/schemas/generator/runtimeobject_test.go index 4fdd4369..3d285c6b 100644 --- a/internal/schemas/generator/runtimeobject_test.go +++ b/internal/schemas/generator/runtimeobject_test.go @@ -20,6 +20,7 @@ import ( "go/ast" "go/parser" "go/token" + "strings" "testing" ) @@ -99,6 +100,10 @@ type ObjectMeta struct { Name *string ` + "`json:\"name,omitempty\"`" + ` } +type ListMeta struct { + ResourceVersion *string ` + "`json:\"resourceVersion,omitempty\"`" + ` +} + type Foo struct { APIVersion *FooAPIVersion ` + "`json:\"apiVersion,omitempty\"`" + ` Kind *FooKind ` + "`json:\"kind,omitempty\"`" + ` @@ -109,7 +114,7 @@ type Foo struct { type FooList struct { APIVersion *string ` + "`json:\"apiVersion,omitempty\"`" + ` Kind *string ` + "`json:\"kind,omitempty\"`" + ` - Metadata *ObjectMeta ` + "`json:\"metadata,omitempty\"`" + ` + Metadata *ListMeta ` + "`json:\"metadata,omitempty\"`" + ` Items *[]Foo ` + "`json:\"items,omitempty\"`" + ` } `, @@ -195,6 +200,275 @@ type Foo struct { } } +func TestObjectMetaAccessors(t *testing.T) { + const src = `package v1alpha1 + +type FooAPIVersion string +type FooKind string + +type ObjectMeta struct { + Name *string ` + "`json:\"name,omitempty\"`" + ` +} + +type ListMeta struct { + ResourceVersion *string ` + "`json:\"resourceVersion,omitempty\"`" + ` +} + +type Foo struct { + APIVersion *FooAPIVersion ` + "`json:\"apiVersion,omitempty\"`" + ` + Kind *FooKind ` + "`json:\"kind,omitempty\"`" + ` + Metadata *ObjectMeta ` + "`json:\"metadata,omitempty\"`" + ` +} + +type FooList struct { + APIVersion *FooAPIVersion ` + "`json:\"apiVersion,omitempty\"`" + ` + Kind *FooKind ` + "`json:\"kind,omitempty\"`" + ` + Metadata *ListMeta ` + "`json:\"metadata,omitempty\"`" + ` + Items *[]Foo ` + "`json:\"items,omitempty\"`" + ` +} +` + + code, _, err := addRuntimeObjects(src) + if err != nil { + t.Fatalf("addRuntimeObjects: %v", err) + } + + methods := roMethods(t, code) + for _, m := range []string{ + "Foo.GetName", "Foo.SetName", + "Foo.GetNamespace", "Foo.SetNamespace", + "Foo.GetUID", "Foo.SetUID", + "Foo.GetLabels", "Foo.SetLabels", + "Foo.GetOwnerReferences", "Foo.SetOwnerReferences", + "Foo.GetManagedFields", "Foo.SetManagedFields", + } { + if !methods[m] { + t.Errorf("expected %s to be generated for an ObjectMeta-shaped root", m) + } + } + + // A ListMeta-shaped root must not get ObjectMeta's methods. + for _, m := range []string{"FooList.GetName", "FooList.SetName", "FooList.GetLabels"} { + if methods[m] { + t.Errorf("did not expect %s on a ListMeta-shaped root", m) + } + } + + if !strings.Contains(code, "k8stypes.UID") { + t.Errorf("expected GetUID to return k8stypes.UID, got:\n%s", code) + } + if !strings.Contains(code, `k8stypes "k8s.io/apimachinery/pkg/types"`) { + t.Errorf("expected k8stypes import, got:\n%s", code) + } +} + +func TestListInterfaceAccessors(t *testing.T) { + const src = `package v1alpha1 + +type FooAPIVersion string +type FooKind string + +type ObjectMeta struct { + Name *string ` + "`json:\"name,omitempty\"`" + ` +} + +type ListMeta struct { + ResourceVersion *string ` + "`json:\"resourceVersion,omitempty\"`" + ` +} + +type Foo struct { + APIVersion *FooAPIVersion ` + "`json:\"apiVersion,omitempty\"`" + ` + Kind *FooKind ` + "`json:\"kind,omitempty\"`" + ` + Metadata *ObjectMeta ` + "`json:\"metadata,omitempty\"`" + ` +} + +type FooList struct { + APIVersion *FooAPIVersion ` + "`json:\"apiVersion,omitempty\"`" + ` + Kind *FooKind ` + "`json:\"kind,omitempty\"`" + ` + Metadata *ListMeta ` + "`json:\"metadata,omitempty\"`" + ` + Items *[]Foo ` + "`json:\"items,omitempty\"`" + ` +} +` + + code, _, err := addRuntimeObjects(src) + if err != nil { + t.Fatalf("addRuntimeObjects: %v", err) + } + + methods := roMethods(t, code) + for _, m := range []string{ + "FooList.GetResourceVersion", "FooList.SetResourceVersion", + "FooList.GetContinue", "FooList.SetContinue", + "FooList.GetRemainingItemCount", "FooList.SetRemainingItemCount", + } { + if !methods[m] { + t.Errorf("expected %s to be generated for a ListMeta-shaped root", m) + } + } + + // An ObjectMeta-shaped root must not get ListInterface's methods. + if methods["Foo.GetContinue"] { + t.Error("did not expect Foo.GetContinue on an ObjectMeta-shaped root") + } +} + +func TestFixListItemsFields(t *testing.T) { + const src = `package v1alpha1 + +type FooAPIVersion string +type FooKind string + +type ObjectMeta struct { + Name *string ` + "`json:\"name,omitempty\"`" + ` +} + +type ListMeta struct { + ResourceVersion *string ` + "`json:\"resourceVersion,omitempty\"`" + ` +} + +type Foo struct { + APIVersion *FooAPIVersion ` + "`json:\"apiVersion,omitempty\"`" + ` + Kind *FooKind ` + "`json:\"kind,omitempty\"`" + ` + Metadata *ObjectMeta ` + "`json:\"metadata,omitempty\"`" + ` +} + +type FooList struct { + APIVersion *FooAPIVersion ` + "`json:\"apiVersion,omitempty\"`" + ` + Kind *FooKind ` + "`json:\"kind,omitempty\"`" + ` + Metadata *ListMeta ` + "`json:\"metadata,omitempty\"`" + ` + Items *[]Foo ` + "`json:\"items,omitempty\"`" + ` +} +` + + code, err := applyRuntimeObjects(src, true) + if err != nil { + t.Fatalf("applyRuntimeObjects: %v", err) + } + + if strings.Contains(code, "Items *[]Foo") || strings.Contains(code, "Items *[]Foo") { + t.Errorf("expected Items to be rewritten from *[]Foo to []Foo, got:\n%s", code) + } + + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "", code, parser.ParseComments) + if err != nil { + t.Fatalf("rewritten code does not parse: %v\n%s", err, code) + } + found := 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.Name.Name != "FooList" { + continue + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + continue + } + for _, field := range st.Fields.List { + if !hasFieldName(field, "Items") { + continue + } + if _, ok := field.Type.(*ast.ArrayType); !ok { + t.Errorf("expected FooList.Items to be a plain slice, got %T", field.Type) + } + found = true + } + } + } + if !found { + t.Fatal("did not find FooList.Items field in rewritten code") + } +} + +// TestItemsFieldDeepCopyIndependenceWithAliasedElement pins the shape the +// plain fixture above doesn't cover: oapi-codegen aliases every root type to +// a qualified name, and a List's Items field uses that alias. It must +// resolve back to the struct, or DeepCopy shares item pointers with the +// original instead of copying them. +func TestItemsFieldDeepCopyIndependenceWithAliasedElement(t *testing.T) { + const src = `package v1alpha1 + +type FooAPIVersion string +type FooKind string + +type ObjectMeta struct { + Name *string ` + "`json:\"name,omitempty\"`" + ` +} + +type ListMeta struct { + ResourceVersion *string ` + "`json:\"resourceVersion,omitempty\"`" + ` +} + +type Foo struct { + APIVersion *FooAPIVersion ` + "`json:\"apiVersion,omitempty\"`" + ` + Kind *FooKind ` + "`json:\"kind,omitempty\"`" + ` + Metadata *ObjectMeta ` + "`json:\"metadata,omitempty\"`" + ` + Name *string ` + "`json:\"name,omitempty\"`" + ` +} + +type QualifiedFoo = Foo + +type FooList struct { + APIVersion *FooAPIVersion ` + "`json:\"apiVersion,omitempty\"`" + ` + Kind *FooKind ` + "`json:\"kind,omitempty\"`" + ` + Metadata *ListMeta ` + "`json:\"metadata,omitempty\"`" + ` + Items *[]QualifiedFoo ` + "`json:\"items,omitempty\"`" + ` +} +` + code, err := applyRuntimeObjects(src, true) + if err != nil { + t.Fatalf("applyRuntimeObjects: %v", err) + } + if strings.Contains(code, "copy(out.Items, in.Items)") { + t.Errorf("expected element-wise DeepCopy for an aliased Items element, got a shallow copy:\n%s", code) + } + if !strings.Contains(code, "in.Items[i].DeepCopyInto(&out.Items[i])") { + t.Errorf("expected FooList's DeepCopyInto to deep-copy each aliased Items element, got:\n%s", code) + } +} + +func TestItemsFieldDeepCopyIndependence(t *testing.T) { + const src = `package v1alpha1 + +type FooAPIVersion string +type FooKind string + +type ObjectMeta struct { + Name *string ` + "`json:\"name,omitempty\"`" + ` +} + +type ListMeta struct { + ResourceVersion *string ` + "`json:\"resourceVersion,omitempty\"`" + ` +} + +type Foo struct { + APIVersion *FooAPIVersion ` + "`json:\"apiVersion,omitempty\"`" + ` + Kind *FooKind ` + "`json:\"kind,omitempty\"`" + ` + Metadata *ObjectMeta ` + "`json:\"metadata,omitempty\"`" + ` + Name *string ` + "`json:\"name,omitempty\"`" + ` +} + +type FooList struct { + APIVersion *FooAPIVersion ` + "`json:\"apiVersion,omitempty\"`" + ` + Kind *FooKind ` + "`json:\"kind,omitempty\"`" + ` + Metadata *ListMeta ` + "`json:\"metadata,omitempty\"`" + ` + Items *[]Foo ` + "`json:\"items,omitempty\"`" + ` +} +` + code, err := applyRuntimeObjects(src, true) + if err != nil { + t.Fatalf("applyRuntimeObjects: %v", err) + } + if !strings.Contains(code, "in.Items[i].DeepCopyInto(&out.Items[i])") { + t.Errorf("expected FooList's DeepCopyInto to deep-copy each Items element, got:\n%s", code) + } +} + // countMethod returns how many times recv.method is declared in src. func countMethod(t *testing.T, src, recv, method string) int { t.Helper()