// Copyright (c) The OpenTofu Authors // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2023 HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package initwd import ( "bytes" "context" "flag" "os" "path/filepath" "strings" "testing" "github.com/davecgh/go-spew/spew" "github.com/google/go-cmp/cmp" version "github.com/hashicorp/go-version" "github.com/opentofu/svchost" "github.com/opentofu/opentofu/internal/addrs" "github.com/opentofu/opentofu/internal/configs" "github.com/opentofu/opentofu/internal/configs/configload" "github.com/opentofu/opentofu/internal/copy" "github.com/opentofu/opentofu/internal/getmodules" "github.com/opentofu/opentofu/internal/registry" "github.com/opentofu/opentofu/internal/tfdiags" _ "github.com/opentofu/opentofu/internal/logging" ) func TestMain(m *testing.M) { flag.Parse() os.Exit(m.Run()) } func TestModuleInstaller(t *testing.T) { fixtureDir := filepath.Clean("testdata/local-modules") dir := tempChdir(t, fixtureDir) hooks := &testInstallHooks{} modulesDir := filepath.Join(dir, ".terraform/modules") loader := configload.NewLoaderForTests(t) inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks, configs.RootModuleCallForTesting()) assertNoDiagnostics(t, diags) wantCalls := []testInstallHookCall{ { Name: "Install", ModuleAddr: "child_a", PackageAddr: "", LocalPath: "child_a", }, { Name: "Install", ModuleAddr: "child_a.child_b", PackageAddr: "", LocalPath: filepath.Join("child_a", "child_b"), }, } if assertResultDeepEqual(t, hooks.Calls, wantCalls) { return } loader, err := configload.NewLoader(&configload.Config{ ModulesDir: modulesDir, }) if err != nil { t.Fatal(err) } // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) config, loadDiags := loader.LoadConfig(t.Context(), ".", configs.RootModuleCallForTesting()) assertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) wantTraces := map[string]string{ "": "in root module", "child_a": "in child_a module", "child_a.child_b": "in child_b module", } gotTraces := map[string]string{} config.DeepEach(func(c *configs.Config) { path := strings.Join(c.Path, ".") if c.Module.Variables["v"] == nil { gotTraces[path] = "" return } varDesc := c.Module.Variables["v"].Description gotTraces[path] = varDesc }) assertResultDeepEqual(t, gotTraces, wantTraces) } func TestModuleInstaller_error(t *testing.T) { fixtureDir := filepath.Clean("testdata/local-module-error") dir := tempChdir(t, fixtureDir) hooks := &testInstallHooks{} modulesDir := filepath.Join(dir, ".terraform/modules") loader := configload.NewLoaderForTests(t) inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks, configs.RootModuleCallForTesting()) if !diags.HasErrors() { t.Fatal("expected error") } else { assertDiagnosticSummary(t, diags, "Invalid module source address") } } func TestModuleInstaller_emptyModuleName(t *testing.T) { fixtureDir := filepath.Clean("testdata/empty-module-name") dir := tempChdir(t, fixtureDir) hooks := &testInstallHooks{} modulesDir := filepath.Join(dir, ".terraform/modules") loader := configload.NewLoaderForTests(t) inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks, configs.RootModuleCallForTesting()) if !diags.HasErrors() { t.Fatal("expected error") } else { assertDiagnosticSummary(t, diags, "Invalid module instance name") } } func TestModuleInstaller_invalidModuleName(t *testing.T) { fixtureDir := filepath.Clean("testdata/invalid-module-name") dir := tempChdir(t, fixtureDir) hooks := &testInstallHooks{} modulesDir := filepath.Join(dir, ".terraform/modules") loader := configload.NewLoaderForTests(t) inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(t.Context(), nil, nil), nil) _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false, hooks, configs.RootModuleCallForTesting()) if !diags.HasErrors() { t.Fatal("expected error") } else { assertDiagnosticSummary(t, diags, "Invalid module instance name") } } func TestModuleInstaller_packageEscapeError(t *testing.T) { fixtureDir := filepath.Clean("testdata/load-module-package-escape") dir := tempChdir(t, fixtureDir) // For this particular test we need an absolute path in the root module // that must actually resolve to our temporary directory in "dir", so // we need to do a little rewriting. We replace the arbitrary placeholder // %%BASE%% with the temporary directory path. { rootFilename := filepath.Join(dir, "package-escape.tf") template, err := os.ReadFile(rootFilename) if err != nil { t.Fatal(err) } final := bytes.ReplaceAll(template, []byte("%%BASE%%"), []byte(filepath.ToSlash(dir))) err = os.WriteFile(rootFilename, final, 0o644) if err != nil { t.Fatal(err) } } hooks := &testInstallHooks{} modulesDir := filepath.Join(dir, ".terraform/modules") loader := configload.NewLoaderForTests(t) // This test needs a real getmodules.PackageFetcher because it makes use of // the esoteric legacy support for treating an absolute filesystem path // as if it were a "remote package". This should not use any of the // truly-"remote" module sources, even though it technically has access to. inst := NewModuleInstaller(modulesDir, loader, nil, getmodules.NewPackageFetcher(t.Context(), nil)) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks, configs.RootModuleCallForTesting()) if !diags.HasErrors() { t.Fatal("expected error") } else { assertDiagnosticSummary(t, diags, "Local module path escapes module package") } } func TestModuleInstaller_explicitPackageBoundary(t *testing.T) { fixtureDir := filepath.Clean("testdata/load-module-package-prefix") dir := tempChdir(t, fixtureDir) // For this particular test we need an absolute path in the root module // that must actually resolve to our temporary directory in "dir", so // we need to do a little rewriting. We replace the arbitrary placeholder // %%BASE%% with the temporary directory path. { rootFilename := filepath.Join(dir, "package-prefix.tf") template, err := os.ReadFile(rootFilename) if err != nil { t.Fatal(err) } final := bytes.ReplaceAll(template, []byte("%%BASE%%"), []byte(filepath.ToSlash(dir))) err = os.WriteFile(rootFilename, final, 0o644) if err != nil { t.Fatal(err) } } hooks := &testInstallHooks{} modulesDir := filepath.Join(dir, ".terraform/modules") loader := configload.NewLoaderForTests(t) // This test needs a real getmodules.PackageFetcher because it makes use of // the esoteric legacy support for treating an absolute filesystem path // as if it were a "remote package". This should not use any of the // truly-"remote" module sources, even though it technically has access to. inst := NewModuleInstaller(modulesDir, loader, nil, getmodules.NewPackageFetcher(t.Context(), nil)) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks, configs.RootModuleCallForTesting()) if diags.HasErrors() { t.Fatalf("unexpected errors\n%s", diags.Err().Error()) } } func TestModuleInstaller_Prerelease(t *testing.T) { if os.Getenv("TF_ACC") == "" { t.Skip("this test accesses registry.opentofu.org and github.com; set TF_ACC=1 to run it") } testCases := []struct { name string modulePath string expectedVersion string shouldError bool }{ { name: "exact match", modulePath: "testdata/prerelease-constraint-match", expectedVersion: "v0.0.3-alpha.1", }, { name: "exact match v prefix", modulePath: "testdata/prerelease-constraint-v-match", expectedVersion: "v0.0.3-alpha.1", }, { name: "exact match eq selector", modulePath: "testdata/prerelease-constraint-eq-match", expectedVersion: "v0.0.3-alpha.1", }, { name: "exact match v prefix eq selector", modulePath: "testdata/prerelease-constraint-v-eq-match", expectedVersion: "v0.0.3-alpha.1", }, { name: "partial match", modulePath: "testdata/prerelease-constraint", expectedVersion: "v0.0.2", }, { name: "partial match v prefix", modulePath: "testdata/prerelease-constraint-v", expectedVersion: "v0.0.2", }, { name: "multiple constraints", modulePath: "testdata/prerelease-constraint-multiple", shouldError: true, // NOTE: This one fails because we don't support mixing a prerelease version // selection with other constraints in a single constraint string. This is // unfortunate but accepted for now as a concession to backward compatibility // until we have a more complete plan on how to deal with the various legacy // quirks of our version constraint matching. // // For more information: // https://github.com/opentofu/opentofu/issues/2117 }, { name: "err", modulePath: "testdata/prerelease-constraint-err", shouldError: true, }, { name: "err v prefix", modulePath: "testdata/prerelease-constraint-v-err", shouldError: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { fixtureDir := filepath.Clean(tc.modulePath) dir := tempChdir(t, fixtureDir) hooks := &testInstallHooks{} modulesDir := filepath.Join(dir, ".terraform/modules") loader := configload.NewLoaderForTests(t) inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(t.Context(), nil, nil), nil) cfg, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks, configs.RootModuleCallForTesting()) if tc.shouldError { if !diags.HasErrors() { t.Fatalf("an error was expected, but none was found") } return } if diags.HasErrors() { t.Fatalf("found unexpected errors: %s", diags.Err()) } if !cfg.Children["acctest"].Version.Equal(version.Must(version.NewVersion(tc.expectedVersion))) { t.Fatalf("expected version %s but found version %s", tc.expectedVersion, cfg.Version.String()) } }) } } func TestModuleInstaller_invalid_version_constraint_error(t *testing.T) { fixtureDir := filepath.Clean("testdata/invalid-version-constraint") dir := tempChdir(t, fixtureDir) hooks := &testInstallHooks{} modulesDir := filepath.Join(dir, ".terraform/modules") loader := configload.NewLoaderForTests(t) inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks, configs.RootModuleCallForTesting()) if !diags.HasErrors() { t.Fatal("expected error") } else { // We use the presence of the "version" argument as a heuristic for // user intent to use a registry module, and so we intentionally catch // this as an invalid registry module address rather than an invalid // version constraint, so we can surface the specific address parsing // error instead of a generic version constraint error. assertDiagnosticSummary(t, diags, "Invalid registry module source address") } } func TestModuleInstaller_invalidVersionConstraintGetter(t *testing.T) { fixtureDir := filepath.Clean("testdata/invalid-version-constraint") dir := tempChdir(t, fixtureDir) hooks := &testInstallHooks{} modulesDir := filepath.Join(dir, ".terraform/modules") loader := configload.NewLoaderForTests(t) inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks, configs.RootModuleCallForTesting()) if !diags.HasErrors() { t.Fatal("expected error") } else { // We use the presence of the "version" argument as a heuristic for // user intent to use a registry module, and so we intentionally catch // this as an invalid registry module address rather than an invalid // version constraint, so we can surface the specific address parsing // error instead of a generic version constraint error. assertDiagnosticSummary(t, diags, "Invalid registry module source address") } } func TestModuleInstaller_invalidVersionConstraintLocal(t *testing.T) { fixtureDir := filepath.Clean("testdata/invalid-version-constraint-local") dir := tempChdir(t, fixtureDir) hooks := &testInstallHooks{} modulesDir := filepath.Join(dir, ".terraform/modules") loader := configload.NewLoaderForTests(t) inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks, configs.RootModuleCallForTesting()) if !diags.HasErrors() { t.Fatal("expected error") } else { // We use the presence of the "version" argument as a heuristic for // user intent to use a registry module, and so we intentionally catch // this as an invalid registry module address rather than an invalid // version constraint, so we can surface the specific address parsing // error instead of a generic version constraint error. assertDiagnosticSummary(t, diags, "Invalid registry module source address") } } func TestModuleInstaller_symlink(t *testing.T) { fixtureDir := filepath.Clean("testdata/local-module-symlink") dir := tempChdir(t, fixtureDir) hooks := &testInstallHooks{} modulesDir := filepath.Join(dir, ".terraform/modules") loader := configload.NewLoaderForTests(t) inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks, configs.RootModuleCallForTesting()) assertNoDiagnostics(t, diags) wantCalls := []testInstallHookCall{ { Name: "Install", ModuleAddr: "child_a", PackageAddr: "", LocalPath: "child_a", }, { Name: "Install", ModuleAddr: "child_a.child_b", PackageAddr: "", LocalPath: filepath.Join("child_a", "child_b"), }, } if assertResultDeepEqual(t, hooks.Calls, wantCalls) { return } loader, err := configload.NewLoader(&configload.Config{ ModulesDir: modulesDir, }) if err != nil { t.Fatal(err) } // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) config, loadDiags := loader.LoadConfig(t.Context(), ".", configs.RootModuleCallForTesting()) assertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) wantTraces := map[string]string{ "": "in root module", "child_a": "in child_a module", "child_a.child_b": "in child_b module", } gotTraces := map[string]string{} config.DeepEach(func(c *configs.Config) { path := strings.Join(c.Path, ".") if c.Module.Variables["v"] == nil { gotTraces[path] = "" return } varDesc := c.Module.Variables["v"].Description gotTraces[path] = varDesc }) assertResultDeepEqual(t, gotTraces, wantTraces) } func TestLoaderInstallModules_registry(t *testing.T) { if os.Getenv("TF_ACC") == "" { t.Skip("this test accesses registry.opentofu.org and github.com; set TF_ACC=1 to run it") } fixtureDir := filepath.Clean("testdata/registry-modules") tmpDir := tempChdir(t, fixtureDir) // the module installer runs filepath.EvalSymlinks() on the destination // directory before copying files, and the resultant directory is what is // returned by the install hooks. Without this, tests could fail on machines // where the default temp dir was a symlink. dir, err := filepath.EvalSymlinks(tmpDir) if err != nil { t.Error(err) } hooks := &testInstallHooks{} modulesDir := filepath.Join(dir, ".terraform/modules") loader := configload.NewLoaderForTests(t) inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(t.Context(), nil, nil), nil) _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false, hooks, configs.RootModuleCallForTesting()) assertNoDiagnostics(t, diags) v := version.Must(version.NewVersion("0.0.1")) wantCalls := []testInstallHookCall{ // the configuration builder visits each level of calls in lexicographical // order by name, so the following list is kept in the same order. // acctest_child_a accesses //modules/child_a directly { Name: "Download", ModuleAddr: "acctest_child_a", PackageAddr: "registry.opentofu.org/hashicorp/module-installer-acctest/aws", // intentionally excludes the subdir because we're downloading the whole package here Version: v, }, { Name: "Install", ModuleAddr: "acctest_child_a", Version: v, // NOTE: This local path and the other paths derived from it below // can vary depending on how the registry is implemented. At the // time of writing this test, registry.opentofu.org returns // git repository source addresses and so this path refers to the // root of the git clone, but historically the registry referred // to GitHub-provided tar archives which meant that there was an // extra level of subdirectory here for the typical directory // nesting in tar archives, which would've been reflected as // an extra segment on this path. If this test fails due to an // additional path segment in future, then a change to the upstream // registry might be the root cause. LocalPath: filepath.Join(dir, ".terraform/modules/acctest_child_a/modules/child_a"), }, // acctest_child_a.child_b // (no download because it's a relative path inside acctest_child_a) { Name: "Install", ModuleAddr: "acctest_child_a.child_b", LocalPath: filepath.Join(dir, ".terraform/modules/acctest_child_a/modules/child_b"), }, // acctest_child_b accesses //modules/child_b directly { Name: "Download", ModuleAddr: "acctest_child_b", PackageAddr: "registry.opentofu.org/hashicorp/module-installer-acctest/aws", // intentionally excludes the subdir because we're downloading the whole package here Version: v, }, { Name: "Install", ModuleAddr: "acctest_child_b", Version: v, LocalPath: filepath.Join(dir, ".terraform/modules/acctest_child_b/modules/child_b"), }, // acctest_root { Name: "Download", ModuleAddr: "acctest_root", PackageAddr: "registry.opentofu.org/hashicorp/module-installer-acctest/aws", Version: v, }, { Name: "Install", ModuleAddr: "acctest_root", Version: v, LocalPath: filepath.Join(dir, ".terraform/modules/acctest_root"), }, // acctest_root.child_a // (no download because it's a relative path inside acctest_root) { Name: "Install", ModuleAddr: "acctest_root.child_a", LocalPath: filepath.Join(dir, ".terraform/modules/acctest_root/modules/child_a"), }, // acctest_root.child_a.child_b // (no download because it's a relative path inside acctest_root, via acctest_root.child_a) { Name: "Install", ModuleAddr: "acctest_root.child_a.child_b", LocalPath: filepath.Join(dir, ".terraform/modules/acctest_root/modules/child_b"), }, } if diff := cmp.Diff(wantCalls, hooks.Calls); diff != "" { t.Fatalf("wrong installer calls\n%s", diff) } // check that the registry responses were cached packageAddr := addrs.ModuleRegistryPackage{ Host: svchost.Hostname("registry.opentofu.org"), Namespace: "hashicorp", Name: "module-installer-acctest", TargetSystem: "aws", } if _, ok := inst.registryPackageVersions[packageAddr]; !ok { t.Errorf("module versions cache was not populated\ngot: %s\nwant: key hashicorp/module-installer-acctest/aws", spew.Sdump(inst.registryPackageVersions)) } if _, ok := inst.registryPackageSources[moduleVersion{module: packageAddr, version: "0.0.1"}]; !ok { t.Errorf("module download url cache was not populated\ngot: %s", spew.Sdump(inst.registryPackageSources)) } loader, err = configload.NewLoader(&configload.Config{ ModulesDir: modulesDir, }) if err != nil { t.Fatal(err) } // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) config, loadDiags := loader.LoadConfig(t.Context(), ".", configs.RootModuleCallForTesting()) assertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) wantTraces := map[string]string{ "": "in local caller for registry-modules", "acctest_root": "in root module", "acctest_root.child_a": "in child_a module", "acctest_root.child_a.child_b": "in child_b module", "acctest_child_a": "in child_a module", "acctest_child_a.child_b": "in child_b module", "acctest_child_b": "in child_b module", } gotTraces := map[string]string{} config.DeepEach(func(c *configs.Config) { path := strings.Join(c.Path, ".") if c.Module.Variables["v"] == nil { gotTraces[path] = "" return } varDesc := c.Module.Variables["v"].Description gotTraces[path] = varDesc }) assertResultDeepEqual(t, gotTraces, wantTraces) } func TestLoaderInstallModules_goGetter(t *testing.T) { if os.Getenv("TF_ACC") == "" { t.Skip("this test accesses github.com; set TF_ACC=1 to run it") } fixtureDir := filepath.Clean("testdata/go-getter-modules") tmpDir := tempChdir(t, fixtureDir) // the module installer runs filepath.EvalSymlinks() on the destination // directory before copying files, and the resultant directory is what is // returned by the install hooks. Without this, tests could fail on machines // where the default temp dir was a symlink. dir, err := filepath.EvalSymlinks(tmpDir) if err != nil { t.Error(err) } hooks := &testInstallHooks{} modulesDir := filepath.Join(dir, ".terraform/modules") loader := configload.NewLoaderForTests(t) inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(t.Context(), nil, nil), nil) _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false, hooks, configs.RootModuleCallForTesting()) assertNoDiagnostics(t, diags) wantCalls := []testInstallHookCall{ // the configuration builder visits each level of calls in lexicographical // order by name, so the following list is kept in the same order. // acctest_child_a accesses //modules/child_a directly { Name: "Download", ModuleAddr: "acctest_child_a", PackageAddr: "git::https://github.com/hashicorp/terraform-aws-module-installer-acctest.git?ref=v0.0.1", // intentionally excludes the subdir because we're downloading the whole repo here }, { Name: "Install", ModuleAddr: "acctest_child_a", LocalPath: filepath.Join(dir, ".terraform/modules/acctest_child_a/modules/child_a"), }, // acctest_child_a.child_b // (no download because it's a relative path inside acctest_child_a) { Name: "Install", ModuleAddr: "acctest_child_a.child_b", LocalPath: filepath.Join(dir, ".terraform/modules/acctest_child_a/modules/child_b"), }, // acctest_child_b accesses //modules/child_b directly { Name: "Download", ModuleAddr: "acctest_child_b", PackageAddr: "git::https://github.com/hashicorp/terraform-aws-module-installer-acctest.git?ref=v0.0.1", // intentionally excludes the subdir because we're downloading the whole package here }, { Name: "Install", ModuleAddr: "acctest_child_b", LocalPath: filepath.Join(dir, ".terraform/modules/acctest_child_b/modules/child_b"), }, // acctest_root { Name: "Download", ModuleAddr: "acctest_root", PackageAddr: "git::https://github.com/hashicorp/terraform-aws-module-installer-acctest.git?ref=v0.0.1", }, { Name: "Install", ModuleAddr: "acctest_root", LocalPath: filepath.Join(dir, ".terraform/modules/acctest_root"), }, // acctest_root.child_a // (no download because it's a relative path inside acctest_root) { Name: "Install", ModuleAddr: "acctest_root.child_a", LocalPath: filepath.Join(dir, ".terraform/modules/acctest_root/modules/child_a"), }, // acctest_root.child_a.child_b // (no download because it's a relative path inside acctest_root, via acctest_root.child_a) { Name: "Install", ModuleAddr: "acctest_root.child_a.child_b", LocalPath: filepath.Join(dir, ".terraform/modules/acctest_root/modules/child_b"), }, } if diff := cmp.Diff(wantCalls, hooks.Calls); diff != "" { t.Fatalf("wrong installer calls\n%s", diff) } loader, err = configload.NewLoader(&configload.Config{ ModulesDir: modulesDir, }) if err != nil { t.Fatal(err) } // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) config, loadDiags := loader.LoadConfig(t.Context(), ".", configs.RootModuleCallForTesting()) assertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) wantTraces := map[string]string{ "": "in local caller for go-getter-modules", "acctest_root": "in root module", "acctest_root.child_a": "in child_a module", "acctest_root.child_a.child_b": "in child_b module", "acctest_child_a": "in child_a module", "acctest_child_a.child_b": "in child_b module", "acctest_child_b": "in child_b module", } gotTraces := map[string]string{} config.DeepEach(func(c *configs.Config) { path := strings.Join(c.Path, ".") if c.Module.Variables["v"] == nil { gotTraces[path] = "" return } varDesc := c.Module.Variables["v"].Description gotTraces[path] = varDesc }) assertResultDeepEqual(t, gotTraces, wantTraces) } func TestModuleInstaller_fromTests(t *testing.T) { fixtureDir := filepath.Clean("testdata/local-module-from-test") dir := tempChdir(t, fixtureDir) hooks := &testInstallHooks{} modulesDir := filepath.Join(dir, ".terraform/modules") loader := configload.NewLoaderForTests(t) inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks, configs.RootModuleCallForTesting()) assertNoDiagnostics(t, diags) wantCalls := []testInstallHookCall{ { Name: "Install", ModuleAddr: "test.tests.main.setup", PackageAddr: "", LocalPath: "setup", }, } if assertResultDeepEqual(t, hooks.Calls, wantCalls) { return } loader, err := configload.NewLoader(&configload.Config{ ModulesDir: modulesDir, }) if err != nil { t.Fatal(err) } // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) config, loadDiags := loader.LoadConfigWithTests(t.Context(), ".", "tests", configs.RootModuleCallForTesting()) assertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) if config.Module.Tests[filepath.Join("tests", "main.tftest.hcl")].Runs[0].ConfigUnderTest == nil { t.Fatalf("should have loaded config into the relevant run block but did not") } } func TestLoadInstallModules_registryFromTest(t *testing.T) { if os.Getenv("TF_ACC") == "" { t.Skip("this test accesses registry.opentofu.org and github.com; set TF_ACC=1 to run it") } fixtureDir := filepath.Clean("testdata/registry-module-from-test") tmpDir := tempChdir(t, fixtureDir) // the module installer runs filepath.EvalSymlinks() on the destination // directory before copying files, and the resultant directory is what is // returned by the install hooks. Without this, tests could fail on machines // where the default temp dir was a symlink. dir, err := filepath.EvalSymlinks(tmpDir) if err != nil { t.Error(err) } hooks := &testInstallHooks{} modulesDir := filepath.Join(dir, ".terraform/modules") loader := configload.NewLoaderForTests(t) inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(t.Context(), nil, nil), nil) _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false, hooks, configs.RootModuleCallForTesting()) assertNoDiagnostics(t, diags) v := version.Must(version.NewVersion("0.0.1")) wantCalls := []testInstallHookCall{ // the configuration builder visits each level of calls in lexicographical // order by name, so the following list is kept in the same order. // setup access acctest directly. { Name: "Download", ModuleAddr: "test.main.setup", PackageAddr: "registry.opentofu.org/hashicorp/module-installer-acctest/aws", // intentionally excludes the subdir because we're downloading the whole package here Version: v, }, { Name: "Install", ModuleAddr: "test.main.setup", Version: v, // NOTE: This local path and the other paths derived from it below // can vary depending on how the registry is implemented. At the // time of writing this test, registry.opentofu.org returns // git repository source addresses and so this path refers to the // root of the git clone, but historically the registry referred // to GitHub-provided tar archives which meant that there was an // extra level of subdirectory here for the typical directory // nesting in tar archives, which would've been reflected as // an extra segment on this path. If this test fails due to an // additional path segment in future, then a change to the upstream // registry might be the root cause. LocalPath: filepath.Join(dir, ".terraform/modules/test.main.setup"), }, // main.tftest.hcl.setup.child_a // (no download because it's a relative path inside acctest_child_a) { Name: "Install", ModuleAddr: "test.main.setup.child_a", LocalPath: filepath.Join(dir, ".terraform/modules/test.main.setup/modules/child_a"), }, // main.tftest.hcl.setup.child_a.child_b // (no download because it's a relative path inside main.tftest.hcl.setup.child_a) { Name: "Install", ModuleAddr: "test.main.setup.child_a.child_b", LocalPath: filepath.Join(dir, ".terraform/modules/test.main.setup/modules/child_b"), }, } if diff := cmp.Diff(wantCalls, hooks.Calls); diff != "" { t.Fatalf("wrong installer calls\n%s", diff) } // check that the registry responses were cached packageAddr := addrs.ModuleRegistryPackage{ Host: svchost.Hostname("registry.opentofu.org"), Namespace: "hashicorp", Name: "module-installer-acctest", TargetSystem: "aws", } if _, ok := inst.registryPackageVersions[packageAddr]; !ok { t.Errorf("module versions cache was not populated\ngot: %s\nwant: key hashicorp/module-installer-acctest/aws", spew.Sdump(inst.registryPackageVersions)) } if _, ok := inst.registryPackageSources[moduleVersion{module: packageAddr, version: "0.0.1"}]; !ok { t.Errorf("module download url cache was not populated\ngot: %s", spew.Sdump(inst.registryPackageSources)) } loader, err = configload.NewLoader(&configload.Config{ ModulesDir: modulesDir, }) if err != nil { t.Fatal(err) } // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) config, loadDiags := loader.LoadConfigWithTests(t.Context(), ".", "tests", configs.RootModuleCallForTesting()) assertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) if config.Module.Tests["main.tftest.hcl"].Runs[0].ConfigUnderTest == nil { t.Fatalf("should have loaded config into the relevant run block but did not") } } func TestIsSubDirNonExistent(t *testing.T) { tmpDir := t.TempDir() // Create test directory structure existingDir := filepath.Join(tmpDir, "existing") nestedExistingDir := filepath.Join(existingDir, "nested", "deep") err := os.MkdirAll(nestedExistingDir, 0o755) if err != nil { t.Fatal(err) } tests := []struct { name string path string wantExists bool wantMissingDir string }{ { name: "directory exists", path: existingDir, wantExists: false, wantMissingDir: "", }, { name: "nested directory exists", path: nestedExistingDir, wantExists: false, wantMissingDir: "", }, { name: "single missing directory with existing parent", path: filepath.Join(existingDir, "missing"), wantExists: true, wantMissingDir: "missing", }, { name: "nested missing directory", path: filepath.Join(existingDir, "missing", "nested"), wantExists: true, wantMissingDir: "missing", }, { name: "deeply nested missing with existing parent", path: filepath.Join(nestedExistingDir, "missing"), wantExists: true, wantMissingDir: "missing", }, { name: "multiple missing levels", path: filepath.Join(existingDir, "missing", "also", "missingtoo"), wantExists: true, wantMissingDir: "missing", }, { name: "completely non-existent path", path: filepath.Join(tmpDir, "completely", "missing", "path"), wantExists: true, wantMissingDir: "completely", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotExists, gotMissingDir := isSubDirNonExistent(tt.path) if gotExists != tt.wantExists { t.Errorf("isSubDirNonExistent() exists = %v, want %v", gotExists, tt.wantExists) } if gotMissingDir != tt.wantMissingDir { t.Errorf("isSubDirNonExistent() missingDir = %q, want %q", gotMissingDir, tt.wantMissingDir) } }) } } // TestModuleInstaller_nonExistentSubmodule ensures that the error message returned when a module does not exist in an existing module // is not that it's a bug in OpenTofu. See issue https://github.com/opentofu/opentofu/issues/3142 for more information // // Code is taken from tests above func TestModuleInstaller_nonExistentSubmodule(t *testing.T) { if os.Getenv("TF_ACC") == "" { t.Skip("this test accesses github.com; set TF_ACC=1 to run it") } fixtureDir := filepath.Clean("testdata/submodule-non-existent") tmpDir := tempChdir(t, fixtureDir) // the module installer runs filepath.EvalSymlinks() on the destination // directory before copying files, and the resultant directory is what is // returned by the install hooks. Without this, tests could fail on machines // where the default temp dir was a symlink. dir, err := filepath.EvalSymlinks(tmpDir) if err != nil { t.Error(err) } hooks := &testInstallHooks{} modulesDir := filepath.Join(dir, ".terraform/modules") loader := configload.NewLoaderForTests(t) fetcher := getmodules.NewPackageFetcher(t.Context(), nil) inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(t.Context(), nil, nil), fetcher) _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false, hooks, configs.RootModuleCallForTesting()) if !diags.HasErrors() { t.Fatal("expected error for non-existent submodule, but got none") } errorStr := diags.Err().Error() t.Logf("Actual error: %s", errorStr) if !strings.Contains(errorStr, "subdirectory") && !strings.Contains(errorStr, "does not exist") { t.Errorf("Expected error to mention missing subdirectory. Got: %s", errorStr) } if !strings.Contains(errorStr, "Module subdirectory not found") { t.Errorf("Expected 'Module subdirectory not found' in error message. Got: %s", errorStr) } } type testInstallHooks struct { Calls []testInstallHookCall } type testInstallHookCall struct { Name string ModuleAddr string PackageAddr string Version *version.Version LocalPath string } func (h *testInstallHooks) Download(moduleAddr, packageAddr string, version *version.Version) { h.Calls = append(h.Calls, testInstallHookCall{ Name: "Download", ModuleAddr: moduleAddr, PackageAddr: packageAddr, Version: version, }) } func (h *testInstallHooks) Install(moduleAddr string, version *version.Version, localPath string) { h.Calls = append(h.Calls, testInstallHookCall{ Name: "Install", ModuleAddr: moduleAddr, Version: version, LocalPath: localPath, }) } // tempChdir copies the contents of the given directory to a temporary // directory and changes the test process's current working directory to // point to that directory. The temporary directory is deleted and the // working directory restored after the calling test is complete. // // Tests using this helper cannot safely be run in parallel with other tests. func tempChdir(t testing.TB, sourceDir string) string { t.Helper() tmpDir := t.TempDir() if err := copy.CopyDir(tmpDir, sourceDir); err != nil { t.Fatalf("failed to copy fixture to temporary directory: %s", err) return "" } t.Chdir(tmpDir) // Most of the tests need this, so we'll make it just in case. if err := os.MkdirAll(filepath.Join(tmpDir, ".terraform/modules"), os.ModePerm); err != nil { t.Fatalf("failed to make module cache directory: %s", err) return "" } t.Logf("tempChdir switched to %s after copying from %s", tmpDir, sourceDir) return tmpDir } func assertNoDiagnostics(t *testing.T, diags tfdiags.Diagnostics) bool { t.Helper() return assertDiagnosticCount(t, diags, 0) } func assertDiagnosticCount(t *testing.T, diags tfdiags.Diagnostics, want int) bool { t.Helper() if len(diags) != want { t.Errorf("wrong number of diagnostics %d; want %d", len(diags), want) for _, diag := range diags { desc := diag.Description() t.Logf("- %s: %s", desc.Summary, desc.Detail) } return true } return false } func assertDiagnosticSummary(t *testing.T, diags tfdiags.Diagnostics, want string) bool { t.Helper() for _, diag := range diags { if diag.Description().Summary == want { return false } } t.Errorf("missing diagnostic summary %q", want) for _, diag := range diags { desc := diag.Description() t.Logf("- %s: %s", desc.Summary, desc.Detail) } return true } func assertResultDeepEqual(t *testing.T, got, want any) bool { // Note that, unlike some "assert" functions elsewhere, this function // returns true to indicate that the assertion _failed_, and false // to indicate that it succeeded. t.Helper() if diff := cmp.Diff(want, got); diff != "" { t.Error("wrong result:\n" + diff) return true } return false }