Add CLI and backend policy wiring for plan apply and query

This commit is contained in:
Samsondeen Dare 2026-05-04 17:22:07 +02:00
parent d4ca814cbe
commit efc3b9e59d
40 changed files with 2010 additions and 66 deletions

View file

@ -4,8 +4,11 @@
package backendrun
import (
"context"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/policy"
"github.com/hashicorp/terraform/internal/states"
"github.com/hashicorp/terraform/internal/states/statemgr"
"github.com/hashicorp/terraform/internal/terraform"
@ -29,7 +32,11 @@ type Local interface {
// backend's implementations of this to understand what this actually
// does, because this operation has no well-defined contract aside from
// "whatever it already does".
LocalRun(*Operation) (*LocalRun, statemgr.Full, tfdiags.Diagnostics)
LocalRun(context.Context, *Operation) (*LocalRun, statemgr.Full, tfdiags.Diagnostics)
// Finish should be called when the local run has completed executing and
// the resources should be cleaned up.
Finish()
}
// LocalRun represents the assortment of objects that we can collect or
@ -77,4 +84,17 @@ type LocalRun struct {
//
// This is nil when we're not applying a saved plan.
Plan *plans.Plan
// PolicyClient is an optional argument that enables policy evaluations
// during the run.
PolicyClient policy.Client
}
func (lr *LocalRun) Finish() {
if lr == nil {
return
}
if lr.PolicyClient != nil {
lr.PolicyClient.Stop()
}
}

View file

@ -18,6 +18,7 @@ import (
"github.com/hashicorp/terraform/internal/depsfile"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/plans/planfile"
"github.com/hashicorp/terraform/internal/policy"
"github.com/hashicorp/terraform/internal/states"
"github.com/hashicorp/terraform/internal/terraform"
"github.com/hashicorp/terraform/internal/tfdiags"
@ -165,6 +166,11 @@ type Operation struct {
// Query is true if the operation should be a query operation
Query bool
// PolicyPaths will trigger Terraform to load policies from the specified
// paths.
PolicyPaths []string
PolicyClient policy.Client
}
// HasConfig returns true if and only if the operation has a ConfigDir value

View file

@ -13,6 +13,8 @@ import (
"sort"
"sync"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/backend/backendrun"
"github.com/hashicorp/terraform/internal/command/views"
@ -21,7 +23,6 @@ import (
"github.com/hashicorp/terraform/internal/states/statemgr"
"github.com/hashicorp/terraform/internal/terraform"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
)
const (
@ -113,6 +114,10 @@ func NewWithBackend(backend backend.Backend) *Local {
}
}
func (b *Local) Finish() {
// nothing to do
}
func (b *Local) ConfigSchema() *configschema.Block {
if b.Backend != nil {
return b.Backend.ConfigSchema()

View file

@ -59,6 +59,8 @@ func (b *Local) opApply(
// Get our context
lr, _, opState, contextDiags := b.localRun(op)
defer lr.Finish()
diags = diags.Append(contextDiags)
if contextDiags.HasErrors() {
op.ReportResult(runningOp, diags)
@ -94,6 +96,8 @@ func (b *Local) opApply(
combinedPlanApply := false
// If we weren't given a plan, then we refresh/plan
if op.PlanFile == nil {
// set the policy client to nil for the plan preceding apply
lr.PlanOpts.PolicyClient = nil
combinedPlanApply = true
// Perform the plan
log.Printf("[INFO] backend/local: apply calling Plan")
@ -110,6 +114,8 @@ func (b *Local) opApply(
if plan != nil && (len(plan.Changes.Resources) != 0 || len(plan.Changes.Outputs) != 0) {
op.View.Plan(plan, schemas)
}
// Report all policy results that may have accumulated during the plan
op.View.PolicyResults(plan.PolicyResults)
op.ReportResult(runningOp, diags)
return
}
@ -420,6 +426,10 @@ func (b *Local) opApply(
// Start the apply in a goroutine so that we can be interrupted.
var applyState *states.State
var applyDiags tfdiags.Diagnostics
// We use a new store for the apply policy results, as objects that failed during the plan policy
// evaluation may have updated data which yields a different policy evaluation result.
policyResults := plans.NewPolicyResults()
doneCh := make(chan struct{})
go func() {
defer logging.PanicHandler()
@ -427,7 +437,10 @@ func (b *Local) opApply(
log.Printf("[INFO] backend/local: apply calling Apply")
applyState, applyDiags = lr.Core.Apply(plan, lr.Config, &terraform.ApplyOpts{
SetVariables: applyTimeValues,
SetVariables: applyTimeValues,
Locks: providerLocksSnapshot(op.DependencyLocks),
PolicyClient: lr.PolicyClient,
PolicyResults: policyResults,
})
}()
@ -436,6 +449,9 @@ func (b *Local) opApply(
}
diags = diags.Append(applyDiags)
// Print the policy results we found during apply
op.View.PolicyResults(policyResults)
// Even on error with an empty state, the state value should not be nil.
// Return early here to prevent corrupting any existing state.
if diags.HasErrors() && applyState == nil {

View file

@ -14,10 +14,12 @@ import (
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/backend/backendrun"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/configs/configload"
"github.com/hashicorp/terraform/internal/depsfile"
"github.com/hashicorp/terraform/internal/lang"
"github.com/hashicorp/terraform/internal/plans/planfile"
"github.com/hashicorp/terraform/internal/states/statemgr"
@ -26,7 +28,7 @@ import (
)
// backendrun.Local implementation.
func (b *Local) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, statemgr.Full, tfdiags.Diagnostics) {
func (b *Local) LocalRun(ctx context.Context, op *backendrun.Operation) (*backendrun.LocalRun, statemgr.Full, tfdiags.Diagnostics) {
// Make sure the type is invalid. We use this as a way to know not
// to ask for input/validate. We're modifying this through a pointer,
// so we're mutating an object that belongs to the caller here, which
@ -35,7 +37,7 @@ func (b *Local) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, statem
// happens to do.
op.Type = backendrun.OperationTypeInvalid
op.StateLocker = op.StateLocker.WithContext(context.Background())
op.StateLocker = op.StateLocker.WithContext(ctx)
lr, _, stateMgr, diags := b.localRun(op)
return lr, stateMgr, diags
@ -70,8 +72,6 @@ func (b *Local) localRun(op *backendrun.Operation) (*backendrun.LocalRun, *confi
return nil, nil, nil, diags
}
ret := &backendrun.LocalRun{}
// Initialize our context options
var coreOpts terraform.ContextOpts
if v := b.ContextOpts; v != nil {
@ -80,11 +80,16 @@ func (b *Local) localRun(op *backendrun.Operation) (*backendrun.LocalRun, *confi
coreOpts.UIInput = op.UIIn
coreOpts.Hooks = op.Hooks
// the run must be closed now
ret := &backendrun.LocalRun{
PolicyClient: op.PolicyClient,
}
var ctxDiags tfdiags.Diagnostics
var configSnap *configload.Snapshot
if op.PlanFile.IsCloud() {
diags = diags.Append(fmt.Errorf("error: using a saved cloud plan when executing Terraform locally is not supported"))
return nil, nil, nil, diags
return ret, nil, nil, diags
}
if lp, ok := op.PlanFile.Local(); ok {
@ -100,7 +105,7 @@ func (b *Local) localRun(op *backendrun.Operation) (*backendrun.LocalRun, *confi
ret, configSnap, ctxDiags = b.localRunForPlanFile(op, lp, ret, &coreOpts, stateMeta)
if ctxDiags.HasErrors() {
diags = diags.Append(ctxDiags)
return nil, nil, nil, diags
return ret, nil, nil, diags
}
// Write sources into the cache of the main loader so that they are
@ -112,7 +117,7 @@ func (b *Local) localRun(op *backendrun.Operation) (*backendrun.LocalRun, *confi
}
diags = diags.Append(ctxDiags)
if diags.HasErrors() {
return nil, nil, nil, diags
return ret, nil, nil, diags
}
// If we have an operation, then we automatically do the input/validate
@ -126,7 +131,7 @@ func (b *Local) localRun(op *backendrun.Operation) (*backendrun.LocalRun, *confi
inputDiags := ret.Core.Input(ret.Config, mode)
diags = diags.Append(inputDiags)
if inputDiags.HasErrors() {
return nil, nil, nil, diags
return ret, nil, nil, diags
}
}
@ -149,7 +154,7 @@ func (b *Local) localRunDirect(op *backendrun.Operation, run *backendrun.LocalRu
rootMod, configDiags := op.ConfigLoader.LoadRootModule(op.ConfigDir)
diags = diags.Append(configDiags)
if configDiags.HasErrors() {
return nil, nil, diags
return run, nil, diags
}
var rawVariables map[string]arguments.UnparsedVariableValue
@ -170,7 +175,7 @@ func (b *Local) localRunDirect(op *backendrun.Operation, run *backendrun.LocalRu
variables, varDiags := backendrun.ParseVariableValues(rawVariables, rootMod.Variables)
diags = diags.Append(varDiags)
if diags.HasErrors() {
return nil, nil, diags
return run, nil, diags
}
planOpts := &terraform.PlanOpts{
@ -183,6 +188,8 @@ func (b *Local) localRunDirect(op *backendrun.Operation, run *backendrun.LocalRu
GenerateConfigPath: op.GenerateConfigOut,
DeferralAllowed: op.DeferralAllowed,
Query: op.Query,
Locks: providerLocksSnapshot(op.DependencyLocks),
PolicyClient: run.PolicyClient,
}
run.PlanOpts = planOpts
@ -193,7 +200,7 @@ func (b *Local) localRunDirect(op *backendrun.Operation, run *backendrun.LocalRu
tfCtx, moreDiags := terraform.NewContext(coreOpts)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
return nil, nil, diags
return run, nil, diags
}
run.Core = tfCtx
@ -261,7 +268,7 @@ func (b *Local) localRunForPlanFile(op *backendrun.Operation, pf *planfile.Reade
errSummary,
fmt.Sprintf("Failed to read configuration snapshot from plan file: %s.", err),
))
return nil, snap, diags
return run, snap, diags
}
loader := configload.NewLoaderFromSnapshot(snap)
loader.AllowLanguageExperiments(op.ConfigLoader.AllowsLanguageExperiments())
@ -299,7 +306,7 @@ func (b *Local) localRunForPlanFile(op *backendrun.Operation, pf *planfile.Reade
errSummary,
fmt.Sprintf("Failed to read prior state snapshot from plan file: %s.", err),
))
return nil, snap, diags
return run, snap, diags
}
if currentStateMeta != nil {
@ -343,7 +350,7 @@ func (b *Local) localRunForPlanFile(op *backendrun.Operation, pf *planfile.Reade
errSummary,
fmt.Sprintf("Failed to read plan from plan file: %s.", err),
))
return nil, snap, diags
return run, snap, diags
}
// When we're applying a saved plan, we populate Plan instead of PlanOpts,
// because a plan object incorporates the subset of data from PlanOps that
@ -377,7 +384,7 @@ func (b *Local) localRunForPlanFile(op *backendrun.Operation, pf *planfile.Reade
tfCtx, moreDiags := terraform.NewContext(coreOpts)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
return nil, nil, diags
return run, nil, diags
}
run.Core = tfCtx
@ -596,3 +603,12 @@ func (v unparsedTestVariableValue) ParseVariableValue(mode configs.VariableParsi
SourceRange: tfdiags.SourceRangeFromHCL(v.Expr.Range()),
}, diags
}
// providerLocksSnapshot returns a read-only snapshot of provider locks for
// use during graph walks. Returns nil if locks is nil.
func providerLocksSnapshot(locks *depsfile.Locks) map[addrs.Provider]*depsfile.ProviderLock {
if locks == nil {
return nil
}
return locks.AllProviders()
}

View file

@ -48,7 +48,7 @@ func TestLocalRun(t *testing.T) {
StateLocker: stateLocker,
}
_, _, diags := b.LocalRun(op)
_, _, diags := b.LocalRun(context.Background(), op)
if diags.HasErrors() {
t.Fatalf("unexpected error: %s", diags.Err().Error())
}
@ -79,7 +79,7 @@ func TestLocalRun_error(t *testing.T) {
StateLocker: stateLocker,
}
_, _, diags := b.LocalRun(op)
_, _, diags := b.LocalRun(context.Background(), op)
if !diags.HasErrors() {
t.Fatal("unexpected success")
}
@ -114,7 +114,7 @@ func TestLocalRun_cloudPlan(t *testing.T) {
StateLocker: stateLocker,
}
_, _, diags := b.LocalRun(op)
_, _, diags := b.LocalRun(context.Background(), op)
if !diags.HasErrors() {
t.Fatal("unexpected success")
}
@ -201,7 +201,7 @@ func TestLocalRun_stalePlan(t *testing.T) {
StateLocker: stateLocker,
}
_, _, diags := b.LocalRun(op)
_, _, diags := b.LocalRun(context.Background(), op)
if !diags.HasErrors() {
t.Fatal("unexpected success")
}

View file

@ -90,6 +90,8 @@ func (b *Local) opPlan(
// Set up backend and get our context
lr, configSnap, opState, ctxDiags := b.localRun(op)
defer lr.Finish()
diags = diags.Append(ctxDiags)
if ctxDiags.HasErrors() {
op.ReportResult(runningOp, diags)
@ -211,6 +213,9 @@ func (b *Local) opPlan(
return
}
// set the config sources of the plan
plan.ConfigSources = op.ConfigLoader.Sources()
// Write out any generated config, before we render the plan.
wroteConfig, moreDiags := maybeWriteGeneratedConfig(plan, op.GenerateConfigOut)
diags = diags.Append(moreDiags)
@ -221,6 +226,9 @@ func (b *Local) opPlan(
op.View.Plan(plan, schemas)
// Report all policy results that may have accumulated during the plan
op.View.PolicyResults(plan.PolicyResults)
// 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
// include errors, because we intentionally try to show a partial plan

View file

@ -49,6 +49,8 @@ func (b *Local) opRefresh(
// Get our context
lr, _, opState, contextDiags := b.localRun(op)
defer lr.Finish()
diags = diags.Append(contextDiags)
if contextDiags.HasErrors() {
op.ReportResult(runningOp, diags)

View file

@ -24,7 +24,7 @@ import (
)
// Context implements backendrun.Local.
func (b *Remote) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, statemgr.Full, tfdiags.Diagnostics) {
func (b *Remote) LocalRun(ctx context.Context, op *backendrun.Operation) (*backendrun.LocalRun, statemgr.Full, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
ret := &backendrun.LocalRun{
PlanOpts: &terraform.PlanOpts{
@ -33,7 +33,7 @@ func (b *Remote) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, state
},
}
op.StateLocker = op.StateLocker.WithContext(context.Background())
op.StateLocker = op.StateLocker.WithContext(ctx)
// Get the remote workspace name.
remoteWorkspaceName := b.getRemoteWorkspaceName(op.Workspace)
@ -171,6 +171,10 @@ func (b *Remote) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, state
return ret, stateMgr, diags
}
func (b *Remote) Finish() {
// nothing to do
}
func (b *Remote) getRemoteWorkspaceName(localWorkspaceName string) string {
switch {
case localWorkspaceName == backend.DefaultStateName:

View file

@ -212,7 +212,7 @@ func TestRemoteContextWithVars(t *testing.T) {
}
b.client.Variables.Create(context.TODO(), workspaceID, *v)
_, _, diags := b.LocalRun(op)
_, _, diags := b.LocalRun(context.Background(), op)
if test.WantError != "" {
if !diags.HasErrors() {
@ -433,7 +433,7 @@ func TestRemoteVariablesDoNotOverride(t *testing.T) {
b.client.Variables.Create(context.TODO(), workspaceID, *v)
}
lr, _, diags := b.LocalRun(op)
lr, _, diags := b.LocalRun(context.Background(), op)
if diags.HasErrors() {
t.Fatalf("unexpected error\ngot: %s\nwant: <no error>", diags.Err().Error())

View file

@ -23,7 +23,7 @@ import (
)
// LocalRun implements backendrun.Local
func (b *Cloud) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, statemgr.Full, tfdiags.Diagnostics) {
func (b *Cloud) LocalRun(ctx context.Context, op *backendrun.Operation) (*backendrun.LocalRun, statemgr.Full, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
ret := &backendrun.LocalRun{
PlanOpts: &terraform.PlanOpts{
@ -32,7 +32,7 @@ func (b *Cloud) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, statem
},
}
op.StateLocker = op.StateLocker.WithContext(context.Background())
op.StateLocker = op.StateLocker.WithContext(ctx)
// Get the remote workspace name.
remoteWorkspaceName := b.getRemoteWorkspaceName(op.Workspace)
@ -149,6 +149,10 @@ func (b *Cloud) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, statem
return ret, stateMgr, diags
}
func (b *Cloud) Finish() {
// nothing to do here
}
func (b *Cloud) getRemoteWorkspaceName(localWorkspaceName string) string {
switch {
case localWorkspaceName == backend.DefaultStateName:

View file

@ -212,7 +212,7 @@ func TestRemoteContextWithVars(t *testing.T) {
}
b.client.Variables.Create(context.TODO(), workspaceID, *v)
_, _, diags := b.LocalRun(op)
_, _, diags := b.LocalRun(context.Background(), op)
if test.WantError != "" {
if !diags.HasErrors() {
@ -433,7 +433,7 @@ func TestRemoteVariablesDoNotOverride(t *testing.T) {
b.client.Variables.Create(context.TODO(), workspaceID, *v)
}
lr, _, diags := b.LocalRun(op)
lr, _, diags := b.LocalRun(context.Background(), op)
if diags.HasErrors() {
t.Fatalf("unexpected error\ngot: %s\nwant: <no error>", diags.Err().Error())

View file

@ -199,6 +199,10 @@ func (b *Cloud) plan(stopCtx, cancelCtx context.Context, op *backendrun.Operatio
}
}
if len(op.PolicyPaths) != 0 {
runOptions.PolicyPaths = append(runOptions.PolicyPaths, op.PolicyPaths...)
}
runVariables, err := b.parseRunVariables(op)
if err != nil {
return nil, err
@ -318,6 +322,10 @@ func (b *Cloud) plan(stopCtx, cancelCtx context.Context, op *backendrun.Operatio
}
}
if len(r.PolicyPaths) > 0 && shouldRenderPlan(r) {
b.renderer.Streams.Println(b.Colorize().Color(tfpolicyEvalSuccessful))
}
return r, nil
}
@ -572,3 +580,7 @@ const lockTimeoutErr = `
[reset][red]Lock timeout exceeded, sending interrupt to cancel the remote operation.
[reset]
`
const tfpolicyEvalSuccessful = `
[reset][green]Terraform policies evaluated successfully.[reset]
`

View file

@ -262,6 +262,43 @@ func TestCloud_planJSONFull(t *testing.T) {
}
}
func TestCloud_planWithPolicyPaths(t *testing.T) {
b, bCleanup := testBackendWithName(t)
t.Cleanup(bCleanup)
stream, close := terminal.StreamsForTesting(t)
b.renderer = &jsonformat.Renderer{
Streams: stream,
Colorize: mockColorize(),
}
op, configCleanup, done := testOperationPlan(t, "./testdata/plan-json-full")
t.Cleanup(configCleanup)
defer done(t)
op.Workspace = testBackendSingleWorkspaceName
op.PolicyPaths = []string{"./foo/bar", "./bar/foo"}
mockSROWorkspace(t, b, op.Workspace)
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
if run.Result != backendrun.OperationSuccess {
t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String())
}
outp := close(t)
gotOut := outp.Stdout()
if !strings.Contains(gotOut, "Terraform policies evaluated successfully.") {
t.Fatalf("expected tfpolicy status in output: %s", gotOut)
}
}
func TestCloud_planWithoutPermissions(t *testing.T) {
b, bCleanup := testBackendWithTags(t)
defer bCleanup()

View file

@ -1309,6 +1309,7 @@ func (m *MockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*t
Status: tfe.RunPending,
TargetAddrs: options.TargetAddrs,
AllowConfigGeneration: options.AllowConfigGeneration,
PolicyPaths: options.PolicyPaths,
}
if options.Message != nil {

View file

@ -4,13 +4,16 @@
package command
import (
"context"
"fmt"
"strings"
"github.com/hashicorp/terraform/internal/backend/backendrun"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/views"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/plans/planfile"
"github.com/hashicorp/terraform/internal/policy"
"github.com/hashicorp/terraform/internal/tfdiags"
)
@ -45,6 +48,7 @@ func (c *ApplyCommand) Run(rawArgs []string) int {
default:
args, diags = arguments.ParseApply(rawArgs)
}
c.Meta.policyPaths = args.PolicyPaths
// Instantiate the view, even if there are flag errors, so that we render
// diagnostics according to the desired view
@ -95,13 +99,22 @@ func (c *ApplyCommand) Run(rawArgs []string) int {
}
// Build the operation request
opReq, opDiags := c.OperationRequest(be, view, args.ViewType, planFile, args.Operation, args.AutoApprove)
opReq, opDiags := c.OperationRequest(be, view, args.ViewType, planFile, args.Operation, args.AutoApprove, args.PolicyPaths)
diags = diags.Append(opDiags)
if diags.HasErrors() {
view.Diagnostics(diags)
return 1
}
if len(c.policyPaths) > 0 {
var policyDiags policy.Diagnostics
opReq.PolicyClient, policyDiags = c.PolicyClient(context.Background(), c.policyPaths)
// if there has been any errors when setting up the policy client, we'll want to log them
if opReq.View != nil && policyDiags != nil {
opReq.View.PolicyResults(&plans.PolicyResults{Diagnostics: policyDiags})
}
}
// Collect variable value and add them to the operation request
var varDiags tfdiags.Diagnostics
opReq.Variables, varDiags = args.Vars.CollectValues(func(filename string, src []byte) {
@ -241,14 +254,7 @@ func (c *ApplyCommand) PrepareBackend(planFile *planfile.WrappedPlanFile, args *
return be, diags
}
func (c *ApplyCommand) OperationRequest(
be backendrun.OperationsBackend,
view views.Apply,
viewType arguments.ViewType,
planFile *planfile.WrappedPlanFile,
args *arguments.Operation,
autoApprove bool,
) (*backendrun.Operation, tfdiags.Diagnostics) {
func (c *ApplyCommand) OperationRequest(be backendrun.OperationsBackend, view views.Apply, viewType arguments.ViewType, planFile *planfile.WrappedPlanFile, args *arguments.Operation, autoApprove bool, policyPaths []string) (*backendrun.Operation, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
// Applying changes with dev overrides in effect could make it impossible
@ -275,6 +281,7 @@ func (c *ApplyCommand) OperationRequest(
opReq.View = view.Operation()
opReq.StatePersistInterval = c.Meta.StatePersistInterval()
opReq.ActionTargets = args.ActionTargets
opReq.PolicyPaths = policyPaths
// EXPERIMENTAL: maybe enable deferred actions
if c.AllowExperimentalFeatures {

View file

@ -0,0 +1,310 @@
// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1
package command
import (
"context"
"os"
"testing"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/terraform/internal/policy"
"github.com/hashicorp/terraform/internal/policy/proto"
"github.com/hashicorp/terraform/internal/providers"
"github.com/zclconf/go-cty/cty"
)
// This tests that the apply policy diagnostics are reported.
func TestApply_WithPolicyDiagnosticsJSON(t *testing.T) {
td := t.TempDir()
testCopyDir(t, testFixturePath("plan"), td)
t.Chdir(td)
policyCode := ` resource_policy "resource_type" "policy_name" {
enforce_attrs {
key = attr.value == "foo"
}
}
`
if err := os.WriteFile("policy.hcl", []byte(policyCode), 0644); err != nil {
t.Fatal(err)
}
p := planFixtureProvider()
view, done := testView(t)
overrides := metaOverridesForProvider(p)
policyClient := policy.NewTestMockClient(t)
overrides.PolicyClient = policyClient
c := &ApplyCommand{
Meta: Meta{
testingOverrides: overrides,
View: view,
AllowExperimentalFeatures: true,
},
}
resp := policy.EvaluationFromProtoResponse(
proto.EvaluateResult_DENY_EVALUATE_RESULT, []*proto.PolicyEvaluationDetail{
{
Address: "resource_policy.foo",
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
File: "policy_file.tfpolicy.hcl",
PolicySetEnforcement: "mandatory",
DefRange: &proto.Range{
Filename: "policy_file.tfpolicy.hcl",
Start: &proto.Position{
Line: 1,
Column: 1,
},
End: &proto.Position{
Line: 2,
Column: 4,
},
},
EnforceResults: []*proto.EnforceBlockResult{
{
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
BlockIndex: 1,
Diagnostics: []*proto.Diagnostic{
{
Severity: proto.Severity_ERROR,
Summary: "policy denied",
Result: &proto.DiagnosticResult{
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
},
Subject: &proto.Range{
Filename: "policy_file.tfpolicy.hcl",
Start: &proto.Position{
Line: 1,
Column: 1,
},
End: &proto.Position{
Line: 2,
Column: 4,
},
},
Context: &proto.Range{
Filename: "policy_file.tfpolicy.hcl",
Start: &proto.Position{
Line: 1,
Column: 1,
},
End: &proto.Position{
Line: 4,
Column: 10,
},
},
Snippet: &proto.Snippet{
Code: policyCode,
StartLine: 1,
HighlightStartOffset: 1,
HighlightEndOffset: 100,
},
},
},
},
},
},
})
policyClient.EvaluateResponse = &resp
// implicit allow, in a case where the evaluated provider matched no policy in the engine
policyClient.EvaluateProviderResponse = &policy.EvaluationResponse{
Overall: policy.AllowResult,
Policies: []*policy.Policy{{
Result: policy.AllowResult,
PolicySetName: "some_policy_set",
Address: "policy_name",
Directory: "some/path/to",
Filename: "provider_policy_file.tfpolicy.hcl",
EnforcementLevel: "mandatory",
Range: &hcl.Range{
Filename: "provider_policy_file.tfpolicy.hcl",
Start: hcl.Pos{
Line: 1,
Column: 1,
},
End: hcl.Pos{
Line: 5,
Column: 12,
},
},
},
},
}
args := []string{"-policies", td}
code := c.Run(append(args, "-no-color", "-json", "-auto-approve"))
output := done(t)
if code != 0 {
t.Fatalf("bad: %d\n\n%s", code, output.Stdout())
}
expected := `{"@level":"info","@message":"Terraform 1.15.0-dev","@module":"terraform.ui","terraform":"1.15.0-dev","type":"version","ui":"1.2"}
{"@level":"info","@message":"data.test_data_source.a: Refreshing...","@module":"terraform.ui","hook":{"resource":{"addr":"data.test_data_source.a","module":"","resource":"data.test_data_source.a","implied_provider":"test","resource_type":"test_data_source","resource_name":"a","resource_key":null},"action":"read"},"type":"apply_start"}
{"@level":"info","@message":"data.test_data_source.a: Refresh complete after 0s [id=zzzzz]","@module":"terraform.ui","hook":{"resource":{"addr":"data.test_data_source.a","module":"","resource":"data.test_data_source.a","implied_provider":"test","resource_type":"test_data_source","resource_name":"a","resource_key":null},"action":"read","id_key":"id","id_value":"zzzzz","elapsed_seconds":0},"type":"apply_complete"}
{"@level":"info","@message":"test_instance.foo: Plan to create","@module":"terraform.ui","change":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create"},"type":"planned_change"}
{"@level":"info","@message":"Plan: 1 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","changes":{"add":1,"change":0,"import":0,"remove":0,"action_invocation":0,"operation":"plan"},"type":"change_summary"}
{"@level":"info","@message":"test_instance.foo: Creating...","@module":"terraform.ui","hook":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create"},"type":"apply_start"}
{"@level":"info","@message":"test_instance.foo: Creation complete after 0s","@module":"terraform.ui","hook":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create","elapsed_seconds":0},"type":"apply_complete"}
{"@level":"error","@message":"Error: policy denied","@module":"terraform.ui","@policy":"true","target_address":"test_instance.foo","policy_diagnostic":{"severity":"error","summary":"policy denied","detail":"","range":{"filename":"main.tf","start":{"line":1,"column":1,"byte":0},"end":{"line":1,"column":31,"byte":30}},"snippet":{"context":null,"code":"resource \"test_instance\" \"foo\" {","start_line":1,"highlight_start_offset":0,"highlight_end_offset":30,"values":[]},"policy_range":{"filename":"policy_file.tfpolicy.hcl","start":{"line":1,"column":1,"byte":0},"end":{"line":2,"column":4,"byte":0}},"policy_snippet":{"context":null,"code":"\t\tresource_policy \"resource_type\" \"policy_name\" {\n\t\t enforce_attrs {\n\t\t key = attr.value == \"foo\"\n\t\t }\n\t\t}\n\t","start_line":1,"highlight_start_offset":1,"highlight_end_offset":100,"values":null}},"policy_metadata":{"enforce_index":1,"policy_set_path":"policy_file.tfpolicy.hcl","policy_name":"resource_policy.foo","file_name":"policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"result":"DenyResult","type":"policy_diagnostic"}
{"@level":"info","@message":"Policy Result","@module":"terraform.ui","@policy":"true","policy_address":"resource_policy.foo","policy_metadata":{"policy_set_path":"policy_file.tfpolicy.hcl","policy_name":"resource_policy.foo","file_name":"policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"result":"DenyResult","target_address":"test_instance.foo","type":"policy_result"}
{"@level":"info","@message":"Apply complete! Resources: 1 added, 0 changed, 0 destroyed.","@module":"terraform.ui","changes":{"add":1,"change":0,"import":0,"remove":0,"action_invocation":0,"operation":"apply"},"type":"change_summary"}
{"@level":"info","@message":"Outputs: 0","@module":"terraform.ui","outputs":{},"type":"outputs"}`
checkGoldenReferenceStr(t, output, expected)
}
// This tests that the plan policy diagnostic is superceded by the apply policy evaluation.
func TestApply_WithPlanPolicyDiagnosticsJSON(t *testing.T) {
td := t.TempDir()
testCopyDir(t, testFixturePath("plan"), td)
t.Chdir(td)
policyCode := ` resource_policy "resource_type" "policy_name" {
enforce_attrs {
key = attr.value == "foo"
}
}
`
if err := os.WriteFile("policy.hcl", []byte(policyCode), 0644); err != nil {
t.Fatal(err)
}
p := planFixtureProvider()
view, done := testView(t)
overrides := metaOverridesForProvider(p)
policyClient := policy.NewTestMockClient(t)
overrides.PolicyClient = policyClient
c := &ApplyCommand{
Meta: Meta{
testingOverrides: overrides,
View: view,
AllowExperimentalFeatures: true,
},
}
p.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) (resp providers.PlanResourceChangeResponse) {
s := req.ProposedNewState.AsValueMap()
s["id"] = cty.UnknownVal(cty.String)
resp.PlannedState = cty.ObjectVal(s)
return
}
evalRespFn := func(result proto.EvaluateResult) policy.EvaluationResponse {
detail := &proto.PolicyEvaluationDetail{
Address: "resource_policy.foo",
Result: result,
File: "policy_file.tfpolicy.hcl",
PolicySetEnforcement: "mandatory",
DefRange: &proto.Range{
Filename: "policy_file.tfpolicy.hcl",
Start: &proto.Position{
Line: 1,
Column: 1,
},
End: &proto.Position{
Line: 2,
Column: 4,
},
},
EnforceResults: []*proto.EnforceBlockResult{{
Result: result,
}},
}
if result == proto.EvaluateResult_DENY_EVALUATE_RESULT {
detail.EnforceResults[0].Diagnostics = []*proto.Diagnostic{
{
Severity: proto.Severity_ERROR,
Summary: "policy denied",
Result: &proto.DiagnosticResult{
Result: result,
},
Subject: &proto.Range{
Filename: "policy_file.tfpolicy.hcl",
Start: &proto.Position{
Line: 1,
Column: 1,
},
End: &proto.Position{
Line: 2,
Column: 4,
},
},
Context: &proto.Range{
Filename: "policy_file.tfpolicy.hcl",
Start: &proto.Position{
Line: 1,
Column: 1,
},
End: &proto.Position{
Line: 4,
Column: 10,
},
},
Snippet: &proto.Snippet{
Code: policyCode,
StartLine: 1,
HighlightStartOffset: 1,
HighlightEndOffset: 100,
},
},
}
}
return policy.EvaluationFromProtoResponse(result, []*proto.PolicyEvaluationDetail{detail})
}
policyClient.EvaluateFn = func(ctx context.Context, er policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse {
// This is what is returned during the post-plan policy evaluation
if !er.Attrs.GetAttr("id").IsWhollyKnown() {
return evalRespFn(proto.EvaluateResult_DENY_EVALUATE_RESULT)
}
// This is for the post-apply policy evaluation
return evalRespFn(proto.EvaluateResult_ALLOW_EVALUATE_RESULT)
}
// implicit allow, in a case where the evaluated provider matched no policy in the engine
policyClient.EvaluateProviderResponse = &policy.EvaluationResponse{
Overall: policy.AllowResult,
Policies: []*policy.Policy{{
Result: policy.AllowResult,
PolicySetName: "some_policy_set",
Address: "policy_name",
Directory: "some/path/to",
Filename: "provider_policy_file.tfpolicy.hcl",
EnforcementLevel: "mandatory",
Range: &hcl.Range{
Filename: "provider_policy_file.tfpolicy.hcl",
Start: hcl.Pos{
Line: 1,
Column: 1,
},
End: hcl.Pos{
Line: 5,
Column: 12,
},
},
},
},
}
args := []string{"-policies", td}
code := c.Run(append(args, "-no-color", "-json", "-auto-approve"))
output := done(t)
if code != 0 {
t.Fatalf("bad: %d\n\n%s", code, output.Stdout())
}
// The resulting json only contains the policy result, because the object that
// had a failed policy evaluation during the plan succeeded during apply.
// This can occur when more references become known.
expected := `{"@level":"info","@message":"Terraform 1.15.0-dev","@module":"terraform.ui","terraform":"1.15.0-dev","type":"version","ui":"1.2"}
{"@level":"info","@message":"data.test_data_source.a: Refreshing...","@module":"terraform.ui","hook":{"resource":{"addr":"data.test_data_source.a","module":"","resource":"data.test_data_source.a","implied_provider":"test","resource_type":"test_data_source","resource_name":"a","resource_key":null},"action":"read"},"type":"apply_start"}
{"@level":"info","@message":"data.test_data_source.a: Refresh complete after 0s [id=zzzzz]","@module":"terraform.ui","hook":{"resource":{"addr":"data.test_data_source.a","module":"","resource":"data.test_data_source.a","implied_provider":"test","resource_type":"test_data_source","resource_name":"a","resource_key":null},"action":"read","id_key":"id","id_value":"zzzzz","elapsed_seconds":0},"type":"apply_complete"}
{"@level":"info","@message":"test_instance.foo: Plan to create","@module":"terraform.ui","change":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create"},"type":"planned_change"}
{"@level":"info","@message":"Plan: 1 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","changes":{"add":1,"change":0,"import":0,"remove":0,"action_invocation":0,"operation":"plan"},"type":"change_summary"}
{"@level":"info","@message":"test_instance.foo: Creating...","@module":"terraform.ui","hook":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create"},"type":"apply_start"}
{"@level":"info","@message":"test_instance.foo: Creation complete after 0s","@module":"terraform.ui","hook":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create","elapsed_seconds":0,"id_key":"id"},"type":"apply_complete"}
{"@level":"info","@message":"Policy Result","@module":"terraform.ui","@policy":"true","policy_address":"resource_policy.foo","policy_metadata":{"policy_set_path":"policy_file.tfpolicy.hcl","policy_name":"resource_policy.foo","file_name":"policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"result":"AllowResult","target_address":"test_instance.foo","type":"policy_result"}
{"@level":"info","@message":"Apply complete! Resources: 1 added, 0 changed, 0 destroyed.","@module":"terraform.ui","changes":{"add":1,"change":0,"import":0,"remove":0,"action_invocation":0,"operation":"apply"},"type":"change_summary"}
{"@level":"info","@message":"Outputs: 0","@module":"terraform.ui","outputs":{},"type":"outputs"}`
checkGoldenReferenceStr(t, output, expected)
}

View file

@ -29,6 +29,10 @@ type Apply struct {
// ViewType specifies which output format to use
ViewType ViewType
// PolicyPath contains an optional path to any defined policies that should
// be applied for this apply operation.
PolicyPaths []string
}
// ParseApply processes CLI arguments, returning an Apply value and errors.
@ -45,6 +49,7 @@ func ParseApply(args []string) (*Apply, tfdiags.Diagnostics) {
cmdFlags := extendedFlagSet("apply", apply.State, apply.Operation, apply.Vars)
cmdFlags.BoolVar(&apply.AutoApprove, "auto-approve", false, "auto-approve")
cmdFlags.BoolVar(&apply.InputEnabled, "input", true, "input")
cmdFlags.Var((*FlagStringSlice)(&apply.PolicyPaths), "policies", "policies")
var json bool
cmdFlags.BoolVar(&json, "json", false, "json")

View file

@ -32,6 +32,10 @@ type Plan struct {
// ViewType specifies which output format to use
ViewType ViewType
// PolicyPath contains an optional path to any defined policies that should
// be applied for this plan operation.
PolicyPaths []string
}
// ParsePlan processes CLI arguments, returning a Plan value and errors.
@ -50,6 +54,7 @@ func ParsePlan(args []string) (*Plan, tfdiags.Diagnostics) {
cmdFlags.BoolVar(&plan.InputEnabled, "input", true, "input")
cmdFlags.StringVar(&plan.OutPath, "out", "", "out")
cmdFlags.StringVar(&plan.GenerateConfigPath, "generate-config-out", "", "generate-config-out")
cmdFlags.Var((*FlagStringSlice)(&plan.PolicyPaths), "policies", "policies")
var json bool
cmdFlags.BoolVar(&json, "json", false, "json")

View file

@ -21,6 +21,10 @@ type Query struct {
// the found resources in the query and which path the generated file should
// be written to.
GenerateConfigPath string
// PolicyPath contains an optional path to any defined policies that should
// be applied for this plan operation.
PolicyPaths []string
}
func ParseQuery(args []string) (*Query, tfdiags.Diagnostics) {
@ -40,6 +44,7 @@ func ParseQuery(args []string) (*Query, tfdiags.Diagnostics) {
query.Vars.varFiles = &varFilesFlags
cmdFlags.Var(query.Vars.vars, "var", "var")
cmdFlags.Var(query.Vars.varFiles, "var-file", "var-file")
cmdFlags.Var((*FlagStringSlice)(&query.PolicyPaths), "policies", "policies")
var json bool
cmdFlags.BoolVar(&json, "json", false, "json")

View file

@ -159,7 +159,7 @@ func (b *TestVariableBackend) FetchVariables(ctx context.Context, workspace stri
return result, nil
}
func (b *TestVariableBackend) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, statemgr.Full, tfdiags.Diagnostics) {
func (b *TestVariableBackend) LocalRun(ctx context.Context, op *backendrun.Operation) (*backendrun.LocalRun, statemgr.Full, tfdiags.Diagnostics) {
// Sometimes a command (like graph) requires a local backend. The cloud
// backend implements LocalRun and will fetch variables from the backend.
// But our mock TestVariableBackend will fail in these tests, because it
@ -177,7 +177,11 @@ func (b *TestVariableBackend) LocalRun(op *backendrun.Operation) (*backendrun.Lo
}
}
return b.Local.LocalRun(op)
return b.Local.LocalRun(ctx, op)
}
func (b *TestVariableBackend) Finish() {
}
type testUnparsedVariableValueString struct {

View file

@ -5,6 +5,7 @@ package command
import (
"bufio"
"context"
"fmt"
"os"
"strings"
@ -93,7 +94,9 @@ func (c *ConsoleCommand) Run(args []string) int {
}
// Get the context
lr, _, ctxDiags := local.LocalRun(opReq)
lr, _, ctxDiags := local.LocalRun(context.Background(), opReq)
defer lr.Finish()
diags = diags.Append(ctxDiags)
if ctxDiags.HasErrors() {
c.showDiagnostics(diags)

View file

@ -4,6 +4,7 @@
package command
import (
"context"
"fmt"
"sort"
"strings"
@ -95,7 +96,9 @@ func (c *GraphCommand) Run(rawArgs []string) int {
}
// Get the context
lr, _, ctxDiags := local.LocalRun(opReq)
lr, _, ctxDiags := local.LocalRun(context.Background(), opReq)
defer lr.Finish()
diags = diags.Append(ctxDiags)
if ctxDiags.HasErrors() {
c.showDiagnostics(diags)

View file

@ -4,6 +4,7 @@
package command
import (
"context"
"errors"
"fmt"
"log"
@ -206,7 +207,9 @@ func (c *ImportCommand) Run(args []string) int {
}
// Get the context
lr, state, ctxDiags := local.LocalRun(opReq)
lr, state, ctxDiags := local.LocalRun(context.Background(), opReq)
defer lr.Finish()
diags = diags.Append(ctxDiags)
if ctxDiags.HasErrors() {
c.showDiagnostics(diags)

View file

@ -35,6 +35,7 @@ import (
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/configs/configload"
"github.com/hashicorp/terraform/internal/getproviders"
"github.com/hashicorp/terraform/internal/policy"
"github.com/hashicorp/terraform/internal/providers"
"github.com/hashicorp/terraform/internal/provisioners"
"github.com/hashicorp/terraform/internal/states"
@ -201,6 +202,8 @@ type Meta struct {
// Override certain behavior for tests within this package
testingOverrides *testingOverrides
policyPaths []string
//----------------------------------------------------------
// Private: do not set these
//----------------------------------------------------------
@ -284,6 +287,7 @@ type Meta struct {
type testingOverrides struct {
Providers map[addrs.Provider]providers.Factory
Provisioners map[string]provisioners.Factory
PolicyClient policy.Client
}
// initStatePaths is used to initialize the default values for

View file

@ -279,7 +279,7 @@ func (m *Meta) loadHCLFile(filename string) (hcl.Body, tfdiags.Diagnostics) {
// can then be relayed to the end-user. The uiModuleInstallHooks type in
// this package has a reasonable implementation for displaying notifications
// via a provided cli.Ui.
func (m *Meta) installModules(ctx context.Context, rootDir, testsDir string, upgrade, installErrsOnly bool, hooks initwd.ModuleInstallHooks) (abort bool, diags tfdiags.Diagnostics) {
func (m *Meta) installModules(ctx context.Context, rootDir, testsDir string, upgrade, installErrsOnly bool, hooks ...initwd.ModuleInstallHook) (abort bool, diags tfdiags.Diagnostics) {
ctx, span := tracer.Start(ctx, "install modules")
defer span.End()
@ -311,9 +311,9 @@ func (m *Meta) installModules(ctx context.Context, rootDir, testsDir string, upg
SetVariables: variables,
})
}
inst := initwd.NewModuleInstaller(m.modulesDir(), loader, m.registryClient(), initializer)
inst := initwd.NewModuleInstaller(m.modulesDir(), loader, m.registryClient(), initializer, hooks...)
_, moreDiags := inst.InstallModules(ctx, rootDir, testsDir, upgrade, installErrsOnly, hooks)
_, moreDiags := inst.InstallModules(ctx, rootDir, testsDir, upgrade, installErrsOnly)
diags = diags.Append(moreDiags)
if ctx.Err() == context.Canceled {
@ -334,7 +334,7 @@ func (m *Meta) installModules(ctx context.Context, rootDir, testsDir string, upg
// can then be relayed to the end-user. The uiModuleInstallHooks type in
// this package has a reasonable implementation for displaying notifications
// via a provided cli.Ui.
func (m *Meta) initDirFromModule(ctx context.Context, targetDir string, addr string, hooks initwd.ModuleInstallHooks) (abort bool, diags tfdiags.Diagnostics) {
func (m *Meta) initDirFromModule(ctx context.Context, targetDir string, addr string, hooks initwd.ModuleInstallHook) (abort bool, diags tfdiags.Diagnostics) {
ctx, span := tracer.Start(ctx, "initialize directory from module", trace.WithAttributes(
attribute.String("source_addr", addr),
))

View file

@ -0,0 +1,235 @@
// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1
package command
import (
"context"
"fmt"
"log"
"maps"
"slices"
"github.com/apparentlymart/go-versions/versions"
"github.com/apparentlymart/go-versions/versions/constraints"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/initwd"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/policy"
"github.com/hashicorp/terraform/internal/policy/proto"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/hashicorp/terraform/version"
)
func (c *Meta) PolicyClient(ctx context.Context, policyPaths []string) (policy.Client, policy.Diagnostics) {
var client policy.Client
// Policies are currently only supported in alpha versions.
// TODO: Uncomment in the public release
// if !c.AllowExperimentalFeatures {
// log.Printf("[DEBUG] Policies are not supported, skipping policy client setup")
// return client, nil
// }
if len(policyPaths) == 0 {
log.Printf("[DEBUG] No policy paths configured, skipping policy client setup")
return client, nil
}
// Use a pre-initialized client for tests if one is available
if c.testingOverrides != nil {
if client := c.testingOverrides.PolicyClient; client != nil {
return client, nil
}
}
var diags policy.Diagnostics
client, err := policy.Connect(ctx)
if client == nil {
diags = append(diags, policy.NewErrorDiagnostic(
"Failed to connect to policy engine",
fmt.Sprintf("Failed to connect to policy engine: %s.", err),
policy.SetupErrorResult,
))
return nil, diags
}
var callbackServiceID uint32
// initialize the callback service if the client supports it
if srv, ok := client.(policy.CallbackService); ok {
callbackServer, cbDiags := srv.RegisterCallbackService(ctx)
if cbDiags != nil {
return nil, cbDiags
}
callbackServiceID = callbackServer.ID
}
resp := client.Setup(ctx, policy.SetupRequest{
SourceLocations: policyPaths,
CallbackService: callbackServiceID,
})
diags = append(diags, resp.Diagnostics...)
var requiredVersions constraints.IntersectionSpec
for _, config := range resp.ServerConfigurations() {
version, err := constraints.ParseRubyStyleMulti(config.RequiredVersion)
if err != nil {
diags = append(diags, policy.NewErrorDiagnostic(
"Failed to validate required Terraform version",
fmt.Sprintf("The policy file %s had a Terraform version constraint that could not be parsed: %s.", config.File, err),
policy.SetupErrorResult,
))
continue
}
requiredVersions = append(requiredVersions, version...)
}
if len(diags) > 0 {
client.Stop()
return nil, diags
}
terraformVersion, err := versions.ParseVersion(version.Version)
if err != nil {
client.Stop()
// This is crazy, it means the internal version number is invalid.
panic(err)
}
constraint := versions.MeetingConstraints(requiredVersions)
if !constraint.Has(terraformVersion) {
diags = append(diags, policy.NewErrorDiagnostic(
"Invalid Terraform version for policies",
fmt.Sprintf("The current version of Terraform is %s, and it is not compatible with the versions of Terraform required by the selected policies.", version.String()),
policy.SetupErrorResult,
))
client.Stop()
return nil, diags
}
log.Printf("[INFO] backend/operation/policy: Policy engine initialized")
return client, diags
}
// policyModuleInstallHook implements initwd.ModuleInstallHook and
// enables policy evaluation during module installation.
type policyModuleInstallHook struct {
initwd.ModuleInstallHookImpl
client policy.Client
rootModule *configs.Module
policyResults *plans.PolicyResults
}
func (h *policyModuleInstallHook) EvaluatePolicy(ctx context.Context, req *configs.ModuleRequest, source, version string) tfdiags.Diagnostics {
moduleAddr := req.Path.String()
moduleCall := h.rootModule.ModuleCalls[req.Name]
result := h.client.EvaluateModule(ctx, policy.EvaluationRequest[*proto.ModuleMetadata]{
Attrs: cty.NilVal,
Target: source,
Meta: &proto.ModuleMetadata{
Address: moduleAddr,
Source: source,
Version: version,
},
})
if moduleCall != nil && moduleCall.Config != nil {
ptr := moduleCall.DeclRange.Ptr()
for idx, diag := range result.Diagnostics {
result.Diagnostics[idx] = diag.WithLocalRange(ptr)
}
for idx := range result.Enforcements {
result.Enforcements[idx].LocalRange = ptr
}
}
h.policyResults.AddModule(req.Path, result, moduleCall)
// return a generic error here that the init command returns to the CLI.
// The detailed policy diagnostics are included in the policy results
// and will be formatted in the CLI output.
if len(result.Diagnostics) > 0 && result.Diagnostics.AsTerraformDiags().HasErrors() {
return tfdiags.Diagnostics{
policy.NewErrorDiagnostic(
"Policy evaluation failed",
"Module download blocked due to policy violations. Please review other diagnostics for details.",
policy.SetupErrorResult,
),
}
}
return nil
}
type providerInstallerHook struct {
Reqs *configs.ModuleRequirements
Client policy.Client
moduleMap map[addrs.Provider]string
policyResults *plans.PolicyResults
config *configs.Config
}
func (p *providerInstallerHook) moduleSources() map[addrs.Provider]string {
if p.moduleMap != nil {
return p.moduleMap
}
// We iterate through the module requirements to build a map of providers
// to their module source addresses. The first module requirement we encounter
// for each provider will be recorded as the provider's module.
// This matches how Terraform adds providers to the graph.
p.moduleMap = map[addrs.Provider]string{}
moduleReqs := []*configs.ModuleRequirements{p.Reqs}
for len(moduleReqs) != 0 {
moduleReq := moduleReqs[0]
for reqProvider := range moduleReq.Requirements {
if _, ok := p.moduleMap[reqProvider]; ok {
// if we already have a module for this provider, skip
continue
}
// The source is nil in the root module, so we use the root module address.
if moduleReq.SourceAddr == nil {
p.moduleMap[reqProvider] = addrs.RootModule.String()
} else {
p.moduleMap[reqProvider] = moduleReq.SourceAddr.String()
}
}
newReqs := slices.Collect(maps.Values(moduleReq.Children))
moduleReqs = append(moduleReqs[1:], newReqs...)
}
return p.moduleMap
}
func (p *providerInstallerHook) EvaluatePolicy(ctx context.Context, provider addrs.Provider, version string) policy.EvaluationResponse {
// If the client is nil, then policy evaluation is disabled, so we can skip.
if p.Client == nil {
return policy.EvaluationResponse{}
}
moduleSources := p.moduleSources()
log.Println("[DEBUG] init: evaluating policy for provider", provider.String(), version)
result := p.Client.EvaluateProvider(ctx, policy.EvaluationRequest[*proto.ProviderMetadata]{
Target: provider.Type,
// Configuration attributes may not be available during init, so we will not
// send any attributes to the policy client.
Attrs: cty.NilVal,
Meta: &proto.ProviderMetadata{
Name: provider.Type,
Namespace: provider.Namespace,
Type: provider.Type,
Source: provider.String(),
ModulePath: moduleSources[provider],
Version: version,
},
})
// We use the root module as the module for provider configs since the version resolution
// is ambiguous, and we do not know which module the provider config belongs to.
addr := addrs.AbsProviderConfig{Provider: provider, Module: addrs.RootModule}
providerConfig := p.config.Module.ProviderConfigs[provider.Type]
p.policyResults.AddProvider(addr, result, providerConfig)
return result
}

View file

@ -4,12 +4,15 @@
package command
import (
"context"
"fmt"
"strings"
"github.com/hashicorp/terraform/internal/backend/backendrun"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/views"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/policy"
"github.com/hashicorp/terraform/internal/tfdiags"
)
@ -32,6 +35,7 @@ func (c *PlanCommand) Run(rawArgs []string) int {
// Parse and validate flags
args, diags := arguments.ParsePlan(rawArgs)
c.Meta.policyPaths = args.PolicyPaths
// Instantiate the view, even if there are flag errors, so that we render
// diagnostics according to the desired view
@ -79,7 +83,7 @@ func (c *PlanCommand) Run(rawArgs []string) int {
}
// Build the operation request
opReq, opDiags := c.OperationRequest(be, view, args.ViewType, args.Operation, args.OutPath, args.GenerateConfigPath)
opReq, opDiags := c.OperationRequest(be, view, args.ViewType, args.Operation, args.OutPath, args.GenerateConfigPath, args.PolicyPaths)
diags = diags.Append(opDiags)
if diags.HasErrors() {
view.Diagnostics(diags)
@ -133,14 +137,7 @@ func (c *PlanCommand) PrepareBackend(args *arguments.State, viewType arguments.V
return be, diags
}
func (c *PlanCommand) OperationRequest(
be backendrun.OperationsBackend,
view views.Plan,
viewType arguments.ViewType,
args *arguments.Operation,
planOutPath string,
generateConfigOut string,
) (*backendrun.Operation, tfdiags.Diagnostics) {
func (c *PlanCommand) OperationRequest(be backendrun.OperationsBackend, view views.Plan, viewType arguments.ViewType, args *arguments.Operation, planOutPath string, generateConfigOut string, policyPaths []string) (*backendrun.Operation, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
// Build the operation
@ -156,6 +153,7 @@ func (c *PlanCommand) OperationRequest(
opReq.Type = backendrun.OperationTypePlan
opReq.View = view.Operation()
opReq.ActionTargets = args.ActionTargets
opReq.PolicyPaths = policyPaths
// EXPERIMENTAL: maybe enable deferred actions
if c.AllowExperimentalFeatures {
@ -178,6 +176,15 @@ func (c *PlanCommand) OperationRequest(
return nil, diags
}
if len(c.policyPaths) > 0 {
var policyDiags policy.Diagnostics
opReq.PolicyClient, policyDiags = c.PolicyClient(context.Background(), c.policyPaths)
// if there has been any errors when setting up the policy client, we'll want to log them
if opReq.View != nil && policyDiags != nil {
opReq.View.PolicyResults(&plans.PolicyResults{Diagnostics: policyDiags})
}
}
return opReq, diags
}

View file

@ -0,0 +1,909 @@
// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1
package command
import (
"context"
"fmt"
"os"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/terraform/internal/policy"
"github.com/hashicorp/terraform/internal/policy/proto"
)
// Tests the output of a plan that includes a policy evaluation
func TestPlan_WithPolicy(t *testing.T) {
td := t.TempDir()
testCopyDir(t, testFixturePath("plan"), td)
t.Chdir(td)
policyCode := ` resource_policy "resource_type" "policy_name" {
enforce_attrs {
key = attr.value == "foo"
}
}
`
if err := os.WriteFile("policy.hcl", []byte(policyCode), 0644); err != nil {
t.Fatal(err)
}
p := planFixtureProvider()
view, done := testView(t)
overrides := metaOverridesForProvider(p)
policyClient := policy.NewTestMockClient(t)
overrides.PolicyClient = policyClient
c := &PlanCommand{
Meta: Meta{
testingOverrides: overrides,
View: view,
AllowExperimentalFeatures: true,
},
}
resp := policy.EvaluationFromProtoResponse(
proto.EvaluateResult_DENY_EVALUATE_RESULT, []*proto.PolicyEvaluationDetail{
{
Address: "resource_policy.foo",
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
File: "policy_file.tfpolicy.hcl",
DefRange: &proto.Range{
Filename: "policy_file.tfpolicy.hcl",
Start: &proto.Position{
Line: 1,
Column: 1,
},
End: &proto.Position{
Line: 2,
Column: 4,
},
},
EnforceResults: []*proto.EnforceBlockResult{
{
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
Diagnostics: []*proto.Diagnostic{
{
Severity: proto.Severity_ERROR,
Summary: "policy denied",
Result: &proto.DiagnosticResult{
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
},
Subject: &proto.Range{
Filename: "policy_file.tfpolicy.hcl",
Start: &proto.Position{
Line: 1,
Column: 1,
},
End: &proto.Position{
Line: 2,
Column: 4,
},
},
Context: &proto.Range{
Filename: "policy_file.tfpolicy.hcl",
Start: &proto.Position{
Line: 1,
Column: 1,
},
End: &proto.Position{
Line: 4,
Column: 10,
},
},
Snippet: &proto.Snippet{
Code: policyCode,
StartLine: 1,
HighlightStartOffset: 1,
HighlightEndOffset: 100,
},
},
},
},
},
},
})
policyClient.EvaluateResponse = &resp
args := []string{"-policies", td}
code := c.Run(append(args, "-no-color"))
output := done(t)
if code != 0 {
t.Fatalf("bad: %d\n\n%s", code, output.Stderr())
}
expected := `
Error: policy denied
on policy_file.tfpolicy.hcl line 1:
1: resource_policy "resource_type" "policy_name" {
2: enforce_attrs {
3: key = attr.value == "foo"
4: }
5: }
6:
while evaluating policy for main.tf line 1:
1: resource "test_instance" "foo" {
`
if diff := cmp.Diff(expected, output.Stderr()); diff != "" {
t.Fatalf("unexpected output:\n%s", diff)
}
}
func TestPlan_WithPolicyDiagnosticsJSON(t *testing.T) {
td := t.TempDir()
testCopyDir(t, testFixturePath("plan"), td)
t.Chdir(td)
policyCode := ` resource_policy "resource_type" "policy_name" {
enforce_attrs {
key = attr.value == "foo"
}
}
`
if err := os.WriteFile("policy.hcl", []byte(policyCode), 0644); err != nil {
t.Fatal(err)
}
p := planFixtureProvider()
view, done := testView(t)
overrides := metaOverridesForProvider(p)
policyClient := policy.NewTestMockClient(t)
overrides.PolicyClient = policyClient
c := &PlanCommand{
Meta: Meta{
testingOverrides: overrides,
View: view,
AllowExperimentalFeatures: true,
},
}
resp := policy.EvaluationFromProtoResponse(
proto.EvaluateResult_DENY_EVALUATE_RESULT, []*proto.PolicyEvaluationDetail{
{
Address: "resource_policy.foo",
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
File: "policy_file.tfpolicy.hcl",
PolicySetEnforcement: "mandatory",
DefRange: &proto.Range{
Filename: "policy_file.tfpolicy.hcl",
Start: &proto.Position{
Line: 1,
Column: 1,
},
End: &proto.Position{
Line: 2,
Column: 4,
},
},
EnforceResults: []*proto.EnforceBlockResult{
{
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
BlockIndex: 1,
Diagnostics: []*proto.Diagnostic{
{
Severity: proto.Severity_ERROR,
Summary: "policy denied",
Result: &proto.DiagnosticResult{
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
},
Subject: &proto.Range{
Filename: "policy_file.tfpolicy.hcl",
Start: &proto.Position{
Line: 1,
Column: 1,
},
End: &proto.Position{
Line: 2,
Column: 4,
},
},
Context: &proto.Range{
Filename: "policy_file.tfpolicy.hcl",
Start: &proto.Position{
Line: 1,
Column: 1,
},
End: &proto.Position{
Line: 4,
Column: 10,
},
},
Snippet: &proto.Snippet{
Code: policyCode,
StartLine: 1,
HighlightStartOffset: 1,
HighlightEndOffset: 100,
},
},
},
},
},
},
{
Address: "resource_policy.bar",
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
File: "policy_file.tfpolicy.hcl",
PolicySetEnforcement: "mandatory",
DefRange: &proto.Range{
Filename: "policy_file.tfpolicy.hcl",
Start: &proto.Position{
Line: 1,
Column: 1,
},
End: &proto.Position{
Line: 2,
Column: 4,
},
},
EnforceResults: []*proto.EnforceBlockResult{
{
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
BlockIndex: 2,
Diagnostics: []*proto.Diagnostic{
{
Severity: proto.Severity_ERROR,
Summary: "policy failed for some other reason",
Result: &proto.DiagnosticResult{
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
},
Subject: &proto.Range{
Filename: "policy_file.tfpolicy.hcl",
Start: &proto.Position{
Line: 1,
Column: 1,
},
End: &proto.Position{
Line: 2,
Column: 4,
},
},
Context: &proto.Range{
Filename: "policy_file.tfpolicy.hcl",
Start: &proto.Position{
Line: 1,
Column: 1,
},
End: &proto.Position{
Line: 4,
Column: 10,
},
},
Snippet: &proto.Snippet{
Code: policyCode,
StartLine: 1,
HighlightStartOffset: 1,
HighlightEndOffset: 100,
},
},
},
},
},
},
})
policyClient.EvaluateResponse = &resp
// implicit allow, in a case where the evaluated provider matched no policy in the engine
policyClient.EvaluateProviderResponse = &policy.EvaluationResponse{
Overall: policy.AllowResult,
Policies: []*policy.Policy{{
Result: policy.AllowResult,
PolicySetName: "some_policy_set",
Address: "policy_name",
Directory: "some/path/to",
Filename: "provider_policy_file.tfpolicy.hcl",
EnforcementLevel: "mandatory",
Range: &hcl.Range{
Filename: "provider_policy_file.tfpolicy.hcl",
Start: hcl.Pos{
Line: 1,
Column: 1,
},
End: hcl.Pos{
Line: 5,
Column: 12,
},
},
},
},
}
args := []string{"-policies", td}
code := c.Run(append(args, "-no-color", "-json"))
output := done(t)
if code != 0 {
t.Fatalf("bad: %d\n\n%s", code, output.Stderr())
}
expected := `{"@level":"info","@message":"Terraform 1.15.0-dev","@module":"terraform.ui","terraform":"1.15.0-dev","type":"version","ui":"1.2"}
{"@level":"info","@message":"data.test_data_source.a: Refreshing...","@module":"terraform.ui","hook":{"resource":{"addr":"data.test_data_source.a","module":"","resource":"data.test_data_source.a","implied_provider":"test","resource_type":"test_data_source","resource_name":"a","resource_key":null},"action":"read"},"type":"apply_start"}
{"@level":"info","@message":"data.test_data_source.a: Refresh complete after 0s [id=zzzzz]","@module":"terraform.ui","hook":{"resource":{"addr":"data.test_data_source.a","module":"","resource":"data.test_data_source.a","implied_provider":"test","resource_type":"test_data_source","resource_name":"a","resource_key":null},"action":"read","id_key":"id","id_value":"zzzzz","elapsed_seconds":0},"type":"apply_complete"}
{"@level":"info","@message":"test_instance.foo: Plan to create","@module":"terraform.ui","change":{"resource":{"addr":"test_instance.foo","implied_provider":"test","module":"","resource":"test_instance.foo","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create"},"type":"planned_change"}
{"@level":"info","@message":"Plan: 1 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","changes":{"add":1,"change":0,"import":0,"remove":0,"action_invocation":0,"operation":"plan"},"type":"change_summary"}
{"@level":"error","@message":"Error: policy denied","@module":"terraform.ui","@policy":"true","target_address":"test_instance.foo","policy_diagnostic":{"severity":"error","summary":"policy denied","detail":"","range":{"filename":"main.tf","start":{"line":1,"column":1,"byte":0},"end":{"line":1,"column":31,"byte":30}},"snippet":{"context":null,"code":"resource \"test_instance\" \"foo\" {","start_line":1,"highlight_start_offset":0,"highlight_end_offset":30,"values":[]},"policy_range":{"filename":"policy_file.tfpolicy.hcl","start":{"line":1,"column":1,"byte":0},"end":{"line":2,"column":4,"byte":0}},"policy_snippet":{"context":null,"code":"\t\tresource_policy \"resource_type\" \"policy_name\" {\n\t\t enforce_attrs {\n\t\t key = attr.value == \"foo\"\n\t\t }\n\t\t}\n\t","start_line":1,"highlight_start_offset":1,"highlight_end_offset":100,"values":null}},"policy_metadata":{"enforce_index":1,"policy_set_path":"policy_file.tfpolicy.hcl","policy_name":"resource_policy.foo","file_name":"policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"result":"DenyResult","type":"policy_diagnostic"}
{"@level":"error","@message":"Error: policy failed for some other reason","@module":"terraform.ui","@policy":"true","target_address":"test_instance.foo","policy_diagnostic":{"severity":"error","summary":"policy failed for some other reason","detail":"","range":{"filename":"main.tf","start":{"line":1,"column":1,"byte":0},"end":{"line":1,"column":31,"byte":30}},"snippet":{"context":null,"code":"resource \"test_instance\" \"foo\" {","start_line":1,"highlight_start_offset":0,"highlight_end_offset":30,"values":[]},"policy_range":{"filename":"policy_file.tfpolicy.hcl","start":{"line":1,"column":1,"byte":0},"end":{"line":2,"column":4,"byte":0}},"policy_snippet":{"context":null,"code":"\t\tresource_policy \"resource_type\" \"policy_name\" {\n\t\t enforce_attrs {\n\t\t key = attr.value == \"foo\"\n\t\t }\n\t\t}\n\t","start_line":1,"highlight_start_offset":1,"highlight_end_offset":100,"values":null}},"policy_metadata":{"enforce_index":2,"policy_set_path":"policy_file.tfpolicy.hcl","policy_name":"resource_policy.bar","file_name":"policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"result":"DenyResult","type":"policy_diagnostic"}
{"@level":"info","@message":"Policy Result","@module":"terraform.ui","@policy":"true","target_address":"test_instance.foo","policy_address":"resource_policy.foo","policy_metadata":{"policy_set_path":"policy_file.tfpolicy.hcl","policy_name":"resource_policy.foo","file_name":"policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"result":"DenyResult","type":"policy_result"}
{"@level":"info","@message":"Policy Result","@module":"terraform.ui","@policy":"true","target_address":"test_instance.foo","policy_address":"resource_policy.bar","policy_metadata":{"policy_set_path":"policy_file.tfpolicy.hcl","policy_name":"resource_policy.bar","file_name":"policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"result":"DenyResult","type":"policy_result"}`
checkGoldenReferenceStr(t, output, expected)
}
func TestPlan_WithPolicyUnknown(t *testing.T) {
td := t.TempDir()
testCopyDir(t, testFixturePath("plan"), td)
t.Chdir(td)
policyCode := ` resource_policy "resource_type" "policy_name" {
enforce_attrs {
key = attr.value == "foo"
}
}
`
if err := os.WriteFile("policy.hcl", []byte(policyCode), 0644); err != nil {
t.Fatal(err)
}
p := planFixtureProvider()
view, done := testView(t)
overrides := metaOverridesForProvider(p)
policyClient := policy.NewTestMockClient(t)
overrides.PolicyClient = policyClient
c := &PlanCommand{
Meta: Meta{
testingOverrides: overrides,
View: view,
AllowExperimentalFeatures: true,
},
}
resp := policy.EvaluationFromProtoResponse(proto.EvaluateResult_UNKNOWN_EVALUATE_RESULT, []*proto.PolicyEvaluationDetail{
{
Result: proto.EvaluateResult_UNKNOWN_EVALUATE_RESULT,
Diagnostics: []*proto.Diagnostic{
{
Severity: proto.Severity_WARNING,
Summary: "policy with unknowns",
Result: &proto.DiagnosticResult{
Result: proto.EvaluateResult_UNKNOWN_EVALUATE_RESULT,
},
Subject: &proto.Range{
Filename: "policy_file.tfpolicy.hcl",
Start: &proto.Position{
Line: 1,
Column: 1,
},
End: &proto.Position{
Line: 2,
Column: 4,
},
},
Context: &proto.Range{
Filename: "policy_file.tfpolicy.hcl",
Start: &proto.Position{
Line: 1,
Column: 1,
},
End: &proto.Position{
Line: 4,
Column: 10,
},
},
Snippet: &proto.Snippet{
Code: policyCode,
StartLine: 1,
HighlightStartOffset: 1,
HighlightEndOffset: 100,
},
},
},
},
})
policyClient.EvaluateResponse = &resp
args := []string{"-policies", td}
code := c.Run(append(args, "-no-color"))
output := done(t)
if code != 0 {
t.Fatalf("bad: %d\n\n%s", code, output.Stdout())
}
expected := `data.test_data_source.a: Reading...
data.test_data_source.a: Read complete after 0s [id=zzzzz]
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# test_instance.foo will be created
+ resource "test_instance" "foo" {
+ ami = "bar"
+ network_interface {
+ description = "Main network interface"
+ device_index = "0"
}
}
Plan: 1 to add, 0 to change, 0 to destroy.
Warning: policy with unknowns
on policy_file.tfpolicy.hcl line 1:
1: resource_policy "resource_type" "policy_name" {
2: enforce_attrs {
3: key = attr.value == "foo"
4: }
5: }
6:
while evaluating policy for main.tf line 1:
1: resource "test_instance" "foo" {
Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.
`
if actual, diff := output.Stdout(), cmp.Diff(expected, output.Stdout()); diff != "" {
t.Fatalf("unexpected output:\n%s. \nDiff: %s", actual, diff)
}
}
func TestPlan_WithPolicySuccessInfo(t *testing.T) {
td := t.TempDir()
testCopyDir(t, testFixturePath("plan"), td)
t.Chdir(td)
policyCode := ` resource_policy "resource_type" "policy_name" {
enforce_attrs {
key = attr.value == "foo"
}
}
`
if err := os.WriteFile("policy.hcl", []byte(policyCode), 0644); err != nil {
t.Fatal(err)
}
providerSource := newMockProviderSource(t, map[string][]string{
"test": {"1.0.0"},
})
p := planFixtureProvider()
view, done := testView(t)
overrides := metaOverridesForProvider(p)
policyClient := policy.NewTestMockClient(t)
overrides.PolicyClient = policyClient
meta := Meta{
testingOverrides: overrides,
View: view,
ProviderSource: providerSource,
AllowExperimentalFeatures: true,
}
init := &InitCommand{
Meta: meta,
}
if code := init.Run(nil); code != 0 {
output := done(t)
t.Fatalf("expected status code %d but got %d: %s", 0, code, output.All())
}
view, done = testView(t)
meta.View = view
c := &PlanCommand{
Meta: meta,
}
policyObj := &policy.Policy{
Result: policy.AllowResult,
PolicySetName: "some_policy_set",
Address: "policy_name",
Directory: "some/path/to",
Filename: "provider_policy_file.tfpolicy.hcl",
EnforcementLevel: "mandatory",
Range: &hcl.Range{
Filename: "provider_policy_file.tfpolicy.hcl",
Start: hcl.Pos{
Line: 1,
Column: 1,
},
End: hcl.Pos{
Line: 5,
Column: 12,
},
},
}
policyClient.EvaluateProviderFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ProviderMetadata]) policy.EvaluationResponse {
if req.Meta.Version != "1.0.0" {
t.Fatalf("Expected provider version to be 1.0.0")
}
return policy.EvaluationResponse{
Overall: policy.AllowResult,
Policies: []*policy.Policy{policyObj},
Enforcements: []policy.EnforcementResult{
{
Result: policy.AllowResult,
Message: "Something about this enforcement",
BlockIndex: 1,
Snippet: &proto.Snippet{
Code: "provider_policy \"test_policy\" \"name\"",
StartLine: 1,
HighlightStartOffset: 1,
HighlightEndOffset: 100,
},
Range: &hcl.Range{
Filename: "provider_policy_file.tfpolicy.hcl",
Start: hcl.Pos{
Line: 3,
Column: 5,
},
End: hcl.Pos{
Line: 4,
Column: 10,
},
},
Policy: policyObj,
},
},
}
}
policyObj = &policy.Policy{
Result: policy.AllowResult,
PolicySetName: "some_policy_set",
Address: "policy_name",
Directory: "some/path/to",
Filename: "policy_file.tfpolicy.hcl",
EnforcementLevel: "mandatory",
Range: &hcl.Range{
Filename: "policy_file.tfpolicy.hcl",
Start: hcl.Pos{
Line: 1,
Column: 1,
},
End: hcl.Pos{
Line: 5,
Column: 12,
},
},
}
policyClient.EvaluateResponse = &policy.EvaluationResponse{
Overall: policy.AllowResult,
Policies: []*policy.Policy{policyObj},
Enforcements: []policy.EnforcementResult{
{
Result: policy.AllowResult,
Message: "Something about this enforcement",
BlockIndex: 1,
Snippet: &proto.Snippet{
Code: "resource_policy \"test_policy\" \"name\"",
StartLine: 1,
HighlightStartOffset: 1,
HighlightEndOffset: 100,
},
Range: &hcl.Range{
Filename: "policy_file.tfpolicy.hcl",
Start: hcl.Pos{
Line: 3,
Column: 5,
},
End: hcl.Pos{
Line: 4,
Column: 10,
},
},
Policy: policyObj,
},
},
}
args := []string{"-policies", td}
code := c.Run(append(args, "-no-color"))
output := done(t)
if code != 0 {
t.Fatalf("bad: %d\n\n%s", code, output.Stdout())
}
expected := `data.test_data_source.a: Reading...
data.test_data_source.a: Read complete after 0s [id=zzzzz]
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# test_instance.foo will be created
+ resource "test_instance" "foo" {
+ ami = "bar"
+ network_interface {
+ description = "Main network interface"
+ device_index = "0"
}
}
Plan: 1 to add, 0 to change, 0 to destroy.
Policy Info:
on policy_file.tfpolicy.hcl line 3, in resource_policy "test_policy" "name"
"Something about this enforcement"
on main.tf line 1, in resource "test_instance" "foo"
Policy Info:
on provider_policy_file.tfpolicy.hcl line 3, in provider_policy "test_policy" "name"
"Something about this enforcement"
Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.
`
if actual, diff := output.Stdout(), cmp.Diff(expected, output.Stdout()); diff != "" {
t.Fatalf("unexpected output:\n%s. \nDiff: %s", actual, diff)
}
}
func TestPlan_WithPolicySuccessInfoJSON(t *testing.T) {
td := t.TempDir()
testCopyDir(t, testFixturePath("plan"), td)
t.Chdir(td)
p := planFixtureProvider()
view, done := testView(t)
overrides := metaOverridesForProvider(p)
policyClient := policy.NewTestMockClient(t)
overrides.PolicyClient = policyClient
c := &PlanCommand{
Meta: Meta{
testingOverrides: overrides,
View: view,
AllowExperimentalFeatures: true,
},
}
policyObj := &policy.Policy{
Result: policy.AllowResult,
PolicySetName: "some_policy_set",
Address: "policy_name",
Directory: "some/path/to",
Filename: "provider_policy_file.tfpolicy.hcl",
EnforcementLevel: "mandatory",
Range: &hcl.Range{
Filename: "provider_policy_file.tfpolicy.hcl",
Start: hcl.Pos{
Line: 1,
Column: 1,
},
End: hcl.Pos{
Line: 5,
Column: 12,
},
},
}
policyClient.EvaluateProviderResponse = &policy.EvaluationResponse{
Overall: policy.AllowResult,
Policies: []*policy.Policy{policyObj},
Enforcements: []policy.EnforcementResult{
{
Result: policy.AllowResult,
Message: "Something about this enforcement",
BlockIndex: 1,
Snippet: &proto.Snippet{
Code: "provider_policy \"test_policy\" \"name\"",
StartLine: 1,
HighlightStartOffset: 1,
HighlightEndOffset: 100,
},
Range: &hcl.Range{
Filename: "provider_policy_file.tfpolicy.hcl",
Start: hcl.Pos{
Line: 3,
Column: 5,
},
End: hcl.Pos{
Line: 4,
Column: 10,
},
},
Policy: policyObj,
},
},
}
policyObj = &policy.Policy{
Result: policy.AllowResult,
PolicySetName: "some_policy_set",
Address: "policy_name",
Directory: "some/path/to",
Filename: "policy_file.tfpolicy.hcl",
EnforcementLevel: "mandatory",
Range: &hcl.Range{
Filename: "policy_file.tfpolicy.hcl",
Start: hcl.Pos{
Line: 1,
Column: 1,
},
End: hcl.Pos{
Line: 5,
Column: 12,
},
},
}
policyClient.EvaluateResponse = &policy.EvaluationResponse{
Overall: policy.AllowResult,
Policies: []*policy.Policy{policyObj},
Enforcements: []policy.EnforcementResult{
{
Result: policy.AllowResult,
Message: "Something about this enforcement",
BlockIndex: 1,
Snippet: &proto.Snippet{
Code: "resource_policy \"test_policy\" \"name\"",
StartLine: 1,
HighlightStartOffset: 1,
HighlightEndOffset: 100,
},
Range: &hcl.Range{
Filename: "policy_file.tfpolicy.hcl",
Start: hcl.Pos{
Line: 3,
Column: 5,
},
End: hcl.Pos{
Line: 4,
Column: 10,
},
},
Policy: policyObj,
},
},
}
args := []string{"-policies", td}
code := c.Run(append(args, "-no-color", "-json"))
output := done(t)
if code != 0 {
t.Fatalf("bad: %d\n\n%s", code, output.Stdout())
}
checkGoldenReference(t, output, "plan-policy")
}
func TestPlan_WithPolicySetupFailure(t *testing.T) {
td := t.TempDir()
testCopyDir(t, testFixturePath("plan"), td)
t.Chdir(td)
policyCode := ` resource_policy "resource_type" "policy_name" {
enforce_attrs {
key = attr.value == "foo"
}
}
`
if err := os.WriteFile("policy.hcl", []byte(policyCode), 0644); err != nil {
t.Fatal(err)
}
p := planFixtureProvider()
view, done := testView(t)
overrides := metaOverridesForProvider(p)
// We intentionally do not pass a policy client override here so the command
// exercises the real policy client initialization path and emits any setup
// diagnostics from attempting to connect to the policy engine.
c := &PlanCommand{
Meta: Meta{
testingOverrides: overrides,
View: view,
AllowExperimentalFeatures: true,
},
}
args := []string{"-policies", td}
code := c.Run(append(args, "-no-color"))
output := done(t)
// expect the operation to be a success
if code != 0 {
t.Fatalf("bad: %d\n\n%s", code, output.Stderr())
}
// we still display the policy output
// and the plan still succeeds
expectedOut := `
Error: Failed to connect to policy engine
Failed to connect to policy engine: failed to connect to plugin: exec:
"tfpolicy-plugin": executable file not found in $PATH.
data.test_data_source.a: Reading...
data.test_data_source.a: Read complete after 0s [id=zzzzz]
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# test_instance.foo will be created
+ resource "test_instance" "foo" {
+ ami = "bar"
+ network_interface {
+ description = "Main network interface"
+ device_index = "0"
}
}
Plan: 1 to add, 0 to change, 0 to destroy.
Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.
`
if diff := cmp.Diff(expectedOut, output.All()); diff != "" {
t.Fatalf("unexpected output:\n%s", diff)
}
}
func TestPlan_WithPolicySetupFailureJSON(t *testing.T) {
td := t.TempDir()
testCopyDir(t, testFixturePath("plan"), td)
t.Chdir(td)
policyCode := ` resource_policy "resource_type" "policy_name" {
enforce_attrs {
key = attr.value == "foo"
}
}
`
if err := os.WriteFile("policy.hcl", []byte(policyCode), 0644); err != nil {
t.Fatal(err)
}
p := planFixtureProvider()
view, done := testView(t)
overrides := metaOverridesForProvider(p)
// We intentionally do not pass a policy client override here so the command
// exercises the real policy client initialization path and emits any setup
// diagnostics from attempting to connect to the policy engine.
c := &PlanCommand{
Meta: Meta{
testingOverrides: overrides,
View: view,
AllowExperimentalFeatures: true,
},
}
args := []string{"-policies", td}
code := c.Run(append(args, "-no-color", "-json"))
output := done(t)
// expect the operation to be a success
if code != 0 {
t.Fatalf("bad: %d\n\n%s", code, output.Stderr())
}
expected := `{"@level":"info","@message":"Terraform 1.15.0-dev","@module":"terraform.ui","terraform":"1.15.0-dev","type":"version","ui":"1.3"}
{"@level":"error","@message":"Error: Failed to connect to policy engine","@module":"terraform.ui","@policy":"true","policy_diagnostic":{"severity":"error","summary":"Failed to connect to policy engine","detail":"Failed to connect to policy engine: failed to connect to plugin: exec: \"tfpolicy-plugin\": executable file not found in $PATH."},"policy_metadata":{},"result":"SetupErrorResult","type":"policy_diagnostic"}
{"@level":"info","@message":"data.test_data_source.a: Refreshing...","@module":"terraform.ui","hook":{"resource":{"addr":"data.test_data_source.a","module":"","resource":"data.test_data_source.a","implied_provider":"test","resource_type":"test_data_source","resource_name":"a","resource_key":null},"action":"read"},"type":"apply_start"}
{"@level":"info","@message":"data.test_data_source.a: Refresh complete after 0s [id=zzzzz]","@module":"terraform.ui","hook":{"resource":{"addr":"data.test_data_source.a","module":"","resource":"data.test_data_source.a","implied_provider":"test","resource_type":"test_data_source","resource_name":"a","resource_key":null},"action":"read","id_key":"id","id_value":"zzzzz","elapsed_seconds":0},"type":"apply_complete"}
{"@level":"info","@message":"test_instance.foo: Plan to create","@module":"terraform.ui","change":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create"},"type":"planned_change"}
{"@level":"info","@message":"Plan: 1 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","changes":{"add":1,"change":0,"import":0,"remove":0,"action_invocation":0,"operation":"plan"},"type":"change_summary"}`
fmt.Println(output.Stdout())
checkGoldenReferenceStr(t, output, expected)
}

View file

@ -4,6 +4,7 @@
package command
import (
"context"
"fmt"
"os"
@ -90,7 +91,9 @@ func (c *ProvidersSchemaCommand) Run(args []string) int {
}
// Get the context
lr, _, ctxDiags := local.LocalRun(opReq)
lr, _, ctxDiags := local.LocalRun(context.Background(), opReq)
defer lr.Finish()
diags = diags.Append(ctxDiags)
if ctxDiags.HasErrors() {
c.showDiagnostics(diags)

View file

@ -118,7 +118,7 @@ func (c *QueryCommand) Run(rawArgs []string) int {
}
// Build the operation request
opReq, opDiags := c.OperationRequest(be, view, args.ViewType, args.GenerateConfigPath)
opReq, opDiags := c.OperationRequest(be, view, args.ViewType, args.GenerateConfigPath, args.PolicyPaths)
diags = diags.Append(opDiags)
if diags.HasErrors() {
view.Diagnostics(diags)
@ -164,6 +164,7 @@ func (c *QueryCommand) OperationRequest(
view views.Query,
viewType arguments.ViewType,
generateConfigOut string,
policyPaths []string,
) (*backendrun.Operation, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
@ -175,6 +176,7 @@ func (c *QueryCommand) OperationRequest(
opReq.GenerateConfigOut = generateConfigOut
opReq.View = view.Operation()
opReq.Query = true
opReq.PolicyPaths = policyPaths
var err error
opReq.ConfigLoader, err = c.initConfigLoader()

View file

@ -4,6 +4,7 @@
package command
import (
"context"
"errors"
"fmt"
"os"
@ -93,7 +94,9 @@ func (c *StateShowCommand) Run(args []string) int {
}
// Get the context (required to get the schemas)
lr, _, ctxDiags := local.LocalRun(opReq)
lr, _, ctxDiags := local.LocalRun(context.Background(), opReq)
defer lr.Finish()
if ctxDiags.HasErrors() {
return view.DisplayResourceInstanceState(jsonformat.State{}, diags)
}

View file

@ -0,0 +1,13 @@
resource "test_instance" "foo" {
ami = "bar"
# This is here because at some point it caused a test failure
network_interface {
device_index = 0
description = "Main network interface"
}
}
data "test_data_source" "a" {
id = "zzzzz"
}

View file

@ -0,0 +1,9 @@
{"@level":"info","@message":"Terraform 1.15.0-dev","@module":"terraform.ui","terraform":"1.15.0-dev","type":"version","ui":"1.2"}
{"@level":"info","@message":"data.test_data_source.a: Refreshing...","@module":"terraform.ui","hook":{"resource":{"addr":"data.test_data_source.a","module":"","resource":"data.test_data_source.a","implied_provider":"test","resource_type":"test_data_source","resource_name":"a","resource_key":null},"action":"read"},"type":"apply_start"}
{"@level":"info","@message":"data.test_data_source.a: Refresh complete after 0s [id=zzzzz]","@module":"terraform.ui","hook":{"resource":{"addr":"data.test_data_source.a","module":"","resource":"data.test_data_source.a","implied_provider":"test","resource_type":"test_data_source","resource_name":"a","resource_key":null},"action":"read","id_key":"id","id_value":"zzzzz","elapsed_seconds":0},"type":"apply_complete"}
{"@level":"info","@message":"test_instance.foo: Plan to create","@module":"terraform.ui","change":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create"},"type":"planned_change"}
{"@level":"info","@message":"Plan: 1 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","changes":{"add":1,"change":0,"import":0,"remove":0,"action_invocation":0,"operation":"plan"},"type":"change_summary"}
{"@level":"info","@message":"Policy info","@module":"terraform.ui","@policy":"true","target_address":"test_instance.foo","policy_info":{"message":"Something about this enforcement","policy_range":{"filename":"policy_file.tfpolicy.hcl","start":{"line":3,"column":5,"byte":0},"end":{"line":4,"column":10,"byte":0}},"policy_snippet":{"context":null,"code":"resource_policy \"test_policy\" \"name\"","start_line":1,"highlight_start_offset":1,"highlight_end_offset":100,"values":null},"range":{"filename":"main.tf","start":{"line":1,"column":1,"byte":0},"end":{"line":1,"column":31,"byte":30}},"snippet":{"context":null,"code":"resource \"test_instance\" \"foo\" {","start_line":1,"highlight_start_offset":0,"highlight_end_offset":30,"values":[]}},"policy_metadata":{"enforce_index":1,"policy_set_name":"some_policy_set","policy_set_path":"some/path/to","policy_name":"policy_name","file_name":"policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"result":"AllowResult","type":"policy_info"}
{"@level":"info","@message":"Policy Result","@module":"terraform.ui","@policy":"true","result":"AllowResult","target_address":"test_instance.foo","policy_address":"policy_name","policy_metadata":{"policy_set_name":"some_policy_set","policy_set_path":"some/path/to","policy_name":"policy_name","file_name":"policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"type":"policy_result"}
{"@level":"info","@message":"Policy info","@module":"terraform.ui","@policy":"true","target_address":"provider[\"registry.terraform.io/hashicorp/test\"]","policy_info":{"message":"Something about this enforcement","policy_range":{"filename":"provider_policy_file.tfpolicy.hcl","start":{"line":3,"column":5,"byte":0},"end":{"line":4,"column":10,"byte":0}},"policy_snippet":{"context":null,"code":"provider_policy \"test_policy\" \"name\"","start_line":1,"highlight_start_offset":1,"highlight_end_offset":100,"values":null}},"policy_metadata":{"enforce_index":1,"policy_set_name":"some_policy_set","policy_set_path":"some/path/to","policy_name":"policy_name","file_name":"provider_policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"result":"AllowResult","type":"policy_info"}
{"@level":"info","@message":"Policy Result","@module":"terraform.ui","@policy":"true","target_address":"provider[\"registry.terraform.io/hashicorp/test\"]","policy_address":"policy_name","policy_metadata":{"policy_set_name":"some_policy_set","policy_set_path":"some/path/to","policy_name":"policy_name","file_name":"provider_policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"result":"AllowResult","type":"policy_result"}

View file

@ -59,4 +59,9 @@ const (
MessageActionProgress MessageType = "action_progress"
MessageActionComplete MessageType = "action_complete"
MessageActionErrored MessageType = "action_errored"
// Policy messages
MessagePolicyInfo MessageType = "policy_info"
MessagePolicyDiagnostic MessageType = "policy_diagnostic"
MessagePolicyEvaluationResult MessageType = "policy_result"
)

View file

@ -0,0 +1,89 @@
// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1
package json
import (
"github.com/hashicorp/terraform/internal/policy"
)
// PolicyInfo is like an info diagnostic from the policy engine,
// and as such borrows diagnostic-related structs to
// host source information such as range and snippet.
type PolicyInfo struct {
Message string `json:"message,omitempty"`
PolicyRange *DiagnosticRange `json:"policy_range,omitempty"`
PolicySnippet *DiagnosticSnippet `json:"policy_snippet,omitempty"`
// Range and Snippet are the terraform source information
Range *DiagnosticRange `json:"range,omitempty"`
Snippet *DiagnosticSnippet `json:"snippet,omitempty"`
}
type PolicyMetadata struct {
PolicySetName string `json:"policy_set_name,omitempty"`
PolicySetPath string `json:"policy_set_path,omitempty"`
PolicyName string `json:"policy_name,omitempty"`
FileName string `json:"file_name,omitempty"`
EnforcementLevel string `json:"enforcement_level,omitempty"`
EnforceIndex *int32 `json:"enforce_index,omitempty"`
}
type EnforceMetadata struct {
BlockIndex *int32 `json:"block_index,omitempty"`
}
func NewPolicyInfo(sourceCode []byte, enforcement policy.EnforcementResult) PolicyInfo {
ret := PolicyInfo{
Message: enforcement.Message,
}
if rng := enforcement.Range; rng != nil {
ret.PolicyRange = &DiagnosticRange{
Filename: rng.Filename,
Start: Pos(rng.Start),
End: Pos(rng.End),
}
}
if snippet := enforcement.Snippet; snippet != nil {
ret.PolicySnippet = &DiagnosticSnippet{
Code: snippet.Code,
StartLine: int(snippet.StartLine),
HighlightStartOffset: int(snippet.HighlightStartOffset),
HighlightEndOffset: int(snippet.HighlightEndOffset),
}
if snippet.Context != nil && snippet.Context.Context != "" {
ret.PolicySnippet.Context = &snippet.Context.Context
}
}
if rng := enforcement.LocalRange; rng != nil {
ret.Range = &DiagnosticRange{
Filename: rng.Filename,
Start: Pos(rng.Start),
End: Pos(rng.End),
}
if sourceCode != nil {
ret.Snippet = snippetFromRange(sourceCode, *rng, *rng)
}
}
return ret
}
func MetadataFromPolicy(policy policy.Policy) PolicyMetadata {
return PolicyMetadata{
PolicySetName: policy.PolicySetName,
PolicySetPath: policy.Directory,
PolicyName: policy.Address,
FileName: policy.Filename,
EnforcementLevel: policy.EnforcementLevel,
}
}
func MetadataFromEnforcement(enforcement policy.EnforcementResult) PolicyMetadata {
ret := MetadataFromPolicy(*enforcement.Policy)
ret.EnforceIndex = &enforcement.BlockIndex
return ret
}

View file

@ -8,8 +8,11 @@ import (
"fmt"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/terraform/internal/command/views/json"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/policy"
"github.com/hashicorp/terraform/internal/tfdiags"
tfversion "github.com/hashicorp/terraform/version"
)
@ -142,3 +145,85 @@ func (v *JSONView) Outputs(outputs json.Outputs) {
"outputs", outputs,
)
}
func (v *JSONView) PolicyResults(results *plans.PolicyResults) {
if results == nil {
return
}
// Log all non-policy-specific diagnostics if any.
for _, diag := range results.Diagnostics {
v.logPolicyDiagnostic(diag)
}
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),
)
}
}
}
func (v *JSONView) logPolicyDiagnostic(diag tfdiags.Diagnostic, extraArgs ...any) {
// Log the policy diagnostics. The severity level here is from the policy engine, and terraform
// does not use it at all. Therefore, the log level of these diagnostics is only relevant
// for policies.
sources := v.view.configSources()
diagnostic := json.NewDiagnostic(diag, sources)
args := []any{
"type", json.MessagePolicyDiagnostic,
"@policy", "true",
json.MessagePolicyDiagnostic, diagnostic,
}
args = append(args, extraArgs...)
extra := tfdiags.ExtraInfo[*policy.PolicyExtra](diag)
if extra != nil {
policyMetadata := json.MetadataFromPolicy(extra.Policy)
if extra.EnforceIndex != nil {
policyMetadata.EnforceIndex = extra.EnforceIndex
}
args = append(args, "policy_metadata", policyMetadata)
args = append(args, "result", extra.Result.String())
}
switch extra.Severity {
case hcl.DiagWarning:
v.log.Warn(fmt.Sprintf("Warning: %s", diag.Description().Summary), args...)
default:
v.log.Error(fmt.Sprintf("Error: %s", diag.Description().Summary), args...)
}
}

View file

@ -34,6 +34,8 @@ type Operation interface {
PlanNextStep(planPath string, genConfigPath string)
Diagnostics(diags tfdiags.Diagnostics)
PolicyResults(results *plans.PolicyResults)
}
func NewOperation(vt arguments.ViewType, inAutomation bool, view *View) Operation {
@ -131,6 +133,10 @@ func (v *OperationHuman) Plan(plan *plans.Plan, schemas *terraform.Schemas) {
renderer.RenderHumanPlan(jplan, plan.UIMode, opts...)
}
func (v *OperationHuman) PolicyResults(results *plans.PolicyResults) {
v.view.PolicyResults(results)
}
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
@ -290,6 +296,10 @@ func (v *OperationJSON) Diagnostics(diags tfdiags.Diagnostics) {
v.view.Diagnostics(diags)
}
func (v *OperationJSON) PolicyResults(results *plans.PolicyResults) {
v.view.PolicyResults(results)
}
const fatalInterrupt = `
Two interrupts received. Exiting immediately. Note that data loss may have occurred.
`

View file

@ -97,6 +97,10 @@ func (v *QueryOperationHuman) Diagnostics(diags tfdiags.Diagnostics) {
v.view.Diagnostics(diags)
}
func (v *QueryOperationHuman) PolicyResults(results *plans.PolicyResults) {
v.view.PolicyResults(results)
}
type QueryOperationJSON struct {
view *JSONView
}
@ -135,3 +139,7 @@ func (v *QueryOperationJSON) PlanNextStep(planPath string, genConfigPath string)
func (v *QueryOperationJSON) Diagnostics(diags tfdiags.Diagnostics) {
v.view.Diagnostics(diags)
}
func (v *QueryOperationJSON) PolicyResults(results *plans.PolicyResults) {
v.view.PolicyResults(results)
}

View file

@ -4,10 +4,15 @@
package views
import (
"fmt"
"strings"
"github.com/mitchellh/colorstring"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/format"
"github.com/hashicorp/terraform/internal/command/views/json"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/terminal"
"github.com/hashicorp/terraform/internal/tfdiags"
)
@ -111,12 +116,7 @@ func (v *View) Diagnostics(diags tfdiags.Diagnostics) {
}
for _, diag := range diags {
var msg string
if v.colorize.Disable {
msg = format.DiagnosticPlain(diag, v.configSources(), v.streams.Stderr.Columns())
} else {
msg = format.Diagnostic(diag, v.configSources(), v.colorize, v.streams.Stderr.Columns())
}
msg := v.formatDiagnostic(diag)
if diag.Severity() == tfdiags.Error {
v.streams.Eprint(msg)
@ -126,6 +126,84 @@ func (v *View) Diagnostics(diags tfdiags.Diagnostics) {
}
}
// PolicyResults renders the policy results in human-readable format.
// This is done separately from the plan rendering because it may require additional
// source information that is not available in the plan renderer.
func (v *View) PolicyResults(results *plans.PolicyResults) {
if results == nil {
return
}
configSources := v.configSources()
var buf strings.Builder
var foundInfo bool
// Print setup diagnostics
for _, diag := range results.Diagnostics {
msg := v.formatDiagnostic(diag)
if diag.Severity() == tfdiags.Error {
v.streams.Eprint(msg)
} else {
v.streams.Print(msg)
}
}
for _, result := range results.Iter() {
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 {
buf.WriteString(fmt.Sprintf(
"on %s line %d, in %s\n",
info.PolicyRange.Filename,
info.PolicyRange.Start.Line,
info.PolicySnippet.Code,
))
} else if enforcement.Policy != nil {
buf.WriteString(fmt.Sprintf(
"in policy %s\n",
enforcement.Policy.Address,
))
}
buf.WriteString(fmt.Sprintf("%q\n", info.Message))
if !result.ConfigDeclRange.Empty() {
cfgRange := result.ConfigDeclRange
resourceContext := string(cfgRange.SliceBytes(configSources[cfgRange.Filename]))
// Here we want the resource source context
buf.WriteString(fmt.Sprintf(
"\non %s line %d, in %s\n",
cfgRange.Filename,
cfgRange.Start.Line,
resourceContext,
))
}
buf.WriteString("\n")
}
}
// Print policy diagnostics
for _, diag := range result.EvaluationResponse.Diagnostics {
msg := v.formatDiagnostic(diag)
if diag.Severity() == tfdiags.Error {
v.streams.Eprint(msg)
} else {
v.streams.Print(msg)
}
}
}
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.
@ -164,3 +242,11 @@ func (v *View) errorColumns() int {
func (v *View) outputHorizRule() {
v.streams.Println(format.HorizontalRule(v.colorize, v.outputColumns()))
}
func (v *View) formatDiagnostic(diag tfdiags.Diagnostic) string {
if v.colorize.Disable {
return format.DiagnosticPlain(diag, v.configSources(), v.streams.Stderr.Columns())
} else {
return format.Diagnostic(diag, v.configSources(), v.colorize, v.streams.Stderr.Columns())
}
}