tools/diff-processor/detector/detector.go (259 lines of code) (raw):

package detector import ( "fmt" "os" "path/filepath" "sort" "strings" "github.com/GoogleCloudPlatform/magic-modules/tools/diff-processor/diff" "github.com/GoogleCloudPlatform/magic-modules/tools/diff-processor/documentparser" "github.com/GoogleCloudPlatform/magic-modules/tools/test-reader/reader" "github.com/hashicorp/hcl/v2/hclwrite" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/zclconf/go-cty/cty" ) type MissingTestInfo struct { UntestedFields []string SuggestedTest string Tests []string } type FieldSet map[string]struct{} // ResourceChanges is a nested map with field names as keys and Field objects // as bottom-level values. // Fields are assumed not to be covered until detected in a test. type ResourceChanges map[string]*Field type Field struct { // Added is true when the field is newly added between oldProvider and newProvider. Added bool // Changed is true when the field type has changed between oldProvider and newProvider. Changed bool // Tested is true when a test has been found that includes the field. Tested bool } // MissingDocDetails denotes the doc file path and the fields that are not shown up in the corresponding doc. type MissingDocDetails struct { Name string FilePath string Fields []string } // Detect missing tests for the given resource changes map in the given slice of tests. // Return a map of resource names to missing test info about that resource. func DetectMissingTests(schemaDiff diff.SchemaDiff, allTests []*reader.Test) (map[string]*MissingTestInfo, error) { changedFields := getChangedFieldsFromSchemaDiff(schemaDiff) return getMissingTestsForChanges(changedFields, allTests) } // Convert SchemaDiff object to map of ResourceChanges objects. // Also remove parent fields and output-only fields. func getChangedFieldsFromSchemaDiff(schemaDiff diff.SchemaDiff) map[string]ResourceChanges { changedFields := make(map[string]ResourceChanges) for resource, resourceDiff := range schemaDiff { resourceChanges := make(ResourceChanges) for field, fieldDiff := range resourceDiff.Fields { if field == "project" { // Skip the project field. continue } if strings.Contains(resource, "iam") && field == "condition" { // Skip the condition field of iam resources because some iam resources do not support it. continue } if fieldDiff.New == nil { // Skip deleted fields. continue } if fieldDiff.New.Computed && !fieldDiff.New.Optional { // Skip output-only fields. continue } if _, ok := fieldDiff.New.Elem.(*schema.Resource); ok { // Skip parent fields. continue } if fieldDiff.Old == nil { resourceChanges[field] = &Field{Added: true} } else { resourceChanges[field] = &Field{Changed: true} } } if len(resourceChanges) > 0 { changedFields[resource] = resourceChanges } } return changedFields } func getMissingTestsForChanges(changedFields map[string]ResourceChanges, allTests []*reader.Test) (map[string]*MissingTestInfo, error) { resourceNamesToTests := make(map[string][]string) for _, test := range allTests { for _, step := range test.Steps { for resourceName, resourceMap := range step { if changedResourceFields, ok := changedFields[resourceName]; ok { // This resource type has changed fields. resourceNamesToTests[resourceName] = append(resourceNamesToTests[resourceName], test.Name) for _, resourceConfig := range resourceMap { if err := markCoverage(changedResourceFields, resourceConfig); err != nil { return nil, err } } } } } } missingTests := make(map[string]*MissingTestInfo) for resourceName, fieldCoverage := range changedFields { untested := untestedFields(fieldCoverage) sort.Strings(untested) if len(untested) > 0 { missingTests[resourceName] = &MissingTestInfo{ UntestedFields: untested, SuggestedTest: suggestedTest(resourceName, untested), Tests: resourceNamesToTests[resourceName], } } } return missingTests, nil } func markCoverage(fieldCoverage ResourceChanges, config reader.Resource) error { for fieldName := range config { if field, ok := fieldCoverage[fieldName]; ok { field.Tested = true } } return nil } func untestedFields(fieldCoverage ResourceChanges) []string { fields := make([]string, 0) for key, field := range fieldCoverage { if !field.Tested { fields = append(fields, key) } } return fields } func suggestedTest(resourceName string, untested []string) string { f := hclwrite.NewEmptyFile() rootBody := f.Body() resourceBlock := rootBody.AppendNewBlock("resource", []string{resourceName, "primary"}) for _, field := range untested { body := resourceBlock.Body() path := strings.Split(field, ".") for i, step := range path { if i < len(path)-1 { block := body.FirstMatchingBlock(step, nil) if block == nil { block = body.AppendNewBlock(step, nil) } body = block.Body() } else { body.SetAttributeValue(step, cty.StringVal("VALUE")) } } } return strings.ReplaceAll(string(f.Bytes()), `"VALUE"`, "# value needed") } // DetectMissingDocs detect new fields that are missing docs given the schema diffs. // Return a map of resource names to missing doc info. // It parses the document to see if the field is present within the resource document file, // and is therefore heavily reliant on the document being written in the expected format. // Should avoid printing to stdout since the output will be consumed in generate_comment.go. func DetectMissingDocs(schemaDiff diff.SchemaDiff, repoPath string) (map[string]MissingDocDetails, error) { ret := make(map[string]MissingDocDetails) for resource, resourceDiff := range schemaDiff { fieldsInDoc := make(map[string]bool) docFilePath, err := resourceToDocFile(resource, repoPath) if err == nil { content, err := os.ReadFile(docFilePath) if err != nil { return nil, fmt.Errorf("failed to read resource doc %s: %w", docFilePath, err) } parser := documentparser.NewParser() err = parser.Parse(content) if err != nil { return nil, fmt.Errorf("failed to parse document %s: %w", docFilePath, err) } fieldsInDoc = listToMap(parser.FlattenFields()) // for iam resource if v, ok := fieldsInDoc["member/members"]; ok { fieldsInDoc["member"] = v fieldsInDoc["members"] = v } } var newFields []string for field, fieldDiff := range resourceDiff.Fields { if !isNewField(fieldDiff) { continue } // skip condition field, check mmv1/templates/terraform/resource_iam.html.markdown.tmpl for IamConditionsRequestType if field == "condition" || strings.HasPrefix(field, "condition.") { continue } if !fieldsInDoc[field] { newFields = append(newFields, field) } } if len(newFields) > 0 { sort.Strings(newFields) ret[resource] = MissingDocDetails{ Name: resource, FilePath: strings.ReplaceAll(docFilePath, repoPath, ""), Fields: newFields, } } } return ret, nil } // DetectMissingDocsForDatasource detect new fields that are missing docs given the schema diffs. // Return a map of resource names to missing doc info. // It only checks whether the data source doc file exists. // Should avoid printing to stdout since the output will be consumed in generate_comment.go. func DetectMissingDocsForDatasource(schemaDiff diff.SchemaDiff, repoPath string) (map[string]MissingDocDetails, error) { ret := make(map[string]MissingDocDetails) for resource, resourceDiff := range schemaDiff { docFilePath, err := dataSourceToDocFile(resource, repoPath) if err != nil { var newFields []string for field, fieldDiff := range resourceDiff.Fields { if !isNewField(fieldDiff) { continue } newFields = append(newFields, field) } if len(newFields) > 0 { sort.Strings(newFields) ret[resource] = MissingDocDetails{ Name: resource, FilePath: strings.ReplaceAll(docFilePath, repoPath, ""), Fields: newFields, } } } } return ret, nil } func isNewField(fieldDiff diff.FieldDiff) bool { return fieldDiff.Old == nil && fieldDiff.New != nil } func resourceToDocFile(resource string, repoPath string) (string, error) { baseNameOptions := []string{ strings.TrimPrefix(resource, "google_") + ".html.markdown", resource + ".html.markdown", } suffix := []string{"_iam_policy", "_iam_binding", "_iam_member", "_iam_audit_config"} for _, s := range suffix { if strings.HasSuffix(resource, s) { iamName := strings.TrimSuffix(resource, s) + "_iam" baseNameOptions = append(baseNameOptions, iamName+".html.markdown") baseNameOptions = append(baseNameOptions, strings.TrimPrefix(iamName, "google_")+".html.markdown") } } for _, baseName := range baseNameOptions { fullPath := filepath.Join(repoPath, "website", "docs", "r", baseName) _, err := os.ReadFile(fullPath) if !os.IsNotExist(err) { return fullPath, nil } } return filepath.Join(repoPath, "website", "docs", "r", baseNameOptions[0]), fmt.Errorf("no document files found in %s for resource %q", baseNameOptions, resource) } func dataSourceToDocFile(resource string, repoPath string) (string, error) { baseNameOptions := []string{ strings.TrimPrefix(resource, "google_"), resource, } // There are only iam_policy files, no iam_binding, iam_member, iam_audit_config. suffix := []string{"_iam_binding", "_iam_member", "iam_audit_config"} for _, s := range suffix { if strings.HasSuffix(resource, s) { iamName := strings.ReplaceAll(resource, s, "_iam_policy") baseNameOptions = append(baseNameOptions, iamName) baseNameOptions = append(baseNameOptions, strings.TrimPrefix(iamName, "google_")) } } for _, baseName := range baseNameOptions { // some file only has .markdown for _, suffix := range []string{".html.markdown", ".markdown"} { fullPath := filepath.Join(repoPath, "website", "docs", "d", baseName+suffix) _, err := os.ReadFile(fullPath) if !os.IsNotExist(err) { return fullPath, nil } } } return filepath.Join(repoPath, "website", "docs", "d", baseNameOptions[0]+".html.markdown"), fmt.Errorf("no document files found in %s for resource %q", baseNameOptions, resource) } func listToMap(items []string) map[string]bool { m := make(map[string]bool) for _, item := range items { m[item] = true } return m }