cli/bpmetadata/tfconfig.go (469 lines of code) (raw):

package bpmetadata import ( "flag" "fmt" "os" "path/filepath" "regexp" "sort" "strings" "github.com/GoogleCloudPlatform/cloud-foundation-toolkit/cli/bpmetadata/parser" "github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test/pkg/tft" "github.com/gruntwork-io/terratest/modules/terraform" hcl "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclparse" "github.com/hashicorp/terraform-config-inspect/tfconfig" testingiface "github.com/mitchellh/go-testing-interface" "github.com/stretchr/testify/assert" "google.golang.org/protobuf/types/known/structpb" ) const ( versionRegEx = "/v([0-9]+[.0-9]*)$" ) type blueprintVersion struct { moduleVersion string requiredTfVersion string } var rootSchema = &hcl.BodySchema{ Blocks: []hcl.BlockHeaderSchema{ { Type: "terraform", LabelNames: nil, }, { Type: "locals", LabelNames: nil, }, { Type: "resource", LabelNames: []string{"type", "name"}, }, { Type: "module", LabelNames: []string{"name"}, }, }, } var metaSchema = &hcl.BodySchema{ Blocks: []hcl.BlockHeaderSchema{ { Type: "provider_meta", LabelNames: []string{"name"}, }, }, } var variableSchema = &hcl.BodySchema{ Blocks: []hcl.BlockHeaderSchema{ { Type: "variable", LabelNames: []string{"name"}, }, }, } var metaBlockSchema = &hcl.BodySchema{ Attributes: []hcl.AttributeSchema{ { Name: "module_name", }, }, } var moduleSchema = &hcl.BodySchema{ Attributes: []hcl.AttributeSchema{ { Name: "activate_apis", }, }, } // Create alias for generateTFStateFile so we can mock it in unit test. var tfState = generateTFState // getBlueprintVersion gets both the required core version and the // version of the blueprint func getBlueprintVersion(configPath string) (*blueprintVersion, error) { bytes, err := os.ReadFile(configPath) if err != nil { return nil, err } //create hcl file object from the provided tf config fileName := filepath.Base(configPath) var diags hcl.Diagnostics p := hclparse.NewParser() versionsFile, fileDiags := p.ParseHCL(bytes, fileName) diags = append(diags, fileDiags...) err = hasHclErrors(diags) if err != nil { return nil, err } //parse out the blueprint version from the config modName, err := parseBlueprintVersion(versionsFile, diags) if err != nil { return nil, fmt.Errorf("error parsing blueprint version: %w", err) } //parse out the required version from the config var hclModule tfconfig.Module hclModule.RequiredProviders = make(map[string]*tfconfig.ProviderRequirement) hclModuleDiag := tfconfig.LoadModuleFromFile(versionsFile, &hclModule) diags = append(diags, hclModuleDiag...) err = hasHclErrors(diags) if err != nil { return nil, err } requiredCore := "" if len(hclModule.RequiredCore) != 0 { //always looking for the first element since tf blueprints //have one required core version requiredCore = hclModule.RequiredCore[0] } return &blueprintVersion{ moduleVersion: modName, requiredTfVersion: requiredCore, }, nil } // parseBlueprintVersion gets the blueprint version from the provided config // from the provider_meta block func parseBlueprintVersion(versionsFile *hcl.File, diags hcl.Diagnostics) (string, error) { re := regexp.MustCompile(versionRegEx) // PartialContent() returns TF content containing blocks and attributes // based on the provided schema rootContent, _, rootContentDiags := versionsFile.Body.PartialContent(rootSchema) diags = append(diags, rootContentDiags...) err := hasHclErrors(diags) if err != nil { return "", err } // based on the content returned, iterate through blocks and look for // the terraform block specfically for _, rootBlock := range rootContent.Blocks { if rootBlock.Type != "terraform" { continue } // do a PartialContent() call again but now for the provider_meta block // within the terraform block tfContent, _, tfContentDiags := rootBlock.Body.PartialContent(metaSchema) diags = append(diags, tfContentDiags...) err := hasHclErrors(diags) if err != nil { return "", err } for _, tfContentBlock := range tfContent.Blocks { if tfContentBlock.Type != "provider_meta" { continue } // this PartialContent() call with get the module_name attribute // that contains the version info metaContent, _, metaContentDiags := tfContentBlock.Body.PartialContent(metaBlockSchema) diags = append(diags, metaContentDiags...) err := hasHclErrors(diags) if err != nil { return "", err } versionAttr, defined := metaContent.Attributes["module_name"] if !defined { return "", fmt.Errorf("module_name not defined for provider_meta") } // get the module name from the version attribute and extract the // version name only var modName string diags := gohcl.DecodeExpression(versionAttr.Expr, nil, &modName) err = hasHclErrors(diags) if err != nil { return "", err } m := re.FindStringSubmatch(modName) if len(m) > 0 { return m[len(m)-1], nil } return "", nil } break } return "", nil } // parseBlueprintProviderVersions gets the blueprint provider_versions from the provided config // from the required_providers block. func parseBlueprintProviderVersions(versionsFile *hcl.File) ([]*ProviderVersion, error) { var v []*ProviderVersion // parse out the required providers from the config var hclModule tfconfig.Module hclModule.RequiredProviders = make(map[string]*tfconfig.ProviderRequirement) diags := tfconfig.LoadModuleFromFile(versionsFile, &hclModule) err := hasHclErrors(diags) if err != nil { return nil, err } for _, providerData := range hclModule.RequiredProviders { if providerData.Source == "" { Log.Info("Not found source in provider settings\n") continue } if len(providerData.VersionConstraints) == 0 { Log.Info("Not found version in provider settings\n") continue } v = append(v, &ProviderVersion{ Source: providerData.Source, Version: strings.Join(providerData.VersionConstraints, ", "), }) } // Sort provider_versions sort.SliceStable(v, func(i, j int) bool { return v[i].Source < v[j].Source }) return v, nil } // getBlueprintInterfaces gets the variables and outputs associated // with the blueprint func getBlueprintInterfaces(configPath string) (*BlueprintInterface, error) { //load the configs from the dir path mod, diags := tfconfig.LoadModule(configPath) err := hasTfconfigErrors(diags) if err != nil { return nil, err } var variables []*BlueprintVariable for _, val := range mod.Variables { v := getBlueprintVariable(val) variables = append(variables, v) } // Get the varible orders from tf file. variableOrders, sortErr := getBlueprintVariableOrders(configPath) if sortErr != nil { Log.Info("Failed to get variables orders. Fallback to sort by variable names.", sortErr) sort.SliceStable(variables, func(i, j int) bool { return variables[i].Name < variables[j].Name }) } else { Log.Info("Sort variables by the original input order.") sort.SliceStable(variables, func(i, j int) bool { return variableOrders[variables[i].Name] < variableOrders[variables[j].Name] }) } var outputs []*BlueprintOutput for _, val := range mod.Outputs { o := getBlueprintOutput(val) outputs = append(outputs, o) } // Sort outputs sort.SliceStable(outputs, func(i, j int) bool { return outputs[i].Name < outputs[j].Name }) return &BlueprintInterface{ Variables: variables, Outputs: outputs, }, nil } func getBlueprintVariableOrders(configPath string) (map[string]int, error) { p := hclparse.NewParser() variableFile, hclDiags := p.ParseHCLFile(filepath.Join(configPath, "variables.tf")) err := hasHclErrors(hclDiags) if hclDiags.HasErrors() { return nil, err } variableContent, _, hclDiags := variableFile.Body.PartialContent(variableSchema) err = hasHclErrors(hclDiags) if hclDiags.HasErrors() { return nil, err } variableOrderKeys := make(map[string]int) for i, block := range variableContent.Blocks { // We only care about variable blocks. if block.Type != "variable" { continue } // We expect a single label which is the variable name. if len(block.Labels) != 1 || len(block.Labels[0]) == 0 { return nil, fmt.Errorf("variable block has no name") } variableOrderKeys[block.Labels[0]] = i } return variableOrderKeys, nil } // build variable func getBlueprintVariable(modVar *tfconfig.Variable) *BlueprintVariable { v := &BlueprintVariable{ Name: modVar.Name, Description: modVar.Description, Required: modVar.Required, VarType: modVar.Type, } if modVar.Default == nil { return v } vl, err := structpb.NewValue(modVar.Default) if err == nil { v.DefaultValue = vl } return v } // build output func getBlueprintOutput(modOut *tfconfig.Output) *BlueprintOutput { return &BlueprintOutput{ Name: modOut.Name, Description: modOut.Description, } } // getBlueprintRequirements gets the services and roles associated // with the blueprint func getBlueprintRequirements(rolesConfigPath, servicesConfigPath, versionsConfigPath string) (*BlueprintRequirements, error) { //parse blueprint roles p := hclparse.NewParser() rolesFile, diags := p.ParseHCLFile(rolesConfigPath) err := hasHclErrors(diags) if err != nil { return nil, err } r, err := parseBlueprintRoles(rolesFile) if err != nil { return nil, err } //parse blueprint services servicesFile, diags := p.ParseHCLFile(servicesConfigPath) err = hasHclErrors(diags) if err != nil { return nil, err } s, err := parseBlueprintServices(servicesFile) if err != nil { return nil, err } versionCfgFileExists, _ := fileExists(versionsConfigPath) if !versionCfgFileExists { return &BlueprintRequirements{ Roles: r, Services: s, }, nil } //parse blueprint provider versions versionsFile, diags := p.ParseHCLFile(versionsConfigPath) err = hasHclErrors(diags) if err != nil { return nil, err } v, err := parseBlueprintProviderVersions(versionsFile) if err != nil { return nil, err } return &BlueprintRequirements{ Roles: r, Services: s, ProviderVersions: v, }, nil } // parseBlueprintRoles gets the roles required for the blueprint to be provisioned func parseBlueprintRoles(rolesFile *hcl.File) ([]*BlueprintRoles, error) { var r []*BlueprintRoles iamContent, _, diags := rolesFile.Body.PartialContent(rootSchema) err := hasHclErrors(diags) if err != nil { return nil, err } for _, block := range iamContent.Blocks { if block.Type != "locals" { continue } iamAttrs, diags := block.Body.JustAttributes() err := hasHclErrors(diags) if err != nil { return nil, err } for k := range iamAttrs { var iamRoles []string attrValue, _ := iamAttrs[k].Expr.Value(nil) if !attrValue.Type().IsTupleType() { continue } ie := attrValue.ElementIterator() for ie.Next() { _, v := ie.Element() iamRoles = append(iamRoles, v.AsString()) } containerRoles := &BlueprintRoles{ // TODO: (b/248123274) no good way to associate granularity yet Level: "Project", Roles: iamRoles, } r = append(r, containerRoles) } // because we're only interested in the top-level locals block break } sortBlueprintRoles(r) return r, nil } // Sort blueprint roles. func sortBlueprintRoles(r []*BlueprintRoles) { sort.SliceStable(r, func(i, j int) bool { // 1. Sort by Level if r[i].Level != r[j].Level { return r[i].Level < r[j].Level } // 2. Sort by the len of roles if len(r[i].Roles) != len(r[j].Roles) { return len(r[i].Roles) < len(r[j].Roles) } // 3. Sort by the first role (if available) if len(r[i].Roles) > 0 && len(r[j].Roles) > 0 { return r[i].Roles[0] < r[j].Roles[0] } return false }) } // parseBlueprintServices gets the gcp api services required for the blueprint // to be provisioned func parseBlueprintServices(servicesFile *hcl.File) ([]string, error) { var s []string servicesContent, _, diags := servicesFile.Body.PartialContent(rootSchema) err := hasHclErrors(diags) if err != nil { return nil, err } for _, block := range servicesContent.Blocks { if block.Type != "module" { continue } moduleContent, _, moduleContentDiags := block.Body.PartialContent(moduleSchema) diags = append(diags, moduleContentDiags...) err := hasHclErrors(diags) if err != nil { return nil, err } apisAttr, defined := moduleContent.Attributes["activate_apis"] if !defined { return nil, fmt.Errorf("activate_apis not defined for project module") } diags = gohcl.DecodeExpression(apisAttr.Expr, nil, &s) err = hasHclErrors(diags) if err != nil { return nil, err } // because we're only interested in the top-level modules block break } return s, nil } func hasHclErrors(diags hcl.Diagnostics) error { for _, diag := range diags { if diag.Severity == hcl.DiagError { return fmt.Errorf("hcl error: %s | detail: %s", diag.Summary, diag.Detail) } } return nil } // this is almost a dup of hasHclErrors because the TF api has two // different structs for diagnostics... func hasTfconfigErrors(diags tfconfig.Diagnostics) error { for _, diag := range diags { if diag.Severity == tfconfig.DiagError { return fmt.Errorf("hcl error: %s | detail: %s", diag.Summary, diag.Detail) } } return nil } // MergeExistingConnections merges existing connections from an old BlueprintInterface into a new one, // preserving manually authored connections. func mergeExistingConnections(newInterfaces, existingInterfaces *BlueprintInterface) { if existingInterfaces == nil { return // Nothing to merge if existingInterfaces is nil } for i, variable := range newInterfaces.Variables { for _, existingVariable := range existingInterfaces.Variables { if variable.Name == existingVariable.Name && existingVariable.Connections != nil { newInterfaces.Variables[i].Connections = existingVariable.Connections } } } } // mergeExistingOutputTypes merges existing output types from an old BlueprintInterface into a new one, // preserving manually authored types. func mergeExistingOutputTypes(newInterfaces, existingInterfaces *BlueprintInterface) { if existingInterfaces == nil { return // Nothing to merge if existingInterfaces is nil } existingOutputs := make(map[string]*BlueprintOutput) for _, output := range existingInterfaces.Outputs { existingOutputs[output.Name] = output } for i, output := range newInterfaces.Outputs { if output.Type != nil { continue } if existingOutput, ok := existingOutputs[output.Name]; ok && existingOutput.Type != nil { newInterfaces.Outputs[i].Type = existingOutput.Type } } } // UpdateOutputTypes generates the terraform.tfstate file, extracts output types from it, // and updates the output types in the provided BlueprintInterface. func updateOutputTypes(bpPath string, bpInterfaces *BlueprintInterface) error { // Generate the terraform.tfstate file stateData, err := tfState(bpPath) if err != nil { return fmt.Errorf("error generating terraform.tfstate file: %w", err) } // Parse the state file and extract output types outputTypes, err := parser.ParseOutputTypesFromState(stateData) if err != nil { return fmt.Errorf("error parsing output types: %w", err) } // Update the output types in the BlueprintInterface for i, output := range bpInterfaces.Outputs { if outputType, ok := outputTypes[output.Name]; ok { bpInterfaces.Outputs[i].Type = outputType } } return nil } // generateTFState generates the terraform.tfstate by running terraform init and apply, and terraform show to capture the state. func generateTFState(bpPath string) ([]byte, error) { var stateData []byte // Construct the path to the test/setup directory tfDir := filepath.Join(bpPath) // testing.T checks verbose flag to determine its mode. Add this line as a flags initializer // so the program doesn't panic flag.Parse() runtimeT := testingiface.RuntimeT{} root := tft.NewTFBlueprintTest( &runtimeT, tft.WithTFDir(tfDir), // Setup test at the blueprint path, ) root.DefineVerify(func(assert *assert.Assertions) { stateStr, err := terraform.ShowE(&runtimeT, root.GetTFOptions()) if err != nil { assert.FailNowf("Failed to generate terraform.tfstate", "Error calling `terraform show`: %v", err) } stateData = []byte(stateStr) }) root.Test() // This will run terraform init and apply, and then destroy return stateData, nil }