| // Copyright (c) HashiCorp, Inc. |
| // SPDX-License-Identifier: BUSL-1.1 |
| |
| package stackeval |
| |
| import ( |
| "context" |
| "testing" |
| "time" |
| |
| "github.com/google/go-cmp/cmp" |
| "github.com/zclconf/go-cty-debug/ctydebug" |
| "github.com/zclconf/go-cty/cty" |
| "google.golang.org/protobuf/encoding/prototext" |
| |
| "github.com/hashicorp/terraform/internal/addrs" |
| "github.com/hashicorp/terraform/internal/lang/marks" |
| "github.com/hashicorp/terraform/internal/plans" |
| "github.com/hashicorp/terraform/internal/promising" |
| "github.com/hashicorp/terraform/internal/stacks/stackaddrs" |
| "github.com/hashicorp/terraform/internal/stacks/stackplan" |
| "github.com/hashicorp/terraform/internal/stacks/stackstate" |
| ) |
| |
| func TestInputVariableValue(t *testing.T) { |
| ctx := context.Background() |
| cfg := testStackConfig(t, "input_variable", "basics") |
| |
| // NOTE: This also indirectly tests the propagation of input values |
| // from a parent stack into one of its children, even though that's |
| // technically the responsibility of [StackCall] rather than [InputVariable], |
| // because propagating downward into child stacks is a major purpose |
| // of input variables that must keep working. |
| childStackAddr := stackaddrs.RootStackInstance.Child("child", addrs.NoKey) |
| |
| tests := map[string]struct { |
| NameVal cty.Value |
| WantRootVal cty.Value |
| WantChildVal cty.Value |
| |
| WantRootErr bool |
| }{ |
| "known string": { |
| NameVal: cty.StringVal("jackson"), |
| WantRootVal: cty.StringVal("jackson"), |
| WantChildVal: cty.StringVal("child of jackson"), |
| }, |
| "unknown string": { |
| NameVal: cty.UnknownVal(cty.String), |
| WantRootVal: cty.UnknownVal(cty.String), |
| WantChildVal: cty.UnknownVal(cty.String).Refine(). |
| NotNull(). |
| StringPrefix("child of "). |
| NewValue(), |
| }, |
| "unknown of unknown type": { |
| NameVal: cty.DynamicVal, |
| WantRootVal: cty.UnknownVal(cty.String), |
| WantChildVal: cty.UnknownVal(cty.String).Refine(). |
| NotNull(). |
| StringPrefix("child of "). |
| NewValue(), |
| }, |
| "bool": { |
| // This one is testing that the given value gets converted to |
| // the declared type constraint, which is string in this case. |
| NameVal: cty.True, |
| WantRootVal: cty.StringVal("true"), |
| WantChildVal: cty.StringVal("child of true"), |
| }, |
| "object": { |
| // This one is testing that the given value gets converted to |
| // the declared type constraint, which is string in this case. |
| NameVal: cty.EmptyObjectVal, |
| WantRootErr: true, // Type mismatch error |
| }, |
| } |
| |
| for name, test := range tests { |
| t.Run(name, func(t *testing.T) { |
| main := testEvaluator(t, testEvaluatorOpts{ |
| Config: cfg, |
| InputVariableValues: map[string]cty.Value{ |
| "name": test.NameVal, |
| }, |
| }) |
| |
| t.Run("root", func(t *testing.T) { |
| promising.MainTask(ctx, func(ctx context.Context) (struct{}, error) { |
| mainStack := main.MainStack() |
| rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: "name"}) |
| got, diags := rootVar.CheckValue(ctx, InspectPhase) |
| |
| if test.WantRootErr { |
| if !diags.HasErrors() { |
| t.Errorf("succeeded; want error\ngot: %#v", got) |
| } |
| return struct{}{}, nil |
| } |
| |
| if diags.HasErrors() { |
| t.Errorf("unexpected errors\n%s", diags.Err().Error()) |
| } |
| want := test.WantRootVal |
| if !want.RawEquals(got) { |
| t.Errorf("wrong value\ngot: %#v\nwant: %#v", got, want) |
| } |
| return struct{}{}, nil |
| }) |
| }) |
| if !test.WantRootErr { |
| t.Run("child", func(t *testing.T) { |
| promising.MainTask(ctx, func(ctx context.Context) (struct{}, error) { |
| childStack := main.Stack(ctx, childStackAddr, InspectPhase) |
| rootVar := childStack.InputVariable(stackaddrs.InputVariable{Name: "name"}) |
| got, diags := rootVar.CheckValue(ctx, InspectPhase) |
| if diags.HasErrors() { |
| t.Errorf("unexpected errors\n%s", diags.Err().Error()) |
| } |
| want := test.WantChildVal |
| if !want.RawEquals(got) { |
| t.Errorf("wrong value\ngot: %#v\nwant: %#v", got, want) |
| } |
| return struct{}{}, nil |
| }) |
| }) |
| } |
| }) |
| } |
| } |
| |
| func TestInputVariableEphemeral(t *testing.T) { |
| ctx := context.Background() |
| |
| tests := map[string]struct { |
| fixtureName string |
| givenVal cty.Value |
| allowed bool |
| wantInputs cty.Value |
| wantVal cty.Value |
| }{ |
| "ephemeral and allowed": { |
| fixtureName: "ephemeral_yes", |
| givenVal: cty.StringVal("beep").Mark(marks.Ephemeral), |
| allowed: true, |
| wantInputs: cty.ObjectVal(map[string]cty.Value{ |
| "a": cty.StringVal("beep").Mark(marks.Ephemeral), |
| }), |
| wantVal: cty.StringVal("beep").Mark(marks.Ephemeral), |
| }, |
| "ephemeral and not allowed": { |
| fixtureName: "ephemeral_no", |
| givenVal: cty.StringVal("beep").Mark(marks.Ephemeral), |
| allowed: false, |
| wantInputs: cty.UnknownVal(cty.Object(map[string]cty.Type{ |
| "a": cty.String, |
| })), |
| wantVal: cty.UnknownVal(cty.String), |
| }, |
| "non-ephemeral and allowed": { |
| fixtureName: "ephemeral_yes", |
| givenVal: cty.StringVal("beep"), |
| allowed: true, |
| wantInputs: cty.ObjectVal(map[string]cty.Value{ |
| "a": cty.StringVal("beep"), // not marked on the input side... |
| }), |
| wantVal: cty.StringVal("beep").Mark(marks.Ephemeral), // ...but marked on the result side |
| }, |
| "non-ephemeral and not allowed": { |
| fixtureName: "ephemeral_no", |
| givenVal: cty.StringVal("beep"), |
| allowed: true, |
| wantInputs: cty.ObjectVal(map[string]cty.Value{ |
| "a": cty.StringVal("beep"), |
| }), |
| wantVal: cty.StringVal("beep"), |
| }, |
| } |
| |
| for name, test := range tests { |
| t.Run(name, func(t *testing.T) { |
| cfg := testStackConfig(t, "input_variable", test.fixtureName) |
| childStackAddr := stackaddrs.RootStackInstance.Child("child", addrs.NoKey) |
| childStackCallAddr := stackaddrs.StackCall{Name: "child"} |
| aVarAddr := stackaddrs.InputVariable{Name: "a"} |
| |
| main := testEvaluator(t, testEvaluatorOpts{ |
| Config: cfg, |
| TestOnlyGlobals: map[string]cty.Value{ |
| "var_val": test.givenVal, |
| }, |
| }) |
| |
| promising.MainTask(ctx, func(ctx context.Context) (struct{}, error) { |
| childStack := main.Stack(ctx, childStackAddr, InspectPhase) |
| if childStack == nil { |
| t.Fatalf("missing %s", childStackAddr) |
| } |
| childStackCall := main.MainStack().EmbeddedStackCall(childStackCallAddr) |
| if childStackCall == nil { |
| t.Fatalf("missing %s", childStackCallAddr) |
| } |
| insts, unknown := childStackCall.Instances(ctx, InspectPhase) |
| if unknown { |
| t.Fatalf("stack call instances are unknown") |
| } |
| childStackCallInst := insts[addrs.NoKey] |
| if childStackCallInst == nil { |
| t.Fatalf("missing %s instance", childStackCallAddr) |
| } |
| |
| // The responsibility for handling ephemeral input variables |
| // is split between the stack call which decides whether an |
| // ephemeral value is acceptable, and the variable declaration |
| // itself which ensures that variables declared as ephemeral |
| // always appear as ephemeral inside even if the given value |
| // wasn't. |
| |
| wantInputs := test.wantInputs |
| gotInputs, diags := childStackCallInst.CheckInputVariableValues(ctx, InspectPhase) |
| if diff := cmp.Diff(wantInputs, gotInputs, ctydebug.CmpOptions); diff != "" { |
| t.Errorf("wrong inputs for %s\n%s", childStackCallAddr, diff) |
| } |
| |
| aVar := childStack.InputVariable(aVarAddr) |
| if aVar == nil { |
| t.Fatalf("missing %s", stackaddrs.Absolute(childStackAddr, aVarAddr)) |
| } |
| want := test.wantVal |
| got, moreDiags := aVar.CheckValue(ctx, InspectPhase) |
| diags = diags.Append(moreDiags) |
| if diff := cmp.Diff(want, got, ctydebug.CmpOptions); diff != "" { |
| t.Errorf("wrong value for %s\n%s", aVarAddr, diff) |
| } |
| |
| if test.allowed { |
| if diags.HasErrors() { |
| t.Errorf("unexpected errors\n%s", diags.Err().Error()) |
| } |
| } else { |
| if !diags.HasErrors() { |
| t.Fatalf("no errors; should have failed") |
| } |
| found := 0 |
| for _, diag := range diags { |
| summary := diag.Description().Summary |
| if summary == "Ephemeral value not allowed" { |
| found++ |
| } |
| } |
| if found == 0 { |
| t.Errorf("no diagnostics about disallowed ephemeral values\n%s", diags.Err().Error()) |
| } else if found > 1 { |
| t.Errorf("found %d errors about disallowed ephemeral values, but wanted only one\n%s", found, diags.Err().Error()) |
| } |
| } |
| return struct{}{}, nil |
| }) |
| }) |
| } |
| } |
| |
| func TestInputVariablePlanApply(t *testing.T) { |
| ctx := context.Background() |
| cfg := testStackConfig(t, "input_variable", "basics") |
| |
| tests := map[string]struct { |
| PlanVal cty.Value |
| ApplyVal cty.Value |
| WantErr bool |
| }{ |
| "unmarked": { |
| PlanVal: cty.StringVal("alisdair"), |
| ApplyVal: cty.StringVal("alisdair"), |
| }, |
| "sensitive": { |
| PlanVal: cty.StringVal("alisdair").Mark(marks.Sensitive), |
| ApplyVal: cty.StringVal("alisdair").Mark(marks.Sensitive), |
| }, |
| "changed": { |
| PlanVal: cty.StringVal("alice"), |
| ApplyVal: cty.StringVal("bob"), |
| WantErr: true, |
| }, |
| } |
| |
| for name, test := range tests { |
| t.Run(name, func(t *testing.T) { |
| planOutput, err := promising.MainTask(ctx, func(ctx context.Context) (*planOutputTester, error) { |
| main := NewForPlanning(cfg, stackstate.NewState(), PlanOpts{ |
| PlanningMode: plans.NormalMode, |
| PlanTimestamp: time.Now().UTC(), |
| InputVariableValues: map[stackaddrs.InputVariable]ExternalInputValue{ |
| {Name: "name"}: { |
| Value: test.PlanVal, |
| }, |
| }, |
| }) |
| |
| outp, outpTester := testPlanOutput(t) |
| main.PlanAll(ctx, outp) |
| |
| return outpTester, nil |
| }) |
| if err != nil { |
| t.Fatalf("planning failed: %s", err) |
| } |
| |
| rawPlan := planOutput.RawChanges(t) |
| plan, diags := planOutput.Close(t) |
| assertNoDiagnostics(t, diags) |
| |
| if !plan.Applyable { |
| m := prototext.MarshalOptions{ |
| Multiline: true, |
| Indent: " ", |
| } |
| for _, raw := range rawPlan { |
| t.Log(m.Format(raw)) |
| } |
| t.Fatalf("plan is not applyable") |
| } |
| |
| _, err = promising.MainTask(ctx, func(ctx context.Context) (struct{}, error) { |
| main := NewForApplying(cfg, plan, nil, ApplyOpts{ |
| InputVariableValues: map[stackaddrs.InputVariable]ExternalInputValue{ |
| {Name: "name"}: { |
| Value: test.ApplyVal, |
| }, |
| }, |
| }) |
| mainStack := main.MainStack() |
| rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: "name"}) |
| got, diags := rootVar.CheckValue(ctx, ApplyPhase) |
| |
| if test.WantErr { |
| if !diags.HasErrors() { |
| t.Errorf("succeeded; want error\ngot: %#v", got) |
| } |
| return struct{}{}, nil |
| } |
| |
| if diags.HasErrors() { |
| t.Errorf("unexpected errors\n%s", diags.Err().Error()) |
| } |
| want := test.ApplyVal |
| if !want.RawEquals(got) { |
| t.Errorf("wrong value\ngot: %#v\nwant: %#v", got, want) |
| } |
| |
| return struct{}{}, nil |
| }) |
| if err != nil { |
| t.Fatal(err) |
| } |
| }) |
| } |
| } |
| |
| func TestInputVariablePlanChanges(t *testing.T) { |
| ctx := context.Background() |
| cfg := testStackConfig(t, "input_variable", "basics") |
| |
| tests := map[string]struct { |
| PlanVal cty.Value |
| PreviousPlanVal cty.Value |
| WantPlannedChanges []stackplan.PlannedChange |
| }{ |
| "unmarked": { |
| PlanVal: cty.StringVal("value_1"), |
| PreviousPlanVal: cty.NullVal(cty.String), |
| WantPlannedChanges: []stackplan.PlannedChange{ |
| &stackplan.PlannedChangeRootInputValue{ |
| Addr: stackaddrs.InputVariable{Name: "name"}, |
| Action: plans.Update, |
| Before: cty.NullVal(cty.String), |
| After: cty.StringVal("value_1"), |
| RequiredOnApply: false, |
| DeleteOnApply: false, |
| }, |
| }, |
| }, |
| "sensitive": { |
| PlanVal: cty.StringVal("value_2").Mark(marks.Sensitive), |
| PreviousPlanVal: cty.NullVal(cty.String), |
| WantPlannedChanges: []stackplan.PlannedChange{ |
| &stackplan.PlannedChangeRootInputValue{ |
| Addr: stackaddrs.InputVariable{Name: "name"}, |
| Action: plans.Update, |
| Before: cty.NullVal(cty.String), |
| After: cty.StringVal("value_2").Mark(marks.Sensitive), |
| RequiredOnApply: false, |
| DeleteOnApply: false, |
| }, |
| }, |
| }, |
| "ephemeral": { |
| PlanVal: cty.StringVal("value_3").Mark(marks.Ephemeral), |
| PreviousPlanVal: cty.NullVal(cty.String), |
| WantPlannedChanges: []stackplan.PlannedChange{ |
| &stackplan.PlannedChangeRootInputValue{ |
| Addr: stackaddrs.InputVariable{Name: "name"}, |
| Action: plans.Update, |
| Before: cty.NullVal(cty.String), |
| After: cty.StringVal("value_3").Mark(marks.Ephemeral), |
| RequiredOnApply: false, |
| DeleteOnApply: false, |
| }, |
| }, |
| }, |
| "sensitive_and_ephemeral": { |
| PlanVal: cty.StringVal("value_4").Mark(marks.Ephemeral).Mark(marks.Sensitive), |
| PreviousPlanVal: cty.NullVal(cty.String), |
| WantPlannedChanges: []stackplan.PlannedChange{ |
| &stackplan.PlannedChangeRootInputValue{ |
| Addr: stackaddrs.InputVariable{Name: "name"}, |
| Action: plans.Update, |
| Before: cty.NullVal(cty.String), |
| After: cty.StringVal("value_4").Mark(marks.Ephemeral).Mark(marks.Sensitive), |
| RequiredOnApply: false, |
| DeleteOnApply: false, |
| }, |
| }, |
| }, |
| "from_non_null_to_sensitive": { |
| PlanVal: cty.StringVal("value_2").Mark(marks.Sensitive), |
| PreviousPlanVal: cty.StringVal("value_1"), |
| WantPlannedChanges: []stackplan.PlannedChange{ |
| &stackplan.PlannedChangeRootInputValue{ |
| Addr: stackaddrs.InputVariable{Name: "name"}, |
| Action: plans.Update, |
| Before: cty.StringVal("value_1"), |
| After: cty.StringVal("value_2").Mark(marks.Sensitive), |
| RequiredOnApply: false, |
| DeleteOnApply: false, |
| }, |
| }, |
| }, |
| "from_ephemeral_to_unmark": { |
| PlanVal: cty.StringVal("value_2"), |
| PreviousPlanVal: cty.StringVal("value_1").Mark(marks.Ephemeral), |
| WantPlannedChanges: []stackplan.PlannedChange{ |
| &stackplan.PlannedChangeRootInputValue{ |
| Addr: stackaddrs.InputVariable{Name: "name"}, |
| Action: plans.Update, |
| Before: cty.StringVal("value_1").Mark(marks.Ephemeral), |
| After: cty.StringVal("value_2"), |
| RequiredOnApply: false, |
| DeleteOnApply: false, |
| }, |
| }, |
| }, |
| } |
| |
| for name, test := range tests { |
| t.Run(name, func(t *testing.T) { |
| _, err := promising.MainTask(ctx, func(ctx context.Context) (*planOutputTester, error) { |
| previousState := stackstate.NewStateBuilder().AddInput("name", test.PreviousPlanVal).Build() |
| |
| main := NewForPlanning(cfg, previousState, PlanOpts{ |
| PlanningMode: plans.NormalMode, |
| PlanTimestamp: time.Now().UTC(), |
| InputVariableValues: map[stackaddrs.InputVariable]ExternalInputValue{ |
| {Name: "name"}: { |
| Value: test.PlanVal, |
| }, |
| }, |
| }) |
| |
| mainStack := main.MainStack() |
| rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: "name"}) |
| got, diags := rootVar.PlanChanges(ctx) |
| if diags.HasErrors() { |
| t.Errorf("unexpected errors\n%s", diags.Err().Error()) |
| } |
| |
| opts := cmp.Options{ctydebug.CmpOptions} |
| if diff := cmp.Diff(test.WantPlannedChanges, got, opts); len(diff) > 0 { |
| t.Errorf("wrong planned changes\n%s", diff) |
| } |
| |
| return nil, nil |
| }) |
| if err != nil { |
| t.Fatalf("planning failed: %s", err) |
| } |
| }) |
| } |
| } |