diff --git a/internal/command/arguments/init.go b/internal/command/arguments/init.go index 94df38df074e..04695787e7eb 100644 --- a/internal/command/arguments/init.go +++ b/internal/command/arguments/init.go @@ -79,6 +79,10 @@ type Init struct { // TODO(SarahFrench/radeksimko): Remove this once the feature is no longer // experimental EnablePssExperiment bool + + // PolicyPath contains an optional path to any defined policies that should + // be applied for this plan operation. + PolicyPaths []string } // ParseInit processes CLI arguments, returning an Init value and errors. @@ -112,6 +116,7 @@ func ParseInit(args []string, experimentsEnabled bool) (*Init, tfdiags.Diagnosti cmdFlags.BoolVar(&init.Json, "json", false, "json") cmdFlags.Var(&init.BackendConfig, "backend-config", "") cmdFlags.Var(&init.PluginPath, "plugin-dir", "plugin directory") + cmdFlags.Var((*FlagStringSlice)(&init.PolicyPaths), "policies", "policies") // Used for enabling experimental code that's invoked before configuration is parsed. cmdFlags.BoolVar(&init.EnablePssExperiment, "enable-pluggable-state-storage-experiment", false, "Enable the pluggable state storage experiment") diff --git a/internal/command/command_test.go b/internal/command/command_test.go index d33da3df6a19..3cc56b2982af 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -160,7 +160,7 @@ func testModuleWithSnapshot(t *testing.T, name string) (*configs.Config, *config // sources only this ultimately just records all of the module paths // in a JSON file so that we can load them below. inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) - _, instDiags := inst.InstallModules(context.Background(), dir, "tests", true, false, initwd.ModuleInstallHooksImpl{}) + _, instDiags := inst.InstallModules(context.Background(), dir, "tests", true, false) if instDiags.HasErrors() { t.Fatal(instDiags.Err()) } diff --git a/internal/command/graph_test.go b/internal/command/graph_test.go index 71f6611dd199..1ec243c6321b 100644 --- a/internal/command/graph_test.go +++ b/internal/command/graph_test.go @@ -227,7 +227,7 @@ func TestGraph_resourcesOnly(t *testing.T) { t.Fatal(err) } inst := initwd.NewModuleInstaller(".terraform/modules", loader, registry.NewClient(nil, nil), nil) - _, instDiags := inst.InstallModules(context.Background(), ".", "tests", true, false, initwd.ModuleInstallHooksImpl{}) + _, instDiags := inst.InstallModules(context.Background(), ".", "tests", true, false) if instDiags.HasErrors() { t.Fatal(instDiags.Err()) } diff --git a/internal/command/hook_module_install.go b/internal/command/hook_module_install.go index c4a6c51a5fe9..fdc2d30d7014 100644 --- a/internal/command/hook_module_install.go +++ b/internal/command/hook_module_install.go @@ -15,13 +15,13 @@ type view interface { Log(message string, params ...any) } type uiModuleInstallHooks struct { - initwd.ModuleInstallHooksImpl + initwd.ModuleInstallHookImpl Ui cli.Ui ShowLocalPaths bool View view } -var _ initwd.ModuleInstallHooks = uiModuleInstallHooks{} +var _ initwd.ModuleInstallHook = uiModuleInstallHooks{} func (h uiModuleInstallHooks) Download(modulePath, packageAddr string, v *version.Version) { if v != nil { diff --git a/internal/command/init.go b/internal/command/init.go index 73be79d1fcd4..077ddb46fa21 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -33,6 +33,9 @@ import ( "github.com/hashicorp/terraform/internal/getproviders" "github.com/hashicorp/terraform/internal/getproviders/providerreqs" "github.com/hashicorp/terraform/internal/getproviders/reattach" + "github.com/hashicorp/terraform/internal/initwd" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/policy" "github.com/hashicorp/terraform/internal/providercache" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/tfdiags" @@ -76,7 +79,7 @@ func (c *InitCommand) Run(args []string) int { return c.run(initArgs, view) } -func (c *InitCommand) getModules(ctx context.Context, path, testsDir string, earlyRoot *configs.Module, upgrade bool, view views.Init) (output bool, abort bool, diags tfdiags.Diagnostics) { +func (c *InitCommand) getModules(ctx context.Context, path, testsDir string, earlyRoot *configs.Module, upgrade bool, view views.Init, policyClient policy.Client) (output bool, abort bool, policyResults *plans.PolicyResults, diags tfdiags.Diagnostics) { testModules := false // We can also have modules buried in test files. for _, file := range earlyRoot.Tests { for _, run := range file.Runs { @@ -88,7 +91,7 @@ func (c *InitCommand) getModules(ctx context.Context, path, testsDir string, ear if len(earlyRoot.ModuleCalls) == 0 && !testModules { // Nothing to do - return false, false, nil + return false, false, nil, nil } ctx, span := tracer.Start(ctx, "install modules", trace.WithAttributes( @@ -102,13 +105,24 @@ func (c *InitCommand) getModules(ctx context.Context, path, testsDir string, ear view.Output(views.InitializingModulesMessage) } - hooks := uiModuleInstallHooks{ + uiHook := uiModuleInstallHooks{ Ui: c.Ui, ShowLocalPaths: true, View: view, } + hooks := []initwd.ModuleInstallHook{uiHook} + var policyHook *policyModuleInstallHook + if policyClient != nil { + policyResults = plans.NewPolicyResults() + policyHook = &policyModuleInstallHook{ + client: policyClient, + rootModule: earlyRoot, + policyResults: policyResults, + } + hooks = append(hooks, policyHook) + } - installAbort, installDiags := c.installModules(ctx, path, testsDir, upgrade, false, hooks) + installAbort, installDiags := c.installModules(ctx, path, testsDir, upgrade, false, hooks...) diags = diags.Append(installDiags) // At this point, installModules may have generated error diags or been @@ -128,7 +142,7 @@ func (c *InitCommand) getModules(ctx context.Context, path, testsDir string, ear } } - return true, installAbort, diags + return true, installAbort, policyResults, diags } func (c *InitCommand) initCloud(ctx context.Context, root *configs.Module, extraConfig arguments.FlagNameValueSlice, viewType arguments.ViewType, view views.Init) (be backend.Backend, output bool, diags tfdiags.Diagnostics) { @@ -364,7 +378,7 @@ the backend configuration is present and valid. // The method downloads any missing providers that aren't already downloaded and then returns // dependency lock data based on the configuration. // The dependency lock file itself isn't updated here. -func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *configs.Config, upgrade bool, pluginDirs []string, flagLockfile string, view views.Init) (output bool, resultingLocks *depsfile.Locks, diags tfdiags.Diagnostics) { +func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *configs.Config, upgrade bool, pluginDirs []string, flagLockfile string, view views.Init, installerHook *providerInstallerHook) (output bool, resultingLocks *depsfile.Locks, diags tfdiags.Diagnostics) { ctx, span := tracer.Start(ctx, "install providers from config") defer span.End() @@ -422,6 +436,7 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config evts := c.prepareInstallerEvents(ctx, reqs, &diags, inst, view, views.InitializingProviderPluginFromConfigMessage, views.ReusingPreviousVersionInfo) ctx = evts.OnContext(ctx) + inst.SetHook(installerHook) mode := providercache.InstallNewProvidersOnly if upgrade { @@ -443,20 +458,19 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config // Determine which required providers are already downloaded, and download any // new providers or newer versions of providers - configLocks, err := inst.EnsureProviderVersions(ctx, previousLocks, reqs, mode) + configLocks, installErr := inst.EnsureProviderVersions(ctx, previousLocks, reqs, mode) if ctx.Err() == context.Canceled { diags = diags.Append(fmt.Errorf("Provider installation was canceled by an interrupt signal.")) - view.Diagnostics(diags) + view.Diagnostics(diags) // TODO: Why is the output viewed here? return true, nil, diags } - if err != nil { - // The errors captured in "err" should be redundant with what we + if installErr != nil { + // The errors captured in "installErr" should be redundant with what we // received via the InstallerEvents callbacks above, so we'll // just return those as long as we have some. if !diags.HasErrors() { - diags = diags.Append(err) + diags = diags.Append(installErr) } - return true, nil, diags } @@ -469,7 +483,7 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config // The calling code is assumed to have already called getProvidersFromConfig, which is used to // supply the configLocks argument. // The dependency lock file itself isn't updated here. -func (c *InitCommand) getProvidersFromState(ctx context.Context, state *states.State, configReqs providerreqs.Requirements, configLocks *depsfile.Locks, upgrade bool, pluginDirs []string, flagLockfile string, view views.Init) (output bool, resultingLocks *depsfile.Locks, diags tfdiags.Diagnostics) { +func (c *InitCommand) getProvidersFromState(ctx context.Context, state *states.State, configReqs providerreqs.Requirements, configLocks *depsfile.Locks, upgrade bool, pluginDirs []string, flagLockfile string, view views.Init, installerHook *providerInstallerHook) (output bool, resultingLocks *depsfile.Locks, diags tfdiags.Diagnostics) { ctx, span := tracer.Start(ctx, "install providers from state") defer span.End() @@ -547,6 +561,7 @@ func (c *InitCommand) getProvidersFromState(ctx context.Context, state *states.S // are shimming our vt100 output to the legacy console API on Windows. evts := c.prepareInstallerEvents(ctx, reqs, &diags, inst, view, views.InitializingProviderPluginFromStateMessage, views.ReusingVersionIdentifiedFromConfig) ctx = evts.OnContext(ctx) + inst.SetHook(installerHook) mode := providercache.InstallNewProvidersOnly diff --git a/internal/command/init_policy_test.go b/internal/command/init_policy_test.go new file mode 100644 index 000000000000..dad3906b4b51 --- /dev/null +++ b/internal/command/init_policy_test.go @@ -0,0 +1,447 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "context" + "slices" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/cli" + "google.golang.org/protobuf/testing/protocmp" + + "github.com/hashicorp/terraform/internal/policy" + "github.com/hashicorp/terraform/internal/policy/proto" +) + +func TestInit_WithModulePolicy(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath("dynamic-module-sources/local-source-with-variable"), td) + t.Chdir(td) + + ui := new(cli.MockUi) + view, done := testView(t) + + overrides := metaOverridesForProvider(testProvider()) + policyClient := policy.NewTestMockClient(t) + overrides.PolicyClient = policyClient + + c := &InitCommand{ + Meta: Meta{ + testingOverrides: overrides, + Ui: ui, + View: view, + AllowExperimentalFeatures: true, + }, + } + + code := c.Run([]string{"-policies", td, "-var", "module_name=example"}) + output := done(t) + if code != 0 { + t.Fatalf("got exit status %d; want 0\nstderr:\n%s\n\nstdout:\n%s", code, output.Stderr(), output.Stdout()) + } + + if !policyClient.EvaluateModuleCalled { + t.Fatal("expected EvaluateModule to be called") + } + + if got, want := policyClient.EvaluateModuleRequest.Target, "./modules/example"; got != want { + t.Fatalf("wrong module policy target\ngot: %q\nwant: %q", got, want) + } + + if policyClient.EvaluateModuleRequest.Meta == nil { + t.Fatal("expected module metadata to be set") + } + + if got, want := policyClient.EvaluateModuleRequest.Meta.Address, "module.example"; got != want { + t.Fatalf("wrong module address\ngot: %q\nwant: %q", got, want) + } + + if got, want := policyClient.EvaluateModuleRequest.Meta.Source, "./modules/example"; got != want { + t.Fatalf("wrong module source\ngot: %q\nwant: %q", got, want) + } + + if got, want := policyClient.EvaluateModuleRequest.Meta.Version, ""; got != want { + t.Fatalf("wrong module version\ngot: %q\nwant: %q", got, want) + } +} + +func TestInit_WithModulePolicyDiagnostics(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath("dynamic-module-sources/local-source-with-variable"), td) + t.Chdir(td) + + ui := new(cli.MockUi) + view, done := testView(t) + + overrides := metaOverridesForProvider(testProvider()) + policyClient := policy.NewTestMockClient(t) + policyClient.EvaluateModuleResponse = &policy.EvaluationResponse{ + Overall: policy.DenyResult, + Diagnostics: policy.Diagnostics{ + policy.DiagsFromProto([]*proto.Diagnostic{ + { + Severity: proto.Severity_ERROR, + Summary: "module policy denied", + Result: &proto.DiagnosticResult{ + Result: proto.EvaluateResult_DENY_EVALUATE_RESULT, + }, + }, + }, &policy.Policy{ + Address: "module_policy.example", + Filename: "policy_file.tfpolicy.hcl", + EnforcementLevel: "mandatory", + Result: policy.DenyResult, + })[0], + }, + Policies: []*policy.Policy{ + { + Address: "module_policy.example", + Filename: "policy_file.tfpolicy.hcl", + EnforcementLevel: "mandatory", + Result: policy.DenyResult, + }, + }, + } + overrides.PolicyClient = policyClient + + c := &InitCommand{ + Meta: Meta{ + testingOverrides: overrides, + Ui: ui, + View: view, + AllowExperimentalFeatures: true, + }, + } + + code := c.Run([]string{"-policies", td, "-var", "module_name=example", "-no-color"}) + output := done(t) + if code != 1 { + t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, output.Stderr(), output.Stdout()) + } + + if !policyClient.EvaluateModuleCalled { + t.Fatal("expected EvaluateModule to be called") + } + + stderr := output.Stderr() + expected := ` +Error: module policy denied + + on main.tf line 6: + 6: module "example" { + + +Error: Policy evaluation failed + +Module download blocked due to policy violations. Please review other +diagnostics for details. +` + if diff := cmp.Diff(expected, stderr); diff != "" { + t.Fatalf("unexpected stderr:\n%s", diff) + } +} + +func TestInit_WithModulePolicyJSON(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath("dynamic-module-sources/local-source-with-variable"), td) + t.Chdir(td) + + ui := new(cli.MockUi) + view, done := testView(t) + + overrides := metaOverridesForProvider(testProvider()) + policyClient := policy.NewTestMockClient(t) + resp := policy.EvaluationFromProtoResponse( + proto.EvaluateResult_DENY_EVALUATE_RESULT, + []*proto.PolicyEvaluationDetail{ + { + Address: "module_policy.example", + Result: proto.EvaluateResult_DENY_EVALUATE_RESULT, + File: "policy_file.tfpolicy.hcl", + PolicySetEnforcement: "mandatory", + Diagnostics: []*proto.Diagnostic{ + { + Severity: proto.Severity_ERROR, + Summary: "module policy denied", + Result: &proto.DiagnosticResult{ + Result: proto.EvaluateResult_DENY_EVALUATE_RESULT, + }, + }, + }, + }, + }, + ) + policyClient.EvaluateModuleResponse = &resp + overrides.PolicyClient = policyClient + + c := &InitCommand{ + Meta: Meta{ + testingOverrides: overrides, + Ui: ui, + View: view, + AllowExperimentalFeatures: true, + }, + } + + code := c.Run([]string{"-policies", td, "-var", "module_name=example", "-no-color", "-json"}) + output := done(t) + if code != 1 { + t.Fatalf("got exit status %d; want 0\nstderr:\n%s\n\nstdout:\n%s", code, output.Stderr(), output.Stdout()) + } + + if !policyClient.EvaluateModuleCalled { + t.Fatal("expected EvaluateModule to be called") + } + + expected := `{"@level":"info","@message":"Terraform 1.15.0-dev","@module":"terraform.ui","terraform":"1.15.0-dev","type":"version","ui":"1.3"} +{"@level":"info","@message":"Initializing modules...","@module":"terraform.ui","message_code":"initializing_modules_message","type":"init_output"} +{"@level":"error","@message":"Error: module policy denied","@module":"terraform.ui","@policy":"true","policy_diagnostic":{"severity":"error","summary":"module policy denied","detail":"","range":{"filename":"main.tf","start":{"line":6,"column":1,"byte":60},"end":{"line":6,"column":17,"byte":76}},"snippet":{"context":null,"code":"module \"example\" {","start_line":6,"highlight_start_offset":0,"highlight_end_offset":16,"values":[]}},"policy_metadata":{"policy_set_path":"policy_file.tfpolicy.hcl","policy_name":"module_policy.example","file_name":".","enforcement_level":"mandatory"},"result":"DenyResult","target_address":"module.example","type":"policy_diagnostic"} +{"@level":"info","@message":"Policy Result","@module":"terraform.ui","@policy":"true","target_address":"module.example","policy_address":"module_policy.example","policy_metadata":{"policy_set_path":"policy_file.tfpolicy.hcl","policy_name":"module_policy.example","file_name":".","enforcement_level":"mandatory"},"result":"DenyResult","type":"policy_result"} +{"@level":"error","@message":"Error: Policy evaluation failed","@module":"terraform.ui","diagnostic":{"severity":"error","summary":"Policy evaluation failed","detail":"Module download blocked due to policy violations. Please review other diagnostics for details."},"type":"diagnostic"}` + checkGoldenReferenceStr(t, output, expected) +} + +func TestInit_WithProviderPolicy(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath("init-nested-provider-requirements"), td) + t.Chdir(td) + + providerSource := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.2.3", "1.2.4"}, + "tf.example.com/awesomecorp/happycloud": {"1.0.0"}, + "hashicorp/null": {"2.0.1"}, + "hashicorp/grandchild": {"1.0.0"}, + }) + + ui := new(cli.MockUi) + view, done := testView(t) + + overrides := metaOverridesForProvider(testProvider()) + policyClient := policy.NewTestMockClient(t) + actualTargets := []string{} + expectedTargets := []string{"test", "happycloud", "null", "grandchild"} + + policyClient.EvaluateProviderFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ProviderMetadata]) policy.EvaluationResponse { + actualTargets = append(actualTargets, req.Target) + var expected *proto.ProviderMetadata + switch req.Target { + case "test": + expected = &proto.ProviderMetadata{ + Name: "test", + Namespace: "hashicorp", + Type: "test", + Source: "registry.terraform.io/hashicorp/test", + Version: "1.2.3", + ModulePath: "", + } + case "happycloud": + expected = &proto.ProviderMetadata{ + Name: "happycloud", + Namespace: "awesomecorp", + Type: "happycloud", + Source: "tf.example.com/awesomecorp/happycloud", + Version: "1.0.0", + ModulePath: "./modules/child", + } + case "null": + expected = &proto.ProviderMetadata{ + Name: "null", + Namespace: "hashicorp", + Type: "null", + Source: "registry.terraform.io/hashicorp/null", + Version: "2.0.1", + ModulePath: "./modules/child", + } + case "grandchild": + expected = &proto.ProviderMetadata{ + Name: "grandchild", + Namespace: "hashicorp", + Type: "grandchild", + Source: "registry.terraform.io/hashicorp/grandchild", + Version: "1.0.0", + ModulePath: "./grandchild", + } + } + if diff := cmp.Diff(expected, req.Meta, protocmp.Transform()); diff != "" { + t.Fatalf("wrong provider metadata\ngot: %s\nwant: %v", diff, expected) + } + + t.Cleanup(func() { + if !slices.Contains(expectedTargets, req.Target) { + t.Errorf("expected target %q to be in %v", req.Target, expectedTargets) + } + }) + + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + overrides.PolicyClient = policyClient + + c := &InitCommand{ + Meta: Meta{ + testingOverrides: overrides, + Ui: ui, + View: view, + ProviderSource: providerSource, + AllowExperimentalFeatures: true, + }, + } + + code := c.Run([]string{"-policies", td}) + output := done(t) + if code != 0 { + t.Fatalf("got exit status %d; want 0\nstderr:\n%s\n\nstdout:\n%s", code, output.Stderr(), output.Stdout()) + } + + if !policyClient.EvaluateProviderCalled { + t.Fatal("expected EvaluateProvider to be called") + } +} + +func TestInit_WithProviderPolicyDiagnostics(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath("init-nested-provider-requirements"), td) + t.Chdir(td) + + providerSource := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.2.3", "1.2.4"}, + "tf.example.com/awesomecorp/happycloud": {"1.0.0"}, + "hashicorp/null": {"2.0.1"}, + "hashicorp/grandchild": {"1.0.0"}, + }) + + ui := new(cli.MockUi) + view, done := testView(t) + + overrides := metaOverridesForProvider(testProvider()) + policyClient := policy.NewTestMockClient(t) + resp := policy.EvaluationFromProtoResponse( + proto.EvaluateResult_DENY_EVALUATE_RESULT, + []*proto.PolicyEvaluationDetail{ + { + Address: "provider_policy.example", + Result: proto.EvaluateResult_DENY_EVALUATE_RESULT, + File: "policy_file.tfpolicy.hcl", + PolicySetEnforcement: "mandatory", + Diagnostics: []*proto.Diagnostic{ + { + Severity: proto.Severity_ERROR, + Summary: "provider policy denied", + Result: &proto.DiagnosticResult{ + Result: proto.EvaluateResult_DENY_EVALUATE_RESULT, + }, + }, + }, + }, + }, + ) + policyClient.EvaluateProviderResponse = &resp + overrides.PolicyClient = policyClient + + c := &InitCommand{ + Meta: Meta{ + testingOverrides: overrides, + Ui: ui, + View: view, + ProviderSource: providerSource, + AllowExperimentalFeatures: true, + }, + } + + code := c.Run([]string{"-policies", td, "-no-color"}) + output := done(t) + if code != 1 { + t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, output.Stderr(), output.Stdout()) + } + + if !policyClient.EvaluateProviderCalled { + t.Fatal("expected EvaluateProvider to be called") + } + + stderr := output.Stderr() + expected := ` +Error: provider policy denied + + +Error: Provider download failed due to policy violations. Please review other diagnostics for details. + +` + if diff := cmp.Diff(expected, stderr); diff != "" { + t.Fatalf("unexpected stderr:\n%s", diff) + } +} + +func TestInit_WithProviderPolicyJSON(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath("init-nested-provider-requirements"), td) + t.Chdir(td) + + providerSource := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.2.3", "1.2.4"}, + "awesomecorp/happycloud": {"1.0.0"}, + "hashicorp/null": {"2.0.1"}, + "hashicorp/grandchild": {"1.0.0"}, + }) + + ui := new(cli.MockUi) + view, done := testView(t) + + overrides := metaOverridesForProvider(testProvider()) + policyClient := policy.NewTestMockClient(t) + resp := policy.EvaluationFromProtoResponse( + proto.EvaluateResult_DENY_EVALUATE_RESULT, + []*proto.PolicyEvaluationDetail{ + { + Address: "provider_policy.example", + Result: proto.EvaluateResult_DENY_EVALUATE_RESULT, + File: "policy_file.tfpolicy.hcl", + PolicySetEnforcement: "mandatory", + Diagnostics: []*proto.Diagnostic{ + { + Severity: proto.Severity_ERROR, + Summary: "provider policy denied", + Result: &proto.DiagnosticResult{ + Result: proto.EvaluateResult_DENY_EVALUATE_RESULT, + }, + }, + }, + }, + }, + ) + policyClient.EvaluateProviderResponse = &resp + overrides.PolicyClient = policyClient + + c := &InitCommand{ + Meta: Meta{ + testingOverrides: overrides, + Ui: ui, + View: view, + ProviderSource: providerSource, + AllowExperimentalFeatures: true, + }, + } + + code := c.Run([]string{"-policies", td, "-no-color", "-json"}) + output := done(t) + if code != 1 { + t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, output.Stderr(), output.Stdout()) + } + + if !policyClient.EvaluateProviderCalled { + t.Fatal("expected EvaluateProvider to be called") + } + + allOutput := strings.SplitSeq(output.Stdout(), "\n") + var foundPolicyDiagnostic bool + for line := range allOutput { + if strings.Contains(line, `"type":"policy_diagnostic"`) { + foundPolicyDiagnostic = strings.Contains(line, "provider policy denied") + } + } + if !foundPolicyDiagnostic { + t.Fatal("expected diagnostic output") + } +} diff --git a/internal/command/init_run.go b/internal/command/init_run.go index 9840018fe9df..583f8be87349 100644 --- a/internal/command/init_run.go +++ b/internal/command/init_run.go @@ -14,6 +14,8 @@ import ( "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/views" "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/terraform" "github.com/hashicorp/terraform/internal/tfdiags" @@ -34,6 +36,7 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { c.Meta.input = initArgs.InputEnabled c.Meta.targetFlags = initArgs.TargetFlags c.Meta.compactWarnings = initArgs.CompactWarnings + c.Meta.policyPaths = initArgs.PolicyPaths // Copying the state only happens during backend migration, so setting // -force-copy implies -migrate-state @@ -163,9 +166,19 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { return 1 } + var client policy.Client + if len(initArgs.PolicyPaths) > 0 { + var policyDiags policy.Diagnostics + client, policyDiags = c.PolicyClient(ctx, initArgs.PolicyPaths) + diags = diags.Append(policyDiags.AsTerraformDiags()) + } + if initArgs.Get { - modsOutput, modsAbort, modsDiags := c.getModules(ctx, path, initArgs.TestsDirectory, rootModEarly, initArgs.Upgrade, view) + modsOutput, modsAbort, policyResults, modsDiags := c.getModules(ctx, path, initArgs.TestsDirectory, rootModEarly, initArgs.Upgrade, view, client) diags = diags.Append(modsDiags) + if policyResults != nil { + view.PolicyResults(policyResults) + } if modsAbort || modsDiags.HasErrors() { view.Diagnostics(diags) return 1 @@ -210,9 +223,23 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { previousLocks, moreDiags := c.lockedDependencies() diags = diags.Append(moreDiags) - configProvidersOutput, configLocks, configProviderDiags := c.getProvidersFromConfig(ctx, config, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view) + reqsByModule, reqDiags := config.ProviderRequirementsByModule() + if reqDiags.HasErrors() { + view.Diagnostics(diags.Append(reqDiags)) + return 1 + } + policyResults := plans.NewPolicyResults() + providerHook := &providerInstallerHook{ + Client: client, + Reqs: reqsByModule, + policyResults: policyResults, + config: config, + } + + configProvidersOutput, configLocks, configProviderDiags := c.getProvidersFromConfig(ctx, config, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view, providerHook) diags = diags.Append(configProviderDiags) if configProviderDiags.HasErrors() { + view.PolicyResults(policyResults) view.Diagnostics(diags) return 1 } @@ -332,7 +359,7 @@ If you do not intend to upgrade the state store provider, please update your con view.Diagnostics(diags) return 1 } - stateProvidersOutput, stateLocks, stateProvidersDiags := c.getProvidersFromState(ctx, state, configReqs, configLocks, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view) + stateProvidersOutput, stateLocks, stateProvidersDiags := c.getProvidersFromState(ctx, state, configReqs, configLocks, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view, providerHook) diags = diags.Append(stateProvidersDiags) if stateProvidersDiags.HasErrors() { view.Diagnostics(diags) @@ -376,6 +403,7 @@ If you do not intend to upgrade the state store provider, please update your con // If we accumulated any warnings along the way that weren't accompanied // by errors then we'll output them here so that the success message is // still the final thing shown. + view.PolicyResults(policyResults) view.Diagnostics(diags) _, cloud := back.(*cloud.Cloud) output := views.OutputInitSuccessMessage diff --git a/internal/command/meta_config.go b/internal/command/meta_config.go index c476378ff1b5..685eed17bc47 100644 --- a/internal/command/meta_config.go +++ b/internal/command/meta_config.go @@ -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), )) diff --git a/internal/command/meta_policy.go b/internal/command/meta_policy.go index 736e125ffb61..aaa84f97730a 100644 --- a/internal/command/meta_policy.go +++ b/internal/command/meta_policy.go @@ -7,11 +7,20 @@ 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" ) @@ -104,3 +113,123 @@ func (c *Meta) PolicyClient(ctx context.Context, policyPaths []string) (policy.C 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 +} diff --git a/internal/command/test_test.go b/internal/command/test_test.go index 5ece6a07124f..3272eb80b161 100644 --- a/internal/command/test_test.go +++ b/internal/command/test_test.go @@ -418,11 +418,6 @@ func TestTest_Runs(t *testing.T) { "no-tests": { code: 0, }, - "simple_pass_function": { - expectedOut: []string{"2 passed, 0 failed."}, - code: 0, - expectedResourceCount: 0, - }, "mocking-invalid-outputs": { expectedErr: []string{ "Invalid outputs attribute", @@ -5969,7 +5964,7 @@ func testModuleInline(t *testing.T, sources map[string]string) (*configs.Config, // sources only this ultimately just records all of the module paths // in a JSON file so that we can load them below. inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) - _, instDiags := inst.InstallModules(context.Background(), cfgPath, "tests", true, false, initwd.ModuleInstallHooksImpl{}) + _, instDiags := inst.InstallModules(context.Background(), cfgPath, "tests", true, false) if instDiags.HasErrors() { t.Fatal(instDiags.Err()) } diff --git a/internal/command/testdata/init-nested-provider-requirements/main.tf b/internal/command/testdata/init-nested-provider-requirements/main.tf new file mode 100644 index 000000000000..10d1dbcfc4a7 --- /dev/null +++ b/internal/command/testdata/init-nested-provider-requirements/main.tf @@ -0,0 +1,14 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.2.3" + } + } +} + +provider "test" {} + +module "child" { + source = "./modules/child" +} diff --git a/internal/command/testdata/init-nested-provider-requirements/modules/child/grandchild/main.tf b/internal/command/testdata/init-nested-provider-requirements/modules/child/grandchild/main.tf new file mode 100644 index 000000000000..5ac9dfab4db1 --- /dev/null +++ b/internal/command/testdata/init-nested-provider-requirements/modules/child/grandchild/main.tf @@ -0,0 +1,4 @@ +# There is no provider in required_providers called "grandchild", so this +# implicitly declares a dependency on "hashicorp/grandchild". +resource "grandchild_foo" "bar" { +} diff --git a/internal/command/testdata/init-nested-provider-requirements/modules/child/main.tf b/internal/command/testdata/init-nested-provider-requirements/modules/child/main.tf new file mode 100644 index 000000000000..5b7cc384ef5a --- /dev/null +++ b/internal/command/testdata/init-nested-provider-requirements/modules/child/main.tf @@ -0,0 +1,14 @@ +terraform { + required_providers { + cloud = { + source = "tf.example.com/awesomecorp/happycloud" + } + null = { + version = "2.0.1" + } + } +} + +module "nested" { + source = "./grandchild" +} diff --git a/internal/command/views/init.go b/internal/command/views/init.go index ce4f1598cbc6..baaf661d7c46 100644 --- a/internal/command/views/init.go +++ b/internal/command/views/init.go @@ -8,12 +8,14 @@ import ( "strings" "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/tfdiags" ) // The Init view is used for the init command. type Init interface { Diagnostics(diags tfdiags.Diagnostics) + PolicyResults(results *plans.PolicyResults) Output(messageCode InitMessageCode, params ...any) LogInitMessage(messageCode InitMessageCode, params ...any) Log(message string, params ...any) @@ -48,6 +50,10 @@ func (v *InitHuman) Diagnostics(diags tfdiags.Diagnostics) { v.view.Diagnostics(diags) } +func (v *InitHuman) PolicyResults(results *plans.PolicyResults) { + v.view.PolicyResults(results) +} + func (v *InitHuman) Output(messageCode InitMessageCode, params ...any) { v.view.streams.Println(v.PrepareMessage(messageCode, params...)) } @@ -88,6 +94,10 @@ func (v *InitJSON) Diagnostics(diags tfdiags.Diagnostics) { v.view.Diagnostics(diags) } +func (v *InitJSON) PolicyResults(results *plans.PolicyResults) { + v.view.PolicyResults(results) +} + func (v *InitJSON) Output(messageCode InitMessageCode, params ...any) { // don't add empty messages to json output preppedMessage := v.PrepareMessage(messageCode, params...) diff --git a/internal/command/views/init_test.go b/internal/command/views/init_test.go index 13847a5068d5..0810145b46aa 100644 --- a/internal/command/views/init_test.go +++ b/internal/command/views/init_test.go @@ -10,7 +10,11 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/command/arguments" + viewjson "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/terminal" "github.com/hashicorp/terraform/internal/tfdiags" tfversion "github.com/hashicorp/terraform/version" @@ -120,6 +124,157 @@ func getTestDiags(t *testing.T) tfdiags.Diagnostics { return diags } +func TestNewInit_jsonViewPolicyResults(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + + newInit := NewInit(arguments.ViewJSON, NewView(streams).SetRunningInAutomation(true)) + if _, ok := newInit.(*InitJSON); !ok { + t.Fatalf("unexpected return type %t", newInit) + } + + results := plans.NewPolicyResults() + results.AddModule( + addrs.RootModule.Child("example"), + policy.EvaluationResponse{ + Overall: policy.DenyResult, + Diagnostics: policy.Diagnostics{ + policy.NewErrorDiagnostic( + "module policy denied", + "module policy blocked installation", + policy.DenyResult, + ), + }, + Policies: []*policy.Policy{ + { + Address: "module_policy.example", + Filename: "policy_file.tfpolicy.hcl", + EnforcementLevel: "mandatory", + Result: policy.DenyResult, + }, + }, + }, + nil, + ) + + newInit.PolicyResults(results) + + version := tfversion.String() + want := []map[string]interface{}{ + { + "@level": "info", + "@message": fmt.Sprintf("Terraform %s", version), + "@module": "terraform.ui", + "terraform": version, + "type": "version", + "ui": JSON_UI_VERSION, + }, + { + "@level": "error", + "@message": "Error: module policy denied", + "@module": "terraform.ui", + "@policy": "true", + "policy_diagnostic": map[string]interface{}{ + "severity": "error", + "summary": "module policy denied", + "detail": "module policy blocked installation", + }, + "policy_metadata": map[string]interface{}{}, + "result": policy.DenyResult.String(), + "target_address": "module.example", + "type": string(viewjson.MessagePolicyDiagnostic), + }, + { + "@level": "info", + "@message": "Policy Result", + "@module": "terraform.ui", + "@policy": "true", + "target_address": "module.example", + "policy_address": "module_policy.example", + "policy_metadata": map[string]interface{}{ + "policy_name": "module_policy.example", + "file_name": "policy_file.tfpolicy.hcl", + "enforcement_level": "mandatory", + }, + "result": policy.DenyResult.String(), + "type": string(viewjson.MessagePolicyEvaluationResult), + }, + } + + actual := done(t).Stdout() + testJSONViewOutputEqualsFull(t, actual, want) +} + +func TestNewInit_humanViewPolicyResults(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + + newInit := NewInit(arguments.ViewHuman, NewView(streams).SetRunningInAutomation(true)) + if _, ok := newInit.(*InitHuman); !ok { + t.Fatalf("unexpected return type %t", newInit) + } + + results := plans.NewPolicyResults() + results.AddModule( + addrs.RootModule.Child("example"), + policy.EvaluationResponse{ + Overall: policy.DenyResult, + Diagnostics: policy.Diagnostics{ + policy.NewErrorDiagnostic( + "module policy denied", + "module policy blocked installation", + policy.DenyResult, + ), + }, + }, + nil, + ) + + newInit.PolicyResults(results) + + actual := done(t).All() + expected := "\nError: module policy denied\n\nmodule policy blocked installation\n" + if !strings.Contains(actual, expected) { + t.Fatalf("expected output to contain: %s, but got %s", expected, actual) + } +} + +func TestNewInit_humanViewPolicyResults_infoWithoutSnippet(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + + newInit := NewInit(arguments.ViewHuman, NewView(streams).SetRunningInAutomation(true)) + if _, ok := newInit.(*InitHuman); !ok { + t.Fatalf("unexpected return type %t", newInit) + } + + results := plans.NewPolicyResults() + results.AddModule( + addrs.RootModule.Child("example"), + policy.EvaluationResponse{ + Overall: policy.AllowResult, + Enforcements: []policy.EnforcementResult{{ + Result: policy.AllowResult, + Message: "module policy allowed installation", + Policy: &policy.Policy{ + Address: "module_policy.example", + }, + }}, + }, + nil, + ) + + newInit.PolicyResults(results) + + actual := done(t).Stdout() + if !strings.Contains(actual, "Policy Info:") { + t.Fatalf("expected output to contain policy info header, but got %s", actual) + } + if !strings.Contains(actual, "module policy allowed installation") { + t.Fatalf("expected output to contain policy message, but got %s", actual) + } + if !strings.Contains(actual, "module_policy.example") { + t.Fatalf("expected output to contain policy address fallback, but got %s", actual) + } +} + func TestNewInit_jsonViewOutput(t *testing.T) { t.Run("no param", func(t *testing.T) { streams, done := terminal.StreamsForTesting(t) diff --git a/internal/initwd/from_module.go b/internal/initwd/from_module.go index 01742a141f2f..471ca1aaa47f 100644 --- a/internal/initwd/from_module.go +++ b/internal/initwd/from_module.go @@ -48,7 +48,7 @@ const initFromModuleRootKeyPrefix = initFromModuleRootCallName + "." // references using ../ from that module to be unresolvable. Error diagnostics // are produced in that case, to prompt the user to rewrite the source strings // to be absolute references to the original remote module. -func DirFromModule(ctx context.Context, loader *configload.Loader, rootDir, modulesDir, sourceAddrStr string, reg *registry.Client, hooks ModuleInstallHooks) tfdiags.Diagnostics { +func DirFromModule(ctx context.Context, loader *configload.Loader, rootDir, modulesDir, sourceAddrStr string, reg *registry.Client, hooks ...ModuleInstallHook) tfdiags.Diagnostics { var diags tfdiags.Diagnostics @@ -94,7 +94,7 @@ func DirFromModule(ctx context.Context, loader *configload.Loader, rootDir, modu } instDir := filepath.Join(rootDir, ".terraform/init-from-module") - inst := NewModuleInstaller(instDir, loader, reg, nil) + inst := NewModuleInstaller(instDir, loader, reg, nil, hooks...) log.Printf("[DEBUG] installing modules in %s to initialize working directory from %q", instDir, sourceAddrStr) os.RemoveAll(instDir) // if this fails then we'll fail on MkdirAll below too err := os.MkdirAll(instDir, os.ModePerm) @@ -148,8 +148,10 @@ func DirFromModule(ctx context.Context, loader *configload.Loader, rootDir, modu // wrapHooks filters hook notifications to only include Download calls // and to trim off the initFromModuleRootCallName prefix. We'll produce // our own Install notifications directly below. - wrapHooks := installHooksInitDir{ - Wrapped: hooks, + inst.hooks = []ModuleInstallHook{ + &installHooksInitDir{ + Wrapped: hooks, + }, } // Create a manifest record for the root module. This will be used if // there are any relative-pathed modules in the root. @@ -159,7 +161,7 @@ func DirFromModule(ctx context.Context, loader *configload.Loader, rootDir, modu } fetcher := getmodules.NewPackageFetcher() - walker := inst.moduleInstallWalker(ctx, instManifest, true, wrapHooks, fetcher) + walker := inst.moduleInstallWalker(ctx, instManifest, true, fetcher) _, cDiags := inst.installDescendantModules(fakeRootModule, walker, true) if cDiags.HasErrors() { return diags.Append(cDiags) @@ -347,7 +349,10 @@ func DirFromModule(ctx context.Context, loader *configload.Loader, rootDir, modu newRecord.Dir = newDir newRecord.Key = newKey retManifest[newKey] = newRecord - hooks.Install(newRecord.Key, newRecord.Version, newRecord.Dir) + // Call the original hooks here, not the wrapper + for _, hook := range hooks { + hook.Install(newRecord.Key, newRecord.Version, newRecord.Dir) + } continue } @@ -385,7 +390,10 @@ func DirFromModule(ctx context.Context, loader *configload.Loader, rootDir, modu newRecord.Dir = filepath.Join(instPath, subDir) newRecord.Key = newKey retManifest[newKey] = newRecord - hooks.Install(newRecord.Key, newRecord.Version, newRecord.Dir) + // Call the original hooks here, not the wrapper + for _, hook := range hooks { + hook.Install(newRecord.Key, newRecord.Version, newRecord.Dir) + } } retManifest.WriteSnapshotToDir(modulesDir) @@ -418,8 +426,19 @@ func pathTraversesUp(path string) bool { // does its own installation steps after the initial installation pass // has completed. type installHooksInitDir struct { - Wrapped ModuleInstallHooks - ModuleInstallHooksImpl + Wrapped []ModuleInstallHook + ModuleInstallHookImpl +} + +// EvaluatePolicy calls EvaluatePolicy on each wrapped hook and returns the first error it encounters. +func (h installHooksInitDir) EvaluatePolicy(ctx context.Context, req *configs.ModuleRequest, source, version string) tfdiags.Diagnostics { + for _, hook := range h.Wrapped { + diags := hook.EvaluatePolicy(ctx, req, source, version) + if diags.HasErrors() { + return diags + } + } + return nil } func (h installHooksInitDir) Download(moduleAddr, packageAddr string, version *version.Version) { @@ -431,5 +450,7 @@ func (h installHooksInitDir) Download(moduleAddr, packageAddr string, version *v } trimAddr := moduleAddr[len(initFromModuleRootKeyPrefix):] - h.Wrapped.Download(trimAddr, packageAddr, version) + for _, hook := range h.Wrapped { + hook.Download(trimAddr, packageAddr, version) + } } diff --git a/internal/initwd/from_module_test.go b/internal/initwd/from_module_test.go index 690f142c780c..eda2a660ed47 100644 --- a/internal/initwd/from_module_test.go +++ b/internal/initwd/from_module_test.go @@ -171,6 +171,21 @@ func TestDirFromModule_submodules(t *testing.T) { diags := DirFromModule(context.Background(), loader, dir, modInstallDir, fromModuleDir, nil, hooks) tfdiags.AssertNoDiagnostics(t, diags) wantCalls := []testInstallHookCall{ + { + Name: "EvaluatePolicy", + ModuleAddr: "root", + PackageAddr: "", + }, + { + Name: "EvaluatePolicy", + ModuleAddr: "root.child_a", + PackageAddr: "", + }, + { + Name: "EvaluatePolicy", + ModuleAddr: "root.child_a.child_b", + PackageAddr: "", + }, { Name: "Install", ModuleAddr: "child_a", @@ -304,6 +319,21 @@ func TestDirFromModule_rel_submodules(t *testing.T) { diags := DirFromModule(context.Background(), loader, ".", modInstallDir, sourceDir, nil, hooks) tfdiags.AssertNoDiagnostics(t, diags) wantCalls := []testInstallHookCall{ + { + Name: "EvaluatePolicy", + ModuleAddr: "root", + PackageAddr: "", + }, + { + Name: "EvaluatePolicy", + ModuleAddr: "root.child_a", + PackageAddr: "", + }, + { + Name: "EvaluatePolicy", + ModuleAddr: "root.child_a.child_b", + PackageAddr: "", + }, { Name: "Install", ModuleAddr: "child_a", diff --git a/internal/initwd/module_install.go b/internal/initwd/module_install.go index 1a4992d42c67..0c9505d32da9 100644 --- a/internal/initwd/module_install.go +++ b/internal/initwd/module_install.go @@ -46,6 +46,7 @@ type ModuleInstaller struct { registryPackageSources map[moduleVersion]addrs.ModuleSourceRemote initializer Initializer + hooks []ModuleInstallHook } type moduleVersion struct { @@ -53,13 +54,18 @@ type moduleVersion struct { version string } -func NewModuleInstaller(modsDir string, loader *configload.Loader, reg *registry.Client, initializer Initializer) *ModuleInstaller { +func NewModuleInstaller(modsDir string, loader *configload.Loader, reg *registry.Client, initializer Initializer, hooks ...ModuleInstallHook) *ModuleInstaller { + if len(hooks) == 0 { + // Use our no-op implementation as a placeholder + hooks = []ModuleInstallHook{ModuleInstallHookImpl{}} + } return &ModuleInstaller{ modsDir: modsDir, loader: loader, reg: reg, registryPackageVersions: make(map[addrs.ModuleRegistryPackage]*response.ModuleVersions), registryPackageSources: make(map[moduleVersion]addrs.ModuleSourceRemote), + hooks: hooks, initializer: initializer, } } @@ -98,7 +104,7 @@ func NewModuleInstaller(modsDir string, loader *configload.Loader, reg *registry // If successful (the returned diagnostics contains no errors) then the // first return value is the early configuration tree that was constructed by // the installation process. -func (i *ModuleInstaller) InstallModules(ctx context.Context, rootDir, testsDir string, upgrade, installErrsOnly bool, hooks ModuleInstallHooks) (*configs.Config, tfdiags.Diagnostics) { +func (i *ModuleInstaller) InstallModules(ctx context.Context, rootDir, testsDir string, upgrade, installErrsOnly bool) (*configs.Config, tfdiags.Diagnostics) { log.Printf("[TRACE] ModuleInstaller: installing child modules for %s into %s", rootDir, i.modsDir) var diags tfdiags.Diagnostics @@ -129,18 +135,13 @@ func (i *ModuleInstaller) InstallModules(ctx context.Context, rootDir, testsDir fetcher := getmodules.NewPackageFetcher() - if hooks == nil { - // Use our no-op implementation as a placeholder - hooks = ModuleInstallHooksImpl{} - } - // Create a manifest record for the root module. This will be used if // there are any relative-pathed modules in the root. manifest[""] = modsdir.Record{ Key: "", Dir: rootDir, } - walker := i.moduleInstallWalker(ctx, manifest, upgrade, hooks, fetcher) + walker := i.moduleInstallWalker(ctx, manifest, upgrade, fetcher) var cfg *configs.Config var instDiags tfdiags.Diagnostics @@ -171,7 +172,7 @@ func (i *ModuleInstaller) InstallModules(ctx context.Context, rootDir, testsDir return cfg, diags } -func (i *ModuleInstaller) moduleInstallWalker(ctx context.Context, manifest modsdir.Manifest, upgrade bool, hooks ModuleInstallHooks, fetcher *getmodules.PackageFetcher) configs.ModuleWalker { +func (i *ModuleInstaller) moduleInstallWalker(ctx context.Context, manifest modsdir.Manifest, upgrade bool, fetcher *getmodules.PackageFetcher) configs.ModuleWalker { return configs.ModuleWalkerFunc( func(req *configs.ModuleRequest) (*configs.Module, *version.Version, hcl.Diagnostics) { var diags hcl.Diagnostics @@ -294,20 +295,20 @@ func (i *ModuleInstaller) moduleInstallWalker(ctx context.Context, manifest mods case addrs.ModuleSourceLocal: log.Printf("[TRACE] ModuleInstaller: %s has local path %q", key, addr.String()) - mod, mDiags := i.installLocalModule(req, key, manifest, hooks) + mod, mDiags := i.installLocalModule(req, key, manifest) mDiags = maybeImproveLocalInstallError(req, mDiags) diags = append(diags, mDiags...) return mod, nil, diags case addrs.ModuleSourceRegistry: log.Printf("[TRACE] ModuleInstaller: %s is a registry module at %s", key, addr.String()) - mod, v, mDiags := i.installRegistryModule(ctx, req, key, instPath, addr, manifest, hooks, fetcher) + mod, v, mDiags := i.installRegistryModule(ctx, req, key, instPath, addr, manifest, fetcher) diags = append(diags, mDiags...) return mod, v, diags case addrs.ModuleSourceRemote: log.Printf("[TRACE] ModuleInstaller: %s address %q will be handled by go-getter", key, addr.String()) - mod, mDiags := i.installGoGetterModule(ctx, req, key, instPath, manifest, hooks, fetcher) + mod, mDiags := i.installGoGetterModule(ctx, req, key, instPath, manifest, fetcher) diags = append(diags, mDiags...) return mod, nil, diags @@ -363,7 +364,7 @@ func (i *ModuleInstaller) installDescendantModules(rootMod *configs.Module, inst return cfg, diags } -func (i *ModuleInstaller) installLocalModule(req *configs.ModuleRequest, key string, manifest modsdir.Manifest, hooks ModuleInstallHooks) (*configs.Module, hcl.Diagnostics) { +func (i *ModuleInstaller) installLocalModule(req *configs.ModuleRequest, key string, manifest modsdir.Manifest) (*configs.Module, hcl.Diagnostics) { var diags hcl.Diagnostics parentKey := manifest.ModuleKey(req.Parent.Path) @@ -382,6 +383,15 @@ func (i *ModuleInstaller) installLocalModule(req *configs.ModuleRequest, key str }) } + // Evaluate pre-plan policy for the matched version. + // if the policy fails, we should not proceed with installation. + policyDiags := i.CallHooks(func(hook ModuleInstallHook) tfdiags.Diagnostics { + return hook.EvaluatePolicy(context.TODO(), req, req.SourceAddr.String(), "") + }) + if policyDiags.HasErrors() { + return nil, diags.Extend(policyDiags.ToHCL()) + } + // For local sources we don't actually need to modify the // filesystem at all because the parent already wrote // the files we need, and so we just load up what's already here. @@ -425,12 +435,15 @@ func (i *ModuleInstaller) installLocalModule(req *configs.ModuleRequest, key str SourceAddr: req.SourceAddr.String(), } log.Printf("[DEBUG] Module installer: %s installed at %s", key, newDir) - hooks.Install(key, nil, newDir) + i.CallHooks(func(hook ModuleInstallHook) tfdiags.Diagnostics { + hook.Install(key, nil, newDir) + return nil + }) return mod, diags } -func (i *ModuleInstaller) installRegistryModule(ctx context.Context, req *configs.ModuleRequest, key string, instPath string, addr addrs.ModuleSourceRegistry, manifest modsdir.Manifest, hooks ModuleInstallHooks, fetcher *getmodules.PackageFetcher) (*configs.Module, *version.Version, hcl.Diagnostics) { +func (i *ModuleInstaller) installRegistryModule(ctx context.Context, req *configs.ModuleRequest, key string, instPath string, addr addrs.ModuleSourceRegistry, manifest modsdir.Manifest, fetcher *getmodules.PackageFetcher) (*configs.Module, *version.Version, hcl.Diagnostics) { var diags hcl.Diagnostics hostname := addr.Package.Host @@ -590,8 +603,21 @@ func (i *ModuleInstaller) installRegistryModule(ctx context.Context, req *config return nil, nil, diags } - // Report up to the caller that we're about to start downloading. - hooks.Download(key, packageAddr.String(), latestMatch) + tfDiags := i.CallHooks(func(hook ModuleInstallHook) tfdiags.Diagnostics { + // Evaluate pre-plan policy for the matched version. + // if the policy fails, we should not proceed with installation. + policyDiags := hook.EvaluatePolicy(ctx, req, packageAddr.String(), latestMatch.String()) + if policyDiags.HasErrors() { + return policyDiags + } + + // Report up to the caller that we're about to start downloading. + hook.Download(key, packageAddr.String(), latestMatch) + return nil + }) + if tfDiags.HasErrors() { + return nil, nil, diags.Extend(tfDiags.ToHCL()) + } // If we manage to get down here then we've found a suitable version to // install, so we need to ask the registry where we should download it from. @@ -734,18 +760,44 @@ func (i *ModuleInstaller) installRegistryModule(ctx context.Context, req *config SourceAddr: req.SourceAddr.String(), } log.Printf("[DEBUG] Module installer: %s installed at %s", key, modDir) - hooks.Install(key, latestMatch, modDir) + i.CallHooks(func(hook ModuleInstallHook) tfdiags.Diagnostics { + hook.Install(key, latestMatch, modDir) + return nil + }) return mod, latestMatch, diags } -func (i *ModuleInstaller) installGoGetterModule(ctx context.Context, req *configs.ModuleRequest, key string, instPath string, manifest modsdir.Manifest, hooks ModuleInstallHooks, fetcher *getmodules.PackageFetcher) (*configs.Module, hcl.Diagnostics) { +func (i *ModuleInstaller) CallHooks(fn func(ModuleInstallHook) tfdiags.Diagnostics) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + for _, hook := range i.hooks { + hookDiags := fn(hook) + if hookDiags.HasErrors() { + diags = diags.Append(hookDiags) + } + } + return diags +} + +func (i *ModuleInstaller) installGoGetterModule(ctx context.Context, req *configs.ModuleRequest, key string, instPath string, manifest modsdir.Manifest, fetcher *getmodules.PackageFetcher) (*configs.Module, hcl.Diagnostics) { var diags hcl.Diagnostics + // Evaluate pre-plan policy for the matched version. + // if the policy fails, we should not proceed with installation. + policyDiags := i.CallHooks(func(hook ModuleInstallHook) tfdiags.Diagnostics { + return hook.EvaluatePolicy(context.TODO(), req, req.SourceAddr.String(), "") + }) + if policyDiags.HasErrors() { + return nil, diags.Extend(policyDiags.ToHCL()) + } + // Report up to the caller that we're about to start downloading. addr := req.SourceAddr.(addrs.ModuleSourceRemote) packageAddr := addr.Package - hooks.Download(key, packageAddr.String(), nil) + i.CallHooks(func(hook ModuleInstallHook) tfdiags.Diagnostics { + hook.Download(key, packageAddr.String(), nil) + return nil + }) if len(req.VersionConstraint.Required) != 0 { diags = diags.Append(&hcl.Diagnostic{ @@ -834,7 +886,10 @@ func (i *ModuleInstaller) installGoGetterModule(ctx context.Context, req *config SourceAddr: req.SourceAddr.String(), } log.Printf("[DEBUG] Module installer: %s installed at %s", key, modDir) - hooks.Install(key, nil, modDir) + i.CallHooks(func(hook ModuleInstallHook) tfdiags.Diagnostics { + hook.Install(key, nil, modDir) + return nil + }) return mod, diags } diff --git a/internal/initwd/module_install_hooks.go b/internal/initwd/module_install_hooks.go index 8bf9049c5855..2767aa48316d 100644 --- a/internal/initwd/module_install_hooks.go +++ b/internal/initwd/module_install_hooks.go @@ -4,16 +4,21 @@ package initwd import ( + "context" + version "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/tfdiags" ) -// ModuleInstallHooks is an interface used to provide notifications about the +// ModuleInstallHook is an interface used to provide notifications about the // installation process being orchestrated by InstallModules. // // This interface may have new methods added in future, so implementers should // embed InstallHooksImpl to get no-op implementations of any unimplemented // methods. -type ModuleInstallHooks interface { +type ModuleInstallHook interface { + EvaluatePolicy(ctx context.Context, req *configs.ModuleRequest, source, version string) tfdiags.Diagnostics // Download is called for modules that are retrieved from a remote source // before that download begins, to allow a caller to give feedback // on progress through a possibly-long sequence of downloads. @@ -24,16 +29,20 @@ type ModuleInstallHooks interface { Install(moduleAddr string, version *version.Version, localPath string) } -// ModuleInstallHooksImpl is a do-nothing implementation of InstallHooks that +// ModuleInstallHookImpl is a do-nothing implementation of InstallHooks that // can be embedded in another implementation struct to allow only partial // implementation of the interface. -type ModuleInstallHooksImpl struct { +type ModuleInstallHookImpl struct { +} + +func (h ModuleInstallHookImpl) Download(moduleAddr, packageAddr string, version *version.Version) { } -func (h ModuleInstallHooksImpl) Download(moduleAddr, packageAddr string, version *version.Version) { +func (h ModuleInstallHookImpl) Install(moduleAddr string, version *version.Version, localPath string) { } -func (h ModuleInstallHooksImpl) Install(moduleAddr string, version *version.Version, localPath string) { +func (h ModuleInstallHookImpl) EvaluatePolicy(ctx context.Context, req *configs.ModuleRequest, source, version string) tfdiags.Diagnostics { + return nil } -var _ ModuleInstallHooks = ModuleInstallHooksImpl{} +var _ ModuleInstallHook = ModuleInstallHookImpl{} diff --git a/internal/initwd/module_install_test.go b/internal/initwd/module_install_test.go index d36b2bf03d72..d02b9eafdcd4 100644 --- a/internal/initwd/module_install_test.go +++ b/internal/initwd/module_install_test.go @@ -46,17 +46,27 @@ func TestModuleInstaller(t *testing.T) { modulesDir := filepath.Join(dir, ".terraform/modules") loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil, nil) - _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) + inst := NewModuleInstaller(modulesDir, loader, nil, nil, hooks) + _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false) tfdiags.AssertNoDiagnostics(t, diags) wantCalls := []testInstallHookCall{ + { + Name: "EvaluatePolicy", + ModuleAddr: "child_a", + PackageAddr: "", + }, { Name: "Install", ModuleAddr: "child_a", PackageAddr: "", LocalPath: "child_a", }, + { + Name: "EvaluatePolicy", + ModuleAddr: "child_a.child_b", + PackageAddr: "", + }, { Name: "Install", ModuleAddr: "child_a.child_b", @@ -118,8 +128,8 @@ func TestModuleInstaller_error(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil, nil) - _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) + inst := NewModuleInstaller(modulesDir, loader, nil, nil, hooks) + _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false) if !diags.HasErrors() { t.Fatal("expected error") @@ -139,8 +149,8 @@ func TestModuleInstaller_emptyModuleName(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil, nil) - _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) + inst := NewModuleInstaller(modulesDir, loader, nil, nil, hooks) + _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false) if !diags.HasErrors() { t.Fatal("expected error") @@ -160,8 +170,8 @@ func TestModuleInstaller_invalidModuleName(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil) - _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false, hooks) + inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil, hooks) + _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false) if !diags.HasErrors() { t.Fatal("expected error") @@ -198,8 +208,8 @@ func TestModuleInstaller_packageEscapeError(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil, nil) - _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) + inst := NewModuleInstaller(modulesDir, loader, nil, nil, hooks) + _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false) if !diags.HasErrors() { t.Fatal("expected error") @@ -236,8 +246,8 @@ func TestModuleInstaller_explicitPackageBoundary(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil, nil) - _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) + inst := NewModuleInstaller(modulesDir, loader, nil, nil, hooks) + _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false) if diags.HasErrors() { t.Fatalf("unexpected errors\n%s", diags.Err().Error()) @@ -259,8 +269,8 @@ func TestModuleInstaller_ExactMatchPrerelease(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil) - cfg, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) + inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil, hooks) + cfg, diags := inst.InstallModules(context.Background(), ".", "tests", false, false) if diags.HasErrors() { t.Fatalf("found unexpected errors: %s", diags.Err()) @@ -286,8 +296,8 @@ func TestModuleInstaller_PartialMatchPrerelease(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil) - cfg, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) + inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil, hooks) + cfg, diags := inst.InstallModules(context.Background(), ".", "tests", false, false) if diags.HasErrors() { t.Fatalf("found unexpected errors: %s", diags.Err()) @@ -309,8 +319,8 @@ func TestModuleInstaller_invalid_version_constraint_error(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil, nil) - _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) + inst := NewModuleInstaller(modulesDir, loader, nil, nil, hooks) + _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false) if !diags.HasErrors() { t.Fatal("expected error") @@ -335,8 +345,8 @@ func TestModuleInstaller_invalidVersionConstraintGetter(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil, nil) - _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) + inst := NewModuleInstaller(modulesDir, loader, nil, nil, hooks) + _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false) if !diags.HasErrors() { t.Fatal("expected error") @@ -361,8 +371,8 @@ func TestModuleInstaller_invalidVersionConstraintLocal(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil, nil) - _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) + inst := NewModuleInstaller(modulesDir, loader, nil, nil, hooks) + _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false) if !diags.HasErrors() { t.Fatal("expected error") @@ -387,17 +397,27 @@ func TestModuleInstaller_symlink(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil, nil) - _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) + inst := NewModuleInstaller(modulesDir, loader, nil, nil, hooks) + _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false) tfdiags.AssertNoDiagnostics(t, diags) wantCalls := []testInstallHookCall{ + { + Name: "EvaluatePolicy", + ModuleAddr: "child_a", + PackageAddr: "", + }, { Name: "Install", ModuleAddr: "child_a", PackageAddr: "", LocalPath: "child_a", }, + { + Name: "EvaluatePolicy", + ModuleAddr: "child_a.child_b", + PackageAddr: "", + }, { Name: "Install", ModuleAddr: "child_a.child_b", @@ -471,8 +491,8 @@ func TestLoaderInstallModules_invalidRegistry(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil) - _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false, hooks) + inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil, hooks) + _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false) if !diags.HasErrors() { t.Fatal("expected error") @@ -510,8 +530,8 @@ func TestLoaderInstallModules_registry(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil) - _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false, hooks) + inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil, hooks) + _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false) tfdiags.AssertNoDiagnostics(t, diags) v := version.Must(version.NewVersion("0.0.1")) @@ -521,6 +541,11 @@ func TestLoaderInstallModules_registry(t *testing.T) { // order by name, so the following list is kept in the same order. // acctest_child_a accesses //modules/child_a directly + { + Name: "EvaluatePolicy", + ModuleAddr: "acctest_child_a", + PackageAddr: "", + }, { Name: "Download", ModuleAddr: "acctest_child_a", @@ -547,6 +572,11 @@ func TestLoaderInstallModules_registry(t *testing.T) { // acctest_child_a.child_b // (no download because it's a relative path inside acctest_child_a) + { + Name: "EvaluatePolicy", + ModuleAddr: "acctest_child_a.child_b", + PackageAddr: "", + }, { Name: "Install", ModuleAddr: "acctest_child_a.child_b", @@ -554,6 +584,11 @@ func TestLoaderInstallModules_registry(t *testing.T) { }, // acctest_child_b accesses //modules/child_b directly + { + Name: "EvaluatePolicy", + ModuleAddr: "acctest_child_b", + PackageAddr: "", + }, { Name: "Download", ModuleAddr: "acctest_child_b", @@ -568,6 +603,11 @@ func TestLoaderInstallModules_registry(t *testing.T) { }, // acctest_root + { + Name: "EvaluatePolicy", + ModuleAddr: "acctest_root", + PackageAddr: "", + }, { Name: "Download", ModuleAddr: "acctest_root", @@ -583,6 +623,11 @@ func TestLoaderInstallModules_registry(t *testing.T) { // acctest_root.child_a // (no download because it's a relative path inside acctest_root) + { + Name: "EvaluatePolicy", + ModuleAddr: "acctest_root.child_a", + PackageAddr: "", + }, { Name: "Install", ModuleAddr: "acctest_root.child_a", @@ -591,6 +636,11 @@ func TestLoaderInstallModules_registry(t *testing.T) { // acctest_root.child_a.child_b // (no download because it's a relative path inside acctest_root, via acctest_root.child_a) + { + Name: "EvaluatePolicy", + ModuleAddr: "acctest_root.child_a.child_b", + PackageAddr: "", + }, { Name: "Install", ModuleAddr: "acctest_root.child_a.child_b", @@ -681,8 +731,8 @@ func TestLoaderInstallModules_goGetter(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil) - _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false, hooks) + inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil, hooks) + _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false) tfdiags.AssertNoDiagnostics(t, diags) wantCalls := []testInstallHookCall{ @@ -807,11 +857,15 @@ func TestModuleInstaller_fromTests(t *testing.T) { modulesDir := filepath.Join(dir, ".terraform/modules") loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil, nil) - _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) + inst := NewModuleInstaller(modulesDir, loader, nil, nil, hooks) + _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false) tfdiags.AssertNoDiagnostics(t, diags) wantCalls := []testInstallHookCall{ + { + Name: "EvaluatePolicy", + ModuleAddr: "test.tests.main.setup", + }, { Name: "Install", ModuleAddr: "test.tests.main.setup", @@ -872,8 +926,8 @@ func TestLoadInstallModules_registryFromTest(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil) - _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false, hooks) + inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil, hooks) + _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false) tfdiags.AssertNoDiagnostics(t, diags) v := version.Must(version.NewVersion("0.0.1")) @@ -996,6 +1050,17 @@ func (h *testInstallHooks) Install(moduleAddr string, version *version.Version, }) } +func (h *testInstallHooks) EvaluatePolicy(ctx context.Context, req *configs.ModuleRequest, source, vsn string) tfdiags.Diagnostics { + // we ignore errors here because local paths do not have a version + v, _ := version.NewVersion(vsn) + h.Calls = append(h.Calls, testInstallHookCall{ + Name: "EvaluatePolicy", + ModuleAddr: strings.Join(req.Path, "."), + Version: v, + }) + return nil +} + // tempChdir copies the contents of the given directory to a temporary // directory and changes the test process's current working directory to // point to that directory. Also returned is a function that should be diff --git a/internal/lang/globalref/testing_test.go b/internal/lang/globalref/testing_test.go index 9d6d843f2106..b33c8a194db3 100644 --- a/internal/lang/globalref/testing_test.go +++ b/internal/lang/globalref/testing_test.go @@ -30,7 +30,7 @@ func testAnalyzer(t *testing.T, fixtureName string) *globalref.Analyzer { defer cleanup() inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) - _, instDiags := inst.InstallModules(context.Background(), configDir, "tests", true, false, initwd.ModuleInstallHooksImpl{}) + _, instDiags := inst.InstallModules(context.Background(), configDir, "tests", true, false) if instDiags.HasErrors() { t.Fatalf("unexpected module installation errors: %s", instDiags.Err().Error()) } diff --git a/internal/moduletest/graph/eval_context_test.go b/internal/moduletest/graph/eval_context_test.go index d2bde834fb3c..4fea18a0c17e 100644 --- a/internal/moduletest/graph/eval_context_test.go +++ b/internal/moduletest/graph/eval_context_test.go @@ -836,7 +836,7 @@ func testModuleInline(t *testing.T, sources map[string]string) *configs.Config { // sources only this ultimately just records all of the module paths // in a JSON file so that we can load them below. inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) - _, instDiags := inst.InstallModules(context.Background(), cfgPath, "tests", true, false, initwd.ModuleInstallHooksImpl{}) + _, instDiags := inst.InstallModules(context.Background(), cfgPath, "tests", true, false) if instDiags.HasErrors() { t.Fatal(instDiags.Err()) } diff --git a/internal/providercache/installer.go b/internal/providercache/installer.go index d375e90ca504..5925f61a710f 100644 --- a/internal/providercache/installer.go +++ b/internal/providercache/installer.go @@ -17,6 +17,7 @@ import ( copydir "github.com/hashicorp/terraform/internal/copy" "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/getproviders" + "github.com/hashicorp/terraform/internal/policy" ) // Installer is the main type in this package, representing a provider installer @@ -58,6 +59,10 @@ type Installer struct { // lifecycle for, and therefore does not need to worry about the // installation of. unmanagedProviderTypes map[addrs.Provider]struct{} + + // hook is an optional hook whose methods may be called for each provider + // version that is being installed or upgraded. + hook InstallerHook } // NewInstaller constructs and returns a new installer with the given target @@ -161,6 +166,10 @@ func (i *Installer) SetUnmanagedProviderTypes(types map[addrs.Provider]struct{}) i.unmanagedProviderTypes = types } +func (i *Installer) SetHook(hook InstallerHook) { + i.hook = hook +} + // EnsureProviderVersions compares the given provider requirements with what // is already available in the installer's target directory and then takes // appropriate installation actions to ensure that suitable packages @@ -343,6 +352,22 @@ NeedProvider: return nil, err } + if i.hook != nil { + // For each needed provider, we will send the version + // and provider to the hook for policy evaluation. + // If the hook returns an error, we'll abort the installation. + // We do this before checking the lock file, so that we also + // evaluate policy for providers that are already installed. + result := i.hook.EvaluatePolicy(ctx, provider, version.String()) + + // 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 nil, fmt.Errorf("Provider download failed due to policy violations. Please review other diagnostics for details.") + } + } + lock := locks.Provider(provider) var preferredHashes []getproviders.Hash if lock != nil && lock.Version() == version { // hash changes are expected if the version is also changing @@ -718,9 +743,7 @@ NeedProvider: } if len(errs) > 0 { - return locks, InstallerError{ - ProviderErrors: errs, - } + return locks, InstallerError{ProviderErrors: errs} } return locks, nil } @@ -781,3 +804,9 @@ func (err InstallerError) Error() string { } return strings.TrimSpace(b.String()) } + +type InstallerHook interface { + // EvaluatePolicy is called for each provider version that is being installed + // or upgraded, allowing the caller to implement custom policy evaluation. + EvaluatePolicy(ctx context.Context, provider addrs.Provider, version string) policy.EvaluationResponse +} diff --git a/internal/refactoring/testing_test.go b/internal/refactoring/testing_test.go index 1fab18b0b27d..1b41a0230208 100644 --- a/internal/refactoring/testing_test.go +++ b/internal/refactoring/testing_test.go @@ -28,7 +28,7 @@ func loadRefactoringFixture(t *testing.T, dir string) (*configs.Config, instance defer cleanup() inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) - _, instDiags := inst.InstallModules(context.Background(), dir, "tests", true, false, initwd.ModuleInstallHooksImpl{}) + _, instDiags := inst.InstallModules(context.Background(), dir, "tests", true, false) if instDiags.HasErrors() { t.Fatal(instDiags.Err()) } diff --git a/internal/terraform/terraform_test.go b/internal/terraform/terraform_test.go index 3eab0c60d804..554602fb4243 100644 --- a/internal/terraform/terraform_test.go +++ b/internal/terraform/terraform_test.go @@ -69,7 +69,7 @@ func testModuleWithSnapshot(t *testing.T, name string) (*configs.Config, *config // sources only this ultimately just records all of the module paths // in a JSON file so that we can load them below. inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) - _, instDiags := inst.InstallModules(context.Background(), dir, "tests", true, false, initwd.ModuleInstallHooksImpl{}) + _, instDiags := inst.InstallModules(context.Background(), dir, "tests", true, false) if instDiags.HasErrors() { t.Fatal(instDiags.Err()) } @@ -165,7 +165,7 @@ func testModuleInlineWithVars(t testing.TB, sources map[string]string, vars Inpu // sources only this ultimately just records all of the module paths // in a JSON file so that we can load them below. inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) - _, instDiags := inst.InstallModules(context.Background(), cfgPath, "tests", true, false, initwd.ModuleInstallHooksImpl{}) + _, instDiags := inst.InstallModules(context.Background(), cfgPath, "tests", true, false) if instDiags.HasErrors() { t.Fatal(instDiags.Err()) } diff --git a/internal/terraform/testing/config.go b/internal/terraform/testing/config.go index 8d3b9ab9534b..4dcb5cb1ef19 100644 --- a/internal/terraform/testing/config.go +++ b/internal/terraform/testing/config.go @@ -43,7 +43,7 @@ func LoadConfigForTests(t *testing.T, rootDir string, testsDir string) (*configs loader, cleanup := configload.NewLoaderForTests(t) inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) - _, moreDiags := inst.InstallModules(context.Background(), rootDir, testsDir, true, false, initwd.ModuleInstallHooksImpl{}) + _, moreDiags := inst.InstallModules(context.Background(), rootDir, testsDir, true, false) diags = diags.Append(moreDiags) if diags.HasErrors() { cleanup()