| // Copyright (c) The OpenTofu Authors | |
| // SPDX-License-Identifier: MPL-2.0 | |
| // Copyright (c) 2023 HashiCorp, Inc. | |
| // SPDX-License-Identifier: MPL-2.0 | |
| package command | |
| import ( | |
| "bufio" | |
| "bytes" | |
| "context" | |
| "crypto/md5" | |
| "encoding/base64" | |
| "encoding/json" | |
| "fmt" | |
| "io" | |
| "io/fs" | |
| "net/http" | |
| "net/http/httptest" | |
| "os" | |
| "os/exec" | |
| "path" | |
| "path/filepath" | |
| "runtime" | |
| "strings" | |
| "testing" | |
| "github.com/google/go-cmp/cmp" | |
| "github.com/mitchellh/cli" | |
| "github.com/opentofu/opentofu/internal/command/arguments" | |
| "github.com/opentofu/svchost" | |
| "github.com/opentofu/svchost/disco" | |
| "github.com/zclconf/go-cty/cty" | |
| "github.com/opentofu/opentofu/internal/addrs" | |
| backendInit "github.com/opentofu/opentofu/internal/backend/init" | |
| backendLocal "github.com/opentofu/opentofu/internal/backend/local" | |
| "github.com/opentofu/opentofu/internal/command/clistate" | |
| "github.com/opentofu/opentofu/internal/command/views" | |
| "github.com/opentofu/opentofu/internal/command/workdir" | |
| "github.com/opentofu/opentofu/internal/configs" | |
| "github.com/opentofu/opentofu/internal/configs/configload" | |
| "github.com/opentofu/opentofu/internal/configs/configschema" | |
| "github.com/opentofu/opentofu/internal/copy" | |
| "github.com/opentofu/opentofu/internal/depsfile" | |
| "github.com/opentofu/opentofu/internal/encryption" | |
| "github.com/opentofu/opentofu/internal/getproviders" | |
| "github.com/opentofu/opentofu/internal/initwd" | |
| _ "github.com/opentofu/opentofu/internal/logging" | |
| "github.com/opentofu/opentofu/internal/plans" | |
| "github.com/opentofu/opentofu/internal/plans/planfile" | |
| "github.com/opentofu/opentofu/internal/providers" | |
| "github.com/opentofu/opentofu/internal/registry" | |
| "github.com/opentofu/opentofu/internal/states" | |
| "github.com/opentofu/opentofu/internal/states/statefile" | |
| "github.com/opentofu/opentofu/internal/states/statemgr" | |
| "github.com/opentofu/opentofu/internal/terminal" | |
| "github.com/opentofu/opentofu/internal/tofu" | |
| "github.com/opentofu/opentofu/version" | |
| ) | |
| // These are the directories for our test data and fixtures. | |
| var ( | |
| fixtureDir = "./testdata" | |
| testDataDir = "./testdata" | |
| ) | |
| func init() { | |
| test = true | |
| // Initialize the backends | |
| backendInit.Init(nil) | |
| // Expand the data and fixture dirs on init because | |
| // we change the working directory in some tests. | |
| var err error | |
| fixtureDir, err = filepath.Abs(fixtureDir) | |
| if err != nil { | |
| panic(err) | |
| } | |
| testDataDir, err = filepath.Abs(testDataDir) | |
| if err != nil { | |
| panic(err) | |
| } | |
| } | |
| func TestMain(m *testing.M) { | |
| // Make sure backend init is initialized, since our tests tend to assume it. | |
| backendInit.Init(nil) | |
| os.Exit(m.Run()) | |
| } | |
| // RunCommander handles simulating the arg handling that is typically available | |
| // through the cli package | |
| func RunCommander(t *testing.T, cmd Command, meta Meta, args []string) int { | |
| diags := cmd.CommandLine.ParseDirect(t.Context(), args) | |
| return RunCli("testing", cmd, meta, diags) | |
| } | |
| // tempWorkingDir constructs a workdir.Dir object referring to a newly-created | |
| // temporary directory. The temporary directory is automatically removed when | |
| // the test and all its subtests complete. | |
| // | |
| // Although workdir.Dir is built to support arbitrary base directories, the | |
| // not-yet-migrated behaviors in command.Meta tend to expect the root module | |
| // directory to be the real process working directory, and so if you intend | |
| // to use the result inside a command.Meta object you must use a pattern | |
| // similar to the following when initializing your test: | |
| // | |
| // wd := tempWorkingDir(t) | |
| // defer testChdir(t, wd.RootModuleDir())() | |
| // | |
| // Note that testChdir modifies global state for the test process, and so a | |
| // test using this pattern must never call t.Parallel(). | |
| func tempWorkingDir(t *testing.T) *workdir.Dir { | |
| t.Helper() | |
| dirPath := t.TempDir() | |
| t.Logf("temporary directory %s", dirPath) | |
| return workdir.NewDir(dirPath) | |
| } | |
| // tempWorkingDirFixture is like tempWorkingDir but it also copies the content | |
| // from a fixture directory into the temporary directory before returning it. | |
| // | |
| // The same caveats about working directory apply as for testWorkingDir. See | |
| // the testWorkingDir commentary for an example of how to use this function | |
| // along with testChdir to meet the expectations of command.Meta legacy | |
| // functionality. | |
| func tempWorkingDirFixture(t *testing.T, fixtureName string) *workdir.Dir { | |
| t.Helper() | |
| dirPath := testTempDirRealpath(t) | |
| t.Logf("temporary directory %s with fixture %q", dirPath, fixtureName) | |
| fixturePath := testFixturePath(fixtureName) | |
| testCopyDir(t, fixturePath, dirPath) | |
| // NOTE: Unfortunately because testCopyDir immediately aborts the test | |
| // on failure, a failure to copy will prevent us from cleaning up the | |
| // temporary directory. Oh well. :( | |
| return workdir.NewDir(dirPath) | |
| } | |
| func testFixturePath(name string) string { | |
| return filepath.Join(fixtureDir, name) | |
| } | |
| func metaOverridesForProvider(p providers.Interface) *testingOverrides { | |
| return &testingOverrides{ | |
| Providers: map[addrs.Provider]providers.Factory{ | |
| addrs.NewDefaultProvider("test"): providers.FactoryFixed(p), | |
| addrs.NewProvider(addrs.DefaultProviderRegistryHost, "hashicorp2", "test"): providers.FactoryFixed(p), | |
| }, | |
| } | |
| } | |
| func testModuleWithSnapshot(t *testing.T, name string) (*configs.Config, *configload.Snapshot) { | |
| t.Helper() | |
| dir := filepath.Join(fixtureDir, name) | |
| loader := configload.NewLoaderForTests(t, false) | |
| // Test modules usually do not refer to remote sources, and for local | |
| // sources only this ultimately just records all of the module paths | |
| // in a JSON file so that we can load them below. | |
| inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(t.Context(), nil, nil), nil) | |
| _, instDiags := inst.InstallModules(context.Background(), dir, "tests", true, false, initwd.ModuleInstallHooksImpl{}, configs.RootModuleCallForTesting()) | |
| if instDiags.HasErrors() { | |
| t.Fatal(instDiags.Err()) | |
| } | |
| config, snap, diags := loader.LoadConfigWithSnapshot(t.Context(), dir, configs.RootModuleCallForTesting()) | |
| if diags.HasErrors() { | |
| t.Fatal(diags.Error()) | |
| } | |
| return config, snap | |
| } | |
| // testPlan returns a non-nil noop plan. | |
| func testPlan(t *testing.T) *plans.Plan { | |
| t.Helper() | |
| // This is what an empty configuration block would look like after being | |
| // decoded with the schema of the "local" backend. | |
| backendConfig := cty.ObjectVal(map[string]cty.Value{ | |
| "path": cty.NullVal(cty.String), | |
| "workspace_dir": cty.NullVal(cty.String), | |
| }) | |
| backendConfigRaw, err := plans.NewDynamicValue(backendConfig, backendConfig.Type()) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| return &plans.Plan{ | |
| Backend: plans.Backend{ | |
| // This is just a placeholder so that the plan file can be written | |
| // out. Caller may wish to override it to something more "real" | |
| // where the plan will actually be subsequently applied. | |
| Type: "local", | |
| Config: backendConfigRaw, | |
| }, | |
| Changes: plans.NewChanges(), | |
| } | |
| } | |
| func testPlanFile(t *testing.T, configSnap *configload.Snapshot, state *states.State, plan *plans.Plan) string { | |
| return testPlanFileMatchState(t, configSnap, state, plan, statemgr.SnapshotMeta{}) | |
| } | |
| func testPlanFileMatchState(t *testing.T, configSnap *configload.Snapshot, state *states.State, plan *plans.Plan, stateMeta statemgr.SnapshotMeta) string { | |
| t.Helper() | |
| stateFile := &statefile.File{ | |
| Lineage: stateMeta.Lineage, | |
| Serial: stateMeta.Serial, | |
| State: state, | |
| TerraformVersion: version.SemVer, | |
| } | |
| prevStateFile := &statefile.File{ | |
| Lineage: stateMeta.Lineage, | |
| Serial: stateMeta.Serial, | |
| State: state, // we just assume no changes detected during refresh | |
| TerraformVersion: version.SemVer, | |
| } | |
| path := testTempFile(t) | |
| err := planfile.Create(path, planfile.CreateArgs{ | |
| ConfigSnapshot: configSnap, | |
| PreviousRunStateFile: prevStateFile, | |
| StateFile: stateFile, | |
| Plan: plan, | |
| DependencyLocks: depsfile.NewLocks(), | |
| }, encryption.PlanEncryptionDisabled()) | |
| if err != nil { | |
| t.Fatalf("failed to create temporary plan file: %s", err) | |
| } | |
| return path | |
| } | |
| // testPlanFileNoop is a shortcut function that creates a plan file that | |
| // represents no changes and returns its path. This is useful when a test | |
| // just needs any plan file, and it doesn't matter what is inside it. | |
| func testPlanFileNoop(t *testing.T) string { | |
| snap := &configload.Snapshot{ | |
| Modules: map[string]*configload.SnapshotModule{ | |
| "": { | |
| Dir: ".", | |
| Files: map[string][]byte{ | |
| "main.tf": nil, | |
| }, | |
| }, | |
| }, | |
| } | |
| state := states.NewState() | |
| plan := testPlan(t) | |
| return testPlanFile(t, snap, state, plan) | |
| } | |
| func testFileEquals(t *testing.T, got, want string) { | |
| t.Helper() | |
| actual, err := os.ReadFile(got) | |
| if err != nil { | |
| t.Fatalf("error reading %s", got) | |
| } | |
| expected, err := os.ReadFile(want) | |
| if err != nil { | |
| t.Fatalf("error reading %s", want) | |
| } | |
| if diff := cmp.Diff(string(actual), string(expected)); len(diff) > 0 { | |
| t.Fatalf("got:\n%s\nwant:\n%s\ndiff:\n%s", actual, expected, diff) | |
| } | |
| } | |
| func testReadPlan(t *testing.T, path string) *plans.Plan { | |
| t.Helper() | |
| f, err := planfile.Open(path, encryption.PlanEncryptionDisabled()) | |
| if err != nil { | |
| t.Fatalf("error opening plan file %q: %s", path, err) | |
| } | |
| p, err := f.ReadPlan() | |
| if err != nil { | |
| t.Fatalf("error reading plan from plan file %q: %s", path, err) | |
| } | |
| return p | |
| } | |
| // testState returns a test State structure that we use for a lot of tests. | |
| func testState() *states.State { | |
| return states.BuildState(func(s *states.SyncState) { | |
| s.SetResourceInstanceCurrent( | |
| addrs.Resource{ | |
| Mode: addrs.ManagedResourceMode, | |
| Type: "test_instance", | |
| Name: "foo", | |
| }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), | |
| &states.ResourceInstanceObjectSrc{ | |
| // The weird whitespace here is reflective of how this would | |
| // get written out in a real state file, due to the indentation | |
| // of all of the containing wrapping objects and arrays. | |
| AttrsJSON: []byte("{\n \"id\": \"bar\"\n }"), | |
| Status: states.ObjectReady, | |
| Dependencies: []addrs.ConfigResource{}, | |
| }, | |
| addrs.AbsProviderConfig{ | |
| Provider: addrs.NewDefaultProvider("test"), | |
| Module: addrs.RootModule, | |
| }, | |
| addrs.NoKey, | |
| ) | |
| // DeepCopy is used here to ensure our synthetic state matches exactly | |
| // with a state that will have been copied during the command | |
| // operation, and all fields have been copied correctly. | |
| }).DeepCopy() | |
| } | |
| // writeStateForTesting is a helper that writes the given naked state to the | |
| // given writer, generating a stub *statefile.File wrapper which is then | |
| // immediately discarded. | |
| func writeStateForTesting(state *states.State, w io.Writer) error { | |
| sf := &statefile.File{ | |
| Serial: 0, | |
| Lineage: "fake-for-testing", | |
| State: state, | |
| } | |
| return statefile.WriteIndent(sf, w, encryption.StateEncryptionDisabled()) | |
| } | |
| // testStateMgrCurrentLineage returns the current lineage for the given state | |
| // manager, or the empty string if it does not use lineage. This is primarily | |
| // for testing against the local backend, which always supports lineage. | |
| func testStateMgrCurrentLineage(mgr statemgr.Persistent) string { | |
| if pm, ok := mgr.(statemgr.PersistentMeta); ok { | |
| m := pm.StateSnapshotMeta() | |
| return m.Lineage | |
| } | |
| return "" | |
| } | |
| // markStateForMatching is a helper that writes a specific marker value to | |
| // a state so that it can be recognized later with getStateMatchingMarker. | |
| // | |
| // Internally this just sets a root module output value called "testing_mark" | |
| // to the given string value. If the state is being checked in other ways, | |
| // the test code may need to compensate for the addition or overwriting of this | |
| // special output value name. | |
| // | |
| // The given mark string is returned verbatim, to allow the following pattern | |
| // in tests: | |
| // | |
| // mark := markStateForMatching(state, "foo") | |
| // // (do stuff to the state) | |
| // assertStateHasMarker(state, mark) | |
| func markStateForMatching(state *states.State, mark string) string { | |
| state.RootModule().SetOutputValue("testing_mark", cty.StringVal(mark), false, "") | |
| return mark | |
| } | |
| // getStateMatchingMarker is used with markStateForMatching to retrieve the | |
| // mark string previously added to the given state. If no such mark is present, | |
| // the result is an empty string. | |
| func getStateMatchingMarker(state *states.State) string { | |
| os := state.RootModule().OutputValues["testing_mark"] | |
| if os == nil { | |
| return "" | |
| } | |
| v := os.Value | |
| if v.Type() == cty.String && v.IsKnown() && !v.IsNull() { | |
| return v.AsString() | |
| } | |
| return "" | |
| } | |
| // stateHasMarker is a helper around getStateMatchingMarker that also includes | |
| // the equality test, for more convenient use in test assertion branches. | |
| func stateHasMarker(state *states.State, want string) bool { | |
| return getStateMatchingMarker(state) == want | |
| } | |
| // assertStateHasMarker wraps stateHasMarker to automatically generate a | |
| // fatal test result (i.e. t.Fatal) if the marker doesn't match. | |
| func assertStateHasMarker(t *testing.T, state *states.State, want string) { | |
| if !stateHasMarker(state, want) { | |
| t.Fatalf("wrong state marker\ngot: %q\nwant: %q", getStateMatchingMarker(state), want) | |
| } | |
| } | |
| func testStateFile(t *testing.T, s *states.State) string { | |
| t.Helper() | |
| path := testTempFile(t) | |
| f, err := os.Create(path) | |
| if err != nil { | |
| t.Fatalf("failed to create temporary state file %s: %s", path, err) | |
| } | |
| defer f.Close() | |
| err = writeStateForTesting(s, f) | |
| if err != nil { | |
| t.Fatalf("failed to write state to temporary file %s: %s", path, err) | |
| } | |
| return path | |
| } | |
| // testStateFileDefault writes the state out to the default statefile | |
| // in the cwd. Use `testCwd` to change into a temp cwd. | |
| func testStateFileDefault(t *testing.T, s *states.State) { | |
| t.Helper() | |
| f, err := os.Create(arguments.DefaultStateFilename) | |
| if err != nil { | |
| t.Fatalf("err: %s", err) | |
| } | |
| defer f.Close() | |
| if err := writeStateForTesting(s, f); err != nil { | |
| t.Fatalf("err: %s", err) | |
| } | |
| } | |
| // testStateFileWorkspaceDefault writes the state out to the default statefile | |
| // for the given workspace in the cwd. Use `testCwd` to change into a temp cwd. | |
| func testStateFileWorkspaceDefault(t *testing.T, workspace string, s *states.State) string { | |
| t.Helper() | |
| workspaceDir := filepath.Join(backendLocal.DefaultWorkspaceDir, workspace) | |
| err := os.MkdirAll(workspaceDir, os.ModePerm) | |
| if err != nil { | |
| t.Fatalf("err: %s", err) | |
| } | |
| path := filepath.Join(workspaceDir, arguments.DefaultStateFilename) | |
| f, err := os.Create(path) | |
| if err != nil { | |
| t.Fatalf("err: %s", err) | |
| } | |
| defer f.Close() | |
| if err := writeStateForTesting(s, f); err != nil { | |
| t.Fatalf("err: %s", err) | |
| } | |
| return path | |
| } | |
| // testStateFileRemote writes the state out to the remote statefile | |
| // in the cwd. Use `testCwd` to change into a temp cwd. | |
| func testStateFileRemote(t *testing.T, s *clistate.CLIState) string { | |
| t.Helper() | |
| path := filepath.Join(workdir.DefaultDataDir, arguments.DefaultStateFilename) | |
| if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { | |
| t.Fatalf("err: %s", err) | |
| } | |
| f, err := os.Create(path) | |
| if err != nil { | |
| t.Fatalf("err: %s", err) | |
| } | |
| defer f.Close() | |
| if err := clistate.WriteState(s, f); err != nil { | |
| t.Fatalf("err: %s", err) | |
| } | |
| return path | |
| } | |
| // testStateRead reads the state from a file | |
| func testStateRead(t *testing.T, path string) *states.State { | |
| t.Helper() | |
| f, err := os.Open(path) | |
| if err != nil { | |
| t.Fatalf("err: %s", err) | |
| } | |
| defer f.Close() | |
| sf, err := statefile.Read(f, encryption.StateEncryptionDisabled()) | |
| if err != nil { | |
| t.Fatalf("err: %s", err) | |
| } | |
| return sf.State | |
| } | |
| // testDataStateRead reads a "data state", which is a file format resembling | |
| // our state format v3 that is used only to track current backend settings. | |
| // | |
| // This uses *clistate.CLIState which is the specialized type for | |
| // tracking backend configuration. | |
| func testDataStateRead(t *testing.T, path string) *clistate.CLIState { | |
| t.Helper() | |
| f, err := os.Open(path) | |
| if err != nil { | |
| t.Fatalf("err: %s", err) | |
| } | |
| defer f.Close() | |
| s, err := clistate.ReadState(f, false) | |
| if err != nil { | |
| t.Fatalf("err: %s", err) | |
| } | |
| return s | |
| } | |
| // testStateOutput tests that the state at the given path contains | |
| // the expected state string. | |
| func testStateOutput(t *testing.T, path string, expected string) { | |
| t.Helper() | |
| newState := testStateRead(t, path) | |
| actual := strings.TrimSpace(newState.String()) | |
| expected = strings.TrimSpace(expected) | |
| if actual != expected { | |
| t.Fatalf("expected:\n%s\nactual:\n%s", expected, actual) | |
| } | |
| } | |
| func testProvider() *tofu.MockProvider { | |
| p := new(tofu.MockProvider) | |
| p.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) (resp providers.PlanResourceChangeResponse) { | |
| resp.PlannedState = req.ProposedNewState | |
| return resp | |
| } | |
| p.ReadResourceFn = func(req providers.ReadResourceRequest) providers.ReadResourceResponse { | |
| return providers.ReadResourceResponse{ | |
| NewState: req.PriorState, | |
| } | |
| } | |
| return p | |
| } | |
| func testTempFile(t *testing.T) string { | |
| t.Helper() | |
| return filepath.Join(testTempDirRealpath(t), "state.tfstate") | |
| } | |
| // testTempDirRealpath is like [testing.T.TempDir] but takes the | |
| // extra step of ensuring that the result is a path that does not | |
| // include any symlinks. | |
| func testTempDirRealpath(t *testing.T) string { | |
| t.Helper() | |
| d, err := filepath.EvalSymlinks(t.TempDir()) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| return d | |
| } | |
| // testCwdTemp is used to change the current working directory into a temporary | |
| // directory. The cleanup is performed automatically after the test and all its | |
| // subtests complete. | |
| func testCwdTemp(t testing.TB) string { | |
| t.Helper() | |
| tmp := t.TempDir() | |
| t.Chdir(tmp) | |
| return tmp | |
| } | |
| // testStdinPipe changes os.Stdin to be a pipe that sends the data from | |
| // the reader before closing the pipe. | |
| // | |
| // The returned function should be deferred to properly clean up and restore | |
| // the original stdin. | |
| func testStdinPipe(t *testing.T, src io.Reader) func() { | |
| t.Helper() | |
| r, w, err := os.Pipe() | |
| if err != nil { | |
| t.Fatalf("err: %s", err) | |
| } | |
| // Modify stdin to point to our new pipe | |
| old := os.Stdin | |
| os.Stdin = r | |
| // Copy the data from the reader to the pipe | |
| go func() { | |
| defer w.Close() | |
| if _, err := io.Copy(w, src); err != nil { | |
| panic(err) | |
| } | |
| }() | |
| return func() { | |
| // Close our read end | |
| r.Close() | |
| // Reset stdin | |
| os.Stdin = old | |
| } | |
| } | |
| // testInteractiveInput configures tests so that the answers given are sent | |
| // in order to interactive prompts. The returned function must be called | |
| // in a defer to clean up. | |
| func testInteractiveInput(t *testing.T, answers []string) func() { | |
| t.Helper() | |
| // Disable test mode so input is called | |
| test = false | |
| // Set up reader/writers | |
| testInputResponse = answers | |
| defaultInputReader = bytes.NewBufferString("") | |
| defaultInputWriter = new(bytes.Buffer) | |
| // Return the cleanup | |
| return func() { | |
| test = true | |
| testInputResponse = nil | |
| } | |
| } | |
| // testInputMap configures tests so that the given answers are returned | |
| // for calls to Input when the right question is asked. The key is the | |
| // question "Id" that is used. | |
| func testInputMap(t *testing.T, answers map[string]string) func() { | |
| t.Helper() | |
| // Disable test mode so input is called | |
| test = false | |
| // Set up reader/writers | |
| defaultInputReader = bytes.NewBufferString("") | |
| defaultInputWriter = new(bytes.Buffer) | |
| // Setup answers | |
| testInputResponse = nil | |
| testInputResponseMap = answers | |
| // Return the cleanup | |
| return func() { | |
| var unusedAnswers = testInputResponseMap | |
| // First, clean up! | |
| test = true | |
| testInputResponseMap = nil | |
| if len(unusedAnswers) > 0 { | |
| t.Fatalf("expected no unused answers provided to command.testInputMap, got: %v", unusedAnswers) | |
| } | |
| } | |
| } | |
| // testBackendState is used to make a test HTTP server to test a configured | |
| // backend. This returns the complete state that can be saved. Use | |
| // `testStateFileRemote` to write the returned state. | |
| // | |
| // When using this function, the configuration fixture for the test must | |
| // include an empty configuration block for the HTTP backend, like this: | |
| // | |
| // terraform { | |
| // backend "http" { | |
| // } | |
| // } | |
| // | |
| // If such a block isn't present, or if it isn't empty, then an error will | |
| // be returned about the backend configuration having changed and that | |
| // "tofu init" must be run, since the test backend config cache created | |
| // by this function contains the hash for an empty configuration. | |
| func testBackendState(t *testing.T, s *states.State, c int) (*clistate.CLIState, *httptest.Server) { | |
| t.Helper() | |
| var b64md5 string | |
| buf := bytes.NewBuffer(nil) | |
| cb := func(resp http.ResponseWriter, req *http.Request) { | |
| if req.Method == "PUT" { | |
| resp.WriteHeader(c) | |
| return | |
| } | |
| if s == nil { | |
| resp.WriteHeader(404) | |
| return | |
| } | |
| resp.Header().Set("Content-MD5", b64md5) | |
| if _, err := resp.Write(buf.Bytes()); err != nil { | |
| t.Fatal(err) | |
| } | |
| } | |
| // If a state was given, make sure we calculate the proper b64md5 | |
| if s != nil { | |
| err := statefile.Write(&statefile.File{State: s}, buf, encryption.StateEncryptionDisabled()) | |
| if err != nil { | |
| t.Fatalf("err: %v", err) | |
| } | |
| md5 := md5.Sum(buf.Bytes()) | |
| b64md5 = base64.StdEncoding.EncodeToString(md5[:16]) | |
| } | |
| srv := httptest.NewServer(http.HandlerFunc(cb)) | |
| backendConfig := &configs.Backend{ | |
| Type: "http", | |
| Config: configs.SynthBody("<testBackendState>", map[string]cty.Value{}), | |
| Eval: configs.NewStaticEvaluator(nil, nil, configs.RootModuleCallForTesting()), | |
| } | |
| httpBackendInit, _ := backendInit.Backend("http") | |
| b := httpBackendInit(encryption.StateEncryptionDisabled()) | |
| configSchema := b.ConfigSchema() | |
| hash, _ := backendConfig.Hash(t.Context(), configSchema) | |
| state := clistate.NewState() | |
| state.Backend = &clistate.BackendState{ | |
| Type: "http", | |
| ConfigRaw: json.RawMessage(fmt.Sprintf(`{"address":%q}`, srv.URL)), | |
| Hash: uint64(hash), | |
| } | |
| return state, srv | |
| } | |
| // testRemoteState is used to make a test HTTP server to return a given | |
| // state file that can be used for testing remote backend state. | |
| // | |
| // The return values are a *clistate.CLIState instance that should be written | |
| // as the "data state" (really: backend state) and the server that the | |
| // returned data state refers to. | |
| func testRemoteState(t *testing.T, s *states.State, c int) (*clistate.CLIState, *httptest.Server) { | |
| t.Helper() | |
| var b64md5 string | |
| buf := bytes.NewBuffer(nil) | |
| cb := func(resp http.ResponseWriter, req *http.Request) { | |
| if req.Method == "PUT" { | |
| resp.WriteHeader(c) | |
| return | |
| } | |
| if s == nil { | |
| resp.WriteHeader(404) | |
| return | |
| } | |
| resp.Header().Set("Content-MD5", b64md5) | |
| if _, err := resp.Write(buf.Bytes()); err != nil { | |
| t.Fatal(err) | |
| } | |
| } | |
| retState := clistate.NewState() | |
| srv := httptest.NewServer(http.HandlerFunc(cb)) | |
| b := &clistate.BackendState{ | |
| Type: "http", | |
| } | |
| if err := b.SetConfig(cty.ObjectVal(map[string]cty.Value{ | |
| "address": cty.StringVal(srv.URL), | |
| }), &configschema.Block{ | |
| Attributes: map[string]*configschema.Attribute{ | |
| "address": { | |
| Type: cty.String, | |
| Required: true, | |
| }, | |
| }, | |
| }); err != nil { | |
| t.Fatal(err) | |
| } | |
| retState.Backend = b | |
| if s != nil { | |
| err := statefile.Write(&statefile.File{State: s}, buf, encryption.StateEncryptionDisabled()) | |
| if err != nil { | |
| t.Fatalf("failed to write initial state: %v", err) | |
| } | |
| } | |
| return retState, srv | |
| } | |
| // testLockState calls a separate process to the lock the state file at path. | |
| // deferFunc should be called in the caller to properly unlock the file. | |
| // Since many tests change the working directory, the sourceDir argument must be | |
| // supplied to locate the statelocker.go source. | |
| func testLockState(t *testing.T, sourceDir, path string) (func(), error) { | |
| // build and run the binary ourselves so we can quickly terminate it for cleanup | |
| buildDir := t.TempDir() | |
| source := filepath.Join(sourceDir, "statelocker.go") | |
| lockBin := filepath.Join(buildDir, "statelocker") | |
| if runtime.GOOS == "windows" { | |
| lockBin = lockBin + ".exe" | |
| } | |
| cmd := exec.Command("go", "build", "-o", lockBin, source) | |
| cmd.Dir = filepath.Dir(sourceDir) | |
| out, err := cmd.CombinedOutput() | |
| if err != nil { | |
| return nil, fmt.Errorf("%w %s", err, out) | |
| } | |
| locker := exec.Command(lockBin, path) | |
| stdin, err := locker.StdinPipe() | |
| if err != nil { | |
| return nil, err | |
| } | |
| stdout, err := locker.StdoutPipe() | |
| if err != nil { | |
| return nil, err | |
| } | |
| if err := locker.Start(); err != nil { | |
| return nil, err | |
| } | |
| reader := bufio.NewReader(stdout) | |
| // callback function to unlock the state file | |
| cbFunc := func() { | |
| stdin.Close() | |
| stdout.Close() | |
| _ = locker.Wait() | |
| t.Logf("closed statelocker stdin and finished.") | |
| // Trigger garbage collection to ensure that all open file handles are closed. | |
| // This prevents TempDir RemoveAll cleanup errors on Windows. | |
| if runtime.GOOS == "windows" { | |
| runtime.GC() | |
| } | |
| } | |
| // wait for the process to lock | |
| buf, err := reader.ReadString('\n') | |
| if err != nil { | |
| return cbFunc, fmt.Errorf("read from statelocker returned: %w", err) | |
| } | |
| output := string(buf) | |
| if !strings.HasPrefix(output, "LOCKID") { | |
| return cbFunc, fmt.Errorf("statelocker wrote: %s", output) | |
| } | |
| t.Logf("statelocker locked %s", output) | |
| return cbFunc, nil | |
| } | |
| // testCopyDir recursively copies a directory tree, attempting to preserve | |
| // permissions. Source directory must exist, destination directory may exist | |
| // but will be created if not; it should typically be a temporary directory, | |
| // and thus already created using os.MkdirTemp or similar. | |
| // Symlinks are ignored and skipped. | |
| func testCopyDir(t *testing.T, src, dst string) { | |
| t.Helper() | |
| src = filepath.Clean(src) | |
| dst = filepath.Clean(dst) | |
| si, err := os.Stat(src) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if !si.IsDir() { | |
| t.Fatal("source is not a directory") | |
| } | |
| _, err = os.Stat(dst) | |
| if err != nil && !os.IsNotExist(err) { | |
| t.Fatal(err) | |
| } | |
| err = os.MkdirAll(dst, si.Mode()) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| entries, err := os.ReadDir(src) | |
| if err != nil { | |
| return | |
| } | |
| for _, entry := range entries { | |
| srcPath := filepath.Join(src, entry.Name()) | |
| dstPath := filepath.Join(dst, entry.Name()) | |
| // If the entry is a symlink, we copy the contents | |
| for entry.Type()&os.ModeSymlink != 0 { | |
| target, err := os.Readlink(srcPath) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| fi, err := os.Stat(target) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| entry = fs.FileInfoToDirEntry(fi) | |
| } | |
| if entry.IsDir() { | |
| testCopyDir(t, srcPath, dstPath) | |
| } else { | |
| err = copy.CopyFile(srcPath, dstPath) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| } | |
| } | |
| t.Cleanup(func() { | |
| // Trigger garbage collection to ensure that all open file handles are closed. | |
| // This prevents TempDir RemoveAll cleanup errors on Windows. | |
| if runtime.GOOS == "windows" { | |
| runtime.GC() | |
| } | |
| }) | |
| } | |
| // normalizeJSON removes all insignificant whitespace from the given JSON buffer | |
| // and returns it as a string for easier comparison. | |
| func normalizeJSON(t *testing.T, src []byte) string { | |
| t.Helper() | |
| var buf bytes.Buffer | |
| err := json.Compact(&buf, src) | |
| if err != nil { | |
| t.Fatalf("error normalizing JSON: %s", err) | |
| } | |
| return buf.String() | |
| } | |
| // This map from provider type name to namespace is used by the fake registry | |
| // when called via LookupLegacyProvider. Providers not in this map will return | |
| // a 404 Not Found error. | |
| var legacyProviderNamespaces = map[string]string{ | |
| "foo": "hashicorp", | |
| "bar": "hashicorp", | |
| "baz": "terraform-providers", | |
| "qux": "hashicorp", | |
| } | |
| // This map is used to mock the provider redirect feature. | |
| var movedProviderNamespaces = map[string]string{ | |
| "qux": "acme", | |
| } | |
| // testServices starts up a local HTTP server running a fake provider registry | |
| // service which responds only to discovery requests and legacy provider lookup | |
| // API calls. | |
| // | |
| // The final return value is a function to call at the end of a test function | |
| // to shut down the test server. After you call that function, the discovery | |
| // object becomes useless. | |
| func testServices(t *testing.T) (services *disco.Disco, cleanup func()) { | |
| server := httptest.NewServer(http.HandlerFunc(fakeRegistryHandler)) | |
| services = disco.New() | |
| services.ForceHostServices(svchost.Hostname("registry.opentofu.org"), map[string]any{ | |
| "providers.v1": server.URL + "/providers/v1/", | |
| }) | |
| return services, func() { | |
| server.Close() | |
| } | |
| } | |
| // testRegistrySource is a wrapper around testServices that uses the created | |
| // discovery object to produce a Source instance that is ready to use with the | |
| // fake registry services. | |
| // | |
| // As with testServices, the final return value is a function to call at the end | |
| // of your test in order to shut down the test server. | |
| func testRegistrySource(t *testing.T) (source *getproviders.RegistrySource, cleanup func()) { | |
| services, close := testServices(t) | |
| source = getproviders.NewRegistrySource(t.Context(), services, nil, getproviders.LocationConfig{ProviderDownloadRetries: 0}) | |
| return source, close | |
| } | |
| func fakeRegistryHandler(resp http.ResponseWriter, req *http.Request) { | |
| path := req.URL.EscapedPath() | |
| write := func(data string) { | |
| if _, err := resp.Write([]byte(data)); err != nil { | |
| panic(err) | |
| } | |
| } | |
| if !strings.HasPrefix(path, "/providers/v1/") { | |
| resp.WriteHeader(404) | |
| write(`not a provider registry endpoint`) | |
| return | |
| } | |
| pathParts := strings.Split(path, "/")[3:] | |
| if len(pathParts) != 3 { | |
| resp.WriteHeader(404) | |
| write(`unrecognized path scheme`) | |
| return | |
| } | |
| if pathParts[2] != "versions" { | |
| resp.WriteHeader(404) | |
| write(`this registry only supports legacy namespace lookup requests`) | |
| return | |
| } | |
| name := pathParts[1] | |
| // Legacy lookup | |
| if pathParts[0] == "-" { | |
| if namespace, ok := legacyProviderNamespaces[name]; ok { | |
| resp.Header().Set("Content-Type", "application/json") | |
| resp.WriteHeader(200) | |
| if movedNamespace, ok := movedProviderNamespaces[name]; ok { | |
| fmt.Fprintf(resp, `{"id":"%s/%s","moved_to":"%s/%s","versions":[{"version":"1.0.0","protocols":["4"]}]}`, namespace, name, movedNamespace, name) | |
| } else { | |
| fmt.Fprintf(resp, `{"id":"%s/%s","versions":[{"version":"1.0.0","protocols":["4"]}]}`, namespace, name) | |
| } | |
| } else { | |
| resp.WriteHeader(404) | |
| write(`provider not found`) | |
| } | |
| return | |
| } | |
| // Also return versions for redirect target | |
| if namespace, ok := movedProviderNamespaces[name]; ok && pathParts[0] == namespace { | |
| resp.Header().Set("Content-Type", "application/json") | |
| resp.WriteHeader(200) | |
| fmt.Fprintf(resp, `{"id":"%s/%s","versions":[{"version":"1.0.0","protocols":["4"]}]}`, namespace, name) | |
| } else { | |
| resp.WriteHeader(404) | |
| write(`provider not found`) | |
| } | |
| } | |
| func testView(t *testing.T) (*views.View, func(*testing.T) *terminal.TestOutput) { | |
| streams, done := terminal.StreamsForTesting(t) | |
| return views.NewView(streams), done | |
| } | |
| // checkGoldenReference compares the given test output with a known "golden" output log | |
| // located under the specified fixture path. | |
| // | |
| // If any of these tests fail, please communicate with Terraform Cloud folks before resolving, | |
| // as changes to UI output may also affect the behavior of Terraform Cloud's structured run output. | |
| func checkGoldenReference(t *testing.T, output *terminal.TestOutput, fixturePathName string) { | |
| t.Helper() | |
| // Load the golden reference fixture | |
| wantFile, err := os.Open(path.Join(testFixturePath(fixturePathName), "output.jsonlog")) | |
| if err != nil { | |
| t.Fatalf("failed to open output file: %s", err) | |
| } | |
| defer wantFile.Close() | |
| wantBytes, err := io.ReadAll(wantFile) | |
| if err != nil { | |
| t.Fatalf("failed to read output file: %s", err) | |
| } | |
| want := string(wantBytes) | |
| got := output.Stdout() | |
| // Split the output and the reference into lines so that we can compare | |
| // messages | |
| got = strings.TrimSuffix(got, "\n") | |
| gotLines := strings.Split(got, "\n") | |
| want = strings.TrimSuffix(want, "\n") | |
| wantLines := strings.Split(want, "\n") | |
| if len(gotLines) != len(wantLines) { | |
| t.Errorf("unexpected number of log lines: got %d, want %d\n"+ | |
| "NOTE: This failure may indicate a UI change affecting the behavior of structured run output on TFC.\n"+ | |
| "Please communicate with Terraform Cloud team before resolving", len(gotLines), len(wantLines)) | |
| } | |
| // Verify that the log starts with a version message | |
| type versionMessage struct { | |
| Level string `json:"@level"` | |
| Message string `json:"@message"` | |
| Type string `json:"type"` | |
| OpenTofu string `json:"tofu"` | |
| UI string `json:"ui"` | |
| } | |
| var gotVersion versionMessage | |
| if err := json.Unmarshal([]byte(gotLines[0]), &gotVersion); err != nil { | |
| t.Errorf("failed to unmarshal version line: %s\n%s", err, gotLines[0]) | |
| } | |
| wantVersion := versionMessage{ | |
| "info", | |
| fmt.Sprintf("OpenTofu %s", version.String()), | |
| "version", | |
| version.String(), | |
| views.JSON_UI_VERSION, | |
| } | |
| if !cmp.Equal(wantVersion, gotVersion) { | |
| t.Errorf("unexpected first message:\n%s", cmp.Diff(wantVersion, gotVersion)) | |
| } | |
| // Compare the rest of the lines against the golden reference | |
| var gotLineMaps []map[string]any | |
| for i, line := range gotLines[1:] { | |
| index := i + 1 | |
| var gotMap map[string]any | |
| if err := json.Unmarshal([]byte(line), &gotMap); err != nil { | |
| t.Errorf("failed to unmarshal got line %d: %s\n%s", index, err, gotLines[index]) | |
| } | |
| if _, ok := gotMap["@timestamp"]; !ok { | |
| t.Errorf("missing @timestamp field in log: %s", gotLines[index]) | |
| } | |
| gotMap = deleteMapField(gotMap, "hook", "elapsed_seconds") | |
| delete(gotMap, "@timestamp") | |
| gotLineMaps = append(gotLineMaps, gotMap) | |
| } | |
| var wantLineMaps []map[string]any | |
| for i, line := range wantLines[1:] { | |
| index := i + 1 | |
| var wantMap map[string]any | |
| if err := json.Unmarshal([]byte(line), &wantMap); err != nil { | |
| t.Errorf("failed to unmarshal want line %d: %s\n%s", index, err, gotLines[index]) | |
| } | |
| wantMap = deleteMapField(wantMap, "hook", "elapsed_seconds") | |
| wantLineMaps = append(wantLineMaps, wantMap) | |
| } | |
| if diff := cmp.Diff(wantLineMaps, gotLineMaps); diff != "" { | |
| t.Errorf("wrong output lines\n%s\n"+ | |
| "NOTE: This failure may indicate a UI change affecting the behavior of structured run output on TFC.\n"+ | |
| "Please communicate with Terraform Cloud team before resolving", diff) | |
| } | |
| } | |
| func deleteMapField(fieldMap map[string]any, rootField, field string) map[string]any { | |
| rootMap, ok := fieldMap[rootField].(map[string]any) | |
| if !ok { | |
| return fieldMap | |
| } | |
| delete(rootMap, field) | |
| return rootMap | |
| } | |
| // testHangServer starts a local HTTP server that accepts incoming requests | |
| // but then intentionally leaves the connection hanging without responding, | |
| // writing the request to the returned channel so that the caller can then | |
| // trigger some mechanism for cancelling that hung request. | |
| // | |
| // This is intended for testing anything that needs to be able to cancel | |
| // slow requests to remote HTTP servers, so that the test can be sure that | |
| // the request definitely will be "slow enough" that cancellation is | |
| // definitely the only way the request could've halted. | |
| // | |
| // The returned server is automatically closed when the calling test | |
| // is complete, but the caller is also allowed to optionally call Close | |
| // directly itself. Note that the Close method alone will not close | |
| // any active requests, but testHangServer guarantees that it will | |
| // eventually terminate active requests once the calling test is | |
| // complete. | |
| func testHangServer(t testing.TB) (server *httptest.Server, reqs <-chan *http.Request) { | |
| t.Helper() | |
| // We'll use this channel to signal any active requests to terminate | |
| // during test cleanup, so that the active requests can't remain | |
| // running indefinitely. | |
| cleanupCh := make(chan struct{}) | |
| // This channel is how we'll notify the caller when we get a request. | |
| // This has a buffer so that in the assumed-typical case where the | |
| // test server will only start serving a few requests before they | |
| // get cancelled the server's handler can be decoupled from the | |
| // channel reads in the caller. | |
| reqsCh := make(chan *http.Request, 8) | |
| server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { | |
| // We intentionally don't take any action on this request until | |
| // the test cleanup function runs, but we will notify our | |
| // caller that the request was started. | |
| // | |
| // We'll also accept getting told to clean up before the | |
| // channel write succeeds just in case the calling test exits | |
| // before it reads from reqsCh. | |
| select { | |
| case reqsCh <- req: | |
| case <-cleanupCh: | |
| } | |
| // If we managed to send req to reqsCh above then we still | |
| // need to wait for cleanupCh to close. The following is | |
| // no-op if the channel is already closed. | |
| <-cleanupCh | |
| // If any client is still connected by the time we get here then | |
| // we'll respond quickly just to get their connection closed. | |
| // This is unlikely but could potentially happen if a new client | |
| // connects in the narrow time window between us closing the | |
| // existing client connections and fully closing the server, | |
| // after cleanupCh is already closed: in that case the new client | |
| // will get a 500 Internal Server Error response immediately. | |
| w.WriteHeader(500) | |
| })) | |
| t.Logf("testHangServer is running at %s", server.URL) | |
| t.Cleanup(func() { | |
| t.Helper() | |
| t.Log("shutting down testHangServer") | |
| close(cleanupCh) // terminate any active handlers | |
| close(reqsCh) // unblock any test that's awaiting a request notification | |
| server.CloseClientConnections() // force any active clients to disconnect | |
| server.Close() // stop accepting new requests and wait for existing ones to stop | |
| }) | |
| return server, reqsCh | |
| } | |
| // TestVarsParsing checks that the -var/-var-file are parsed correctly and processed as expected. | |
| // This was wrote while doing the Meta removal refactor, before removing all the temporary | |
| // GatherVariables methods to ensure that the logic added to replace the removed method does | |
| // not alter the way variable related arguments are parsed. | |
| // Tested against commands with checkable outputs to validate that the right variable values reached the | |
| // execution context. | |
| func TestVarsParsing(t *testing.T) { | |
| p := testProvider() | |
| varArgs := []string{"-var", "snack=chips", "-var-file", "all.tfvars"} | |
| t.Run("console", func(t *testing.T) { | |
| td := t.TempDir() | |
| testCopyDir(t, testFixturePath("variables"), td) | |
| t.Chdir(td) | |
| t.Cleanup(testStdinPipe(t, strings.NewReader("var.foo\nvar.snack\n"))) | |
| streams, done := terminal.StreamsForTesting(t) | |
| c := &ConsoleCommand{ | |
| WorkingDir: workdir.NewDir("."), | |
| testingOverrides: metaOverridesForProvider(p), | |
| View: views.NewView(streams), | |
| } | |
| args := append([]string{"-no-color", "-lock=false"}, varArgs...) | |
| code := c.Run(args) | |
| output := done(t) | |
| if code != 0 { | |
| t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) | |
| } | |
| actual := output.Stdout() | |
| expected := `"from tfvars" | |
| "chips" | |
| ` | |
| if diff := cmp.Diff(expected, actual); diff != "" { | |
| t.Errorf("variables parsed incorrectly (-want,+got):\n%s", diff) | |
| } | |
| }) | |
| cases := map[string]struct { | |
| cmdBuilder func(m Meta) cli.Command | |
| expectedContent []string | |
| confirmation bool | |
| }{ | |
| "plan": { | |
| cmdBuilder: func(m Meta) cli.Command { | |
| return &PlanCommand{m} | |
| }, | |
| }, | |
| "apply": { | |
| cmdBuilder: func(m Meta) cli.Command { | |
| return &ApplyCommand{Meta: m} | |
| }, | |
| confirmation: true, | |
| }, | |
| "output": { | |
| cmdBuilder: func(m Meta) cli.Command { | |
| return &OutputCommand{m} | |
| }, | |
| }, | |
| "show": { | |
| cmdBuilder: func(m Meta) cli.Command { | |
| return &ShowCommand{Meta: m} | |
| }, | |
| }, | |
| "refresh": { | |
| cmdBuilder: func(m Meta) cli.Command { | |
| return &RefreshCommand{m} | |
| }, | |
| }, | |
| } | |
| for name, tc := range cases { | |
| t.Run(name, func(t *testing.T) { | |
| td := t.TempDir() | |
| testCopyDir(t, testFixturePath("variables"), td) | |
| t.Chdir(td) | |
| view, done := testView(t) | |
| m := Meta{ | |
| WorkingDir: workdir.NewDir("."), | |
| testingOverrides: metaOverridesForProvider(p), | |
| View: view, | |
| } | |
| c := tc.cmdBuilder(m) | |
| args := append([]string{"-no-color"}, varArgs...) | |
| if tc.confirmation { | |
| t.Cleanup(testInputMap(t, map[string]string{"approve": "yes"})) | |
| } | |
| code := c.Run(args) | |
| output := done(t) | |
| if code != 0 { | |
| t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) | |
| } | |
| actual := output.Stdout() | |
| for _, want := range tc.expectedContent { | |
| if !strings.Contains(actual, want) { | |
| t.Errorf("variables parsed incorrectly. Want %q to exist in the output, but it didn't\noutput:\n%s", want, actual) | |
| } | |
| } | |
| }) | |
| } | |
| } |