// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 package stackmigrate import ( "fmt" "io" "net/http" "net/http/httptest" "os" "path" "path/filepath" "strings" "testing" svchost "github.com/hashicorp/terraform-svchost" "github.com/hashicorp/terraform-svchost/auth" "github.com/hashicorp/terraform-svchost/disco" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/httpclient" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/states/statefile" "github.com/hashicorp/terraform/version" "github.com/zclconf/go-cty/cty" ) func TestLoad_Local(t *testing.T) { state := 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{ AttrsJSON: []byte(`{"id":"bar","foo":"value","bar":"value"}`), Status: states.ObjectReady, }, addrs.AbsProviderConfig{ Provider: addrs.NewDefaultProvider("test"), Module: addrs.RootModule, }, ) s.SetResourceInstanceCurrent( addrs.Resource{ Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "baz", }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), &states.ResourceInstanceObjectSrc{ AttrsJSON: []byte(`{"id":"foo","foo":"value","bar":"value"}`), Status: states.ObjectReady, Dependencies: []addrs.ConfigResource{mustResourceAddr("test_instance.foo")}, }, addrs.AbsProviderConfig{ Provider: addrs.NewDefaultProvider("test"), Module: addrs.RootModule, }, ) }) statePath := TestStateFile(t, state) loader := &Loader{} loadedState, diags := loader.LoadState(strings.TrimSuffix(statePath, "/terraform.tfstate")) if diags.HasErrors() { t.Fatalf("failed to load state: %s", diags.Err()) } if !statefile.StatesMarshalEqual(state, loadedState) { t.Fatalf("loaded state does not match original state") } } func TestLoad(t *testing.T) { state := 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{ AttrsJSON: []byte(`{"id":"bar","foo":"value","bar":"value"}`), Status: states.ObjectReady, }, addrs.AbsProviderConfig{ Provider: addrs.NewDefaultProvider("test"), Module: addrs.RootModule, }, ) s.SetResourceInstanceCurrent( addrs.Resource{ Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "baz", }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), &states.ResourceInstanceObjectSrc{ AttrsJSON: []byte(`{"id":"foo","foo":"value","bar":"value"}`), Status: states.ObjectReady, Dependencies: []addrs.ConfigResource{mustResourceAddr("test_instance.foo")}, }, addrs.AbsProviderConfig{ Provider: addrs.NewDefaultProvider("test"), Module: addrs.RootModule, }, ) }) statePath := TestStateFile(t, state) s := testServer(t, statePath) backendStatePath := testBackendStateFile(t, cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("hashicorp"), "hostname": cty.StringVal("localhost"), "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.NullVal(cty.String), "prefix": cty.StringVal("my-app-"), }), })) dir := strings.TrimSuffix(backendStatePath, ".terraform/.terraform.tfstate") defer s.Close() loader := Loader{Discovery: testDisco(s)} t.Setenv(WorkspaceNameEnvVar, "test") loadedState, diags := loader.LoadState(dir) if diags.HasErrors() { t.Fatalf("failed to load state: %s", diags.Err()) } if !statefile.StatesMarshalEqual(state, loadedState) { t.Fatalf("loaded state does not match original state") } } func mustResourceAddr(s string) addrs.ConfigResource { addr, diags := addrs.ParseAbsResourceStr(s) if diags.HasErrors() { panic(diags.Err()) } return addr.Config() } func testBackendStateFile(t *testing.T, value cty.Value) string { t.Helper() path := filepath.Join(t.TempDir(), ".terraform", ".terraform.tfstate") err := os.MkdirAll(filepath.Dir(path), 0755) if err != nil { t.Fatalf("failed to create directories for temporary state file %s: %s", path, err) } f, err := os.Create(path) if err != nil { t.Fatalf("failed to create temporary state file %s: %s", path, err) } fmt.Fprintf(f, `{ "version": 3, "terraform_version": "1.9.4", "backend": { "type": "remote", "config": { "hostname": %q, "organization": %q, "token": "foo", "workspaces": { "name": null, "prefix": %q } }, "hash": 2143736989 } }`, value.GetAttr("hostname").AsString(), value.GetAttr("organization").AsString(), value.GetAttr("workspaces").GetAttr("prefix").AsString()) f.Close() return path } func createTempFile(t *testing.T, dir, filename, content string) string { t.Helper() filePath := filepath.Join(dir, filename) err := os.WriteFile(filePath, []byte(content), 0644) if err != nil { t.Fatalf("failed to write temp file: %v", err) } return filePath } // testDisco returns a *disco.Disco mapping app.terraform.io and // localhost to a local test server. func testDisco(s *httptest.Server) *disco.Disco { services := map[string]interface{}{ "state.v2": fmt.Sprintf("%s/api/v2/", s.URL), "tfe.v2.1": fmt.Sprintf("%s/api/v2/", s.URL), "versions.v1": fmt.Sprintf("%s/v1/versions/", s.URL), } d := disco.NewWithCredentialsSource(auth.NoCredentials) d.SetUserAgent(httpclient.TerraformUserAgent(version.String())) d.ForceHostServices(svchost.Hostname("localhost"), services) d.ForceHostServices(svchost.Hostname("app.terraform.io"), services) return d } // testServer returns a *httptest.Server used for local testing. // This server simulates the APIs needed to load a remote state. func testServer(t *testing.T, statePath string) *httptest.Server { mux := http.NewServeMux() f, err := os.Open(statePath) if err != nil { t.Fatalf("failed to open state file: %s", err) } // Respond to service discovery calls. mux.HandleFunc("/well-known/terraform.json", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") io.WriteString(w, `{ "state.v2": "/api/v2/", "tfe.v2.1": "/api/v2/", "versions.v1": "/v1/versions/" }`) }) // Respond to service version constraints calls. mux.HandleFunc("/v1/versions/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") io.WriteString(w, fmt.Sprintf(`{ "service": "%s", "product": "terraform", "minimum": "0.1.0", "maximum": "10.0.0" }`, path.Base(r.URL.Path))) }) // Respond to pings to get the API version header. mux.HandleFunc("/api/v2/ping", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("TFP-API-Version", "2.4") }) // Respond to the initial query to read the hashicorp org entitlements. mux.HandleFunc("/api/v2/organizations/hashicorp/entitlement-set", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/vnd.api+json") io.WriteString(w, `{ "data": { "id": "org-GExadygjSbKP8hsY", "type": "entitlement-sets", "attributes": { "operations": true, "private-module-registry": true, "sentinel": true, "state-storage": true, "teams": true, "vcs-integrations": true } } }`) }) // Respond to the initial query to read the no-operations org entitlements. mux.HandleFunc("/api/v2/organizations/no-operations/entitlement-set", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/vnd.api+json") io.WriteString(w, `{ "data": { "id": "org-ufxa3y8jSbKP8hsT", "type": "entitlement-sets", "attributes": { "operations": false, "private-module-registry": true, "sentinel": true, "state-storage": true, "teams": true, "vcs-integrations": true } } }`) }) mux.HandleFunc("/api/v2/organizations/hashicorp/workspaces/my-app-test", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) io.WriteString(w, `{ "data": { "id": "ws-EUht4zmoJaZTZMv8", "type": "workspaces", "attributes": { "locked": false, "name": "my-app-test", "queue-all-runs": false, "speculative-enabled": true, "structured-run-output-enabled": true, "terraform-version": "1.9.4", "operations": true, "execution-mode": "remote", "file-triggers-enabled": true, "locked-reason": "", "source": "terraform" } } }`) }) mux.HandleFunc("/api/v2/workspaces/ws-EUht4zmoJaZTZMv8/actions/lock", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) io.WriteString(w, `{ "data": { "id": "ws-EUht4zmoJaZTZMv8", "type": "workspaces", "attributes": { "locked": true, "name": "my-app-test", "queue-all-runs": false, "speculative-enabled": true, "structured-run-output-enabled": true, "terraform-version": "1.9.4", "source": "terraform", "source-name": null, "source-url": null, "tag-names": [] } } }`) }) mux.HandleFunc("/api/v2/workspaces/ws-EUht4zmoJaZTZMv8/current-state-version", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) io.WriteString(w, ` { "data": { "id": "sv-XJmHFY12zJFmwkWN", "type": "state-versions", "attributes": { "created-at": "2025-02-12T14:16:43.541Z", "size": 878, "hosted-state-download-url": "/api/state-versions/sv-XJmHFY12zJFmwkWN/hosted_state", "hosted-json-state-download-url": "/api/state-versions/sv-XJmHFY12zJFmwkWN/hosted_json_state", "serial": 1, "state-version": 4, "status": "finalized", "terraform-version": "1.9.4" } } } `) }) mux.HandleFunc("/api/state-versions/sv-XJmHFY12zJFmwkWN/hosted_state", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) io.Copy(w, f) }) mux.HandleFunc("/api/v2/workspaces/ws-EUht4zmoJaZTZMv8/actions/unlock", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) io.WriteString(w, `{ "data": { "id": "ws-EUht4zmoJaZTZMv8", "type": "workspaces", "attributes": { "locked": false, "name": "my-app-test", "queue-all-runs": false, "speculative-enabled": true, "structured-run-output-enabled": true, "terraform-version": "1.9.4", "source": "terraform", "source-name": null, "source-url": null, "tag-names": [] } } }`) }) return httptest.NewServer(mux) }