diff --git a/internal/backend/local/backend_plan.go b/internal/backend/local/backend_plan.go index f421347163d2..75f85332978a 100644 --- a/internal/backend/local/backend_plan.go +++ b/internal/backend/local/backend_plan.go @@ -41,18 +41,30 @@ func (b *Local) opPlan( return } - // Local planning requires a config, unless we're planning to destroy. - if op.PlanMode != plans.DestroyMode && !op.HasConfig() { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "No configuration files", - "Plan requires configuration to be present. Planning without a configuration would "+ - "mark everything for destruction, which is normally not what is desired. If you "+ - "would like to destroy everything, run plan with the -destroy option. Otherwise, "+ - "create a Terraform configuration file (.tf file) and try again.", - )) - op.ReportResult(runningOp, diags) - return + if !op.HasConfig() { + switch { + case op.Query: + // Special diag for terraform query command + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "No configuration files", + "Query requires a query configuration to be present. Create a Terraform query configuration file (.tfquery.hcl file) and try again.", + )) + op.ReportResult(runningOp, diags) + return + case op.PlanMode != plans.DestroyMode: + // Local planning requires a config, unless we're planning to destroy. + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "No configuration files", + "Plan requires configuration to be present. Planning without a configuration would "+ + "mark everything for destruction, which is normally not what is desired. If you "+ + "would like to destroy everything, run plan with the -destroy option. Otherwise, "+ + "create a Terraform configuration file (.tf file) and try again.", + )) + op.ReportResult(runningOp, diags) + return + } } if len(op.GenerateConfigOut) > 0 { diff --git a/internal/command/query_test.go b/internal/command/query_test.go new file mode 100644 index 000000000000..0fe101263c6b --- /dev/null +++ b/internal/command/query_test.go @@ -0,0 +1,299 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "path" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/providers" + testing_provider "github.com/hashicorp/terraform/internal/providers/testing" + "github.com/zclconf/go-cty/cty" +) + +func TestQuery(t *testing.T) { + tests := []struct { + name string + directory string + expectedOut string + expectedErr []string + initCode int + }{ + { + name: "basic query", + directory: "basic", + expectedOut: `list.test_instance.example id=test-instance-1 Test Instance 1 +list.test_instance.example id=test-instance-2 Test Instance 2 + +`, + }, + { + name: "query referencing local variable", + directory: "with-locals", + expectedOut: `list.test_instance.example id=test-instance-1 Test Instance 1 +list.test_instance.example id=test-instance-2 Test Instance 2 + +`, + }, + { + name: "config with no query block", + directory: "no-list-block", + expectedOut: "", + expectedErr: []string{` +Error: No resources to query + +The configuration does not contain any resources that can be queried. +`}, + }, + { + name: "missing query file", + directory: "missing-query-file", + expectedOut: "", + expectedErr: []string{` +Error: No resources to query + +The configuration does not contain any resources that can be queried. +`}, + }, + { + name: "missing configuration", + directory: "missing-configuration", + expectedOut: "", + expectedErr: []string{` +Error: No configuration files + +Query requires a query configuration to be present. Create a Terraform query +configuration file (.tfquery.hcl file) and try again. +`}, + }, + { + name: "invalid query syntax", + directory: "invalid-syntax", + expectedOut: "", + initCode: 1, + expectedErr: []string{` +Error: Unsupported block type + + on query.tfquery.hcl line 11: + 11: resource "test_instance" "example" { + +Blocks of type "resource" are not expected here. +`}, + }, + } + + for _, ts := range tests { + t.Run(ts.name, func(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(path.Join("query", ts.directory)), td) + t.Chdir(td) + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.0.0"}, + }) + defer close() + + p := queryFixtureProvider() + view, done := testView(t) + meta := Meta{ + testingOverrides: metaOverridesForProvider(p), + View: view, + AllowExperimentalFeatures: true, + ProviderSource: providerSource, + } + + init := &InitCommand{Meta: meta} + code := init.Run(nil) + output := done(t) + if code != ts.initCode { + t.Fatalf("expected status code %d but got %d: %s", ts.initCode, code, output.All()) + } + + view, done = testView(t) + meta.View = view + + c := &QueryCommand{Meta: meta} + args := []string{"-no-color"} + code = c.Run(args) + output = done(t) + actual := output.All() + if len(ts.expectedErr) == 0 { + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) + } + + // Check that we have query output + if diff := cmp.Diff(ts.expectedOut, actual); diff != "" { + t.Errorf("expected query output to contain %q, \ngot: %q, \ndiff: %s", ts.expectedOut, actual, diff) + } + + } else { + for _, expected := range ts.expectedErr { + if diff := cmp.Diff(expected, actual); diff != "" { + t.Errorf("expected error message to contain '%s', \ngot: %s, \ndiff: %s", expected, actual, diff) + } + } + } + }) + } +} + +func queryFixtureProvider() *testing_provider.MockProvider { + p := testProvider() + instanceListSchema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "data": { + Type: cty.DynamicPseudoType, + Computed: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "config": { + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "ami": { + Type: cty.String, + Required: true, + }, + }, + }, + Nesting: configschema.NestingSingle, + }, + }, + } + databaseListSchema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "data": { + Type: cty.DynamicPseudoType, + Computed: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "config": { + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "engine": { + Type: cty.String, + Optional: true, + }, + }, + }, + Nesting: configschema.NestingSingle, + }, + }, + } + p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "test_instance": { + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "ami": { + Type: cty.String, + Optional: true, + }, + }, + }, + }, + "test_database": { + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "engine": { + Type: cty.String, + Optional: true, + }, + }, + }, + }, + }, + ListResourceTypes: map[string]providers.Schema{ + "test_instance": {Body: instanceListSchema}, + "test_database": {Body: databaseListSchema}, + }, + } + + // Mock the ListResources method for query operations + p.ListResourceFn = func(request providers.ListResourceRequest) providers.ListResourceResponse { + // Check the config to determine what kind of response to return + wholeConfigMap := request.Config.AsValueMap() + + configMap := wholeConfigMap["config"] + + // For empty results test case //TODO: Remove? + if ami, ok := wholeConfigMap["ami"]; ok && ami.AsString() == "ami-nonexistent" { + return providers.ListResourceResponse{ + Result: cty.ObjectVal(map[string]cty.Value{ + "data": cty.ListVal([]cty.Value{}), + "config": configMap, + }), + } + } + + switch request.TypeName { + case "test_instance": + return providers.ListResourceResponse{ + Result: cty.ObjectVal(map[string]cty.Value{ + "data": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("test-instance-1"), + }), + "state": cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("test-instance-1"), + "ami": cty.StringVal("ami-12345"), + }), + "display_name": cty.StringVal("Test Instance 1"), + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("test-instance-2"), + }), + "state": cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("test-instance-2"), + "ami": cty.StringVal("ami-67890"), + }), + "display_name": cty.StringVal("Test Instance 2"), + }), + }), + "config": configMap, + }), + } + case "test_database": + return providers.ListResourceResponse{ + Result: cty.ObjectVal(map[string]cty.Value{ + "data": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("test-db-1"), + }), + "state": cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("test-db-1"), + "engine": cty.StringVal("mysql"), + }), + "display_name": cty.StringVal("Test Database 1"), + }), + }), + "config": configMap, + }), + } + default: + return providers.ListResourceResponse{ + Result: cty.ObjectVal(map[string]cty.Value{ + "data": cty.ListVal([]cty.Value{}), + "config": configMap, + }), + } + } + } + + return p +} diff --git a/internal/command/testdata/query/basic/main.tf b/internal/command/testdata/query/basic/main.tf new file mode 100644 index 000000000000..2090cb13d43d --- /dev/null +++ b/internal/command/testdata/query/basic/main.tf @@ -0,0 +1,13 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } +} + +provider "test" {} + +resource "test_instance" "example" { + ami = "ami-12345" +} diff --git a/internal/command/testdata/query/basic/query.tfquery.hcl b/internal/command/testdata/query/basic/query.tfquery.hcl new file mode 100644 index 000000000000..a55128eafb4b --- /dev/null +++ b/internal/command/testdata/query/basic/query.tfquery.hcl @@ -0,0 +1,7 @@ +list "test_instance" "example" { + provider = test + + config { + ami = "ami-12345" + } +} diff --git a/internal/command/testdata/query/invalid-syntax/main.tf b/internal/command/testdata/query/invalid-syntax/main.tf new file mode 100644 index 000000000000..2090cb13d43d --- /dev/null +++ b/internal/command/testdata/query/invalid-syntax/main.tf @@ -0,0 +1,13 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } +} + +provider "test" {} + +resource "test_instance" "example" { + ami = "ami-12345" +} diff --git a/internal/command/testdata/query/invalid-syntax/query.tfquery.hcl b/internal/command/testdata/query/invalid-syntax/query.tfquery.hcl new file mode 100644 index 000000000000..70d52cd63b95 --- /dev/null +++ b/internal/command/testdata/query/invalid-syntax/query.tfquery.hcl @@ -0,0 +1,13 @@ +list "test_instance" "example" { + provider = test + + config { + ami = "ami-12345" + } +} + + +// resource type not supported in query files +resource "test_instance" "example" { + provider = test +} diff --git a/internal/command/testdata/query/missing-configuration/.gitkeep b/internal/command/testdata/query/missing-configuration/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/internal/command/testdata/query/missing-query-file/main.tf b/internal/command/testdata/query/missing-query-file/main.tf new file mode 100644 index 000000000000..2090cb13d43d --- /dev/null +++ b/internal/command/testdata/query/missing-query-file/main.tf @@ -0,0 +1,13 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } +} + +provider "test" {} + +resource "test_instance" "example" { + ami = "ami-12345" +} diff --git a/internal/command/testdata/query/no-list-block/main.tf b/internal/command/testdata/query/no-list-block/main.tf new file mode 100644 index 000000000000..2090cb13d43d --- /dev/null +++ b/internal/command/testdata/query/no-list-block/main.tf @@ -0,0 +1,13 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } +} + +provider "test" {} + +resource "test_instance" "example" { + ami = "ami-12345" +} diff --git a/internal/command/testdata/query/no-list-block/query.tfquery.hcl b/internal/command/testdata/query/no-list-block/query.tfquery.hcl new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/internal/command/testdata/query/with-locals/main.tf b/internal/command/testdata/query/with-locals/main.tf new file mode 100644 index 000000000000..2090cb13d43d --- /dev/null +++ b/internal/command/testdata/query/with-locals/main.tf @@ -0,0 +1,13 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } +} + +provider "test" {} + +resource "test_instance" "example" { + ami = "ami-12345" +} diff --git a/internal/command/testdata/query/with-locals/query.tfquery.hcl b/internal/command/testdata/query/with-locals/query.tfquery.hcl new file mode 100644 index 000000000000..31509a491ca0 --- /dev/null +++ b/internal/command/testdata/query/with-locals/query.tfquery.hcl @@ -0,0 +1,11 @@ +locals { + ami = "ami-12345" +} + +list "test_instance" "example" { + provider = test + + config { + ami = local.ami + } +} diff --git a/internal/configs/config.go b/internal/configs/config.go index 888d2db1b0b5..08828ce62646 100644 --- a/internal/configs/config.go +++ b/internal/configs/config.go @@ -5,6 +5,7 @@ package configs import ( "fmt" + "iter" "log" "maps" "slices" @@ -143,15 +144,19 @@ func (c *Config) DeepEach(cb func(c *Config)) { } } -// AllModules returns a slice of all the receiver and all of its descendant -// nodes in the module tree, in the same order they would be visited by -// DeepEach. -func (c *Config) AllModules() []*Config { - var ret []*Config - c.DeepEach(func(c *Config) { - ret = append(ret, c) - }) - return ret +// AllModules returns an iterator of all the receiver and all of its descendant +// nodes in the module tree until the iterator is exhausted or terminated. +func (c *Config) AllModules() iter.Seq[*Config] { + return func(yield func(*Config) bool) { + if !yield(c) { + return + } + for _, ch := range c.Children { + if !yield(ch) { + return + } + } + } } // Descendant returns the descendant config that has the given path beneath diff --git a/internal/terraform/context_plan.go b/internal/terraform/context_plan.go index 8fae34c2c5c6..678d292054bc 100644 --- a/internal/terraform/context_plan.go +++ b/internal/terraform/context_plan.go @@ -270,6 +270,25 @@ The -target option is not for routine use, and is provided only for exceptional )) } + if opts.Query { + var hasQuery bool + for c := range config.AllModules() { + if len(c.Module.ListResources) > 0 { + hasQuery = true + break + } + } + + if !hasQuery { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "No resources to query", + `The configuration does not contain any resources that can be queried.`, + )) + return nil, nil, diags + } + } + var plan *plans.Plan var planDiags tfdiags.Diagnostics var evalScope *lang.Scope