| package command |
| |
| import ( |
| "bytes" |
| "context" |
| "crypto/md5" |
| "encoding/base64" |
| "encoding/json" |
| "fmt" |
| "io" |
| "io/ioutil" |
| "net/http" |
| "net/http/httptest" |
| "os" |
| "os/exec" |
| "path" |
| "path/filepath" |
| "strings" |
| "syscall" |
| "testing" |
| |
| "github.com/google/go-cmp/cmp" |
| |
| svchost "github.com/hashicorp/terraform-svchost" |
| "github.com/hashicorp/terraform-svchost/disco" |
| "github.com/hashicorp/terraform/internal/addrs" |
| backendInit "github.com/hashicorp/terraform/internal/backend/init" |
| backendLocal "github.com/hashicorp/terraform/internal/backend/local" |
| "github.com/hashicorp/terraform/internal/command/views" |
| "github.com/hashicorp/terraform/internal/command/workdir" |
| "github.com/hashicorp/terraform/internal/configs" |
| "github.com/hashicorp/terraform/internal/configs/configload" |
| "github.com/hashicorp/terraform/internal/configs/configschema" |
| "github.com/hashicorp/terraform/internal/copy" |
| "github.com/hashicorp/terraform/internal/depsfile" |
| "github.com/hashicorp/terraform/internal/getproviders" |
| "github.com/hashicorp/terraform/internal/initwd" |
| legacy "github.com/hashicorp/terraform/internal/legacy/terraform" |
| _ "github.com/hashicorp/terraform/internal/logging" |
| "github.com/hashicorp/terraform/internal/plans" |
| "github.com/hashicorp/terraform/internal/plans/planfile" |
| "github.com/hashicorp/terraform/internal/providers" |
| "github.com/hashicorp/terraform/internal/registry" |
| "github.com/hashicorp/terraform/internal/states" |
| "github.com/hashicorp/terraform/internal/states/statefile" |
| "github.com/hashicorp/terraform/internal/states/statemgr" |
| "github.com/hashicorp/terraform/internal/terminal" |
| "github.com/hashicorp/terraform/internal/terraform" |
| "github.com/hashicorp/terraform/version" |
| "github.com/zclconf/go-cty/cty" |
| ) |
| |
| // 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()) |
| } |
| |
| // 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 := testTempDir(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) |
| // FIXME: We're not dealing with the cleanup function here because |
| // this testModule function is used all over and so we don't want to |
| // change its interface at this late stage. |
| loader, _ := configload.NewLoaderForTests(t) |
| |
| // 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(), registry.NewClient(nil, nil)) |
| _, instDiags := inst.InstallModules(context.Background(), dir, true, initwd.ModuleInstallHooksImpl{}) |
| if instDiags.HasErrors() { |
| t.Fatal(instDiags.Err()) |
| } |
| |
| config, snap, diags := loader.LoadConfigWithSnapshot(dir) |
| 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(), |
| }) |
| 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 testReadPlan(t *testing.T, path string) *plans.Plan { |
| t.Helper() |
| |
| f, err := planfile.Open(path) |
| if err != nil { |
| t.Fatalf("error opening plan file %q: %s", path, err) |
| } |
| defer f.Close() |
| |
| 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, |
| }, |
| ) |
| // 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.Write(sf, w) |
| } |
| |
| // 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(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, 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 *legacy.State) string { |
| t.Helper() |
| |
| path := filepath.Join(DefaultDataDir, 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 := legacy.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) |
| 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 old format still uses *legacy.State, but should be replaced with |
| // a more specialized type in a later release. |
| func testDataStateRead(t *testing.T, path string) *legacy.State { |
| t.Helper() |
| |
| f, err := os.Open(path) |
| if err != nil { |
| t.Fatalf("err: %s", err) |
| } |
| defer f.Close() |
| |
| s, err := legacy.ReadState(f) |
| 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() *terraform.MockProvider { |
| p := new(terraform.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(testTempDir(t), "state.tfstate") |
| } |
| |
| func testTempDir(t *testing.T) string { |
| t.Helper() |
| d, err := filepath.EvalSymlinks(t.TempDir()) |
| if err != nil { |
| t.Fatal(err) |
| } |
| |
| return d |
| } |
| |
| // testChdir changes the directory and returns a function to defer to |
| // revert the old cwd. |
| func testChdir(t *testing.T, new string) func() { |
| t.Helper() |
| |
| old, err := os.Getwd() |
| if err != nil { |
| t.Fatalf("err: %s", err) |
| } |
| |
| if err := os.Chdir(new); err != nil { |
| t.Fatalf("err: %v", err) |
| } |
| |
| return func() { |
| // Re-run the function ignoring the defer result |
| testChdir(t, old) |
| } |
| } |
| |
| // testCwd 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 testCwd(t *testing.T) string { |
| t.Helper() |
| |
| tmp := t.TempDir() |
| |
| cwd, err := os.Getwd() |
| if err != nil { |
| t.Fatalf("err: %v", err) |
| } |
| |
| if err := os.Chdir(tmp); err != nil { |
| t.Fatalf("err: %v", err) |
| } |
| |
| t.Cleanup(func() { |
| if err := os.Chdir(cwd); err != nil { |
| t.Fatalf("err: %v", err) |
| } |
| }) |
| |
| 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() |
| io.Copy(w, src) |
| }() |
| |
| return func() { |
| // Close our read end |
| r.Close() |
| |
| // Reset stdin |
| os.Stdin = old |
| } |
| } |
| |
| // Modify os.Stdout to write to the given buffer. Note that this is generally |
| // not useful since the commands are configured to write to a cli.Ui, not |
| // Stdout directly. Commands like `console` though use the raw stdout. |
| func testStdoutCapture(t *testing.T, dst io.Writer) func() { |
| t.Helper() |
| |
| r, w, err := os.Pipe() |
| if err != nil { |
| t.Fatalf("err: %s", err) |
| } |
| |
| // Modify stdout |
| old := os.Stdout |
| os.Stdout = w |
| |
| // Copy |
| doneCh := make(chan struct{}) |
| go func() { |
| defer close(doneCh) |
| defer r.Close() |
| io.Copy(dst, r) |
| }() |
| |
| return func() { |
| // Close the writer end of the pipe |
| w.Sync() |
| w.Close() |
| |
| // Reset stdout |
| os.Stdout = old |
| |
| // Wait for the data copy to complete to avoid a race reading data |
| <-doneCh |
| } |
| } |
| |
| // 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 |
| // "terraform 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) (*legacy.State, *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) |
| resp.Write(buf.Bytes()) |
| } |
| |
| // If a state was given, make sure we calculate the proper b64md5 |
| if s != nil { |
| err := statefile.Write(&statefile.File{State: s}, buf) |
| 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{}), |
| } |
| b := backendInit.Backend("http")() |
| configSchema := b.ConfigSchema() |
| hash := backendConfig.Hash(configSchema) |
| |
| state := legacy.NewState() |
| state.Backend = &legacy.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 legacy remote state. |
| // |
| // The return values are a *legacy.State 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) (*legacy.State, *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) |
| resp.Write(buf.Bytes()) |
| } |
| |
| retState := legacy.NewState() |
| |
| srv := httptest.NewServer(http.HandlerFunc(cb)) |
| b := &legacy.BackendState{ |
| Type: "http", |
| } |
| 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, |
| }, |
| }, |
| }) |
| retState.Backend = b |
| |
| if s != nil { |
| err := statefile.Write(&statefile.File{State: s}, buf) |
| 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") |
| |
| cmd := exec.Command("go", "build", "-o", lockBin, source) |
| cmd.Dir = filepath.Dir(sourceDir) |
| |
| out, err := cmd.CombinedOutput() |
| if err != nil { |
| return nil, fmt.Errorf("%s %s", err, out) |
| } |
| |
| locker := exec.Command(lockBin, path) |
| pr, pw, err := os.Pipe() |
| if err != nil { |
| return nil, err |
| } |
| defer pr.Close() |
| defer pw.Close() |
| locker.Stderr = pw |
| locker.Stdout = pw |
| |
| if err := locker.Start(); err != nil { |
| return nil, err |
| } |
| deferFunc := func() { |
| locker.Process.Signal(syscall.SIGTERM) |
| locker.Wait() |
| } |
| |
| // wait for the process to lock |
| buf := make([]byte, 1024) |
| n, err := pr.Read(buf) |
| if err != nil { |
| return deferFunc, fmt.Errorf("read from statelocker returned: %s", err) |
| } |
| |
| output := string(buf[:n]) |
| if !strings.HasPrefix(output, "LOCKID") { |
| return deferFunc, fmt.Errorf("statelocker wrote: %s", string(buf[:n])) |
| } |
| return deferFunc, 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 := ioutil.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.Mode()&os.ModeSymlink != 0 { |
| target, err := os.Readlink(srcPath) |
| if err != nil { |
| t.Fatal(err) |
| } |
| |
| entry, err = os.Stat(target) |
| if err != nil { |
| t.Fatal(err) |
| } |
| } |
| |
| if entry.IsDir() { |
| testCopyDir(t, srcPath, dstPath) |
| } else { |
| err = copy.CopyFile(srcPath, dstPath) |
| if err != nil { |
| t.Fatal(err) |
| } |
| } |
| } |
| } |
| |
| // 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() |
| } |
| |
| func mustResourceAddr(s string) addrs.ConfigResource { |
| addr, diags := addrs.ParseAbsResourceStr(s) |
| if diags.HasErrors() { |
| panic(diags.Err()) |
| } |
| return addr.Config() |
| } |
| |
| // 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.terraform.io"), map[string]interface{}{ |
| "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(services) |
| return source, close |
| } |
| |
| func fakeRegistryHandler(resp http.ResponseWriter, req *http.Request) { |
| path := req.URL.EscapedPath() |
| |
| if !strings.HasPrefix(path, "/providers/v1/") { |
| resp.WriteHeader(404) |
| resp.Write([]byte(`not a provider registry endpoint`)) |
| return |
| } |
| |
| pathParts := strings.Split(path, "/")[3:] |
| |
| if len(pathParts) != 3 { |
| resp.WriteHeader(404) |
| resp.Write([]byte(`unrecognized path scheme`)) |
| return |
| } |
| |
| if pathParts[2] != "versions" { |
| resp.WriteHeader(404) |
| resp.Write([]byte(`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 { |
| resp.Write([]byte(fmt.Sprintf(`{"id":"%s/%s","moved_to":"%s/%s","versions":[{"version":"1.0.0","protocols":["4"]}]}`, namespace, name, movedNamespace, name))) |
| } else { |
| resp.Write([]byte(fmt.Sprintf(`{"id":"%s/%s","versions":[{"version":"1.0.0","protocols":["4"]}]}`, namespace, name))) |
| } |
| } else { |
| resp.WriteHeader(404) |
| resp.Write([]byte(`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) |
| resp.Write([]byte(fmt.Sprintf(`{"id":"%s/%s","versions":[{"version":"1.0.0","protocols":["4"]}]}`, namespace, name))) |
| } else { |
| resp.WriteHeader(404) |
| resp.Write([]byte(`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 := ioutil.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"` |
| Terraform string `json:"terraform"` |
| 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("Terraform %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]interface{} |
| for i, line := range gotLines[1:] { |
| index := i + 1 |
| var gotMap map[string]interface{} |
| 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]) |
| } |
| delete(gotMap, "@timestamp") |
| gotLineMaps = append(gotLineMaps, gotMap) |
| } |
| var wantLineMaps []map[string]interface{} |
| for i, line := range wantLines[1:] { |
| index := i + 1 |
| var wantMap map[string]interface{} |
| if err := json.Unmarshal([]byte(line), &wantMap); err != nil { |
| t.Errorf("failed to unmarshal want line %d: %s\n%s", index, err, gotLines[index]) |
| } |
| 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) |
| } |
| } |