diff --git a/internal/backend/backendrun/local_run.go b/internal/backend/backendrun/local_run.go index d08082e3c62d..7e24f9f9a132 100644 --- a/internal/backend/backendrun/local_run.go +++ b/internal/backend/backendrun/local_run.go @@ -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() + } } diff --git a/internal/backend/backendrun/operation.go b/internal/backend/backendrun/operation.go index 4168f7dbc162..2f0c7dba822f 100644 --- a/internal/backend/backendrun/operation.go +++ b/internal/backend/backendrun/operation.go @@ -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 diff --git a/internal/backend/local/backend.go b/internal/backend/local/backend.go index 1195c2d9273f..a9d2efa68017 100644 --- a/internal/backend/local/backend.go +++ b/internal/backend/local/backend.go @@ -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() diff --git a/internal/backend/local/backend_apply.go b/internal/backend/local/backend_apply.go index 3c250069397c..79c2ef86f5f9 100644 --- a/internal/backend/local/backend_apply.go +++ b/internal/backend/local/backend_apply.go @@ -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 { diff --git a/internal/backend/local/backend_local.go b/internal/backend/local/backend_local.go index 0c0269c1825d..c0f38d05fa9f 100644 --- a/internal/backend/local/backend_local.go +++ b/internal/backend/local/backend_local.go @@ -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() +} diff --git a/internal/backend/local/backend_local_test.go b/internal/backend/local/backend_local_test.go index 9c169bd4a18a..4da619c15437 100644 --- a/internal/backend/local/backend_local_test.go +++ b/internal/backend/local/backend_local_test.go @@ -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") } diff --git a/internal/backend/local/backend_plan.go b/internal/backend/local/backend_plan.go index 38490fc05e8d..559ba893a476 100644 --- a/internal/backend/local/backend_plan.go +++ b/internal/backend/local/backend_plan.go @@ -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 diff --git a/internal/backend/local/backend_refresh.go b/internal/backend/local/backend_refresh.go index ec5a4fd364e1..0b4dcbf740d2 100644 --- a/internal/backend/local/backend_refresh.go +++ b/internal/backend/local/backend_refresh.go @@ -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) diff --git a/internal/backend/remote/backend_context.go b/internal/backend/remote/backend_context.go index db72d709edac..0c4b40996584 100644 --- a/internal/backend/remote/backend_context.go +++ b/internal/backend/remote/backend_context.go @@ -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: diff --git a/internal/backend/remote/backend_context_test.go b/internal/backend/remote/backend_context_test.go index 70883b3bae96..de02ae41d015 100644 --- a/internal/backend/remote/backend_context_test.go +++ b/internal/backend/remote/backend_context_test.go @@ -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: ", diags.Err().Error()) diff --git a/internal/cloud/backend_context.go b/internal/cloud/backend_context.go index 89bf81415284..2932bfed3af3 100644 --- a/internal/cloud/backend_context.go +++ b/internal/cloud/backend_context.go @@ -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: diff --git a/internal/cloud/backend_context_test.go b/internal/cloud/backend_context_test.go index 3b7dc05aed46..3eb0cda7dc30 100644 --- a/internal/cloud/backend_context_test.go +++ b/internal/cloud/backend_context_test.go @@ -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: ", diags.Err().Error()) diff --git a/internal/cloud/backend_plan.go b/internal/cloud/backend_plan.go index f5e554ee45f9..62612e356f65 100644 --- a/internal/cloud/backend_plan.go +++ b/internal/cloud/backend_plan.go @@ -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] +` diff --git a/internal/cloud/backend_plan_test.go b/internal/cloud/backend_plan_test.go index 98d04ae9b65b..e396b25b2410 100644 --- a/internal/cloud/backend_plan_test.go +++ b/internal/cloud/backend_plan_test.go @@ -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() diff --git a/internal/cloud/tfe_client_mock.go b/internal/cloud/tfe_client_mock.go index b8cba1cc983f..dd7f8c65a75b 100644 --- a/internal/cloud/tfe_client_mock.go +++ b/internal/cloud/tfe_client_mock.go @@ -1304,6 +1304,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 { diff --git a/internal/command/apply.go b/internal/command/apply.go index 29cfb7698e0f..288f93adce82 100644 --- a/internal/command/apply.go +++ b/internal/command/apply.go @@ -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 { diff --git a/internal/command/apply_policy_test.go b/internal/command/apply_policy_test.go new file mode 100644 index 000000000000..e8d02670925c --- /dev/null +++ b/internal/command/apply_policy_test.go @@ -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) +} diff --git a/internal/command/arguments/apply.go b/internal/command/arguments/apply.go index 5811d1455b15..accf7c3c63d6 100644 --- a/internal/command/arguments/apply.go +++ b/internal/command/arguments/apply.go @@ -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") diff --git a/internal/command/arguments/plan.go b/internal/command/arguments/plan.go index 8dd086735485..8a1b3b382165 100644 --- a/internal/command/arguments/plan.go +++ b/internal/command/arguments/plan.go @@ -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") diff --git a/internal/command/arguments/query.go b/internal/command/arguments/query.go index 7792d750a4fe..c930bad305d3 100644 --- a/internal/command/arguments/query.go +++ b/internal/command/arguments/query.go @@ -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") diff --git a/internal/command/cloud_mock.go b/internal/command/cloud_mock.go index 93f59199bffd..2a481bd7ab58 100644 --- a/internal/command/cloud_mock.go +++ b/internal/command/cloud_mock.go @@ -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 { diff --git a/internal/command/command_test.go b/internal/command/command_test.go index 8eecaf1ef286..d33da3df6a19 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -1230,3 +1230,76 @@ func checkGoldenReference(t *testing.T, output *terminal.TestOutput, fixturePath "Please communicate with HCP Terraform team before resolving", diff) } } + +func checkGoldenReferenceStr(t *testing.T, output *terminal.TestOutput, out string) { + t.Helper() + want := out + + got := output.Stdout() + + // Split the output and the reference into lines so that we can compare + // messages + got = strings.TrimSuffix(got, "\n") + gotLines := strings.Split(got, "\n") + + want = strings.TrimSuffix(want, "\n") + wantLines := strings.Split(want, "\n") + + if len(gotLines) != len(wantLines) { + t.Errorf("unexpected number of log lines: got %d, want %d\n"+ + "NOTE: This failure may indicate a UI change affecting the behavior of structured run output on HCP Terraform.\n"+ + "Please communicate with HCP Terraform team before resolving", len(gotLines), len(wantLines)) + } + + // Verify that the log starts with a version message + type versionMessage struct { + Level string `json:"@level"` + Message string `json:"@message"` + Type string `json:"type"` + Terraform string `json:"terraform"` + UI string `json:"ui"` + } + var gotVersion versionMessage + if err := json.Unmarshal([]byte(gotLines[0]), &gotVersion); err != nil { + t.Errorf("failed to unmarshal version line: %s\n%s", err, gotLines[0]) + } + wantVersion := versionMessage{ + "info", + fmt.Sprintf("Terraform %s", version.String()), + "version", + version.String(), + views.JSON_UI_VERSION, + } + if !cmp.Equal(wantVersion, gotVersion) { + t.Errorf("unexpected first message:\n%s", cmp.Diff(wantVersion, gotVersion)) + } + + // Compare the rest of the lines against the golden reference + var gotLineMaps []map[string]interface{} + for i, line := range gotLines[1:] { + index := i + 1 + var gotMap map[string]interface{} + if err := json.Unmarshal([]byte(line), &gotMap); err != nil { + t.Errorf("failed to unmarshal got line %d: %s\n%s", index, err, gotLines[index]) + } + if _, ok := gotMap["@timestamp"]; !ok { + t.Errorf("missing @timestamp field in log: %s", gotLines[index]) + } + delete(gotMap, "@timestamp") + gotLineMaps = append(gotLineMaps, gotMap) + } + var wantLineMaps []map[string]interface{} + for i, line := range wantLines[1:] { + index := i + 1 + var wantMap map[string]interface{} + if err := json.Unmarshal([]byte(line), &wantMap); err != nil { + t.Errorf("failed to unmarshal want line %d: %s\n%s", index, err, gotLines[index]) + } + wantLineMaps = append(wantLineMaps, wantMap) + } + if diff := cmp.Diff(wantLineMaps, gotLineMaps); diff != "" { + t.Errorf("wrong output lines\n%s\n"+ + "NOTE: This failure may indicate a UI change affecting the behavior of structured run output on TFC.\n"+ + "Please communicate with HCP Terraform team before resolving", diff) + } +} diff --git a/internal/command/console.go b/internal/command/console.go index 29432a8c9d88..69735e732c31 100644 --- a/internal/command/console.go +++ b/internal/command/console.go @@ -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) diff --git a/internal/command/format/diagnostic.go b/internal/command/format/diagnostic.go index 365d23314ea7..9319e4d8bd94 100644 --- a/internal/command/format/diagnostic.go +++ b/internal/command/format/diagnostic.go @@ -229,6 +229,30 @@ type snippetFormatter struct { func (f *snippetFormatter) write() { diag := f.diag buf := f.buf + + snippetPrefix := " on" + if diag.PolicyRange != nil { + + snippetPrefix = " while evaluating policy for" + + if diag.PolicySnippet == nil { + fmt.Fprintf(buf, " on %s line %d:\n (source code not available)\n", diag.PolicyRange.Filename, diag.PolicyRange.Start.Line) + } else { + snippet := diag.PolicySnippet + code := snippet.Code + + var contextStr string + if snippet.Context != nil { + contextStr = fmt.Sprintf(", in %s", *snippet.Context) + } + + fmt.Fprintf(buf, " on %s line %d%s:\n", diag.PolicyRange.Filename, diag.PolicyRange.Start.Line, contextStr) + f.writeSnippet(snippet, code) + } + + buf.WriteByte('\n') + } + if diag.Address != "" { fmt.Fprintf(buf, " with %s,\n", diag.Address) } @@ -242,7 +266,7 @@ func (f *snippetFormatter) write() { // loaded through the main loader. We may load things in other // ways in weird cases, so we'll tolerate it at the expense of // a not-so-helpful error message. - fmt.Fprintf(buf, " on %s line %d:\n (source code not available)\n", diag.Range.Filename, diag.Range.Start.Line) + fmt.Fprintf(buf, "%s %s line %d:\n (source code not available)\n", snippetPrefix, diag.Range.Filename, diag.Range.Start.Line) } else { snippet := diag.Snippet code := snippet.Code @@ -251,7 +275,7 @@ func (f *snippetFormatter) write() { if snippet.Context != nil { contextStr = fmt.Sprintf(", in %s", *snippet.Context) } - fmt.Fprintf(buf, " on %s line %d%s:\n", diag.Range.Filename, diag.Range.Start.Line, contextStr) + fmt.Fprintf(buf, "%s %s line %d%s:\n", snippetPrefix, diag.Range.Filename, diag.Range.Start.Line, contextStr) f.writeSnippet(snippet, code) if diag.DeprecationOriginDescription != "" { @@ -365,7 +389,6 @@ func (f *snippetFormatter) writeSnippet(snippet *viewsjson.DiagnosticSnippet, co } } } - } func (f *snippetFormatter) printTestDiagOutput(diag *viewsjson.DiagnosticTestBinaryExpr) { diff --git a/internal/command/graph.go b/internal/command/graph.go index 02e8b3c924c7..3e8eb014f0a1 100644 --- a/internal/command/graph.go +++ b/internal/command/graph.go @@ -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) diff --git a/internal/command/import.go b/internal/command/import.go index f733fa2ae8d3..32559a53adc3 100644 --- a/internal/command/import.go +++ b/internal/command/import.go @@ -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) diff --git a/internal/command/meta.go b/internal/command/meta.go index 7a9dee73083b..3cc9c3afda60 100644 --- a/internal/command/meta.go +++ b/internal/command/meta.go @@ -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 diff --git a/internal/command/meta_policy.go b/internal/command/meta_policy.go new file mode 100644 index 000000000000..736e125ffb61 --- /dev/null +++ b/internal/command/meta_policy.go @@ -0,0 +1,106 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "context" + "fmt" + "log" + + "github.com/apparentlymart/go-versions/versions" + "github.com/apparentlymart/go-versions/versions/constraints" + + "github.com/hashicorp/terraform/internal/policy" + "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 +} diff --git a/internal/command/plan.go b/internal/command/plan.go index 3021db5cc4b0..ef301cc82eb0 100644 --- a/internal/command/plan.go +++ b/internal/command/plan.go @@ -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 } diff --git a/internal/command/plan_policy_test.go b/internal/command/plan_policy_test.go new file mode 100644 index 000000000000..a125389b5758 --- /dev/null +++ b/internal/command/plan_policy_test.go @@ -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) +} diff --git a/internal/command/providers_schema.go b/internal/command/providers_schema.go index 33541806d514..d469cb531d07 100644 --- a/internal/command/providers_schema.go +++ b/internal/command/providers_schema.go @@ -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) diff --git a/internal/command/query.go b/internal/command/query.go index 456d084ffc8f..987e232b6adb 100644 --- a/internal/command/query.go +++ b/internal/command/query.go @@ -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() diff --git a/internal/command/state_show.go b/internal/command/state_show.go index bddcae07182f..3132507f5c26 100644 --- a/internal/command/state_show.go +++ b/internal/command/state_show.go @@ -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) } diff --git a/internal/command/testdata/plan-policy/main.tf b/internal/command/testdata/plan-policy/main.tf new file mode 100644 index 000000000000..7b30915731c7 --- /dev/null +++ b/internal/command/testdata/plan-policy/main.tf @@ -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" +} diff --git a/internal/command/testdata/plan-policy/output.jsonlog b/internal/command/testdata/plan-policy/output.jsonlog new file mode 100644 index 000000000000..e5d33fdf31d2 --- /dev/null +++ b/internal/command/testdata/plan-policy/output.jsonlog @@ -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"} diff --git a/internal/command/views/json/diagnostic.go b/internal/command/views/json/diagnostic.go index 00e843f5485f..3f99a0975bf0 100644 --- a/internal/command/views/json/diagnostic.go +++ b/internal/command/views/json/diagnostic.go @@ -5,6 +5,7 @@ package json import ( "bufio" + "bytes" "fmt" "sort" "strings" @@ -14,8 +15,10 @@ import ( "github.com/hashicorp/hcl/v2/hclparse" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/msgpack" "github.com/hashicorp/terraform/internal/lang/marks" + "github.com/hashicorp/terraform/internal/policy" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -41,6 +44,9 @@ type Diagnostic struct { Snippet *DiagnosticSnippet `json:"snippet,omitempty"` DeprecationOriginDescription string `json:"deprecation_origin_description,omitempty"` + + PolicyRange *DiagnosticRange `json:"policy_range,omitempty"` + PolicySnippet *DiagnosticSnippet `json:"policy_snippet,omitempty"` } // Pos represents a position in the source code. @@ -223,6 +229,8 @@ func NewDiagnostic(diag tfdiags.Diagnostic, sources map[string][]byte) *Diagnost // If we have a source file for the diagnostic, we can emit a code // snippet. if src != nil { + // Build the string of the code snippet, tracking at which byte of + // the file the snippet starts. diagnostic.Snippet = snippetFromRange(src, highlightRange, snippetRange) if fromExpr := diag.FromExpr(); fromExpr != nil { @@ -275,94 +283,12 @@ func NewDiagnostic(diag tfdiags.Diagnostic, sources map[string][]byte) *Diagnost continue Traversals } - // We'll skip any value that has a mark that we don't - // know how to handle, because in that case we can't - // know what that mark is intended to represent and so - // must be conservative. - _, valMarks := val.Unmark() - for mark := range valMarks { - switch mark { - case marks.Sensitive, marks.Ephemeral: - // These are handled below - continue - default: - // All other marks are unhandled, so we'll - // skip this traversal entirely. - continue Traversals - } + stmt, ok := statementStr(val, includeUnknown, includeSensitive, includeEphemeral) + if !ok { + continue Traversals } - switch { - case marks.Has(val, marks.Sensitive) && marks.Has(val, marks.Ephemeral): - // We only mention the combination of sensitive and ephemeral - // values if the diagnostic we're rendering is explicitly - // marked as being caused by sensitive and ephemeral values, - // because otherwise readers tend to be misled into thinking the error - // is caused by the sensitive value even when it isn't. - if !includeSensitive || !includeEphemeral { - continue Traversals - } - value.Statement = "has an ephemeral, sensitive value" - case marks.Has(val, marks.Sensitive): - // We only mention a sensitive value if the diagnostic - // we're rendering is explicitly marked as being - // caused by sensitive values, because otherwise - // readers tend to be misled into thinking the error - // is caused by the sensitive value even when it isn't. - if !includeSensitive { - continue Traversals - } - // Even when we do mention one, we keep it vague - // in order to minimize the chance of giving away - // whatever was sensitive about it. - value.Statement = "has a sensitive value" - case marks.Has(val, marks.Ephemeral): - if !includeEphemeral { - continue Traversals - } - value.Statement = "has an ephemeral value" - case !val.IsKnown(): - // We'll avoid saying anything about unknown or - // "known after apply" unless the diagnostic is - // explicitly marked as being caused by unknown - // values, because otherwise readers tend to be - // misled into thinking the error is caused by the - // unknown value even when it isn't. - if ty := val.Type(); ty != cty.DynamicPseudoType { - if includeUnknown { - switch { - case ty.IsCollectionType(): - valRng := val.Range() - minLen := valRng.LengthLowerBound() - maxLen := valRng.LengthUpperBound() - const maxLimit = 1024 // (upper limit is just an arbitrary value to avoid showing distracting large numbers in the UI) - switch { - case minLen == maxLen: - value.Statement = fmt.Sprintf("is a %s of length %d, known only after apply", ty.FriendlyName(), minLen) - case minLen != 0 && maxLen <= maxLimit: - value.Statement = fmt.Sprintf("is a %s with between %d and %d elements, known only after apply", ty.FriendlyName(), minLen, maxLen) - case minLen != 0: - value.Statement = fmt.Sprintf("is a %s with at least %d elements, known only after apply", ty.FriendlyName(), minLen) - case maxLen <= maxLimit: - value.Statement = fmt.Sprintf("is a %s with up to %d elements, known only after apply", ty.FriendlyName(), maxLen) - default: - value.Statement = fmt.Sprintf("is a %s, known only after apply", ty.FriendlyName()) - } - default: - value.Statement = fmt.Sprintf("is a %s, known only after apply", ty.FriendlyName()) - } - } else { - value.Statement = fmt.Sprintf("is a %s", ty.FriendlyName()) - } - } else { - if !includeUnknown { - continue Traversals - } - value.Statement = "will be known only after apply" - } - default: - value.Statement = fmt.Sprintf("is %s", tfdiags.CompactValueStr(val)) - } + value.Statement = stmt values = append(values, value) seen[traversalStr] = struct{}{} } @@ -406,69 +332,166 @@ func NewDiagnostic(diag tfdiags.Diagnostic, sources map[string][]byte) *Diagnost diagnostic.DeprecationOriginDescription = deprecationOrigin } - return diagnostic -} + // Extra policy information from the diagnostics + extra := tfdiags.ExtraInfo[*policy.PolicyExtra](diag) + if extra != nil { + if snippet := extra.Snippet; snippet != nil { + target := &DiagnosticSnippet{ + Code: snippet.Code, + StartLine: int(snippet.StartLine), + HighlightStartOffset: int(snippet.HighlightStartOffset), + HighlightEndOffset: int(snippet.HighlightEndOffset), + } -func snippetFromRange(src []byte, highlightRange hcl.Range, snippetRange hcl.Range) *DiagnosticSnippet { - snippet := &DiagnosticSnippet{ - StartLine: snippetRange.Start.Line, + if snippet.Context != nil { + target.Context = &snippet.Context.Context + } - // Ensure that the default Values struct is an empty array, as this - // makes consuming the JSON structure easier in most languages. - Values: []DiagnosticExpressionValue{}, - } + if values := extra.ExpressionValues; values != nil { + target.Values = make([]DiagnosticExpressionValue, 0, len(values)) + seen := make(map[string]struct{}, len(values)) - file, offset := parseRange(src, highlightRange) + for _, val := range values { + path, err := val.Path.ToCtyPath() + if err != nil { + continue // then we can't display this value + } + value := DiagnosticExpressionValue{ + Traversal: pathStr(path), + } - // Some diagnostics may have a useful top-level context to add to - // the code snippet output. - contextStr := hcled.ContextString(file, offset-1) - if contextStr != "" { - snippet.Context = &contextStr - } + if _, exists := seen[value.Traversal]; exists { + continue + } + seen[value.Traversal] = struct{}{} - // Build the string of the code snippet, tracking at which byte of - // the file the snippet starts. - var codeStartByte int - sc := hcl.NewRangeScanner(src, highlightRange.Filename, bufio.ScanLines) - var code strings.Builder - for sc.Scan() { - lineRange := sc.Range() - if lineRange.Overlaps(snippetRange) { - if codeStartByte == 0 && code.Len() == 0 { - codeStartByte = lineRange.Start.Byte + v, err := msgpack.Unmarshal(val.Value, cty.DynamicPseudoType) + if err != nil { + continue + } + + stmt, ok := statementStr(v, false, false, false) + if !ok { + continue + } + value.Statement = stmt + + target.Values = append(target.Values, value) + } + } + + diagnostic.PolicySnippet = target + } + + if rng := extra.Range; rng != nil { + diagnostic.PolicyRange = &DiagnosticRange{ + Filename: rng.Subject.Filename, + Start: Pos{ + Line: int(rng.Subject.Start.Line), + Column: int(rng.Subject.Start.Column), + Byte: int(rng.Subject.Start.Byte), + }, + End: Pos{ + Line: int(rng.Subject.End.Line), + Column: int(rng.Subject.End.Column), + Byte: int(rng.Subject.End.Byte), + }, } - code.Write(lineRange.SliceBytes(src)) - code.WriteRune('\n') } } - codeStr := strings.TrimSuffix(code.String(), "\n") - snippet.Code = codeStr - // Calculate the start and end byte of the highlight range relative - // to the code snippet string. - start := highlightRange.Start.Byte - codeStartByte - end := start + (highlightRange.End.Byte - highlightRange.Start.Byte) + return diagnostic +} - // We can end up with some quirky results here in edge cases like - // when a source range starts or ends at a newline character, - // so we'll cap the results at the bounds of the highlight range - // so that consumers of this data don't need to contend with - // out-of-bounds errors themselves. - if start < 0 { - start = 0 - } else if start > len(codeStr) { - start = len(codeStr) - } - if end < 0 { - end = 0 - } else if end > len(codeStr) { - end = len(codeStr) +func statementStr(val cty.Value, includeUnknown, includeSensitive, includeEphemeral bool) (string, bool) { + // We'll skip any value that has a mark that we don't + // know how to handle, because in that case we can't + // know what that mark is intended to represent and so + // must be conservative. + _, valMarks := val.Unmark() + for mark := range valMarks { + switch mark { + case marks.Sensitive, marks.Ephemeral: + // These are handled below + continue + default: + // All other marks are unhandled, so we'll + // skip this traversal entirely. + return "", false + } } + switch { + case val.HasMark(marks.Sensitive) && val.HasMark(marks.Ephemeral): + // We only mention the combination of sensitive and ephemeral + // values if the diagnostic we're rendering is explicitly + // marked as being caused by sensitive and ephemeral values, + // because otherwise readers tend to be misled into thinking the error + // is caused by the sensitive value even when it isn't. + if !includeSensitive || !includeEphemeral { + return "", false + } - snippet.HighlightStartOffset = start - snippet.HighlightEndOffset = end - return snippet + return "has an ephemeral, sensitive value", true + case val.HasMark(marks.Sensitive): + // We only mention a sensitive value if the diagnostic + // we're rendering is explicitly marked as being + // caused by sensitive values, because otherwise + // readers tend to be misled into thinking the error + // is caused by the sensitive value even when it isn't. + if !includeSensitive { + return "", false + } + // Even when we do mention one, we keep it vague + // in order to minimize the chance of giving away + // whatever was sensitive about it. + return "has a sensitive value", true + case val.HasMark(marks.Ephemeral): + if !includeEphemeral { + return "", false + } + return "has an ephemeral value", true + case !val.IsKnown(): + // We'll avoid saying anything about unknown or + // "known after apply" unless the diagnostic is + // explicitly marked as being caused by unknown + // values, because otherwise readers tend to be + // misled into thinking the error is caused by the + // unknown value even when it isn't. + if ty := val.Type(); ty != cty.DynamicPseudoType { + if includeUnknown { + switch { + case ty.IsCollectionType(): + valRng := val.Range() + minLen := valRng.LengthLowerBound() + maxLen := valRng.LengthUpperBound() + const maxLimit = 1024 // (upper limit is just an arbitrary value to avoid showing distracting large numbers in the UI) + switch { + case minLen == maxLen: + return fmt.Sprintf("is a %s of length %d, known only after apply", ty.FriendlyName(), minLen), true + case minLen != 0 && maxLen <= maxLimit: + return fmt.Sprintf("is a %s with between %d and %d elements, known only after apply", ty.FriendlyName(), minLen, maxLen), true + case minLen != 0: + return fmt.Sprintf("is a %s with at least %d elements, known only after apply", ty.FriendlyName(), minLen), true + case maxLen <= maxLimit: + return fmt.Sprintf("is a %s with up to %d elements, known only after apply", ty.FriendlyName(), maxLen), true + default: + return fmt.Sprintf("is a %s, known only after apply", ty.FriendlyName()), true + } + default: + return fmt.Sprintf("is a %s, known only after apply", ty.FriendlyName()), true + } + } else { + return fmt.Sprintf("is a %s", ty.FriendlyName()), true + } + } else { + if !includeUnknown { + return "", false + } + return "will be known only after apply", true + } + default: + return fmt.Sprintf("is %s", tfdiags.CompactValueStr(val)), true + } } // formatRunBinaryDiag formats the binary expression that caused the failed run diagnostic. @@ -522,3 +545,98 @@ func parseRange(src []byte, rng hcl.Range) (*hcl.File, int) { return file, offset } + +func pathStr(path cty.Path) string { + // This is a specialized subset of traversal rendering tailored to + // producing helpful contextual messages in diagnostics. It is not + // comprehensive nor intended to be used for other purposes. + + var buf bytes.Buffer + first := true + for _, step := range path { + switch tStep := step.(type) { + case cty.GetAttrStep: + if !first { + buf.WriteByte('.') + } + buf.WriteString(tStep.Name) + case cty.IndexStep: + buf.WriteByte('[') + if keyTy := tStep.Key.Type(); keyTy.IsPrimitiveType() { + buf.WriteString(tfdiags.CompactValueStr(tStep.Key)) + } else { + // We'll just use a placeholder for more complex values, + // since otherwise our result could grow ridiculously long. + buf.WriteString("...") + } + buf.WriteByte(']') + } + first = false + } + return buf.String() +} + +// snippetFromRange builds the code snippet from within the highlight. +// highlightRange is the range of the entire highlighted code, while the +// snippetRange is a subset of that. +func snippetFromRange(src []byte, highlightRange, snippetRange hcl.Range) *DiagnosticSnippet { + var codeStartByte int + // Build the string of the code snippet, tracking at which byte of + // the file the snippet starts. + sc := hcl.NewRangeScanner(src, snippetRange.Filename, bufio.ScanLines) + var code strings.Builder + for sc.Scan() { + lineRange := sc.Range() + if lineRange.Overlaps(snippetRange) { + if codeStartByte == 0 && code.Len() == 0 { + codeStartByte = lineRange.Start.Byte + } + code.Write(lineRange.SliceBytes(src)) + code.WriteRune('\n') + } + } + codeStr := strings.TrimSuffix(code.String(), "\n") + + // Calculate the start and end byte of the highlight range relative + // to the code snippet string. + start := highlightRange.Start.Byte - codeStartByte + end := start + (highlightRange.End.Byte - highlightRange.Start.Byte) + + // We can end up with some quirky results here in edge cases like + // when a source range starts or ends at a newline character, + // so we'll cap the results at the bounds of the highlight range + // so that consumers of this data don't need to contend with + // out-of-bounds errors themselves. + if start < 0 { + start = 0 + } else if start > len(codeStr) { + start = len(codeStr) + } + if end < 0 { + end = 0 + } else if end > len(codeStr) { + end = len(codeStr) + } + + snippet := &DiagnosticSnippet{ + StartLine: snippetRange.Start.Line, + Code: codeStr, + + // Ensure that the default Values struct is an empty array, as this + // makes consuming the JSON structure easier in most languages. + Values: []DiagnosticExpressionValue{}, + HighlightStartOffset: start, + HighlightEndOffset: end, + } + + file, offset := parseRange(src, highlightRange) + + // Some diagnostics may have a useful top-level context to add to + // the code snippet output. + contextStr := hcled.ContextString(file, offset-1) + if contextStr != "" { + snippet.Context = &contextStr + } + + return snippet +} diff --git a/internal/command/views/json/message_types.go b/internal/command/views/json/message_types.go index d9a9dc59e117..ceb10f6f981f 100644 --- a/internal/command/views/json/message_types.go +++ b/internal/command/views/json/message_types.go @@ -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" ) diff --git a/internal/command/views/json/policy.go b/internal/command/views/json/policy.go new file mode 100644 index 000000000000..9af18ba4e4e9 --- /dev/null +++ b/internal/command/views/json/policy.go @@ -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 +} diff --git a/internal/command/views/json_view.go b/internal/command/views/json_view.go index c3b88526f015..90dd7b08bcf2 100644 --- a/internal/command/views/json_view.go +++ b/internal/command/views/json_view.go @@ -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...) + } +} diff --git a/internal/command/views/operation.go b/internal/command/views/operation.go index d39e1e357281..7ef362d5a19d 100644 --- a/internal/command/views/operation.go +++ b/internal/command/views/operation.go @@ -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. ` diff --git a/internal/command/views/query_operation.go b/internal/command/views/query_operation.go index 9c9aff4762ad..d2eb1bc33f4f 100644 --- a/internal/command/views/query_operation.go +++ b/internal/command/views/query_operation.go @@ -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) +} diff --git a/internal/command/views/view.go b/internal/command/views/view.go index e52b537dbc48..23c4214bf4f7 100644 --- a/internal/command/views/view.go +++ b/internal/command/views/view.go @@ -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,19 +116,92 @@ 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()) + msg := v.formatDiagnostic(diag) + + if diag.Severity() == tfdiags.Error { + v.streams.Eprint(msg) } else { - msg = format.Diagnostic(diag, v.configSources(), v.colorize, v.streams.Stderr.Columns()) + v.streams.Print(msg) } + } +} +// 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 @@ -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()) + } +}