| // Copyright IBM Corp. 2014, 2026 | |
| // SPDX-License-Identifier: BUSL-1.1 | |
| package command | |
| import ( | |
| "context" | |
| "os" | |
| "strings" | |
| "testing" | |
| "github.com/google/go-cmp/cmp" | |
| "github.com/zclconf/go-cty/cty" | |
| "github.com/hashicorp/terraform/internal/addrs" | |
| "github.com/hashicorp/terraform/internal/backend" | |
| backendInit "github.com/hashicorp/terraform/internal/backend/init" | |
| "github.com/hashicorp/terraform/internal/configs/configload" | |
| "github.com/hashicorp/terraform/internal/configs/configschema" | |
| "github.com/hashicorp/terraform/internal/initwd" | |
| "github.com/hashicorp/terraform/internal/plans" | |
| "github.com/hashicorp/terraform/internal/providers" | |
| "github.com/hashicorp/terraform/internal/registry" | |
| "github.com/hashicorp/terraform/internal/states" | |
| "github.com/hashicorp/terraform/internal/terminal" | |
| ) | |
| func TestGraph_planPhase(t *testing.T) { | |
| td := t.TempDir() | |
| testCopyDir(t, testFixturePath("graph"), td) | |
| t.Chdir(td) | |
| ui := testUiWrapped(t) | |
| streams, closeStreams := terminal.StreamsForTesting(t) | |
| c := &GraphCommand{ | |
| Meta: Meta{ | |
| testingOverrides: metaOverridesForProvider(applyFixtureProvider()), | |
| Ui: ui, | |
| Streams: streams, | |
| }, | |
| } | |
| args := []string{"-type=plan"} | |
| if code := c.Run(args); code != 0 { | |
| t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) | |
| } | |
| output := closeStreams(t) | |
| if !strings.Contains(output.Stdout(), `provider[\"registry.terraform.io/hashicorp/test\"]`) { | |
| t.Fatalf("doesn't look like digraph:\n%s\n\nstderr:\n%s", output.Stdout(), output.Stderr()) | |
| } | |
| } | |
| func TestGraph_cyclic(t *testing.T) { | |
| td := t.TempDir() | |
| testCopyDir(t, testFixturePath("graph-cyclic"), td) | |
| t.Chdir(td) | |
| tests := []struct { | |
| name string | |
| args []string | |
| expected string | |
| // The cyclic errors do not maintain a consistent order, so we can't | |
| // predict the exact output. We'll just check that the error messages | |
| // are present for the things we know are cyclic. | |
| errors []string | |
| }{ | |
| { | |
| name: "plan", | |
| args: []string{"-type=plan"}, | |
| errors: []string{"Error: Cycle:\n test_instance.", | |
| "Error: Cycle:\n local."}, | |
| }, | |
| { | |
| name: "plan with -draw-cycles option", | |
| args: []string{"-draw-cycles", "-type=plan"}, | |
| expected: `digraph { | |
| compound = "true" | |
| newrank = "true" | |
| subgraph "root" { | |
| "[root] provider[\"registry.terraform.io/hashicorp/test\"]" [label = "provider[\"registry.terraform.io/hashicorp/test\"]", shape = "diamond"] | |
| "[root] test_instance.bar (expand)" [label = "test_instance.bar", shape = "box"] | |
| "[root] test_instance.foo (expand)" [label = "test_instance.foo", shape = "box"] | |
| "[root] local.test1 (expand)" -> "[root] local.test2 (expand)" | |
| "[root] local.test2 (expand)" -> "[root] local.test1 (expand)" | |
| "[root] provider[\"registry.terraform.io/hashicorp/test\"] (close)" -> "[root] provider[\"registry.terraform.io/hashicorp/test\"]" | |
| "[root] provider[\"registry.terraform.io/hashicorp/test\"] (close)" -> "[root] test_instance.bar (expand)" | |
| "[root] provider[\"registry.terraform.io/hashicorp/test\"] (close)" -> "[root] test_instance.foo (expand)" | |
| "[root] root" -> "[root] provider[\"registry.terraform.io/hashicorp/test\"] (close)" | |
| "[root] test_instance.bar (expand)" -> "[root] provider[\"registry.terraform.io/hashicorp/test\"]" | |
| "[root] test_instance.bar (expand)" -> "[root] test_instance.foo (expand)" [color = "red", penwidth = "2.0"] | |
| "[root] test_instance.foo (expand)" -> "[root] provider[\"registry.terraform.io/hashicorp/test\"]" | |
| "[root] test_instance.foo (expand)" -> "[root] test_instance.bar (expand)" [color = "red", penwidth = "2.0"] | |
| } | |
| }`, | |
| }, | |
| { | |
| name: "apply", | |
| args: []string{"-type=apply"}, | |
| // The cyclic errors do not maintain a consistent order, so we can't | |
| // predict the exact output. We'll just check that the error messages | |
| // are present for the things we know are cyclic. | |
| errors: []string{"Error: Cycle:\n test_instance.", | |
| "Error: Cycle:\n local."}, | |
| }, | |
| { | |
| name: "apply with -draw-cycles option", | |
| args: []string{"-draw-cycles", "-type=apply"}, | |
| expected: `digraph { | |
| compound = "true" | |
| newrank = "true" | |
| subgraph "root" { | |
| "[root] provider[\"registry.terraform.io/hashicorp/test\"]" [label = "provider[\"registry.terraform.io/hashicorp/test\"]", shape = "diamond"] | |
| "[root] test_instance.bar (expand)" [label = "test_instance.bar", shape = "box"] | |
| "[root] test_instance.foo (expand)" [label = "test_instance.foo", shape = "box"] | |
| "[root] local.test1 (expand)" -> "[root] local.test2 (expand)" | |
| "[root] local.test2 (expand)" -> "[root] local.test1 (expand)" | |
| "[root] provider[\"registry.terraform.io/hashicorp/test\"] (close)" -> "[root] provider[\"registry.terraform.io/hashicorp/test\"]" | |
| "[root] provider[\"registry.terraform.io/hashicorp/test\"] (close)" -> "[root] test_instance.bar (expand)" | |
| "[root] provider[\"registry.terraform.io/hashicorp/test\"] (close)" -> "[root] test_instance.foo (expand)" | |
| "[root] root" -> "[root] provider[\"registry.terraform.io/hashicorp/test\"] (close)" | |
| "[root] test_instance.bar (expand)" -> "[root] provider[\"registry.terraform.io/hashicorp/test\"]" | |
| "[root] test_instance.bar (expand)" -> "[root] test_instance.foo (expand)" [color = "red", penwidth = "2.0"] | |
| "[root] test_instance.foo (expand)" -> "[root] provider[\"registry.terraform.io/hashicorp/test\"]" | |
| "[root] test_instance.foo (expand)" -> "[root] test_instance.bar (expand)" [color = "red", penwidth = "2.0"] | |
| } | |
| }`, | |
| }, | |
| } | |
| for _, tt := range tests { | |
| t.Run(tt.name, func(t *testing.T) { | |
| ui := testUiWrapped(t) | |
| streams, closeStreams := terminal.StreamsForTesting(t) | |
| c := &GraphCommand{ | |
| Meta: Meta{ | |
| testingOverrides: metaOverridesForProvider(applyFixtureProvider()), | |
| Ui: ui, | |
| Streams: streams, | |
| }, | |
| } | |
| code := c.Run(tt.args) | |
| // If we expect errors, make sure they are present | |
| if len(tt.errors) > 0 { | |
| if code == 0 { | |
| t.Fatalf("expected error, got none") | |
| } | |
| got := strings.TrimSpace(ui.ErrorWriter.String()) | |
| for _, err := range tt.errors { | |
| if !strings.Contains(got, err) { | |
| t.Fatalf("expected error:\n%s\n\nactual error:\n%s", err, got) | |
| } | |
| } | |
| return | |
| } | |
| // If we don't expect errors, make sure the command ran successfully | |
| if code != 0 { | |
| t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) | |
| } | |
| output := closeStreams(t) | |
| if strings.TrimSpace(output.Stdout()) != strings.TrimSpace(tt.expected) { | |
| t.Fatalf("expected dot graph to match:\n%s", cmp.Diff(output.Stdout(), tt.expected)) | |
| } | |
| }) | |
| } | |
| } | |
| func TestGraph_multipleArgs(t *testing.T) { | |
| ui := testUiWrapped(t) | |
| c := &GraphCommand{ | |
| Meta: Meta{ | |
| testingOverrides: metaOverridesForProvider(applyFixtureProvider()), | |
| Ui: ui, | |
| }, | |
| } | |
| args := []string{ | |
| "bad", | |
| "bad", | |
| } | |
| if code := c.Run(args); code != 1 { | |
| t.Fatalf("bad: \n%s", ui.OutputWriter.String()) | |
| } | |
| } | |
| func TestGraph_noConfig(t *testing.T) { | |
| td := t.TempDir() | |
| os.MkdirAll(td, 0755) | |
| t.Chdir(td) | |
| streams, closeStreams := terminal.StreamsForTesting(t) | |
| defer closeStreams(t) | |
| ui := testUiWrapped(t) | |
| c := &GraphCommand{ | |
| Meta: Meta{ | |
| testingOverrides: metaOverridesForProvider(applyFixtureProvider()), | |
| Ui: ui, | |
| Streams: streams, | |
| }, | |
| } | |
| // Running the graph command without a config should not panic, | |
| // but this may be an error at some point in the future. | |
| args := []string{"-type", "apply"} | |
| if code := c.Run(args); code != 0 { | |
| t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) | |
| } | |
| } | |
| func TestGraph_resourcesOnly(t *testing.T) { | |
| wd := tempWorkingDirFixture(t, "graph-interesting") | |
| t.Chdir(wd.RootModuleDir()) | |
| // The graph-interesting fixture has a child module, so we'll need to | |
| // run the module installer just to get the working directory set up | |
| // properly, as if the user has run "terraform init". This is really | |
| // just building the working directory's index of module directories. | |
| loader, cleanupLoader := configload.NewLoaderForTests(t) | |
| t.Cleanup(cleanupLoader) | |
| err := os.MkdirAll(".terraform/modules", 0700) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| inst := initwd.NewModuleInstaller(".terraform/modules", loader, registry.NewClient(nil, nil), testModuleInstallerInitializer(loader)) | |
| _, instDiags := inst.InstallModules(context.Background(), ".", "tests", true, false) | |
| if instDiags.HasErrors() { | |
| t.Fatal(instDiags.Err()) | |
| } | |
| p := testProvider() | |
| p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{ | |
| ResourceTypes: map[string]providers.Schema{ | |
| "foo": { | |
| Body: &configschema.Block{ | |
| Attributes: map[string]*configschema.Attribute{ | |
| "arg": { | |
| Type: cty.String, | |
| Optional: true, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| } | |
| ui := testUiWrapped(t) | |
| streams, closeStreams := terminal.StreamsForTesting(t) | |
| c := &GraphCommand{ | |
| Meta: Meta{ | |
| testingOverrides: &testingOverrides{ | |
| Providers: map[addrs.Provider]providers.Factory{ | |
| addrs.NewDefaultProvider("foo"): providers.FactoryFixed(p), | |
| }, | |
| }, | |
| Ui: ui, | |
| Streams: streams, | |
| }, | |
| } | |
| // A "resources only" graph is the default behavior, with no extra arguments. | |
| args := []string{} | |
| if code := c.Run(args); code != 0 { | |
| output := closeStreams(t) | |
| t.Fatalf("unexpected error: \n%s", output.Stderr()) | |
| } | |
| output := closeStreams(t) | |
| gotGraph := strings.TrimSpace(output.Stdout()) | |
| wantGraph := strings.TrimSpace(` | |
| digraph G { | |
| rankdir = "RL"; | |
| node [shape = rect, fontname = "sans-serif"]; | |
| "foo.bar" [label="foo.bar"]; | |
| "foo.baz" [label="foo.baz"]; | |
| "foo.boop" [label="foo.boop"]; | |
| subgraph "cluster_module.child" { | |
| label = "module.child" | |
| fontname = "sans-serif" | |
| "module.child.foo.bleep" [label="foo.bleep"]; | |
| } | |
| "foo.baz" -> "foo.bar"; | |
| "foo.boop" -> "module.child.foo.bleep"; | |
| "module.child.foo.bleep" -> "foo.bar"; | |
| } | |
| `) | |
| if diff := cmp.Diff(wantGraph, gotGraph); diff != "" { | |
| t.Fatalf("wrong result\n%s", diff) | |
| } | |
| } | |
| func TestGraph_applyPhaseSavedPlan(t *testing.T) { | |
| tmp := t.TempDir() | |
| t.Chdir(tmp) | |
| emptyObj, err := plans.NewDynamicValue(cty.EmptyObjectVal, cty.EmptyObject) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| nullEmptyObj, err := plans.NewDynamicValue(cty.NullVal((cty.EmptyObject)), cty.EmptyObject) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| plan := &plans.Plan{ | |
| Changes: plans.NewChangesSrc(), | |
| } | |
| plan.Changes.Resources = append(plan.Changes.Resources, &plans.ResourceInstanceChangeSrc{ | |
| Addr: addrs.Resource{ | |
| Mode: addrs.ManagedResourceMode, | |
| Type: "test_instance", | |
| Name: "bar", | |
| }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), | |
| ChangeSrc: plans.ChangeSrc{ | |
| Action: plans.Delete, | |
| Before: emptyObj, | |
| After: nullEmptyObj, | |
| }, | |
| ProviderAddr: addrs.AbsProviderConfig{ | |
| Provider: addrs.NewDefaultProvider("test"), | |
| Module: addrs.RootModule, | |
| }, | |
| }) | |
| plan.Backend = &plans.Backend{ | |
| // Doesn't actually matter since we aren't going to activate the backend | |
| // for this command anyway, but we need something here for the plan | |
| // file writer to succeed. | |
| Type: "placeholder", | |
| Config: emptyObj, | |
| Workspace: "default", | |
| } | |
| _, configSnap := testModuleWithSnapshot(t, "graph") | |
| planPath := testPlanFile(t, configSnap, states.NewState(), plan) | |
| streams, closeStreams := terminal.StreamsForTesting(t) | |
| ui := testUiWrapped(t) | |
| c := &GraphCommand{ | |
| Meta: Meta{ | |
| testingOverrides: metaOverridesForProvider(applyFixtureProvider()), | |
| Ui: ui, | |
| Streams: streams, | |
| }, | |
| } | |
| args := []string{ | |
| "-plan", planPath, | |
| } | |
| if code := c.Run(args); code != 0 { | |
| t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) | |
| } | |
| output := closeStreams(t) | |
| if !strings.Contains(output.Stdout(), `provider[\"registry.terraform.io/hashicorp/test\"]`) { | |
| t.Fatalf("doesn't look like digraph:\n%s\n\nstderr:\n%s", output.Stdout(), output.Stderr()) | |
| } | |
| } | |
| func TestGraph_constVariable(t *testing.T) { | |
| t.Run("missing value", func(t *testing.T) { | |
| wd := tempWorkingDirFixture(t, "dynamic-module-sources/command-with-const-var") | |
| t.Chdir(wd.RootModuleDir()) | |
| ui := testUiWrapped(t) | |
| streams, closeStreams := terminal.StreamsForTesting(t) | |
| c := &GraphCommand{ | |
| Meta: Meta{ | |
| testingOverrides: metaOverridesForProvider(testProvider()), | |
| Ui: ui, | |
| Streams: streams, | |
| WorkingDir: wd, | |
| }, | |
| } | |
| args := []string{} | |
| if code := c.Run(args); code != 1 { | |
| output := closeStreams(t) | |
| t.Fatalf("expected exit status 1\nstdout:\n%s\n\nstderr:\n%s", output.Stdout(), output.Stderr()) | |
| } | |
| if !strings.Contains(ui.ErrorWriter.String(), "No value for required variable") { | |
| t.Fatalf("expected missing variable error, got:\n%s", ui.ErrorWriter.String()) | |
| } | |
| closeStreams(t) | |
| }) | |
| t.Run("value via cli", func(t *testing.T) { | |
| wd := tempWorkingDirFixture(t, "dynamic-module-sources/command-with-const-var") | |
| t.Chdir(wd.RootModuleDir()) | |
| ui := testUiWrapped(t) | |
| streams, closeStreams := terminal.StreamsForTesting(t) | |
| c := &GraphCommand{ | |
| Meta: Meta{ | |
| testingOverrides: metaOverridesForProvider(testProvider()), | |
| Ui: ui, | |
| Streams: streams, | |
| WorkingDir: wd, | |
| }, | |
| } | |
| args := []string{"-var", "module_name=child"} | |
| if code := c.Run(args); code != 0 { | |
| output := closeStreams(t) | |
| t.Fatalf("bad:\nstdout:\n%s\n\nstderr:\n%s", output.Stdout(), output.Stderr()) | |
| } | |
| output := closeStreams(t) | |
| wantOutput := []string{ | |
| `"module.child.test_instance.test" [label="test_instance.test"]`, | |
| } | |
| for _, want := range wantOutput { | |
| if !strings.Contains(output.Stdout(), want) { | |
| t.Fatalf("output missing %s:\n%s", want, output.Stdout()) | |
| } | |
| } | |
| }) | |
| t.Run("value via backend", func(t *testing.T) { | |
| mockBackend := TestNewVariableBackend(map[string]string{ | |
| "module_name": "child", | |
| }) | |
| backendInit.Set("local-vars", func() backend.Backend { return mockBackend }) | |
| defer backendInit.Set("local-vars", nil) | |
| wd := tempWorkingDirFixture(t, "dynamic-module-sources/command-with-const-var-backend") | |
| t.Chdir(wd.RootModuleDir()) | |
| ui := testUiWrapped(t) | |
| streams, closeStreams := terminal.StreamsForTesting(t) | |
| c := &GraphCommand{ | |
| Meta: Meta{ | |
| testingOverrides: metaOverridesForProvider(testProvider()), | |
| Ui: ui, | |
| Streams: streams, | |
| WorkingDir: wd, | |
| }, | |
| } | |
| args := []string{} | |
| if code := c.Run(args); code != 0 { | |
| output := closeStreams(t) | |
| stderr := ui.ErrorWriter.String() | |
| t.Fatalf("bad:\nstdout:\n%s\n\nstderr:\n%s", output.Stdout(), stderr) | |
| } | |
| output := closeStreams(t) | |
| wantOutput := []string{ | |
| `"module.child.test_instance.test" [label="test_instance.test"]`, | |
| } | |
| for _, want := range wantOutput { | |
| if !strings.Contains(output.Stdout(), want) { | |
| t.Fatalf("output missing %s:\n%s", want, output.Stdout()) | |
| } | |
| } | |
| }) | |
| } |