cli/bpmetadata/cmd.go (378 lines of code) (raw):

package bpmetadata import ( "errors" "fmt" "os" "path" "strings" "github.com/GoogleCloudPlatform/cloud-foundation-toolkit/cli/util" "github.com/itchyny/json2yaml" "github.com/spf13/cobra" "github.com/spf13/viper" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" "sigs.k8s.io/yaml" ) var mdFlags struct { path string nested bool force bool display bool validate bool quiet bool genOutputType bool } const ( readmeFileName = "README.md" tfVersionsFileName = "versions.tf" tfRolesFileName = "test/setup/iam.tf" tfServicesFileName = "test/setup/main.tf" iconFilePath = "assets/icon.png" modulesPath = "modules/" examplesPath = "examples" metadataFileName = "metadata.yaml" metadataDisplayFileName = "metadata.display.yaml" metadataApiVersion = "blueprints.cloud.google.com/v1alpha1" metadataKind = "BlueprintMetadata" localConfigAnnotation = "config.kubernetes.io/local-config" ) func init() { viper.AutomaticEnv() Cmd.Flags().BoolVarP(&mdFlags.display, "display", "d", false, "Generate the display metadata used for UI rendering.") Cmd.Flags().BoolVarP(&mdFlags.force, "force", "f", false, "Force the generation of fresh metadata.") Cmd.Flags().StringVarP(&mdFlags.path, "path", "p", ".", "Path to the blueprint for generating metadata.") Cmd.Flags().BoolVar(&mdFlags.nested, "nested", true, "Flag for generating metadata for nested blueprint, if any.") Cmd.Flags().BoolVarP(&mdFlags.validate, "validate", "v", false, "Validate metadata against the schema definition.") Cmd.Flags().BoolVarP(&mdFlags.quiet, "quiet", "q", false, "Run in quiet mode suppressing all prompts.") Cmd.Flags().BoolVarP(&mdFlags.genOutputType, "generate-output-type", "g", false, "Automatically generate type field for outputs.") } var Cmd = &cobra.Command{ Use: "metadata", Short: "Generates blueprint metadata", Long: `Generates metadata.yaml for specified blueprint`, Args: cobra.NoArgs, RunE: generate, } var repoDetails repoDetail // The top-level command function that generates metadata based on the provided flags func generate(cmd *cobra.Command, args []string) error { wdPath, err := os.Getwd() if err != nil { return fmt.Errorf("error getting working dir: %w", err) } // validate metadata if there is an argument passed into the command if mdFlags.validate { if err := validateMetadata(mdFlags.path, wdPath); err != nil { return err } return nil } currBpPath := mdFlags.path if !path.IsAbs(mdFlags.path) { currBpPath = path.Join(wdPath, mdFlags.path) } var allBpPaths []string _, err = os.Stat(path.Join(currBpPath, readmeFileName)) // throw an error and exit if root level readme.md doesn't exist if err != nil { return fmt.Errorf("top-level module does not have a readme: %w", err) } allBpPaths = append(allBpPaths, currBpPath) var errors []string // if nested, check if modules/ exists and create paths // for submodules if mdFlags.nested { modulesPathforBp := path.Join(currBpPath, modulesPath) _, err = os.Stat(modulesPathforBp) if os.IsNotExist(err) { Log.Info("sub-modules do not exist for this blueprint") } else { moduleDirs, err := util.WalkTerraformDirs(modulesPathforBp) if err != nil { errors = append(errors, err.Error()) } else { allBpPaths = append(allBpPaths, moduleDirs...) } } } for _, modPath := range allBpPaths { // check if module path has readme.md _, err := os.Stat(path.Join(modPath, readmeFileName)) // log info if a sub-module doesn't have a readme.md and continue if err != nil { Log.Info("skipping metadata for sub-module identified as an internal module", "Path:", modPath) continue } err = generateMetadataForBpPath(modPath) if err != nil { e := fmt.Sprintf("path: %s\n %s", modPath, err.Error()) errors = append(errors, e) } } if len(errors) > 0 { return fmt.Errorf("%s", strings.Join(errors, "\n")) } Log.Info("metadata generated successfully") return nil } func generateMetadataForBpPath(bpPath string) error { //try to read existing metadata.yaml bpObj, err := UnmarshalMetadata(bpPath, metadataFileName) if err != nil && !errors.Is(err, os.ErrNotExist) && !mdFlags.force { return err } // create core metadata bpMetaObj, err := CreateBlueprintMetadata(bpPath, bpObj) if err != nil { return fmt.Errorf("error creating metadata for blueprint at path: %s. Details: %w", bpPath, err) } // If the flag is set, update output types if mdFlags.genOutputType { err = updateOutputTypes(bpPath, bpMetaObj.Spec.Interfaces) if err != nil { return fmt.Errorf("error updating output types: %w", err) } } // write core metadata to disk err = WriteMetadata(bpMetaObj, bpPath, metadataFileName) if err != nil { return fmt.Errorf("error writing metadata to disk for blueprint at path: %s. Details: %w", bpPath, err) } // continue with creating display metadata if the flag is set, // else let the command exit if !mdFlags.display { return nil } bpDpObj, err := UnmarshalMetadata(bpPath, metadataDisplayFileName) if err != nil && !errors.Is(err, os.ErrNotExist) && !mdFlags.force { return err } // create display metadata bpMetaDpObj, err := CreateBlueprintDisplayMetadata(bpPath, bpDpObj, bpMetaObj) if err != nil { return fmt.Errorf("error creating display metadata for blueprint at path: %s. Details: %w", bpPath, err) } // write display metadata to disk err = WriteMetadata(bpMetaDpObj, bpPath, metadataDisplayFileName) if err != nil { return fmt.Errorf("error writing display metadata to disk for blueprint at path: %s. Details: %w", bpPath, err) } return nil } func CreateBlueprintMetadata(bpPath string, bpMetadataObj *BlueprintMetadata) (*BlueprintMetadata, error) { // Verify that readme is present. readmeContent, err := os.ReadFile(path.Join(bpPath, readmeFileName)) if err != nil { return nil, fmt.Errorf("blueprint readme markdown is missing, create one using https://tinyurl.com/tf-mod-readme | error: %w", err) } // verify that the blueprint path is valid & get repo details getRepoDetailsByPath(bpPath, &repoDetails, readmeContent) if repoDetails.ModuleName == "" && !mdFlags.quiet { fmt.Printf("Provide a name for the blueprint at path [%s]: ", bpPath) _, err := fmt.Scan(&repoDetails.ModuleName) if err != nil { fmt.Println("Unable to scan the name for the blueprint.") } } if repoDetails.Source.URL == "" && !mdFlags.quiet { fmt.Printf("Provide a URL for the blueprint source at path [%s]: ", bpPath) _, err := fmt.Scan(&repoDetails.Source.URL) if err != nil { fmt.Println("Unable to scan the URL for the blueprint.") } } // start creating blueprint metadata bpMetadataObj.ApiVersion = metadataApiVersion bpMetadataObj.Kind = metadataKind if bpMetadataObj.Metadata == nil { bpMetadataObj.Metadata = &ResourceTypeMeta{ Name: repoDetails.ModuleName, Annotations: map[string]string{localConfigAnnotation: "true"}, } } if bpMetadataObj.Spec == nil { bpMetadataObj.Spec = &BlueprintMetadataSpec{} } if bpMetadataObj.Spec.Info == nil { bpMetadataObj.Spec.Info = &BlueprintInfo{} } // create blueprint info err = bpMetadataObj.Spec.Info.create(bpPath, repoDetails, readmeContent) if err != nil { return nil, fmt.Errorf("error creating blueprint info: %w", err) } var existingInterfaces *BlueprintInterface if bpMetadataObj.Spec.Interfaces == nil { bpMetadataObj.Spec.Interfaces = &BlueprintInterface{} } else { existingInterfaces = proto.Clone(bpMetadataObj.Spec.Interfaces).(*BlueprintInterface) } // create blueprint interfaces i.e. variables & outputs err = bpMetadataObj.Spec.Interfaces.create(bpPath) if err != nil { return nil, fmt.Errorf("error creating blueprint interfaces: %w", err) } // Merge existing connections (if any) into the newly generated interfaces mergeExistingConnections(bpMetadataObj.Spec.Interfaces, existingInterfaces) // Merge existing output types (if any) into the newly generated interfaces mergeExistingOutputTypes(bpMetadataObj.Spec.Interfaces, existingInterfaces) // get blueprint requirements rolesCfgPath := path.Join(repoDetails.Source.BlueprintRootPath, tfRolesFileName) svcsCfgPath := path.Join(repoDetails.Source.BlueprintRootPath, tfServicesFileName) versionsCfgPath := path.Join(bpPath, tfVersionsFileName) requirements, err := getBlueprintRequirements(rolesCfgPath, svcsCfgPath, versionsCfgPath) if err != nil { Log.Info("skipping blueprint requirements since roles and/or services configurations were not found as per https://tinyurl.com/tf-iam and https://tinyurl.com/tf-services") } else { bpMetadataObj.Spec.Requirements = requirements } if bpMetadataObj.Spec.Content == nil { bpMetadataObj.Spec.Content = &BlueprintContent{} } // create blueprint content i.e. documentation, icons, etc. bpMetadataObj.Spec.Content.create(bpPath, repoDetails.Source.BlueprintRootPath, readmeContent) return bpMetadataObj, nil } func CreateBlueprintDisplayMetadata(bpPath string, bpDisp, bpCore *BlueprintMetadata) (*BlueprintMetadata, error) { // start creating blueprint metadata bpDisp.ApiVersion = bpCore.ApiVersion bpDisp.Kind = bpCore.Kind if bpDisp.Metadata == nil { bpDisp.Metadata = &ResourceTypeMeta{ Name: bpCore.Metadata.Name + "-display", Annotations: map[string]string{localConfigAnnotation: "true"}, } } if bpDisp.Spec == nil { bpDisp.Spec = &BlueprintMetadataSpec{} } if bpDisp.Spec.Info == nil { bpDisp.Spec.Info = &BlueprintInfo{} } if bpDisp.Spec.Ui == nil { bpDisp.Spec.Ui = &BlueprintUI{} bpDisp.Spec.Ui.Input = &BlueprintUIInput{} } bpDisp.Spec.Info.Title = bpCore.Spec.Info.Title bpDisp.Spec.Info.Source = bpCore.Spec.Info.Source buildUIInputFromVariables(bpCore.Spec.Interfaces.Variables, bpDisp.Spec.Ui.Input) existingInput := func() *BlueprintUIInput { if bpCore.Spec.Ui != nil && bpCore.Spec.Ui.Input != nil { return proto.Clone(bpCore.Spec.Ui.Input).(*BlueprintUIInput) } return &BlueprintUIInput{} }() // Merge existing data (if any) into the newly generated UI Input mergeExistingAltDefaults(bpDisp.Spec.Ui.Input, existingInput) return bpDisp, nil } func (i *BlueprintInfo) create(bpPath string, r repoDetail, readmeContent []byte) error { title, err := getMdContent(readmeContent, 1, 1, "", false) if err != nil { return fmt.Errorf("title tag missing in markdown, err: %w", err) } i.Title = title.literal rootPath := r.Source.RepoRootPath if rootPath == "" { rootPath = r.Source.BlueprintRootPath } bpDir := strings.ReplaceAll(bpPath, rootPath, "") i.Source = &BlueprintRepoDetail{ Repo: r.Source.URL, SourceType: r.Source.SourceType, Dir: bpDir, } versionInfo, err := getBlueprintVersion(path.Join(bpPath, tfVersionsFileName)) if err == nil { i.Version = versionInfo.moduleVersion i.ActuationTool = &BlueprintActuationTool{ Version: versionInfo.requiredTfVersion, Flavor: "Terraform", } } // create descriptions i.Description = &BlueprintDescription{} tagline, err := getMdContent(readmeContent, -1, -1, "Tagline", true) if err == nil { i.Description.Tagline = tagline.literal } detailed, err := getMdContent(readmeContent, -1, -1, "Detailed", true) if err == nil { i.Description.Detailed = detailed.literal } preDeploy, err := getMdContent(readmeContent, -1, -1, "PreDeploy", true) if err == nil { i.Description.PreDeploy = preDeploy.literal } var archListToSet []string architecture, err := getMdContent(readmeContent, -1, -1, "Architecture", true) if err == nil { for _, li := range architecture.listItems { archListToSet = append(archListToSet, li.text) } i.Description.Architecture = archListToSet } // create icon iPath := path.Join(r.Source.BlueprintRootPath, iconFilePath) exists, _ := fileExists(iPath) if exists { i.Icon = iconFilePath } d, err := getDeploymentDuration(readmeContent, "Deployment Duration") if err == nil { i.DeploymentDuration = d } c, err := getCostEstimate(readmeContent, "Cost") if err == nil { i.CostEstimate = c } return nil } func (i *BlueprintInterface) create(bpPath string) error { interfaces, err := getBlueprintInterfaces(bpPath) if err != nil { return err } i.Variables = interfaces.Variables i.Outputs = interfaces.Outputs return nil } func (c *BlueprintContent) create(bpPath string, rootPath string, readmeContent []byte) { var docListToSet []*BlueprintListContent documentation, err := getMdContent(readmeContent, -1, -1, "Documentation", true) if err == nil { for _, li := range documentation.listItems { doc := &BlueprintListContent{ Title: li.text, Url: li.url, } docListToSet = append(docListToSet, doc) } c.Documentation = docListToSet } // create architecture a, err := getArchitctureInfo(readmeContent, "Architecture") if err == nil { c.Architecture = a } // create sub-blueprints modPath := path.Join(bpPath, modulesPath) modContent, err := getModules(modPath) if err == nil { c.SubBlueprints = modContent } // create examples exPath := path.Join(rootPath, examplesPath) exContent, err := getExamples(exPath) if err == nil { c.Examples = exContent } } func WriteMetadata(obj *BlueprintMetadata, bpPath, fileName string) error { jBytes, err := protojson.Marshal(obj) if err != nil { return err } input := strings.NewReader(string(jBytes)) var output strings.Builder if err := json2yaml.Convert(&output, input); err != nil { return err } return os.WriteFile(path.Join(bpPath, fileName), []byte(output.String()), 0644) } func UnmarshalMetadata(bpPath, fileName string) (*BlueprintMetadata, error) { bpObj := BlueprintMetadata{} metaFilePath := path.Join(bpPath, fileName) // return empty metadata if file does not exist or if the file is not read if _, err := os.Stat(metaFilePath); errors.Is(err, os.ErrNotExist) { return &bpObj, err } f, err := os.ReadFile(metaFilePath) if err != nil { return &bpObj, fmt.Errorf("unable to read metadata from the existing file: %w", err) } // convert yaml bytes to json bytes for unmarshaling metadata // content to proto definition j, err := yaml.YAMLToJSON(f) if err != nil { return nil, err } if err := protojson.Unmarshal(j, &bpObj); err != nil { return &bpObj, err } currVersion := bpObj.ApiVersion currKind := bpObj.Kind //validate GVK for current metadata if currVersion != metadataApiVersion { return &bpObj, fmt.Errorf("found incorrect version for the metadata: %s. Supported version is: %s", currVersion, metadataApiVersion) } if currKind != metadataKind { return &bpObj, fmt.Errorf("found incorrect kind for the metadata: %s. Supported kind is %s", currKind, metadataKind) } return &bpObj, nil }