Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion infra/blueprint-test/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
189 changes: 189 additions & 0 deletions infra/blueprint-test/pkg/tft/module_walk.go
Original file line number Diff line number Diff line change
@@ -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
}
88 changes: 88 additions & 0 deletions infra/blueprint-test/pkg/tft/module_walk_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading
Loading