Skip to content

Commit 88d8f75

Browse files
committed
feat: Make Terraform assert that the description of a backend or state store is complete when reading or writing a plan file
1 parent f89e3e1 commit 88d8f75

File tree

2 files changed

+246
-1
lines changed

2 files changed

+246
-1
lines changed

internal/plans/planfile/tfplan.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,10 @@ func readTfplan(r io.Reader) (*plans.Plan, error) {
227227
Config: config,
228228
Workspace: rawBackend.Workspace,
229229
}
230+
err = plan.Backend.Validate()
231+
if err != nil {
232+
return nil, fmt.Errorf("plan describes an invalid backend: %w", err)
233+
}
230234
case rawPlan.StateStore != nil:
231235
rawStateStore := rawPlan.StateStore
232236

@@ -256,6 +260,10 @@ func readTfplan(r io.Reader) (*plans.Plan, error) {
256260
Config: storeConfig,
257261
Workspace: rawStateStore.Workspace,
258262
}
263+
err = plan.StateStore.Validate()
264+
if err != nil {
265+
return nil, fmt.Errorf("plan describes an invalid state store: %w", err)
266+
}
259267
}
260268

261269
if plan.Timestamp, err = time.Parse(time.RFC3339, rawPlan.Timestamp); err != nil {
@@ -755,12 +763,20 @@ func writeTfplan(plan *plans.Plan, w io.Writer) error {
755763
// should never have both a backend and state_store populated.
756764
return fmt.Errorf("plan contains both backend and state_store configurations, only one is expected")
757765
case plan.Backend != nil:
766+
err := plan.Backend.Validate()
767+
if err != nil {
768+
return fmt.Errorf("plan describes an invalid backend: %w", err)
769+
}
758770
rawPlan.Backend = &planproto.Backend{
759771
Type: plan.Backend.Type,
760772
Config: valueToTfplan(plan.Backend.Config),
761773
Workspace: plan.Backend.Workspace,
762774
}
763775
case plan.StateStore != nil:
776+
err := plan.StateStore.Validate()
777+
if err != nil {
778+
return fmt.Errorf("plan describes an invalid state store: %w", err)
779+
}
764780
rawPlan.StateStore = &planproto.StateStore{
765781
Type: plan.StateStore.Type,
766782
Provider: &planproto.Provider{

internal/plans/planfile/tfplan_test.go

Lines changed: 230 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package planfile
55

66
import (
77
"bytes"
8+
"strings"
89
"testing"
910

1011
"github.com/go-test/deep"
@@ -165,6 +166,234 @@ func Test_writeTfplan_validation(t *testing.T) {
165166
}(),
166167
wantWriteErrMsg: "plan contains both backend and state_store configurations, only one is expected",
167168
},
169+
"error when state store lacks a provider source": {
170+
plan: func() *plans.Plan {
171+
rawPlan := examplePlanForTest(t)
172+
// remove backend from example plan
173+
rawPlan.Backend = nil
174+
175+
// Add state store with missing data
176+
ver, err := version.NewVersion("9.9.9")
177+
if err != nil {
178+
t.Fatalf("error encountered during test setup: %s", err)
179+
}
180+
rawPlan.StateStore = &plans.StateStore{
181+
Type: "foo_bar",
182+
Provider: &plans.Provider{
183+
Version: ver,
184+
// Source: omitted
185+
Config: mustNewDynamicValue(
186+
cty.ObjectVal(map[string]cty.Value{
187+
"foo": cty.StringVal("bar"),
188+
}),
189+
cty.Object(map[string]cty.Type{
190+
"foo": cty.String,
191+
}),
192+
),
193+
},
194+
Config: mustNewDynamicValue(
195+
cty.ObjectVal(map[string]cty.Value{
196+
"foo": cty.StringVal("bar"),
197+
}),
198+
cty.Object(map[string]cty.Type{
199+
"foo": cty.String,
200+
}),
201+
),
202+
Workspace: "default",
203+
}
204+
return rawPlan
205+
}(),
206+
wantWriteErrMsg: "contains a nil provider Source",
207+
},
208+
"error when state store lacks a provider version": {
209+
plan: func() *plans.Plan {
210+
rawPlan := examplePlanForTest(t)
211+
// remove backend from example plan
212+
rawPlan.Backend = nil
213+
214+
// Add state store with missing data
215+
rawPlan.StateStore = &plans.StateStore{
216+
Type: "foo_bar",
217+
Provider: &plans.Provider{
218+
// Version: omitted
219+
Source: &tfaddr.Provider{
220+
Hostname: tfaddr.DefaultProviderRegistryHost,
221+
Namespace: "foobar",
222+
Type: "foo",
223+
},
224+
Config: mustNewDynamicValue(
225+
cty.ObjectVal(map[string]cty.Value{
226+
"foo": cty.StringVal("bar"),
227+
}),
228+
cty.Object(map[string]cty.Type{
229+
"foo": cty.String,
230+
}),
231+
),
232+
},
233+
Config: mustNewDynamicValue(
234+
cty.ObjectVal(map[string]cty.Value{
235+
"foo": cty.StringVal("bar"),
236+
}),
237+
cty.Object(map[string]cty.Type{
238+
"foo": cty.String,
239+
}),
240+
),
241+
Workspace: "default",
242+
}
243+
return rawPlan
244+
}(),
245+
wantWriteErrMsg: "contains a nil provider Version",
246+
},
247+
"error when state store lacks provider config": {
248+
plan: func() *plans.Plan {
249+
rawPlan := examplePlanForTest(t)
250+
// remove backend from example plan
251+
rawPlan.Backend = nil
252+
253+
// Add state store with missing data
254+
ver, err := version.NewVersion("9.9.9")
255+
if err != nil {
256+
t.Fatalf("error encountered during test setup: %s", err)
257+
}
258+
rawPlan.StateStore = &plans.StateStore{
259+
Type: "foo_bar",
260+
Provider: &plans.Provider{
261+
Version: ver,
262+
Source: &tfaddr.Provider{
263+
Hostname: tfaddr.DefaultProviderRegistryHost,
264+
Namespace: "foobar",
265+
Type: "foo",
266+
},
267+
// Config: omitted
268+
},
269+
Config: mustNewDynamicValue(
270+
cty.ObjectVal(map[string]cty.Value{
271+
"foo": cty.StringVal("bar"),
272+
}),
273+
cty.Object(map[string]cty.Type{
274+
"foo": cty.String,
275+
}),
276+
),
277+
Workspace: "default",
278+
}
279+
return rawPlan
280+
}(),
281+
wantWriteErrMsg: "includes no provider Config",
282+
},
283+
"error when state store lacks a type": {
284+
plan: func() *plans.Plan {
285+
rawPlan := examplePlanForTest(t)
286+
// remove backend from example plan
287+
rawPlan.Backend = nil
288+
289+
// Add state store with missing data
290+
ver, err := version.NewVersion("9.9.9")
291+
if err != nil {
292+
t.Fatalf("error encountered during test setup: %s", err)
293+
}
294+
rawPlan.StateStore = &plans.StateStore{
295+
// Type: omitted
296+
Provider: &plans.Provider{
297+
Version: ver,
298+
Source: &tfaddr.Provider{
299+
Hostname: tfaddr.DefaultProviderRegistryHost,
300+
Namespace: "foobar",
301+
Type: "foo",
302+
},
303+
},
304+
Config: mustNewDynamicValue(
305+
cty.ObjectVal(map[string]cty.Value{
306+
"foo": cty.StringVal("bar"),
307+
}),
308+
cty.Object(map[string]cty.Type{
309+
"foo": cty.String,
310+
}),
311+
),
312+
Workspace: "default",
313+
}
314+
return rawPlan
315+
}(),
316+
wantWriteErrMsg: "state store has an unset Type",
317+
},
318+
"error when state store lacks config": {
319+
plan: func() *plans.Plan {
320+
rawPlan := examplePlanForTest(t)
321+
// remove backend from example plan
322+
rawPlan.Backend = nil
323+
324+
// Add state store with missing data
325+
ver, err := version.NewVersion("9.9.9")
326+
if err != nil {
327+
t.Fatalf("error encountered during test setup: %s", err)
328+
}
329+
rawPlan.StateStore = &plans.StateStore{
330+
Type: "foo_bar",
331+
Provider: &plans.Provider{
332+
Version: ver,
333+
Source: &tfaddr.Provider{
334+
Hostname: tfaddr.DefaultProviderRegistryHost,
335+
Namespace: "foobar",
336+
Type: "foo",
337+
},
338+
Config: mustNewDynamicValue(
339+
cty.ObjectVal(map[string]cty.Value{
340+
"foo": cty.StringVal("bar"),
341+
}),
342+
cty.Object(map[string]cty.Type{
343+
"foo": cty.String,
344+
}),
345+
),
346+
},
347+
// Config: omitted
348+
Workspace: "default",
349+
}
350+
return rawPlan
351+
}(),
352+
wantWriteErrMsg: "state store includes no Config",
353+
},
354+
"error when state store lacks a workspace": {
355+
plan: func() *plans.Plan {
356+
rawPlan := examplePlanForTest(t)
357+
// remove backend from example plan
358+
rawPlan.Backend = nil
359+
360+
// Add state store with missing data
361+
ver, err := version.NewVersion("9.9.9")
362+
if err != nil {
363+
t.Fatalf("error encountered during test setup: %s", err)
364+
}
365+
rawPlan.StateStore = &plans.StateStore{
366+
Type: "foo_bar",
367+
Provider: &plans.Provider{
368+
Version: ver,
369+
Source: &tfaddr.Provider{
370+
Hostname: tfaddr.DefaultProviderRegistryHost,
371+
Namespace: "foobar",
372+
Type: "foo",
373+
},
374+
Config: mustNewDynamicValue(
375+
cty.ObjectVal(map[string]cty.Value{
376+
"foo": cty.StringVal("bar"),
377+
}),
378+
cty.Object(map[string]cty.Type{
379+
"foo": cty.String,
380+
}),
381+
),
382+
},
383+
Config: mustNewDynamicValue(
384+
cty.ObjectVal(map[string]cty.Value{
385+
"foo": cty.StringVal("bar"),
386+
}),
387+
cty.Object(map[string]cty.Type{
388+
"foo": cty.String,
389+
}),
390+
),
391+
// Workspace: omitted
392+
}
393+
return rawPlan
394+
}(),
395+
wantWriteErrMsg: "state store has an unset Workspace",
396+
},
168397
}
169398

170399
for tn, tc := range cases {
@@ -174,7 +403,7 @@ func Test_writeTfplan_validation(t *testing.T) {
174403
if err == nil {
175404
t.Fatal("this test expects an error but got none")
176405
}
177-
if err.Error() != tc.wantWriteErrMsg {
406+
if !strings.Contains(err.Error(), tc.wantWriteErrMsg) {
178407
t.Fatalf("unexpected error message: wanted %q, got %q", tc.wantWriteErrMsg, err)
179408
}
180409
})

0 commit comments

Comments
 (0)