From 2b9d25c7fd43373f703c69b97aa40ad659d1914f Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Wed, 2 Jul 2025 15:06:25 +0200 Subject: [PATCH] Add `terraform query` subcommand (TF-25494) (#37174) * WIP * Reuse plan command for query CLI * Basic CLI output * Only fail a list request on error * poc: store query results in separate field * WIP: odd mixture between JSONs * Fix list references * Separate JSON rendering The structured JSON now only logs a status on which list query is currently running. The new jsonlist package can marshal the query fields of a plan. * Remove matcher * Store results in an extra struct * Structured list result logging * Move list result output into hooks * Add help text and additional flag * Disable query runs with the cloud backend for now * Review feedback --- commands.go | 6 + internal/backend/backendrun/operation.go | 3 + internal/backend/local/backend_local.go | 1 + internal/cloud/backend_plan.go | 9 + internal/command/arguments/default.go | 4 +- internal/command/arguments/query.go | 75 ++++++ internal/command/jsonformat/plan.go | 9 +- internal/command/jsonformat/renderer.go | 18 +- internal/command/meta_config.go | 4 +- internal/command/query.go | 221 ++++++++++++++++++ internal/command/views/hook_json.go | 30 +++ internal/command/views/hook_ui.go | 22 ++ internal/command/views/json/message_types.go | 4 + internal/command/views/json/query.go | 61 +++++ internal/command/views/query.go | 85 +++++++ internal/command/views/query_operation.go | 111 +++++++++ internal/plans/changes.go | 67 +++++- internal/plans/changes_src.go | 46 +++- internal/plans/changes_sync.go | 24 ++ internal/terraform/context_plan_query_test.go | 32 +-- internal/terraform/evaluate.go | 18 +- internal/terraform/hook.go | 13 ++ internal/terraform/hook_mock.go | 28 +++ internal/terraform/hook_stop.go | 8 + internal/terraform/hook_test.go | 14 ++ .../node_resource_plan_instance_query.go | 31 ++- 26 files changed, 885 insertions(+), 59 deletions(-) create mode 100644 internal/command/arguments/query.go create mode 100644 internal/command/query.go create mode 100644 internal/command/views/json/query.go create mode 100644 internal/command/views/query.go create mode 100644 internal/command/views/query_operation.go diff --git a/commands.go b/commands.go index 29e228b6af..ebf2e9f815 100644 --- a/commands.go +++ b/commands.go @@ -445,6 +445,12 @@ func initCommands( }, nil } + Commands["query"] = func() (cli.Command, error) { + return &command.QueryCommand{ + Meta: meta, + }, nil + } + Commands["stacks"] = func() (cli.Command, error) { return &command.StacksCommand{ Meta: meta, diff --git a/internal/backend/backendrun/operation.go b/internal/backend/backendrun/operation.go index 94c72ab5ad..ec66e842cf 100644 --- a/internal/backend/backendrun/operation.go +++ b/internal/backend/backendrun/operation.go @@ -154,6 +154,9 @@ type Operation struct { // for unmatched import targets and where any generated config should be // written to. GenerateConfigOut string + + // Query is true if the operation should be a query operation + Query bool } // HasConfig returns true if and only if the operation has a ConfigDir value diff --git a/internal/backend/local/backend_local.go b/internal/backend/local/backend_local.go index 11f9dce01a..d6e522a602 100644 --- a/internal/backend/local/backend_local.go +++ b/internal/backend/local/backend_local.go @@ -207,6 +207,7 @@ func (b *Local) localRunDirect(op *backendrun.Operation, run *backendrun.LocalRu SkipRefresh: op.Type != backendrun.OperationTypeRefresh && !op.PlanRefresh, GenerateConfigPath: op.GenerateConfigOut, DeferralAllowed: op.DeferralAllowed, + Query: op.Query, } run.PlanOpts = planOpts diff --git a/internal/cloud/backend_plan.go b/internal/cloud/backend_plan.go index 8c59119225..999947ffdc 100644 --- a/internal/cloud/backend_plan.go +++ b/internal/cloud/backend_plan.go @@ -92,6 +92,15 @@ func (b *Cloud) opPlan(stopCtx, cancelCtx context.Context, op *backendrun.Operat diags = diags.Append(genconfig.ValidateTargetFile(op.GenerateConfigOut)) } + if op.Query { + // We don't support running a query operation with the cloud backend for now + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Query operations are not supported", + fmt.Sprintf("%s does not support query operations at this time.", b.appName), + )) + } + // Return if there are any errors. if diags.HasErrors() { return nil, diags.Err() diff --git a/internal/command/arguments/default.go b/internal/command/arguments/default.go index 9a08504d7d..bc69f92693 100644 --- a/internal/command/arguments/default.go +++ b/internal/command/arguments/default.go @@ -5,14 +5,14 @@ package arguments import ( "flag" - "io/ioutil" + "io" ) // defaultFlagSet creates a FlagSet with the common settings to override // the flag package's noisy defaults. func defaultFlagSet(name string) *flag.FlagSet { f := flag.NewFlagSet(name, flag.ContinueOnError) - f.SetOutput(ioutil.Discard) + f.SetOutput(io.Discard) f.Usage = func() {} return f diff --git a/internal/command/arguments/query.go b/internal/command/arguments/query.go new file mode 100644 index 0000000000..00a2a8fe48 --- /dev/null +++ b/internal/command/arguments/query.go @@ -0,0 +1,75 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package arguments + +import ( + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// Query represents the command-line arguments for the query command. +type Query struct { + // State, Operation, and Vars are the common extended flags + State *State + Operation *Operation + Vars *Vars + + // ViewType specifies which output format to use: human or JSON. + ViewType ViewType + + // GenerateConfigPath tells Terraform that config should be generated for + // the found resources in the query and which path the generated file should + // be written to. + GenerateConfigPath string +} + +func ParseQuery(args []string) (*Query, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + query := &Query{ + State: &State{}, + Operation: &Operation{}, + Vars: &Vars{}, + } + + cmdFlags := defaultFlagSet("query") + cmdFlags.StringVar(&query.GenerateConfigPath, "generate-config-out", "", "generate-config-out") + + varsFlags := NewFlagNameValueSlice("-var") + varFilesFlags := varsFlags.Alias("-var-file") + query.Vars.vars = &varsFlags + query.Vars.varFiles = &varFilesFlags + cmdFlags.Var(query.Vars.vars, "var", "var") + cmdFlags.Var(query.Vars.varFiles, "var-file", "var-file") + + var json bool + cmdFlags.BoolVar(&json, "json", false, "json") + + if err := cmdFlags.Parse(args); err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to parse command-line flags", + err.Error(), + )) + } + + args = cmdFlags.Args() + + if len(args) > 0 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments", + "To specify a working directory for the query, use the global -chdir flag.", + )) + } + + diags = diags.Append(query.Operation.Parse()) + + switch { + case json: + query.ViewType = ViewJSON + default: + query.ViewType = ViewHuman + } + + return query, diags +} diff --git a/internal/command/jsonformat/plan.go b/internal/command/jsonformat/plan.go index 7eb54899d1..ab33da9eb0 100644 --- a/internal/command/jsonformat/plan.go +++ b/internal/command/jsonformat/plan.go @@ -10,6 +10,8 @@ import ( "sort" "strings" + "slices" + "github.com/hashicorp/terraform/internal/command/format" "github.com/hashicorp/terraform/internal/command/jsonformat/computed" "github.com/hashicorp/terraform/internal/command/jsonformat/computed/renderers" @@ -49,12 +51,7 @@ func (plan Plan) getSchema(change jsonplan.ResourceChange) *jsonprovider.Schema func (plan Plan) renderHuman(renderer Renderer, mode plans.Mode, opts ...plans.Quality) { checkOpts := func(target plans.Quality) bool { - for _, opt := range opts { - if opt == target { - return true - } - } - return false + return slices.Contains(opts, target) } diffs := precomputeDiffs(plan, mode) diff --git a/internal/command/jsonformat/renderer.go b/internal/command/jsonformat/renderer.go index a3463ac8c5..936aad6897 100644 --- a/internal/command/jsonformat/renderer.go +++ b/internal/command/jsonformat/renderer.go @@ -43,6 +43,8 @@ type JSONLog struct { TestFatalInterrupt *viewsjson.TestFatalInterrupt `json:"test_interrupt,omitempty"` TestState *State `json:"test_state,omitempty"` TestPlan *Plan `json:"test_plan,omitempty"` + + ListQueryResult *viewsjson.QueryResult `json:"list_resource_found,omitempty"` } const ( @@ -78,6 +80,10 @@ const ( LogTestInterrupt JSONLogType = "test_interrupt" LogTestStatus JSONLogType = "test_status" LogTestRetry JSONLogType = "test_retry" + + // Query Messages + LogListStart JSONLogType = "list_start" + LogListResourceFound JSONLogType = "list_resource_found" ) func incompatibleVersions(localVersion, remoteVersion string) bool { @@ -156,7 +162,8 @@ func (renderer Renderer) RenderLog(log *JSONLog) error { LogTestRetry, LogTestPlan, LogTestState, - LogTestInterrupt: + LogTestInterrupt, + LogListStart: // We won't display these types of logs return nil @@ -290,6 +297,15 @@ func (renderer Renderer) RenderLog(log *JSONLog) error { } } + case LogListResourceFound: + // TODO: revisit once the cloud backend support list runs + // We will need to transform the identity to a more human-readable form + result := log.ListQueryResult + renderer.Streams.Printf("%s\t%s\t%s\n", + result.Address, + result.Identity, + result.DisplayName) + default: // If the log type is not a known log type, we will just print the log message renderer.Streams.Println(log.Message) diff --git a/internal/command/meta_config.go b/internal/command/meta_config.go index 922518f7ab..3887d77db4 100644 --- a/internal/command/meta_config.go +++ b/internal/command/meta_config.go @@ -38,7 +38,7 @@ func (m *Meta) normalizePath(path string) string { // loadConfig reads a configuration from the given directory, which should // contain a root module and have already have any required descendant modules // installed. -func (m *Meta) loadConfig(rootDir string) (*configs.Config, tfdiags.Diagnostics) { +func (m *Meta) loadConfig(rootDir string, parserOpts ...configs.Option) (*configs.Config, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics rootDir = m.normalizePath(rootDir) @@ -48,7 +48,7 @@ func (m *Meta) loadConfig(rootDir string) (*configs.Config, tfdiags.Diagnostics) return nil, diags } - config, hclDiags := loader.LoadConfig(rootDir) + config, hclDiags := loader.LoadConfig(rootDir, parserOpts...) diags = diags.Append(hclDiags) return config, diags } diff --git a/internal/command/query.go b/internal/command/query.go new file mode 100644 index 0000000000..1cfc88a695 --- /dev/null +++ b/internal/command/query.go @@ -0,0 +1,221 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "fmt" + "strings" + + "github.com/hashicorp/terraform/internal/backend/backendrun" + "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/command/views" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +type QueryCommand struct { + Meta +} + +func (c *QueryCommand) Help() string { + helpText := ` +Usage: terraform [global options] query [options] + + Queries the remote infrastructure for resources. + + Terraform will search for .tfquery.hcl files within the current configuration. + Terraform will then use the configured providers to query the remote + infrastructure for resources that match the defined list blocks. The results + will be printed to the terminal and optionally can be used to generate + configuration. + +Query Customization Options: + + The following options customize how Terraform will run the query. + + -var 'foo=bar' Set a value for one of the input variables in the query + file of the configuration. Use this option more than + once to set more than one variable. + + -var-file=filename Load variable values from the given file, in addition + to the default files terraform.tfvars and *.auto.tfvars. + Use this option more than once to include more than one + variables file. + +Other Options: + + -generate-config-out=path Instructs Terraform to generate import and resource + blocks for any found results. The configuration is + written to a new file at PATH, which must not + already exist. When this option is used with the + json option, the generated configuration will be + part of the JSON output instead of written to a + file. + + -json If specified, machine readable output will be + printed in JSON format + + -no-color If specified, output won't contain any color. + +` + return strings.TrimSpace(helpText) +} + +func (c *QueryCommand) Synopsis() string { + return "Search and list remote infrastructure with Terraform" +} + +func (c *QueryCommand) Run(rawArgs []string) int { + // Parse and apply global view arguments + common, rawArgs := arguments.ParseView(rawArgs) + c.View.Configure(common) + + // Propagate -no-color for legacy use of Ui. The remote backend and + // cloud package use this; it should be removed when/if they are + // migrated to views. + c.Meta.color = !common.NoColor + c.Meta.Color = c.Meta.color + + // Parse and validate flags + args, diags := arguments.ParseQuery(rawArgs) + + // Instantiate the view, even if there are flag errors, so that we render + // diagnostics according to the desired view + view := views.NewQuery(args.ViewType, c.View) + + if diags.HasErrors() { + view.Diagnostics(diags) + view.HelpPrompt() + return 1 + } + + // Check for user-supplied plugin path + var err error + if c.pluginPath, err = c.loadPluginPath(); err != nil { + diags = diags.Append(err) + view.Diagnostics(diags) + return 1 + } + + // We currently don't support the paralleism flag in the query command, + // so we set it to the default value here. This avoids backend errors + // that check for deviant values. + c.Meta.parallelism = DefaultParallelism + + // Prepare the backend with the backend-specific arguments + be, beDiags := c.PrepareBackend(args.State, args.ViewType) + b, isRemoteBackend := be.(BackendWithRemoteTerraformVersion) + if isRemoteBackend && !b.IsLocalOperations() { + diags = diags.Append(c.providerDevOverrideRuntimeWarningsRemoteExecution()) + } else { + diags = diags.Append(c.providerDevOverrideRuntimeWarnings()) + } + diags = diags.Append(beDiags) + if diags.HasErrors() { + view.Diagnostics(diags) + return 1 + } + + // Build the operation request + opReq, opDiags := c.OperationRequest(be, view, args.ViewType, args.GenerateConfigPath) + diags = diags.Append(opDiags) + if diags.HasErrors() { + view.Diagnostics(diags) + return 1 + } + + // Collect variable value and add them to the operation request + diags = diags.Append(c.GatherVariables(opReq, args.Vars)) + if diags.HasErrors() { + view.Diagnostics(diags) + return 1 + } + + // Before we delegate to the backend, we'll print any warning diagnostics + // we've accumulated here, since the backend will start fresh with its own + // diagnostics. + view.Diagnostics(diags) + diags = nil + + // Perform the operation + op, err := c.RunOperation(be, opReq) + if err != nil { + diags = diags.Append(err) + view.Diagnostics(diags) + return 1 + } + + if op.Result != backendrun.OperationSuccess { + return op.Result.ExitStatus() + } + + return op.Result.ExitStatus() +} + +func (c *QueryCommand) PrepareBackend(args *arguments.State, viewType arguments.ViewType) (backendrun.OperationsBackend, tfdiags.Diagnostics) { + backendConfig, diags := c.loadBackendConfig(".") + if diags.HasErrors() { + return nil, diags + } + + // Load the backend + be, beDiags := c.Backend(&BackendOpts{ + Config: backendConfig, + ViewType: viewType, + }) + diags = diags.Append(beDiags) + if beDiags.HasErrors() { + return nil, diags + } + + return be, diags +} + +func (c *QueryCommand) OperationRequest( + be backendrun.OperationsBackend, + view views.Query, + viewType arguments.ViewType, + generateConfigOut string, +) (*backendrun.Operation, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + // Build the operation + opReq := c.Operation(be, viewType) + opReq.Hooks = view.Hooks() + opReq.ConfigDir = "." + opReq.Type = backendrun.OperationTypePlan + opReq.GenerateConfigOut = generateConfigOut + opReq.View = view.Operation() + opReq.Query = true + + var err error + opReq.ConfigLoader, err = c.initConfigLoader() + if err != nil { + diags = diags.Append(fmt.Errorf("Failed to initialize config loader: %s", err)) + return nil, diags + } + + return opReq, diags +} + +func (c *QueryCommand) GatherVariables(opReq *backendrun.Operation, args *arguments.Vars) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + // FIXME the arguments package currently trivially gathers variable related + // arguments in a heterogenous slice, in order to minimize the number of + // code paths gathering variables during the transition to this structure. + // Once all commands that gather variables have been converted to this + // structure, we could move the variable gathering code to the arguments + // package directly, removing this shim layer. + + varArgs := args.All() + items := make([]arguments.FlagNameValue, len(varArgs)) + for i := range varArgs { + items[i].Name = varArgs[i].Name + items[i].Value = varArgs[i].Value + } + c.Meta.variableArgs = arguments.FlagNameValueSlice{Items: &items} + opReq.Variables, diags = c.collectVariableValues() + + return diags +} diff --git a/internal/command/views/hook_json.go b/internal/command/views/hook_json.go index 4d05849bd9..081036ab18 100644 --- a/internal/command/views/hook_json.go +++ b/internal/command/views/hook_json.go @@ -5,6 +5,7 @@ package views import ( "bufio" + "fmt" "strings" "sync" "time" @@ -231,3 +232,32 @@ func (h *jsonHook) PostEphemeralOp(id terraform.HookResourceIdentity, action pla return terraform.HookActionContinue, nil } + +func (h *jsonHook) PreListQuery(id terraform.HookResourceIdentity, input_config cty.Value) (terraform.HookAction, error) { + addr := id.Addr + h.view.log.Info( + fmt.Sprintf("%s: Starting query...", addr.String()), + "type", json.MessageListStart, + json.MessageListStart, json.NewQueryStart(addr, input_config), + ) + + return terraform.HookActionContinue, nil +} + +func (h *jsonHook) PostListQuery(id terraform.HookResourceIdentity, results plans.QueryResults) (terraform.HookAction, error) { + addr := id.Addr + data := results.Value.GetAttr("data") + for it := data.ElementIterator(); it.Next(); { + _, value := it.Element() + + result := json.NewQueryResult(addr, value) + + h.view.log.Info( + fmt.Sprintf("%s: Result found", addr.String()), + "type", json.MessageListResourceFound, + json.MessageListResourceFound, result, + ) + } + + return terraform.HookActionContinue, nil +} diff --git a/internal/command/views/hook_ui.go b/internal/command/views/hook_ui.go index aff1a6316c..7cdfefaed8 100644 --- a/internal/command/views/hook_ui.go +++ b/internal/command/views/hook_ui.go @@ -507,6 +507,28 @@ func (h *UiHook) PostEphemeralOp(rId terraform.HookResourceIdentity, action plan return terraform.HookActionContinue, nil } +func (h *UiHook) PreListQuery(id terraform.HookResourceIdentity, input_config cty.Value) (terraform.HookAction, error) { + return terraform.HookActionContinue, nil +} + +func (h *UiHook) PostListQuery(id terraform.HookResourceIdentity, results plans.QueryResults) (terraform.HookAction, error) { + addr := id.Addr + data := results.Value.GetAttr("data") + for it := data.ElementIterator(); it.Next(); { + _, value := it.Element() + + h.println(fmt.Sprintf( + "%s\t%s\t%s", + addr.String(), + // TODO maybe deduplicate common identity attributes? + tfdiags.ObjectToString(value.GetAttr("identity")), + value.GetAttr("display_name").AsString(), + )) + } + + return terraform.HookActionContinue, nil +} + // Wrap calls to the view so that concurrent calls do not interleave println. func (h *UiHook) println(s string) { h.viewLock.Lock() diff --git a/internal/command/views/json/message_types.go b/internal/command/views/json/message_types.go index 3c3ec299bb..68107f454c 100644 --- a/internal/command/views/json/message_types.go +++ b/internal/command/views/json/message_types.go @@ -46,4 +46,8 @@ const ( MessageTestInterrupt MessageType = "test_interrupt" MessageTestStatus MessageType = "test_status" MessageTestRetry MessageType = "test_retry" + + // List messages + MessageListStart MessageType = "list_start" + MessageListResourceFound MessageType = "list_resource_found" ) diff --git a/internal/command/views/json/query.go b/internal/command/views/json/query.go new file mode 100644 index 0000000000..27aa73c51a --- /dev/null +++ b/internal/command/views/json/query.go @@ -0,0 +1,61 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package json + +import ( + "encoding/json" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/zclconf/go-cty/cty" + ctyjson "github.com/zclconf/go-cty/cty/json" +) + +type QueryStart struct { + Address string `json:"address"` + ResourceType string `json:"resource_type"` + InputConfig map[string]json.RawMessage `json:"input_config,omitempty"` +} + +type QueryResult struct { + Address string `json:"address"` + DisplayName string `json:"display_name"` + Identity map[string]json.RawMessage `json:"identity"` + ResourceType string `json:"resource_type"` + ResourceObject map[string]json.RawMessage `json:"resource_object,omitempty"` + Config string `json:"config,omitempty"` +} + +func NewQueryStart(addr addrs.AbsResourceInstance, input_config cty.Value) QueryStart { + return QueryStart{ + Address: addr.String(), + ResourceType: addr.Resource.Resource.Type, + InputConfig: marshalValues(input_config), + } +} + +func NewQueryResult(addr addrs.AbsResourceInstance, value cty.Value) QueryResult { + return QueryResult{ + Address: addr.String(), + DisplayName: value.GetAttr("display_name").AsString(), + Identity: marshalValues(value.GetAttr("identity")), + ResourceType: addr.Resource.Resource.Type, + ResourceObject: marshalValues(value.GetAttr("state")), + // TODO: Add config once we have it available + } +} + +func marshalValues(value cty.Value) map[string]json.RawMessage { + if value == cty.NilVal || value.IsNull() { + return nil + } + + ret := make(map[string]json.RawMessage) + it := value.ElementIterator() + for it.Next() { + k, v := it.Element() + vJSON, _ := ctyjson.Marshal(v, v.Type()) + ret[k.AsString()] = json.RawMessage(vJSON) + } + return ret +} diff --git a/internal/command/views/query.go b/internal/command/views/query.go new file mode 100644 index 0000000000..7d15c41f0e --- /dev/null +++ b/internal/command/views/query.go @@ -0,0 +1,85 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package views + +import ( + "fmt" + + "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/terraform" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// Query renders outputs for query executions. +type Query interface { + Operation() Operation + Hooks() []terraform.Hook + + Diagnostics(diags tfdiags.Diagnostics) + HelpPrompt() +} + +func NewQuery(vt arguments.ViewType, view *View) Query { + switch vt { + case arguments.ViewJSON: + return &QueryJSON{ + view: NewJSONView(view), + } + case arguments.ViewHuman: + return &QueryHuman{ + view: view, + inAutomation: view.RunningInAutomation(), + } + default: + panic(fmt.Sprintf("unknown view type %v", vt)) + } +} + +type QueryHuman struct { + view *View + + inAutomation bool +} + +var _ Query = (*QueryHuman)(nil) + +func (v *QueryHuman) Operation() Operation { + return NewQueryOperation(arguments.ViewHuman, v.inAutomation, v.view) +} + +func (v *QueryHuman) Hooks() []terraform.Hook { + return []terraform.Hook{ + NewUiHook(v.view), + } +} + +func (v *QueryHuman) Diagnostics(diags tfdiags.Diagnostics) { + v.view.Diagnostics(diags) +} +func (v *QueryHuman) HelpPrompt() { + v.view.HelpPrompt("query") +} + +type QueryJSON struct { + view *JSONView +} + +var _ Query = (*QueryJSON)(nil) + +func (v *QueryJSON) Operation() Operation { + return &QueryOperationJSON{view: v.view} +} + +func (v *QueryJSON) Hooks() []terraform.Hook { + return []terraform.Hook{ + newJSONHook(v.view), + } +} + +func (v *QueryJSON) Diagnostics(diags tfdiags.Diagnostics) { + v.view.Diagnostics(diags) +} + +func (v *QueryJSON) HelpPrompt() { +} diff --git a/internal/command/views/query_operation.go b/internal/command/views/query_operation.go new file mode 100644 index 0000000000..f97b298e2c --- /dev/null +++ b/internal/command/views/query_operation.go @@ -0,0 +1,111 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package views + +import ( + "fmt" + + "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/command/format" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/states/statefile" + "github.com/hashicorp/terraform/internal/terraform" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +func NewQueryOperation(vt arguments.ViewType, inAutomation bool, view *View) Operation { + switch vt { + case arguments.ViewHuman: + return &QueryOperationHuman{view: view, inAutomation: inAutomation} + default: + panic(fmt.Sprintf("unknown view type %v", vt)) + } +} + +type QueryOperationHuman struct { + view *View + + // inAutomation indicates that commands are being run by an + // automated system rather than directly at a command prompt. + // + // This is a hint not to produce messages that expect that a user can + // run a follow-up command, perhaps because Terraform is running in + // some sort of workflow automation tool that abstracts away the + // exact commands that are being run. + inAutomation bool +} + +var _ Operation = (*QueryOperationHuman)(nil) + +func (v *QueryOperationHuman) Interrupted() { + v.view.streams.Println(format.WordWrap(interrupted, v.view.outputColumns())) +} + +func (v *QueryOperationHuman) FatalInterrupt() { + v.view.streams.Eprintln(format.WordWrap(fatalInterrupt, v.view.errorColumns())) +} + +func (v *QueryOperationHuman) Stopping() { + v.view.streams.Println("Stopping operation...") +} + +func (v *QueryOperationHuman) Cancelled(planMode plans.Mode) { + v.view.streams.Println("Query cancelled.") +} + +func (v *QueryOperationHuman) EmergencyDumpState(stateFile *statefile.File) error { + return nil +} + +func (v *QueryOperationHuman) Plan(plan *plans.Plan, schemas *terraform.Schemas) { +} + +func (v *QueryOperationHuman) PlannedChange(change *plans.ResourceInstanceChangeSrc) { +} + +func (v *QueryOperationHuman) PlanNextStep(planPath string, genConfigPath string) { +} + +func (v *QueryOperationHuman) Diagnostics(diags tfdiags.Diagnostics) { + v.view.Diagnostics(diags) +} + +type QueryOperationJSON struct { + view *JSONView +} + +var _ Operation = (*QueryOperationJSON)(nil) + +func (v *QueryOperationJSON) Interrupted() { + v.view.Log(interrupted) +} + +func (v *QueryOperationJSON) FatalInterrupt() { + v.view.Log(fatalInterrupt) +} + +func (v *QueryOperationJSON) Stopping() { + v.view.Log("Stopping operation...") +} + +func (v *QueryOperationJSON) Cancelled(planMode plans.Mode) { + v.view.Log("Query cancelled") +} + +func (v *QueryOperationJSON) EmergencyDumpState(stateFile *statefile.File) error { + return nil +} + +func (v *QueryOperationJSON) Plan(plan *plans.Plan, schemas *terraform.Schemas) { +} + +func (v *QueryOperationJSON) PlannedChange(change *plans.ResourceInstanceChangeSrc) { +} + +func (v *QueryOperationJSON) PlanNextStep(planPath string, genConfigPath string) { +} + +func (v *QueryOperationJSON) Diagnostics(diags tfdiags.Diagnostics) { + v.view.Diagnostics(diags) +} diff --git a/internal/plans/changes.go b/internal/plans/changes.go index 88b20f151b..e2cb7dd187 100644 --- a/internal/plans/changes.go +++ b/internal/plans/changes.go @@ -22,6 +22,8 @@ type Changes struct { // Resources tracks planned changes to resource instance objects. Resources []*ResourceInstanceChange + Queries []*QueryInstance + // Outputs tracks planned changes output values. // // Note that although an in-memory plan contains planned changes for @@ -73,6 +75,24 @@ func (c *Changes) Encode(schemas *schemarepo.Schemas) (*ChangesSrc, error) { changesSrc.Resources = append(changesSrc.Resources, rcs) } + for _, qi := range c.Queries { + p, ok := schemas.Providers[qi.ProviderAddr.Provider] + if !ok { + return changesSrc, fmt.Errorf("Changes.Encode: missing provider %s for %s", qi.ProviderAddr, qi.Addr) + } + + schema := p.ListResourceTypes[qi.Addr.Resource.Resource.Type] + if schema.Body == nil { + return changesSrc, fmt.Errorf("Changes.Encode: missing schema for %s", qi.Addr) + } + rcs, err := qi.Encode(schema) + if err != nil { + return changesSrc, fmt.Errorf("Changes.Encode: %w", err) + } + + changesSrc.Queries = append(changesSrc.Queries, rcs) + } + for _, ocs := range c.Outputs { oc, err := ocs.Encode() if err != nil { @@ -113,6 +133,18 @@ func (c *Changes) InstancesForAbsResource(addr addrs.AbsResource) []*ResourceIns return changes } +func (c *Changes) QueriesForAbsResource(addr addrs.AbsResource) []*QueryInstance { + var queries []*QueryInstance + for _, q := range c.Queries { + qAddr := q.Addr.ContainingResource() + if qAddr.Equal(addr) { + queries = append(queries, q) + } + } + + return queries +} + // InstancesForConfigResource returns the planned change for the current objects // of the resource instances of the given address, if any. Returns nil if no // changes are planned. @@ -210,6 +242,40 @@ func (c *Changes) SyncWrapper() *ChangesSync { } } +type QueryInstance struct { + Addr addrs.AbsResourceInstance + + ProviderAddr addrs.AbsProviderConfig + + Results QueryResults +} + +type QueryResults struct { + Value cty.Value +} + +func (qi *QueryInstance) DeepCopy() *QueryInstance { + if qi == nil { + return qi + } + + ret := *qi + return &ret +} + +func (rc *QueryInstance) Encode(schema providers.Schema) (*QueryInstanceSrc, error) { + results, err := NewDynamicValue(rc.Results.Value, schema.Body.ImpliedType()) + if err != nil { + return nil, err + } + + return &QueryInstanceSrc{ + Addr: rc.Addr, + Results: results, + ProviderAddr: rc.ProviderAddr, + }, nil +} + // ResourceInstanceChange describes a change to a particular resource instance // object. type ResourceInstanceChange struct { @@ -293,7 +359,6 @@ func (rc *ResourceInstanceChange) Encode(schema providers.Schema) (*ResourceInst prevRunAddr = rc.Addr } return &ResourceInstanceChangeSrc{ - Addr: rc.Addr, PrevRunAddr: prevRunAddr, DeposedKey: rc.DeposedKey, diff --git a/internal/plans/changes_src.go b/internal/plans/changes_src.go index 8482cbe32c..023dca14ec 100644 --- a/internal/plans/changes_src.go +++ b/internal/plans/changes_src.go @@ -24,6 +24,8 @@ type ChangesSrc struct { // Resources tracks planned changes to resource instance objects. Resources []*ResourceInstanceChangeSrc + Queries []*QueryInstanceSrc + // Outputs tracks planned changes output values. // // Note that although an in-memory plan contains planned changes for @@ -114,8 +116,6 @@ func (c *ChangesSrc) Decode(schemas *schemarepo.Schemas) (*Changes, error) { schema = p.ResourceTypes[rcs.Addr.Resource.Resource.Type] case addrs.DataResourceMode: schema = p.DataSources[rcs.Addr.Resource.Resource.Type] - case addrs.ListResourceMode: - schema = p.ListResourceTypes[rcs.Addr.Resource.Resource.Type] default: panic(fmt.Sprintf("unexpected resource mode %s", rcs.Addr.Resource.Resource.Mode)) } @@ -135,6 +135,25 @@ func (c *ChangesSrc) Decode(schemas *schemarepo.Schemas) (*Changes, error) { changes.Resources = append(changes.Resources, rc) } + for _, qis := range c.Queries { + p, ok := schemas.Providers[qis.ProviderAddr.Provider] + if !ok { + return nil, fmt.Errorf("ChangesSrc.Decode: missing provider %s for %s", qis.ProviderAddr, qis.Addr) + } + schema := p.ListResourceTypes[qis.Addr.Resource.Resource.Type] + + if schema.Body == nil { + return nil, fmt.Errorf("ChangesSrc.Decode: missing schema for %s", qis.Addr) + } + + query, err := qis.Decode(schema) + if err != nil { + return nil, err + } + + changes.Queries = append(changes.Queries, query) + } + for _, ocs := range c.Outputs { oc, err := ocs.Decode() if err != nil { @@ -156,6 +175,29 @@ func (c *ChangesSrc) AppendResourceInstanceChange(change *ResourceInstanceChange c.Resources = append(c.Resources, s) } +type QueryInstanceSrc struct { + Addr addrs.AbsResourceInstance + + ProviderAddr addrs.AbsProviderConfig + + Results DynamicValue +} + +func (qis *QueryInstanceSrc) Decode(schema providers.Schema) (*QueryInstance, error) { + query, err := qis.Results.Decode(schema.Body.ImpliedType()) + if err != nil { + return nil, err + } + + return &QueryInstance{ + Addr: qis.Addr, + Results: QueryResults{ + Value: query, + }, + ProviderAddr: qis.ProviderAddr, + }, nil +} + // ResourceInstanceChangeSrc is a not-yet-decoded ResourceInstanceChange. // Pass the associated resource type's schema type to method Decode to // obtain a ResourceInstanceChange. diff --git a/internal/plans/changes_sync.go b/internal/plans/changes_sync.go index fe2197224c..8a6b7e5cc3 100644 --- a/internal/plans/changes_sync.go +++ b/internal/plans/changes_sync.go @@ -39,6 +39,17 @@ func (cs *ChangesSync) AppendResourceInstanceChange(change *ResourceInstanceChan cs.changes.Resources = append(cs.changes.Resources, s) } +func (cs *ChangesSync) AppendQueryInstance(query *QueryInstance) { + if cs == nil { + panic("AppendQueryInstance on nil ChangesSync") + } + cs.lock.Lock() + defer cs.lock.Unlock() + + s := query.DeepCopy() // TODO do we need to deep copy here? + cs.changes.Queries = append(cs.changes.Queries, s) +} + // GetResourceInstanceChange searches the set of resource instance changes for // one matching the given address and deposed key, returning it if it exists. // Use [addrs.NotDeposed] as the deposed key to represent the "current" @@ -106,6 +117,19 @@ func (cs *ChangesSync) GetChangesForAbsResource(addr addrs.AbsResource) []*Resou return changes } +func (cs *ChangesSync) GetQueryInstancesForAbsResource(addr addrs.AbsResource) []*QueryInstance { + if cs == nil { + panic("GetQueryInstancesForAbsResource on nil ChangesSync") + } + cs.lock.Lock() + defer cs.lock.Unlock() + var queries []*QueryInstance + for _, q := range cs.changes.QueriesForAbsResource(addr) { + queries = append(queries, q.DeepCopy()) + } + return queries +} + // RemoveResourceInstanceChange searches the set of resource instance changes // for one matching the given address and deposed key, and removes it from the // set if it exists. diff --git a/internal/terraform/context_plan_query_test.go b/internal/terraform/context_plan_query_test.go index 53c8bbf168..5d6fc6f237 100644 --- a/internal/terraform/context_plan_query_test.go +++ b/internal/terraform/context_plan_query_test.go @@ -112,20 +112,16 @@ func TestContext2Plan_queryList(t *testing.T) { "list.test_resource.test2": {}, } actualResources := map[string][]string{} - for _, change := range changes.Resources { + for _, change := range changes.Queries { schema := sch.ListResourceTypes[change.Addr.Resource.Resource.Type] cs, err := change.Decode(schema) if err != nil { t.Fatalf("failed to decode change: %s", err) } - if cs.Change.Action != plans.Read { - t.Fatalf("expected action to be Read, got %s", cs.Change.Action) - } - // Verify instance types actualTypes := make([]string, 0) - obj := cs.After.GetAttr("data") + obj := cs.Results.Value.GetAttr("data") if obj.IsNull() { t.Fatalf("Expected 'data' attribute to be present, but it is null") } @@ -229,7 +225,7 @@ func TestContext2Plan_queryList(t *testing.T) { assertChanges: func(sch providers.ProviderSchema, changes *plans.ChangesSrc) { expectedResources := []string{"list.test_resource.test[0]", "list.test_resource.test2"} actualResources := make([]string, 0) - for _, change := range changes.Resources { + for _, change := range changes.Queries { actualResources = append(actualResources, change.Addr.String()) schema := sch.ListResourceTypes[change.Addr.Resource.Resource.Type] cs, err := change.Decode(schema) @@ -237,14 +233,10 @@ func TestContext2Plan_queryList(t *testing.T) { t.Fatalf("failed to decode change: %s", err) } - if cs.Change.Action != plans.Read { - t.Fatalf("expected action to be Read, got %s", cs.Change.Action) - } - // Verify instance types expectedTypes := []string{"ami-123456", "ami-654321"} actualTypes := make([]string, 0) - obj := cs.After.GetAttr("data") + obj := cs.Results.Value.GetAttr("data") if obj.IsNull() { t.Fatalf("Expected 'data' attribute to be present, but it is null") } @@ -535,7 +527,7 @@ func TestContext2Plan_queryList(t *testing.T) { assertChanges: func(sch providers.ProviderSchema, changes *plans.ChangesSrc) { expectedResources := []string{"list.test_resource.test1", "list.test_resource.test2"} actualResources := make([]string, 0) - for _, change := range changes.Resources { + for _, change := range changes.Queries { actualResources = append(actualResources, change.Addr.String()) schema := sch.ListResourceTypes[change.Addr.Resource.Resource.Type] cs, err := change.Decode(schema) @@ -543,14 +535,10 @@ func TestContext2Plan_queryList(t *testing.T) { t.Fatalf("failed to decode change: %s", err) } - if cs.Change.Action != plans.Read { - t.Fatalf("expected action to be Read, got %s", cs.Change.Action) - } - // Verify instance types expectedTypes := []string{"ami-123456"} actualTypes := make([]string, 0) - obj := cs.After.GetAttr("data") + obj := cs.Results.Value.GetAttr("data") if obj.IsNull() { t.Fatalf("Expected 'data' attribute to be present, but it is null") } @@ -650,7 +638,7 @@ func TestContext2Plan_queryList(t *testing.T) { assertChanges: func(sch providers.ProviderSchema, changes *plans.ChangesSrc) { expectedResources := []string{"list.test_resource.test1[\"foo\"]", "list.test_resource.test1[\"bar\"]", "list.test_resource.test2[\"foo\"]", "list.test_resource.test2[\"bar\"]"} actualResources := make([]string, 0) - for _, change := range changes.Resources { + for _, change := range changes.Queries { actualResources = append(actualResources, change.Addr.String()) schema := sch.ListResourceTypes[change.Addr.Resource.Resource.Type] cs, err := change.Decode(schema) @@ -658,14 +646,10 @@ func TestContext2Plan_queryList(t *testing.T) { t.Fatalf("failed to decode change: %s", err) } - if cs.Change.Action != plans.Read { - t.Fatalf("expected action to be Read, got %s", cs.Change.Action) - } - // Verify instance types expectedTypes := []string{"ami-123456"} actualTypes := make([]string, 0) - obj := cs.After.GetAttr("data") + obj := cs.Results.Value.GetAttr("data") if obj.IsNull() { t.Fatalf("Expected 'data' attribute to be present, but it is null") } diff --git a/internal/terraform/evaluate.go b/internal/terraform/evaluate.go index 5beb3f1c9b..87ca973026 100644 --- a/internal/terraform/evaluate.go +++ b/internal/terraform/evaluate.go @@ -846,9 +846,9 @@ func (d *evaluationStateData) getListResource(config *configs.Resource, rng tfdi return cty.DynamicVal, diags } resourceType := resourceSchema.Body.ImpliedType() - changes := d.Evaluator.Changes.GetChangesForAbsResource(lAddr.Absolute(d.ModulePath)) + queries := d.Evaluator.Changes.GetQueryInstancesForAbsResource(lAddr.Absolute(d.ModulePath)) - if len(changes) == 0 { + if len(queries) == 0 { // Since we know there are no instances, return an empty container of the expected type. switch { case config.Count != nil: @@ -865,7 +865,7 @@ func (d *evaluationStateData) getListResource(config *configs.Resource, rng tfdi case config.Count != nil: // figure out what the last index we have is length := -1 - for _, inst := range changes { + for _, inst := range queries { if intKey, ok := inst.Addr.Resource.Key.(addrs.IntKey); ok { length = max(int(intKey)+1, length) } @@ -873,10 +873,10 @@ func (d *evaluationStateData) getListResource(config *configs.Resource, rng tfdi if length > 0 { vals := make([]cty.Value, length) - for _, inst := range changes { + for _, inst := range queries { key := inst.Addr.Resource.Key if intKey, ok := key.(addrs.IntKey); ok { - vals[int(intKey)] = inst.After + vals[int(intKey)] = inst.Results.Value } } @@ -892,10 +892,10 @@ func (d *evaluationStateData) getListResource(config *configs.Resource, rng tfdi } case config.ForEach != nil: vals := make(map[string]cty.Value) - for _, inst := range changes { + for _, inst := range queries { key := inst.Addr.Resource.Key if strKey, ok := key.(addrs.StringKey); ok { - vals[string(strKey)] = inst.After + vals[string(strKey)] = inst.Results.Value } } @@ -909,13 +909,13 @@ func (d *evaluationStateData) getListResource(config *configs.Resource, rng tfdi ret = cty.EmptyObjectVal } default: - if len(changes) <= 0 { + if len(queries) <= 0 { // if the instance is missing, insert an empty tuple ret = cty.ObjectVal(map[string]cty.Value{ "data": cty.EmptyTupleVal, }) } else { - ret = changes[0].After + ret = queries[0].Results.Value } } diff --git a/internal/terraform/hook.go b/internal/terraform/hook.go index 4a6e4365fc..cae1511969 100644 --- a/internal/terraform/hook.go +++ b/internal/terraform/hook.go @@ -103,6 +103,11 @@ type Hook interface { PreEphemeralOp(id HookResourceIdentity, action plans.Action) (HookAction, error) PostEphemeralOp(id HookResourceIdentity, action plans.Action, opErr error) (HookAction, error) + // PreListQuery and PostListQuery are called during a query operation before and after + // resources are queried from the provider. + PreListQuery(id HookResourceIdentity, input_config cty.Value) (HookAction, error) + PostListQuery(id HookResourceIdentity, results plans.QueryResults) (HookAction, error) + // Stopping is called if an external signal requests that Terraform // gracefully abort an operation in progress. // @@ -209,6 +214,14 @@ func (h *NilHook) PostEphemeralOp(id HookResourceIdentity, action plans.Action, return HookActionContinue, nil } +func (h *NilHook) PreListQuery(id HookResourceIdentity, input_config cty.Value) (HookAction, error) { + return HookActionContinue, nil +} + +func (h *NilHook) PostListQuery(id HookResourceIdentity, results plans.QueryResults) (HookAction, error) { + return HookActionContinue, nil +} + func (*NilHook) Stopping() { // Does nothing at all by default } diff --git a/internal/terraform/hook_mock.go b/internal/terraform/hook_mock.go index b15ae345de..766a319231 100644 --- a/internal/terraform/hook_mock.go +++ b/internal/terraform/hook_mock.go @@ -142,6 +142,16 @@ type MockHook struct { PostEphemeralOpReturn HookAction PostEphemeralOpReturnError error + PreListQueryCalled bool + PreListQueryAddr addrs.AbsResourceInstance + PreListQueryReturn HookAction + PreListQueryReturnError error + + PostListQueryCalled bool + PostListQueryAddr addrs.AbsResourceInstance + PostListQueryReturn HookAction + PostListQueryReturnError error + StoppingCalled bool PostStateUpdateCalled bool @@ -346,6 +356,24 @@ func (h *MockHook) PostEphemeralOp(id HookResourceIdentity, action plans.Action, return h.PostEphemeralOpReturn, h.PostEphemeralOpReturnError } +func (h *MockHook) PreListQuery(id HookResourceIdentity, input_config cty.Value) (HookAction, error) { + h.Lock() + defer h.Unlock() + + h.PreListQueryCalled = true + h.PreListQueryAddr = id.Addr + return h.PreListQueryReturn, h.PreListQueryReturnError +} + +func (h *MockHook) PostListQuery(id HookResourceIdentity, results plans.QueryResults) (HookAction, error) { + h.Lock() + defer h.Unlock() + + h.PostListQueryCalled = true + h.PostListQueryAddr = id.Addr + return h.PostListQueryReturn, h.PostListQueryReturnError +} + func (h *MockHook) Stopping() { h.Lock() defer h.Unlock() diff --git a/internal/terraform/hook_stop.go b/internal/terraform/hook_stop.go index 11a38aacbe..86e697eb8d 100644 --- a/internal/terraform/hook_stop.go +++ b/internal/terraform/hook_stop.go @@ -98,6 +98,14 @@ func (h *stopHook) PostEphemeralOp(id HookResourceIdentity, action plans.Action, return h.hook() } +func (h *stopHook) PreListQuery(id HookResourceIdentity, input_config cty.Value) (HookAction, error) { + return h.hook() +} + +func (h *stopHook) PostListQuery(id HookResourceIdentity, results plans.QueryResults) (HookAction, error) { + return h.hook() +} + func (h *stopHook) Stopping() {} func (h *stopHook) PostStateUpdate(new *states.State) (HookAction, error) { diff --git a/internal/terraform/hook_test.go b/internal/terraform/hook_test.go index d4230fcf44..2bc676c1e5 100644 --- a/internal/terraform/hook_test.go +++ b/internal/terraform/hook_test.go @@ -169,6 +169,20 @@ func (h *testHook) PostEphemeralOp(id HookResourceIdentity, action plans.Action, return HookActionContinue, nil } +func (h *testHook) PreListQuery(id HookResourceIdentity, input_config cty.Value) (HookAction, error) { + h.mu.Lock() + defer h.mu.Unlock() + h.Calls = append(h.Calls, &testHookCall{"PreListQuery", id.Addr.String()}) + return HookActionContinue, nil +} + +func (h *testHook) PostListQuery(id HookResourceIdentity, results plans.QueryResults) (HookAction, error) { + h.mu.Lock() + defer h.mu.Unlock() + h.Calls = append(h.Calls, &testHookCall{"PostListQuery", id.Addr.String()}) + return HookActionContinue, nil +} + func (h *testHook) Stopping() { h.mu.Lock() defer h.mu.Unlock() diff --git a/internal/terraform/node_resource_plan_instance_query.go b/internal/terraform/node_resource_plan_instance_query.go index c5cc9ad4d8..f79f130804 100644 --- a/internal/terraform/node_resource_plan_instance_query.go +++ b/internal/terraform/node_resource_plan_instance_query.go @@ -9,9 +9,7 @@ import ( "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/providers" - "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/tfdiags" - "github.com/zclconf/go-cty/cty" ) func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (diags tfdiags.Diagnostics) { @@ -76,6 +74,13 @@ func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (di return diags } + rId := HookResourceIdentity{ + Addr: addr, + ProviderAddr: n.ResolvedProvider.Provider, + } + ctx.Hook(func(h Hook) (HookAction, error) { + return h.PreListQuery(rId, unmarkedBlockVal.GetAttr("config")) + }) // If we get down here then our configuration is complete and we're ready // to actually call the provider to list the data. resp := provider.ListResource(providers.ListResourceRequest{ @@ -84,21 +89,23 @@ func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (di Limit: limit, IncludeResourceObject: includeResource, }) - if resp.Diagnostics != nil { - return diags.Append(resp.Diagnostics.InConfigBody(config.Config, n.Addr.String())) + results := plans.QueryResults{ + Value: resp.Result, + } + ctx.Hook(func(h Hook) (HookAction, error) { + return h.PostListQuery(rId, results) + }) + diags = diags.Append(resp.Diagnostics.InConfigBody(config.Config, n.Addr.String())) + if diags.HasErrors() { + return diags } - change := &plans.ResourceInstanceChange{ + query := &plans.QueryInstance{ Addr: n.Addr, ProviderAddr: n.ResolvedProvider, - Change: plans.Change{ - Action: plans.Read, - Before: cty.DynamicVal, - After: resp.Result, - }, - DeposedKey: states.NotDeposed, + Results: results, } - ctx.Changes().AppendResourceInstanceChange(change) + ctx.Changes().AppendQueryInstance(query) return diags }