Skip to content

Commit dda992b

Browse files
authored
Save tofu lockfile to preserve provider choices (#184)
This PR adds the Tofu [lockfile](https://opentofu.org/docs/language/files/dependency-lock/) to the ModuleState resource. Now whenever we push/pull state we also push/pull the lockfile. Using the lockfile will ensure that every time we run tofu we will always select the same version of the providers. closes #115
1 parent d38d161 commit dda992b

File tree

4 files changed

+84
-31
lines changed

4 files changed

+84
-31
lines changed

pkg/modprovider/module_component.go

+7-9
Original file line numberDiff line numberDiff line change
@@ -151,14 +151,14 @@ func NewModuleComponentResource(
151151
}
152152

153153
var moduleOutputs resource.PropertyMap
154-
err = tf.Init(ctx.Context())
154+
err = tf.PushStateAndLockFile(ctx.Context(), state.rawState, state.rawLockFile)
155155
if err != nil {
156-
return nil, nil, fmt.Errorf("Init failed: %w", err)
156+
return nil, nil, fmt.Errorf("PushStateAndLockFile failed: %w", err)
157157
}
158158

159-
err = tf.PushState(ctx.Context(), state.rawState)
159+
err = tf.Init(ctx.Context())
160160
if err != nil {
161-
return nil, nil, fmt.Errorf("PushState failed: %w", err)
161+
return nil, nil, fmt.Errorf("Init failed: %w", err)
162162
}
163163

164164
var childResources []*childResource
@@ -219,14 +219,12 @@ func NewModuleComponentResource(
219219

220220
planStore.SetState(urn, tfState)
221221

222-
rawState, ok, err := tf.PullState(ctx.Context())
222+
rawState, rawLockFile, err := tf.PullStateAndLockFile(ctx.Context())
223223
if err != nil {
224-
return nil, nil, fmt.Errorf("PullState failed: %w", err)
225-
}
226-
if !ok {
227-
return nil, nil, errors.New("PullState did not find state")
224+
return nil, nil, fmt.Errorf("PullStateAndLockFile failed: %w", err)
228225
}
229226
state.rawState = rawState
227+
state.rawLockFile = rawLockFile
230228

231229
// Make sure child resources can read updated state.
232230
stateStore.SetNewState(urn, state)

pkg/modprovider/state.go

+18-14
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ package modprovider
1717
import (
1818
"bytes"
1919
"context"
20-
"errors"
2120
"fmt"
2221

2322
"google.golang.org/protobuf/types/known/emptypb"
@@ -45,10 +44,12 @@ const (
4544
type moduleState struct {
4645
// Intended to store contents of TF state exactly.
4746
rawState []byte
47+
// Intended to store contents of TF lock file exactly.
48+
rawLockFile []byte
4849
}
4950

5051
func (ms *moduleState) Equal(other moduleState) bool {
51-
return bytes.Equal(ms.rawState, other.rawState)
52+
return bytes.Equal(ms.rawState, other.rawState) && bytes.Equal(ms.rawLockFile, other.rawLockFile)
5253
}
5354

5455
func (ms *moduleState) Unmarshal(s *structpb.Struct) {
@@ -63,6 +64,10 @@ func (ms *moduleState) Unmarshal(s *structpb.Struct) {
6364
if !ok {
6465
return // empty
6566
}
67+
if lock, ok := props["lock"]; ok {
68+
lockString := lock.StringValue()
69+
ms.rawLockFile = []byte(lockString)
70+
}
6671
stateString := state.StringValue()
6772
ms.rawState = []byte(stateString)
6873
}
@@ -71,6 +76,7 @@ func (ms *moduleState) Marshal() *structpb.Struct {
7176
state := resource.PropertyMap{
7277
// TODO[pulumi/pulumi-terraform-module#148] store as JSON-y map
7378
"state": resource.MakeSecret(resource.NewStringProperty(string(ms.rawState))),
79+
"lock": resource.NewStringProperty(string(ms.rawLockFile)),
7480
}
7581

7682
value, err := plugin.MarshalProperties(state, plugin.MarshalOptions{
@@ -281,14 +287,14 @@ func (h *moduleStateHandler) Delete(
281287
return nil, fmt.Errorf("Seed file generation failed: %w", err)
282288
}
283289

284-
err = tf.Init(ctx)
290+
err = tf.PushStateAndLockFile(ctx, oldState.rawState, oldState.rawLockFile)
285291
if err != nil {
286-
return nil, fmt.Errorf("Init failed: %w", err)
292+
return nil, fmt.Errorf("PushStateAndLockFile failed: %w", err)
287293
}
288294

289-
err = tf.PushState(ctx, oldState.rawState)
295+
err = tf.Init(ctx)
290296
if err != nil {
291-
return nil, fmt.Errorf("PushState failed: %w", err)
297+
return nil, fmt.Errorf("Init failed: %w", err)
292298
}
293299

294300
err = tf.Destroy(ctx)
@@ -343,9 +349,9 @@ func (h *moduleStateHandler) Read(
343349
oldState := moduleState{}
344350
oldState.Unmarshal(req.GetProperties())
345351

346-
err = tf.PushState(ctx, oldState.rawState)
352+
err = tf.PushStateAndLockFile(ctx, oldState.rawState, oldState.rawLockFile)
347353
if err != nil {
348-
return nil, fmt.Errorf("PushState failed: %w", err)
354+
return nil, fmt.Errorf("PushStateAndLockFile failed: %w", err)
349355
}
350356

351357
plan, err := tf.PlanRefreshOnly(ctx)
@@ -365,16 +371,14 @@ func (h *moduleStateHandler) Read(
365371
// Child resources need to access the state in their Read() implementation.
366372
h.planStore.SetState(modUrn, state)
367373

368-
rawState, ok, err := tf.PullState(ctx)
374+
rawState, rawLockFile, err := tf.PullStateAndLockFile(ctx)
369375
if err != nil {
370-
return nil, fmt.Errorf("PullState failed: %w", err)
371-
}
372-
if !ok {
373-
return nil, errors.New("PullState did not find state")
376+
return nil, fmt.Errorf("PullStateAndLockFile failed: %w", err)
374377
}
375378

376379
refreshedModuleState := moduleState{
377-
rawState: rawState,
380+
rawState: rawState,
381+
rawLockFile: rawLockFile,
378382
}
379383

380384
// The engine will call Diff() after Read(), and it would expect this to be populated.

pkg/tfsandbox/state.go

+57-5
Original file line numberDiff line numberDiff line change
@@ -23,23 +23,50 @@ import (
2323
)
2424

2525
const defaultStateFile = "terraform.tfstate"
26+
const defaultLockFile = ".terraform.lock.hcl"
2627

27-
func (t *Tofu) PullState(_ context.Context) (json.RawMessage, bool, error) {
28+
// PullStateAndLockFile reads the state and lock file from the Tofu working directory.
29+
// If the lock file is not present, it returns nil for the lock file and no error.
30+
// It's possible for modules to not have any providers which would mean no lock file
31+
func (t *Tofu) PullStateAndLockFile(_ context.Context) (state json.RawMessage, lockFile []byte, err error) {
32+
state, err = t.pullState(context.Background())
33+
if err != nil {
34+
return nil, nil, err
35+
}
36+
lockFile, err = t.pullLockFile(context.Background())
37+
if err != nil {
38+
return nil, nil, err
39+
}
40+
return state, lockFile, nil
41+
}
42+
43+
// PushStateAndLockFile writes the state and lock file to the Tofu working directory.
44+
func (t *Tofu) PushStateAndLockFile(_ context.Context, state json.RawMessage, lock []byte) error {
45+
if err := t.pushState(context.Background(), state); err != nil {
46+
return err
47+
}
48+
if err := t.pushLockFile(context.Background(), lock); err != nil {
49+
return err
50+
}
51+
return nil
52+
}
53+
54+
func (t *Tofu) pullState(_ context.Context) (json.RawMessage, error) {
2855
// If for some reason this needs to work in contexts with a non-default state provider, or
2956
// take advantage of built-in locking, then tofu state pull command can be used instead.
3057
path := filepath.Join(t.WorkingDir(), defaultStateFile)
3158
bytes, err := os.ReadFile(path)
3259
switch {
3360
case err != nil && os.IsNotExist(err):
34-
return nil, false, nil
61+
return nil, fmt.Errorf("default tfstate file not found: %w", err)
3562
case err != nil:
36-
return nil, false, fmt.Errorf("failed to read the default tfstate file: %w", err)
63+
return nil, fmt.Errorf("failed to read the default tfstate file: %w", err)
3764
default:
38-
return json.RawMessage(bytes), true, nil
65+
return json.RawMessage(bytes), nil
3966
}
4067
}
4168

42-
func (t *Tofu) PushState(_ context.Context, data json.RawMessage) error {
69+
func (t *Tofu) pushState(_ context.Context, data json.RawMessage) error {
4370
// If for some reason this needs to work in contexts with a non-default state provider, or
4471
// take advantage of built-in locking, then tofu state push command can be used instead.
4572
path := filepath.Join(t.WorkingDir(), defaultStateFile)
@@ -48,3 +75,28 @@ func (t *Tofu) PushState(_ context.Context, data json.RawMessage) error {
4875
}
4976
return nil
5077
}
78+
79+
func (t *Tofu) pullLockFile(_ context.Context) ([]byte, error) {
80+
path := filepath.Join(t.WorkingDir(), defaultLockFile)
81+
bytes, err := os.ReadFile(path)
82+
switch {
83+
// If the lock file is not present, that's fine
84+
case err != nil && os.IsNotExist(err):
85+
return nil, nil
86+
case err != nil:
87+
return nil, fmt.Errorf("failed to read the default lock file: %w", err)
88+
default:
89+
return bytes, nil
90+
}
91+
}
92+
93+
func (t *Tofu) pushLockFile(_ context.Context, data []byte) error {
94+
if data == nil {
95+
return nil
96+
}
97+
path := filepath.Join(t.WorkingDir(), defaultLockFile)
98+
if err := os.WriteFile(path, data, 0600); err != nil {
99+
return fmt.Errorf("failed to write the default lock file: %w", err)
100+
}
101+
return nil
102+
}

pkg/tfsandbox/state_test.go

+2-3
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,8 @@ func TestState(t *testing.T) {
7373
resource.PropertyKey("statically_known"): resource.NewStringProperty("static value"),
7474
}, moduleOutputs)
7575

76-
rawState, ok, err := tofu.PullState(ctx)
76+
rawState, rawLockFile, err := tofu.PullStateAndLockFile(ctx)
7777
require.NoError(t, err, "error pulling tofu state")
78-
require.True(t, ok, "no tofu state found")
7978

8079
type stateModel struct {
8180
Resources []any `json:"resources"`
@@ -97,7 +96,7 @@ func TestState(t *testing.T) {
9796
// Now modify the state and run a plan.
9897

9998
newState := bytes.ReplaceAll(rawState, []byte(`"test"`), []byte(`"test2"`))
100-
err = tofu.PushState(ctx, newState)
99+
err = tofu.PushStateAndLockFile(ctx, newState, rawLockFile)
101100
require.NoError(t, err, "error pushing tofu state")
102101

103102
plan, err := tofu.Plan(ctx)

0 commit comments

Comments
 (0)