From ee8ff55361efb047b01bb8cc5ee85fc39f622ed8 Mon Sep 17 00:00:00 2001 From: Igor Karpukhin Date: Mon, 10 Nov 2025 15:23:28 +0100 Subject: [PATCH] Scaffolder: generate getSDK and getTranslation methods for every controller --- tools/scaffolder/.gitignore | 1 + tools/scaffolder/Makefile | 6 + tools/scaffolder/cmd/main.go | 10 +- tools/scaffolder/go.mod | 7 +- tools/scaffolder/internal/generate/config.go | 36 +- .../internal/generate/config_test.go | 117 +++++ .../internal/generate/controller.go | 144 ++++- .../internal/generate/controller_test.go | 491 ++++++++++++++++++ .../internal/generate/indexers_test.go | 442 ++++++++++++++++ 9 files changed, 1234 insertions(+), 20 deletions(-) create mode 100644 tools/scaffolder/.gitignore create mode 100644 tools/scaffolder/internal/generate/config_test.go create mode 100644 tools/scaffolder/internal/generate/controller_test.go create mode 100644 tools/scaffolder/internal/generate/indexers_test.go diff --git a/tools/scaffolder/.gitignore b/tools/scaffolder/.gitignore new file mode 100644 index 0000000000..31ea3ff186 --- /dev/null +++ b/tools/scaffolder/.gitignore @@ -0,0 +1 @@ +./bin/ \ No newline at end of file diff --git a/tools/scaffolder/Makefile b/tools/scaffolder/Makefile index 822f6e6505..95912ab93f 100644 --- a/tools/scaffolder/Makefile +++ b/tools/scaffolder/Makefile @@ -34,3 +34,9 @@ generate-all: build $(BINARY_PATH) --input $(CRD_FILE) --all \ --indexer-out $(INDEXER_OUT) \ --controller-out $(CONTROLLER_OUT) \ + +.PHONY: generate-all-override +generate-all-override: + $(BINARY_PATH) --input $(CRD_FILE) --all --override \ + --indexer-out $(INDEXER_OUT) \ + --controller-out $(CONTROLLER_OUT) \ \ No newline at end of file diff --git a/tools/scaffolder/cmd/main.go b/tools/scaffolder/cmd/main.go index 3184712dc1..7bdb7bd086 100644 --- a/tools/scaffolder/cmd/main.go +++ b/tools/scaffolder/cmd/main.go @@ -17,6 +17,7 @@ var ( controllerOutDir string indexerOutDir string typesPath string + override bool ) func main() { @@ -47,10 +48,10 @@ func main() { } if allCRDs { - return generateAllCRDs(inputPath, controllerOutDir, indexerOutDir, typesPath) + return generateAllCRDs(inputPath, controllerOutDir, indexerOutDir, typesPath, override) } - return generate.FromConfig(inputPath, crdKind, controllerOutDir, indexerOutDir, typesPath) + return generate.FromConfig(inputPath, crdKind, controllerOutDir, indexerOutDir, typesPath, override) }, } @@ -61,6 +62,7 @@ func main() { rootCmd.Flags().StringVar(&controllerOutDir, "controller-out", "", "Output directory for controller files (default: ../mongodb-atlas-kubernetes/internal/controller)") rootCmd.Flags().StringVar(&indexerOutDir, "indexer-out", "", "Output directory for indexer files (default: ../mongodb-atlas-kubernetes/internal/indexer)") rootCmd.Flags().StringVar(&typesPath, "types-path", "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/nextapi/generated/v1", "Full import path to the API types package") + rootCmd.Flags().BoolVar(&override, "override", false, "Override existing versioned handler files (default: false)") if err := rootCmd.Execute(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -92,7 +94,7 @@ func validateGoImportPath(path string) error { return nil } -func generateAllCRDs(inputPath, controllerOutDir, indexerOutDir, typesPath string) error { +func generateAllCRDs(inputPath, controllerOutDir, indexerOutDir, typesPath string, override bool) error { crds, err := generate.ListCRDs(inputPath) if err != nil { return fmt.Errorf("failed to list CRDs: %w", err) @@ -106,7 +108,7 @@ func generateAllCRDs(inputPath, controllerOutDir, indexerOutDir, typesPath strin for _, crd := range crds { fmt.Printf("Generating for CRD: %s...\n", crd.Kind) - err := generate.FromConfig(inputPath, crd.Kind, controllerOutDir, indexerOutDir, typesPath) + err := generate.FromConfig(inputPath, crd.Kind, controllerOutDir, indexerOutDir, typesPath, override) result := CRDGenerationResult{ CRDKind: crd.Kind, diff --git a/tools/scaffolder/go.mod b/tools/scaffolder/go.mod index 879bbca80e..27d764e390 100644 --- a/tools/scaffolder/go.mod +++ b/tools/scaffolder/go.mod @@ -12,9 +12,13 @@ require ( k8s.io/apimachinery v0.34.1 // indirect ) -require k8s.io/apiextensions-apiserver v0.34.1 +require ( + github.com/stretchr/testify v1.10.0 + k8s.io/apiextensions-apiserver v0.34.1 +) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -23,6 +27,7 @@ require ( github.com/kr/pretty v0.3.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect diff --git a/tools/scaffolder/internal/generate/config.go b/tools/scaffolder/internal/generate/config.go index bd1499c6a9..bd5895e361 100644 --- a/tools/scaffolder/internal/generate/config.go +++ b/tools/scaffolder/internal/generate/config.go @@ -37,10 +37,13 @@ type MappingWithConfig struct { } type ParsedConfig struct { - SelectedCRD CRDInfo - Mappings []MappingWithConfig - ResourceName string - APIVersion string // API version package (e.g., "v1", "v3") + SelectedCRD CRDInfo + Mappings []MappingWithConfig + ResourceName string + APIVersion string // API version package (e.g., "v1", "v3") + StorageVersion string + PluralName string + CRDGroup string } type CRDDocument struct { @@ -71,6 +74,7 @@ type CRDInfo struct { Kind string Group string Version string + Plural string ShortNames []string Categories []string Versions []CRDVersionInfo @@ -159,6 +163,7 @@ func decodeCRDDocument(content []byte) (*CRDInfo, error) { crdInfo := &CRDInfo{ Kind: crd.Spec.Names.Kind, Group: crd.Spec.Group, + Plural: crd.Spec.Names.Plural, ShortNames: crd.Spec.Names.ShortNames, Categories: crd.Spec.Names.Categories, } @@ -212,11 +217,20 @@ func ParseCRDConfig(resultPath, crdKind string) (*ParsedConfig, error) { // apiVersion = "v3" // } + // Extract storage version - default to "v1" if not found + storageVersion := "v1" + if crdInfo.Version != "" { + storageVersion = crdInfo.Version + } + return &ParsedConfig{ - SelectedCRD: *crdInfo, - Mappings: mappings, - ResourceName: crdInfo.Kind, - APIVersion: apiVersion, + SelectedCRD: *crdInfo, + Mappings: mappings, + ResourceName: crdInfo.Kind, + APIVersion: apiVersion, + StorageVersion: storageVersion, + PluralName: crdInfo.Plural, + CRDGroup: crdInfo.Group, }, nil } @@ -225,7 +239,11 @@ func ListCRDs(resultPath string) ([]CRDInfo, error) { if err != nil { return nil, fmt.Errorf("failed to open result file: %w", err) } - defer file.Close() + defer func() { + if err := file.Close(); err != nil { + fmt.Printf("failed to close result file: %v\n", err) + } + }() var crds []CRDInfo scanner := bufio.NewScanner(file) diff --git a/tools/scaffolder/internal/generate/config_test.go b/tools/scaffolder/internal/generate/config_test.go new file mode 100644 index 0000000000..6b8ee314a2 --- /dev/null +++ b/tools/scaffolder/internal/generate/config_test.go @@ -0,0 +1,117 @@ +// Copyright 2025 MongoDB Inc +// +// 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 generate + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseCRDConfig(t *testing.T) { + testYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: clusters.atlas.generated.mongodb.com + annotations: + api-mappings: | + properties: + spec: + properties: + v20250312: + x-atlas-sdk-version: go.mongodb.org/atlas-sdk/v20250312008/admin +spec: + group: atlas.generated.mongodb.com + names: + kind: Cluster + plural: clusters + versions: + - name: v1 +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.yaml") + err := os.WriteFile(testFile, []byte(testYAML), 0644) + require.NoError(t, err) + + t.Run("ParseValidCRD", func(t *testing.T) { + config, err := ParseCRDConfig(testFile, "Cluster") + require.NoError(t, err) + assert.Equal(t, "Cluster", config.ResourceName) + assert.Len(t, config.Mappings, 1) + assert.Equal(t, "v20250312", config.Mappings[0].Version) + assert.Equal(t, "go.mongodb.org/atlas-sdk/v20250312008/admin", config.Mappings[0].OpenAPIConfig.Package) + }) + + t.Run("ParseNonExistentCRD", func(t *testing.T) { + _, err := ParseCRDConfig(testFile, "NonExistent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) + + t.Run("ParseInvalidFile", func(t *testing.T) { + _, err := ParseCRDConfig("/nonexistent/file.yaml", "Cluster") + assert.Error(t, err) + }) +} + +func TestListCRDs(t *testing.T) { + testYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: clusters.atlas.generated.mongodb.com +spec: + group: atlas.generated.mongodb.com + names: + kind: Cluster + plural: clusters + categories: [atlas] + versions: + - name: v1 +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: groups.atlas.generated.mongodb.com +spec: + group: atlas.generated.mongodb.com + names: + kind: Group + plural: groups + versions: + - name: v1 +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.yaml") + err := os.WriteFile(testFile, []byte(testYAML), 0644) + require.NoError(t, err) + + crds, err := ListCRDs(testFile) + require.NoError(t, err) + assert.Len(t, crds, 2) + + assert.Equal(t, "Cluster", crds[0].Kind) + assert.Equal(t, "atlas.generated.mongodb.com", crds[0].Group) + assert.Equal(t, "v1", crds[0].Version) + assert.Contains(t, crds[0].Categories, "atlas") + + assert.Equal(t, "Group", crds[1].Kind) +} diff --git a/tools/scaffolder/internal/generate/controller.go b/tools/scaffolder/internal/generate/controller.go index eabf96efe8..8ada81cfc9 100644 --- a/tools/scaffolder/internal/generate/controller.go +++ b/tools/scaffolder/internal/generate/controller.go @@ -32,7 +32,7 @@ func getAPIPackage(apiVersion string) string { } // FromConfig generates controllers and handlers based on the parsed CRD result file -func FromConfig(resultPath, crdKind, controllerOutDir, indexerOutDir, typesPath string) error { +func FromConfig(resultPath, crdKind, controllerOutDir, indexerOutDir, typesPath string, override bool) error { parsedConfig, err := ParseCRDConfig(resultPath, crdKind) if err != nil { return err @@ -78,13 +78,13 @@ func FromConfig(resultPath, crdKind, controllerOutDir, indexerOutDir, typesPath return fmt.Errorf("failed to generate controller file: %w", err) } - if err := generateMainHandlerFile(controllerDir, controllerName, resourceName, typesPath, parsedConfig.Mappings, refsByKind); err != nil { + if err := generateMainHandlerFile(controllerDir, controllerName, resourceName, typesPath, parsedConfig.Mappings, refsByKind, parsedConfig); err != nil { return fmt.Errorf("failed to generate main handler file: %w", err) } // Generate version-specific handlers for _, mapping := range parsedConfig.Mappings { - if err := generateVersionHandlerFile(controllerDir, controllerName, resourceName, typesPath, mapping); err != nil { + if err := generateVersionHandlerFile(controllerDir, controllerName, resourceName, typesPath, mapping, override, parsedConfig); err != nil { return fmt.Errorf("failed to generate handler for version %s: %w", mapping.Version, err) } } @@ -214,7 +214,55 @@ func generateControllerFileWithMultipleVersions(dir, controllerName, resourceNam return f.Save(fileName) } -func generateMainHandlerFile(dir, controllerName, resourceName, typesPath string, mappings []MappingWithConfig, refsByKind map[string][]ReferenceField) error { +func generatePackageLevelTranslationHelper(f *jen.File) { + f.Comment("getTranslationRequest creates a translation request for converting entities between API and AKO.") + f.Comment("This is a package-level function that can be called from any handler.") + f.Func().Id("getTranslationRequest").Params( + jen.Id("ctx").Qual("context", "Context"), + jen.Id("k8sClient").Qual("sigs.k8s.io/controller-runtime/pkg/client", "Client"), + jen.Id("crdName").String(), + jen.Id("storageVersion").String(), + jen.Id("targetVersion").String(), + ).Params( + jen.Op("*").Qual("github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/generated/translate", "Request"), + jen.Error(), + ).Block( + jen.Id("crd").Op(":=").Op("&").Qual("k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1", "CustomResourceDefinition").Values(), + jen.Id("err").Op(":=").Id("k8sClient").Dot("Get").Call( + jen.Id("ctx"), + jen.Qual("sigs.k8s.io/controller-runtime/pkg/client", "ObjectKey").Values(jen.Dict{ + jen.Id("Name"): jen.Id("crdName"), + }), + jen.Id("crd"), + ), + jen.If(jen.Id("err").Op("!=").Nil()).Block( + jen.Return(jen.Nil(), jen.Qual("fmt", "Errorf").Call( + jen.Lit("failed to resolve CRD %s: %w"), + jen.Id("crdName"), + jen.Id("err"), + )), + ), + jen.Line(), + jen.List(jen.Id("translator"), jen.Id("err")).Op(":=").Qual("github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/generated/translate", "NewTranslator").Call( + jen.Id("crd"), + jen.Id("storageVersion"), + jen.Id("targetVersion"), + ), + jen.If(jen.Id("err").Op("!=").Nil()).Block( + jen.Return(jen.Nil(), jen.Qual("fmt", "Errorf").Call( + jen.Lit("failed to setup translator: %w"), + jen.Id("err"), + )), + ), + jen.Line(), + jen.Return(jen.Op("&").Qual("github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/generated/translate", "Request").Values(jen.Dict{ + jen.Id("Translator"): jen.Id("translator"), + jen.Id("Dependencies"): jen.Nil(), + }), jen.Nil()), + ) +} + +func generateMainHandlerFile(dir, controllerName, resourceName, typesPath string, mappings []MappingWithConfig, refsByKind map[string][]ReferenceField, config *ParsedConfig) error { atlasResourceName := strings.ToLower(resourceName) apiPkg := typesPath @@ -223,6 +271,9 @@ func generateMainHandlerFile(dir, controllerName, resourceName, typesPath string f.ImportAlias(pkgCtrlState, "ctrlstate") + // Generate package-level helper function attached to the handler + generatePackageLevelTranslationHelper(f) + f.Comment("getHandlerForResource selects the appropriate version-specific handler based on which resource spec version is set") f.Func().Params(jen.Id("h").Op("*").Id(controllerName+"Handler")).Id("getHandlerForResource").Params( jen.Id(strings.ToLower(resourceName)).Op("*").Qual(apiPkg, resourceName), @@ -319,11 +370,89 @@ func generateDelegatingStateHandlers(f *jen.File, controllerName, resourceName, generateSetupWithManager(f, controllerName, resourceName, refsByKind) } -func generateVersionHandlerFile(dir, controllerName, resourceName, typesPath string, mapping MappingWithConfig) error { +func generateTranslationRequestWrapper(f *jen.File, controllerName, versionSuffix string, config *ParsedConfig) { + // Construct CRD name: {plural}.{group} + crdName := fmt.Sprintf("%s.%s", config.PluralName, config.CRDGroup) + + f.Comment("getTranslationRequest is a convenience wrapper for the package-level getTranslationRequest function") + f.Func().Params( + jen.Id("h").Op("*").Id(controllerName+"Handler"+versionSuffix), + ).Id("getTranslationRequest").Params( + jen.Id("ctx").Qual("context", "Context"), + ).Params( + jen.Op("*").Qual("github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/generated/translate", "Request"), + jen.Error(), + ).Block( + jen.Return(jen.Id("getTranslationRequest").Call( + jen.Id("ctx"), + jen.Id("h").Dot("client"), + jen.Lit(crdName), + jen.Lit(config.StorageVersion), + jen.Lit(versionSuffix), + )), + ) +} + +func generateSDKClientSetMethod(f *jen.File, controllerName, resourceName, apiPkg, versionSuffix string) { + resourceLower := strings.ToLower(resourceName) + + f.Comment("getSDKClientSet creates an Atlas SDK client set using credentials from the resource's connection secret") + f.Func().Params( + jen.Id("h").Op("*").Id(controllerName+"Handler"+versionSuffix), + ).Id("getSDKClientSet").Params( + jen.Id("ctx").Qual("context", "Context"), + jen.Id(resourceLower).Op("*").Qual(apiPkg, resourceName), + ).Params( + jen.Op("*").Qual("github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/atlas", "ClientSet"), + jen.Error(), + ).Block( + jen.List(jen.Id("connectionConfig"), jen.Id("err")).Op(":=").Qual("github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/reconciler", "GetConnectionConfig").Call( + jen.Id("ctx"), + jen.Id("h").Dot("client"), + jen.Op("&").Qual("sigs.k8s.io/controller-runtime/pkg/client", "ObjectKey").Values(jen.Dict{ + jen.Id("Namespace"): jen.Id(resourceLower).Dot("Namespace"), + jen.Id("Name"): jen.Id(resourceLower).Dot("Spec").Dot("ConnectionSecretRef").Dot("Name"), + }), + jen.Op("&").Id("h").Dot("globalSecretRef"), + ), + jen.If(jen.Id("err").Op("!=").Nil()).Block( + jen.Return(jen.Nil(), jen.Qual("fmt", "Errorf").Call( + jen.Lit("failed to resolve Atlas credentials: %w"), + jen.Id("err"), + )), + ), + jen.Line(), + jen.List(jen.Id("clientSet"), jen.Id("err")).Op(":=").Id("h").Dot("atlasProvider").Dot("SdkClientSet").Call( + jen.Id("ctx"), + jen.Id("connectionConfig").Dot("Credentials"), + jen.Id("h").Dot("log"), + ), + jen.If(jen.Id("err").Op("!=").Nil()).Block( + jen.Return(jen.Nil(), jen.Qual("fmt", "Errorf").Call( + jen.Lit("failed to setup Atlas SDK client: %w"), + jen.Id("err"), + )), + ), + jen.Line(), + jen.Return(jen.Id("clientSet"), jen.Nil()), + ) +} + +func generateVersionHandlerFile(dir, controllerName, resourceName, typesPath string, mapping MappingWithConfig, override bool, config *ParsedConfig) error { atlasResourceName := strings.ToLower(resourceName) versionSuffix := mapping.Version apiPkg := typesPath + fileName := filepath.Join(dir, "handler_"+versionSuffix+".go") + + // Check if a versioned handler file exists + if !override { + if _, err := os.Stat(fileName); err == nil { + fmt.Printf("Skipping versioned handler %s (already exists, use --override to overwrite)\n", fileName) + return nil + } + } + f := jen.NewFile(atlasResourceName) AddLicenseHeader(f) @@ -350,12 +479,15 @@ func generateVersionHandlerFile(dir, controllerName, resourceName, typesPath str })), ) + // ClientSet and translation request helpers + generateSDKClientSetMethod(f, controllerName, resourceName, apiPkg, versionSuffix) + generateTranslationRequestWrapper(f, controllerName, versionSuffix, config) + generateVersionStateHandlers(f, controllerName, resourceName, apiPkg, versionSuffix) // Generate For and SetupWithManager methods to satisfy StateHandler interface generateVersionInterfaceMethods(f, controllerName, resourceName, apiPkg, versionSuffix) - fileName := filepath.Join(dir, "handler_"+versionSuffix+".go") return f.Save(fileName) } diff --git a/tools/scaffolder/internal/generate/controller_test.go b/tools/scaffolder/internal/generate/controller_test.go new file mode 100644 index 0000000000..44477ae03b --- /dev/null +++ b/tools/scaffolder/internal/generate/controller_test.go @@ -0,0 +1,491 @@ +// Copyright 2025 MongoDB Inc +// +// 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 generate + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFromConfig_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + testYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: clusters.atlas.generated.mongodb.com + annotations: + api-mappings: | + properties: + spec: + properties: + v20250312: + x-atlas-sdk-version: go.mongodb.org/atlas-sdk/v20250312008/admin + properties: + groupRef: + x-kubernetes-mapping: + type: + kind: Group + group: atlas.generated.mongodb.com + version: v1 +spec: + group: atlas.generated.mongodb.com + names: + kind: Cluster + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + v20250312: + properties: + groupRef: + type: object +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.yaml") + err := os.WriteFile(testFile, []byte(testYAML), 0644) + require.NoError(t, err) + + controllerDir := filepath.Join(tmpDir, "controllers") + indexerDir := filepath.Join(tmpDir, "indexers") + + err = FromConfig(testFile, "Cluster", controllerDir, indexerDir, "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/nextapi/generated/v1", true) + require.NoError(t, err) + + clusterControllerDir := filepath.Join(controllerDir, "cluster") + assert.DirExists(t, clusterControllerDir) + + controllerFile := filepath.Join(clusterControllerDir, "cluster_controller.go") + assert.FileExists(t, controllerFile) + + handlerFile := filepath.Join(clusterControllerDir, "handler.go") + assert.FileExists(t, handlerFile) + + versionHandlerFile := filepath.Join(clusterControllerDir, "handler_v20250312.go") + assert.FileExists(t, versionHandlerFile) + + indexerFile := filepath.Join(indexerDir, "clusterbygroup.go") + assert.FileExists(t, indexerFile) +} + +func TestGenerateSetupWithManager_Watches(t *testing.T) { + testYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: clusters.atlas.generated.mongodb.com + annotations: + api-mappings: | + properties: + spec: + properties: + v20250312: + x-atlas-sdk-version: go.mongodb.org/atlas-sdk/v20250312008/admin + properties: + groupRef: + x-kubernetes-mapping: + type: + kind: Group + group: atlas.generated.mongodb.com + version: v1 +spec: + group: atlas.generated.mongodb.com + names: + kind: Cluster + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + v20250312: + properties: + groupRef: + type: object +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.yaml") + err := os.WriteFile(testFile, []byte(testYAML), 0644) + require.NoError(t, err) + + controllerDir := filepath.Join(tmpDir, "controllers") + indexerDir := filepath.Join(tmpDir, "indexers") + + err = FromConfig(testFile, "Cluster", controllerDir, indexerDir, "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/nextapi/generated/v1", true) + require.NoError(t, err) + + handlerFile := filepath.Join(controllerDir, "cluster", "handler.go") + content, err := os.ReadFile(handlerFile) + require.NoError(t, err) + + contentStr := string(content) + + assert.Contains(t, contentStr, "func (h *ClusterHandler) SetupWithManager") + + assert.Contains(t, contentStr, "Watches") + // Group conflicts with apiextensions, sometimes linted as v11 + assert.True(t, + strings.Contains(contentStr, "&v1.Group{}") || strings.Contains(contentStr, "&v11.Group{}"), + "Should contain Group reference") + + assert.Contains(t, contentStr, "func (h *ClusterHandler) clusterForGroupMapFunc()") + assert.Contains(t, contentStr, "ProjectsIndexMapperFunc") + + assert.Contains(t, contentStr, "ResourceVersionChangedPredicate") +} + +func TestGenerateMapperFunctions_MultipleReferences(t *testing.T) { + testYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: integrations.atlas.generated.mongodb.com + annotations: + api-mappings: | + properties: + spec: + properties: + v20250312: + x-atlas-sdk-version: go.mongodb.org/atlas-sdk/v20250312008/admin + properties: + groupRef: + x-kubernetes-mapping: + type: + kind: Group + group: atlas.generated.mongodb.com + version: v1 + apiKeyRef: + x-kubernetes-mapping: + type: + kind: Secret +spec: + group: atlas.generated.mongodb.com + names: + kind: Integration + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + v20250312: + properties: + groupRef: + type: object + apiKeyRef: + type: object +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.yaml") + err := os.WriteFile(testFile, []byte(testYAML), 0644) + require.NoError(t, err) + + controllerDir := filepath.Join(tmpDir, "controllers") + indexerDir := filepath.Join(tmpDir, "indexers") + + err = FromConfig(testFile, "Integration", controllerDir, indexerDir, "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/nextapi/generated/v1", true) + require.NoError(t, err) + + handlerFile := filepath.Join(controllerDir, "integration", "handler.go") + content, err := os.ReadFile(handlerFile) + require.NoError(t, err) + + contentStr := string(content) + + assert.Contains(t, contentStr, "integrationForGroupMapFunc") + assert.Contains(t, contentStr, "integrationForSecretMapFunc") + + // Check for Group and Secret references (may be aliased due to import conflicts) + assert.Contains(t, contentStr, ".Group{}") + assert.Contains(t, contentStr, ".Secret{}") + + assert.Contains(t, contentStr, "ProjectsIndexMapperFunc") + assert.Contains(t, contentStr, "CredentialsIndexMapperFunc") +} + +func TestGetWatchedTypeInstance(t *testing.T) { + tests := []struct { + kind string + expected string + }{ + {"Secret", "&corev1.Secret{}"}, + {"Group", "&v1.Group{}"}, + {"CustomResource", "&v1.CustomResource{}"}, + } + + for _, tt := range tests { + t.Run(tt.kind, func(t *testing.T) { + stmt := getWatchedTypeInstance(tt.kind) + result := stmt.GoString() + assert.Contains(t, result, tt.kind) + }) + } +} + +func TestGenerateController_NoReferences(t *testing.T) { + testYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: teams.atlas.generated.mongodb.com + annotations: + api-mappings: | + properties: + spec: + properties: + v20250312: + x-atlas-sdk-version: go.mongodb.org/atlas-sdk/v20250312008/admin +spec: + group: atlas.generated.mongodb.com + names: + kind: Team + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + v20250312: + properties: + name: + type: string +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.yaml") + err := os.WriteFile(testFile, []byte(testYAML), 0644) + require.NoError(t, err) + + controllerDir := filepath.Join(tmpDir, "controllers") + indexerDir := filepath.Join(tmpDir, "indexers") + + err = FromConfig(testFile, "Team", controllerDir, indexerDir, "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/nextapi/generated/v1", true) + require.NoError(t, err) + + handlerFile := filepath.Join(controllerDir, "team", "handler.go") + content, err := os.ReadFile(handlerFile) + require.NoError(t, err) + + contentStr := string(content) + + assert.Contains(t, contentStr, "func (h *TeamHandler) SetupWithManager") + + // Should not have Watches() calls because there are no refs + assert.NotContains(t, contentStr, ".Watches(") +} + +func TestGeneratedControllerStructure(t *testing.T) { + testYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: resources.atlas.generated.mongodb.com + annotations: + api-mappings: | + properties: + spec: + properties: + v20250312: + x-atlas-sdk-version: go.mongodb.org/atlas-sdk/v20250312008/admin +spec: + group: atlas.generated.mongodb.com + names: + kind: Resource + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + v20250312: + type: object +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.yaml") + err := os.WriteFile(testFile, []byte(testYAML), 0644) + require.NoError(t, err) + + controllerDir := filepath.Join(tmpDir, "controllers") + indexerDir := filepath.Join(tmpDir, "indexers") + + err = FromConfig(testFile, "Resource", controllerDir, indexerDir, "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/nextapi/generated/v1", true) + require.NoError(t, err) + + controllerFile := filepath.Join(controllerDir, "resource", "resource_controller.go") + content, err := os.ReadFile(controllerFile) + require.NoError(t, err) + + contentStr := string(content) + + assert.Contains(t, contentStr, "package resource") + assert.Contains(t, contentStr, "type ResourceHandler struct") + assert.Contains(t, contentStr, "+kubebuilder:rbac:groups=atlas.generated.mongodb.com,resources=resources") + assert.Contains(t, contentStr, "func NewResourceReconciler") +} + +func TestGeneratedHandlerDelegation(t *testing.T) { + testYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: resources.atlas.generated.mongodb.com + annotations: + api-mappings: | + properties: + spec: + properties: + v20250312: + x-atlas-sdk-version: go.mongodb.org/atlas-sdk/v20250312008/admin + v20250401: + x-atlas-sdk-version: go.mongodb.org/atlas-sdk/v20250401001/admin +spec: + group: atlas.generated.mongodb.com + names: + kind: Resource + plural: resources + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + v20250312: + type: object + v20250401: + type: object +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.yaml") + err := os.WriteFile(testFile, []byte(testYAML), 0644) + require.NoError(t, err) + + controllerDir := filepath.Join(tmpDir, "controllers") + indexerDir := filepath.Join(tmpDir, "indexers") + + err = FromConfig(testFile, "Resource", controllerDir, indexerDir, "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/nextapi/generated/v1", true) + require.NoError(t, err) + + handlerFile := filepath.Join(controllerDir, "resource", "handler.go") + content, err := os.ReadFile(handlerFile) + require.NoError(t, err) + + contentStr := string(content) + + assert.Contains(t, contentStr, "func (h *ResourceHandler) getHandlerForResource") + assert.Contains(t, contentStr, "func (h *ResourceHandler) HandleInitial") + assert.Contains(t, contentStr, "func (h *ResourceHandler) HandleCreating") + assert.Contains(t, contentStr, "func (h *ResourceHandler) HandleDeletionRequested") + v1Handler := filepath.Join(controllerDir, "resource", "handler_v20250312.go") + assert.FileExists(t, v1Handler) + + v2Handler := filepath.Join(controllerDir, "resource", "handler_v20250401.go") + assert.FileExists(t, v2Handler) +} + +func TestGeneratedHelperFunctions(t *testing.T) { + testYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: groups.atlas.generated.mongodb.com + annotations: + api-mappings: | + properties: + spec: + properties: + v20250312: + x-atlas-sdk-version: go.mongodb.org/atlas-sdk/v20250312008/admin +spec: + group: atlas.generated.mongodb.com + names: + kind: Group + plural: groups + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + v20250312: + type: object +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.yaml") + err := os.WriteFile(testFile, []byte(testYAML), 0644) + require.NoError(t, err) + + controllerDir := filepath.Join(tmpDir, "controllers") + indexerDir := filepath.Join(tmpDir, "indexers") + + err = FromConfig(testFile, "Group", controllerDir, indexerDir, "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/nextapi/generated/v1", true) + require.NoError(t, err) + + // Test handler.go contains package-level getTranslationRequest function + handlerFile := filepath.Join(controllerDir, "group", "handler.go") + content, err := os.ReadFile(handlerFile) + require.NoError(t, err) + contentStr := string(content) + + // Verify package-level getTranslationRequest function + assert.Contains(t, contentStr, "func getTranslationRequest(") + assert.Contains(t, contentStr, "ctx context.Context") + assert.Contains(t, contentStr, "client client.Client") + assert.Contains(t, contentStr, "crdName string") + assert.Contains(t, contentStr, "storageVersion string") + assert.Contains(t, contentStr, "targetVersion string") + assert.Contains(t, contentStr, "NewTranslator") + + // Test versioned handler contains helper methods + versionHandlerFile := filepath.Join(controllerDir, "group", "handler_v20250312.go") + versionContent, err := os.ReadFile(versionHandlerFile) + require.NoError(t, err) + versionContentStr := string(versionContent) + + // Verify getSDKClientSet method + assert.Contains(t, versionContentStr, "func (h *GroupHandlerv20250312) getSDKClientSet(") + assert.Contains(t, versionContentStr, "GetConnectionConfig") + assert.Contains(t, versionContentStr, "SdkClientSet") + assert.Contains(t, versionContentStr, "ConnectionSecretRef") + + // Verify getTranslationRequest wrapper method + assert.Contains(t, versionContentStr, "func (h *GroupHandlerv20250312) getTranslationRequest(") + assert.Contains(t, versionContentStr, "return getTranslationRequest(") + assert.Contains(t, versionContentStr, "groups.atlas.generated.mongodb.com") + assert.Contains(t, versionContentStr, "\"v1\"") // storage version + assert.Contains(t, versionContentStr, "\"v20250312\"") // target version +} diff --git a/tools/scaffolder/internal/generate/indexers_test.go b/tools/scaffolder/internal/generate/indexers_test.go new file mode 100644 index 0000000000..bf5f2eeead --- /dev/null +++ b/tools/scaffolder/internal/generate/indexers_test.go @@ -0,0 +1,442 @@ +// Copyright 2025 MongoDB Inc +// +// 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 generate + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseReferenceFields(t *testing.T) { + testYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: clusters.atlas.generated.mongodb.com + annotations: + api-mappings: | + properties: + spec: + properties: + v20250312: + properties: + groupRef: + x-kubernetes-mapping: + type: + kind: Group + group: atlas.generated.mongodb.com + version: v1 +spec: + group: atlas.generated.mongodb.com + names: + kind: Cluster + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + v20250312: + properties: + groupRef: + type: object + properties: + name: + type: string +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.yaml") + err := os.WriteFile(testFile, []byte(testYAML), 0644) + require.NoError(t, err) + + t.Run("ParseGroupReference", func(t *testing.T) { + refs, err := ParseReferenceFields(testFile, "Cluster") + require.NoError(t, err) + require.Len(t, refs, 1) + + ref := refs[0] + assert.Equal(t, "groupRef", ref.FieldName) + assert.Equal(t, "Group", ref.ReferencedKind) + assert.Equal(t, "project", ref.IndexerType) + assert.Contains(t, ref.FieldPath, "groupRef") + }) + + t.Run("ParseNonExistentCRD", func(t *testing.T) { + refs, err := ParseReferenceFields(testFile, "NonExistent") + assert.Error(t, err) + assert.Nil(t, refs) + }) +} + +func TestParseReferenceFields_ArrayReferences(t *testing.T) { + testYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: alerts.atlas.generated.mongodb.com + annotations: + api-mappings: | + properties: + spec: + properties: + v20250312: + properties: + notifications: + items: + properties: + secretRef: + x-kubernetes-mapping: + type: + kind: Secret +spec: + group: atlas.generated.mongodb.com + names: + kind: AlertConfig + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + v20250312: + properties: + notifications: + type: array + items: + properties: + secretRef: + type: object + properties: + name: + type: string +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.yaml") + err := os.WriteFile(testFile, []byte(testYAML), 0644) + require.NoError(t, err) + + refs, err := ParseReferenceFields(testFile, "AlertConfig") + require.NoError(t, err) + require.Len(t, refs, 1) + + assert.Contains(t, refs[0].FieldPath, ".items.") +} + +func TestBuildFieldAccessPath(t *testing.T) { + tests := []struct { + name string + fieldPath string + expected string + }{ + { + name: "Simple field path", + fieldPath: "properties.spec.properties.v20250312.properties.groupRef", + expected: "resource.Spec.V20250312.GroupRef", + }, + { + name: "Field path with array items", + fieldPath: "properties.spec.properties.v20250312.properties.notifications.items.properties.secretRef", + expected: "resource.Spec.V20250312.Notifications.SecretRef", + }, + { + name: "Nested field path", + fieldPath: "properties.spec.properties.entry.properties.apiKeyRef", + expected: "resource.Spec.Entry.ApiKeyRef", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildFieldAccessPath(tt.fieldPath) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGenerateIndexers_Integration(t *testing.T) { + testYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: clusters.atlas.generated.mongodb.com + annotations: + api-mappings: | + properties: + spec: + properties: + v20250312: + properties: + groupRef: + x-kubernetes-mapping: + type: + kind: Group + group: atlas.generated.mongodb.com + version: v1 +spec: + group: atlas.generated.mongodb.com + names: + kind: Cluster + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + v20250312: + properties: + groupRef: + type: object + properties: + name: + type: string +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.yaml") + err := os.WriteFile(testFile, []byte(testYAML), 0644) + require.NoError(t, err) + + outputDir := filepath.Join(tmpDir, "indexers") + + t.Run("GenerateIndexerFiles", func(t *testing.T) { + err := GenerateIndexers(testFile, "Cluster", outputDir) + require.NoError(t, err) + + indexerFile := filepath.Join(outputDir, "clusterbygroup.go") + assert.FileExists(t, indexerFile) + + content, err := os.ReadFile(indexerFile) + require.NoError(t, err) + + contentStr := string(content) + assert.Contains(t, contentStr, "package indexer") + assert.Contains(t, contentStr, "type ClusterByGroupIndexer struct") + assert.Contains(t, contentStr, "const ClusterByGroupIndex") + assert.Contains(t, contentStr, "func NewClusterByGroupIndexer") + assert.Contains(t, contentStr, "func (*ClusterByGroupIndexer) Object()") + assert.Contains(t, contentStr, "func (*ClusterByGroupIndexer) Name()") + assert.Contains(t, contentStr, "func (i *ClusterByGroupIndexer) Keys(") + assert.Contains(t, contentStr, "func ClusterRequestsFromGroup") + assert.Contains(t, contentStr, `"k8s.io/apimachinery/pkg/types"`) + assert.Contains(t, contentStr, `"sigs.k8s.io/controller-runtime/pkg/reconcile"`) + }) + + t.Run("SkipArrayReferences", func(t *testing.T) { + arrayYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: alerts.atlas.generated.mongodb.com + annotations: + api-mappings: | + properties: + spec: + properties: + v20250312: + properties: + notifications: + items: + properties: + secretRef: + x-kubernetes-mapping: + type: + kind: Secret +spec: + group: atlas.generated.mongodb.com + names: + kind: AlertConfig + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + v20250312: + properties: + notifications: + type: array + items: + properties: + secretRef: + type: object +` + arrayFile := filepath.Join(tmpDir, "array.yaml") + err := os.WriteFile(arrayFile, []byte(arrayYAML), 0644) + require.NoError(t, err) + + arrayOutputDir := filepath.Join(tmpDir, "array-indexers") + err = GenerateIndexers(arrayFile, "AlertConfig", arrayOutputDir) + require.NoError(t, err) + + files, err := os.ReadDir(arrayOutputDir) + if err == nil { + assert.Empty(t, files, "No indexer files should be generated for array references") + } + }) +} + +func TestGenerateIndexers_NoReferences(t *testing.T) { + testYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: teams.atlas.generated.mongodb.com +spec: + group: atlas.generated.mongodb.com + names: + kind: Team + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + v20250312: + properties: + name: + type: string +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.yaml") + err := os.WriteFile(testFile, []byte(testYAML), 0644) + require.NoError(t, err) + + outputDir := filepath.Join(tmpDir, "indexers") + + err = GenerateIndexers(testFile, "Team", outputDir) + require.NoError(t, err) + + files, err := os.ReadDir(outputDir) + if err == nil { + assert.Empty(t, files) + } +} + +func TestCapitalizeFirst(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"groupRef", "GroupRef"}, + {"v20250312", "V20250312"}, + {"spec", "Spec"}, + {"", ""}, + {"a", "A"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := capitalizeFirst(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestCreateIndexerInfoForKind(t *testing.T) { + refs := []ReferenceField{ + { + FieldName: "groupRef", + FieldPath: "properties.spec.properties.v20250312.properties.groupRef", + ReferencedKind: "Group", + IndexerType: "project", + }, + } + + indexer := createIndexerInfoForKind("Cluster", "Group", refs) + + assert.Equal(t, "Group", indexer.TargetKind) + assert.Equal(t, "ClusterByGroupIndex", indexer.ConstantName) + assert.Equal(t, "Cluster", indexer.ResourceName) + assert.Equal(t, "cluster.groupRef", indexer.IndexerName) + assert.Equal(t, refs, indexer.ReferenceFields) +} + +func TestGenerateRequestsFunction_UniqueNames(t *testing.T) { + tmpDir := t.TempDir() + outputDir := filepath.Join(tmpDir, "indexers") + err := os.MkdirAll(outputDir, 0755) + require.NoError(t, err) + + testYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: integrations.atlas.generated.mongodb.com + annotations: + api-mappings: | + properties: + spec: + properties: + v20250312: + properties: + groupRef: + x-kubernetes-mapping: + type: + kind: Group + group: atlas.generated.mongodb.com + version: v1 + apiKeyRef: + x-kubernetes-mapping: + type: + kind: Secret +spec: + group: atlas.generated.mongodb.com + names: + kind: Integration + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + v20250312: + properties: + groupRef: + type: object + apiKeyRef: + type: object +` + + testFile := filepath.Join(tmpDir, "test.yaml") + err = os.WriteFile(testFile, []byte(testYAML), 0644) + require.NoError(t, err) + + err = GenerateIndexers(testFile, "Integration", outputDir) + require.NoError(t, err) + + groupFile, err := os.ReadFile(filepath.Join(outputDir, "integrationbygroup.go")) + require.NoError(t, err) + + secretFile, err := os.ReadFile(filepath.Join(outputDir, "integrationbysecret.go")) + require.NoError(t, err) + + assert.Contains(t, string(groupFile), "IntegrationRequestsFromGroup") + assert.Contains(t, string(secretFile), "IntegrationRequestsFromSecret") + assert.NotContains(t, string(groupFile), "func IntegrationRequests(") + assert.NotContains(t, string(secretFile), "func IntegrationRequests(") +}