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
This commit is contained in:
Daniel Banck 2025-07-02 15:06:25 +02:00 committed by GitHub
parent 577f8f02ae
commit 2b9d25c7fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 885 additions and 59 deletions

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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
}

View file

@ -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)

View file

@ -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)

View file

@ -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
}

221
internal/command/query.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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()

View file

@ -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"
)

View file

@ -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
}

View file

@ -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() {
}

View file

@ -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)
}

View file

@ -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,

View file

@ -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.

View file

@ -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.

View file

@ -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")
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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()

View file

@ -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) {

View file

@ -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()

View file

@ -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
}