mirror of
https://github.com/hashicorp/terraform.git
synced 2026-02-18 18:29:44 -05:00
PSS: Migration from a backend to a state store (#38048)
Some checks are pending
build / Determine intended Terraform version (push) Waiting to run
build / Determine Go toolchain version (push) Waiting to run
build / Generate release metadata (push) Blocked by required conditions
build / Build for freebsd_386 (push) Blocked by required conditions
build / Build for linux_386 (push) Blocked by required conditions
build / Build for openbsd_386 (push) Blocked by required conditions
build / Build for windows_386 (push) Blocked by required conditions
build / Build for darwin_amd64 (push) Blocked by required conditions
build / Build for freebsd_amd64 (push) Blocked by required conditions
build / Build for linux_amd64 (push) Blocked by required conditions
build / Build for openbsd_amd64 (push) Blocked by required conditions
build / Build for solaris_amd64 (push) Blocked by required conditions
build / Build for windows_amd64 (push) Blocked by required conditions
build / Build for freebsd_arm (push) Blocked by required conditions
build / Build for linux_arm (push) Blocked by required conditions
build / Build for darwin_arm64 (push) Blocked by required conditions
build / Build for linux_arm64 (push) Blocked by required conditions
build / Build for windows_arm64 (push) Blocked by required conditions
build / Build Docker image for linux_386 (push) Blocked by required conditions
build / Build Docker image for linux_amd64 (push) Blocked by required conditions
build / Build Docker image for linux_arm (push) Blocked by required conditions
build / Build Docker image for linux_arm64 (push) Blocked by required conditions
build / Build e2etest for linux_386 (push) Blocked by required conditions
build / Build e2etest for windows_386 (push) Blocked by required conditions
build / Build e2etest for darwin_amd64 (push) Blocked by required conditions
build / Build e2etest for linux_amd64 (push) Blocked by required conditions
build / Build e2etest for windows_amd64 (push) Blocked by required conditions
build / Build e2etest for linux_arm (push) Blocked by required conditions
build / Build e2etest for darwin_arm64 (push) Blocked by required conditions
build / Build e2etest for linux_arm64 (push) Blocked by required conditions
build / Run e2e test for linux_386 (push) Blocked by required conditions
build / Run e2e test for windows_386 (push) Blocked by required conditions
build / Run e2e test for darwin_amd64 (push) Blocked by required conditions
build / Run e2e test for linux_amd64 (push) Blocked by required conditions
build / Run e2e test for windows_amd64 (push) Blocked by required conditions
build / Run e2e test for linux_arm (push) Blocked by required conditions
build / Run e2e test for linux_arm64 (push) Blocked by required conditions
build / Run terraform-exec test for linux amd64 (push) Blocked by required conditions
Quick Checks / Unit Tests (push) Waiting to run
Quick Checks / Race Tests (push) Waiting to run
Quick Checks / End-to-end Tests (push) Waiting to run
Quick Checks / Code Consistency Checks (push) Waiting to run
Some checks are pending
build / Determine intended Terraform version (push) Waiting to run
build / Determine Go toolchain version (push) Waiting to run
build / Generate release metadata (push) Blocked by required conditions
build / Build for freebsd_386 (push) Blocked by required conditions
build / Build for linux_386 (push) Blocked by required conditions
build / Build for openbsd_386 (push) Blocked by required conditions
build / Build for windows_386 (push) Blocked by required conditions
build / Build for darwin_amd64 (push) Blocked by required conditions
build / Build for freebsd_amd64 (push) Blocked by required conditions
build / Build for linux_amd64 (push) Blocked by required conditions
build / Build for openbsd_amd64 (push) Blocked by required conditions
build / Build for solaris_amd64 (push) Blocked by required conditions
build / Build for windows_amd64 (push) Blocked by required conditions
build / Build for freebsd_arm (push) Blocked by required conditions
build / Build for linux_arm (push) Blocked by required conditions
build / Build for darwin_arm64 (push) Blocked by required conditions
build / Build for linux_arm64 (push) Blocked by required conditions
build / Build for windows_arm64 (push) Blocked by required conditions
build / Build Docker image for linux_386 (push) Blocked by required conditions
build / Build Docker image for linux_amd64 (push) Blocked by required conditions
build / Build Docker image for linux_arm (push) Blocked by required conditions
build / Build Docker image for linux_arm64 (push) Blocked by required conditions
build / Build e2etest for linux_386 (push) Blocked by required conditions
build / Build e2etest for windows_386 (push) Blocked by required conditions
build / Build e2etest for darwin_amd64 (push) Blocked by required conditions
build / Build e2etest for linux_amd64 (push) Blocked by required conditions
build / Build e2etest for windows_amd64 (push) Blocked by required conditions
build / Build e2etest for linux_arm (push) Blocked by required conditions
build / Build e2etest for darwin_arm64 (push) Blocked by required conditions
build / Build e2etest for linux_arm64 (push) Blocked by required conditions
build / Run e2e test for linux_386 (push) Blocked by required conditions
build / Run e2e test for windows_386 (push) Blocked by required conditions
build / Run e2e test for darwin_amd64 (push) Blocked by required conditions
build / Run e2e test for linux_amd64 (push) Blocked by required conditions
build / Run e2e test for windows_amd64 (push) Blocked by required conditions
build / Run e2e test for linux_arm (push) Blocked by required conditions
build / Run e2e test for linux_arm64 (push) Blocked by required conditions
build / Run terraform-exec test for linux amd64 (push) Blocked by required conditions
Quick Checks / Unit Tests (push) Waiting to run
Quick Checks / Race Tests (push) Waiting to run
Quick Checks / End-to-end Tests (push) Waiting to run
Quick Checks / Code Consistency Checks (push) Waiting to run
* backend/inmem: Make it easier to use the backend in tests * cloud: Make the cloud backend testable * command/views: Introduce migration UI messages * command/init: Add 3 tests for migrations from a backend to PSS - TestInit_backend_to_stateStore_singleWorkspace - TestInit_backend_to_stateStore_multipleWorkspaces - TestInit_cloud_to_stateStore * command/init: Implement migration from a backend to PSS * address PR feedback * remove local state after migration
This commit is contained in:
parent
a48e873790
commit
27770ee805
8 changed files with 1182 additions and 80 deletions
|
|
@ -71,22 +71,27 @@ func (b *Backend) Configure(configVal cty.Value) tfdiags.Diagnostics {
|
|||
states.Lock()
|
||||
defer states.Unlock()
|
||||
|
||||
defaultClient := &RemoteClient{
|
||||
Name: backend.DefaultStateName,
|
||||
}
|
||||
// Some tests may configure this backend multiple times
|
||||
// and expect the same state from memory afterwards.
|
||||
_, ok := states.m[backend.DefaultStateName]
|
||||
if !ok {
|
||||
defaultClient := &RemoteClient{
|
||||
Name: backend.DefaultStateName,
|
||||
}
|
||||
|
||||
states.m[backend.DefaultStateName] = &remote.State{
|
||||
Client: defaultClient,
|
||||
}
|
||||
states.m[backend.DefaultStateName] = &remote.State{
|
||||
Client: defaultClient,
|
||||
}
|
||||
|
||||
// set the default client lock info per the test config
|
||||
if v := configVal.GetAttr("lock_id"); !v.IsNull() {
|
||||
info := statemgr.NewLockInfo()
|
||||
info.ID = v.AsString()
|
||||
info.Operation = "test"
|
||||
info.Info = "test config"
|
||||
// set the default client lock info per the test config
|
||||
if v := configVal.GetAttr("lock_id"); !v.IsNull() {
|
||||
info := statemgr.NewLockInfo()
|
||||
info.ID = v.AsString()
|
||||
info.Operation = "test"
|
||||
info.Info = "test config"
|
||||
|
||||
locks.lock(backend.DefaultStateName, info)
|
||||
locks.lock(backend.DefaultStateName, info)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
34
internal/backend/remote-state/inmem/testing.go
Normal file
34
internal/backend/remote-state/inmem/testing.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package inmem
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
statespkg "github.com/hashicorp/terraform/internal/states"
|
||||
)
|
||||
|
||||
func ReadState(t *testing.T, wsName string) *statespkg.State {
|
||||
states.Lock()
|
||||
defer states.Unlock()
|
||||
|
||||
stateMgr, ok := states.m[wsName]
|
||||
if !ok {
|
||||
t.Fatalf("state not found for workspace %s", wsName)
|
||||
}
|
||||
|
||||
return stateMgr.State()
|
||||
}
|
||||
|
||||
func ReadWorkspaces(t *testing.T) []string {
|
||||
states.Lock()
|
||||
defer states.Unlock()
|
||||
|
||||
workspaces := make([]string, 0, len(states.m))
|
||||
for wsName := range states.m {
|
||||
workspaces = append(workspaces, wsName)
|
||||
}
|
||||
|
||||
return workspaces
|
||||
}
|
||||
|
|
@ -261,7 +261,7 @@ func TestCloud_PrepareConfig(t *testing.T) {
|
|||
}
|
||||
|
||||
for name, tc := range cases {
|
||||
s := testServer(t)
|
||||
s := TestServer(t)
|
||||
b := New(testDisco(s))
|
||||
|
||||
// Validate
|
||||
|
|
@ -794,7 +794,7 @@ func TestCloud_configVerifyMinimumTFEVersion(t *testing.T) {
|
|||
w.Header().Set("TFP-API-Version", "2.4")
|
||||
},
|
||||
}
|
||||
s := testServerWithHandlers(handlers)
|
||||
s := TestServerWithHandlers(t, handlers)
|
||||
|
||||
b := New(testDisco(s))
|
||||
|
||||
|
|
@ -831,7 +831,7 @@ func TestCloud_configVerifyMinimumTFEVersionInAutomation(t *testing.T) {
|
|||
w.Header().Set("TFP-API-Version", "2.4")
|
||||
},
|
||||
}
|
||||
s := testServerWithHandlers(handlers)
|
||||
s := TestServerWithHandlers(t, handlers)
|
||||
|
||||
b := New(testDisco(s))
|
||||
b.runningInAutomation = true
|
||||
|
|
@ -1680,7 +1680,7 @@ func TestCloudBackend_DeleteWorkspace_DoesNotExist(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCloud_ServiceDiscoveryAliases(t *testing.T) {
|
||||
s := testServer(t)
|
||||
s := TestServer(t)
|
||||
b := New(testDisco(s))
|
||||
|
||||
diag := b.Configure(cty.ObjectVal(map[string]cty.Value{
|
||||
|
|
|
|||
|
|
@ -245,9 +245,9 @@ func testBackendWithOutputs(t *testing.T) (*Cloud, func()) {
|
|||
func testBackend(t *testing.T, obj cty.Value, handlers map[string]func(http.ResponseWriter, *http.Request)) (*Cloud, *MockClient, func()) {
|
||||
var s *httptest.Server
|
||||
if handlers != nil {
|
||||
s = testServerWithHandlers(handlers)
|
||||
s = TestServerWithHandlers(t, handlers)
|
||||
} else {
|
||||
s = testServer(t)
|
||||
s = TestServer(t)
|
||||
}
|
||||
b := New(testDisco(s))
|
||||
|
||||
|
|
@ -324,7 +324,7 @@ func testBackend(t *testing.T, obj cty.Value, handlers map[string]func(http.Resp
|
|||
// testUnconfiguredBackend is used for testing the configuration of the backend
|
||||
// with the mock client
|
||||
func testUnconfiguredBackend(t *testing.T) (*Cloud, func()) {
|
||||
s := testServer(t)
|
||||
s := TestServer(t)
|
||||
b := New(testDisco(s))
|
||||
|
||||
// Normally, the client is created during configuration, but the configuration uses the
|
||||
|
|
@ -395,15 +395,15 @@ func testLocalBackend(t *testing.T, cloud *Cloud) backendrun.OperationsBackend {
|
|||
return b
|
||||
}
|
||||
|
||||
// testServer returns a started *httptest.Server used for local testing with the default set of
|
||||
// TestServer returns a started *httptest.Server used for local testing with the default set of
|
||||
// request handlers.
|
||||
func testServer(t *testing.T) *httptest.Server {
|
||||
return testServerWithHandlers(testDefaultRequestHandlers)
|
||||
func TestServer(t *testing.T) *httptest.Server {
|
||||
return TestServerWithHandlers(t, testDefaultRequestHandlers)
|
||||
}
|
||||
|
||||
// testServerWithHandlers returns a started *httptest.Server with the given set of request handlers
|
||||
// TestServerWithHandlers returns a started *httptest.Server with the given set of request handlers
|
||||
// overriding any default request handlers (testDefaultRequestHandlers).
|
||||
func testServerWithHandlers(handlers map[string]func(http.ResponseWriter, *http.Request)) *httptest.Server {
|
||||
func TestServerWithHandlers(t *testing.T, handlers map[string]func(http.ResponseWriter, *http.Request)) *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
for route, handler := range handlers {
|
||||
mux.HandleFunc(route, handler)
|
||||
|
|
@ -414,6 +414,11 @@ func testServerWithHandlers(handlers map[string]func(http.ResponseWriter, *http.
|
|||
}
|
||||
}
|
||||
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
|
||||
t.Logf("unexpected %s request received for %q", req.Method, req.URL.String())
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
})
|
||||
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
|
|
@ -23,7 +26,10 @@ import (
|
|||
|
||||
"github.com/hashicorp/terraform/internal/addrs"
|
||||
"github.com/hashicorp/terraform/internal/backend"
|
||||
backendInit "github.com/hashicorp/terraform/internal/backend/init"
|
||||
httpBackend "github.com/hashicorp/terraform/internal/backend/remote-state/http"
|
||||
"github.com/hashicorp/terraform/internal/backend/remote-state/inmem"
|
||||
"github.com/hashicorp/terraform/internal/cloud"
|
||||
"github.com/hashicorp/terraform/internal/command/arguments"
|
||||
"github.com/hashicorp/terraform/internal/command/clistate"
|
||||
"github.com/hashicorp/terraform/internal/command/views"
|
||||
|
|
@ -4742,6 +4748,899 @@ func TestInit_unitialized_stateStore(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestInit_backend_to_stateStore_singleWorkspace(t *testing.T) {
|
||||
// Create a temporary working directory that is empty
|
||||
td := t.TempDir()
|
||||
|
||||
testBackend := new(httpBackend.TestHTTPBackend)
|
||||
ts := httptest.NewServer(http.HandlerFunc(testBackend.Handle))
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
cfg := fmt.Sprintf(`terraform {
|
||||
backend "http" {
|
||||
address = %q
|
||||
}
|
||||
}
|
||||
`, ts.URL)
|
||||
if err := os.WriteFile(filepath.Join(td, "main.tf"), []byte(cfg), 0644); err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
t.Chdir(td)
|
||||
|
||||
mockProvider := mockPluggableStateStorageProvider()
|
||||
mockProviderAddress := addrs.NewDefaultProvider("test")
|
||||
providerSource, close := newMockProviderSource(t, map[string][]string{
|
||||
"hashicorp/test": {"1.2.3"},
|
||||
})
|
||||
defer close()
|
||||
|
||||
tOverrides := &testingOverrides{
|
||||
Providers: map[addrs.Provider]providers.Factory{
|
||||
mockProviderAddress: providers.FactoryFixed(mockProvider),
|
||||
},
|
||||
}
|
||||
|
||||
{
|
||||
log.Printf("[TRACE] %s: beginning first init with backend", t.Name())
|
||||
// Init
|
||||
ui := cli.NewMockUi()
|
||||
view, done := testView(t)
|
||||
c := &InitCommand{
|
||||
Meta: Meta{
|
||||
Ui: ui,
|
||||
View: view,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
args := []string{
|
||||
"-enable-pluggable-state-storage-experiment=true",
|
||||
}
|
||||
code := c.Run(args)
|
||||
testOutput := done(t)
|
||||
if code != 0 {
|
||||
t.Fatalf("bad: \n%s", testOutput.All())
|
||||
}
|
||||
log.Printf("[TRACE] %s: first init complete", t.Name())
|
||||
t.Logf("First run output:\n%s", testOutput.Stdout())
|
||||
t.Logf("First run errors:\n%s", testOutput.Stderr())
|
||||
|
||||
if testBackend.CallCount("POST") != 0 {
|
||||
t.Fatalf("expected 0 POST calls after init, got %d", testBackend.CallCount("POST"))
|
||||
}
|
||||
if testBackend.CallCount("GET") != 2 {
|
||||
t.Fatalf("expected 2 GET calls after init, got %d", testBackend.CallCount("GET"))
|
||||
}
|
||||
}
|
||||
{
|
||||
// run apply to ensure state isn't empty
|
||||
// to bypass edge case handling which causes empty state to stop migration
|
||||
log.Printf("[TRACE] %s: beginning apply with backend", t.Name())
|
||||
|
||||
outputCfg := `output "test" {
|
||||
value = "test"
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(td, "output.tf"), []byte(outputCfg), 0644); err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
ui := cli.NewMockUi()
|
||||
aView, aDone := testView(t)
|
||||
cApply := &ApplyCommand{
|
||||
Meta: Meta{
|
||||
Ui: ui,
|
||||
View: aView,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
aCode := cApply.Run([]string{"-auto-approve"})
|
||||
aTestOutput := aDone(t)
|
||||
if aCode != 0 {
|
||||
t.Fatalf("bad: \n%s", aTestOutput.All())
|
||||
}
|
||||
|
||||
t.Logf("Apply output:\n%s", aTestOutput.Stdout())
|
||||
t.Logf("Apply errors:\n%s", aTestOutput.Stderr())
|
||||
|
||||
if testBackend.CallCount("POST") != 1 {
|
||||
t.Fatalf("expected 1 POST call after apply, got %d", testBackend.CallCount("POST"))
|
||||
}
|
||||
if testBackend.CallCount("GET") != 5 {
|
||||
t.Fatalf("expected 5 GET calls after apply, got %d", testBackend.CallCount("GET"))
|
||||
}
|
||||
data, err := statefile.Read(bytes.NewBuffer(testBackend.Data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedOutputs := map[string]*states.OutputValue{
|
||||
"test": {
|
||||
Addr: addrs.AbsOutputValue{
|
||||
OutputValue: addrs.OutputValue{
|
||||
Name: "test",
|
||||
},
|
||||
},
|
||||
Value: cty.StringVal("test"),
|
||||
},
|
||||
}
|
||||
if diff := cmp.Diff(expectedOutputs, data.State.RootOutputValues); diff != "" {
|
||||
t.Fatalf("unexpected data after apply: %s", diff)
|
||||
}
|
||||
}
|
||||
{
|
||||
log.Printf("[TRACE] %s: beginning second init with state store", t.Name())
|
||||
|
||||
ssCfg := `terraform {
|
||||
required_providers {
|
||||
test = {
|
||||
source = "hashicorp/test"
|
||||
}
|
||||
}
|
||||
state_store "test_store" {
|
||||
provider "test" {}
|
||||
value = "foobar"
|
||||
}
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(td, "main.tf"), []byte(ssCfg), 0644); err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
ui := cli.NewMockUi()
|
||||
view, done := testView(t)
|
||||
c := &InitCommand{
|
||||
Meta: Meta{
|
||||
testingOverrides: tOverrides,
|
||||
ProviderSource: providerSource,
|
||||
Ui: ui,
|
||||
View: view,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-enable-pluggable-state-storage-experiment=true",
|
||||
"-force-copy",
|
||||
}
|
||||
code := c.Run(args)
|
||||
testOutput := done(t)
|
||||
if code != 0 {
|
||||
t.Fatalf("bad: \n%s", testOutput.All())
|
||||
}
|
||||
log.Printf("[TRACE] %s: second init with state store complete", t.Name())
|
||||
t.Logf("Second run output:\n%s", testOutput.Stdout())
|
||||
t.Logf("Second run errors:\n%s", testOutput.Stderr())
|
||||
|
||||
s := testDataStateRead(t, filepath.Join(DefaultDataDir, DefaultStateFilename))
|
||||
if s.StateStore.Empty() {
|
||||
t.Fatal("should have StateStore config")
|
||||
}
|
||||
if !s.Backend.Empty() {
|
||||
t.Fatalf("expected backend to be empty")
|
||||
}
|
||||
|
||||
rawData, ok := mockProvider.MockStates[backend.DefaultStateName].([]byte)
|
||||
if !ok {
|
||||
t.Fatalf("expected %q state to exist in %s: %#v",
|
||||
backend.DefaultStateName,
|
||||
mockProviderAddress,
|
||||
mockProvider.MockStates)
|
||||
}
|
||||
|
||||
data, err := statefile.Read(bytes.NewBuffer(rawData))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedOutputs := map[string]*states.OutputValue{
|
||||
"test": {
|
||||
Addr: addrs.AbsOutputValue{
|
||||
OutputValue: addrs.OutputValue{
|
||||
Name: "test",
|
||||
},
|
||||
},
|
||||
Value: cty.StringVal("test"),
|
||||
},
|
||||
}
|
||||
if diff := cmp.Diff(expectedOutputs, data.State.RootOutputValues); diff != "" {
|
||||
t.Fatalf("unexpected data: %s", diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInit_backend_to_stateStore_noState tests that given no state
|
||||
// in the source backend, no state is created in the destination state store
|
||||
// as a result of the migration.
|
||||
func TestInit_backend_to_stateStore_noState(t *testing.T) {
|
||||
// Create a temporary working directory that is empty
|
||||
td := t.TempDir()
|
||||
|
||||
testBackend := new(httpBackend.TestHTTPBackend)
|
||||
ts := httptest.NewServer(http.HandlerFunc(testBackend.Handle))
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
cfg := fmt.Sprintf(`terraform {
|
||||
backend "http" {
|
||||
address = %q
|
||||
}
|
||||
}
|
||||
`, ts.URL)
|
||||
if err := os.WriteFile(filepath.Join(td, "main.tf"), []byte(cfg), 0644); err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
t.Chdir(td)
|
||||
|
||||
mockProvider := mockPluggableStateStorageProvider()
|
||||
mockProviderAddress := addrs.NewDefaultProvider("test")
|
||||
providerSource, close := newMockProviderSource(t, map[string][]string{
|
||||
"hashicorp/test": {"1.2.3"},
|
||||
})
|
||||
defer close()
|
||||
|
||||
tOverrides := &testingOverrides{
|
||||
Providers: map[addrs.Provider]providers.Factory{
|
||||
mockProviderAddress: providers.FactoryFixed(mockProvider),
|
||||
},
|
||||
}
|
||||
{
|
||||
log.Printf("[TRACE] %s: beginning first init with backend", t.Name())
|
||||
// Init
|
||||
ui := cli.NewMockUi()
|
||||
view, done := testView(t)
|
||||
c := &InitCommand{
|
||||
Meta: Meta{
|
||||
Ui: ui,
|
||||
View: view,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
args := []string{
|
||||
"-enable-pluggable-state-storage-experiment=true",
|
||||
}
|
||||
code := c.Run(args)
|
||||
testOutput := done(t)
|
||||
if code != 0 {
|
||||
t.Fatalf("first init exited with non-zero code %d:\n%s", code, testOutput.Stderr())
|
||||
}
|
||||
log.Printf("[TRACE] %s: first init complete", t.Name())
|
||||
t.Logf("First run output:\n%s", testOutput.Stdout())
|
||||
t.Logf("First run errors:\n%s", testOutput.Stderr())
|
||||
|
||||
if testBackend.CallCount("POST") != 0 {
|
||||
t.Fatalf("expected 0 POST calls after init, got %d", testBackend.CallCount("POST"))
|
||||
}
|
||||
if testBackend.CallCount("GET") != 2 {
|
||||
t.Fatalf("expected 2 GET calls after init, got %d", testBackend.CallCount("GET"))
|
||||
}
|
||||
}
|
||||
{
|
||||
log.Printf("[TRACE] %s: beginning second init with state store", t.Name())
|
||||
|
||||
ssCfg := `terraform {
|
||||
required_providers {
|
||||
test = {
|
||||
source = "hashicorp/test"
|
||||
}
|
||||
}
|
||||
state_store "test_store" {
|
||||
provider "test" {}
|
||||
value = "foobar"
|
||||
}
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(td, "main.tf"), []byte(ssCfg), 0644); err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
ui := cli.NewMockUi()
|
||||
view, done := testView(t)
|
||||
c := &InitCommand{
|
||||
Meta: Meta{
|
||||
testingOverrides: tOverrides,
|
||||
ProviderSource: providerSource,
|
||||
Ui: ui,
|
||||
View: view,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-enable-pluggable-state-storage-experiment=true",
|
||||
"-force-copy",
|
||||
}
|
||||
code := c.Run(args)
|
||||
testOutput := done(t)
|
||||
if code != 0 {
|
||||
t.Fatalf("second init exited with non-zero code %d:\n%s", code, testOutput.Stderr())
|
||||
}
|
||||
log.Printf("[TRACE] %s: second init with state store complete", t.Name())
|
||||
t.Logf("Second run output:\n%s", testOutput.Stdout())
|
||||
t.Logf("Second run errors:\n%s", testOutput.Stderr())
|
||||
|
||||
s := testDataStateRead(t, filepath.Join(DefaultDataDir, DefaultStateFilename))
|
||||
if s.StateStore.Empty() {
|
||||
t.Fatal("should have StateStore config")
|
||||
}
|
||||
if !s.Backend.Empty() {
|
||||
t.Fatalf("expected backend to be empty")
|
||||
}
|
||||
|
||||
if len(mockProvider.MockStates) != 0 {
|
||||
t.Fatalf("expected no state to exist in %s: %#v",
|
||||
mockProviderAddress,
|
||||
mockProvider.MockStates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_localBackend_to_stateStore(t *testing.T) {
|
||||
// Create a temporary working directory that is empty
|
||||
td := t.TempDir()
|
||||
|
||||
cfg := `terraform {
|
||||
backend "local" {}
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(td, "main.tf"), []byte(cfg), 0644); err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
t.Chdir(td)
|
||||
|
||||
mockProvider := mockPluggableStateStorageProvider()
|
||||
mockProviderAddress := addrs.NewDefaultProvider("test")
|
||||
providerSource, close := newMockProviderSource(t, map[string][]string{
|
||||
"hashicorp/test": {"1.2.3"},
|
||||
})
|
||||
defer close()
|
||||
|
||||
tOverrides := &testingOverrides{
|
||||
Providers: map[addrs.Provider]providers.Factory{
|
||||
mockProviderAddress: providers.FactoryFixed(mockProvider),
|
||||
},
|
||||
}
|
||||
{
|
||||
log.Printf("[TRACE] %s: beginning first init with local backend", t.Name())
|
||||
// Init
|
||||
ui := cli.NewMockUi()
|
||||
view, done := testView(t)
|
||||
c := &InitCommand{
|
||||
Meta: Meta{
|
||||
Ui: ui,
|
||||
View: view,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
args := []string{
|
||||
"-enable-pluggable-state-storage-experiment=true",
|
||||
}
|
||||
code := c.Run(args)
|
||||
testOutput := done(t)
|
||||
if code != 0 {
|
||||
t.Fatalf("first init exited with non-zero code %d:\n%s", code, testOutput.Stderr())
|
||||
}
|
||||
log.Printf("[TRACE] %s: first init complete", t.Name())
|
||||
t.Logf("First run output:\n%s", testOutput.Stdout())
|
||||
t.Logf("First run errors:\n%s", testOutput.Stderr())
|
||||
}
|
||||
{
|
||||
// run apply to ensure state isn't empty
|
||||
// to bypass edge case handling which causes empty state to stop migration
|
||||
log.Printf("[TRACE] %s: beginning apply with backend", t.Name())
|
||||
|
||||
outputCfg := `output "test" {
|
||||
value = "test"
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(td, "output.tf"), []byte(outputCfg), 0644); err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
ui := cli.NewMockUi()
|
||||
aView, aDone := testView(t)
|
||||
cApply := &ApplyCommand{
|
||||
Meta: Meta{
|
||||
Ui: ui,
|
||||
View: aView,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
aCode := cApply.Run([]string{"-auto-approve"})
|
||||
aTestOutput := aDone(t)
|
||||
if aCode != 0 {
|
||||
t.Fatalf("bad: \n%s", aTestOutput.All())
|
||||
}
|
||||
|
||||
t.Logf("Apply output:\n%s", aTestOutput.Stdout())
|
||||
t.Logf("Apply errors:\n%s", aTestOutput.Stderr())
|
||||
|
||||
b, err := os.ReadFile(DefaultStateFilename)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to read state file: %s", err)
|
||||
}
|
||||
|
||||
data, err := statefile.Read(bytes.NewBuffer(b))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedOutputs := map[string]*states.OutputValue{
|
||||
"test": {
|
||||
Addr: addrs.AbsOutputValue{
|
||||
OutputValue: addrs.OutputValue{
|
||||
Name: "test",
|
||||
},
|
||||
},
|
||||
Value: cty.StringVal("test"),
|
||||
},
|
||||
}
|
||||
if diff := cmp.Diff(expectedOutputs, data.State.RootOutputValues); diff != "" {
|
||||
t.Fatalf("unexpected data after apply: %s", diff)
|
||||
}
|
||||
}
|
||||
{
|
||||
log.Printf("[TRACE] %s: beginning second init with state store", t.Name())
|
||||
|
||||
ssCfg := `terraform {
|
||||
required_providers {
|
||||
test = {
|
||||
source = "hashicorp/test"
|
||||
}
|
||||
}
|
||||
state_store "test_store" {
|
||||
provider "test" {}
|
||||
value = "foobar"
|
||||
}
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(td, "main.tf"), []byte(ssCfg), 0644); err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
ui := cli.NewMockUi()
|
||||
view, done := testView(t)
|
||||
c := &InitCommand{
|
||||
Meta: Meta{
|
||||
testingOverrides: tOverrides,
|
||||
ProviderSource: providerSource,
|
||||
Ui: ui,
|
||||
View: view,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-enable-pluggable-state-storage-experiment=true",
|
||||
"-force-copy",
|
||||
}
|
||||
code := c.Run(args)
|
||||
testOutput := done(t)
|
||||
if code != 0 {
|
||||
t.Fatalf("second init exited with non-zero code %d:\n%s", code, testOutput.Stderr())
|
||||
}
|
||||
log.Printf("[TRACE] %s: second init with state store complete", t.Name())
|
||||
t.Logf("Second run output:\n%s", testOutput.Stdout())
|
||||
t.Logf("Second run errors:\n%s", testOutput.Stderr())
|
||||
|
||||
s := testDataStateRead(t, filepath.Join(DefaultDataDir, DefaultStateFilename))
|
||||
if s.StateStore.Empty() {
|
||||
t.Fatal("should have StateStore config")
|
||||
}
|
||||
if !s.Backend.Empty() {
|
||||
t.Fatalf("expected backend to be empty")
|
||||
}
|
||||
|
||||
rawData, ok := mockProvider.MockStates[backend.DefaultStateName].([]byte)
|
||||
if !ok {
|
||||
t.Fatalf("expected %q state to exist in %s: %#v",
|
||||
backend.DefaultStateName,
|
||||
mockProviderAddress,
|
||||
mockProvider.MockStates)
|
||||
}
|
||||
|
||||
data, err := statefile.Read(bytes.NewBuffer(rawData))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedOutputs := map[string]*states.OutputValue{
|
||||
"test": {
|
||||
Addr: addrs.AbsOutputValue{
|
||||
OutputValue: addrs.OutputValue{
|
||||
Name: "test",
|
||||
},
|
||||
},
|
||||
Value: cty.StringVal("test"),
|
||||
},
|
||||
}
|
||||
if diff := cmp.Diff(expectedOutputs, data.State.RootOutputValues); diff != "" {
|
||||
t.Fatalf("unexpected data: %s", diff)
|
||||
}
|
||||
|
||||
if f, err := os.Stat(DefaultStateFilename); err == nil && f.Size() > 0 {
|
||||
t.Fatalf("expected state file to have been removed at %q. Has size %d bytes.", DefaultStateFilename, f.Size())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_backend_to_stateStore_multipleWorkspaces(t *testing.T) {
|
||||
// Create a temporary working directory that is empty
|
||||
td := t.TempDir()
|
||||
|
||||
cfg := `terraform {
|
||||
backend "inmem" {}
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(td, "main.tf"), []byte(cfg), 0644); err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
t.Chdir(td)
|
||||
|
||||
mockProvider := mockPluggableStateStorageProvider()
|
||||
mockProviderAddress := addrs.NewDefaultProvider("test")
|
||||
providerSource, close := newMockProviderSource(t, map[string][]string{
|
||||
"hashicorp/test": {"1.2.3"},
|
||||
})
|
||||
defer close()
|
||||
|
||||
tOverrides := &testingOverrides{
|
||||
Providers: map[addrs.Provider]providers.Factory{
|
||||
mockProviderAddress: providers.FactoryFixed(mockProvider),
|
||||
},
|
||||
}
|
||||
|
||||
{
|
||||
log.Printf("[TRACE] %s: beginning first init with backend", t.Name())
|
||||
// Init
|
||||
ui := cli.NewMockUi()
|
||||
view, done := testView(t)
|
||||
c := &InitCommand{
|
||||
Meta: Meta{
|
||||
Ui: ui,
|
||||
View: view,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
args := []string{
|
||||
"-enable-pluggable-state-storage-experiment=true",
|
||||
}
|
||||
code := c.Run(args)
|
||||
testOutput := done(t)
|
||||
if code != 0 {
|
||||
t.Fatalf("bad: \n%s", testOutput.All())
|
||||
}
|
||||
log.Printf("[TRACE] %s: first init complete", t.Name())
|
||||
t.Logf("First run output:\n%s", testOutput.Stdout())
|
||||
t.Logf("First run errors:\n%s", testOutput.Stderr())
|
||||
}
|
||||
{
|
||||
// run apply to ensure state isn't empty
|
||||
// to bypass edge case handling which causes empty state to stop migration
|
||||
log.Printf("[TRACE] %s: beginning first apply to default workspace with backend", t.Name())
|
||||
|
||||
outputCfg := `output "test" {
|
||||
value = "test"
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(td, "output.tf"), []byte(outputCfg), 0644); err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
ui := cli.NewMockUi()
|
||||
aView, aDone := testView(t)
|
||||
cApply := &ApplyCommand{
|
||||
Meta: Meta{
|
||||
Ui: ui,
|
||||
View: aView,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
aCode := cApply.Run([]string{"-auto-approve"})
|
||||
aTestOutput := aDone(t)
|
||||
if aCode != 0 {
|
||||
t.Fatalf("bad: \n%s", aTestOutput.All())
|
||||
}
|
||||
|
||||
t.Logf("Apply output:\n%s", aTestOutput.Stdout())
|
||||
t.Logf("Apply errors:\n%s", aTestOutput.Stderr())
|
||||
|
||||
data := inmem.ReadState(t, backend.DefaultStateName)
|
||||
expectedOutputs := map[string]*states.OutputValue{
|
||||
"test": {
|
||||
Addr: addrs.AbsOutputValue{
|
||||
OutputValue: addrs.OutputValue{
|
||||
Name: "test",
|
||||
},
|
||||
},
|
||||
Value: cty.StringVal("test"),
|
||||
},
|
||||
}
|
||||
if diff := cmp.Diff(expectedOutputs, data.RootOutputValues); diff != "" {
|
||||
t.Fatalf("unexpected data after apply: %s", diff)
|
||||
}
|
||||
}
|
||||
{
|
||||
ui := cli.NewMockUi()
|
||||
aView, aDone := testView(t)
|
||||
cSelect := &WorkspaceSelectCommand{
|
||||
Meta: Meta{
|
||||
Ui: ui,
|
||||
View: aView,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
sCode := cSelect.Run([]string{"-or-create", "second"})
|
||||
aTestOutput := aDone(t)
|
||||
if sCode != 0 {
|
||||
t.Fatalf("unable to select workspace: \n%s", aTestOutput.All())
|
||||
}
|
||||
t.Logf("Select workspace output:\n%s", aTestOutput.All())
|
||||
}
|
||||
{
|
||||
ui := cli.NewMockUi()
|
||||
aView, aDone := testView(t)
|
||||
cApply := &ApplyCommand{
|
||||
Meta: Meta{
|
||||
Ui: ui,
|
||||
View: aView,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
aCode := cApply.Run([]string{"-auto-approve"})
|
||||
aTestOutput := aDone(t)
|
||||
if aCode != 0 {
|
||||
t.Fatalf("bad: \n%s", aTestOutput.All())
|
||||
}
|
||||
|
||||
t.Logf("Apply output:\n%s", aTestOutput.Stdout())
|
||||
t.Logf("Apply errors:\n%s", aTestOutput.Stderr())
|
||||
|
||||
data := inmem.ReadState(t, "second")
|
||||
expectedOutputs := map[string]*states.OutputValue{
|
||||
"test": {
|
||||
Addr: addrs.AbsOutputValue{
|
||||
OutputValue: addrs.OutputValue{
|
||||
Name: "test",
|
||||
},
|
||||
},
|
||||
Value: cty.StringVal("test"),
|
||||
},
|
||||
}
|
||||
if diff := cmp.Diff(expectedOutputs, data.RootOutputValues); diff != "" {
|
||||
t.Fatalf("unexpected data after apply: %s", diff)
|
||||
}
|
||||
}
|
||||
{
|
||||
log.Printf("[TRACE] %s: beginning second init with state store", t.Name())
|
||||
|
||||
ssCfg := `terraform {
|
||||
required_providers {
|
||||
test = {
|
||||
source = "hashicorp/test"
|
||||
}
|
||||
}
|
||||
state_store "test_store" {
|
||||
provider "test" {}
|
||||
value = "foobar"
|
||||
}
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(td, "main.tf"), []byte(ssCfg), 0644); err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
ui := cli.NewMockUi()
|
||||
view, done := testView(t)
|
||||
c := &InitCommand{
|
||||
Meta: Meta{
|
||||
testingOverrides: tOverrides,
|
||||
ProviderSource: providerSource,
|
||||
Ui: ui,
|
||||
View: view,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-enable-pluggable-state-storage-experiment=true",
|
||||
"-force-copy",
|
||||
"-migrate-state",
|
||||
}
|
||||
code := c.Run(args)
|
||||
testOutput := done(t)
|
||||
if code != 0 {
|
||||
t.Fatalf("second init failed: \n%s", testOutput.All())
|
||||
}
|
||||
log.Printf("[TRACE] %s: second init with state store complete", t.Name())
|
||||
t.Logf("Second init output:\n%s", testOutput.All())
|
||||
|
||||
s := testDataStateRead(t, filepath.Join(DefaultDataDir, DefaultStateFilename))
|
||||
if s.StateStore.Empty() {
|
||||
t.Fatal("should have StateStore config")
|
||||
}
|
||||
if !s.Backend.Empty() {
|
||||
t.Fatalf("expected backend to be empty")
|
||||
}
|
||||
|
||||
expectedOutputs := map[string]*states.OutputValue{
|
||||
"test": {
|
||||
Addr: addrs.AbsOutputValue{
|
||||
OutputValue: addrs.OutputValue{
|
||||
Name: "test",
|
||||
},
|
||||
},
|
||||
Value: cty.StringVal("test"),
|
||||
},
|
||||
}
|
||||
|
||||
expectedWorkspaces := []string{"default", "second"}
|
||||
ws := slices.Sorted(maps.Keys(mockProvider.MockStates))
|
||||
if diff := cmp.Diff(expectedWorkspaces, ws); diff != "" {
|
||||
t.Fatalf("unexpected workspaces: %s", diff)
|
||||
}
|
||||
|
||||
// check default workspace first
|
||||
rawData, ok := mockProvider.MockStates[backend.DefaultStateName].([]byte)
|
||||
if !ok {
|
||||
t.Fatalf("expected %q state to exist in %s: %#v",
|
||||
backend.DefaultStateName,
|
||||
mockProviderAddress,
|
||||
mockProvider.MockStates)
|
||||
}
|
||||
stateData, err := statefile.Read(bytes.NewBuffer(rawData))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if diff := cmp.Diff(expectedOutputs, stateData.State.RootOutputValues); diff != "" {
|
||||
t.Fatalf("unexpected data: %s", diff)
|
||||
}
|
||||
|
||||
// check second workspace
|
||||
rawData2, ok := mockProvider.MockStates["second"].([]byte)
|
||||
if !ok {
|
||||
t.Fatalf("expected %q state to exist in %s: %#v",
|
||||
backend.DefaultStateName,
|
||||
mockProviderAddress,
|
||||
mockProvider.MockStates)
|
||||
}
|
||||
stateData2, err := statefile.Read(bytes.NewBuffer(rawData2))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if diff := cmp.Diff(expectedOutputs, stateData2.State.RootOutputValues); diff != "" {
|
||||
t.Fatalf("unexpected data: %s", diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_cloud_to_stateStore(t *testing.T) {
|
||||
// Create a temporary working directory that is empty
|
||||
td := t.TempDir()
|
||||
|
||||
ts := cloud.TestServerWithHandlers(t, map[string]func(http.ResponseWriter, *http.Request){
|
||||
"/api/v2/organizations/hashicorp/workspaces/test": func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" {
|
||||
w.Write([]byte(`{"data":{"id":"ws-TEST","type":"workspaces","attributes":{"allow-destroy-plan":true,"auto-apply":false,"auto-apply-run-trigger":false,"auto-destroy-activity-duration":null,"auto-destroy-at":null,"auto-destroy-status":null,"inherits-project-auto-destroy":true,"created-at":"2022-06-22T14:24:13.836Z","environment":"default","locked":false,"name":"test","queue-all-runs":false,"speculative-enabled":true,"structured-run-output-enabled":true,"terraform-version":"1.10.0","working-directory":null,"global-remote-state":false,"updated-at":"2026-01-29T15:09:18.075Z","resource-count":0,"apply-duration-average":2000,"plan-duration-average":4000,"policy-check-failures":0,"run-failures":0,"workspace-kpis-runs-count":1,"unarchived-workspace-change-requests-count":0,"latest-change-at":"2026-01-29T15:09:17.200Z","operations":true,"execution-mode":"remote","vcs-repo":null,"vcs-repo-identifier":null,"permissions":{"can-update":true,"can-destroy":true,"can-queue-run":true,"can-read-run":true,"can-read-variable":true,"can-update-variable":true,"can-read-state-versions":true,"can-read-state-outputs":true,"can-create-state-versions":true,"can-queue-apply":true,"can-lock":true,"can-unlock":true,"can-force-unlock":true,"can-read-settings":true,"can-manage-tags":true,"can-manage-run-tasks":true,"can-force-delete":true,"can-manage-assessments":true,"can-manage-ephemeral-workspaces":false,"can-read-assessment-results":true,"can-read-change-requests":false,"can-update-change-requests":false,"can-queue-destroy":true},"actions":{"is-destroyable":true},"description":null,"file-triggers-enabled":true,"trigger-prefixes":[],"trigger-patterns":[],"assessments-enabled":false,"last-assessment-result-at":null,"locked-reason":"","source":"terraform","source-name":null,"source-url":null,"tag-names":[],"setting-overwrites":{"execution-mode":true,"agent-pool":true}},"relationships":{"organization":{"data":{"id":"hashicorp","type":"organizations"}},"current-run":{"data":{"id":"run-TEST","type":"runs"},"links":{"related":"/api/v2/runs/run-TEST"}},"latest-run":{"data":{"id":"run-TEST","type":"runs"},"links":{"related":"/api/v2/runs/run-TEST"}},"outputs":{"data":[{"id":"wsout-TEST","type":"workspace-outputs"}],"links":{"related":"/api/v2/workspaces/ws-TEST/current-state-version-outputs"}},"remote-state-consumers":{"links":{"related":"/api/v2/workspaces/ws-TEST/relationships/remote-state-consumers"}},"current-state-version":{"data":{"id":"sv-TEST","type":"state-versions"},"links":{"related":"/api/v2/workspaces/ws-TEST/current-state-version"}},"current-configuration-version":{"data":{"id":"cv-TEST","type":"configuration-versions"},"links":{"related":"/api/v2/configuration-versions/cv-TEST"}},"agent-pool":{"data":null},"readme":{"data":null},"project":{"data":{"id":"prj-TEST","type":"projects"}},"current-assessment-result":{"data":null},"vars":{"data":[]}},"links":{"self":"/api/v2/organizations/hashicorp/workspaces/test","self-html":"/app/hashicorp/workspaces/test"}}}`))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
},
|
||||
"/api/v2/workspaces/ws-TEST/current-state-version": func(w http.ResponseWriter, r *http.Request) {
|
||||
hostname := r.URL.Hostname()
|
||||
w.Write(fmt.Appendf([]byte{}, `{"data":{"id":"sv-TEST","type":"state-versions","attributes":{"created-at":"2026-01-29T15:09:17.200Z","size":651,"hosted-state-download-url":"%s/api/state-versions/sv-TEST/hosted_state","hosted-json-state-download-url":"%s/api/state-versions/sv-TEST/hosted_json_state","modules":{},"providers":{},"resources-processed":true,"serial":1,"state-version":4,"status":"finalized","terraform-version":"1.10.0","vcs-commit-url":null,"vcs-commit-sha":null,"resources":[],"billable-rum-count":0},"relationships":{"run":{"data":{"id":"run-TEST","type":"runs"}},"rollback-state-version":{"data":null},"created-by":{"data":{"id":"user-TEST","type":"users"},"links":{"self":"/api/v2/users/user-TEST","related":"/api/v2/runs/run-TEST/created-by"}},"workspace":{"data":{"id":"ws-TEST","type":"workspaces"}},"outputs":{"data":[{"id":"wsout-TEST","type":"state-version-outputs"}],"links":{"related":"/api/v2/state-versions/sv-TEST/outputs"}}},"links":{"self":"/api/v2/state-versions/sv-TEST"}}}`, hostname, hostname))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
"/api/state-versions/sv-TEST/hosted_state": func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"version":4,"terraform_version":"1.15.0","serial":1,"lineage":"91adaece-23b3-7bce-0695-5aea537d2fef","outputs":{"test":{"value":"test","type":"string"}},"resources":[],"check_results":null}`))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
})
|
||||
t.Cleanup(ts.Close)
|
||||
mockURL, err := url.Parse(ts.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
backendInit.Init(testDisco(ts))
|
||||
t.Cleanup(func() { backendInit.Init(nil) })
|
||||
|
||||
cfg := fmt.Sprintf(`terraform {
|
||||
cloud {
|
||||
hostname = %q
|
||||
organization = "hashicorp"
|
||||
token = "test-token"
|
||||
workspaces {
|
||||
name = "test"
|
||||
}
|
||||
}
|
||||
}
|
||||
`, mockURL.Host)
|
||||
if err := os.WriteFile(filepath.Join(td, "main.tf"), []byte(cfg), 0644); err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
t.Chdir(td)
|
||||
|
||||
mockProvider := mockPluggableStateStorageProvider()
|
||||
mockProviderAddress := addrs.NewDefaultProvider("test")
|
||||
providerSource, close := newMockProviderSource(t, map[string][]string{
|
||||
"hashicorp/test": {"1.2.3"},
|
||||
})
|
||||
defer close()
|
||||
|
||||
tOverrides := &testingOverrides{
|
||||
Providers: map[addrs.Provider]providers.Factory{
|
||||
mockProviderAddress: providers.FactoryFixed(mockProvider),
|
||||
},
|
||||
}
|
||||
|
||||
{
|
||||
log.Printf("[TRACE] %s: beginning first init with backend", t.Name())
|
||||
// Init
|
||||
ui := cli.NewMockUi()
|
||||
view, done := testView(t)
|
||||
c := &InitCommand{
|
||||
Meta: Meta{
|
||||
Services: testDisco(ts),
|
||||
Ui: ui,
|
||||
View: view,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
args := []string{
|
||||
"-enable-pluggable-state-storage-experiment=true",
|
||||
}
|
||||
code := c.Run(args)
|
||||
testOutput := done(t)
|
||||
if code != 0 {
|
||||
t.Fatalf("bad: \n%s", testOutput.All())
|
||||
}
|
||||
log.Printf("[TRACE] %s: first init complete", t.Name())
|
||||
t.Logf("First run output:\n%s", testOutput.Stdout())
|
||||
t.Logf("First run errors:\n%s", testOutput.Stderr())
|
||||
}
|
||||
{
|
||||
log.Printf("[TRACE] %s: beginning second init with state store", t.Name())
|
||||
|
||||
ssCfg := `terraform {
|
||||
required_providers {
|
||||
test = {
|
||||
source = "hashicorp/test"
|
||||
}
|
||||
}
|
||||
state_store "test_store" {
|
||||
provider "test" {}
|
||||
value = "foobar"
|
||||
}
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(td, "main.tf"), []byte(ssCfg), 0644); err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
ui := cli.NewMockUi()
|
||||
view, done := testView(t)
|
||||
c := &InitCommand{
|
||||
Meta: Meta{
|
||||
Services: testDisco(ts),
|
||||
testingOverrides: tOverrides,
|
||||
ProviderSource: providerSource,
|
||||
Ui: ui,
|
||||
View: view,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-enable-pluggable-state-storage-experiment=true",
|
||||
}
|
||||
code := c.Run(args)
|
||||
testOutput := done(t)
|
||||
if code == 0 {
|
||||
t.Fatalf("expected migration from cloud to fail: \n%s", testOutput.Stdout())
|
||||
}
|
||||
log.Printf("[TRACE] %s: second init with state store complete", t.Name())
|
||||
expectedMsg := "Migrating state from HCP Terraform or Terraform Enterprise to another backend is not \nyet implemented."
|
||||
if !strings.Contains(testOutput.Stderr(), expectedMsg) {
|
||||
t.Fatalf("expected error message %q not found: \n%s", expectedMsg, testOutput.Stderr())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newMockProviderSource is a helper to succinctly construct a mock provider
|
||||
// source that contains a set of packages matching the given provider versions
|
||||
// that are available for installation (from temporary local files).
|
||||
|
|
|
|||
|
|
@ -1159,11 +1159,16 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di
|
|||
stateStoreConfig.Provider.Name,
|
||||
stateStoreConfig.ProviderAddr,
|
||||
)
|
||||
return nil, diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Not implemented yet",
|
||||
Detail: "Migration from backend to state store is not implemented yet",
|
||||
})
|
||||
|
||||
if !opts.Init {
|
||||
initReason := fmt.Sprintf("Migrating from backend %q to state store %q in provider %s (%q)",
|
||||
s.Backend.Type, stateStoreConfig.Type,
|
||||
stateStoreConfig.Provider.Name, stateStoreConfig.ProviderAddr)
|
||||
diags = diags.Append(errBackendInitDiag(initReason))
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
return m.backend_to_stateStore(s.Backend, sMgr, stateStoreConfig, cHash, opts)
|
||||
|
||||
// Potentially changing a backend configuration
|
||||
case backendConfig != nil && !s.Backend.Empty() &&
|
||||
|
|
@ -1958,6 +1963,198 @@ func (m *Meta) backend(configPath string, viewType arguments.ViewType) (backendr
|
|||
return be, diags
|
||||
}
|
||||
|
||||
func (m *Meta) backend_to_stateStore(bcs *workdir.BackendConfigState, sMgr *clistate.LocalState, c *configs.StateStore, cHash int, opts *BackendOpts) (backend.Backend, tfdiags.Diagnostics) {
|
||||
var diags tfdiags.Diagnostics
|
||||
|
||||
vt := arguments.ViewJSON
|
||||
// Set default viewtype if none was set as the StateLocker needs to know exactly
|
||||
// what viewType we want to have.
|
||||
if opts == nil || opts.ViewType != vt {
|
||||
vt = arguments.ViewHuman
|
||||
}
|
||||
|
||||
s := sMgr.State()
|
||||
|
||||
cloudMode := cloud.DetectConfigChangeType(bcs, nil, false)
|
||||
diags = diags.Append(m.assertSupportedCloudInitOptions(cloudMode))
|
||||
if diags.HasErrors() {
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
view := views.NewInit(vt, m.View)
|
||||
if cloudMode == cloud.ConfigMigrationOut {
|
||||
view.Output(views.BackendCloudMigrateStateStoreMessage, c.Type)
|
||||
} else {
|
||||
view.Output(views.BackendMigrateStateStoreMessage, bcs.Type, c.Type)
|
||||
}
|
||||
|
||||
// Initialize the configured backend
|
||||
b, moreDiags := m.savedBackend(sMgr)
|
||||
diags = diags.Append(moreDiags)
|
||||
if moreDiags.HasErrors() {
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
// Get the state store as an instance of backend.Backend
|
||||
ssBackend, storeConfigVal, providerConfigVal, moreDiags := m.stateStoreInitFromConfig(c, opts.Locks)
|
||||
diags = diags.Append(moreDiags)
|
||||
if diags.HasErrors() {
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
// Perform the migration
|
||||
err := m.backendMigrateState(&backendMigrateOpts{
|
||||
SourceType: bcs.Type,
|
||||
DestinationType: c.Type,
|
||||
Source: b,
|
||||
Destination: ssBackend,
|
||||
ViewType: vt,
|
||||
})
|
||||
if err != nil {
|
||||
diags = diags.Append(err)
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
rDiags := m.removeLocalState(bcs.Type, b)
|
||||
if rDiags.HasErrors() {
|
||||
diags = diags.Append(rDiags)
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
if m.stateLock {
|
||||
view := views.NewStateLocker(vt, m.View)
|
||||
stateLocker := clistate.NewLocker(m.stateLockTimeout, view)
|
||||
if err := stateLocker.Lock(sMgr, "init is initializing state_store first time"); err != nil {
|
||||
diags = diags.Append(fmt.Errorf("Error locking state: %s", err))
|
||||
return nil, diags
|
||||
}
|
||||
defer stateLocker.Unlock()
|
||||
}
|
||||
|
||||
// Store the state_store metadata in our saved state location
|
||||
|
||||
var pVersion *version.Version // This will remain nil for builtin providers or unmanaged providers.
|
||||
if c.ProviderAddr.IsBuiltIn() {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagWarning,
|
||||
Summary: "State storage is using a builtin provider",
|
||||
Detail: "Terraform is using a builtin provider for initializing state storage. Terraform will be less able to detect when state migrations are required in future init commands.",
|
||||
})
|
||||
} else {
|
||||
isReattached, err := reattach.IsProviderReattached(c.ProviderAddr, os.Getenv("TF_REATTACH_PROVIDERS"))
|
||||
if err != nil {
|
||||
diags = diags.Append(fmt.Errorf("Unable to determine if state storage provider is reattached while initializing state store for the first time. This is a bug in Terraform and should be reported: %w", err))
|
||||
return nil, diags
|
||||
}
|
||||
if isReattached {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagWarning,
|
||||
Summary: "State storage provider is not managed by Terraform",
|
||||
Detail: "Terraform is using a provider supplied via TF_REATTACH_PROVIDERS for initializing state storage. Terraform will be less able to detect when state migrations are required in future init commands.",
|
||||
})
|
||||
} else {
|
||||
// The provider is not built in and is being managed by Terraform
|
||||
// This is the most common scenario, by far.
|
||||
var vDiags tfdiags.Diagnostics
|
||||
pVersion, vDiags = getStateStorageProviderVersion(c, opts.Locks)
|
||||
diags = diags.Append(vDiags)
|
||||
if vDiags.HasErrors() {
|
||||
return nil, diags
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the stored metadata
|
||||
s.Backend = nil
|
||||
s.StateStore = &workdir.StateStoreConfigState{
|
||||
Type: c.Type,
|
||||
Hash: uint64(cHash),
|
||||
Provider: &workdir.ProviderConfigState{
|
||||
Source: &c.ProviderAddr,
|
||||
Version: pVersion,
|
||||
},
|
||||
}
|
||||
err = s.StateStore.SetConfig(storeConfigVal, ssBackend.ConfigSchema())
|
||||
if err != nil {
|
||||
diags = diags.Append(fmt.Errorf("Failed to set state store configuration: %w", err))
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
// We need to briefly convert away from backend.Backend interface to use the method
|
||||
// for accessing the provider schema. In this method we _always_ expect the concrete value
|
||||
// to be backendPluggable.Pluggable.
|
||||
plug := ssBackend.(*backendPluggable.Pluggable)
|
||||
err = s.StateStore.Provider.SetConfig(providerConfigVal, plug.ProviderSchema())
|
||||
if err != nil {
|
||||
diags = diags.Append(fmt.Errorf("Failed to set state store provider configuration: %w", err))
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
// Update backend state file
|
||||
if err := sMgr.WriteState(s); err != nil {
|
||||
diags = diags.Append(errBackendWriteSavedDiag(err))
|
||||
return nil, diags
|
||||
}
|
||||
if err := sMgr.PersistState(); err != nil {
|
||||
diags = diags.Append(errBackendWriteSavedDiag(err))
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
return b, diags
|
||||
}
|
||||
|
||||
func (m *Meta) removeLocalState(backendType string, b backend.Backend) tfdiags.Diagnostics {
|
||||
var diags tfdiags.Diagnostics
|
||||
|
||||
if backendType != "local" {
|
||||
return diags
|
||||
}
|
||||
|
||||
workspaces, wDiags := b.Workspaces()
|
||||
if wDiags.HasErrors() {
|
||||
diags = diags.Append(&errBackendLocalRead{wDiags.Err()})
|
||||
return diags
|
||||
}
|
||||
|
||||
var localStates []statemgr.Full
|
||||
for _, workspace := range workspaces {
|
||||
localState, sDiags := b.StateMgr(workspace)
|
||||
if sDiags.HasErrors() {
|
||||
diags = diags.Append(&errBackendLocalRead{sDiags.Err()})
|
||||
return diags
|
||||
}
|
||||
if err := localState.RefreshState(); err != nil {
|
||||
diags = diags.Append(&errBackendLocalRead{err})
|
||||
return diags
|
||||
}
|
||||
|
||||
// We only care about non-empty states.
|
||||
if localS := localState.State(); !localS.Empty() {
|
||||
log.Printf("[TRACE] Meta.Backend: will need to migrate workspace states because of existing %q workspace", workspace)
|
||||
localStates = append(localStates, localState)
|
||||
} else {
|
||||
log.Printf("[TRACE] Meta.Backend: ignoring local %q workspace because its state is empty", workspace)
|
||||
}
|
||||
}
|
||||
|
||||
if len(localStates) > 0 {
|
||||
log.Printf("[TRACE] Meta.removeLocalState: removing old state snapshots (%d) from old backend", len(localStates))
|
||||
for idx, localState := range localStates {
|
||||
// We always delete the local state, unless that was our new state too.
|
||||
if err := localState.WriteState(nil); err != nil {
|
||||
diags = diags.Append(&errBackendMigrateLocalDelete{err})
|
||||
return diags
|
||||
}
|
||||
if err := localState.PersistState(nil); err != nil {
|
||||
diags = diags.Append(&errBackendMigrateLocalDelete{err})
|
||||
return diags
|
||||
}
|
||||
log.Printf("[DEBUG] Meta.removeLocalState: deleted local state for workspace %q", workspaces[idx])
|
||||
}
|
||||
}
|
||||
return diags
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------
|
||||
// State Store Config Scenarios
|
||||
// The functions below cover handling all the various scenarios that
|
||||
|
|
|
|||
|
|
@ -2262,56 +2262,6 @@ func Test_determineInitReason(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// Changing from using backend to state_store
|
||||
//
|
||||
// TODO(SarahFrench/radeksimko): currently this test only confirms that we're hitting the switch
|
||||
// case for this scenario, and will need to be updated when that init feature is implemented.
|
||||
func TestMetaBackend_configuredBackendToStateStore(t *testing.T) {
|
||||
td := t.TempDir()
|
||||
testCopyDir(t, testFixturePath("backend-to-state-store"), td)
|
||||
t.Chdir(td)
|
||||
|
||||
mock := testStateStoreMock(t)
|
||||
|
||||
// Setup the meta
|
||||
m := testMetaBackend(t, nil)
|
||||
m.testingOverrides = metaOverridesForProvider(mock)
|
||||
m.AllowExperimentalFeatures = true
|
||||
|
||||
// Get the state store's config
|
||||
mod, loadDiags := m.loadSingleModule(td)
|
||||
if loadDiags.HasErrors() {
|
||||
t.Fatalf("unexpected error when loading test config: %s", loadDiags.Err())
|
||||
}
|
||||
|
||||
// Get the operations backend
|
||||
locks := depsfile.NewLocks()
|
||||
providerAddr := addrs.MustParseProviderSourceString("registry.terraform.io/hashicorp/test")
|
||||
constraint, err := providerreqs.ParseVersionConstraints(">1.0.0")
|
||||
if err != nil {
|
||||
t.Fatalf("test setup failed when making constraint: %s", err)
|
||||
}
|
||||
locks.SetProvider(
|
||||
providerAddr,
|
||||
versions.MustParseVersion("9.9.9"),
|
||||
constraint,
|
||||
[]providerreqs.Hash{""},
|
||||
)
|
||||
_, beDiags := m.Backend(&BackendOpts{
|
||||
Init: true,
|
||||
StateStoreConfig: mod.StateStore,
|
||||
ProviderRequirements: mod.ProviderRequirements,
|
||||
Locks: locks,
|
||||
})
|
||||
if !beDiags.HasErrors() {
|
||||
t.Fatal("expected an error to be returned during partial implementation of PSS")
|
||||
}
|
||||
wantErr := "Migration from backend to state store is not implemented yet"
|
||||
if !strings.Contains(beDiags.Err().Error(), wantErr) {
|
||||
t.Fatalf("expected the returned error to contain %q, but got: %s", wantErr, beDiags.Err())
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that using variables results in an error
|
||||
func TestMetaBackend_configureStateStoreVariableUse(t *testing.T) {
|
||||
wantErr := "Variables not allowed"
|
||||
|
|
|
|||
|
|
@ -303,6 +303,14 @@ var MessageRegistry map[InitMessageCode]InitMessage = map[InitMessageCode]InitMe
|
|||
HumanValue: "Migrating from HCP Terraform or Terraform Enterprise to local state.",
|
||||
JSONValue: "Migrating from HCP Terraform or Terraform Enterprise to local state.",
|
||||
},
|
||||
"backend_cloud_migrate_state_store": {
|
||||
HumanValue: "Migrating from HCP Terraform Terraform Enterprise to state store %q.",
|
||||
JSONValue: "Migrating from HCP Terraform Terraform Enterprise to state store %q.",
|
||||
},
|
||||
"backend_migrate_state_store": {
|
||||
HumanValue: "Migrating from backend %q to state store %q.",
|
||||
JSONValue: "Migrating from backend %q to state store %q.",
|
||||
},
|
||||
"state_store_migrate_local": {
|
||||
HumanValue: stateMigrateLocalHuman,
|
||||
JSONValue: stateMigrateLocalJSON,
|
||||
|
|
@ -365,6 +373,10 @@ const (
|
|||
BackendMigrateLocalMessage InitMessageCode = "backend_migrate_local"
|
||||
// BackendCloudMigrateLocalMessage indicates migration from cloud to local
|
||||
BackendCloudMigrateLocalMessage InitMessageCode = "backend_cloud_migrate_local"
|
||||
// BackendCloudMigrateStateStoreMessage indicates migration from cloud to a state store
|
||||
BackendCloudMigrateStateStoreMessage InitMessageCode = "backend_cloud_migrate_state_store"
|
||||
// BackendMigrateStateStoreMessage indicates migration from a backend to a state store
|
||||
BackendMigrateStateStoreMessage InitMessageCode = "backend_migrate_state_store"
|
||||
// StateMigrateLocalMessage indicates migration from state store to local
|
||||
StateMigrateLocalMessage InitMessageCode = "state_store_migrate_local"
|
||||
// FindingMatchingVersionMessage indicates that Terraform is looking for a provider version that matches the constraint during installation
|
||||
|
|
|
|||
Loading…
Reference in a new issue