Skip to content

Commit fb1a082

Browse files
committed
Policy: dedicated data structure for tracking policy evaluation data
1 parent 91c960d commit fb1a082

19 files changed

Lines changed: 871 additions & 132 deletions

internal/terraform/context_apply_policy_test.go

Lines changed: 274 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ package terraform
55

66
import (
77
"context"
8+
"sync/atomic"
89
"testing"
10+
"time"
911

1012
"github.com/google/go-cmp/cmp"
1113
"github.com/hashicorp/hcl/v2"
@@ -17,6 +19,7 @@ import (
1719
"github.com/hashicorp/terraform/internal/states"
1820
"github.com/hashicorp/terraform/internal/tfdiags"
1921
"github.com/zclconf/go-cty/cty"
22+
"google.golang.org/protobuf/testing/protocmp"
2023
)
2124

2225
func TestContext2Apply_PolicyEvaluation_Full(t *testing.T) {
@@ -96,7 +99,7 @@ func TestContext2Apply_PolicyEvaluation_Full(t *testing.T) {
9699
planPolicyClient := policy.NewTestMockClient(t)
97100

98101
// The expected values to be sent for policy evaluation.
99-
expected := map[string]cty.Value{
102+
expectedPlan := map[string]cty.Value{
100103
"test_resource": cty.ObjectVal(map[string]cty.Value{
101104
"value": cty.NullVal(cty.String),
102105
"sensitive_value": cty.StringVal("foo"),
@@ -106,25 +109,27 @@ func TestContext2Apply_PolicyEvaluation_Full(t *testing.T) {
106109
"sensitive_value": cty.NilVal,
107110
}),
108111
}
112+
actualPlan := make(map[string]cty.Value)
109113

110114
planPolicyClient.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse {
111-
var actual cty.Value
115+
var actualVal cty.Value
112116
attrs := req.Attrs
113117
target := req.Target
114118
if !attrs.IsNull() {
115119
mp := attrs.AsValueMap()
116-
actual = cty.ObjectVal(map[string]cty.Value{
120+
actualVal = cty.ObjectVal(map[string]cty.Value{
117121
"value": mp["value"],
118122
"sensitive_value": mp["sensitive_value"],
119123
})
120124
}
121-
122-
if diff := cmp.Diff(actual, expected[target], cmp.Comparer(cty.Value.RawEquals)); diff != "" {
123-
t.Errorf("Unexpected diff (-got +want):\n%s", diff)
124-
}
125-
125+
actualPlan[target] = actualVal
126126
return policy.EvaluationResponse{Overall: policy.AllowResult}
127127
}
128+
t.Cleanup(func() {
129+
if diff := cmp.Diff(actualPlan, expectedPlan, cmp.Comparer(cty.Value.RawEquals)); diff != "" {
130+
t.Errorf("Unexpected diff (-got +want):\n%s", diff)
131+
}
132+
})
128133

129134
ctx, diags := NewContext(&ContextOpts{
130135
Providers: map[addrs.Provider]providers.Factory{
@@ -145,7 +150,7 @@ func TestContext2Apply_PolicyEvaluation_Full(t *testing.T) {
145150
applyPolicyClient := policy.NewTestMockClient(t)
146151

147152
// The expected values to be sent for policy evaluation.
148-
expected = map[string]cty.Value{
153+
expectedApply := map[string]cty.Value{
149154
"test_resource": cty.ObjectVal(map[string]cty.Value{
150155
"value": cty.NullVal(cty.String),
151156
"sensitive_value": cty.StringVal("foo"),
@@ -155,6 +160,8 @@ func TestContext2Apply_PolicyEvaluation_Full(t *testing.T) {
155160
"sensitive_value": cty.NilVal,
156161
}),
157162
}
163+
actualApply := make(map[string]cty.Value)
164+
158165
applyPolicyClient.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse {
159166
var actual cty.Value
160167
attrs := req.Attrs
@@ -166,15 +173,18 @@ func TestContext2Apply_PolicyEvaluation_Full(t *testing.T) {
166173
"sensitive_value": mp["sensitive_value"],
167174
})
168175
}
169-
170-
if diff := cmp.Diff(actual, expected[target], cmp.Comparer(cty.Value.RawEquals)); diff != "" {
171-
t.Errorf("Unexpected diff (-got +want):\n%s", diff)
172-
}
176+
actualApply[target] = actual
173177

174178
// this return does not actually do anything
175179
return policy.EvaluationResponse{Overall: policy.AllowResult}
176180
}
177181

182+
t.Cleanup(func() {
183+
if diff := cmp.Diff(actualApply, expectedApply, cmp.Comparer(cty.Value.RawEquals)); diff != "" {
184+
t.Errorf("Unexpected diff (-got +want):\n%s", diff)
185+
}
186+
})
187+
178188
_, diags = ctx.Apply(plan, mod, &ApplyOpts{
179189
PolicyClient: applyPolicyClient,
180190
})
@@ -385,6 +395,257 @@ func TestContext2Apply_PolicyEvaluationError(t *testing.T) {
385395
}
386396
}
387397

398+
func TestContext2Apply_PolicyEvaluation_NoResourceAfterPolicy(t *testing.T) {
399+
// This verifies that no resource instance node is run after policy evaluation
400+
mainConfig := `
401+
terraform {
402+
required_providers {
403+
test = {
404+
source = "hashicorp/test"
405+
version = "1.0.0"
406+
}
407+
}
408+
}
409+
410+
resource "test_instance" "test" {
411+
count = 2
412+
value = tostring(count.index)
413+
}
414+
`
415+
416+
policyConfig := `
417+
resource_policy "test_instance" "policy_name" {
418+
enforce {
419+
condition = true
420+
}
421+
}
422+
`
423+
424+
mod := testModuleInline(t, map[string]string{
425+
"main.tf": mainConfig,
426+
"main.tfpolicy.hcl": policyConfig,
427+
})
428+
429+
providerAddr := addrs.NewDefaultProvider("test")
430+
provider := testProvider("test")
431+
432+
var policyRan atomic.Bool
433+
var applyCalls atomic.Int32
434+
435+
provider.ApplyResourceChangeFn = func(req providers.ApplyResourceChangeRequest) (resp providers.ApplyResourceChangeResponse) {
436+
callNum := applyCalls.Add(1)
437+
if callNum == 2 {
438+
time.Sleep(150 * time.Millisecond)
439+
}
440+
441+
if policyRan.Load() {
442+
t.Fatalf("resource apply for %s ran after policy evaluation", req.TypeName)
443+
}
444+
445+
newState := req.PlannedState.AsValueMap()
446+
newState["id"] = cty.StringVal(req.PlannedState.GetAttr("value").AsString())
447+
newState["type"] = cty.StringVal(req.TypeName)
448+
newState["unknown"] = cty.StringVal("known")
449+
resp.NewState = cty.ObjectVal(newState)
450+
return resp
451+
}
452+
453+
ctx, diags := NewContext(&ContextOpts{
454+
Providers: map[addrs.Provider]providers.Factory{
455+
providerAddr: testProviderFuncFixed(provider),
456+
},
457+
Parallelism: 4,
458+
})
459+
tfdiags.AssertNoDiagnostics(t, diags)
460+
461+
plan, diags := ctx.Plan(mod, states.NewState(), &PlanOpts{
462+
Mode: plans.NormalMode,
463+
SetVariables: testInputValuesUnset(mod.Module.Variables),
464+
})
465+
tfdiags.AssertNoDiagnostics(t, diags)
466+
467+
applyPolicyClient := policy.NewTestMockClient(t)
468+
applyPolicyClient.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse {
469+
policyRan.Store(true)
470+
471+
if diff := cmp.Diff(req.Meta, &proto.ResourceMetadata{
472+
Type: "test_instance",
473+
ProviderType: "test",
474+
Operation: proto.Operation_CREATE,
475+
}, protocmp.Transform()); diff != "" {
476+
t.Errorf("Invalid resource metadata: %s", diff)
477+
}
478+
479+
return policy.EvaluationResponse{Overall: policy.AllowResult}
480+
}
481+
482+
resultState, diags := ctx.Apply(plan, mod, &ApplyOpts{
483+
PolicyClient: applyPolicyClient,
484+
})
485+
tfdiags.AssertNoDiagnostics(t, diags)
486+
487+
if !applyPolicyClient.EvaluateCalled {
488+
t.Fatal("expected policy evaluation to be called during apply")
489+
}
490+
491+
remainingAddrs := resultState.AllManagedResourceInstanceObjectAddrs()
492+
if len(remainingAddrs) != 2 {
493+
t.Fatalf("expected 2 managed resources in the state after apply, got %d: %v", len(remainingAddrs), remainingAddrs)
494+
}
495+
}
496+
497+
func TestContext2Apply_PolicyEvaluation_ChangedResourceCount(t *testing.T) {
498+
cases := []struct {
499+
name string
500+
state *states.State
501+
configBody string
502+
expectTarget string
503+
expectOp proto.Operation
504+
expectCalls int
505+
expectFinalAttr cty.Value
506+
}{
507+
{
508+
name: "create",
509+
state: states.NewState(),
510+
configBody: `
511+
resource "test_resource" "test" {
512+
sensitive_value = "foo"
513+
}
514+
`,
515+
expectTarget: "test_resource",
516+
expectOp: proto.Operation_CREATE,
517+
expectCalls: 1,
518+
expectFinalAttr: cty.ObjectVal(map[string]cty.Value{
519+
"id": cty.StringVal("created"),
520+
"sensitive_value": cty.StringVal("foo"),
521+
}),
522+
},
523+
{
524+
name: "update",
525+
state: states.BuildState(func(ss *states.SyncState) {
526+
ss.SetResourceInstanceCurrent(
527+
mustResourceInstanceAddr("test_resource.test"),
528+
&states.ResourceInstanceObjectSrc{
529+
Status: states.ObjectReady,
530+
AttrsJSON: []byte(`{"id":"existing","type":"test_resource","sensitive_value":"before"}`),
531+
},
532+
mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`),
533+
)
534+
}),
535+
configBody: `
536+
resource "test_resource" "test" {
537+
sensitive_value = "after"
538+
}
539+
`,
540+
expectTarget: "test_resource",
541+
expectOp: proto.Operation_UPDATE,
542+
expectCalls: 1,
543+
expectFinalAttr: cty.ObjectVal(map[string]cty.Value{
544+
"id": cty.StringVal("existing"),
545+
"sensitive_value": cty.StringVal("after"),
546+
}),
547+
},
548+
}
549+
550+
for _, tc := range cases {
551+
t.Run(tc.name, func(t *testing.T) {
552+
mainConfig := `
553+
terraform {
554+
required_providers {
555+
test = {
556+
source = "hashicorp/test"
557+
version = "1.0.0"
558+
}
559+
}
560+
}
561+
` + tc.configBody
562+
563+
policyConfig := `
564+
resource_policy "test_resource" "policy_name" {
565+
enforce {
566+
condition = true
567+
}
568+
}
569+
`
570+
mod := testModuleInline(t, map[string]string{
571+
"main.tf": mainConfig,
572+
"main.tfpolicy.hcl": policyConfig,
573+
})
574+
575+
providerAddr := addrs.NewDefaultProvider("test")
576+
provider := testProvider("test")
577+
provider.ApplyResourceChangeFn = func(req providers.ApplyResourceChangeRequest) (resp providers.ApplyResourceChangeResponse) {
578+
cfg := req.Config.AsValueMap()
579+
if req.TypeName == "test_resource" {
580+
if id, ok := cfg["id"]; ok && !id.IsNull() && id.IsKnown() {
581+
cfg["id"] = id
582+
} else if tc.name == "create" {
583+
cfg["id"] = cty.StringVal("created")
584+
} else {
585+
cfg["id"] = cty.StringVal("existing")
586+
}
587+
}
588+
resp.NewState = cty.ObjectVal(cfg)
589+
return resp
590+
}
591+
592+
ctx, diags := NewContext(&ContextOpts{
593+
Providers: map[addrs.Provider]providers.Factory{
594+
providerAddr: testProviderFuncFixed(provider),
595+
},
596+
Parallelism: 1,
597+
})
598+
tfdiags.AssertNoDiagnostics(t, diags)
599+
600+
planPolicyClient := policy.NewTestMockClient(t)
601+
plan, diags := ctx.Plan(mod, tc.state, &PlanOpts{
602+
Mode: plans.NormalMode,
603+
SetVariables: testInputValuesUnset(mod.Module.Variables),
604+
PolicyClient: planPolicyClient,
605+
})
606+
tfdiags.AssertNoDiagnostics(t, diags)
607+
608+
applyPolicyClient := policy.NewTestMockClient(t)
609+
var called int
610+
applyPolicyClient.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse {
611+
called++
612+
if req.Target != tc.expectTarget {
613+
t.Fatalf("expected target %s, got %s", tc.expectTarget, req.Target)
614+
}
615+
if diff := cmp.Diff(req.Meta, &proto.ResourceMetadata{
616+
Type: tc.expectTarget,
617+
ProviderType: "test",
618+
Operation: tc.expectOp,
619+
}, protocmp.Transform()); diff != "" {
620+
t.Fatalf("unexpected resource metadata (-got +want):\n%s", diff)
621+
}
622+
623+
actualAttrs := req.Attrs
624+
if !actualAttrs.IsNull() {
625+
mp := actualAttrs.AsValueMap()
626+
actualAttrs = cty.ObjectVal(map[string]cty.Value{
627+
"id": mp["id"],
628+
"sensitive_value": mp["sensitive_value"],
629+
})
630+
}
631+
if diff := cmp.Diff(actualAttrs, tc.expectFinalAttr, cmp.Comparer(cty.Value.RawEquals)); diff != "" {
632+
t.Fatalf("unexpected attrs (-got +want):\n%s", diff)
633+
}
634+
return policy.EvaluationResponse{Overall: policy.AllowResult}
635+
}
636+
637+
_, diags = ctx.Apply(plan, mod, &ApplyOpts{
638+
PolicyClient: applyPolicyClient,
639+
})
640+
tfdiags.AssertNoDiagnostics(t, diags)
641+
642+
if called != tc.expectCalls {
643+
t.Fatalf("expected %d policy evaluation call(s), got %d", tc.expectCalls, called)
644+
}
645+
})
646+
}
647+
}
648+
388649
func TestContext2Apply_PolicyEvaluation_Destroy(t *testing.T) {
389650
mainConfig := `
390651
terraform {

internal/terraform/context_plan.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1057,6 +1057,7 @@ func (c *Context) planGraph(config *configs.Config, prevRunState *states.State,
10571057
Overrides: opts.Overrides,
10581058
SkipGraphValidation: c.graphOpts.SkipGraphValidation,
10591059
AllowRootEphemeralOutputs: opts.AllowRootEphemeralOutputs,
1060+
PolicyClient: opts.PolicyClient,
10601061
}).Build(addrs.RootModuleInstance)
10611062
return graph, walkPlan, diags
10621063
case plans.DestroyMode:
@@ -1073,6 +1074,7 @@ func (c *Context) planGraph(config *configs.Config, prevRunState *states.State,
10731074
SkipGraphValidation: c.graphOpts.SkipGraphValidation,
10741075
overridePreventDestroy: opts.OverridePreventDestroy,
10751076
AllowRootEphemeralOutputs: opts.AllowRootEphemeralOutputs,
1077+
PolicyClient: opts.PolicyClient,
10761078
}).Build(addrs.RootModuleInstance)
10771079
return graph, walkPlanDestroy, diags
10781080
default:

0 commit comments

Comments
 (0)