diff --git a/.gitignore b/.gitignore index 4e4c85f..8021fde 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ cnoe* go.sum +.vscode diff --git a/go.mod b/go.mod index 06a6dac..3e396dd 100644 --- a/go.mod +++ b/go.mod @@ -5,17 +5,22 @@ go 1.19 require ( github.com/fatih/color v1.15.0 github.com/hashicorp/go-multierror v1.1.1 + github.com/hashicorp/terraform-config-inspect v0.0.0-20230614215431-f32df32a01cd + github.com/itchyny/gojq v0.12.13 github.com/maxbrunsfeld/counterfeiter/v6 v6.6.2 github.com/onsi/ginkgo/v2 v2.9.7 github.com/onsi/gomega v1.27.8 github.com/spf13/cobra v1.7.0 gopkg.in/yaml.v3 v3.0.1 - k8s.io/api v0.27.3 - k8s.io/apimachinery v0.27.3 - k8s.io/client-go v0.27.3 + k8s.io/api v0.27.4 + k8s.io/apimachinery v0.27.4 + k8s.io/client-go v0.27.4 + sigs.k8s.io/yaml v1.3.0 ) require ( + github.com/agext/levenshtein v1.2.2 // indirect + github.com/apparentlymart/go-textseg v1.0.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emicklei/go-restful/v3 v3.9.0 // indirect github.com/go-logr/logr v1.2.4 // indirect @@ -31,17 +36,22 @@ require ( github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 // indirect github.com/google/uuid v1.3.0 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f // indirect + github.com/hashicorp/hcl/v2 v2.0.0 // indirect github.com/imdario/mergo v0.3.6 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/timefmt-go v0.1.5 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mitchellh/go-wordwrap v1.0.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/zclconf/go-cty v1.1.0 // indirect golang.org/x/mod v0.11.0 // indirect golang.org/x/net v0.11.0 // indirect golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b // indirect @@ -59,5 +69,4 @@ require ( k8s.io/utils v0.0.0-20230209194617-a36077c30491 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect - sigs.k8s.io/yaml v1.3.0 // indirect ) diff --git a/hack/build.sh b/hack/build.sh index 76cc21a..fd4449f 100755 --- a/hack/build.sh +++ b/hack/build.sh @@ -2,6 +2,7 @@ set -e -x -u +go mod tidy # go test ./... go fmt ./cmd/... ./pkg/... diff --git a/pkg/cmd/crd.go b/pkg/cmd/crd.go index b89bde4..35c8a09 100644 --- a/pkg/cmd/crd.go +++ b/pkg/cmd/crd.go @@ -1,32 +1,27 @@ package cmd import ( + "context" "errors" "fmt" - "io" - "io/ioutil" + "log" "os" "path/filepath" "strings" "github.com/cnoe-io/cnoe-cli/pkg/models" "github.com/spf13/cobra" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) const ( - defDir = "resources" KindXRD = "CompositeResourceDefinition" KindCRD = "CustomResourceDefinition" ) var ( - inputDir string - outputDir string - templatePath string - verifiers []string - namespaced bool + verifiers []string templateName string templateTitle string @@ -35,294 +30,223 @@ var ( func init() { templateCmd.AddCommand(crdCmd) - crdCmd.Flags().StringVarP(&templatePath, "templatePath", "t", "scaffolding/template.yaml", "path to the template to be augmented with backstage info") crdCmd.Flags().StringArrayVarP(&verifiers, "verifier", "v", []string{}, "list of verifiers to test the resource against") - crdCmd.Flags().BoolVarP(&namespaced, "namespaced", "n", false, "whether or not resources are namespaced") crdCmd.Flags().StringVarP(&templateName, "templateName", "", "", "sets the name of the template") crdCmd.Flags().StringVarP(&templateTitle, "templateTitle", "", "", "sets the title of the template") crdCmd.Flags().StringVarP(&templateDescription, "templateDescription", "", "", "sets the description of the template") - - crdCmd.MarkFlagRequired("templatePath") } var ( crdCmd = &cobra.Command{ - Use: "crd", - Short: "Generate backstage templates from CRD/XRD", - Long: `Generate backstage templates from supplied CRD and XRD definitions`, - PreRunE: func(cmd *cobra.Command, args []string) error { - if !isDirectory(inputDir) || !isDirectory(outputDir) { - return errors.New("inputDir and ouputDir entries need to be directories") - } - return nil - }, - RunE: crd, + Use: "crd", + Short: "Generate backstage templates from CRD/XRD", + Long: `Generate backstage templates from supplied CRD and XRD definitions`, + PreRunE: templatePreRunE, + RunE: crd, } ) -type cmdOutput struct { - Templates []string - Resources []string -} - func crd(cmd *cobra.Command, args []string) error { - return Crd( - cmd.OutOrStdout(), cmd.OutOrStderr(), - inputDir, outputDir, templatePath, - verifiers, namespaced, - templateName, templateTitle, templateDescription, + return Process( + cmd.Context(), + NewCRDModule( + inputDir, outputDir, templatePath, insertionPoint, collapsed, raw, + verifiers, templateName, templateTitle, templateDescription, + ), ) } -func Crd( - stdout, stderr io.Writer, - inputDir, outputDir, templatePath string, - verifiers []string, namespaced bool, - templateName, templateTitle, templateDescription string, -) error { - defs := defs(inputDir, 0) - - output, err := writeSchema( - stdout, stderr, - outputDir, - defs, - ) - if err != nil { - return err - } - - err = writeToTemplate( - stdout, stderr, - templatePath, - outputDir, - output.Resources, 0, - templateName, - templateTitle, - templateDescription, - ) +type CRDModule struct { + EntityConfig + verifiers []string + templateName string + templateTitle string + templateDescription string +} - if err != nil { - return err +func NewCRDModule( + inputDir, outputDir, templatePath, insertionPoint string, collapsed, raw bool, + verifiers []string, templateName, templateTitle, templateDescription string, +) Entity { + return &CRDModule{ + EntityConfig: EntityConfig{ + InputDir: inputDir, + OutputDir: outputDir, + TemplateFile: templatePath, + InsertionPoint: insertionPoint, + Collapsed: collapsed, + Raw: raw, + }, + verifiers: verifiers, + templateName: templateName, + templateTitle: templateTitle, + templateDescription: templateDescription, } +} - return nil +func (c *CRDModule) Config() EntityConfig { + return c.EntityConfig } -func defs(dir string, depth int) []string { - if depth > 2 { - return nil +func (c *CRDModule) GetDefinitions(inputDir string, currentDepth uint32) ([]string, error) { + if currentDepth > depth { + return nil, nil } - - var out []string - base, _ := filepath.Abs(dir) - files, _ := ioutil.ReadDir(base) - for _, file := range files { - f := filepath.Join(base, file.Name()) - stat, _ := os.Stat(f) - if stat.IsDir() { - out = append(out, defs(f, depth+1)...) - continue - } - - out = append(out, f) + out, err := getRelevantFiles(inputDir, currentDepth, c.findDefs) + if err != nil { + return nil, err } - - return out + return out, nil } -func writeSchema(stdout, stderr io.Writer, outputDir string, defs []string) (cmdOutput, error) { - out := cmdOutput{ - Templates: make([]string, 0), - Resources: make([]string, 0), +func (c *CRDModule) findDefs(file os.DirEntry, currentDepth uint32, base string) ([]string, error) { + f := filepath.Join(base, file.Name()) + stat, err := os.Stat(f) + if err != nil { + return nil, err } - - templateOutputDir := fmt.Sprintf("%s/%s", outputDir, defDir) - _, err := os.Stat(templateOutputDir) - if os.IsNotExist(err) { - // Directory doesn't exist, so create it - err := os.MkdirAll(templateOutputDir, 0755) + if stat.IsDir() { + df, err := c.GetDefinitions(f, currentDepth+1) if err != nil { - return cmdOutput{}, err + return nil, err } - fmt.Fprintf(stdout, "Directory created successfully!") - } else if err != nil { - return cmdOutput{}, err + return df, nil } + return []string{f}, nil +} - for _, def := range defs { - data, err := ioutil.ReadFile(def) - if err != nil { - continue - } - - var doc models.Definition - err = yaml.Unmarshal(data, &doc) - if err != nil { - continue - } - - if !isXRD(doc) && !isCRD(doc) { - continue - } - - fmt.Fprintf(stdout, "foud: %s\n", def) - var resourceName string - if doc.Spec.ClaimNames != nil { - resourceName = doc.Spec.ClaimNames.Kind - } else { - resourceName = fmt.Sprintf("%s.%s", doc.Spec.Group, doc.Spec.Names.Kind) +func (c *CRDModule) HandleEntry(ctx context.Context, def, expectedOutDir, templateFile string) (any, string, error) { + log.Printf("processing resource at %s", def) + converted, resourceName, err := convert(def) + if err != nil { + var e NotSupported + if errors.As(err, &e) { + return nil, "", nil } + return nil, "", err + } - var value map[string]interface{} - - v := doc.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"] - if v == nil { - value = doc.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties - } else { - value, err = ConvertMap(v) - if err != nil { - fmt.Fprintf(stdout, "failed %s: %s \n", def, err.Error()) - continue - } - } + fileName := filepath.Join(expectedOutDir, fmt.Sprintf("%s.yaml", strings.ToLower(resourceName))) + content, err := c.createContent(ctx, converted, templateFile) + if err != nil { + log.Printf("failed to write %s: %s \n", def, err.Error()) + return nil, "", err + } - obj := &unstructured.Unstructured{ - Object: make(map[string]interface{}, 0), - } - unstructured.SetNestedSlice(obj.Object, ConvertSlice([]string{resourceName}), "properties", "resources", "enum") - unstructured.SetNestedMap(obj.Object, value, "properties", "config") - unstructured.SetNestedField(obj.Object, fmt.Sprintf("%s configuration options", resourceName), "properties", "config", "title") - - // setting GVK for the resource - if len(doc.Spec.Versions) > 0 { - unstructured.SetNestedMap(obj.Object, map[string]interface{}{ - "type": "string", - "description": "APIVersion for the resource", - "default": fmt.Sprintf("%s/%s", doc.Spec.Group, doc.Spec.Versions[0].Name), - }, - "properties", "apiVersion") - unstructured.SetNestedMap(obj.Object, map[string]interface{}{ - "type": "string", - "description": "Kind for the resource", - "default": doc.Spec.Names.Kind, - }, - "properties", "kind") - } + return content, fileName, nil +} - // add a property to define the namespace for the resource - if namespaced { - unstructured.SetNestedMap(obj.Object, map[string]interface{}{ - "type": "string", - "description": "Namespace for the resource", - "namespace": "default", - }, - "properties", "namespace") +func (c *CRDModule) createContent(ctx context.Context, converted any, templateFile string) (any, error) { + if shouldCreateNonCollapsedTemplate(c) { + input := insertAtInput{ + templatePath: templateFile, + jqPathExpression: c.InsertionPoint, } - - // add verifiers to the resource - if len(verifiers) > 0 { - var convertedVerifiers []interface{} = make([]interface{}, len(verifiers)) - for i, v := range verifiers { - convertedVerifiers[i] = v + props := converted.(map[string]any) + if v, reqOk := props["required"]; reqOk { + if reqs, ok := v.([]string); ok { + input.required = reqs } - - unstructured.SetNestedMap(obj.Object, map[string]interface{}{ - "type": "array", - "description": "verifiers to be used against the resource", - "items": map[string]interface{}{"type": "string"}, - "default": convertedVerifiers, - }, - "properties", "verifiers") } - - wrapperData, err := yaml.Marshal(obj.Object) + input.fields = props + converted, err := insertAt(ctx, input) if err != nil { - fmt.Fprintf(stdout, "failed %s: %s \n", def, err.Error()) - continue + return nil, err } - - template := fmt.Sprintf("%s/%s.yaml", templateOutputDir, strings.ToLower(resourceName)) - err = ioutil.WriteFile(template, []byte(wrapperData), 0644) - if err != nil { - fmt.Fprintf(stdout, "failed %s: %s \n", def, err.Error()) - continue - } - - out.Templates = append(out.Templates, template) - out.Resources = append(out.Resources, resourceName) + return converted, nil } - return out, nil + return converted, nil } -func writeToTemplate( - stdout, stderr io.Writer, - templateFile string, outputPath string, identifiedResources []string, position int, - templateName, templateTitle, templateDescription string, -) error { - templateData, err := ioutil.ReadFile(templateFile) +func convert(def string) (any, string, error) { + data, err := os.ReadFile(def) if err != nil { - return err + return nil, "", err } - - var doc models.Template - err = yaml.Unmarshal(templateData, &doc) + var doc models.Definition + err = yaml.Unmarshal(data, &doc) if err != nil { - return err + log.Printf("failed to read %s. This file will be excluded. %s", def, err) + return nil, "", NotSupported{ + fmt.Errorf("%s is not a kubernetes file", def), + } } - if templateName != "" { - doc.Metadata.Name = templateName + if !isXRD(doc) && !isCRD(doc) { + return nil, "", NotSupported{ + fmt.Errorf("%s is not a CRD or XRD", def), + } } - if templateTitle != "" { - doc.Metadata.Title = templateTitle + var resourceName string + if doc.Spec.ClaimNames != nil { + resourceName = doc.Spec.ClaimNames.Kind + } else { + resourceName = fmt.Sprintf("%s.%s", doc.Spec.Group, doc.Spec.Names.Kind) } - if templateDescription != "" { - doc.Metadata.Description = templateDescription - } + var value map[string]interface{} - dependencies := struct { - Resources struct { - OneOf []map[string]interface{} `yaml:"oneOf,omitempty"` - } `yaml:"resources,omitempty"` - }{} - - resources := struct { - Type string `yaml:"type"` - Enum []string `yaml:"enum"` - }{ - Type: "string", - Enum: identifiedResources, + v := doc.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"] + if v == nil { + value = doc.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties + } else { + value, err = ConvertMap(v) + if err != nil { + return nil, "", err + } } - for _, r := range identifiedResources { - dependencies.Resources.OneOf = append(dependencies.Resources.OneOf, map[string]interface{}{ - "$yaml": fmt.Sprintf("resources/%s.yaml", strings.ToLower(r)), - }) + obj := &unstructured.Unstructured{ + Object: make(map[string]interface{}, 0), } - - if len(doc.Spec.Parameters) <= position { - return errors.New("not the right template or input format") + unstructured.SetNestedMap(obj.Object, value, "properties", "config") + unstructured.SetNestedField(obj.Object, fmt.Sprintf("%s configuration options", resourceName), "properties", "config", "title") + + // setting GVK for the resource + if len(doc.Spec.Versions) > 0 { + unstructured.SetNestedMap(obj.Object, map[string]interface{}{ + "type": "string", + "description": "APIVersion for the resource", + "default": fmt.Sprintf("%s/%s", doc.Spec.Group, doc.Spec.Versions[0].Name), + }, + "properties", "apiVersion") + unstructured.SetNestedMap(obj.Object, map[string]interface{}{ + "type": "string", + "description": "Kind for the resource", + "default": doc.Spec.Names.Kind, + }, + "properties", "kind") } - doc.Spec.Parameters[position].Properties["resources"] = resources - doc.Spec.Parameters[position].Dependencies = dependencies - - outputData, err := yaml.Marshal(&doc) - if err != nil { - return err + // add a property to define the namespace for the resource + if doc.Spec.Scope == "Namespaced" { + unstructured.SetNestedMap(obj.Object, map[string]interface{}{ + "type": "string", + "description": "Namespace for the resource", + "namespace": "default", + }, + "properties", "namespace") } - err = ioutil.WriteFile(fmt.Sprintf("%s/template.yaml", outputPath), outputData, 0644) - if err != nil { - return err - } + // add verifiers to the resource + if len(verifiers) > 0 { + var convertedVerifiers []interface{} = make([]interface{}, len(verifiers)) + for i, v := range verifiers { + convertedVerifiers[i] = v + } - fmt.Fprintf(stdout, "Template successfully written.") - return nil + unstructured.SetNestedMap(obj.Object, map[string]interface{}{ + "type": "array", + "description": "verifiers to be used against the resource", + "items": map[string]interface{}{"type": "string"}, + "default": convertedVerifiers, + }, + "properties", "verifiers") + } + return obj.Object, resourceName, nil } func ConvertSlice(strSlice []string) []interface{} { @@ -334,32 +258,26 @@ func ConvertSlice(strSlice []string) []interface{} { } func ConvertMap(originalData interface{}) (map[string]interface{}, error) { - originalMap, ok := originalData.(map[interface{}]interface{}) + originalMap, ok := originalData.(map[string]interface{}) if !ok { - return nil, errors.New("failed to convert to interface map") + return nil, errors.New("conversion failed: data is not map[string]interface{}") } convertedMap := make(map[string]interface{}) for key, value := range originalMap { - strKey, ok := key.(string) - if !ok { - // Skip the key if it cannot be converted to string - continue - } - switch v := value.(type) { case map[interface{}]interface{}: // If the value is a nested map, recursively convert it var err error - convertedMap[strKey], err = ConvertMap(v) + convertedMap[key], err = ConvertMap(v) if err != nil { - return nil, errors.New(fmt.Sprintf("failed to convert for key %s", strKey)) + return nil, fmt.Errorf("failed to convert for key %s", key) } case int: - convertedMap[strKey] = int64(v) + convertedMap[key] = int64(v) case int32: - convertedMap[strKey] = int64(v) + convertedMap[key] = int64(v) case []interface{}: dv := make([]interface{}, len(v)) for i, ve := range v { @@ -367,7 +285,7 @@ func ConvertMap(originalData interface{}) (map[string]interface{}, error) { case map[interface{}]interface{}: ivec, err := ConvertMap(ive) if err != nil { - return nil, errors.New(fmt.Sprintf("failed to convert for key %s", strKey)) + return nil, fmt.Errorf("failed to convert for key %s", key) } dv[i] = ivec case int: @@ -378,10 +296,10 @@ func ConvertMap(originalData interface{}) (map[string]interface{}, error) { dv[i] = ive } } - convertedMap[strKey] = dv + convertedMap[key] = dv default: // Otherwise, add the key-value pair to the converted map - convertedMap[strKey] = v + convertedMap[key] = v } } @@ -395,15 +313,3 @@ func isXRD(m models.Definition) bool { func isCRD(m models.Definition) bool { return m.Kind == KindCRD } - -func isDirectory(path string) bool { - // Get file information - info, err := os.Stat(path) - if err != nil { - // Error occurred, path does not exist or cannot be accessed - return false - } - - // Check if the path is a directory - return info.Mode().IsDir() -} diff --git a/pkg/cmd/crd_test.go b/pkg/cmd/crd_test.go index eead1df..8990ea5 100644 --- a/pkg/cmd/crd_test.go +++ b/pkg/cmd/crd_test.go @@ -1,27 +1,24 @@ package cmd_test import ( + "context" "fmt" - "path" + "log" + "os" + "path/filepath" "github.com/cnoe-io/cnoe-cli/pkg/cmd" - "github.com/cnoe-io/cnoe-cli/pkg/models" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/onsi/gomega/gbytes" - "gopkg.in/yaml.v3" - - "os" - "path/filepath" ) -var _ = Describe("Template", func() { +var _ = Describe("Template CRDs", func() { var ( tempDir string outputDir string stdout *gbytes.Buffer - stderr *gbytes.Buffer ) const ( @@ -29,11 +26,11 @@ var _ = Describe("Template", func() { templateTitle = "test-title" templateDescription = "test-description" - inputDir = "./fakes/in-resource" - invalidInputDir = "./fakes/invalid-in-resource" + inputDir = "./fakes/crd/valid/input" + validOutputDir = "./fakes/crd/valid/output" + invalidInputDir = "./fakes/crd/invalid/input" templateFile = "./fakes/template/input-template.yaml" - expectedTemplateFile = "./fakes/template/output-template.yaml" - expectedResourceFile = "./fakes/out-resource/output-resource.yaml" + expectedTemplateFile = "./fakes/crd/valid/output/full-template-oneof.yaml" ) BeforeEach(func() { @@ -46,7 +43,7 @@ var _ = Describe("Template", func() { Expect(err).NotTo(HaveOccurred()) stdout = gbytes.NewBuffer() - stderr = gbytes.NewBuffer() + log.SetOutput(stdout) }) AfterEach(func() { @@ -54,69 +51,77 @@ var _ = Describe("Template", func() { Expect(err).NotTo(HaveOccurred()) }) - Context("with valid input", func() { + Context("with valid input with oneof", func() { BeforeEach(func() { - err := cmd.Crd(stdout, stderr, inputDir, outputDir, templateFile, - []string{}, false, templateName, templateTitle, templateDescription, - ) + err := cmd.Process(context.Background(), cmd.NewCRDModule(inputDir, outputDir, templateFile, ".spec.parameters[0]", true, false, + []string{}, templateName, templateTitle, templateDescription, + )) Expect(err).NotTo(HaveOccurred()) }) It("should create the template files for valid definitions", func() { expectedTemplateData, err := os.ReadFile(expectedTemplateFile) Expect(err).NotTo(HaveOccurred()) - - var expectedTemplate models.Template - err = yaml.Unmarshal(expectedTemplateData, &expectedTemplate) - Expect(err).NotTo(HaveOccurred()) - generatedTemplateData, err := os.ReadFile(fmt.Sprintf("%s/%s", outputDir, "template.yaml")) Expect(err).NotTo(HaveOccurred()) - var generatedTemplate models.Template - err = yaml.Unmarshal(generatedTemplateData, &generatedTemplate) - Expect(err).NotTo(HaveOccurred()) - Expect(generatedTemplate).To(Equal(expectedTemplate)) + Expect(expectedTemplateData).To(MatchYAML(generatedTemplateData)) }) It("should create valid resources", func() { - resourceDir := fmt.Sprintf("%s/%s", outputDir, "resources") + resourceDir := filepath.Join(outputDir, "resources") files, err := os.ReadDir(resourceDir) Expect(err).NotTo(HaveOccurred()) - Expect(len(files)).To(Equal(1)) - - filePath := path.Join(resourceDir, files[0].Name()) - Expect(err).NotTo(HaveOccurred()) - generatedResourceData, err := os.ReadFile(filePath) - Expect(err).NotTo(HaveOccurred()) + Expect(len(files)).To(Equal(2)) + + for i := range files { + filePath := filepath.Join(resourceDir, files[i].Name()) + genrated, err := os.ReadFile(filePath) + Expect(err).NotTo(HaveOccurred()) + propFile := fmt.Sprintf("properties-%s", files[i].Name()) + expected, err := os.ReadFile(filepath.Join(validOutputDir, propFile)) + Expect(err).NotTo(HaveOccurred()) + Expect(genrated).To(MatchYAML(expected)) + } + }) + }) - var generatedResource models.Definition - err = yaml.Unmarshal(generatedResourceData, &generatedResource) + Context("with valid input and specify template file and jq path", func() { + BeforeEach(func() { + err := cmd.Process(context.Background(), cmd.NewCRDModule(inputDir, outputDir, templateFile, ".spec.parameters[0]", false, false, + []string{}, templateName, templateTitle, templateDescription, + )) Expect(err).NotTo(HaveOccurred()) + }) - expectedResourceData, err := os.ReadFile(expectedResourceFile) - Expect(err).NotTo(HaveOccurred()) - var expectedResource models.Definition - err = yaml.Unmarshal(expectedResourceData, &expectedResource) + It("should create valid backstage template for each definition", func() { + files, err := os.ReadDir(outputDir) Expect(err).NotTo(HaveOccurred()) - - Expect(generatedResource).To(Equal(expectedResource)) + Expect(len(files)).To(Equal(2)) + for i := range files { + filePath := filepath.Join(outputDir, files[i].Name()) + generated, err := os.ReadFile(filePath) + Expect(err).NotTo(HaveOccurred()) + propFile := fmt.Sprintf("full-template-%s", files[i].Name()) + expected, err := os.ReadFile(filepath.Join(validOutputDir, propFile)) + Expect(err).NotTo(HaveOccurred()) + Expect(generated).To(MatchYAML(expected)) + } }) }) - Context("with invalid input files", func() { + Context("with invalid input only", func() { BeforeEach(func() { - err := cmd.Crd(stdout, stderr, invalidInputDir, outputDir, templateFile, - []string{}, false, templateName, templateTitle, templateDescription, - ) + err := cmd.Process(context.Background(), cmd.NewCRDModule(invalidInputDir, outputDir, "", "", false, false, + []string{}, templateName, templateTitle, templateDescription, + )) Expect(err).NotTo(HaveOccurred()) }) - It("should create the template files for valid definitions only", func() { - resourceDir := fmt.Sprintf("%s/%s", outputDir, "resources") - files, err := os.ReadDir(resourceDir) + It("should not create any files", func() { + files, err := os.ReadDir(outputDir) Expect(err).NotTo(HaveOccurred()) - Expect(len(files)).To(Equal(1)) + Expect(len(files)).To(Equal(0)) }) }) }) diff --git a/pkg/cmd/fakes/invalid-in-resource/invalid-input-resource.yaml b/pkg/cmd/fakes/crd/invalid/input/invalid-input-resource.yaml similarity index 100% rename from pkg/cmd/fakes/invalid-in-resource/invalid-input-resource.yaml rename to pkg/cmd/fakes/crd/invalid/input/invalid-input-resource.yaml diff --git a/pkg/cmd/fakes/crd/valid/input/cdn.yaml b/pkg/cmd/fakes/crd/valid/input/cdn.yaml new file mode 100644 index 0000000..23dca32 --- /dev/null +++ b/pkg/cmd/fakes/crd/valid/input/cdn.yaml @@ -0,0 +1,73 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xcdns.awsblueprints.io +spec: + claimNames: + kind: CDN + plural: cdns + group: awsblueprints.io + names: + kind: XCDN + plural: xcdns + connectionSecretKeys: + - region + - bucket-name + - s3-put-policy + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + properties: + spec: + properties: + resourceConfig: + description: ResourceConfig defines general properties of this AWS + resource. + properties: + deletionPolicy: + description: Defaults to Delete + enum: + - Delete + - Orphan + type: string + name: + description: Set the name of this resource in AWS to the value + provided by this field. + type: string + providerConfigName: + type: string + region: + type: string + tags: + items: + properties: + key: + type: string + value: + type: string + required: + - key + - value + type: object + type: array + required: + - providerConfigName + - region + - tags + type: object + required: + - resourceConfig + type: object + status: + properties: + bucketName: + type: string + bucketArn: + type: string + oaiId: + type: string + type: object + type: object diff --git a/pkg/cmd/fakes/crd/valid/input/service.yaml b/pkg/cmd/fakes/crd/valid/input/service.yaml new file mode 100644 index 0000000..afcf117 --- /dev/null +++ b/pkg/cmd/fakes/crd/valid/input/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: my-service +spec: + selector: + app.kubernetes.io/name: MyApp + ports: + - protocol: TCP + port: 80 + targetPort: 9376 diff --git a/pkg/cmd/fakes/in-resource/input-resource.yaml b/pkg/cmd/fakes/crd/valid/input/sparkapp.yaml similarity index 100% rename from pkg/cmd/fakes/in-resource/input-resource.yaml rename to pkg/cmd/fakes/crd/valid/input/sparkapp.yaml diff --git a/pkg/cmd/fakes/crd/valid/output/full-template-awsblueprints.io.xcdn.yaml b/pkg/cmd/fakes/crd/valid/output/full-template-awsblueprints.io.xcdn.yaml new file mode 100644 index 0000000..233bcef --- /dev/null +++ b/pkg/cmd/fakes/crd/valid/output/full-template-awsblueprints.io.xcdn.yaml @@ -0,0 +1,74 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + description: Deploy Resource to Kubernetes + name: deploy-resources + title: Deploy Resources +spec: + owner: guest + parameters: + - description: Select a AWS resource to add to your repository. + properties: + apiVersion: + default: awsblueprints.io/v1alpha1 + description: APIVersion for the resource + type: string + config: + properties: + resourceConfig: + description: ResourceConfig defines general properties of this AWS resource. + properties: + deletionPolicy: + description: Defaults to Delete + enum: + - Delete + - Orphan + type: string + name: + description: Set the name of this resource in AWS to the value provided by this field. + type: string + providerConfigName: + type: string + region: + type: string + tags: + items: + properties: + key: + type: string + value: + type: string + required: + - key + - value + type: object + type: array + required: + - providerConfigName + - region + - tags + type: object + required: + - resourceConfig + title: awsblueprints.io.XCDN configuration options + type: object + kind: + default: XCDN + description: Kind for the resource + type: string + name: + description: name of this resource. This will be the name of K8s object. + type: string + path: + default: kustomize/base + description: path to place this file into + type: string + required: + - awsResources + - name + title: Choose Resource + steps: + - action: cnoe:verify:dependency + id: verify + name: verify + type: service diff --git a/pkg/cmd/fakes/crd/valid/output/full-template-oneof.yaml b/pkg/cmd/fakes/crd/valid/output/full-template-oneof.yaml new file mode 100644 index 0000000..ea062bd --- /dev/null +++ b/pkg/cmd/fakes/crd/valid/output/full-template-oneof.yaml @@ -0,0 +1,37 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + description: Deploy Resource to Kubernetes + name: deploy-resources + title: Deploy Resources +spec: + owner: guest + parameters: + - dependencies: + resources: + oneOf: + - $yaml: resources/awsblueprints.io.xcdn.yaml + - $yaml: resources/sparkoperator.k8s.io.sparkapplication.yaml + description: Select a AWS resource to add to your repository. + properties: + name: + description: name of this resource. This will be the name of K8s object. + type: string + path: + default: kustomize/base + description: path to place this file into + type: string + resources: + enum: + - awsblueprints.io.xcdn + - sparkoperator.k8s.io.sparkapplication + type: string + required: + - awsResources + - name + title: Choose Resource + steps: + - action: cnoe:verify:dependency + id: verify + name: verify + type: service diff --git a/pkg/cmd/fakes/crd/valid/output/full-template-sparkoperator.k8s.io.sparkapplication.yaml b/pkg/cmd/fakes/crd/valid/output/full-template-sparkoperator.k8s.io.sparkapplication.yaml new file mode 100644 index 0000000..b08221b --- /dev/null +++ b/pkg/cmd/fakes/crd/valid/output/full-template-sparkoperator.k8s.io.sparkapplication.yaml @@ -0,0 +1,63 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + description: Deploy Resource to Kubernetes + name: deploy-resources + title: Deploy Resources +spec: + owner: guest + parameters: + - description: Select a AWS resource to add to your repository. + properties: + apiVersion: + default: sparkoperator.k8s.io/v1beta2 + description: APIVersion for the resource + type: string + config: + properties: + arguments: + items: + type: string + type: array + batchScheduler: + type: string + batchSchedulerOptions: + properties: + priorityClassName: + type: string + queue: + type: string + resources: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + type: object + title: sparkoperator.k8s.io.SparkApplication configuration options + kind: + default: SparkApplication + description: Kind for the resource + type: string + name: + description: name of this resource. This will be the name of K8s object. + type: string + namespace: + description: Namespace for the resource + namespace: default + type: string + path: + default: kustomize/base + description: path to place this file into + type: string + required: + - awsResources + - name + title: Choose Resource + steps: + - action: cnoe:verify:dependency + id: verify + name: verify + type: service diff --git a/pkg/cmd/fakes/crd/valid/output/properties-awsblueprints.io.xcdn.yaml b/pkg/cmd/fakes/crd/valid/output/properties-awsblueprints.io.xcdn.yaml new file mode 100644 index 0000000..e0f1c4d --- /dev/null +++ b/pkg/cmd/fakes/crd/valid/output/properties-awsblueprints.io.xcdn.yaml @@ -0,0 +1,48 @@ +properties: + apiVersion: + default: awsblueprints.io/v1alpha1 + description: APIVersion for the resource + type: string + config: + properties: + resourceConfig: + description: ResourceConfig defines general properties of this AWS resource. + properties: + deletionPolicy: + description: Defaults to Delete + enum: + - Delete + - Orphan + type: string + name: + description: Set the name of this resource in AWS to the value provided by this field. + type: string + providerConfigName: + type: string + region: + type: string + tags: + items: + properties: + key: + type: string + value: + type: string + required: + - key + - value + type: object + type: array + required: + - providerConfigName + - region + - tags + type: object + required: + - resourceConfig + title: awsblueprints.io.XCDN configuration options + type: object + kind: + default: XCDN + description: Kind for the resource + type: string diff --git a/pkg/cmd/fakes/out-resource/output-resource.yaml b/pkg/cmd/fakes/crd/valid/output/properties-sparkoperator.k8s.io.sparkapplication.yaml similarity index 84% rename from pkg/cmd/fakes/out-resource/output-resource.yaml rename to pkg/cmd/fakes/crd/valid/output/properties-sparkoperator.k8s.io.sparkapplication.yaml index 9263254..9059ad6 100644 --- a/pkg/cmd/fakes/out-resource/output-resource.yaml +++ b/pkg/cmd/fakes/crd/valid/output/properties-sparkoperator.k8s.io.sparkapplication.yaml @@ -3,9 +3,6 @@ properties: default: sparkoperator.k8s.io/v1beta2 description: APIVersion for the resource type: string - awsResources: - enum: - - sparkoperator.k8s.io.SparkApplication config: properties: arguments: @@ -23,8 +20,8 @@ properties: resources: additionalProperties: anyOf: - - type: integer - - type: string + - type: integer + - type: string pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ x-kubernetes-int-or-string: true type: object @@ -34,3 +31,7 @@ properties: default: SparkApplication description: Kind for the resource type: string + namespace: + description: Namespace for the resource + namespace: default + type: string diff --git a/pkg/cmd/fakes/terraform/invalid/input/variables.tf b/pkg/cmd/fakes/terraform/invalid/input/variables.tf new file mode 100644 index 0000000..1bd60f6 --- /dev/null +++ b/pkg/cmd/fakes/terraform/invalid/input/variables.tf @@ -0,0 +1,5 @@ +variable "name" { + description = "Name of the VPC and EKS Cluster" + type = string + default = "invalid-configuration" + diff --git a/pkg/cmd/fakes/terraform/valid/input-require/variables.tf b/pkg/cmd/fakes/terraform/valid/input-require/variables.tf new file mode 100644 index 0000000..a539975 --- /dev/null +++ b/pkg/cmd/fakes/terraform/valid/input-require/variables.tf @@ -0,0 +1,43 @@ +variable "name" { + description = "Name of the VPC and EKS Cluster" + type = string + default = "emr-eks-ack" +} + +variable "region" { + description = "Region" + type = string +} + +variable "eks_cluster_version" { + description = "EKS Cluster version" + type = string + default = "1.27" +} + +variable "tags" { + description = "Default tags" + type = map( string ) + default = { + "env" = "test" + } +} + +variable "vpc_cidr" { + description = "VPC CIDR" + type = string + default = "10.1.0.0/16" +} + +# Only two Subnets for with low IP range for internet access +variable "public_subnets" { + description = "Public Subnets CIDRs. 62 IPs per Subnet" + type = list(string) + default = ["10.1.255.128/26", "10.1.255.192/26"] +} + +variable "private_subnets" { + description = "Private Subnets CIDRs. 32766 Subnet1 and 16382 Subnet2 IPs per Subnet" + type = list(string) + default = ["10.1.0.0/17", "10.1.128.0/18"] +} diff --git a/pkg/cmd/fakes/terraform/valid/input/main.tf b/pkg/cmd/fakes/terraform/valid/input/main.tf new file mode 100644 index 0000000..a5296b5 --- /dev/null +++ b/pkg/cmd/fakes/terraform/valid/input/main.tf @@ -0,0 +1,148 @@ +provider "aws" { + region = local.region +} + +# ECR always authenticates with `us-east-1` region +# Docs -> https://docs.aws.amazon.com/AmazonECR/latest/public/public-registries.html +provider "aws" { + alias = "ecr" + region = "us-east-1" +} + +provider "kubernetes" { + host = module.eks.cluster_endpoint + cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data) + token = data.aws_eks_cluster_auth.this.token +} + +provider "helm" { + kubernetes { + host = module.eks.cluster_endpoint + cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data) + token = data.aws_eks_cluster_auth.this.token + } +} + +data "aws_eks_cluster_auth" "this" { + name = module.eks.cluster_name +} + +data "aws_ecrpublic_authorization_token" "token" { + provider = aws.ecr +} + +data "aws_caller_identity" "current" {} +data "aws_availability_zones" "available" {} + +locals { + name = var.name + region = var.region + + vpc_cidr = var.vpc_cidr + azs = slice(data.aws_availability_zones.available.names, 0, 2) + + tags = merge(var.tags, { + Blueprint = local.name + GithubRepo = "github.com/awslabs/data-on-eks" + }) +} + +#--------------------------------------------------------------- +# EKS Cluster +#--------------------------------------------------------------- + +module "eks" { + source = "terraform-aws-modules/eks/aws" + version = "~> 19.15" + + cluster_name = local.name + cluster_version = var.eks_cluster_version + + cluster_endpoint_private_access = true # if true, Kubernetes API requests within your cluster's VPC (such as node to control plane communication) use the private VPC endpoint + cluster_endpoint_public_access = true # if true, Your cluster API server is accessible from the internet. You can, optionally, limit the CIDR blocks that can access the public endpoint. + + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.private_subnets + + manage_aws_auth_configmap = true + aws_auth_roles = [ + { + # Required for EMR on EKS virtual cluster + rolearn = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/AWSServiceRoleForAmazonEMRContainers" + username = "emr-containers" + }, + ] + + #--------------------------------------- + # Note: This can further restricted to specific required for each Add-on and your application + #--------------------------------------- + # Extend cluster security group rules + cluster_security_group_additional_rules = { + ingress_nodes_ephemeral_ports_tcp = { + description = "Nodes on ephemeral ports" + protocol = "tcp" + from_port = 1025 + to_port = 65535 + type = "ingress" + source_node_security_group = true + } + } + + # Extend node-to-node security group rules + node_security_group_additional_rules = { + # Extend node-to-node security group rules. Recommended and required for the Add-ons + ingress_self_all = { + description = "Node to node all ports/protocols" + protocol = "-1" + from_port = 0 + to_port = 0 + type = "ingress" + self = true + } + } + + eks_managed_node_group_defaults = { + iam_role_additional_policies = { + # Not required, but used in the example to access the nodes to inspect mounted volumes + AmazonSSMManagedInstanceCore = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" + } + } + eks_managed_node_groups = { + # We recommend to have a MNG to place your critical workloads and add-ons + # Then rely on Karpenter to scale your workloads + # You can also make uses on nodeSelector and Taints/tolerations to spread workloads on MNG or Karpenter provisioners + core_node_group = { + name = "core-node-group" + description = "EKS managed node group example launch template" + + min_size = 1 + max_size = 9 + desired_size = 3 + + instance_types = ["m5.xlarge"] + + ebs_optimized = true + block_device_mappings = { + xvda = { + device_name = "/dev/xvda" + ebs = { + volume_size = 100 + volume_type = "gp3" + } + } + } + + labels = { + WorkerType = "ON_DEMAND" + NodeGroupType = "core" + } + + tags = { + Name = "core-node-grp", + "karpenter.sh/discovery" = local.name + } + } + } + + tags = local.tags +} diff --git a/pkg/cmd/fakes/terraform/valid/input/variables.tf b/pkg/cmd/fakes/terraform/valid/input/variables.tf new file mode 100644 index 0000000..d7e42be --- /dev/null +++ b/pkg/cmd/fakes/terraform/valid/input/variables.tf @@ -0,0 +1,44 @@ +variable "name" { + description = "Name of the VPC and EKS Cluster" + type = string + default = "emr-eks-ack" +} + +variable "region" { + description = "Region" + type = string + default = "us-west-2" +} + +variable "eks_cluster_version" { + description = "EKS Cluster version" + type = string + default = "1.27" +} + +variable "tags" { + description = "Default tags" + type = map( string ) + default = { + "env" = "test" + } +} + +variable "vpc_cidr" { + description = "VPC CIDR" + type = string + default = "10.1.0.0/16" +} + +# Only two Subnets for with low IP range for internet access +variable "public_subnets" { + description = "Public Subnets CIDRs. 62 IPs per Subnet" + type = list(string) + default = ["10.1.255.128/26", "10.1.255.192/26"] +} + +variable "private_subnets" { + description = "Private Subnets CIDRs. 32766 Subnet1 and 16382 Subnet2 IPs per Subnet" + type = list(string) + default = ["10.1.0.0/17", "10.1.128.0/18"] +} diff --git a/pkg/cmd/fakes/terraform/valid/output/full-template-oneof.yaml b/pkg/cmd/fakes/terraform/valid/output/full-template-oneof.yaml new file mode 100644 index 0000000..ab732b8 --- /dev/null +++ b/pkg/cmd/fakes/terraform/valid/output/full-template-oneof.yaml @@ -0,0 +1,37 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: deploy-resources + title: Deploy Resources + description: Deploy Resource to Kubernetes +spec: + owner: guest + type: service + parameters: + - title: Choose Resource + description: Select a AWS resource to add to your repository. + properties: + resources: + type: string + enum: + - input + - input-require + name: + description: name of this resource. This will be the name of K8s object. + type: string + path: + default: kustomize/base + description: path to place this file into + type: string + dependencies: + resources: + oneOf: + - $yaml: resources/input.yaml + - $yaml: resources/input-require.yaml + required: + - awsResources + - name + steps: + - id: verify + name: verify + action: cnoe:verify:dependency diff --git a/pkg/cmd/fakes/terraform/valid/output/full-template-require.yaml b/pkg/cmd/fakes/terraform/valid/output/full-template-require.yaml new file mode 100644 index 0000000..8a4c2db --- /dev/null +++ b/pkg/cmd/fakes/terraform/valid/output/full-template-require.yaml @@ -0,0 +1,62 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: deploy-resources + title: Deploy Resources + description: Deploy Resource to Kubernetes +spec: + owner: guest + type: service + parameters: + - title: Choose Resource + description: Select a AWS resource to add to your repository. + properties: + path: + type: string + description: path to place this file into + default: kustomize/base + eks_cluster_version: + type: string + description: EKS Cluster version + default: "1.27" + name: + type: string + description: Name of the VPC and EKS Cluster + default: emr-eks-ack + private_subnets: + type: array + description: Private Subnets CIDRs. 32766 Subnet1 and 16382 Subnet2 IPs per Subnet + default: + - 10.1.0.0/17 + - 10.1.128.0/18 + items: + type: string + public_subnets: + type: array + description: Public Subnets CIDRs. 62 IPs per Subnet + default: + - 10.1.255.128/26 + - 10.1.255.192/26 + items: + type: string + region: + type: string + description: Region + tags: + title: tags + type: object + description: Default tags + additionalProperties: + type: string + vpc_cidr: + type: string + description: VPC CIDR + default: 10.1.0.0/16 + required: + - awsResources + - name + - region + steps: + - id: verify + name: verify + action: cnoe:verify:dependency diff --git a/pkg/cmd/fakes/terraform/valid/output/full-template.yaml b/pkg/cmd/fakes/terraform/valid/output/full-template.yaml new file mode 100644 index 0000000..83c53e5 --- /dev/null +++ b/pkg/cmd/fakes/terraform/valid/output/full-template.yaml @@ -0,0 +1,64 @@ +# This template uses $yaml special keys to include objects from different files. For this to work, the catalog type must be "url". Specifically, it must be http, e.g. Using something like file://abc/def/template-add-aws-resources.yaml does not work. +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: deploy-resources + title: Deploy Resources + description: Deploy Resource to Kubernetes +spec: + owner: guest + type: service + # these are the steps which are rendered in the frontend with the form input + parameters: + - title: Choose Resource + description: Select a AWS resource to add to your repository. + properties: + path: + type: string + description: path to place this file into + default: kustomize/base + eks_cluster_version: + type: string + description: EKS Cluster version + default: "1.27" + name: + type: string + description: Name of the VPC and EKS Cluster + default: emr-eks-ack + private_subnets: + type: array + description: Private Subnets CIDRs. 32766 Subnet1 and 16382 Subnet2 IPs per Subnet + default: + - 10.1.0.0/17 + - 10.1.128.0/18 + items: + type: string + public_subnets: + type: array + description: Public Subnets CIDRs. 62 IPs per Subnet + default: + - 10.1.255.128/26 + - 10.1.255.192/26 + items: + type: string + region: + type: string + description: Region + default: us-west-2 + tags: + title: tags + type: object + description: Default tags + additionalProperties: + type: string + vpc_cidr: + type: string + description: VPC CIDR + default: 10.1.0.0/16 + required: + - awsResources + - name + steps: + - id: verify + name: verify + action: cnoe:verify:dependency diff --git a/pkg/cmd/fakes/terraform/valid/output/properties-require.yaml b/pkg/cmd/fakes/terraform/valid/output/properties-require.yaml new file mode 100644 index 0000000..0a2d3f1 --- /dev/null +++ b/pkg/cmd/fakes/terraform/valid/output/properties-require.yaml @@ -0,0 +1,39 @@ +properties: + eks_cluster_version: + type: string + description: EKS Cluster version + default: "1.27" + name: + type: string + description: Name of the VPC and EKS Cluster + default: emr-eks-ack + private_subnets: + type: array + description: Private Subnets CIDRs. 32766 Subnet1 and 16382 Subnet2 IPs per Subnet + default: + - 10.1.0.0/17 + - 10.1.128.0/18 + items: + type: string + public_subnets: + type: array + description: Public Subnets CIDRs. 62 IPs per Subnet + default: + - 10.1.255.128/26 + - 10.1.255.192/26 + items: + type: string + region: + type: string + description: Region + tags: + title: tags + type: object + description: Default tags + additionalProperties: + type: string + vpc_cidr: + type: string + description: VPC CIDR + default: 10.1.0.0/16 +required: [region] diff --git a/pkg/cmd/fakes/terraform/valid/output/properties.yaml b/pkg/cmd/fakes/terraform/valid/output/properties.yaml new file mode 100644 index 0000000..45354fe --- /dev/null +++ b/pkg/cmd/fakes/terraform/valid/output/properties.yaml @@ -0,0 +1,40 @@ +properties: + eks_cluster_version: + type: string + description: EKS Cluster version + default: "1.27" + name: + type: string + description: Name of the VPC and EKS Cluster + default: emr-eks-ack + private_subnets: + type: array + description: Private Subnets CIDRs. 32766 Subnet1 and 16382 Subnet2 IPs per Subnet + default: + - 10.1.0.0/17 + - 10.1.128.0/18 + items: + type: string + public_subnets: + type: array + description: Public Subnets CIDRs. 62 IPs per Subnet + default: + - 10.1.255.128/26 + - 10.1.255.192/26 + items: + type: string + region: + type: string + description: Region + default: us-west-2 + tags: + title: tags + type: object + description: Default tags + additionalProperties: + type: string + vpc_cidr: + type: string + description: VPC CIDR + default: 10.1.0.0/16 +required: [] diff --git a/pkg/cmd/template.go b/pkg/cmd/template.go index dfd76e4..ee6e763 100644 --- a/pkg/cmd/template.go +++ b/pkg/cmd/template.go @@ -1,6 +1,11 @@ package cmd import ( + "context" + "errors" + "log" + "path/filepath" + "github.com/spf13/cobra" ) @@ -9,12 +14,111 @@ var templateCmd = &cobra.Command{ Short: "Generate Backstage templates", } +const ( + DefinitionsDir = "resources" +) + +var ( + depth uint32 + insertionPoint string + inputDir string + outputDir string + templatePath string + collapsed bool + raw bool +) + func init() { rootCmd.AddCommand(templateCmd) - templateCmd.PersistentFlags().StringVarP(&inputDir, "inputDir", "i", "", "input directory for CRDs and XRDs to be templatized") templateCmd.PersistentFlags().StringVarP(&outputDir, "outputDir", "o", "", "output directory for backstage templates to be stored in") + templateCmd.PersistentFlags().StringVarP(&templatePath, "templatePath", "t", "", "path to the template to be augmented with backstage info") + templateCmd.PersistentFlags().Uint32Var(&depth, "depth", 2, "depth from given directory to search for TF modules or CRDs") + templateCmd.PersistentFlags().StringVarP(&insertionPoint, "insertAt", "p", ".spec.parameters[0]", "jq path within the template to insert backstage info") + templateCmd.PersistentFlags().BoolVarP(&collapsed, "colllapse", "c", false, "if set to true, items are rendered and collapsed as drop down items in a single specified template") + templateCmd.PersistentFlags().BoolVarP(&raw, "raww", "", false, "prints the raw open API output without putting it into a template (ignoring `templatePath` and `insertAt`)") templateCmd.MarkFlagRequired("inputDir") templateCmd.MarkFlagRequired("outputDir") } + +func templatePreRunE(cmd *cobra.Command, args []string) error { + if !isDirectory(inputDir) { + return errors.New("inputDir must be a directory") + } + + if collapsed && templatePath == "" && !raw { + return errors.New("templatePath flag must be specified when using the `collapse` flag (optionally you can use `insertAt` as well)") + } + + if templatePath == "" && !raw { + return errors.New("you either need to use the `raw` flag to generate raw OpenAPI files or define a `templatePath` for the tool to populate") + } + + return nil +} + +type EntityConfig struct { + InputDir string + OutputDir string + TemplateFile string + OutputFile string + InsertionPoint string + Defenitions []string + Collapsed bool + Raw bool +} + +type Entity interface { + GetDefinitions(string, uint32) ([]string, error) + HandleEntry(context.Context, string, string, string) (any, string, error) + Config() EntityConfig +} + +func Process(ctx context.Context, p Entity) error { + c := p.Config() + + expectedInDir, expectedOutDir, expectedTemplateFile, err := prepDirectories( + c.InputDir, + c.OutputDir, + c.TemplateFile, + c.Collapsed && !c.Raw, /* only generate nesting if templates need to collapse and not printed as raw*/ + ) + if err != nil { + return err + } + + definitions, err := p.GetDefinitions(expectedInDir, 0) + if err != nil { + return err + } + log.Printf("processing %d definitions", len(definitions)) + + templateOutputFiles := make([]string, 0) + + for _, def := range definitions { + content, contentFileName, err := p.HandleEntry(ctx, def, expectedOutDir, expectedTemplateFile) + if err != nil { + return err + } + if content != nil { // write the content and record the file name + err = writeOutput(content, contentFileName) + if err != nil { + log.Printf("wrinting content failed for %s", contentFileName) + continue + } + templateOutputFiles = append(templateOutputFiles, contentFileName) + } + } + + if shouldCreateCollapsedTemplate(p) && len(templateOutputFiles) > 0 { + generatedTemplateFile := filepath.Join(expectedOutDir, "../template.yaml") + input := insertAtInput{ + templatePath: expectedTemplateFile, + jqPathExpression: c.InsertionPoint, + } + return writeCollapsedTemplate(ctx, input, generatedTemplateFile, templateOutputFiles) + } + + return nil +} diff --git a/pkg/cmd/terraform.go b/pkg/cmd/terraform.go new file mode 100644 index 0000000..24717e3 --- /dev/null +++ b/pkg/cmd/terraform.go @@ -0,0 +1,259 @@ +package cmd + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "strings" + + "github.com/cnoe-io/cnoe-cli/pkg/models" + "github.com/hashicorp/terraform-config-inspect/tfconfig" + "github.com/spf13/cobra" +) + +var ( + tfCmd = &cobra.Command{ + Use: "tf", + Short: "Generate backstage templates from Terraform variables", + Long: "Generate backstage templates by walking the given input directory, find TF modules," + + "then create output file per module.\n" + + "If the templatePath and insertionPoint flags are set, generated objects are merged into the given template at given insertion point.\n" + + "Otherwise a yaml file with two keys are generated. The properties key contains the generated form input. " + + "The required key contains the TF variable names that do not have defaults.", + PreRunE: templatePreRunE, + RunE: tfE, + } +) + +func init() { + templateCmd.AddCommand(tfCmd) +} + +func tfE(cmd *cobra.Command, args []string) error { + return Process(cmd.Context(), NewTerraformModule(inputDir, outputDir, templatePath, insertionPoint, collapsed, raw)) +} + +type TerraformModule struct { + EntityConfig +} + +func NewTerraformModule(inputDir, outputDir, templatePath, insertionPoint string, collapsed, raw bool) Entity { + return &TerraformModule{ + EntityConfig: EntityConfig{ + InputDir: inputDir, + OutputDir: outputDir, + TemplateFile: templatePath, + InsertionPoint: insertionPoint, + Collapsed: collapsed, + Raw: raw, + }, + } +} + +func (t *TerraformModule) Config() EntityConfig { + return t.EntityConfig +} + +func (t *TerraformModule) HandleEntry(ctx context.Context, def, expectedOutDir, templateFile string) (any, string, error) { + log.Printf("processing module at %s", def) + mod, diag := tfconfig.LoadModule(def) + if diag.HasErrors() { + return nil, "", diag.Err() + } + + if len(mod.Variables) == 0 { + log.Printf("module %s does not have variables", def) + return nil, "", nil + } + + params := make(map[string]models.BackstageParamFields) + required := make([]string, 0) + for j := range mod.Variables { + params[j] = convertVariable(*mod.Variables[j]) + if mod.Variables[j].Required { + required = append(required, j) + } + } + + fileName := filepath.Join(expectedOutDir, fmt.Sprintf("%s.yaml", filepath.Base(def))) + content, err := t.createContent(ctx, templateFile, params, required) + if err != nil { + log.Printf("failed to write %s: %s \n", def, err.Error()) + return nil, "", err + } + + return content, fileName, nil +} + +func (t *TerraformModule) createContent(ctx context.Context, templateFile string, properties map[string]models.BackstageParamFields, required []string) (any, error) { + if shouldCreateNonCollapsedTemplate(t) { + input := insertAtInput{ + templatePath: t.TemplateFile, + jqPathExpression: insertionPoint, + fields: map[string]interface{}{ + "properties": properties, + }, + } + if len(required) > 0 { + input.required = required + } + content, err := insertAt(ctx, input) + if err != nil { + return nil, fmt.Errorf("failed to insert to given template: %s", err) + } + return content, nil + } + + content := map[string]interface{}{ + "properties": properties, + "required": required, + } + return content, nil +} + +func (t *TerraformModule) GetDefinitions(inputDir string, currentDepth uint32) ([]string, error) { + if currentDepth > depth { + return nil, nil + } + if tfconfig.IsModuleDir(inputDir) { + return []string{inputDir}, nil + } + out, err := getRelevantFiles(inputDir, currentDepth, t.findModule) + if err != nil { + return nil, err + } + return out, nil +} + +func (t *TerraformModule) findModule(file os.DirEntry, currentDepth uint32, base string) ([]string, error) { + f := filepath.Join(base, file.Name()) + stat, err := os.Stat(f) + if err != nil { + return nil, err + } + if stat.IsDir() { + mods, err := t.GetDefinitions(f, currentDepth+1) + if err != nil { + return nil, err + } + return mods, nil + } + return nil, nil +} + +func convertVariable(tfVar tfconfig.Variable) models.BackstageParamFields { + tfType := cleanString(tfVar.Type) + t := mapType(tfType) + if isPrimitive(tfType) { + b := models.BackstageParamFields{ + Type: t, + } + if tfVar.Description != "" { + b.Description = tfVar.Description + } + if tfVar.Default != nil { + b.Default = tfVar.Default + } + return b + } + + if t == "array" { + return convertArray(tfVar) + } + if t == "object" { + return convertObject(tfVar) + } + return models.BackstageParamFields{} +} + +func convertArray(tfVar tfconfig.Variable) models.BackstageParamFields { + tfType := cleanString(tfVar.Type) + nestedType := getNestedType(tfType) + nestedTfVar := tfconfig.Variable{ + Name: fmt.Sprintf("%s-a", tfVar.Name), + Type: nestedType, + } + nestedItems := convertVariable(nestedTfVar) + out := models.BackstageParamFields{ + Type: "array", + Description: tfVar.Description, + Default: tfVar.Default, + Items: &nestedItems, + } + if strings.HasPrefix(tfType, "set") { + u := true + out.UniqueItems = &u + } + return out +} + +func convertObject(tfVar tfconfig.Variable) models.BackstageParamFields { + out := models.BackstageParamFields{ + Title: tfVar.Name, + Type: mapType(cleanString(tfVar.Type)), + Description: tfVar.Description, + } + + nestedType := getNestedType(cleanString(tfVar.Type)) + if isPrimitive(nestedType) { + p := models.AdditionalProperties{Type: mapType(nestedType)} + out.AdditionalProperties = &p + // defaults for object type is broken in Backstage atm. In the UI, the default values cannot be removed. + // we will enable this once it's fixed in Backstage. + //properties := convertObjectDefaults(tfVar) + //if len(properties) > 0 { + // out.Properties = properties + //} + } else { + name := fmt.Sprintf("%s-n", tfVar.Name) + nestedTfVar := tfconfig.Variable{ + Name: name, + Type: nestedType, + } + converted := convertVariable(nestedTfVar) + out.Properties = map[string]*models.BackstageParamFields{ + name: &converted, + } + } + return out +} + +func cleanString(input string) string { + return strings.ReplaceAll(input, " ", "") +} + +func isPrimitive(s string) bool { + return s == "string" || s == "number" || s == "bool" +} + +func getNestedType(s string) string { + if strings.HasPrefix(s, "object(") { + return strings.TrimSuffix(strings.SplitAfterN(s, "object(", 1)[1], ")") + } + if strings.HasPrefix(s, "map(") { + return strings.TrimSuffix(strings.SplitAfterN(s, "map(", 2)[1], ")") + } + if strings.HasPrefix(s, "list(") { + return strings.TrimSuffix(strings.SplitAfterN(s, "list(", 2)[1], ")") + } + return s +} + +func mapType(tfType string) string { + switch { + case tfType == "string": + return "string" + case tfType == "number": + return "number" + case tfType == "bool": + return "boolean" + case strings.HasPrefix(tfType, "object"), strings.HasPrefix(tfType, "map"): + return "object" + case strings.HasPrefix(tfType, "list"), strings.HasPrefix(tfType, "set"): + return "array" + default: + return "string" + } +} diff --git a/pkg/cmd/terraform_test.go b/pkg/cmd/terraform_test.go new file mode 100644 index 0000000..6c08971 --- /dev/null +++ b/pkg/cmd/terraform_test.go @@ -0,0 +1,159 @@ +package cmd_test + +import ( + "context" + "fmt" + "log" + + "os" + "path/filepath" + + "github.com/cnoe-io/cnoe-cli/pkg/cmd" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/gbytes" +) + +var _ = Describe("Terraform Template", func() { + var ( + tempDir string + outputDir string + + stdout *gbytes.Buffer + ) + + const ( + validInputRootDir = "./fakes/terraform/valid" + inputDir = "./fakes/terraform/valid/input" + inputDirWithRequire = "./fakes/terraform/valid/input-require" + expectedPropertyFile = "./fakes/terraform/valid/output/properties.yaml" + expectedPropertyFileWithRequire = "./fakes/terraform/valid/output/properties-require.yaml" + expectedTemplateFile = "./fakes/terraform/valid/output/full-template.yaml" + expectedTemplateFileWithRequire = "./fakes/terraform/valid/output/full-template-require.yaml" + targetTemplateFile = "./fakes/template/input-template.yaml" + ) + + BeforeEach(func() { + var err error + tempDir, err = os.MkdirTemp("", "test-temp") + Expect(err).NotTo(HaveOccurred()) + + outputDir = filepath.Join(tempDir, "output") + err = os.Mkdir(outputDir, 0755) + Expect(err).NotTo(HaveOccurred()) + + stdout = gbytes.NewBuffer() + log.SetOutput(stdout) + }) + + AfterEach(func() { + err := os.RemoveAll(tempDir) + Expect(err).NotTo(HaveOccurred()) + }) + + Context("with valid input and no target template specified", func() { + BeforeEach(func() { + err := cmd.Process(context.Background(), cmd.NewTerraformModule(inputDir, outputDir, "", "", false, true)) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should create the skeleton file for valid definitions", func() { + generatedData, err := os.ReadFile(fmt.Sprintf("%s/input.yaml", outputDir)) + Expect(err).NotTo(HaveOccurred()) + expectedData, err := os.ReadFile(expectedPropertyFile) + Expect(err).NotTo(HaveOccurred()) + Expect(generatedData).To(MatchYAML(expectedData)) + }) + + }) + Context("with valid input and a target template specified", func() { + BeforeEach(func() { + err := cmd.Process(context.Background(), cmd.NewTerraformModule(inputDir, outputDir, targetTemplateFile, ".spec.parameters[0]", false, false)) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should create the template file with properties merged", func() { + generatedData, err := os.ReadFile(fmt.Sprintf("%s/input.yaml", outputDir)) + Expect(err).NotTo(HaveOccurred()) + expectedData, err := os.ReadFile(expectedTemplateFile) + Expect(err).NotTo(HaveOccurred()) + Expect(generatedData).To(MatchYAML(expectedData)) + }) + + It("should create the template file with properties merged and requirements updated", func() { + generatedData, err := os.ReadFile(fmt.Sprintf("%s/input.yaml", outputDir)) + Expect(err).NotTo(HaveOccurred()) + expectedData, err := os.ReadFile(expectedTemplateFile) + Expect(err).NotTo(HaveOccurred()) + Expect(generatedData).To(MatchYAML(expectedData)) + }) + + }) + Context("with valid input with required variable and a target template specified", func() { + BeforeEach(func() { + err := cmd.Process(context.Background(), cmd.NewTerraformModule(inputDirWithRequire, outputDir, targetTemplateFile, ".spec.parameters[0]", false, false)) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should create the template file with properties merged and requirements updated", func() { + generatedData, err := os.ReadFile(fmt.Sprintf("%s/input-require.yaml", outputDir)) + Expect(err).NotTo(HaveOccurred()) + expectedData, err := os.ReadFile(expectedTemplateFileWithRequire) + Expect(err).NotTo(HaveOccurred()) + Expect(generatedData).To(MatchYAML(expectedData)) + }) + }) + + Context("with a root directory specified", func() { + BeforeEach(func() { + err := cmd.Process(context.Background(), cmd.NewTerraformModule(validInputRootDir, outputDir, "", "", false, true)) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should create properties files with properties merged and requirements updated", func() { + generatedInputData, err := os.ReadFile(fmt.Sprintf("%s/input.yaml", outputDir)) + Expect(err).NotTo(HaveOccurred()) + generatedInputRequireData, err := os.ReadFile(fmt.Sprintf("%s/input-require.yaml", outputDir)) + Expect(err).NotTo(HaveOccurred()) + expectedInputData, err := os.ReadFile(expectedPropertyFile) + Expect(err).NotTo(HaveOccurred()) + expectedInputRequireData, err := os.ReadFile(expectedPropertyFileWithRequire) + Expect(err).NotTo(HaveOccurred()) + Expect(generatedInputData).To(MatchYAML(expectedInputData)) + Expect(generatedInputRequireData).To(MatchYAML(expectedInputRequireData)) + }) + }) + + Context("with an invalid input and no target template specified", func() { + It("should return an error", func() { + err := cmd.Process(context.Background(), cmd.NewTerraformModule("./fakes/terraform/invalid", outputDir, "", "", false, false)) + Expect(err).Should(HaveOccurred()) + }) + }) + + Context("with a root directory and oneOf flag specified", func() { + BeforeEach(func() { + err := cmd.Process(context.Background(), cmd.NewTerraformModule(validInputRootDir, outputDir, targetTemplateFile, ".spec.parameters[0]", true, false)) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should create properties files with properties merged and requirements updated", func() { + generatedInputData, err := os.ReadFile(fmt.Sprintf("%s/resources/input.yaml", outputDir)) + Expect(err).NotTo(HaveOccurred()) + generatedInputRequireData, err := os.ReadFile(fmt.Sprintf("%s/resources/input-require.yaml", outputDir)) + Expect(err).NotTo(HaveOccurred()) + expectedInputData, err := os.ReadFile(expectedPropertyFile) + Expect(err).NotTo(HaveOccurred()) + expectedInputRequireData, err := os.ReadFile(expectedPropertyFileWithRequire) + Expect(err).NotTo(HaveOccurred()) + + expectedTempalteData, err := os.ReadFile("./fakes/terraform/valid/output/full-template-oneof.yaml") + generatedTemplateData, err := os.ReadFile(fmt.Sprintf("%s/template.yaml", outputDir)) + + Expect(generatedInputData).To(MatchYAML(expectedInputData)) + Expect(generatedInputRequireData).To(MatchYAML(expectedInputRequireData)) + Expect(generatedTemplateData).To(MatchYAML(expectedTempalteData)) + + }) + }) +}) diff --git a/pkg/cmd/utils.go b/pkg/cmd/utils.go new file mode 100644 index 0000000..be495bf --- /dev/null +++ b/pkg/cmd/utils.go @@ -0,0 +1,209 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/itchyny/gojq" + yamlv3 "gopkg.in/yaml.v3" + "sigs.k8s.io/yaml" +) + +type finder func(file os.DirEntry, currentDepth uint32, base string) ([]string, error) + +type NotSupported struct { + Err error +} + +func (n NotSupported) Error() string { + return n.Error() +} + +type insertAtInput struct { + templatePath string + jqPathExpression string + fields map[string]any + required []string +} + +type supportedFields struct { + Properties any `yaml:",omitempty"` + Dependencies any `yaml:",omitempty"` +} + +func isDirectory(path string) bool { + // Get file information + info, err := os.Stat(path) + if err != nil { + // Error occurred, path does not exist or cannot be accessed + return false + } + // Check if the path is a directory + return info.Mode().IsDir() +} + +func checkAndCreateDir(path string) error { + _, err := os.Stat(path) + if os.IsNotExist(err) { + return os.MkdirAll(path, 0755) + } + return nil +} + +// return absolute path for given input, output, and template files, if output path does not exist, create it. +func prepDirectories(inputDir, outputDir, templateFile string, oneOf bool) (string, string, string, error) { + input, err := filepath.Abs(inputDir) + if err != nil { + return "", "", "", err + } + output, err := filepath.Abs(outputDir) + if err != nil { + return "", "", "", err + } + t, err := filepath.Abs(templateFile) + if err != nil { + return "", "", "", err + } + expectedOutput := output + if oneOf { + expectedOutput = filepath.Join(output, DefinitionsDir) + } + err = checkAndCreateDir(expectedOutput) + if err != nil { + return "", "", "", err + } + + return input, expectedOutput, t, nil +} + +// Use the given template file, add dependencies and enum fields at the object specified by insertionPoint. +// Write the result to a file specified by outputFile. +func writeCollapsedTemplate(ctx context.Context, input insertAtInput, outputFile string, resourceFiles []string) error { + + t, err := oneOf(ctx, resourceFiles, input) + if err != nil { + return err + } + return writeOutput(t, outputFile) +} + +func oneOf(ctx context.Context, resourceFiles []string, input insertAtInput) (any, error) { + n := make([]string, len(resourceFiles)) + m := make([]map[string]string, len(resourceFiles)) + for i := range resourceFiles { + fileName := filepath.Base(resourceFiles[i]) + n[i] = strings.TrimSuffix(fileName, ".yaml") + m[i] = map[string]string{ + "$yaml": filepath.Join(DefinitionsDir, fileName), + } + } + props := map[string]any{ + "resources": map[string]any{ + "type": "string", + "enum": n, + }, + } + deps := map[string]any{ + "resources": map[string][]map[string]string{ + "oneOf": m, + }, + } + fields := map[string]any{ + "properties": props, + "dependencies": deps, + } + input.fields = fields + + return insertAt(ctx, input) +} + +func jsonFromObject(obj any) ([]byte, error) { + b, err := yamlv3.Marshal(obj) + if err != nil { + return nil, err + } + return yaml.YAMLToJSON(b) +} + +// inserts Backstage parameters at the specified path. Path is specified in the same format as jq. +func insertAt(ctx context.Context, input insertAtInput) (any, error) { + b, err := os.ReadFile(input.templatePath) + if err != nil { + return nil, err + } + var targetTemplate map[string]any + err = yaml.Unmarshal(b, &targetTemplate) + if err != nil { + return nil, err + } + jqProp, err := jsonFromObject(input.fields) + if err != nil { + return nil, err + } + + var sb strings.Builder + // update the properties field. merge (*) then assign (=) + sb.WriteString(fmt.Sprintf("%s = %s * %s", input.jqPathExpression, input.jqPathExpression, string(jqProp))) + if len(input.required) > 0 { + jqReq, err := jsonFromObject(input.required) + if err != nil { + return nil, err + } + // update the required field by feeding the new query the output from previous step (|) + sb.WriteString("| ") + sb.WriteString(fmt.Sprintf("%s.required = (%s.required + %s)", input.jqPathExpression, input.jqPathExpression, string(jqReq))) + } + query, err := gojq.Parse(sb.String()) + if err != nil { + return nil, err + } + iter := query.RunWithContext(ctx, targetTemplate) + v, _ := iter.Next() + if err, ok := v.(error); ok { + return nil, err + } + return v, nil +} + +func writeOutput(content any, path string) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + enc := yamlv3.NewEncoder(f) + defer enc.Close() + enc.SetIndent(2) + return enc.Encode(content) +} + +func getRelevantFiles(inputDir string, currentDepth uint32, f finder) ([]string, error) { + base, err := filepath.Abs(inputDir) + if err != nil { + return nil, err + } + files, err := os.ReadDir(base) + if err != nil { + return nil, err + } + out := make([]string, 0) + for _, file := range files { + o, err := f(file, currentDepth, base) + if err != nil { + return nil, err + } + out = append(out, o...) + } + return out, nil +} + +func shouldCreateCollapsedTemplate(p Entity) bool { + return p.Config().Collapsed && !p.Config().Raw +} + +func shouldCreateNonCollapsedTemplate(p Entity) bool { + return !p.Config().Collapsed && !p.Config().Raw +} diff --git a/pkg/models/structs.go b/pkg/models/structs.go index 0d4bf5e..1c3e15b 100644 --- a/pkg/models/structs.go +++ b/pkg/models/structs.go @@ -23,6 +23,7 @@ type Spec struct { } `yaml:"openAPIV3Schema"` } `json:"schema"` } `json:"versions"` + Scope string } type Definition struct { @@ -51,31 +52,18 @@ type Wrapper struct { Properties Props `json:"properties"` } -type Template struct { - ApiVersion string `yaml:"apiVersion"` - Kind string `yaml:"kind"` - Metadata struct { - Name string `yaml:"name"` - Title string `yaml:"title"` - Description string `yaml:"description"` - } `yaml:"metadata"` - Spec struct { - Owner string `yaml:"owner"` - Type string `yaml:"type"` - Parameters []struct { - Properties map[string]interface{} `yaml:"properties"` - Dependencies struct { - Resources struct { - OneOf []map[string]interface{} `yaml:"oneOf,omitempty"` - } `yaml:"resources,omitempty"` - } `yaml:"dependencies,omitempty"` - } `yaml:"parameters"` +type BackstageParamFields struct { + Title string `yaml:",omitempty"` + Type string + Description string `yaml:",omitempty"` + Default any `yaml:",omitempty"` + Items *BackstageParamFields `yaml:",omitempty"` + UIWidget string `yaml:"ui:widget,omitempty"` + Properties map[string]*BackstageParamFields `yaml:"UiWidget,omitempty"` + AdditionalProperties *AdditionalProperties `yaml:"additionalProperties,omitempty"` + UniqueItems *bool `yaml:",omitempty"` // This does not guarantee a set. Works for primitives only. +} - Steps []struct { - Id string `yaml:"id"` - Name string `yaml:"name"` - Action string `yaml:"action"` - Input map[string]interface{} `yaml:"input"` - } `yaml:"steps"` - } `yaml:"spec"` +type AdditionalProperties struct { // technically any but for our case, it should be a type: string + Type string `yaml:",omitempty"` }