mirror of
https://github.com/hashicorp/terraform.git
synced 2026-05-28 04:03:27 -04:00
Add CLI and backend policy wiring for plan apply and query
This commit is contained in:
parent
d4ca814cbe
commit
efc3b9e59d
40 changed files with 2010 additions and 66 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ func TestRemoteContextWithVars(t *testing.T) {
|
|||
}
|
||||
b.client.Variables.Create(context.TODO(), workspaceID, *v)
|
||||
|
||||
_, _, diags := b.LocalRun(op)
|
||||
_, _, diags := b.LocalRun(context.Background(), op)
|
||||
|
||||
if test.WantError != "" {
|
||||
if !diags.HasErrors() {
|
||||
|
|
@ -433,7 +433,7 @@ func TestRemoteVariablesDoNotOverride(t *testing.T) {
|
|||
b.client.Variables.Create(context.TODO(), workspaceID, *v)
|
||||
}
|
||||
|
||||
lr, _, diags := b.LocalRun(op)
|
||||
lr, _, diags := b.LocalRun(context.Background(), op)
|
||||
|
||||
if diags.HasErrors() {
|
||||
t.Fatalf("unexpected error\ngot: %s\nwant: <no error>", diags.Err().Error())
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ func TestRemoteContextWithVars(t *testing.T) {
|
|||
}
|
||||
b.client.Variables.Create(context.TODO(), workspaceID, *v)
|
||||
|
||||
_, _, diags := b.LocalRun(op)
|
||||
_, _, diags := b.LocalRun(context.Background(), op)
|
||||
|
||||
if test.WantError != "" {
|
||||
if !diags.HasErrors() {
|
||||
|
|
@ -433,7 +433,7 @@ func TestRemoteVariablesDoNotOverride(t *testing.T) {
|
|||
b.client.Variables.Create(context.TODO(), workspaceID, *v)
|
||||
}
|
||||
|
||||
lr, _, diags := b.LocalRun(op)
|
||||
lr, _, diags := b.LocalRun(context.Background(), op)
|
||||
|
||||
if diags.HasErrors() {
|
||||
t.Fatalf("unexpected error\ngot: %s\nwant: <no error>", diags.Err().Error())
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1309,6 +1309,7 @@ func (m *MockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*t
|
|||
Status: tfe.RunPending,
|
||||
TargetAddrs: options.TargetAddrs,
|
||||
AllowConfigGeneration: options.AllowConfigGeneration,
|
||||
PolicyPaths: options.PolicyPaths,
|
||||
}
|
||||
|
||||
if options.Message != nil {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
310
internal/command/apply_policy_test.go
Normal file
310
internal/command/apply_policy_test.go
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
// Copyright IBM Corp. 2014, 2026
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/hashicorp/terraform/internal/policy"
|
||||
"github.com/hashicorp/terraform/internal/policy/proto"
|
||||
"github.com/hashicorp/terraform/internal/providers"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
)
|
||||
|
||||
// This tests that the apply policy diagnostics are reported.
|
||||
func TestApply_WithPolicyDiagnosticsJSON(t *testing.T) {
|
||||
|
||||
td := t.TempDir()
|
||||
testCopyDir(t, testFixturePath("plan"), td)
|
||||
t.Chdir(td)
|
||||
policyCode := ` resource_policy "resource_type" "policy_name" {
|
||||
enforce_attrs {
|
||||
key = attr.value == "foo"
|
||||
}
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile("policy.hcl", []byte(policyCode), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := planFixtureProvider()
|
||||
view, done := testView(t)
|
||||
overrides := metaOverridesForProvider(p)
|
||||
policyClient := policy.NewTestMockClient(t)
|
||||
overrides.PolicyClient = policyClient
|
||||
c := &ApplyCommand{
|
||||
Meta: Meta{
|
||||
testingOverrides: overrides,
|
||||
View: view,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
resp := policy.EvaluationFromProtoResponse(
|
||||
proto.EvaluateResult_DENY_EVALUATE_RESULT, []*proto.PolicyEvaluationDetail{
|
||||
{
|
||||
Address: "resource_policy.foo",
|
||||
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
|
||||
File: "policy_file.tfpolicy.hcl",
|
||||
PolicySetEnforcement: "mandatory",
|
||||
DefRange: &proto.Range{
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
Start: &proto.Position{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: &proto.Position{
|
||||
Line: 2,
|
||||
Column: 4,
|
||||
},
|
||||
},
|
||||
EnforceResults: []*proto.EnforceBlockResult{
|
||||
{
|
||||
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
|
||||
BlockIndex: 1,
|
||||
Diagnostics: []*proto.Diagnostic{
|
||||
{
|
||||
Severity: proto.Severity_ERROR,
|
||||
Summary: "policy denied",
|
||||
Result: &proto.DiagnosticResult{
|
||||
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
|
||||
},
|
||||
Subject: &proto.Range{
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
Start: &proto.Position{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: &proto.Position{
|
||||
Line: 2,
|
||||
Column: 4,
|
||||
},
|
||||
},
|
||||
Context: &proto.Range{
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
Start: &proto.Position{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: &proto.Position{
|
||||
Line: 4,
|
||||
Column: 10,
|
||||
},
|
||||
},
|
||||
Snippet: &proto.Snippet{
|
||||
Code: policyCode,
|
||||
StartLine: 1,
|
||||
HighlightStartOffset: 1,
|
||||
HighlightEndOffset: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
policyClient.EvaluateResponse = &resp
|
||||
|
||||
// implicit allow, in a case where the evaluated provider matched no policy in the engine
|
||||
policyClient.EvaluateProviderResponse = &policy.EvaluationResponse{
|
||||
Overall: policy.AllowResult,
|
||||
Policies: []*policy.Policy{{
|
||||
Result: policy.AllowResult,
|
||||
PolicySetName: "some_policy_set",
|
||||
Address: "policy_name",
|
||||
Directory: "some/path/to",
|
||||
Filename: "provider_policy_file.tfpolicy.hcl",
|
||||
EnforcementLevel: "mandatory",
|
||||
Range: &hcl.Range{
|
||||
Filename: "provider_policy_file.tfpolicy.hcl",
|
||||
Start: hcl.Pos{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: hcl.Pos{
|
||||
Line: 5,
|
||||
Column: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
args := []string{"-policies", td}
|
||||
code := c.Run(append(args, "-no-color", "-json", "-auto-approve"))
|
||||
output := done(t)
|
||||
if code != 0 {
|
||||
t.Fatalf("bad: %d\n\n%s", code, output.Stdout())
|
||||
}
|
||||
|
||||
expected := `{"@level":"info","@message":"Terraform 1.15.0-dev","@module":"terraform.ui","terraform":"1.15.0-dev","type":"version","ui":"1.2"}
|
||||
{"@level":"info","@message":"data.test_data_source.a: Refreshing...","@module":"terraform.ui","hook":{"resource":{"addr":"data.test_data_source.a","module":"","resource":"data.test_data_source.a","implied_provider":"test","resource_type":"test_data_source","resource_name":"a","resource_key":null},"action":"read"},"type":"apply_start"}
|
||||
{"@level":"info","@message":"data.test_data_source.a: Refresh complete after 0s [id=zzzzz]","@module":"terraform.ui","hook":{"resource":{"addr":"data.test_data_source.a","module":"","resource":"data.test_data_source.a","implied_provider":"test","resource_type":"test_data_source","resource_name":"a","resource_key":null},"action":"read","id_key":"id","id_value":"zzzzz","elapsed_seconds":0},"type":"apply_complete"}
|
||||
{"@level":"info","@message":"test_instance.foo: Plan to create","@module":"terraform.ui","change":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create"},"type":"planned_change"}
|
||||
{"@level":"info","@message":"Plan: 1 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","changes":{"add":1,"change":0,"import":0,"remove":0,"action_invocation":0,"operation":"plan"},"type":"change_summary"}
|
||||
{"@level":"info","@message":"test_instance.foo: Creating...","@module":"terraform.ui","hook":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create"},"type":"apply_start"}
|
||||
{"@level":"info","@message":"test_instance.foo: Creation complete after 0s","@module":"terraform.ui","hook":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create","elapsed_seconds":0},"type":"apply_complete"}
|
||||
{"@level":"error","@message":"Error: policy denied","@module":"terraform.ui","@policy":"true","target_address":"test_instance.foo","policy_diagnostic":{"severity":"error","summary":"policy denied","detail":"","range":{"filename":"main.tf","start":{"line":1,"column":1,"byte":0},"end":{"line":1,"column":31,"byte":30}},"snippet":{"context":null,"code":"resource \"test_instance\" \"foo\" {","start_line":1,"highlight_start_offset":0,"highlight_end_offset":30,"values":[]},"policy_range":{"filename":"policy_file.tfpolicy.hcl","start":{"line":1,"column":1,"byte":0},"end":{"line":2,"column":4,"byte":0}},"policy_snippet":{"context":null,"code":"\t\tresource_policy \"resource_type\" \"policy_name\" {\n\t\t enforce_attrs {\n\t\t key = attr.value == \"foo\"\n\t\t }\n\t\t}\n\t","start_line":1,"highlight_start_offset":1,"highlight_end_offset":100,"values":null}},"policy_metadata":{"enforce_index":1,"policy_set_path":"policy_file.tfpolicy.hcl","policy_name":"resource_policy.foo","file_name":"policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"result":"DenyResult","type":"policy_diagnostic"}
|
||||
{"@level":"info","@message":"Policy Result","@module":"terraform.ui","@policy":"true","policy_address":"resource_policy.foo","policy_metadata":{"policy_set_path":"policy_file.tfpolicy.hcl","policy_name":"resource_policy.foo","file_name":"policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"result":"DenyResult","target_address":"test_instance.foo","type":"policy_result"}
|
||||
{"@level":"info","@message":"Apply complete! Resources: 1 added, 0 changed, 0 destroyed.","@module":"terraform.ui","changes":{"add":1,"change":0,"import":0,"remove":0,"action_invocation":0,"operation":"apply"},"type":"change_summary"}
|
||||
{"@level":"info","@message":"Outputs: 0","@module":"terraform.ui","outputs":{},"type":"outputs"}`
|
||||
checkGoldenReferenceStr(t, output, expected)
|
||||
}
|
||||
|
||||
// This tests that the plan policy diagnostic is superceded by the apply policy evaluation.
|
||||
func TestApply_WithPlanPolicyDiagnosticsJSON(t *testing.T) {
|
||||
|
||||
td := t.TempDir()
|
||||
testCopyDir(t, testFixturePath("plan"), td)
|
||||
t.Chdir(td)
|
||||
policyCode := ` resource_policy "resource_type" "policy_name" {
|
||||
enforce_attrs {
|
||||
key = attr.value == "foo"
|
||||
}
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile("policy.hcl", []byte(policyCode), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := planFixtureProvider()
|
||||
view, done := testView(t)
|
||||
overrides := metaOverridesForProvider(p)
|
||||
policyClient := policy.NewTestMockClient(t)
|
||||
overrides.PolicyClient = policyClient
|
||||
c := &ApplyCommand{
|
||||
Meta: Meta{
|
||||
testingOverrides: overrides,
|
||||
View: view,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
p.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) (resp providers.PlanResourceChangeResponse) {
|
||||
s := req.ProposedNewState.AsValueMap()
|
||||
s["id"] = cty.UnknownVal(cty.String)
|
||||
resp.PlannedState = cty.ObjectVal(s)
|
||||
return
|
||||
}
|
||||
evalRespFn := func(result proto.EvaluateResult) policy.EvaluationResponse {
|
||||
detail := &proto.PolicyEvaluationDetail{
|
||||
Address: "resource_policy.foo",
|
||||
Result: result,
|
||||
File: "policy_file.tfpolicy.hcl",
|
||||
PolicySetEnforcement: "mandatory",
|
||||
DefRange: &proto.Range{
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
Start: &proto.Position{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: &proto.Position{
|
||||
Line: 2,
|
||||
Column: 4,
|
||||
},
|
||||
},
|
||||
EnforceResults: []*proto.EnforceBlockResult{{
|
||||
Result: result,
|
||||
}},
|
||||
}
|
||||
if result == proto.EvaluateResult_DENY_EVALUATE_RESULT {
|
||||
detail.EnforceResults[0].Diagnostics = []*proto.Diagnostic{
|
||||
{
|
||||
Severity: proto.Severity_ERROR,
|
||||
Summary: "policy denied",
|
||||
Result: &proto.DiagnosticResult{
|
||||
Result: result,
|
||||
},
|
||||
Subject: &proto.Range{
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
Start: &proto.Position{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: &proto.Position{
|
||||
Line: 2,
|
||||
Column: 4,
|
||||
},
|
||||
},
|
||||
Context: &proto.Range{
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
Start: &proto.Position{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: &proto.Position{
|
||||
Line: 4,
|
||||
Column: 10,
|
||||
},
|
||||
},
|
||||
Snippet: &proto.Snippet{
|
||||
Code: policyCode,
|
||||
StartLine: 1,
|
||||
HighlightStartOffset: 1,
|
||||
HighlightEndOffset: 100,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return policy.EvaluationFromProtoResponse(result, []*proto.PolicyEvaluationDetail{detail})
|
||||
}
|
||||
|
||||
policyClient.EvaluateFn = func(ctx context.Context, er policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse {
|
||||
// This is what is returned during the post-plan policy evaluation
|
||||
if !er.Attrs.GetAttr("id").IsWhollyKnown() {
|
||||
return evalRespFn(proto.EvaluateResult_DENY_EVALUATE_RESULT)
|
||||
}
|
||||
|
||||
// This is for the post-apply policy evaluation
|
||||
return evalRespFn(proto.EvaluateResult_ALLOW_EVALUATE_RESULT)
|
||||
}
|
||||
|
||||
// implicit allow, in a case where the evaluated provider matched no policy in the engine
|
||||
policyClient.EvaluateProviderResponse = &policy.EvaluationResponse{
|
||||
Overall: policy.AllowResult,
|
||||
Policies: []*policy.Policy{{
|
||||
Result: policy.AllowResult,
|
||||
PolicySetName: "some_policy_set",
|
||||
Address: "policy_name",
|
||||
Directory: "some/path/to",
|
||||
Filename: "provider_policy_file.tfpolicy.hcl",
|
||||
EnforcementLevel: "mandatory",
|
||||
Range: &hcl.Range{
|
||||
Filename: "provider_policy_file.tfpolicy.hcl",
|
||||
Start: hcl.Pos{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: hcl.Pos{
|
||||
Line: 5,
|
||||
Column: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
args := []string{"-policies", td}
|
||||
code := c.Run(append(args, "-no-color", "-json", "-auto-approve"))
|
||||
output := done(t)
|
||||
if code != 0 {
|
||||
t.Fatalf("bad: %d\n\n%s", code, output.Stdout())
|
||||
}
|
||||
|
||||
// The resulting json only contains the policy result, because the object that
|
||||
// had a failed policy evaluation during the plan succeeded during apply.
|
||||
// This can occur when more references become known.
|
||||
expected := `{"@level":"info","@message":"Terraform 1.15.0-dev","@module":"terraform.ui","terraform":"1.15.0-dev","type":"version","ui":"1.2"}
|
||||
{"@level":"info","@message":"data.test_data_source.a: Refreshing...","@module":"terraform.ui","hook":{"resource":{"addr":"data.test_data_source.a","module":"","resource":"data.test_data_source.a","implied_provider":"test","resource_type":"test_data_source","resource_name":"a","resource_key":null},"action":"read"},"type":"apply_start"}
|
||||
{"@level":"info","@message":"data.test_data_source.a: Refresh complete after 0s [id=zzzzz]","@module":"terraform.ui","hook":{"resource":{"addr":"data.test_data_source.a","module":"","resource":"data.test_data_source.a","implied_provider":"test","resource_type":"test_data_source","resource_name":"a","resource_key":null},"action":"read","id_key":"id","id_value":"zzzzz","elapsed_seconds":0},"type":"apply_complete"}
|
||||
{"@level":"info","@message":"test_instance.foo: Plan to create","@module":"terraform.ui","change":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create"},"type":"planned_change"}
|
||||
{"@level":"info","@message":"Plan: 1 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","changes":{"add":1,"change":0,"import":0,"remove":0,"action_invocation":0,"operation":"plan"},"type":"change_summary"}
|
||||
{"@level":"info","@message":"test_instance.foo: Creating...","@module":"terraform.ui","hook":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create"},"type":"apply_start"}
|
||||
{"@level":"info","@message":"test_instance.foo: Creation complete after 0s","@module":"terraform.ui","hook":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create","elapsed_seconds":0,"id_key":"id"},"type":"apply_complete"}
|
||||
{"@level":"info","@message":"Policy Result","@module":"terraform.ui","@policy":"true","policy_address":"resource_policy.foo","policy_metadata":{"policy_set_path":"policy_file.tfpolicy.hcl","policy_name":"resource_policy.foo","file_name":"policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"result":"AllowResult","target_address":"test_instance.foo","type":"policy_result"}
|
||||
{"@level":"info","@message":"Apply complete! Resources: 1 added, 0 changed, 0 destroyed.","@module":"terraform.ui","changes":{"add":1,"change":0,"import":0,"remove":0,"action_invocation":0,"operation":"apply"},"type":"change_summary"}
|
||||
{"@level":"info","@message":"Outputs: 0","@module":"terraform.ui","outputs":{},"type":"outputs"}`
|
||||
checkGoldenReferenceStr(t, output, expected)
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@ func (m *Meta) loadHCLFile(filename string) (hcl.Body, tfdiags.Diagnostics) {
|
|||
// can then be relayed to the end-user. The uiModuleInstallHooks type in
|
||||
// this package has a reasonable implementation for displaying notifications
|
||||
// via a provided cli.Ui.
|
||||
func (m *Meta) installModules(ctx context.Context, rootDir, testsDir string, upgrade, installErrsOnly bool, hooks initwd.ModuleInstallHooks) (abort bool, diags tfdiags.Diagnostics) {
|
||||
func (m *Meta) installModules(ctx context.Context, rootDir, testsDir string, upgrade, installErrsOnly bool, hooks ...initwd.ModuleInstallHook) (abort bool, diags tfdiags.Diagnostics) {
|
||||
ctx, span := tracer.Start(ctx, "install modules")
|
||||
defer span.End()
|
||||
|
||||
|
|
@ -311,9 +311,9 @@ func (m *Meta) installModules(ctx context.Context, rootDir, testsDir string, upg
|
|||
SetVariables: variables,
|
||||
})
|
||||
}
|
||||
inst := initwd.NewModuleInstaller(m.modulesDir(), loader, m.registryClient(), initializer)
|
||||
inst := initwd.NewModuleInstaller(m.modulesDir(), loader, m.registryClient(), initializer, hooks...)
|
||||
|
||||
_, moreDiags := inst.InstallModules(ctx, rootDir, testsDir, upgrade, installErrsOnly, hooks)
|
||||
_, moreDiags := inst.InstallModules(ctx, rootDir, testsDir, upgrade, installErrsOnly)
|
||||
diags = diags.Append(moreDiags)
|
||||
|
||||
if ctx.Err() == context.Canceled {
|
||||
|
|
@ -334,7 +334,7 @@ func (m *Meta) installModules(ctx context.Context, rootDir, testsDir string, upg
|
|||
// can then be relayed to the end-user. The uiModuleInstallHooks type in
|
||||
// this package has a reasonable implementation for displaying notifications
|
||||
// via a provided cli.Ui.
|
||||
func (m *Meta) initDirFromModule(ctx context.Context, targetDir string, addr string, hooks initwd.ModuleInstallHooks) (abort bool, diags tfdiags.Diagnostics) {
|
||||
func (m *Meta) initDirFromModule(ctx context.Context, targetDir string, addr string, hooks initwd.ModuleInstallHook) (abort bool, diags tfdiags.Diagnostics) {
|
||||
ctx, span := tracer.Start(ctx, "initialize directory from module", trace.WithAttributes(
|
||||
attribute.String("source_addr", addr),
|
||||
))
|
||||
|
|
|
|||
235
internal/command/meta_policy.go
Normal file
235
internal/command/meta_policy.go
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
// Copyright IBM Corp. 2014, 2026
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"github.com/apparentlymart/go-versions/versions"
|
||||
"github.com/apparentlymart/go-versions/versions/constraints"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/addrs"
|
||||
"github.com/hashicorp/terraform/internal/configs"
|
||||
"github.com/hashicorp/terraform/internal/initwd"
|
||||
"github.com/hashicorp/terraform/internal/plans"
|
||||
"github.com/hashicorp/terraform/internal/policy"
|
||||
"github.com/hashicorp/terraform/internal/policy/proto"
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
"github.com/hashicorp/terraform/version"
|
||||
)
|
||||
|
||||
func (c *Meta) PolicyClient(ctx context.Context, policyPaths []string) (policy.Client, policy.Diagnostics) {
|
||||
var client policy.Client
|
||||
// Policies are currently only supported in alpha versions.
|
||||
// TODO: Uncomment in the public release
|
||||
// if !c.AllowExperimentalFeatures {
|
||||
// log.Printf("[DEBUG] Policies are not supported, skipping policy client setup")
|
||||
// return client, nil
|
||||
// }
|
||||
if len(policyPaths) == 0 {
|
||||
log.Printf("[DEBUG] No policy paths configured, skipping policy client setup")
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// Use a pre-initialized client for tests if one is available
|
||||
if c.testingOverrides != nil {
|
||||
if client := c.testingOverrides.PolicyClient; client != nil {
|
||||
return client, nil
|
||||
}
|
||||
}
|
||||
|
||||
var diags policy.Diagnostics
|
||||
client, err := policy.Connect(ctx)
|
||||
if client == nil {
|
||||
diags = append(diags, policy.NewErrorDiagnostic(
|
||||
"Failed to connect to policy engine",
|
||||
fmt.Sprintf("Failed to connect to policy engine: %s.", err),
|
||||
policy.SetupErrorResult,
|
||||
))
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
var callbackServiceID uint32
|
||||
|
||||
// initialize the callback service if the client supports it
|
||||
if srv, ok := client.(policy.CallbackService); ok {
|
||||
callbackServer, cbDiags := srv.RegisterCallbackService(ctx)
|
||||
if cbDiags != nil {
|
||||
return nil, cbDiags
|
||||
}
|
||||
callbackServiceID = callbackServer.ID
|
||||
}
|
||||
|
||||
resp := client.Setup(ctx, policy.SetupRequest{
|
||||
SourceLocations: policyPaths,
|
||||
CallbackService: callbackServiceID,
|
||||
})
|
||||
diags = append(diags, resp.Diagnostics...)
|
||||
|
||||
var requiredVersions constraints.IntersectionSpec
|
||||
for _, config := range resp.ServerConfigurations() {
|
||||
version, err := constraints.ParseRubyStyleMulti(config.RequiredVersion)
|
||||
if err != nil {
|
||||
diags = append(diags, policy.NewErrorDiagnostic(
|
||||
"Failed to validate required Terraform version",
|
||||
fmt.Sprintf("The policy file %s had a Terraform version constraint that could not be parsed: %s.", config.File, err),
|
||||
policy.SetupErrorResult,
|
||||
))
|
||||
continue
|
||||
}
|
||||
|
||||
requiredVersions = append(requiredVersions, version...)
|
||||
}
|
||||
|
||||
if len(diags) > 0 {
|
||||
client.Stop()
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
terraformVersion, err := versions.ParseVersion(version.Version)
|
||||
if err != nil {
|
||||
client.Stop()
|
||||
// This is crazy, it means the internal version number is invalid.
|
||||
panic(err)
|
||||
}
|
||||
|
||||
constraint := versions.MeetingConstraints(requiredVersions)
|
||||
if !constraint.Has(terraformVersion) {
|
||||
diags = append(diags, policy.NewErrorDiagnostic(
|
||||
"Invalid Terraform version for policies",
|
||||
fmt.Sprintf("The current version of Terraform is %s, and it is not compatible with the versions of Terraform required by the selected policies.", version.String()),
|
||||
policy.SetupErrorResult,
|
||||
))
|
||||
client.Stop()
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
log.Printf("[INFO] backend/operation/policy: Policy engine initialized")
|
||||
return client, diags
|
||||
}
|
||||
|
||||
// policyModuleInstallHook implements initwd.ModuleInstallHook and
|
||||
// enables policy evaluation during module installation.
|
||||
type policyModuleInstallHook struct {
|
||||
initwd.ModuleInstallHookImpl
|
||||
client policy.Client
|
||||
rootModule *configs.Module
|
||||
policyResults *plans.PolicyResults
|
||||
}
|
||||
|
||||
func (h *policyModuleInstallHook) EvaluatePolicy(ctx context.Context, req *configs.ModuleRequest, source, version string) tfdiags.Diagnostics {
|
||||
moduleAddr := req.Path.String()
|
||||
moduleCall := h.rootModule.ModuleCalls[req.Name]
|
||||
result := h.client.EvaluateModule(ctx, policy.EvaluationRequest[*proto.ModuleMetadata]{
|
||||
Attrs: cty.NilVal,
|
||||
Target: source,
|
||||
Meta: &proto.ModuleMetadata{
|
||||
Address: moduleAddr,
|
||||
Source: source,
|
||||
Version: version,
|
||||
},
|
||||
})
|
||||
|
||||
if moduleCall != nil && moduleCall.Config != nil {
|
||||
ptr := moduleCall.DeclRange.Ptr()
|
||||
for idx, diag := range result.Diagnostics {
|
||||
result.Diagnostics[idx] = diag.WithLocalRange(ptr)
|
||||
}
|
||||
for idx := range result.Enforcements {
|
||||
result.Enforcements[idx].LocalRange = ptr
|
||||
}
|
||||
}
|
||||
h.policyResults.AddModule(req.Path, result, moduleCall)
|
||||
|
||||
// return a generic error here that the init command returns to the CLI.
|
||||
// The detailed policy diagnostics are included in the policy results
|
||||
// and will be formatted in the CLI output.
|
||||
if len(result.Diagnostics) > 0 && result.Diagnostics.AsTerraformDiags().HasErrors() {
|
||||
return tfdiags.Diagnostics{
|
||||
policy.NewErrorDiagnostic(
|
||||
"Policy evaluation failed",
|
||||
"Module download blocked due to policy violations. Please review other diagnostics for details.",
|
||||
policy.SetupErrorResult,
|
||||
),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type providerInstallerHook struct {
|
||||
Reqs *configs.ModuleRequirements
|
||||
Client policy.Client
|
||||
moduleMap map[addrs.Provider]string
|
||||
policyResults *plans.PolicyResults
|
||||
config *configs.Config
|
||||
}
|
||||
|
||||
func (p *providerInstallerHook) moduleSources() map[addrs.Provider]string {
|
||||
if p.moduleMap != nil {
|
||||
return p.moduleMap
|
||||
}
|
||||
// We iterate through the module requirements to build a map of providers
|
||||
// to their module source addresses. The first module requirement we encounter
|
||||
// for each provider will be recorded as the provider's module.
|
||||
// This matches how Terraform adds providers to the graph.
|
||||
p.moduleMap = map[addrs.Provider]string{}
|
||||
moduleReqs := []*configs.ModuleRequirements{p.Reqs}
|
||||
for len(moduleReqs) != 0 {
|
||||
moduleReq := moduleReqs[0]
|
||||
for reqProvider := range moduleReq.Requirements {
|
||||
if _, ok := p.moduleMap[reqProvider]; ok {
|
||||
// if we already have a module for this provider, skip
|
||||
continue
|
||||
}
|
||||
|
||||
// The source is nil in the root module, so we use the root module address.
|
||||
if moduleReq.SourceAddr == nil {
|
||||
p.moduleMap[reqProvider] = addrs.RootModule.String()
|
||||
} else {
|
||||
p.moduleMap[reqProvider] = moduleReq.SourceAddr.String()
|
||||
}
|
||||
}
|
||||
|
||||
newReqs := slices.Collect(maps.Values(moduleReq.Children))
|
||||
moduleReqs = append(moduleReqs[1:], newReqs...)
|
||||
}
|
||||
return p.moduleMap
|
||||
}
|
||||
|
||||
func (p *providerInstallerHook) EvaluatePolicy(ctx context.Context, provider addrs.Provider, version string) policy.EvaluationResponse {
|
||||
// If the client is nil, then policy evaluation is disabled, so we can skip.
|
||||
if p.Client == nil {
|
||||
return policy.EvaluationResponse{}
|
||||
}
|
||||
moduleSources := p.moduleSources()
|
||||
log.Println("[DEBUG] init: evaluating policy for provider", provider.String(), version)
|
||||
result := p.Client.EvaluateProvider(ctx, policy.EvaluationRequest[*proto.ProviderMetadata]{
|
||||
Target: provider.Type,
|
||||
|
||||
// Configuration attributes may not be available during init, so we will not
|
||||
// send any attributes to the policy client.
|
||||
Attrs: cty.NilVal,
|
||||
Meta: &proto.ProviderMetadata{
|
||||
Name: provider.Type,
|
||||
Namespace: provider.Namespace,
|
||||
Type: provider.Type,
|
||||
Source: provider.String(),
|
||||
ModulePath: moduleSources[provider],
|
||||
Version: version,
|
||||
},
|
||||
})
|
||||
// We use the root module as the module for provider configs since the version resolution
|
||||
// is ambiguous, and we do not know which module the provider config belongs to.
|
||||
addr := addrs.AbsProviderConfig{Provider: provider, Module: addrs.RootModule}
|
||||
providerConfig := p.config.Module.ProviderConfigs[provider.Type]
|
||||
|
||||
p.policyResults.AddProvider(addr, result, providerConfig)
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
909
internal/command/plan_policy_test.go
Normal file
909
internal/command/plan_policy_test.go
Normal file
|
|
@ -0,0 +1,909 @@
|
|||
// Copyright IBM Corp. 2014, 2026
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/hashicorp/terraform/internal/policy"
|
||||
"github.com/hashicorp/terraform/internal/policy/proto"
|
||||
)
|
||||
|
||||
// Tests the output of a plan that includes a policy evaluation
|
||||
func TestPlan_WithPolicy(t *testing.T) {
|
||||
|
||||
td := t.TempDir()
|
||||
testCopyDir(t, testFixturePath("plan"), td)
|
||||
t.Chdir(td)
|
||||
policyCode := ` resource_policy "resource_type" "policy_name" {
|
||||
enforce_attrs {
|
||||
key = attr.value == "foo"
|
||||
}
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile("policy.hcl", []byte(policyCode), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := planFixtureProvider()
|
||||
view, done := testView(t)
|
||||
overrides := metaOverridesForProvider(p)
|
||||
policyClient := policy.NewTestMockClient(t)
|
||||
overrides.PolicyClient = policyClient
|
||||
c := &PlanCommand{
|
||||
Meta: Meta{
|
||||
testingOverrides: overrides,
|
||||
View: view,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
resp := policy.EvaluationFromProtoResponse(
|
||||
proto.EvaluateResult_DENY_EVALUATE_RESULT, []*proto.PolicyEvaluationDetail{
|
||||
{
|
||||
Address: "resource_policy.foo",
|
||||
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
|
||||
File: "policy_file.tfpolicy.hcl",
|
||||
DefRange: &proto.Range{
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
Start: &proto.Position{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: &proto.Position{
|
||||
Line: 2,
|
||||
Column: 4,
|
||||
},
|
||||
},
|
||||
EnforceResults: []*proto.EnforceBlockResult{
|
||||
{
|
||||
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
|
||||
Diagnostics: []*proto.Diagnostic{
|
||||
{
|
||||
Severity: proto.Severity_ERROR,
|
||||
Summary: "policy denied",
|
||||
Result: &proto.DiagnosticResult{
|
||||
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
|
||||
},
|
||||
Subject: &proto.Range{
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
Start: &proto.Position{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: &proto.Position{
|
||||
Line: 2,
|
||||
Column: 4,
|
||||
},
|
||||
},
|
||||
Context: &proto.Range{
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
Start: &proto.Position{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: &proto.Position{
|
||||
Line: 4,
|
||||
Column: 10,
|
||||
},
|
||||
},
|
||||
Snippet: &proto.Snippet{
|
||||
Code: policyCode,
|
||||
StartLine: 1,
|
||||
HighlightStartOffset: 1,
|
||||
HighlightEndOffset: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
policyClient.EvaluateResponse = &resp
|
||||
|
||||
args := []string{"-policies", td}
|
||||
code := c.Run(append(args, "-no-color"))
|
||||
output := done(t)
|
||||
if code != 0 {
|
||||
t.Fatalf("bad: %d\n\n%s", code, output.Stderr())
|
||||
}
|
||||
|
||||
expected := `
|
||||
Error: policy denied
|
||||
|
||||
on policy_file.tfpolicy.hcl line 1:
|
||||
1: resource_policy "resource_type" "policy_name" {
|
||||
2: enforce_attrs {
|
||||
3: key = attr.value == "foo"
|
||||
4: }
|
||||
5: }
|
||||
6:
|
||||
|
||||
while evaluating policy for main.tf line 1:
|
||||
1: resource "test_instance" "foo" {
|
||||
|
||||
`
|
||||
|
||||
if diff := cmp.Diff(expected, output.Stderr()); diff != "" {
|
||||
t.Fatalf("unexpected output:\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlan_WithPolicyDiagnosticsJSON(t *testing.T) {
|
||||
|
||||
td := t.TempDir()
|
||||
testCopyDir(t, testFixturePath("plan"), td)
|
||||
t.Chdir(td)
|
||||
policyCode := ` resource_policy "resource_type" "policy_name" {
|
||||
enforce_attrs {
|
||||
key = attr.value == "foo"
|
||||
}
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile("policy.hcl", []byte(policyCode), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := planFixtureProvider()
|
||||
view, done := testView(t)
|
||||
overrides := metaOverridesForProvider(p)
|
||||
policyClient := policy.NewTestMockClient(t)
|
||||
overrides.PolicyClient = policyClient
|
||||
c := &PlanCommand{
|
||||
Meta: Meta{
|
||||
testingOverrides: overrides,
|
||||
View: view,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
resp := policy.EvaluationFromProtoResponse(
|
||||
proto.EvaluateResult_DENY_EVALUATE_RESULT, []*proto.PolicyEvaluationDetail{
|
||||
{
|
||||
Address: "resource_policy.foo",
|
||||
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
|
||||
File: "policy_file.tfpolicy.hcl",
|
||||
PolicySetEnforcement: "mandatory",
|
||||
DefRange: &proto.Range{
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
Start: &proto.Position{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: &proto.Position{
|
||||
Line: 2,
|
||||
Column: 4,
|
||||
},
|
||||
},
|
||||
EnforceResults: []*proto.EnforceBlockResult{
|
||||
{
|
||||
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
|
||||
BlockIndex: 1,
|
||||
Diagnostics: []*proto.Diagnostic{
|
||||
{
|
||||
Severity: proto.Severity_ERROR,
|
||||
Summary: "policy denied",
|
||||
Result: &proto.DiagnosticResult{
|
||||
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
|
||||
},
|
||||
Subject: &proto.Range{
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
Start: &proto.Position{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: &proto.Position{
|
||||
Line: 2,
|
||||
Column: 4,
|
||||
},
|
||||
},
|
||||
Context: &proto.Range{
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
Start: &proto.Position{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: &proto.Position{
|
||||
Line: 4,
|
||||
Column: 10,
|
||||
},
|
||||
},
|
||||
Snippet: &proto.Snippet{
|
||||
Code: policyCode,
|
||||
StartLine: 1,
|
||||
HighlightStartOffset: 1,
|
||||
HighlightEndOffset: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Address: "resource_policy.bar",
|
||||
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
|
||||
File: "policy_file.tfpolicy.hcl",
|
||||
PolicySetEnforcement: "mandatory",
|
||||
DefRange: &proto.Range{
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
Start: &proto.Position{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: &proto.Position{
|
||||
Line: 2,
|
||||
Column: 4,
|
||||
},
|
||||
},
|
||||
EnforceResults: []*proto.EnforceBlockResult{
|
||||
{
|
||||
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
|
||||
BlockIndex: 2,
|
||||
Diagnostics: []*proto.Diagnostic{
|
||||
{
|
||||
Severity: proto.Severity_ERROR,
|
||||
Summary: "policy failed for some other reason",
|
||||
Result: &proto.DiagnosticResult{
|
||||
Result: proto.EvaluateResult_DENY_EVALUATE_RESULT,
|
||||
},
|
||||
Subject: &proto.Range{
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
Start: &proto.Position{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: &proto.Position{
|
||||
Line: 2,
|
||||
Column: 4,
|
||||
},
|
||||
},
|
||||
Context: &proto.Range{
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
Start: &proto.Position{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: &proto.Position{
|
||||
Line: 4,
|
||||
Column: 10,
|
||||
},
|
||||
},
|
||||
Snippet: &proto.Snippet{
|
||||
Code: policyCode,
|
||||
StartLine: 1,
|
||||
HighlightStartOffset: 1,
|
||||
HighlightEndOffset: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
policyClient.EvaluateResponse = &resp
|
||||
|
||||
// implicit allow, in a case where the evaluated provider matched no policy in the engine
|
||||
policyClient.EvaluateProviderResponse = &policy.EvaluationResponse{
|
||||
Overall: policy.AllowResult,
|
||||
Policies: []*policy.Policy{{
|
||||
Result: policy.AllowResult,
|
||||
PolicySetName: "some_policy_set",
|
||||
Address: "policy_name",
|
||||
Directory: "some/path/to",
|
||||
Filename: "provider_policy_file.tfpolicy.hcl",
|
||||
EnforcementLevel: "mandatory",
|
||||
Range: &hcl.Range{
|
||||
Filename: "provider_policy_file.tfpolicy.hcl",
|
||||
Start: hcl.Pos{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: hcl.Pos{
|
||||
Line: 5,
|
||||
Column: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
args := []string{"-policies", td}
|
||||
code := c.Run(append(args, "-no-color", "-json"))
|
||||
output := done(t)
|
||||
if code != 0 {
|
||||
t.Fatalf("bad: %d\n\n%s", code, output.Stderr())
|
||||
}
|
||||
|
||||
expected := `{"@level":"info","@message":"Terraform 1.15.0-dev","@module":"terraform.ui","terraform":"1.15.0-dev","type":"version","ui":"1.2"}
|
||||
{"@level":"info","@message":"data.test_data_source.a: Refreshing...","@module":"terraform.ui","hook":{"resource":{"addr":"data.test_data_source.a","module":"","resource":"data.test_data_source.a","implied_provider":"test","resource_type":"test_data_source","resource_name":"a","resource_key":null},"action":"read"},"type":"apply_start"}
|
||||
{"@level":"info","@message":"data.test_data_source.a: Refresh complete after 0s [id=zzzzz]","@module":"terraform.ui","hook":{"resource":{"addr":"data.test_data_source.a","module":"","resource":"data.test_data_source.a","implied_provider":"test","resource_type":"test_data_source","resource_name":"a","resource_key":null},"action":"read","id_key":"id","id_value":"zzzzz","elapsed_seconds":0},"type":"apply_complete"}
|
||||
{"@level":"info","@message":"test_instance.foo: Plan to create","@module":"terraform.ui","change":{"resource":{"addr":"test_instance.foo","implied_provider":"test","module":"","resource":"test_instance.foo","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create"},"type":"planned_change"}
|
||||
{"@level":"info","@message":"Plan: 1 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","changes":{"add":1,"change":0,"import":0,"remove":0,"action_invocation":0,"operation":"plan"},"type":"change_summary"}
|
||||
{"@level":"error","@message":"Error: policy denied","@module":"terraform.ui","@policy":"true","target_address":"test_instance.foo","policy_diagnostic":{"severity":"error","summary":"policy denied","detail":"","range":{"filename":"main.tf","start":{"line":1,"column":1,"byte":0},"end":{"line":1,"column":31,"byte":30}},"snippet":{"context":null,"code":"resource \"test_instance\" \"foo\" {","start_line":1,"highlight_start_offset":0,"highlight_end_offset":30,"values":[]},"policy_range":{"filename":"policy_file.tfpolicy.hcl","start":{"line":1,"column":1,"byte":0},"end":{"line":2,"column":4,"byte":0}},"policy_snippet":{"context":null,"code":"\t\tresource_policy \"resource_type\" \"policy_name\" {\n\t\t enforce_attrs {\n\t\t key = attr.value == \"foo\"\n\t\t }\n\t\t}\n\t","start_line":1,"highlight_start_offset":1,"highlight_end_offset":100,"values":null}},"policy_metadata":{"enforce_index":1,"policy_set_path":"policy_file.tfpolicy.hcl","policy_name":"resource_policy.foo","file_name":"policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"result":"DenyResult","type":"policy_diagnostic"}
|
||||
{"@level":"error","@message":"Error: policy failed for some other reason","@module":"terraform.ui","@policy":"true","target_address":"test_instance.foo","policy_diagnostic":{"severity":"error","summary":"policy failed for some other reason","detail":"","range":{"filename":"main.tf","start":{"line":1,"column":1,"byte":0},"end":{"line":1,"column":31,"byte":30}},"snippet":{"context":null,"code":"resource \"test_instance\" \"foo\" {","start_line":1,"highlight_start_offset":0,"highlight_end_offset":30,"values":[]},"policy_range":{"filename":"policy_file.tfpolicy.hcl","start":{"line":1,"column":1,"byte":0},"end":{"line":2,"column":4,"byte":0}},"policy_snippet":{"context":null,"code":"\t\tresource_policy \"resource_type\" \"policy_name\" {\n\t\t enforce_attrs {\n\t\t key = attr.value == \"foo\"\n\t\t }\n\t\t}\n\t","start_line":1,"highlight_start_offset":1,"highlight_end_offset":100,"values":null}},"policy_metadata":{"enforce_index":2,"policy_set_path":"policy_file.tfpolicy.hcl","policy_name":"resource_policy.bar","file_name":"policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"result":"DenyResult","type":"policy_diagnostic"}
|
||||
{"@level":"info","@message":"Policy Result","@module":"terraform.ui","@policy":"true","target_address":"test_instance.foo","policy_address":"resource_policy.foo","policy_metadata":{"policy_set_path":"policy_file.tfpolicy.hcl","policy_name":"resource_policy.foo","file_name":"policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"result":"DenyResult","type":"policy_result"}
|
||||
{"@level":"info","@message":"Policy Result","@module":"terraform.ui","@policy":"true","target_address":"test_instance.foo","policy_address":"resource_policy.bar","policy_metadata":{"policy_set_path":"policy_file.tfpolicy.hcl","policy_name":"resource_policy.bar","file_name":"policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"result":"DenyResult","type":"policy_result"}`
|
||||
|
||||
checkGoldenReferenceStr(t, output, expected)
|
||||
}
|
||||
|
||||
func TestPlan_WithPolicyUnknown(t *testing.T) {
|
||||
|
||||
td := t.TempDir()
|
||||
testCopyDir(t, testFixturePath("plan"), td)
|
||||
t.Chdir(td)
|
||||
policyCode := ` resource_policy "resource_type" "policy_name" {
|
||||
enforce_attrs {
|
||||
key = attr.value == "foo"
|
||||
}
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile("policy.hcl", []byte(policyCode), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := planFixtureProvider()
|
||||
view, done := testView(t)
|
||||
overrides := metaOverridesForProvider(p)
|
||||
policyClient := policy.NewTestMockClient(t)
|
||||
overrides.PolicyClient = policyClient
|
||||
c := &PlanCommand{
|
||||
Meta: Meta{
|
||||
testingOverrides: overrides,
|
||||
View: view,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
|
||||
resp := policy.EvaluationFromProtoResponse(proto.EvaluateResult_UNKNOWN_EVALUATE_RESULT, []*proto.PolicyEvaluationDetail{
|
||||
{
|
||||
Result: proto.EvaluateResult_UNKNOWN_EVALUATE_RESULT,
|
||||
Diagnostics: []*proto.Diagnostic{
|
||||
{
|
||||
Severity: proto.Severity_WARNING,
|
||||
Summary: "policy with unknowns",
|
||||
Result: &proto.DiagnosticResult{
|
||||
Result: proto.EvaluateResult_UNKNOWN_EVALUATE_RESULT,
|
||||
},
|
||||
Subject: &proto.Range{
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
Start: &proto.Position{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: &proto.Position{
|
||||
Line: 2,
|
||||
Column: 4,
|
||||
},
|
||||
},
|
||||
Context: &proto.Range{
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
Start: &proto.Position{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: &proto.Position{
|
||||
Line: 4,
|
||||
Column: 10,
|
||||
},
|
||||
},
|
||||
Snippet: &proto.Snippet{
|
||||
Code: policyCode,
|
||||
StartLine: 1,
|
||||
HighlightStartOffset: 1,
|
||||
HighlightEndOffset: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
policyClient.EvaluateResponse = &resp
|
||||
|
||||
args := []string{"-policies", td}
|
||||
code := c.Run(append(args, "-no-color"))
|
||||
output := done(t)
|
||||
if code != 0 {
|
||||
t.Fatalf("bad: %d\n\n%s", code, output.Stdout())
|
||||
}
|
||||
|
||||
expected := `data.test_data_source.a: Reading...
|
||||
data.test_data_source.a: Read complete after 0s [id=zzzzz]
|
||||
|
||||
Terraform used the selected providers to generate the following execution
|
||||
plan. Resource actions are indicated with the following symbols:
|
||||
+ create
|
||||
|
||||
Terraform will perform the following actions:
|
||||
|
||||
# test_instance.foo will be created
|
||||
+ resource "test_instance" "foo" {
|
||||
+ ami = "bar"
|
||||
|
||||
+ network_interface {
|
||||
+ description = "Main network interface"
|
||||
+ device_index = "0"
|
||||
}
|
||||
}
|
||||
|
||||
Plan: 1 to add, 0 to change, 0 to destroy.
|
||||
|
||||
Warning: policy with unknowns
|
||||
|
||||
on policy_file.tfpolicy.hcl line 1:
|
||||
1: resource_policy "resource_type" "policy_name" {
|
||||
2: enforce_attrs {
|
||||
3: key = attr.value == "foo"
|
||||
4: }
|
||||
5: }
|
||||
6:
|
||||
|
||||
while evaluating policy for main.tf line 1:
|
||||
1: resource "test_instance" "foo" {
|
||||
|
||||
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Note: You didn't use the -out option to save this plan, so Terraform can't
|
||||
guarantee to take exactly these actions if you run "terraform apply" now.
|
||||
`
|
||||
|
||||
if actual, diff := output.Stdout(), cmp.Diff(expected, output.Stdout()); diff != "" {
|
||||
t.Fatalf("unexpected output:\n%s. \nDiff: %s", actual, diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlan_WithPolicySuccessInfo(t *testing.T) {
|
||||
td := t.TempDir()
|
||||
testCopyDir(t, testFixturePath("plan"), td)
|
||||
t.Chdir(td)
|
||||
policyCode := ` resource_policy "resource_type" "policy_name" {
|
||||
enforce_attrs {
|
||||
key = attr.value == "foo"
|
||||
}
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile("policy.hcl", []byte(policyCode), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
providerSource := newMockProviderSource(t, map[string][]string{
|
||||
"test": {"1.0.0"},
|
||||
})
|
||||
|
||||
p := planFixtureProvider()
|
||||
view, done := testView(t)
|
||||
overrides := metaOverridesForProvider(p)
|
||||
policyClient := policy.NewTestMockClient(t)
|
||||
overrides.PolicyClient = policyClient
|
||||
meta := Meta{
|
||||
testingOverrides: overrides,
|
||||
View: view,
|
||||
ProviderSource: providerSource,
|
||||
AllowExperimentalFeatures: true,
|
||||
}
|
||||
|
||||
init := &InitCommand{
|
||||
Meta: meta,
|
||||
}
|
||||
|
||||
if code := init.Run(nil); code != 0 {
|
||||
output := done(t)
|
||||
t.Fatalf("expected status code %d but got %d: %s", 0, code, output.All())
|
||||
}
|
||||
|
||||
view, done = testView(t)
|
||||
meta.View = view
|
||||
|
||||
c := &PlanCommand{
|
||||
Meta: meta,
|
||||
}
|
||||
|
||||
policyObj := &policy.Policy{
|
||||
Result: policy.AllowResult,
|
||||
PolicySetName: "some_policy_set",
|
||||
Address: "policy_name",
|
||||
Directory: "some/path/to",
|
||||
Filename: "provider_policy_file.tfpolicy.hcl",
|
||||
EnforcementLevel: "mandatory",
|
||||
Range: &hcl.Range{
|
||||
Filename: "provider_policy_file.tfpolicy.hcl",
|
||||
Start: hcl.Pos{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: hcl.Pos{
|
||||
Line: 5,
|
||||
Column: 12,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
policyClient.EvaluateProviderFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ProviderMetadata]) policy.EvaluationResponse {
|
||||
if req.Meta.Version != "1.0.0" {
|
||||
t.Fatalf("Expected provider version to be 1.0.0")
|
||||
}
|
||||
|
||||
return policy.EvaluationResponse{
|
||||
Overall: policy.AllowResult,
|
||||
Policies: []*policy.Policy{policyObj},
|
||||
Enforcements: []policy.EnforcementResult{
|
||||
{
|
||||
Result: policy.AllowResult,
|
||||
Message: "Something about this enforcement",
|
||||
BlockIndex: 1,
|
||||
Snippet: &proto.Snippet{
|
||||
Code: "provider_policy \"test_policy\" \"name\"",
|
||||
StartLine: 1,
|
||||
HighlightStartOffset: 1,
|
||||
HighlightEndOffset: 100,
|
||||
},
|
||||
Range: &hcl.Range{
|
||||
Filename: "provider_policy_file.tfpolicy.hcl",
|
||||
Start: hcl.Pos{
|
||||
Line: 3,
|
||||
Column: 5,
|
||||
},
|
||||
End: hcl.Pos{
|
||||
Line: 4,
|
||||
Column: 10,
|
||||
},
|
||||
},
|
||||
Policy: policyObj,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
policyObj = &policy.Policy{
|
||||
Result: policy.AllowResult,
|
||||
PolicySetName: "some_policy_set",
|
||||
Address: "policy_name",
|
||||
Directory: "some/path/to",
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
EnforcementLevel: "mandatory",
|
||||
Range: &hcl.Range{
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
Start: hcl.Pos{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: hcl.Pos{
|
||||
Line: 5,
|
||||
Column: 12,
|
||||
},
|
||||
},
|
||||
}
|
||||
policyClient.EvaluateResponse = &policy.EvaluationResponse{
|
||||
Overall: policy.AllowResult,
|
||||
Policies: []*policy.Policy{policyObj},
|
||||
Enforcements: []policy.EnforcementResult{
|
||||
{
|
||||
Result: policy.AllowResult,
|
||||
Message: "Something about this enforcement",
|
||||
BlockIndex: 1,
|
||||
Snippet: &proto.Snippet{
|
||||
Code: "resource_policy \"test_policy\" \"name\"",
|
||||
StartLine: 1,
|
||||
HighlightStartOffset: 1,
|
||||
HighlightEndOffset: 100,
|
||||
},
|
||||
Range: &hcl.Range{
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
Start: hcl.Pos{
|
||||
Line: 3,
|
||||
Column: 5,
|
||||
},
|
||||
End: hcl.Pos{
|
||||
Line: 4,
|
||||
Column: 10,
|
||||
},
|
||||
},
|
||||
Policy: policyObj,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
args := []string{"-policies", td}
|
||||
code := c.Run(append(args, "-no-color"))
|
||||
output := done(t)
|
||||
if code != 0 {
|
||||
t.Fatalf("bad: %d\n\n%s", code, output.Stdout())
|
||||
}
|
||||
|
||||
expected := `data.test_data_source.a: Reading...
|
||||
data.test_data_source.a: Read complete after 0s [id=zzzzz]
|
||||
|
||||
Terraform used the selected providers to generate the following execution
|
||||
plan. Resource actions are indicated with the following symbols:
|
||||
+ create
|
||||
|
||||
Terraform will perform the following actions:
|
||||
|
||||
# test_instance.foo will be created
|
||||
+ resource "test_instance" "foo" {
|
||||
+ ami = "bar"
|
||||
|
||||
+ network_interface {
|
||||
+ description = "Main network interface"
|
||||
+ device_index = "0"
|
||||
}
|
||||
}
|
||||
|
||||
Plan: 1 to add, 0 to change, 0 to destroy.
|
||||
|
||||
Policy Info:
|
||||
on policy_file.tfpolicy.hcl line 3, in resource_policy "test_policy" "name"
|
||||
"Something about this enforcement"
|
||||
|
||||
on main.tf line 1, in resource "test_instance" "foo"
|
||||
|
||||
Policy Info:
|
||||
on provider_policy_file.tfpolicy.hcl line 3, in provider_policy "test_policy" "name"
|
||||
"Something about this enforcement"
|
||||
|
||||
|
||||
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Note: You didn't use the -out option to save this plan, so Terraform can't
|
||||
guarantee to take exactly these actions if you run "terraform apply" now.
|
||||
`
|
||||
|
||||
if actual, diff := output.Stdout(), cmp.Diff(expected, output.Stdout()); diff != "" {
|
||||
t.Fatalf("unexpected output:\n%s. \nDiff: %s", actual, diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlan_WithPolicySuccessInfoJSON(t *testing.T) {
|
||||
td := t.TempDir()
|
||||
testCopyDir(t, testFixturePath("plan"), td)
|
||||
t.Chdir(td)
|
||||
|
||||
p := planFixtureProvider()
|
||||
view, done := testView(t)
|
||||
overrides := metaOverridesForProvider(p)
|
||||
policyClient := policy.NewTestMockClient(t)
|
||||
overrides.PolicyClient = policyClient
|
||||
c := &PlanCommand{
|
||||
Meta: Meta{
|
||||
testingOverrides: overrides,
|
||||
View: view,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
|
||||
policyObj := &policy.Policy{
|
||||
Result: policy.AllowResult,
|
||||
PolicySetName: "some_policy_set",
|
||||
Address: "policy_name",
|
||||
Directory: "some/path/to",
|
||||
Filename: "provider_policy_file.tfpolicy.hcl",
|
||||
EnforcementLevel: "mandatory",
|
||||
Range: &hcl.Range{
|
||||
Filename: "provider_policy_file.tfpolicy.hcl",
|
||||
Start: hcl.Pos{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: hcl.Pos{
|
||||
Line: 5,
|
||||
Column: 12,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
policyClient.EvaluateProviderResponse = &policy.EvaluationResponse{
|
||||
Overall: policy.AllowResult,
|
||||
Policies: []*policy.Policy{policyObj},
|
||||
Enforcements: []policy.EnforcementResult{
|
||||
{
|
||||
Result: policy.AllowResult,
|
||||
Message: "Something about this enforcement",
|
||||
BlockIndex: 1,
|
||||
Snippet: &proto.Snippet{
|
||||
Code: "provider_policy \"test_policy\" \"name\"",
|
||||
StartLine: 1,
|
||||
HighlightStartOffset: 1,
|
||||
HighlightEndOffset: 100,
|
||||
},
|
||||
Range: &hcl.Range{
|
||||
Filename: "provider_policy_file.tfpolicy.hcl",
|
||||
Start: hcl.Pos{
|
||||
Line: 3,
|
||||
Column: 5,
|
||||
},
|
||||
End: hcl.Pos{
|
||||
Line: 4,
|
||||
Column: 10,
|
||||
},
|
||||
},
|
||||
Policy: policyObj,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
policyObj = &policy.Policy{
|
||||
Result: policy.AllowResult,
|
||||
PolicySetName: "some_policy_set",
|
||||
Address: "policy_name",
|
||||
Directory: "some/path/to",
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
EnforcementLevel: "mandatory",
|
||||
Range: &hcl.Range{
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
Start: hcl.Pos{
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
},
|
||||
End: hcl.Pos{
|
||||
Line: 5,
|
||||
Column: 12,
|
||||
},
|
||||
},
|
||||
}
|
||||
policyClient.EvaluateResponse = &policy.EvaluationResponse{
|
||||
Overall: policy.AllowResult,
|
||||
Policies: []*policy.Policy{policyObj},
|
||||
Enforcements: []policy.EnforcementResult{
|
||||
{
|
||||
Result: policy.AllowResult,
|
||||
Message: "Something about this enforcement",
|
||||
BlockIndex: 1,
|
||||
Snippet: &proto.Snippet{
|
||||
Code: "resource_policy \"test_policy\" \"name\"",
|
||||
StartLine: 1,
|
||||
HighlightStartOffset: 1,
|
||||
HighlightEndOffset: 100,
|
||||
},
|
||||
Range: &hcl.Range{
|
||||
Filename: "policy_file.tfpolicy.hcl",
|
||||
Start: hcl.Pos{
|
||||
Line: 3,
|
||||
Column: 5,
|
||||
},
|
||||
End: hcl.Pos{
|
||||
Line: 4,
|
||||
Column: 10,
|
||||
},
|
||||
},
|
||||
Policy: policyObj,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
args := []string{"-policies", td}
|
||||
code := c.Run(append(args, "-no-color", "-json"))
|
||||
output := done(t)
|
||||
if code != 0 {
|
||||
t.Fatalf("bad: %d\n\n%s", code, output.Stdout())
|
||||
}
|
||||
|
||||
checkGoldenReference(t, output, "plan-policy")
|
||||
}
|
||||
|
||||
func TestPlan_WithPolicySetupFailure(t *testing.T) {
|
||||
td := t.TempDir()
|
||||
testCopyDir(t, testFixturePath("plan"), td)
|
||||
t.Chdir(td)
|
||||
policyCode := ` resource_policy "resource_type" "policy_name" {
|
||||
enforce_attrs {
|
||||
key = attr.value == "foo"
|
||||
}
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile("policy.hcl", []byte(policyCode), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := planFixtureProvider()
|
||||
view, done := testView(t)
|
||||
overrides := metaOverridesForProvider(p)
|
||||
|
||||
// We intentionally do not pass a policy client override here so the command
|
||||
// exercises the real policy client initialization path and emits any setup
|
||||
// diagnostics from attempting to connect to the policy engine.
|
||||
c := &PlanCommand{
|
||||
Meta: Meta{
|
||||
testingOverrides: overrides,
|
||||
View: view,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
|
||||
args := []string{"-policies", td}
|
||||
code := c.Run(append(args, "-no-color"))
|
||||
output := done(t)
|
||||
// expect the operation to be a success
|
||||
if code != 0 {
|
||||
t.Fatalf("bad: %d\n\n%s", code, output.Stderr())
|
||||
}
|
||||
|
||||
// we still display the policy output
|
||||
// and the plan still succeeds
|
||||
expectedOut := `
|
||||
Error: Failed to connect to policy engine
|
||||
|
||||
Failed to connect to policy engine: failed to connect to plugin: exec:
|
||||
"tfpolicy-plugin": executable file not found in $PATH.
|
||||
data.test_data_source.a: Reading...
|
||||
data.test_data_source.a: Read complete after 0s [id=zzzzz]
|
||||
|
||||
Terraform used the selected providers to generate the following execution
|
||||
plan. Resource actions are indicated with the following symbols:
|
||||
+ create
|
||||
|
||||
Terraform will perform the following actions:
|
||||
|
||||
# test_instance.foo will be created
|
||||
+ resource "test_instance" "foo" {
|
||||
+ ami = "bar"
|
||||
|
||||
+ network_interface {
|
||||
+ description = "Main network interface"
|
||||
+ device_index = "0"
|
||||
}
|
||||
}
|
||||
|
||||
Plan: 1 to add, 0 to change, 0 to destroy.
|
||||
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Note: You didn't use the -out option to save this plan, so Terraform can't
|
||||
guarantee to take exactly these actions if you run "terraform apply" now.
|
||||
`
|
||||
|
||||
if diff := cmp.Diff(expectedOut, output.All()); diff != "" {
|
||||
t.Fatalf("unexpected output:\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlan_WithPolicySetupFailureJSON(t *testing.T) {
|
||||
td := t.TempDir()
|
||||
testCopyDir(t, testFixturePath("plan"), td)
|
||||
t.Chdir(td)
|
||||
policyCode := ` resource_policy "resource_type" "policy_name" {
|
||||
enforce_attrs {
|
||||
key = attr.value == "foo"
|
||||
}
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile("policy.hcl", []byte(policyCode), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := planFixtureProvider()
|
||||
view, done := testView(t)
|
||||
overrides := metaOverridesForProvider(p)
|
||||
|
||||
// We intentionally do not pass a policy client override here so the command
|
||||
// exercises the real policy client initialization path and emits any setup
|
||||
// diagnostics from attempting to connect to the policy engine.
|
||||
c := &PlanCommand{
|
||||
Meta: Meta{
|
||||
testingOverrides: overrides,
|
||||
View: view,
|
||||
AllowExperimentalFeatures: true,
|
||||
},
|
||||
}
|
||||
|
||||
args := []string{"-policies", td}
|
||||
code := c.Run(append(args, "-no-color", "-json"))
|
||||
output := done(t)
|
||||
// expect the operation to be a success
|
||||
if code != 0 {
|
||||
t.Fatalf("bad: %d\n\n%s", code, output.Stderr())
|
||||
}
|
||||
|
||||
expected := `{"@level":"info","@message":"Terraform 1.15.0-dev","@module":"terraform.ui","terraform":"1.15.0-dev","type":"version","ui":"1.3"}
|
||||
{"@level":"error","@message":"Error: Failed to connect to policy engine","@module":"terraform.ui","@policy":"true","policy_diagnostic":{"severity":"error","summary":"Failed to connect to policy engine","detail":"Failed to connect to policy engine: failed to connect to plugin: exec: \"tfpolicy-plugin\": executable file not found in $PATH."},"policy_metadata":{},"result":"SetupErrorResult","type":"policy_diagnostic"}
|
||||
{"@level":"info","@message":"data.test_data_source.a: Refreshing...","@module":"terraform.ui","hook":{"resource":{"addr":"data.test_data_source.a","module":"","resource":"data.test_data_source.a","implied_provider":"test","resource_type":"test_data_source","resource_name":"a","resource_key":null},"action":"read"},"type":"apply_start"}
|
||||
{"@level":"info","@message":"data.test_data_source.a: Refresh complete after 0s [id=zzzzz]","@module":"terraform.ui","hook":{"resource":{"addr":"data.test_data_source.a","module":"","resource":"data.test_data_source.a","implied_provider":"test","resource_type":"test_data_source","resource_name":"a","resource_key":null},"action":"read","id_key":"id","id_value":"zzzzz","elapsed_seconds":0},"type":"apply_complete"}
|
||||
{"@level":"info","@message":"test_instance.foo: Plan to create","@module":"terraform.ui","change":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create"},"type":"planned_change"}
|
||||
{"@level":"info","@message":"Plan: 1 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","changes":{"add":1,"change":0,"import":0,"remove":0,"action_invocation":0,"operation":"plan"},"type":"change_summary"}`
|
||||
fmt.Println(output.Stdout())
|
||||
checkGoldenReferenceStr(t, output, expected)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
13
internal/command/testdata/plan-policy/main.tf
vendored
Normal file
13
internal/command/testdata/plan-policy/main.tf
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
resource "test_instance" "foo" {
|
||||
ami = "bar"
|
||||
|
||||
# This is here because at some point it caused a test failure
|
||||
network_interface {
|
||||
device_index = 0
|
||||
description = "Main network interface"
|
||||
}
|
||||
}
|
||||
|
||||
data "test_data_source" "a" {
|
||||
id = "zzzzz"
|
||||
}
|
||||
9
internal/command/testdata/plan-policy/output.jsonlog
vendored
Normal file
9
internal/command/testdata/plan-policy/output.jsonlog
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{"@level":"info","@message":"Terraform 1.15.0-dev","@module":"terraform.ui","terraform":"1.15.0-dev","type":"version","ui":"1.2"}
|
||||
{"@level":"info","@message":"data.test_data_source.a: Refreshing...","@module":"terraform.ui","hook":{"resource":{"addr":"data.test_data_source.a","module":"","resource":"data.test_data_source.a","implied_provider":"test","resource_type":"test_data_source","resource_name":"a","resource_key":null},"action":"read"},"type":"apply_start"}
|
||||
{"@level":"info","@message":"data.test_data_source.a: Refresh complete after 0s [id=zzzzz]","@module":"terraform.ui","hook":{"resource":{"addr":"data.test_data_source.a","module":"","resource":"data.test_data_source.a","implied_provider":"test","resource_type":"test_data_source","resource_name":"a","resource_key":null},"action":"read","id_key":"id","id_value":"zzzzz","elapsed_seconds":0},"type":"apply_complete"}
|
||||
{"@level":"info","@message":"test_instance.foo: Plan to create","@module":"terraform.ui","change":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create"},"type":"planned_change"}
|
||||
{"@level":"info","@message":"Plan: 1 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","changes":{"add":1,"change":0,"import":0,"remove":0,"action_invocation":0,"operation":"plan"},"type":"change_summary"}
|
||||
{"@level":"info","@message":"Policy info","@module":"terraform.ui","@policy":"true","target_address":"test_instance.foo","policy_info":{"message":"Something about this enforcement","policy_range":{"filename":"policy_file.tfpolicy.hcl","start":{"line":3,"column":5,"byte":0},"end":{"line":4,"column":10,"byte":0}},"policy_snippet":{"context":null,"code":"resource_policy \"test_policy\" \"name\"","start_line":1,"highlight_start_offset":1,"highlight_end_offset":100,"values":null},"range":{"filename":"main.tf","start":{"line":1,"column":1,"byte":0},"end":{"line":1,"column":31,"byte":30}},"snippet":{"context":null,"code":"resource \"test_instance\" \"foo\" {","start_line":1,"highlight_start_offset":0,"highlight_end_offset":30,"values":[]}},"policy_metadata":{"enforce_index":1,"policy_set_name":"some_policy_set","policy_set_path":"some/path/to","policy_name":"policy_name","file_name":"policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"result":"AllowResult","type":"policy_info"}
|
||||
{"@level":"info","@message":"Policy Result","@module":"terraform.ui","@policy":"true","result":"AllowResult","target_address":"test_instance.foo","policy_address":"policy_name","policy_metadata":{"policy_set_name":"some_policy_set","policy_set_path":"some/path/to","policy_name":"policy_name","file_name":"policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"type":"policy_result"}
|
||||
{"@level":"info","@message":"Policy info","@module":"terraform.ui","@policy":"true","target_address":"provider[\"registry.terraform.io/hashicorp/test\"]","policy_info":{"message":"Something about this enforcement","policy_range":{"filename":"provider_policy_file.tfpolicy.hcl","start":{"line":3,"column":5,"byte":0},"end":{"line":4,"column":10,"byte":0}},"policy_snippet":{"context":null,"code":"provider_policy \"test_policy\" \"name\"","start_line":1,"highlight_start_offset":1,"highlight_end_offset":100,"values":null}},"policy_metadata":{"enforce_index":1,"policy_set_name":"some_policy_set","policy_set_path":"some/path/to","policy_name":"policy_name","file_name":"provider_policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"result":"AllowResult","type":"policy_info"}
|
||||
{"@level":"info","@message":"Policy Result","@module":"terraform.ui","@policy":"true","target_address":"provider[\"registry.terraform.io/hashicorp/test\"]","policy_address":"policy_name","policy_metadata":{"policy_set_name":"some_policy_set","policy_set_path":"some/path/to","policy_name":"policy_name","file_name":"provider_policy_file.tfpolicy.hcl","enforcement_level":"mandatory"},"result":"AllowResult","type":"policy_result"}
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
89
internal/command/views/json/policy.go
Normal file
89
internal/command/views/json/policy.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// Copyright IBM Corp. 2014, 2026
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package json
|
||||
|
||||
import (
|
||||
"github.com/hashicorp/terraform/internal/policy"
|
||||
)
|
||||
|
||||
// PolicyInfo is like an info diagnostic from the policy engine,
|
||||
// and as such borrows diagnostic-related structs to
|
||||
// host source information such as range and snippet.
|
||||
type PolicyInfo struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
PolicyRange *DiagnosticRange `json:"policy_range,omitempty"`
|
||||
PolicySnippet *DiagnosticSnippet `json:"policy_snippet,omitempty"`
|
||||
|
||||
// Range and Snippet are the terraform source information
|
||||
Range *DiagnosticRange `json:"range,omitempty"`
|
||||
Snippet *DiagnosticSnippet `json:"snippet,omitempty"`
|
||||
}
|
||||
|
||||
type PolicyMetadata struct {
|
||||
PolicySetName string `json:"policy_set_name,omitempty"`
|
||||
PolicySetPath string `json:"policy_set_path,omitempty"`
|
||||
PolicyName string `json:"policy_name,omitempty"`
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
EnforcementLevel string `json:"enforcement_level,omitempty"`
|
||||
EnforceIndex *int32 `json:"enforce_index,omitempty"`
|
||||
}
|
||||
|
||||
type EnforceMetadata struct {
|
||||
BlockIndex *int32 `json:"block_index,omitempty"`
|
||||
}
|
||||
|
||||
func NewPolicyInfo(sourceCode []byte, enforcement policy.EnforcementResult) PolicyInfo {
|
||||
ret := PolicyInfo{
|
||||
Message: enforcement.Message,
|
||||
}
|
||||
|
||||
if rng := enforcement.Range; rng != nil {
|
||||
ret.PolicyRange = &DiagnosticRange{
|
||||
Filename: rng.Filename,
|
||||
Start: Pos(rng.Start),
|
||||
End: Pos(rng.End),
|
||||
}
|
||||
}
|
||||
|
||||
if snippet := enforcement.Snippet; snippet != nil {
|
||||
ret.PolicySnippet = &DiagnosticSnippet{
|
||||
Code: snippet.Code,
|
||||
StartLine: int(snippet.StartLine),
|
||||
HighlightStartOffset: int(snippet.HighlightStartOffset),
|
||||
HighlightEndOffset: int(snippet.HighlightEndOffset),
|
||||
}
|
||||
if snippet.Context != nil && snippet.Context.Context != "" {
|
||||
ret.PolicySnippet.Context = &snippet.Context.Context
|
||||
}
|
||||
}
|
||||
|
||||
if rng := enforcement.LocalRange; rng != nil {
|
||||
ret.Range = &DiagnosticRange{
|
||||
Filename: rng.Filename,
|
||||
Start: Pos(rng.Start),
|
||||
End: Pos(rng.End),
|
||||
}
|
||||
if sourceCode != nil {
|
||||
ret.Snippet = snippetFromRange(sourceCode, *rng, *rng)
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func MetadataFromPolicy(policy policy.Policy) PolicyMetadata {
|
||||
return PolicyMetadata{
|
||||
PolicySetName: policy.PolicySetName,
|
||||
PolicySetPath: policy.Directory,
|
||||
PolicyName: policy.Address,
|
||||
FileName: policy.Filename,
|
||||
EnforcementLevel: policy.EnforcementLevel,
|
||||
}
|
||||
}
|
||||
|
||||
func MetadataFromEnforcement(enforcement policy.EnforcementResult) PolicyMetadata {
|
||||
ret := MetadataFromPolicy(*enforcement.Policy)
|
||||
ret.EnforceIndex = &enforcement.BlockIndex
|
||||
return ret
|
||||
}
|
||||
|
|
@ -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...)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,15 @@
|
|||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mitchellh/colorstring"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/command/arguments"
|
||||
"github.com/hashicorp/terraform/internal/command/format"
|
||||
"github.com/hashicorp/terraform/internal/command/views/json"
|
||||
"github.com/hashicorp/terraform/internal/plans"
|
||||
"github.com/hashicorp/terraform/internal/terminal"
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
)
|
||||
|
|
@ -111,12 +116,7 @@ func (v *View) Diagnostics(diags tfdiags.Diagnostics) {
|
|||
}
|
||||
|
||||
for _, diag := range diags {
|
||||
var msg string
|
||||
if v.colorize.Disable {
|
||||
msg = format.DiagnosticPlain(diag, v.configSources(), v.streams.Stderr.Columns())
|
||||
} else {
|
||||
msg = format.Diagnostic(diag, v.configSources(), v.colorize, v.streams.Stderr.Columns())
|
||||
}
|
||||
msg := v.formatDiagnostic(diag)
|
||||
|
||||
if diag.Severity() == tfdiags.Error {
|
||||
v.streams.Eprint(msg)
|
||||
|
|
@ -126,6 +126,84 @@ func (v *View) Diagnostics(diags tfdiags.Diagnostics) {
|
|||
}
|
||||
}
|
||||
|
||||
// PolicyResults renders the policy results in human-readable format.
|
||||
// This is done separately from the plan rendering because it may require additional
|
||||
// source information that is not available in the plan renderer.
|
||||
func (v *View) PolicyResults(results *plans.PolicyResults) {
|
||||
if results == nil {
|
||||
return
|
||||
}
|
||||
configSources := v.configSources()
|
||||
var buf strings.Builder
|
||||
var foundInfo bool
|
||||
|
||||
// Print setup diagnostics
|
||||
for _, diag := range results.Diagnostics {
|
||||
msg := v.formatDiagnostic(diag)
|
||||
if diag.Severity() == tfdiags.Error {
|
||||
v.streams.Eprint(msg)
|
||||
} else {
|
||||
v.streams.Print(msg)
|
||||
}
|
||||
}
|
||||
for _, result := range results.Iter() {
|
||||
for _, enforcement := range result.EvaluationResponse.Enforcements {
|
||||
var src []byte
|
||||
if enforcement.LocalRange != nil {
|
||||
src = configSources[enforcement.LocalRange.Filename]
|
||||
}
|
||||
info := json.NewPolicyInfo(src, enforcement)
|
||||
// Print info message attached to the enforcement
|
||||
if info.Message != "" {
|
||||
foundInfo = true
|
||||
buf.WriteString("Policy Info:\n")
|
||||
if info.PolicyRange != nil && info.PolicySnippet != nil {
|
||||
buf.WriteString(fmt.Sprintf(
|
||||
"on %s line %d, in %s\n",
|
||||
info.PolicyRange.Filename,
|
||||
info.PolicyRange.Start.Line,
|
||||
info.PolicySnippet.Code,
|
||||
))
|
||||
} else if enforcement.Policy != nil {
|
||||
buf.WriteString(fmt.Sprintf(
|
||||
"in policy %s\n",
|
||||
enforcement.Policy.Address,
|
||||
))
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("%q\n", info.Message))
|
||||
|
||||
if !result.ConfigDeclRange.Empty() {
|
||||
cfgRange := result.ConfigDeclRange
|
||||
resourceContext := string(cfgRange.SliceBytes(configSources[cfgRange.Filename]))
|
||||
|
||||
// Here we want the resource source context
|
||||
buf.WriteString(fmt.Sprintf(
|
||||
"\non %s line %d, in %s\n",
|
||||
cfgRange.Filename,
|
||||
cfgRange.Start.Line,
|
||||
resourceContext,
|
||||
))
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Print policy diagnostics
|
||||
for _, diag := range result.EvaluationResponse.Diagnostics {
|
||||
msg := v.formatDiagnostic(diag)
|
||||
if diag.Severity() == tfdiags.Error {
|
||||
v.streams.Eprint(msg)
|
||||
} else {
|
||||
v.streams.Print(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
if foundInfo {
|
||||
v.streams.Println()
|
||||
v.streams.Println(buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// HelpPrompt is intended to be called from commands which fail to parse all
|
||||
// of their CLI arguments successfully. It refers users to the full help output
|
||||
// rather than rendering it directly, which can be overwhelming and confusing.
|
||||
|
|
@ -164,3 +242,11 @@ func (v *View) errorColumns() int {
|
|||
func (v *View) outputHorizRule() {
|
||||
v.streams.Println(format.HorizontalRule(v.colorize, v.outputColumns()))
|
||||
}
|
||||
|
||||
func (v *View) formatDiagnostic(diag tfdiags.Diagnostic) string {
|
||||
if v.colorize.Disable {
|
||||
return format.DiagnosticPlain(diag, v.configSources(), v.streams.Stderr.Columns())
|
||||
} else {
|
||||
return format.Diagnostic(diag, v.configSources(), v.colorize, v.streams.Stderr.Columns())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue