diff --git a/infra/blueprint-test/go.mod b/infra/blueprint-test/go.mod index 8060435c7b3..360949fb20f 100644 --- a/infra/blueprint-test/go.mod +++ b/infra/blueprint-test/go.mod @@ -7,6 +7,7 @@ toolchain go1.24.7 require ( github.com/GoogleContainerTools/kpt-functions-sdk/go/api v0.0.0-20250702115044-9bfead305c54 github.com/alexflint/go-filemutex v1.3.0 + github.com/google/go-cmp v0.7.0 github.com/gruntwork-io/terratest v0.50.0 github.com/hashicorp/terraform-config-inspect v0.0.0-20250828155816-225c06ed5fd9 github.com/hashicorp/terraform-json v0.26.0 @@ -75,7 +76,6 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.6.9 // indirect - github.com/google/go-cmp v0.7.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect diff --git a/infra/blueprint-test/pkg/tft/module_walk.go b/infra/blueprint-test/pkg/tft/module_walk.go new file mode 100644 index 00000000000..439eeedc793 --- /dev/null +++ b/infra/blueprint-test/pkg/tft/module_walk.go @@ -0,0 +1,189 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package tft + +import ( + "fmt" + "maps" + "path/filepath" + "regexp" + "strings" + + "github.com/hashicorp/terraform-config-inspect/tfconfig" +) + +const ( + RootModuleName = "root" +) + +var ( + rootModuleRegexp = regexp.MustCompile("^[./]*$") + modulesDirRegexp = regexp.MustCompile("^[./]*/modules/(.+)$") +) + +// findModulesUnderTest does a graph search starting from tfDir, looking +// for any transitively referenced modules that are considered "modules under test", +// which are modules in the "modules/" dir or the root module +// (the exact definition is in isModuleUnderTest() above). +// +// Any external modules encountered are ignored. +// +// The search doesn't continue to unpack any module under test, so for example, if +// modules/aaa sources modules/bbb, then only modules/aaa will be in the returned +// set (unless modules/bbb is reachable from tfDir without going through modules/aaa). +func findModulesUnderTest(tfDir string) (stringSet, error) { + tfDir = filepath.Clean(tfDir) + + modulesUnderTest := make(stringSet) + pathsToVisit := stringSet{tfDir: struct{}{}} + seen := make(stringSet) + + maxIters := 5 + for iterations := 0; iterations < maxIters; iterations++ { + // moduleRefs is a map from filesystem path to a set of modules sourced from there. + moduleRefs, err := findAllReferencedModules(pathsToVisit) + if err != nil { + return nil, err + } + + maps.Insert(seen, maps.All(pathsToVisit)) + + for _, refs := range moduleRefs { + for ref := range refs { + if isModuleUnderTest(ref) { + name, err := localModuleName(ref) + if err != nil { + return nil, err + } + modulesUnderTest[name] = struct{}{} + } + } + } + + pathsToVisit = stripAlreadySeen(nextPathsToVisit(moduleRefs), seen) + + if len(pathsToVisit) == 0 { + return modulesUnderTest, nil + } + } + + return nil, fmt.Errorf("exceeded %v iterations when searching for referenced modules starting from %q, pathsToVisit is currently %v", maxIters, tfDir, pathsToVisit) +} + +// localModuleName takes a local module source string (e.g. ../../modules/my_module) +// and extracts the short module name used to refer to the module in test/setup/*.tf +// when per_module_{roles,services} are defined (e.g. my_module). +// +// This function returns an error if the input is not a local module source +// (e.g. "terraform-google-modules/network/google"). +func localModuleName(moduleRef string) (string, error) { + if rootModuleRegexp.MatchString(moduleRef) { + return RootModuleName, nil + } + + matches := modulesDirRegexp.FindStringSubmatch(moduleRef) + if len(matches) < 2 { + // modulesDirRegexp couldn't find a match even though this function is supposed to + // be called with local module references only. + return "", fmt.Errorf("couldn't extract module name from source %q using regexp %v", moduleRef, modulesDirRegexp) + } + return matches[1], nil +} + +// isLocalModule uses terraform's definition of what a local module source looks like. +func isLocalModule(moduleRef string) bool { + return strings.HasPrefix(moduleRef, "../") || + strings.HasPrefix(moduleRef, "./") || + strings.HasPrefix(moduleRef, "/") +} + +// isModuleUnderTest looks at the given module source string and returns true +// if it is the root module of this repo, or one of the modules inside the "modules/" dir. +// This is done by just checking whether the moduleRef is a local filesystem path leading +// up to the root and then potentially into the "modules/" dir. +func isModuleUnderTest(moduleRef string) bool { + return isLocalModule(moduleRef) && (rootModuleRegexp.MatchString(moduleRef) || modulesDirRegexp.MatchString(moduleRef)) +} + +type stringSet = map[string]struct{} + +// nextPathsToVisit looks at the given mapping of +// (module path) => (modules referenced from that path) and returns a set of +// paths to visit next. External modules and "modules under test" are excluded +// as they do not need further examination. +func nextPathsToVisit(moduleRefs map[string]stringSet) stringSet { + nextPaths := make(stringSet) + for modulePath, refs := range moduleRefs { + for ref := range refs { + if isLocalModule(ref) && !isModuleUnderTest(ref) { + nextPaths[filepath.Clean(filepath.Join(modulePath, ref))] = struct{}{} + } + } + } + return nextPaths +} + +// stripAlreadySeen returns a new set that includes everything in "modulePaths" +// that is not in "seen". +func stripAlreadySeen(modulePaths stringSet, seen stringSet) stringSet { + newPaths := make(stringSet) + for path := range modulePaths { + if _, ok := seen[path]; !ok { + newPaths[path] = struct{}{} + } + } + return newPaths +} + +// findAllReferencedModules takes a set of filesystem paths for terraform +// modules and returns a map from the path to a set of all modules referenced +// from the module at that path. +func findAllReferencedModules(modulePaths stringSet) (map[string]stringSet, error) { + moduleRefs := make(map[string]stringSet) + for path := range modulePaths { + modules, err := findReferencedModules(path) + if err != nil { + return nil, err + } + moduleRefs[path] = modules + } + return moduleRefs, nil +} + +// findReferencedModules looks in tfDir and extracts the sources for all module +// blocks in that directory. +// The returned value is a set of module sources. Some possible examples: +// +// "../.." +// "../../modules/bar" +// "terraform-google-modules/kubernetes-engine/google" +// "terraform-google-modules/kubernetes-engine/google//modules/workload-identity" +// +// Overridden by tests. +var findReferencedModules = func(tfDir string) (stringSet, error) { + mod, diags := tfconfig.LoadModule(tfDir) + err := diags.Err() + if err != nil { + return nil, err + } + + sources := make(stringSet) + for _, moduleBlock := range mod.ModuleCalls { + sources[moduleBlock.Source] = struct{}{} + } + return sources, nil +} diff --git a/infra/blueprint-test/pkg/tft/module_walk_test.go b/infra/blueprint-test/pkg/tft/module_walk_test.go new file mode 100644 index 00000000000..c7f0361cd9c --- /dev/null +++ b/infra/blueprint-test/pkg/tft/module_walk_test.go @@ -0,0 +1,88 @@ +package tft + +import ( + "fmt" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func toSet(args ...string) stringSet { + result := make(stringSet) + for _, arg := range args { + result[arg] = struct{}{} + } + return result +} + +func TestFindModulesUnderTest(t *testing.T) { + // Monkey patch findReferencedModules() in module_walk.go + // to skip parsing real Terraform modules. + findOrig := findReferencedModules + defer func() { findReferencedModules = findOrig }() + findReferencedModules = func(tfDir string) (stringSet, error) { + switch tfDir { + + // Group 1 (with external module references omitted): + // fixture1 -> [example1, example2] + // example1 -> [root, abc] + // example2 -> [xyz] + case "local/test/integration/fixture1": + return toSet("external1", "../../../examples/example1", "../../../examples/example2"), nil + case "local/examples/example1": + return toSet("../..", "../../modules/abc"), nil + case "local/examples/example2": + return toSet("../../modules/xyz", "external2"), nil + + // Group 2: fixture2 -> external_example, both point to external modules. + case "local/test/integration/fixture2": + return toSet("terraform-google-modules/network/google", "../../../examples/external_example"), nil + case "local/examples/external_example": + return toSet("terraform-google-modules/kubernetes-engine/google//modules/private-cluster"), nil + + default: + return nil, fmt.Errorf("no fake behavior configured for module path %q", tfDir) + } + } + + tests := []struct { + startPath string + want stringSet + }{ + // Group 1. + { + startPath: "local/test/integration/fixture1", + want: toSet("root", "abc", "xyz"), + }, + { + startPath: "local/examples/example1", + want: toSet("root", "abc"), + }, + { + startPath: "local/examples/example2", + want: toSet("xyz"), + }, + + // Group 2: no modules under test found. + { + startPath: "local/test/integration/fixture2", + want: toSet(), + }, + { + startPath: "local/examples/external_example", + want: toSet(), + }, + } + for _, tt := range tests { + t.Run(filepath.Base(tt.startPath), func(t *testing.T) { + got, err := findModulesUnderTest(tt.startPath) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("findModulesUnderTest(%q) returned unexpected result (-want +got):\n%s", tt.startPath, diff) + } + }) + } +} diff --git a/infra/blueprint-test/pkg/tft/terraform.go b/infra/blueprint-test/pkg/tft/terraform.go index dadd8484267..6a0d59b2331 100644 --- a/infra/blueprint-test/pkg/tft/terraform.go +++ b/infra/blueprint-test/pkg/tft/terraform.go @@ -20,9 +20,11 @@ package tft import ( b64 "encoding/base64" "fmt" + "maps" "os" "path" "path/filepath" + "slices" "strings" gotest "testing" "time" @@ -41,6 +43,11 @@ import ( const ( setupKeyOutputName = "sa_key" + setupKeyMapOutputName = "sa_keys_per_module" + + setupProjectOutputName = "project_id" + setupProjectMapOutputName = "project_ids_per_module" + tftCacheMutexFilename = "bpt-tft-cache.lock" planFilename = "plan.tfplan" ) @@ -244,9 +251,38 @@ func NewTFBlueprintTest(t testing.TB, opts ...tftOption) *TFBlueprintTest { gcloud.ActivateCredsAndEnvVars(tft.t, tft.saKey) } // load TFEnvVars from setup outputs + if tft.setupOutputOverrides == nil { + tft.setupOutputOverrides = make(map[string]interface{}) + } if tft.setupDir != "" { tft.logger.Logf(tft.t, "Loading env vars from setup %s", tft.setupDir) outputs := tft.getOutputs(tft.sensitiveOutputs(tft.setupDir)) + + modulesUnderTest, err := findModulesUnderTest(tft.tfDir) + if err != nil { + t.Fatal(err) + } + + // Pick a specific project_id and sa_key from project_ids_per_module and sa_keys_per_module + // if available. + overrides, err := resolveProjectAndKey(outputs, modulesUnderTest) + if err != nil { + t.Fatalf("Problem looking up overrides for project_id and sa_key from setup outputs: %v", err) + } + for k, v := range overrides { + tft.logger.Logf(tft.t, "Overriding var %q from per-module isolation settings", k) + outputs[k] = v + tft.setupOutputOverrides[k] = v + } + if _, hasProjectID := outputs[setupProjectMapOutputName]; hasProjectID && len(modulesUnderTest) == 0 { + tft.logger.Logf(tft.t, ` +*** + No local modules were transitively referenced from %q + (did you forget to run module-swapper?) and no default project_id output var is available. + A later ""project_id" is not set" error is likely. +***`, tft.tfDir) + } + loadTFEnvVar(tft.tfEnvVars, tft.getTFOutputsAsInputs(outputs)) if credsEnc, exists := tft.tfEnvVars[fmt.Sprintf("TF_VAR_%s", setupKeyOutputName)]; tft.saKey == "" && exists { if credDec, err := b64.StdEncoding.DecodeString(credsEnc); err == nil { @@ -260,9 +296,6 @@ func NewTFBlueprintTest(t testing.TB, opts ...tftOption) *TFBlueprintTest { } // Load env vars to supplement/override setup tft.logger.Logf(tft.t, "Loading setup from environment") - if tft.setupOutputOverrides == nil { - tft.setupOutputOverrides = make(map[string]interface{}) - } for k, v := range extractFromEnv("CFT_SETUP_") { tft.setupOutputOverrides[k] = v } @@ -452,6 +485,80 @@ func (b *TFBlueprintTest) GetTFSetupJsonOutput(key string) gjson.Result { return gjson.Parse(jsonString) } +// fetchRelevantFromOutputs looks in the given map of terraform outputs for +// outputs[key][subkey]. It returns: +// +// key_present, item_if_present, error +// +// where it is considered OK for `key` to be missing from `outputs`, resulting +// a return value of: +// +// false, "", nil +// +// But it is considered an error if `subkey` is missing from `outputs[key]` or +// if outputs[key][subkey] is not a string. +func fetchRelevantFromOutputs(outputs map[string]interface{}, key string, subkey string) (bool, string, error) { + val, found := outputs[key] + if !found { + return false, "", nil + } + + subMap, ok := val.(map[string]interface{}) + if !ok { + return false, "", fmt.Errorf("wrong data type for %q, expected map[string]interface, got %T", key, val) + } + + relevantItem, ok := subMap[subkey] + if !ok { + return false, "", fmt.Errorf("could not find key %q in map %q, which had keys %v", subkey, key, slices.Collect(maps.Keys(subMap))) + } + relevantItemStr, ok := relevantItem.(string) + if !ok { + return false, "", fmt.Errorf("value for key %q in map %q, had type %T, expected string", subkey, key, relevantItem) + } + return true, relevantItemStr, nil +} + +// resolveProjectAndKey picks a specific project ID and service account key +// to use, given the full map of the test setup outputs and a set of modules +// under test referenced by the current tfDir. +// +// The test setup outputs are taken in as an argument and are not modified. +// Instead, a map of overriding outputs is returned. +// +// If the modulesUnderTest argument contains only one module, then +// project_ids_per_module and sa_keys_per_module are resolved to the +// relevant project ID and key and the relevant ones are returned in the overrides map. +func resolveProjectAndKey(outputs map[string]interface{}, modulesUnderTest stringSet) (map[string]interface{}, error) { + overrides := make(map[string]interface{}) + + if len(modulesUnderTest) != 1 { + return overrides, nil + } + loneModuleName := "" + for module := range modulesUnderTest { + loneModuleName = module + break + } + + foundProjectIDMap, relevantProjectID, err := fetchRelevantFromOutputs(outputs, setupProjectMapOutputName, loneModuleName) + if err != nil { + return nil, err + } + if foundProjectIDMap { + overrides[setupProjectOutputName] = relevantProjectID + } + + foundKeyMap, relevantKey, err := fetchRelevantFromOutputs(outputs, setupKeyMapOutputName, loneModuleName) + if err != nil { + return nil, err + } + if foundKeyMap { + overrides[setupKeyOutputName] = relevantKey + } + return overrides, nil +} + // loadTFEnvVar adds new env variables prefixed with TF_VAR_ to an existing map of variables. func loadTFEnvVar(m map[string]string, new map[string]string) { for k, v := range new {