mirror of
https://github.com/opentofu/opentofu.git
synced 2025-01-08 07:03:16 -06:00
cdb80f68a8
Fix checksum issue with remote state If we read a state file with "null" objects in a module and they become initialized to an empty map the state file may be written out with empty objects rather than "null", changing the checksum. If we can detect this, increment the serial number to prevent a conflict in atlas. Our fakeAtlas test server now needs to decode the state directly rather than using the ReadState function, so as to be able to read the state unaltered. The terraform.State data structures have initialization spread out throughout the package. More thoroughly initialize State during ReadState, and add a call to init() during WriteState as another normalization safeguard. Expose State.init through an exported Init() method, so that a new State can be completely realized outside of the terraform package. Additionally, the internal init now completely walks all internal state structures ensuring that all maps and slices are initialized. While it was mentioned before that the `init()` methods are problematic with too many call sites, expanding this out better exposes the entry points that will need to be refactored later for improved concurrency handling. The State structures had a mix of `omitempty` fields. Remove omitempty for all maps and slices as part of this normalization process. Make Lineage mandatory, which is now explicitly set in some tests.
386 lines
9.0 KiB
Go
386 lines
9.0 KiB
Go
package remote
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/md5"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hashicorp/terraform/helper/acctest"
|
|
"github.com/hashicorp/terraform/terraform"
|
|
)
|
|
|
|
func TestAtlasClient_impl(t *testing.T) {
|
|
var _ Client = new(AtlasClient)
|
|
}
|
|
|
|
func TestAtlasClient(t *testing.T) {
|
|
acctest.RemoteTestPrecheck(t)
|
|
|
|
token := os.Getenv("ATLAS_TOKEN")
|
|
if token == "" {
|
|
t.Skipf("skipping, ATLAS_TOKEN must be set")
|
|
}
|
|
|
|
client, err := atlasFactory(map[string]string{
|
|
"access_token": token,
|
|
"name": "hashicorp/test-remote-state",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("bad: %s", err)
|
|
}
|
|
|
|
testClient(t, client)
|
|
}
|
|
|
|
func TestAtlasClient_noRetryOnBadCerts(t *testing.T) {
|
|
acctest.RemoteTestPrecheck(t)
|
|
|
|
client, err := atlasFactory(map[string]string{
|
|
"access_token": "NOT_REQUIRED",
|
|
"name": "hashicorp/test-remote-state",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("bad: %s", err)
|
|
}
|
|
|
|
ac := client.(*AtlasClient)
|
|
// trigger the AtlasClient to build the http client and assign HTTPClient
|
|
httpClient, err := ac.http()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// remove the CA certs from the client
|
|
brokenCfg := &tls.Config{
|
|
RootCAs: new(x509.CertPool),
|
|
}
|
|
httpClient.HTTPClient.Transport.(*http.Transport).TLSClientConfig = brokenCfg
|
|
|
|
// Instrument CheckRetry to make sure we didn't retry
|
|
retries := 0
|
|
oldCheck := httpClient.CheckRetry
|
|
httpClient.CheckRetry = func(resp *http.Response, err error) (bool, error) {
|
|
if retries > 0 {
|
|
t.Fatal("retried after certificate error")
|
|
}
|
|
retries++
|
|
return oldCheck(resp, err)
|
|
}
|
|
|
|
_, err = client.Get()
|
|
if err != nil {
|
|
if err, ok := err.(*url.Error); ok {
|
|
if _, ok := err.Err.(x509.UnknownAuthorityError); ok {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
t.Fatalf("expected x509.UnknownAuthorityError, got %v", err)
|
|
}
|
|
|
|
func TestAtlasClient_ReportedConflictEqualStates(t *testing.T) {
|
|
fakeAtlas := newFakeAtlas(t, testStateModuleOrderChange)
|
|
srv := fakeAtlas.Server()
|
|
defer srv.Close()
|
|
client, err := atlasFactory(map[string]string{
|
|
"access_token": "sometoken",
|
|
"name": "someuser/some-test-remote-state",
|
|
"address": srv.URL,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("err: %s", err)
|
|
}
|
|
|
|
state, err := terraform.ReadState(bytes.NewReader(testStateModuleOrderChange))
|
|
if err != nil {
|
|
t.Fatalf("err: %s", err)
|
|
}
|
|
|
|
var stateJson bytes.Buffer
|
|
if err := terraform.WriteState(state, &stateJson); err != nil {
|
|
t.Fatalf("err: %s", err)
|
|
}
|
|
if err := client.Put(stateJson.Bytes()); err != nil {
|
|
t.Fatalf("err: %s", err)
|
|
}
|
|
}
|
|
|
|
func TestAtlasClient_NoConflict(t *testing.T) {
|
|
fakeAtlas := newFakeAtlas(t, testStateSimple)
|
|
srv := fakeAtlas.Server()
|
|
defer srv.Close()
|
|
client, err := atlasFactory(map[string]string{
|
|
"access_token": "sometoken",
|
|
"name": "someuser/some-test-remote-state",
|
|
"address": srv.URL,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("err: %s", err)
|
|
}
|
|
|
|
state, err := terraform.ReadState(bytes.NewReader(testStateSimple))
|
|
if err != nil {
|
|
t.Fatalf("err: %s", err)
|
|
}
|
|
|
|
fakeAtlas.NoConflictAllowed(true)
|
|
|
|
var stateJson bytes.Buffer
|
|
if err := terraform.WriteState(state, &stateJson); err != nil {
|
|
t.Fatalf("err: %s", err)
|
|
}
|
|
|
|
if err := client.Put(stateJson.Bytes()); err != nil {
|
|
t.Fatalf("err: %s", err)
|
|
}
|
|
}
|
|
|
|
func TestAtlasClient_LegitimateConflict(t *testing.T) {
|
|
fakeAtlas := newFakeAtlas(t, testStateSimple)
|
|
srv := fakeAtlas.Server()
|
|
defer srv.Close()
|
|
client, err := atlasFactory(map[string]string{
|
|
"access_token": "sometoken",
|
|
"name": "someuser/some-test-remote-state",
|
|
"address": srv.URL,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("err: %s", err)
|
|
}
|
|
|
|
state, err := terraform.ReadState(bytes.NewReader(testStateSimple))
|
|
if err != nil {
|
|
t.Fatalf("err: %s", err)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
terraform.WriteState(state, &buf)
|
|
|
|
// Changing the state but not the serial. Should generate a conflict.
|
|
state.RootModule().Outputs["drift"] = &terraform.OutputState{
|
|
Type: "string",
|
|
Sensitive: false,
|
|
Value: "happens",
|
|
}
|
|
|
|
var stateJson bytes.Buffer
|
|
if err := terraform.WriteState(state, &stateJson); err != nil {
|
|
t.Fatalf("err: %s", err)
|
|
}
|
|
if err := client.Put(stateJson.Bytes()); err == nil {
|
|
t.Fatal("Expected error from state conflict, got none.")
|
|
}
|
|
}
|
|
|
|
func TestAtlasClient_UnresolvableConflict(t *testing.T) {
|
|
fakeAtlas := newFakeAtlas(t, testStateSimple)
|
|
|
|
// Something unexpected causes Atlas to conflict in a way that we can't fix.
|
|
fakeAtlas.AlwaysConflict(true)
|
|
|
|
srv := fakeAtlas.Server()
|
|
defer srv.Close()
|
|
client, err := atlasFactory(map[string]string{
|
|
"access_token": "sometoken",
|
|
"name": "someuser/some-test-remote-state",
|
|
"address": srv.URL,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("err: %s", err)
|
|
}
|
|
|
|
state, err := terraform.ReadState(bytes.NewReader(testStateSimple))
|
|
if err != nil {
|
|
t.Fatalf("err: %s", err)
|
|
}
|
|
|
|
var stateJson bytes.Buffer
|
|
if err := terraform.WriteState(state, &stateJson); err != nil {
|
|
t.Fatalf("err: %s", err)
|
|
}
|
|
doneCh := make(chan struct{})
|
|
go func() {
|
|
defer close(doneCh)
|
|
if err := client.Put(stateJson.Bytes()); err == nil {
|
|
t.Fatal("Expected error from state conflict, got none.")
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case <-doneCh:
|
|
// OK
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatalf("Timed out after 500ms, probably because retrying infinitely.")
|
|
}
|
|
}
|
|
|
|
// Stub Atlas HTTP API for a given state JSON string; does checksum-based
|
|
// conflict detection equivalent to Atlas's.
|
|
type fakeAtlas struct {
|
|
state []byte
|
|
t *testing.T
|
|
|
|
// Used to test that we only do the special conflict handling retry once.
|
|
alwaysConflict bool
|
|
|
|
// Used to fail the test immediately if a conflict happens.
|
|
noConflictAllowed bool
|
|
}
|
|
|
|
func newFakeAtlas(t *testing.T, state []byte) *fakeAtlas {
|
|
return &fakeAtlas{
|
|
state: state,
|
|
t: t,
|
|
}
|
|
}
|
|
|
|
func (f *fakeAtlas) Server() *httptest.Server {
|
|
return httptest.NewServer(http.HandlerFunc(f.handler))
|
|
}
|
|
|
|
func (f *fakeAtlas) CurrentState() *terraform.State {
|
|
// we read the state manually here, because terraform may alter state
|
|
// during read
|
|
currentState := &terraform.State{}
|
|
err := json.Unmarshal(f.state, currentState)
|
|
if err != nil {
|
|
f.t.Fatalf("err: %s", err)
|
|
}
|
|
return currentState
|
|
}
|
|
|
|
func (f *fakeAtlas) CurrentSerial() int64 {
|
|
return f.CurrentState().Serial
|
|
}
|
|
|
|
func (f *fakeAtlas) CurrentSum() [md5.Size]byte {
|
|
return md5.Sum(f.state)
|
|
}
|
|
|
|
func (f *fakeAtlas) AlwaysConflict(b bool) {
|
|
f.alwaysConflict = b
|
|
}
|
|
|
|
func (f *fakeAtlas) NoConflictAllowed(b bool) {
|
|
f.noConflictAllowed = b
|
|
}
|
|
|
|
func (f *fakeAtlas) handler(resp http.ResponseWriter, req *http.Request) {
|
|
// access tokens should only be sent as a header
|
|
if req.FormValue("access_token") != "" {
|
|
http.Error(resp, "access_token in request params", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Header.Get(atlasTokenHeader) == "" {
|
|
http.Error(resp, "missing access token", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
switch req.Method {
|
|
case "GET":
|
|
// Respond with the current stored state.
|
|
resp.Header().Set("Content-Type", "application/json")
|
|
resp.Write(f.state)
|
|
case "PUT":
|
|
var buf bytes.Buffer
|
|
buf.ReadFrom(req.Body)
|
|
sum := md5.Sum(buf.Bytes())
|
|
|
|
// we read the state manually here, because terraform may alter state
|
|
// during read
|
|
state := &terraform.State{}
|
|
err := json.Unmarshal(buf.Bytes(), state)
|
|
if err != nil {
|
|
f.t.Fatalf("err: %s", err)
|
|
}
|
|
|
|
conflict := f.CurrentSerial() == state.Serial && f.CurrentSum() != sum
|
|
conflict = conflict || f.alwaysConflict
|
|
if conflict {
|
|
if f.noConflictAllowed {
|
|
f.t.Fatal("Got conflict when NoConflictAllowed was set.")
|
|
}
|
|
http.Error(resp, "Conflict", 409)
|
|
} else {
|
|
f.state = buf.Bytes()
|
|
resp.WriteHeader(200)
|
|
}
|
|
}
|
|
}
|
|
|
|
// This is a tfstate file with the module order changed, which is a structural
|
|
// but not a semantic difference. Terraform will sort these modules as it
|
|
// loads the state.
|
|
var testStateModuleOrderChange = []byte(
|
|
`{
|
|
"version": 3,
|
|
"serial": 1,
|
|
"modules": [
|
|
{
|
|
"path": [
|
|
"root",
|
|
"child2",
|
|
"grandchild"
|
|
],
|
|
"outputs": {
|
|
"foo": {
|
|
"sensitive": false,
|
|
"type": "string",
|
|
"value": "bar"
|
|
}
|
|
},
|
|
"resources": null
|
|
},
|
|
{
|
|
"path": [
|
|
"root",
|
|
"child1",
|
|
"grandchild"
|
|
],
|
|
"outputs": {
|
|
"foo": {
|
|
"sensitive": false,
|
|
"type": "string",
|
|
"value": "bar"
|
|
}
|
|
},
|
|
"resources": null
|
|
}
|
|
]
|
|
}
|
|
`)
|
|
|
|
var testStateSimple = []byte(
|
|
`{
|
|
"version": 3,
|
|
"serial": 2,
|
|
"lineage": "c00ad9ac-9b35-42fe-846e-b06f0ef877e9",
|
|
"modules": [
|
|
{
|
|
"path": [
|
|
"root"
|
|
],
|
|
"outputs": {
|
|
"foo": {
|
|
"sensitive": false,
|
|
"type": "string",
|
|
"value": "bar"
|
|
}
|
|
},
|
|
"resources": {},
|
|
"depends_on": []
|
|
}
|
|
]
|
|
}
|
|
`)
|