diff --git a/internal/backend/local/backend_apply.go b/internal/backend/local/backend_apply.go index 4f2dec3bc8e7..de9e5b13cbb8 100644 --- a/internal/backend/local/backend_apply.go +++ b/internal/backend/local/backend_apply.go @@ -12,8 +12,6 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/zclconf/go-cty/cty" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/trace" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/backend/backendrun" @@ -437,7 +435,7 @@ func (b *Local) opApply( SetVariables: applyTimeValues, ProviderLocks: providerLocksSnapshot(op.DependencyLocks), PolicyClient: lr.PolicyClient, - PolicyResults: plan.PolicyResults, + PolicyResults: views.NewStreamingPolicyResults(op.View), }) }() @@ -446,23 +444,8 @@ func (b *Local) opApply( } diags = diags.Append(applyDiags) - // Print the policy results we found during apply - policyResultCount := 0 - if plan.PolicyResults != nil { - policyResultCount = plan.PolicyResults.Len() - } - var polRenderSpan trace.Span - polRenderSpanEnd := func() {} - if policyResultCount > 0 { - _, polRenderSpan = tracer().Start(stopCtx, "terraform.local.apply.render_policy_results", - trace.WithAttributes( - attribute.Int("apply.policy_results", policyResultCount), - ), - ) - polRenderSpanEnd = func() { polRenderSpan.End() } - } - op.View.PolicyResults(plan.PolicyResults, nil) - polRenderSpanEnd() + // Policy results (if any) were streamed to the view live during the apply + // walk, so there is nothing to render here. // Even on error with an empty state, the state value should not be nil. // Return early here to prevent corrupting any existing state. diff --git a/internal/backend/local/backend_plan.go b/internal/backend/local/backend_plan.go index d492d173ac83..06e072382ec8 100644 --- a/internal/backend/local/backend_plan.go +++ b/internal/backend/local/backend_plan.go @@ -9,10 +9,8 @@ import ( "io" "log" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/trace" - "github.com/hashicorp/terraform/internal/backend/backendrun" + "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/genconfig" "github.com/hashicorp/terraform/internal/logging" "github.com/hashicorp/terraform/internal/plans" @@ -113,6 +111,12 @@ func (b *Local) opPlan( // resulting state is always just the input state. runningOp.State = lr.InputState + // Stream policy evaluation results to the view as they are produced during + // the plan walk. + if lr.PlanOpts.PolicyClient != nil { + lr.PlanOpts.PolicyResults = views.NewStreamingPolicyResults(op.View) + } + // Perform the plan in a goroutine so we can be interrupted var plan *plans.Plan var planDiags tfdiags.Diagnostics @@ -225,23 +229,8 @@ func (b *Local) opPlan( op.View.Plan(plan, schemas) - // Report all policy results that may have accumulated during the plan - policyResultCount := 0 - if plan.PolicyResults != nil { - policyResultCount = plan.PolicyResults.Len() - } - var polRenderSpan trace.Span - polRenderSpanEnd := func() {} - if policyResultCount > 0 { - _, polRenderSpan = tracer().Start(stopCtx, "terraform.local.plan.render_policy_results", - trace.WithAttributes( - attribute.Int("plan.policy_results", policyResultCount), - ), - ) - polRenderSpanEnd = func() { polRenderSpan.End() } - } - op.View.PolicyResults(plan.PolicyResults, nil) - polRenderSpanEnd() + // Policy results (if any) were streamed to the view live during the plan + // walk, so there is nothing to render here. // If we've accumulated any diagnostics along the way then we'll show them // here just before we show the summary and next steps. This can potentially diff --git a/internal/command/init.go b/internal/command/init.go index a56814442668..5c3fa0b9da4a 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -32,7 +32,6 @@ import ( "github.com/hashicorp/terraform/internal/getproviders" "github.com/hashicorp/terraform/internal/getproviders/providerreqs" "github.com/hashicorp/terraform/internal/initwd" - "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/policy" "github.com/hashicorp/terraform/internal/providercache" "github.com/hashicorp/terraform/internal/states" @@ -77,7 +76,7 @@ func (c *InitCommand) Run(args []string) int { return c.run(initArgs, view) } -func (c *InitCommand) getModules(ctx context.Context, path, testsDir string, earlyRoot *configs.Module, upgrade bool, view views.Init, policyClient policy.Client) (output bool, abort bool, policyResults *plans.PolicyResults, diags tfdiags.Diagnostics) { +func (c *InitCommand) getModules(ctx context.Context, path, testsDir string, earlyRoot *configs.Module, upgrade bool, view views.Init, policyClient policy.Client) (output bool, abort bool, diags tfdiags.Diagnostics) { testModules := false // We can also have modules buried in test files. for _, file := range earlyRoot.Tests { for _, run := range file.Runs { @@ -89,7 +88,7 @@ func (c *InitCommand) getModules(ctx context.Context, path, testsDir string, ear if len(earlyRoot.ModuleCalls) == 0 && !testModules { // Nothing to do - return false, false, nil, nil + return false, false, nil } ctx, span := tracer.Start(ctx, "install modules", trace.WithAttributes( @@ -110,11 +109,10 @@ func (c *InitCommand) getModules(ctx context.Context, path, testsDir string, ear } hooks := []initwd.ModuleInstallHook{uiHook} if policyClient != nil { - policyResults = plans.NewPolicyResults() policyHook := &policyModuleInstallHook{ client: policyClient, rootModule: earlyRoot, - policyResults: policyResults, + policyResults: views.NewStreamingPolicyResults(view), } hooks = append(hooks, policyHook) } @@ -139,7 +137,7 @@ func (c *InitCommand) getModules(ctx context.Context, path, testsDir string, ear } } - return true, installAbort, policyResults, diags + return true, installAbort, diags } func (c *InitCommand) initCloud(ctx context.Context, root *configs.Module, extraConfig arguments.FlagNameValueSlice, viewType arguments.ViewType, view views.Init) (be backend.Backend, output bool, diags tfdiags.Diagnostics) { diff --git a/internal/command/init_run.go b/internal/command/init_run.go index 6589f6d48971..f6213d359aa1 100644 --- a/internal/command/init_run.go +++ b/internal/command/init_run.go @@ -18,7 +18,6 @@ import ( "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/getproviders" - "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/policy" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/terraform" @@ -210,7 +209,7 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { ) } - policyResults := plans.NewPolicyResults() + policyResults := views.NewStreamingPolicyResults(view) var pssLocks *depsfile.Locks // May end up containing 0 or 1 lock. if rootModEarly.StateStore != nil { @@ -245,7 +244,6 @@ Please use \"terraform state migrate -upgrade\" to upgrade the state store provi configProvidersOutput, pssLocks, safeInitAction, stateStoreProviderAuthResult, configProviderDiags = c.getProvidersFromPSSConfig(ctx, rootModEarly, alteredPreviousLocks, allowUpgrade, initArgs.PluginPath, initArgs.Lockfile, view) diags = diags.Append(configProviderDiags) if configProviderDiags.HasErrors() { - view.PolicyResults(policyResults, nil) view.Diagnostics(diags) return 1 } @@ -368,9 +366,8 @@ Please use \"terraform state migrate -upgrade\" to upgrade the state store provi } if initArgs.Get { - modsOutput, modsAbort, policyResults, modsDiags := c.getModules(ctx, path, initArgs.TestsDirectory, rootModEarly, initArgs.Upgrade, view, policyClient) + modsOutput, modsAbort, modsDiags := c.getModules(ctx, path, initArgs.TestsDirectory, rootModEarly, initArgs.Upgrade, view, policyClient) diags = diags.Append(modsDiags) - view.PolicyResults(policyResults, nil) if modsAbort || modsDiags.HasErrors() { view.Diagnostics(diags) return 1 @@ -439,7 +436,6 @@ Please use \"terraform state migrate -upgrade\" to upgrade the state store provi stateProvidersOutput, providerLocks, stateProvidersDiags := c.getProviders(ctx, config, state, initArgs.Upgrade, pssLocks, initArgs.PluginPath, view, providerHook) diags = diags.Append(stateProvidersDiags) if stateProvidersDiags.HasErrors() { - view.PolicyResults(policyResults, nil) view.Diagnostics(diags) return 1 } @@ -465,7 +461,6 @@ Please use \"terraform state migrate -upgrade\" to upgrade the state store provi // If we accumulated any warnings along the way that weren't accompanied // by errors then we'll output them here so that the success message is // still the final thing shown. - view.PolicyResults(policyResults, nil) view.Diagnostics(diags) _, cloud := back.(*cloud.Cloud) output := views.OutputInitSuccessMessage diff --git a/internal/command/meta_policy.go b/internal/command/meta_policy.go index 1b0373d1640e..d372ef4b1fe9 100644 --- a/internal/command/meta_policy.go +++ b/internal/command/meta_policy.go @@ -74,7 +74,7 @@ type policyModuleInstallHook struct { initwd.ModuleInstallHookImpl client policy.Client rootModule *configs.Module - policyResults *plans.PolicyResults + policyResults plans.PolicyResult } // ModuleSourceResolved implements [initwd.ModuleInstallHook] and is called after a module source is resolved, and enables policy evaluation for the module before @@ -137,7 +137,7 @@ var _ providercache.InstallerHook = &providerPolicyHook{} // providerPolicyHook enables policy evaluation during provider installation. type providerPolicyHook struct { client policy.Client - policyResults *plans.PolicyResults + policyResults plans.PolicyResult rootModule *configs.Module } diff --git a/internal/command/views/init.go b/internal/command/views/init.go index bb2d134945bb..a3b07891c6ad 100644 --- a/internal/command/views/init.go +++ b/internal/command/views/init.go @@ -29,6 +29,7 @@ type ProviderInstaller interface { type Init interface { Diagnostics(diags tfdiags.Diagnostics) PolicyResults(results *plans.PolicyResults, setupDiags policy.Diagnostics) + StreamPolicyResult(addr string, result plans.PolicyEvaluation) Output(messageCode InitMessageCode, params ...any) LogInitMessage(messageCode InitMessageCode, params ...any) Log(message string, params ...any) @@ -70,6 +71,10 @@ func (v *InitHuman) PolicyResults(results *plans.PolicyResults, setupDiags polic v.view.PolicyResults(results, setupDiags) } +func (v *InitHuman) StreamPolicyResult(addr string, result plans.PolicyEvaluation) { + v.view.StreamPolicyResult(addr, result) +} + func (v *InitHuman) Output(messageCode InitMessageCode, params ...any) { v.view.streams.Println(v.PrepareMessage(messageCode, params...)) } @@ -117,6 +122,10 @@ func (v *InitJSON) PolicyResults(results *plans.PolicyResults, setupDiags policy v.view.PolicyResults(results, setupDiags) } +func (v *InitJSON) StreamPolicyResult(addr string, result plans.PolicyEvaluation) { + v.view.StreamPolicyResult(addr, result) +} + func (v *InitJSON) Output(messageCode InitMessageCode, params ...any) { // don't add empty messages to json output preppedMessage := v.PrepareMessage(messageCode, params...) diff --git a/internal/command/views/json_view.go b/internal/command/views/json_view.go index 1d32aa12a6a1..a344d33c6a6a 100644 --- a/internal/command/views/json_view.go +++ b/internal/command/views/json_view.go @@ -146,8 +146,53 @@ func (v *JSONView) Outputs(outputs json.Outputs) { ) } +func (v *JSONView) logPolicyResult(addr string, result plans.PolicyEvaluation) { + // Log all the info messages + for _, enforcement := range result.EvaluationResponse.Enforcements { + if enforcement.Message == "" { + continue + } + var src []byte + if enforcement.LocalRange != nil { + src = v.view.configSources()[enforcement.LocalRange.Filename] + } + info := json.NewPolicyInfo(src, enforcement) + args := []any{ + "type", json.MessagePolicyInfo, + "target_address", addr, + json.MessagePolicyInfo, info, + "@policy", "true", + "result", enforcement.Result.String(), + } + if enforcement.Policy != nil { + args = append(args, "policy_metadata", json.MetadataFromEnforcement(enforcement)) + } + v.log.Info("Policy info", args...) + } + + for _, diag := range result.EvaluationResponse.Diagnostics { + v.logPolicyDiagnostic(diag, "target_address", addr) + } + + for _, policy := range result.EvaluationResponse.Policies { + v.log.Info( + "Policy Result", + "type", json.MessagePolicyEvaluationResult, + "result", policy.Result.String(), + "target_address", addr, + "policy_address", policy.Address, + "@policy", "true", + "policy_metadata", json.MetadataFromPolicy(*policy), + ) + } +} + +func (v *JSONView) StreamPolicyResult(addr string, result plans.PolicyEvaluation) { + v.logPolicyResult(addr, result) +} + func (v *JSONView) PolicyResults(results *plans.PolicyResults, setupDiags policy.Diagnostics) { - // Log all non-policy-specific diagnostics if any. + for _, diag := range setupDiags { v.logPolicyDiagnostic(diag) } @@ -157,44 +202,7 @@ func (v *JSONView) PolicyResults(results *plans.PolicyResults, setupDiags policy } for addr, result := range results.Iter() { - // Log all the info messages - for _, enforcement := range result.EvaluationResponse.Enforcements { - if enforcement.Message == "" { - continue - } - var src []byte - if enforcement.LocalRange != nil { - src = v.view.configSources()[enforcement.LocalRange.Filename] - } - info := json.NewPolicyInfo(src, enforcement) - args := []any{ - "type", json.MessagePolicyInfo, - "target_address", addr, - json.MessagePolicyInfo, info, - "@policy", "true", - "result", enforcement.Result.String(), - } - if enforcement.Policy != nil { - args = append(args, "policy_metadata", json.MetadataFromEnforcement(enforcement)) - } - v.log.Info("Policy info", args...) - } - - for _, diag := range result.EvaluationResponse.Diagnostics { - v.logPolicyDiagnostic(diag, "target_address", addr) - } - - for _, policy := range result.EvaluationResponse.Policies { - v.log.Info( - "Policy Result", - "type", json.MessagePolicyEvaluationResult, - "result", policy.Result.String(), - "target_address", addr, - "policy_address", policy.Address, - "@policy", "true", - "policy_metadata", json.MetadataFromPolicy(*policy), - ) - } + v.logPolicyResult(addr, result) } } diff --git a/internal/command/views/operation.go b/internal/command/views/operation.go index bd6c672e6055..fc20276d45c1 100644 --- a/internal/command/views/operation.go +++ b/internal/command/views/operation.go @@ -37,6 +37,8 @@ type Operation interface { Diagnostics(diags tfdiags.Diagnostics) PolicyResults(results *plans.PolicyResults, setupDiags policy.Diagnostics) + + StreamPolicyResult(addr string, result plans.PolicyEvaluation) } func NewOperation(vt arguments.ViewType, inAutomation bool, view *View) Operation { @@ -138,6 +140,10 @@ func (v *OperationHuman) PolicyResults(results *plans.PolicyResults, setupDiags v.view.PolicyResults(results, setupDiags) } +func (v *OperationHuman) StreamPolicyResult(addr string, result plans.PolicyEvaluation) { + v.view.StreamPolicyResult(addr, result) +} + func (v *OperationHuman) PlannedChange(change *plans.ResourceInstanceChangeSrc) { // PlannedChange is primarily for machine-readable output in order to // get a per-resource-instance change description. We don't use it @@ -301,6 +307,10 @@ func (v *OperationJSON) PolicyResults(results *plans.PolicyResults, setupDiags p v.view.PolicyResults(results, setupDiags) } +func (v *OperationJSON) StreamPolicyResult(addr string, result plans.PolicyEvaluation) { + v.view.StreamPolicyResult(addr, result) +} + const fatalInterrupt = ` Two interrupts received. Exiting immediately. Note that data loss may have occurred. ` diff --git a/internal/command/views/policy_results_stream.go b/internal/command/views/policy_results_stream.go new file mode 100644 index 000000000000..594580a7387f --- /dev/null +++ b/internal/command/views/policy_results_stream.go @@ -0,0 +1,76 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package views + +import ( + "sync" + + "github.com/hashicorp/hcl/v2" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/policy" +) + +// streamingPolicyResults is a plans.PolicyResult that renders each result to +// the operation view as soon as it is produced and retains nothing, bounding +// memory regardless of plan size. It is the workspace (CLI) sink; stacks +// continues to use the buffered *plans.PolicyResults. +// policyResultStreamer is the minimal view capability the streaming sink +// needs: rendering a single policy result immediately. Both views.Operation +// (plan and apply) and views.Init (init) satisfy it. +type policyResultStreamer interface { + StreamPolicyResult(addr string, result plans.PolicyEvaluation) +} + +type streamingPolicyResults struct { + view policyResultStreamer + mu sync.Mutex // the graph walk / installer calls Add* concurrently +} + +var _ plans.PolicyResult = (*streamingPolicyResults)(nil) + +// NewStreamingPolicyResults returns a streaming policy-results sink backed by +// the given view. Every AddResource/AddModule/AddProvider call is rendered +// immediately and dropped, so nothing is retained. +func NewStreamingPolicyResults(view policyResultStreamer) plans.PolicyResult { + return &streamingPolicyResults{view: view} +} + +func (s *streamingPolicyResults) AddResource(addr addrs.AbsResourceInstance, result policy.EvaluationResponse, config *configs.Resource) { + var rng hcl.Range + if config != nil { + rng = config.DeclRange + } + s.emit(addr.String(), result, rng) +} + +func (s *streamingPolicyResults) AddModule(addr addrs.Module, result policy.EvaluationResponse, config *configs.ModuleCall) { + var rng hcl.Range + if config != nil { + rng = config.DeclRange + } + s.emit(addr.String(), result, rng) +} + +func (s *streamingPolicyResults) AddProvider(addr addrs.AbsProviderConfig, result policy.EvaluationResponse, config *configs.Provider) { + var rng hcl.Range + if config != nil { + rng = config.DeclRange + } + s.emit(addr.String(), result, rng) +} + +func (s *streamingPolicyResults) emit(addr string, result policy.EvaluationResponse, rng hcl.Range) { + if result.Empty() { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.view.StreamPolicyResult(addr, plans.PolicyEvaluation{ + EvaluationResponse: result, + ConfigDeclRange: rng, + }) +} diff --git a/internal/command/views/query_operation.go b/internal/command/views/query_operation.go index 1d74a4a94b14..a25ae12344af 100644 --- a/internal/command/views/query_operation.go +++ b/internal/command/views/query_operation.go @@ -102,6 +102,10 @@ func (v *QueryOperationHuman) PolicyResults(results *plans.PolicyResults, setupD v.view.PolicyResults(results, setupDiags) } +func (v *QueryOperationHuman) StreamPolicyResult(addr string, result plans.PolicyEvaluation) { + v.view.StreamPolicyResult(addr, result) +} + type QueryOperationJSON struct { view *JSONView } @@ -144,3 +148,7 @@ func (v *QueryOperationJSON) Diagnostics(diags tfdiags.Diagnostics) { func (v *QueryOperationJSON) PolicyResults(results *plans.PolicyResults, setupDiags policy.Diagnostics) { v.view.PolicyResults(results, setupDiags) } + +func (v *QueryOperationJSON) StreamPolicyResult(addr string, result plans.PolicyEvaluation) { + v.view.StreamPolicyResult(addr, result) +} diff --git a/internal/command/views/view.go b/internal/command/views/view.go index 36dbfde5efa9..484bbb5d5918 100644 --- a/internal/command/views/view.go +++ b/internal/command/views/view.go @@ -196,6 +196,63 @@ func (v *View) PolicyResults(results *plans.PolicyResults, setupDiags policy.Dia } } +func (v *View) StreamPolicyResult(addr string, result plans.PolicyEvaluation) { + configSources := v.configSources() + var buf strings.Builder + var foundInfo bool + + for _, enforcement := range result.EvaluationResponse.Enforcements { + var src []byte + if enforcement.LocalRange != nil { + src = configSources[enforcement.LocalRange.Filename] + } + info := json.NewPolicyInfo(src, enforcement) + // Print info message attached to the enforcement + if info.Message != "" { + foundInfo = true + buf.WriteString("Policy Info:\n") + if info.PolicyRange != nil && info.PolicySnippet != nil { + fmt.Fprintf( + &buf, + "on %s line %d, in %s\n", + info.PolicyRange.Filename, + info.PolicyRange.Start.Line, + info.PolicySnippet.Code, + ) + } else if enforcement.Policy != nil { + fmt.Fprintf( + &buf, + "in policy %s\n", + enforcement.Policy.Address, + ) + } + fmt.Fprintf(&buf, "%q\n", info.Message) + + if !result.ConfigDeclRange.Empty() { + cfgRange := result.ConfigDeclRange + resourceContext := string(cfgRange.SliceBytes(configSources[cfgRange.Filename])) + + fmt.Fprintf( + &buf, + "\non %s line %d, in %s\n", + cfgRange.Filename, + cfgRange.Start.Line, + resourceContext, + ) + } + buf.WriteString("\n") + } + } + + // Print policy diagnostics + v.Diagnostics(result.EvaluationResponse.Diagnostics.AsTerraformDiags()) + + if foundInfo { + v.streams.Println() + v.streams.Println(buf.String()) + } +} + // HelpPrompt is intended to be called from commands which fail to parse all // of their CLI arguments successfully. It refers users to the full help output // rather than rendering it directly, which can be overwhelming and confusing. diff --git a/internal/plans/policy.go b/internal/plans/policy.go index 1000510b70cf..06d22973e1c1 100644 --- a/internal/plans/policy.go +++ b/internal/plans/policy.go @@ -13,6 +13,12 @@ import ( "github.com/hashicorp/terraform/internal/policy" ) +type PolicyResult interface { + AddResource(addr addrs.AbsResourceInstance, result policy.EvaluationResponse, config *configs.Resource) + AddModule(addr addrs.Module, result policy.EvaluationResponse, config *configs.ModuleCall) + AddProvider(addr addrs.AbsProviderConfig, result policy.EvaluationResponse, config *configs.Provider) +} + // PolicyResults represents the results of policy evaluation of resources, modules, and providers for a single plan. type PolicyResults struct { mu *sync.Mutex @@ -21,6 +27,20 @@ type PolicyResults struct { mset addrs.Map[addrs.Module, PolicyEvaluation] } +// *PolicyResults is the buffered implementation of PolicyResult. +var _ PolicyResult = (*PolicyResults)(nil) + +// AsPolicyResult adapts a concrete *PolicyResults to the PolicyResult +// interface, converting a nil pointer into a true nil interface. Use this at +// every concrete->interface boundary so callers' `!= nil` checks stay correct +// and never see a typed-nil. +func AsPolicyResult(pr *PolicyResults) PolicyResult { + if pr == nil { + return nil + } + return pr +} + // PolicyEvaluation holds the result of a policy evaluation for a single resource, module, or provider. type PolicyEvaluation struct { EvaluationResponse policy.EvaluationResponse diff --git a/internal/stacks/stackruntime/internal/stackeval/applying.go b/internal/stacks/stackruntime/internal/stackeval/applying.go index 10da64bdf84e..471325f9ad7d 100644 --- a/internal/stacks/stackruntime/internal/stackeval/applying.go +++ b/internal/stacks/stackruntime/internal/stackeval/applying.go @@ -273,7 +273,7 @@ func ApplyComponentPlan(ctx context.Context, main *Main, plan *plans.Plan, requi newState, moreDiags = tfCtx.Apply(plan, moduleTree, &terraform.ApplyOpts{ ExternalProviders: providerClients, PolicyClient: policyClient, - PolicyResults: policyResults, + PolicyResults: plans.AsPolicyResult(policyResults), AllowRootEphemeralOutputs: false, // TODO(issues/37822): Enable this. }) diags = diags.Append(moreDiags) diff --git a/internal/terraform/context_apply.go b/internal/terraform/context_apply.go index ac76c3e5438d..9fec96ad1cfb 100644 --- a/internal/terraform/context_apply.go +++ b/internal/terraform/context_apply.go @@ -57,7 +57,7 @@ type ApplyOpts struct { // When set, policy evaluation logic will be executed in the graph. // When nil, that logic will be skipped. PolicyClient policy.Client - PolicyResults *plans.PolicyResults + PolicyResults plans.PolicyResult } // ApplyOpts creates an [ApplyOpts] with copies of all of the elements that diff --git a/internal/terraform/context_plan.go b/internal/terraform/context_plan.go index f0e2b57c95c7..671a8c1fcd97 100644 --- a/internal/terraform/context_plan.go +++ b/internal/terraform/context_plan.go @@ -169,6 +169,11 @@ type PlanOpts struct { // Optional policy client to enable live policy evaluations. PolicyClient policy.Client + + // PolicyResults, when non nil, is the sink that policy evaluation results + // are written to during the walk. When nil, a buffered *PolicyResults is created and + // attached to the resulting plan (used by stacks currently). + PolicyResults plans.PolicyResult } // Plan generates an execution plan by comparing the given configuration @@ -802,8 +807,15 @@ func (c *Context) planWalk(config *configs.Config, prevRunState *states.State, o // Hold reference to this so we can store the table data in the plan file. funcResults := lang.NewFunctionResultsTable(nil) - // Initialize the map to store policy evaluation results. - policyResults := plans.NewPolicyResults() + // The sink that policy evaluation results are written to. If the caller + // injected one (e.g. a streaming sink), use it and don't retain results on + // the plan; otherwise buffer into a *PolicyResults attached to the plan. + var bufferedPolicyResults *plans.PolicyResults + policyResults := opts.PolicyResults + if policyResults == nil { + bufferedPolicyResults = plans.NewPolicyResults() + policyResults = bufferedPolicyResults + } walker, walkDiags := c.walk(graph, walkOp, &graphWalkOpts{ Config: config, @@ -901,9 +913,9 @@ func (c *Context) planWalk(config *configs.Config, prevRunState *states.State, o // Other fields get populated by Context.Plan after we return } - if policyResults != nil { - plan.PolicyResults = policyResults - } + // Only the buffered sink is retained on the plan; a streaming sink keeps + // nothing (results were rendered live during the walk). + plan.PolicyResults = bufferedPolicyResults if !schemaDiags.HasErrors() { deferredResources, deferredDiags := c.deferredResources(schemas, walker.Deferrals.GetDeferredChanges()) diff --git a/internal/terraform/context_walk.go b/internal/terraform/context_walk.go index 29f11a591245..465f6202088e 100644 --- a/internal/terraform/context_walk.go +++ b/internal/terraform/context_walk.go @@ -86,7 +86,7 @@ type graphWalkOpts struct { ProviderLocks map[addrs.Provider]*depsfile.ProviderLock PolicyClient policy.Client - PolicyResults *plans.PolicyResults + PolicyResults plans.PolicyResult } func (c *Context) walk(graph *Graph, operation walkOperation, opts *graphWalkOpts) (*ContextGraphWalker, tfdiags.Diagnostics) { diff --git a/internal/terraform/eval_context.go b/internal/terraform/eval_context.go index 21c91bd00882..c26a203cac04 100644 --- a/internal/terraform/eval_context.go +++ b/internal/terraform/eval_context.go @@ -236,7 +236,7 @@ type EvalContext interface { PolicyClient() policy.Client // PolicyResults returns the object that tracks policy evaluation results. - PolicyResults() *plans.PolicyResults + PolicyResults() plans.PolicyResult Config() *configs.Config diff --git a/internal/terraform/eval_context_builtin.go b/internal/terraform/eval_context_builtin.go index 5956d0232b6d..1c6d2c034620 100644 --- a/internal/terraform/eval_context_builtin.go +++ b/internal/terraform/eval_context_builtin.go @@ -97,7 +97,7 @@ type BuiltinEvalContext struct { OverrideValues *mocking.Overrides ProviderLocksValue map[addrs.Provider]*depsfile.ProviderLock PolicyClientValue policy.Client - PolicyResultsValue *plans.PolicyResults + PolicyResultsValue plans.PolicyResult DeprecationsValue *deprecation.Deprecations } @@ -109,7 +109,7 @@ func (ctx *BuiltinEvalContext) PolicyClient() policy.Client { return ctx.PolicyClientValue } -func (ctx *BuiltinEvalContext) PolicyResults() *plans.PolicyResults { +func (ctx *BuiltinEvalContext) PolicyResults() plans.PolicyResult { return ctx.PolicyResultsValue } diff --git a/internal/terraform/eval_context_mock.go b/internal/terraform/eval_context_mock.go index 15363fccfe10..c3303fd8c0fd 100644 --- a/internal/terraform/eval_context_mock.go +++ b/internal/terraform/eval_context_mock.go @@ -175,7 +175,7 @@ type MockEvalContext struct { ProviderLocksValue map[addrs.Provider]*depsfile.ProviderLock PolicyClientValue policy.Client - PolicyResultsValue *plans.PolicyResults + PolicyResultsValue plans.PolicyResult ConfigValue *configs.Config DeprecationCalled bool DeprecationState *deprecation.Deprecations @@ -475,7 +475,7 @@ func (c *MockEvalContext) Config() *configs.Config { return c.ConfigValue } -func (c *MockEvalContext) PolicyResults() *plans.PolicyResults { +func (c *MockEvalContext) PolicyResults() plans.PolicyResult { return c.PolicyResultsValue } diff --git a/internal/terraform/graph_walk_context.go b/internal/terraform/graph_walk_context.go index 13b1c624cab6..842f8c21bad3 100644 --- a/internal/terraform/graph_walk_context.go +++ b/internal/terraform/graph_walk_context.go @@ -67,8 +67,8 @@ type ContextGraphWalker struct { ProviderLocks map[addrs.Provider]*depsfile.ProviderLock PolicyClient policy.Client - PolicyResults *plans.PolicyResults // Used to store policy evaluation results - PolicyGraph *policySubgraph // Used for writing resource policy evaluation nodes + PolicyResults plans.PolicyResult // Used to store policy evaluation results + PolicyGraph *policySubgraph // Used for writing resource policy evaluation nodes once sync.Once contexts collections.Map[evalContextScope, *BuiltinEvalContext] diff --git a/internal/terraform/node_policy_resource.go b/internal/terraform/node_policy_resource.go index fde36ee7d4f8..28fce2aa8982 100644 --- a/internal/terraform/node_policy_resource.go +++ b/internal/terraform/node_policy_resource.go @@ -103,7 +103,9 @@ func (n *nodeResourcePolicy) Execute(ctx EvalContext, operation walkOperation) t } result := evaluatePolicies(ctx, n.ResourceAddr, resourceConfig, n.After, n.Before, meta, callbacks) - ctx.PolicyResults().AddResource(n.ResourceAddr, result, resourceConfig) + if ctx.PolicyResults() != nil { + ctx.PolicyResults().AddResource(n.ResourceAddr, result, resourceConfig) + } return diags }