From a03a05db3a31febaa64d6cef3a72d4a309e8ce52 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Mon, 26 May 2025 17:20:29 +0200 Subject: [PATCH 01/15] WIP --- commands.go | 6 + internal/command/arguments/query.go | 51 ++++++ internal/command/meta_config.go | 4 +- internal/command/query.go | 165 +++++++++++++++++ internal/command/views/query.go | 225 ++++++++++++++++++++++++ internal/configs/parser_file_matcher.go | 7 + 6 files changed, 456 insertions(+), 2 deletions(-) create mode 100644 internal/command/arguments/query.go create mode 100644 internal/command/query.go create mode 100644 internal/command/views/query.go diff --git a/commands.go b/commands.go index 29e228b6af2e..bbb66df5af90 100644 --- a/commands.go +++ b/commands.go @@ -276,6 +276,12 @@ func initCommands( }, nil }, + "query": func() (cli.Command, error) { + return &command.QueryCommand{ + Meta: meta, + }, nil + }, + "refresh": func() (cli.Command, error) { return &command.RefreshCommand{ Meta: meta, diff --git a/internal/command/arguments/query.go b/internal/command/arguments/query.go new file mode 100644 index 000000000000..a3422a9b6f3f --- /dev/null +++ b/internal/command/arguments/query.go @@ -0,0 +1,51 @@ +// 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 { + // ViewType specifies which output format to use: human or JSON. + ViewType ViewType + + // You can specify common variables for all tests from the command line. + Vars *Vars + + // Verbose tells the test command to print out the plan either in + // human-readable format or JSON for each run step depending on the + // ViewType. + Verbose bool +} + +func ParseQuery(args []string) (*Query, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + query := Query{ + Vars: new(Vars), + } + + var jsonOutput bool + cmdFlags := extendedFlagSet("query", nil, nil, query.Vars) + cmdFlags.BoolVar(&jsonOutput, "json", false, "json") + cmdFlags.BoolVar(&query.Verbose, "verbose", false, "verbose") + + if err := cmdFlags.Parse(args); err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to parse command-line flags", + err.Error())) + } + + switch { + case jsonOutput: + query.ViewType = ViewJSON + default: + query.ViewType = ViewHuman + } + + return &query, diags +} diff --git a/internal/command/meta_config.go b/internal/command/meta_config.go index 922518f7abc7..3887d77db420 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 000000000000..11e61148b6aa --- /dev/null +++ b/internal/command/query.go @@ -0,0 +1,165 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "context" + "strings" + "time" + + "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/command/jsonformat" + "github.com/hashicorp/terraform/internal/command/views" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/logging" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +type QueryCommand struct { + Meta +} + +func (c *QueryCommand) Help() string { + helpText := ` +Usage: terraform [global options] query [options] + TBD +Options: + -json If specified, machine readable output will be printed in + JSON format + -no-color If specified, output won't contain any color. + -var 'foo=bar' Set a value for one of the input variables in the root + module 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. +` + return strings.TrimSpace(helpText) +} + +func (c *QueryCommand) Synopsis() string { + return "Search and list remote infrastructure with Terraform" +} + +func (c *QueryCommand) Run(rawArgs []string) int { + var diags tfdiags.Diagnostics + + common, rawArgs := arguments.ParseView(rawArgs) + c.View.Configure(common) + + args, diags := arguments.ParseQuery(rawArgs) + if diags.HasErrors() { + c.View.Diagnostics(diags) + c.View.HelpPrompt("query") + return 1 + } + + view := views.NewQuery(args.ViewType, c.View) + + _, configDiags := c.loadConfig(".", configs.MatchQueryFiles()) + diags = diags.Append(configDiags) + if configDiags.HasErrors() { + view.Diagnostics(nil, diags) + return 1 + } + + // Users can also specify variables via the command line, so we'll parse + // all that here. + var items []arguments.FlagNameValue + for _, variable := range args.Vars.All() { + items = append(items, arguments.FlagNameValue{ + Name: variable.Name, + Value: variable.Value, + }) + } + c.variableArgs = arguments.FlagNameValueSlice{Items: &items} + + _, variableDiags := c.collectVariableValues() // TODO: collect query variables? + diags = diags.Append(variableDiags) + if variableDiags.HasErrors() { + view.Diagnostics(nil, diags) + return 1 + } + + _, err := c.contextOpts() + if err != nil { + diags = diags.Append(err) + view.Diagnostics(nil, diags) + return 1 + } + + view.Diagnostics(nil, diags) + + runningCtx, done := context.WithCancel(context.Background()) + _, stop := context.WithCancel(runningCtx) + _, cancel := context.WithCancel(context.Background()) + + hasCloudBackend := false // TODO fetch from config + if hasCloudBackend { + var renderer *jsonformat.Renderer + if args.ViewType == arguments.ViewHuman { + // We only set the renderer if we want Human-readable output. + // Otherwise, we just let the runner echo whatever data it receives + // back from the agent anyway. + renderer = &jsonformat.Renderer{ + Streams: c.Streams, + Colorize: c.Colorize(), + RunningInAutomation: c.RunningInAutomation, + } + } + + // TODO: run cloud query + _ = renderer + } else { + // TODO: run local query + } + + var queryDiags tfdiags.Diagnostics + + go func() { + defer logging.PanicHandler() + defer done() + defer stop() + defer cancel() + + // TODO: RUN + }() + + // Wait for the operation to complete, or for an interrupt to occur. + select { + case <-c.ShutdownCh: + // Nice request to be cancelled. + + view.Interrupted() + // runner.Stop() + stop() + + select { + case <-c.ShutdownCh: + // The user pressed it again, now we have to get it to stop as + // fast as possible. + + view.FatalInterrupt() + // runner.Cancel() + cancel() + + // We'll wait 5 seconds for this operation to finish now, regardless + // of whether it finishes successfully or not. + select { + case <-runningCtx.Done(): + case <-time.After(5 * time.Second): + } + + case <-runningCtx.Done(): + // The application finished nicely after the request was stopped. + } + case <-runningCtx.Done(): + // query finished normally with no interrupts. + } + + view.Diagnostics(nil, queryDiags) + + return 0 +} diff --git a/internal/command/views/query.go b/internal/command/views/query.go new file mode 100644 index 000000000000..afacf89b8aab --- /dev/null +++ b/internal/command/views/query.go @@ -0,0 +1,225 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package views + +import ( + "bytes" + "fmt" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/command/format" + "github.com/hashicorp/terraform/internal/command/views/json" + "github.com/hashicorp/terraform/internal/moduletest" + "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/terraform" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// Query renders outputs for query executions. +type Query interface { + // List renders the list of resources that were discovered in the + // query. + List(terraform.ListStates) + + // Resource renders the output for a single resource. + Resource(addrs.AbsResourceInstance, *states.ResourceInstanceObjectSrc) + + // Conclusion should print out a summary of the tests including their + // completed status. + Conclusion(suite *moduletest.Suite) + + // Diagnostics prints out the provided diagnostics. + Diagnostics(list addrs.List, diags tfdiags.Diagnostics) + + // Interrupted prints out a message stating that an interrupt has been + // received and testing will stop. + Interrupted() + + // FatalInterrupt prints out a message stating that a hard interrupt has + // been received and testing will stop and cleanup will be skipped. + FatalInterrupt() +} + +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, + } + default: + panic(fmt.Sprintf("unknown view type %v", vt)) + } +} + +type QueryHuman struct { + CloudHooks + + view *View +} + +var _ Query = (*QueryHuman)(nil) + +func (t *QueryHuman) Abstract(_ *moduletest.Suite) { + // Do nothing, we don't print an abstract for the human view. +} + +func (t *QueryHuman) List(states terraform.ListStates) { + for _, state := range states.All() { + for _, instance := range state { + t.view.streams.Printf(" - %s\n", instance) + } + } +} + +func (t *QueryHuman) Resource(list addrs.AbsResourceInstance, src *states.ResourceInstanceObjectSrc) { + t.view.streams.Println() + t.view.streams.Printf("identity: %s\n", src.IdentityJSON) + t.view.streams.Printf("resource: %s\n", src.AttrsJSON) +} + +func (t *QueryHuman) Conclusion(suite *moduletest.Suite) { + t.view.streams.Println() + + counts := make(map[moduletest.Status]int) + for _, file := range suite.Files { + for _, run := range file.Runs { + count := counts[run.Status] + counts[run.Status] = count + 1 + } + } + + if suite.Status <= moduletest.Skip { + // Then no tests. + t.view.streams.Print("Executed 0 tests") + if counts[moduletest.Skip] > 0 { + t.view.streams.Printf(", %d skipped.\n", counts[moduletest.Skip]) + } else { + t.view.streams.Println(".") + } + return + } + + if suite.Status == moduletest.Pass { + t.view.streams.Print(t.view.colorize.Color("[green]Success![reset]")) + } else { + t.view.streams.Print(t.view.colorize.Color("[red]Failure![reset]")) + } + + t.view.streams.Printf(" %d passed, %d failed", counts[moduletest.Pass], counts[moduletest.Fail]+counts[moduletest.Error]) + if counts[moduletest.Skip] > 0 { + t.view.streams.Printf(", %d skipped.\n", counts[moduletest.Skip]) + } else { + t.view.streams.Println(".") + } +} + +func (t *QueryHuman) Diagnostics(list addrs.List, diags tfdiags.Diagnostics) { + t.view.Diagnostics(diags) +} + +func (t *QueryHuman) Interrupted() { + t.view.streams.Eprintln(format.WordWrap(interrupted, t.view.errorColumns())) +} + +func (t *QueryHuman) FatalInterrupt() { + t.view.streams.Eprintln(format.WordWrap(fatalInterrupt, t.view.errorColumns())) +} + +type QueryJSON struct { + CloudHooks + + view *JSONView +} + +var _ Query = (*QueryJSON)(nil) + +func (t *QueryJSON) List(states terraform.ListStates) { + jsonStates := make(map[string][]string) + + for addr, state := range states.All() { + var instances []string + for _, instance := range state { + instances = append(instances, string(instance.AttrsJSON)) + } + jsonStates[addr.Name] = instances + } + + t.view.log.Info( + "Resource list", + "resources", jsonStates) +} + +func (t *QueryJSON) Resource(list addrs.AbsResourceInstance, src *states.ResourceInstanceObjectSrc) { + t.view.log.Info( + "Resource", + "identity", src.IdentityJSON, + "resource", src.AttrsJSON) +} + +func (t *QueryJSON) Conclusion(suite *moduletest.Suite) { + summary := json.TestSuiteSummary{ + Status: json.ToTestStatus(suite.Status), + } + for _, file := range suite.Files { + for _, run := range file.Runs { + switch run.Status { + case moduletest.Skip: + summary.Skipped++ + case moduletest.Pass: + summary.Passed++ + case moduletest.Error: + summary.Errored++ + case moduletest.Fail: + summary.Failed++ + } + } + } + + var message bytes.Buffer + if suite.Status <= moduletest.Skip { + // Then no tests. + message.WriteString("Executed 0 tests") + if summary.Skipped > 0 { + message.WriteString(fmt.Sprintf(", %d skipped.", summary.Skipped)) + } else { + message.WriteString(".") + } + } else { + if suite.Status == moduletest.Pass { + message.WriteString("Success!") + } else { + message.WriteString("Failure!") + } + + message.WriteString(fmt.Sprintf(" %d passed, %d failed", summary.Passed, summary.Failed+summary.Errored)) + if summary.Skipped > 0 { + message.WriteString(fmt.Sprintf(", %d skipped.", summary.Skipped)) + } else { + message.WriteString(".") + } + } + + t.view.log.Info( + message.String(), + "type", json.MessageTestSummary, + json.MessageTestSummary, summary) +} + +func (t *QueryJSON) Diagnostics(list addrs.List, diags tfdiags.Diagnostics) { + var metadata []interface{} + t.view.Diagnostics(diags, metadata...) +} + +func (t *QueryJSON) Interrupted() { + t.view.Log(interrupted) +} + +func (t *QueryJSON) FatalInterrupt() { + t.view.Log(fatalInterrupt) +} diff --git a/internal/configs/parser_file_matcher.go b/internal/configs/parser_file_matcher.go index cb282a1a7d35..ba42affe06d4 100644 --- a/internal/configs/parser_file_matcher.go +++ b/internal/configs/parser_file_matcher.go @@ -142,6 +142,13 @@ func MatchTestFiles(dir string) Option { } } +// MatchQueryFiles adds a matcher for Terraform query files (.tfquery.hcl and .tfquery.json) +func MatchQueryFiles() Option { + return func(o *parserConfig) { + o.matchers = append(o.matchers, &queryFiles{}) + } +} + // moduleFiles matches regular Terraform configuration files (.tf and .tf.json) type moduleFiles struct{} From e046fe6183e7079b41c0215121146fc67ca52d86 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Wed, 28 May 2025 13:45:27 +0200 Subject: [PATCH 02/15] Reuse plan command for query CLI --- internal/backend/backendrun/operation.go | 3 + internal/backend/local/backend_local.go | 1 + internal/backend/local/backend_plan.go | 6 +- internal/command/arguments/default.go | 4 +- internal/command/arguments/query.go | 54 ++++-- internal/command/query.go | 221 +++++++++++++---------- internal/command/views/query.go | 189 +++---------------- 7 files changed, 196 insertions(+), 282 deletions(-) diff --git a/internal/backend/backendrun/operation.go b/internal/backend/backendrun/operation.go index 94c72ab5ad80..ec66e842cf07 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 11f9dce01ade..d6e522a602f4 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/backend/local/backend_plan.go b/internal/backend/local/backend_plan.go index 3937a4948b3a..07f760b6965d 100644 --- a/internal/backend/local/backend_plan.go +++ b/internal/backend/local/backend_plan.go @@ -201,7 +201,11 @@ func (b *Local) opPlan( return } - op.View.Plan(plan, schemas) + if op.Query { + + } else { + op.View.Plan(plan, schemas) + } // If we've accumulated any diagnostics along the way then we'll show them // here just before we show the summary and next steps. This can potentially diff --git a/internal/command/arguments/default.go b/internal/command/arguments/default.go index 9a08504d7d36..bc69f9269351 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 index a3422a9b6f3f..5787cd86a3fe 100644 --- a/internal/command/arguments/query.go +++ b/internal/command/arguments/query.go @@ -9,43 +9,61 @@ import ( // 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 - - // You can specify common variables for all tests from the command line. - Vars *Vars - - // Verbose tells the test command to print out the plan either in - // human-readable format or JSON for each run step depending on the - // ViewType. - Verbose bool } func ParseQuery(args []string) (*Query, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - - query := Query{ - Vars: new(Vars), + query := &Query{ + State: &State{}, + Operation: &Operation{}, + Vars: &Vars{}, } - var jsonOutput bool - cmdFlags := extendedFlagSet("query", nil, nil, query.Vars) - cmdFlags.BoolVar(&jsonOutput, "json", false, "json") - cmdFlags.BoolVar(&query.Verbose, "verbose", false, "verbose") + cmdFlags := defaultFlagSet("query") + + 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())) + 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 plan, use the global -chdir flag.", + )) + } + + diags = diags.Append(query.Operation.Parse()) + switch { - case jsonOutput: + case json: query.ViewType = ViewJSON default: query.ViewType = ViewHuman } - return &query, diags + return query, diags } diff --git a/internal/command/query.go b/internal/command/query.go index 11e61148b6aa..83ab6c4f179c 100644 --- a/internal/command/query.go +++ b/internal/command/query.go @@ -4,15 +4,12 @@ package command import ( - "context" + "fmt" "strings" - "time" + "github.com/hashicorp/terraform/internal/backend/backendrun" "github.com/hashicorp/terraform/internal/command/arguments" - "github.com/hashicorp/terraform/internal/command/jsonformat" "github.com/hashicorp/terraform/internal/command/views" - "github.com/hashicorp/terraform/internal/configs" - "github.com/hashicorp/terraform/internal/logging" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -44,122 +41,152 @@ func (c *QueryCommand) Synopsis() string { } func (c *QueryCommand) Run(rawArgs []string) int { - var diags tfdiags.Diagnostics - + // Parse and apply global view arguments common, rawArgs := arguments.ParseView(rawArgs) c.View.Configure(common) - args, diags := arguments.ParseQuery(rawArgs) + // 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.ParsePlan(rawArgs) + + // Instantiate the view, even if there are flag errors, so that we render + // diagnostics according to the desired view + view := views.NewPlan(args.ViewType, c.View) + if diags.HasErrors() { - c.View.Diagnostics(diags) - c.View.HelpPrompt("query") + view.Diagnostics(diags) + view.HelpPrompt() return 1 } - view := views.NewQuery(args.ViewType, c.View) + // 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 + } - _, configDiags := c.loadConfig(".", configs.MatchQueryFiles()) - diags = diags.Append(configDiags) - if configDiags.HasErrors() { - view.Diagnostics(nil, diags) + // 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 } - // Users can also specify variables via the command line, so we'll parse - // all that here. - var items []arguments.FlagNameValue - for _, variable := range args.Vars.All() { - items = append(items, arguments.FlagNameValue{ - Name: variable.Name, - Value: variable.Value, - }) + // Build the operation request + opReq, opDiags := c.OperationRequest(be, view, args.ViewType) + diags = diags.Append(opDiags) + if diags.HasErrors() { + view.Diagnostics(diags) + return 1 } - c.variableArgs = arguments.FlagNameValueSlice{Items: &items} - _, variableDiags := c.collectVariableValues() // TODO: collect query variables? - diags = diags.Append(variableDiags) - if variableDiags.HasErrors() { - view.Diagnostics(nil, diags) + // 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 } - _, err := c.contextOpts() + // 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(nil, diags) + view.Diagnostics(diags) return 1 } - view.Diagnostics(nil, diags) - - runningCtx, done := context.WithCancel(context.Background()) - _, stop := context.WithCancel(runningCtx) - _, cancel := context.WithCancel(context.Background()) - - hasCloudBackend := false // TODO fetch from config - if hasCloudBackend { - var renderer *jsonformat.Renderer - if args.ViewType == arguments.ViewHuman { - // We only set the renderer if we want Human-readable output. - // Otherwise, we just let the runner echo whatever data it receives - // back from the agent anyway. - renderer = &jsonformat.Renderer{ - Streams: c.Streams, - Colorize: c.Colorize(), - RunningInAutomation: c.RunningInAutomation, - } - } - - // TODO: run cloud query - _ = renderer - } else { - // TODO: run local query + if op.Result != backendrun.OperationSuccess { + return op.Result.ExitStatus() + } + if args.DetailedExitCode && !op.PlanEmpty { + return 2 } - var queryDiags tfdiags.Diagnostics - - go func() { - defer logging.PanicHandler() - defer done() - defer stop() - defer cancel() - - // TODO: RUN - }() - - // Wait for the operation to complete, or for an interrupt to occur. - select { - case <-c.ShutdownCh: - // Nice request to be cancelled. - - view.Interrupted() - // runner.Stop() - stop() - - select { - case <-c.ShutdownCh: - // The user pressed it again, now we have to get it to stop as - // fast as possible. - - view.FatalInterrupt() - // runner.Cancel() - cancel() - - // We'll wait 5 seconds for this operation to finish now, regardless - // of whether it finishes successfully or not. - select { - case <-runningCtx.Done(): - case <-time.After(5 * time.Second): - } - - case <-runningCtx.Done(): - // The application finished nicely after the request was stopped. - } - case <-runningCtx.Done(): - // query finished normally with no interrupts. + 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 } - view.Diagnostics(nil, queryDiags) + // 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.Plan, + viewType arguments.ViewType, +) (*backendrun.Operation, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + // Build the operation + opReq := c.Operation(be, viewType) + opReq.ConfigDir = "." + opReq.Hooks = view.Hooks() + opReq.Type = backendrun.OperationTypePlan + 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 0 + return diags } diff --git a/internal/command/views/query.go b/internal/command/views/query.go index afacf89b8aab..881b5e868d38 100644 --- a/internal/command/views/query.go +++ b/internal/command/views/query.go @@ -4,42 +4,20 @@ package views import ( - "bytes" "fmt" - "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/command/arguments" - "github.com/hashicorp/terraform/internal/command/format" - "github.com/hashicorp/terraform/internal/command/views/json" - "github.com/hashicorp/terraform/internal/moduletest" - "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" ) // Query renders outputs for query executions. type Query interface { - // List renders the list of resources that were discovered in the - // query. - List(terraform.ListStates) + Operation() Operation + Hooks() []terraform.Hook - // Resource renders the output for a single resource. - Resource(addrs.AbsResourceInstance, *states.ResourceInstanceObjectSrc) - - // Conclusion should print out a summary of the tests including their - // completed status. - Conclusion(suite *moduletest.Suite) - - // Diagnostics prints out the provided diagnostics. - Diagnostics(list addrs.List, diags tfdiags.Diagnostics) - - // Interrupted prints out a message stating that an interrupt has been - // received and testing will stop. - Interrupted() - - // FatalInterrupt prints out a message stating that a hard interrupt has - // been received and testing will stop and cleanup will be skipped. - FatalInterrupt() + Diagnostics(diags tfdiags.Diagnostics) + HelpPrompt() } func NewQuery(vt arguments.ViewType, view *View) Query { @@ -50,7 +28,8 @@ func NewQuery(vt arguments.ViewType, view *View) Query { } case arguments.ViewHuman: return &QueryHuman{ - view: view, + view: view, + inAutomation: view.RunningInAutomation(), } default: panic(fmt.Sprintf("unknown view type %v", vt)) @@ -58,168 +37,50 @@ func NewQuery(vt arguments.ViewType, view *View) Query { } type QueryHuman struct { - CloudHooks - view *View -} -var _ Query = (*QueryHuman)(nil) - -func (t *QueryHuman) Abstract(_ *moduletest.Suite) { - // Do nothing, we don't print an abstract for the human view. + inAutomation bool } -func (t *QueryHuman) List(states terraform.ListStates) { - for _, state := range states.All() { - for _, instance := range state { - t.view.streams.Printf(" - %s\n", instance) - } - } -} +var _ Query = (*QueryHuman)(nil) -func (t *QueryHuman) Resource(list addrs.AbsResourceInstance, src *states.ResourceInstanceObjectSrc) { - t.view.streams.Println() - t.view.streams.Printf("identity: %s\n", src.IdentityJSON) - t.view.streams.Printf("resource: %s\n", src.AttrsJSON) +func (v *QueryHuman) Operation() Operation { + return NewOperation(arguments.ViewHuman, v.inAutomation, v.view) } -func (t *QueryHuman) Conclusion(suite *moduletest.Suite) { - t.view.streams.Println() - - counts := make(map[moduletest.Status]int) - for _, file := range suite.Files { - for _, run := range file.Runs { - count := counts[run.Status] - counts[run.Status] = count + 1 - } - } - - if suite.Status <= moduletest.Skip { - // Then no tests. - t.view.streams.Print("Executed 0 tests") - if counts[moduletest.Skip] > 0 { - t.view.streams.Printf(", %d skipped.\n", counts[moduletest.Skip]) - } else { - t.view.streams.Println(".") - } - return - } - - if suite.Status == moduletest.Pass { - t.view.streams.Print(t.view.colorize.Color("[green]Success![reset]")) - } else { - t.view.streams.Print(t.view.colorize.Color("[red]Failure![reset]")) - } - - t.view.streams.Printf(" %d passed, %d failed", counts[moduletest.Pass], counts[moduletest.Fail]+counts[moduletest.Error]) - if counts[moduletest.Skip] > 0 { - t.view.streams.Printf(", %d skipped.\n", counts[moduletest.Skip]) - } else { - t.view.streams.Println(".") +func (v *QueryHuman) Hooks() []terraform.Hook { + return []terraform.Hook{ + NewUiHook(v.view), } } -func (t *QueryHuman) Diagnostics(list addrs.List, diags tfdiags.Diagnostics) { - t.view.Diagnostics(diags) +func (v *QueryHuman) Diagnostics(diags tfdiags.Diagnostics) { + v.view.Diagnostics(diags) } -func (t *QueryHuman) Interrupted() { - t.view.streams.Eprintln(format.WordWrap(interrupted, t.view.errorColumns())) -} - -func (t *QueryHuman) FatalInterrupt() { - t.view.streams.Eprintln(format.WordWrap(fatalInterrupt, t.view.errorColumns())) +func (v *QueryHuman) HelpPrompt() { + v.view.HelpPrompt("query") } type QueryJSON struct { - CloudHooks - view *JSONView } var _ Query = (*QueryJSON)(nil) -func (t *QueryJSON) List(states terraform.ListStates) { - jsonStates := make(map[string][]string) - - for addr, state := range states.All() { - var instances []string - for _, instance := range state { - instances = append(instances, string(instance.AttrsJSON)) - } - jsonStates[addr.Name] = instances - } - - t.view.log.Info( - "Resource list", - "resources", jsonStates) +func (v *QueryJSON) Operation() Operation { + return &OperationJSON{view: v.view} } -func (t *QueryJSON) Resource(list addrs.AbsResourceInstance, src *states.ResourceInstanceObjectSrc) { - t.view.log.Info( - "Resource", - "identity", src.IdentityJSON, - "resource", src.AttrsJSON) -} - -func (t *QueryJSON) Conclusion(suite *moduletest.Suite) { - summary := json.TestSuiteSummary{ - Status: json.ToTestStatus(suite.Status), - } - for _, file := range suite.Files { - for _, run := range file.Runs { - switch run.Status { - case moduletest.Skip: - summary.Skipped++ - case moduletest.Pass: - summary.Passed++ - case moduletest.Error: - summary.Errored++ - case moduletest.Fail: - summary.Failed++ - } - } - } - - var message bytes.Buffer - if suite.Status <= moduletest.Skip { - // Then no tests. - message.WriteString("Executed 0 tests") - if summary.Skipped > 0 { - message.WriteString(fmt.Sprintf(", %d skipped.", summary.Skipped)) - } else { - message.WriteString(".") - } - } else { - if suite.Status == moduletest.Pass { - message.WriteString("Success!") - } else { - message.WriteString("Failure!") - } - - message.WriteString(fmt.Sprintf(" %d passed, %d failed", summary.Passed, summary.Failed+summary.Errored)) - if summary.Skipped > 0 { - message.WriteString(fmt.Sprintf(", %d skipped.", summary.Skipped)) - } else { - message.WriteString(".") - } +func (v *QueryJSON) Hooks() []terraform.Hook { + return []terraform.Hook{ + newJSONHook(v.view), } - - t.view.log.Info( - message.String(), - "type", json.MessageTestSummary, - json.MessageTestSummary, summary) -} - -func (t *QueryJSON) Diagnostics(list addrs.List, diags tfdiags.Diagnostics) { - var metadata []interface{} - t.view.Diagnostics(diags, metadata...) } -func (t *QueryJSON) Interrupted() { - t.view.Log(interrupted) +func (v *QueryJSON) Diagnostics(diags tfdiags.Diagnostics) { + v.view.Diagnostics(diags) } -func (t *QueryJSON) FatalInterrupt() { - t.view.Log(fatalInterrupt) +func (v *QueryJSON) HelpPrompt() { } From ef9214fb5b3be9a5a46a055afd535cf34ddd7025 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Jun 2025 16:59:25 +0200 Subject: [PATCH 03/15] Basic CLI output --- internal/backend/local/backend_plan.go | 3 +- internal/command/jsonlist/list.go | 58 ++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 internal/command/jsonlist/list.go diff --git a/internal/backend/local/backend_plan.go b/internal/backend/local/backend_plan.go index 07f760b6965d..ecc98fab0aa7 100644 --- a/internal/backend/local/backend_plan.go +++ b/internal/backend/local/backend_plan.go @@ -10,6 +10,7 @@ import ( "log" "github.com/hashicorp/terraform/internal/backend/backendrun" + "github.com/hashicorp/terraform/internal/command/jsonlist" "github.com/hashicorp/terraform/internal/genconfig" "github.com/hashicorp/terraform/internal/logging" "github.com/hashicorp/terraform/internal/plans" @@ -202,7 +203,7 @@ func (b *Local) opPlan( } if op.Query { - + jsonlist.MarshalListFromResourceChanges(plan.Changes.Resources, schemas) } else { op.View.Plan(plan, schemas) } diff --git a/internal/command/jsonlist/list.go b/internal/command/jsonlist/list.go new file mode 100644 index 000000000000..16980e803041 --- /dev/null +++ b/internal/command/jsonlist/list.go @@ -0,0 +1,58 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package jsonlist + +import ( + "fmt" + + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/terraform" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +func MarshalListFromResourceChanges(resources []*plans.ResourceInstanceChangeSrc, schemas *terraform.Schemas) ([]string, error) { + var ret []string + + for _, rc := range resources { + + r, err := marshalListFromResourceChange(rc, schemas) + if err != nil { + return nil, err + } + ret = append(ret, r) + } + + return ret, nil +} + +func marshalListFromResourceChange(rc *plans.ResourceInstanceChangeSrc, schemas *terraform.Schemas) (string, error) { + var r string + addr := rc.Addr + + schema := schemas.ResourceTypeConfig( + rc.ProviderAddr.Provider, + addr.Resource.Resource.Mode, + addr.Resource.Resource.Type, + ) + if schema.Body == nil { + return r, fmt.Errorf("no schema found for %s (in provider %s)", addr.String(), rc.ProviderAddr.Provider) + } + + changeV, err := rc.Decode(schema) + if err != nil { + return r, err + } + + data := changeV.After.GetAttr("data") + for it := data.ElementIterator(); it.Next(); { + _, value := it.Element() + + name := value.GetAttr("display_name").AsString() + identity := value.GetAttr("identity") + + fmt.Printf("%s.%s\t%s\t%s\n", addr.Resource.Resource.Type, addr.Resource.Resource.Name, tfdiags.ObjectToString(identity), name) + } + + return r, nil +} From debcdd7c652f4624f0f5f4a499a98f8e5c89e8ed Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Jun 2025 17:26:02 +0200 Subject: [PATCH 04/15] Only fail a list request on error --- internal/terraform/node_resource_plan_instance_query.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/terraform/node_resource_plan_instance_query.go b/internal/terraform/node_resource_plan_instance_query.go index c5cc9ad4d8f4..9afd13d6d8a9 100644 --- a/internal/terraform/node_resource_plan_instance_query.go +++ b/internal/terraform/node_resource_plan_instance_query.go @@ -84,8 +84,9 @@ 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())) + diags = diags.Append(resp.Diagnostics.InConfigBody(config.Config, n.Addr.String())) + if diags.HasErrors() { + return diags } change := &plans.ResourceInstanceChange{ From fd795a0feb4bbbcd586ce7ee698fe709d0182eba Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Mon, 16 Jun 2025 15:08:49 +0200 Subject: [PATCH 05/15] poc: store query results in separate field --- internal/backend/local/backend_plan.go | 3 +- internal/command/jsonlist/list.go | 11 ++-- internal/plans/changes.go | 51 ++++++++++++++++++- internal/plans/changes_src.go | 44 +++++++++++++++- internal/plans/changes_sync.go | 11 ++++ .../node_resource_plan_instance_query.go | 13 ++--- 6 files changed, 113 insertions(+), 20 deletions(-) diff --git a/internal/backend/local/backend_plan.go b/internal/backend/local/backend_plan.go index ecc98fab0aa7..8dd4a3ae9d2c 100644 --- a/internal/backend/local/backend_plan.go +++ b/internal/backend/local/backend_plan.go @@ -203,7 +203,8 @@ func (b *Local) opPlan( } if op.Query { - jsonlist.MarshalListFromResourceChanges(plan.Changes.Resources, schemas) + // TODO! move + jsonlist.MarshalQueryInstances(plan.Changes.Queries, schemas) } else { op.View.Plan(plan, schemas) } diff --git a/internal/command/jsonlist/list.go b/internal/command/jsonlist/list.go index 16980e803041..95e811396fd1 100644 --- a/internal/command/jsonlist/list.go +++ b/internal/command/jsonlist/list.go @@ -11,12 +11,11 @@ import ( "github.com/hashicorp/terraform/internal/tfdiags" ) -func MarshalListFromResourceChanges(resources []*plans.ResourceInstanceChangeSrc, schemas *terraform.Schemas) ([]string, error) { +func MarshalQueryInstances(resources []*plans.QueryInstanceSrc, schemas *terraform.Schemas) ([]string, error) { var ret []string for _, rc := range resources { - - r, err := marshalListFromResourceChange(rc, schemas) + r, err := marshalQueryInstance(rc, schemas) if err != nil { return nil, err } @@ -26,7 +25,7 @@ func MarshalListFromResourceChanges(resources []*plans.ResourceInstanceChangeSrc return ret, nil } -func marshalListFromResourceChange(rc *plans.ResourceInstanceChangeSrc, schemas *terraform.Schemas) (string, error) { +func marshalQueryInstance(rc *plans.QueryInstanceSrc, schemas *terraform.Schemas) (string, error) { var r string addr := rc.Addr @@ -39,12 +38,12 @@ func marshalListFromResourceChange(rc *plans.ResourceInstanceChangeSrc, schemas return r, fmt.Errorf("no schema found for %s (in provider %s)", addr.String(), rc.ProviderAddr.Provider) } - changeV, err := rc.Decode(schema) + query, err := rc.Decode(schema) if err != nil { return r, err } - data := changeV.After.GetAttr("data") + data := query.Results.GetAttr("data") for it := data.ElementIterator(); it.Next(); { _, value := it.Element() diff --git a/internal/plans/changes.go b/internal/plans/changes.go index 88b20f151be1..64c467c6b5be 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 { @@ -210,6 +230,36 @@ func (c *Changes) SyncWrapper() *ChangesSync { } } +type QueryInstance struct { + Addr addrs.AbsResourceInstance + + ProviderAddr addrs.AbsProviderConfig + + Results 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, 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 +343,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 8482cbe32c15..c4fb3e1acf23 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,27 @@ 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: 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 fe2197224c75..5c995557cc90 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" diff --git a/internal/terraform/node_resource_plan_instance_query.go b/internal/terraform/node_resource_plan_instance_query.go index 9afd13d6d8a9..b921c26015a2 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) { @@ -89,17 +87,12 @@ func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (di 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: resp.Result, } - ctx.Changes().AppendResourceInstanceChange(change) + ctx.Changes().AppendQueryInstance(query) return diags } From 21fb6851b0d0cd1b5e2d5a55eedb778cd8f9a7d4 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Wed, 18 Jun 2025 16:07:07 +0200 Subject: [PATCH 06/15] WIP: odd mixture between JSONs --- internal/backend/local/backend_plan.go | 8 +- internal/command/jsonformat/plan.go | 11 +- internal/command/jsonlist/list.go | 45 ++++-- internal/command/query.go | 10 +- internal/command/views/json/message_types.go | 3 + internal/command/views/json/query.go | 39 +++++ internal/command/views/json_view.go | 8 ++ internal/command/views/query.go | 18 +-- internal/command/views/query_operation.go | 143 +++++++++++++++++++ 9 files changed, 236 insertions(+), 49 deletions(-) create mode 100644 internal/command/views/json/query.go create mode 100644 internal/command/views/query_operation.go diff --git a/internal/backend/local/backend_plan.go b/internal/backend/local/backend_plan.go index 8dd4a3ae9d2c..3937a4948b3a 100644 --- a/internal/backend/local/backend_plan.go +++ b/internal/backend/local/backend_plan.go @@ -10,7 +10,6 @@ import ( "log" "github.com/hashicorp/terraform/internal/backend/backendrun" - "github.com/hashicorp/terraform/internal/command/jsonlist" "github.com/hashicorp/terraform/internal/genconfig" "github.com/hashicorp/terraform/internal/logging" "github.com/hashicorp/terraform/internal/plans" @@ -202,12 +201,7 @@ func (b *Local) opPlan( return } - if op.Query { - // TODO! move - jsonlist.MarshalQueryInstances(plan.Changes.Queries, schemas) - } else { - op.View.Plan(plan, schemas) - } + op.View.Plan(plan, schemas) // If we've accumulated any diagnostics along the way then we'll show them // here just before we show the summary and next steps. This can potentially diff --git a/internal/command/jsonformat/plan.go b/internal/command/jsonformat/plan.go index 7eb54899d1a1..74ee29275ef6 100644 --- a/internal/command/jsonformat/plan.go +++ b/internal/command/jsonformat/plan.go @@ -10,9 +10,12 @@ 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" + "github.com/hashicorp/terraform/internal/command/jsonlist" "github.com/hashicorp/terraform/internal/command/jsonplan" "github.com/hashicorp/terraform/internal/command/jsonprovider" "github.com/hashicorp/terraform/internal/command/jsonstate" @@ -31,6 +34,7 @@ type Plan struct { ResourceDrift []jsonplan.ResourceChange `json:"resource_drift,omitempty"` RelevantAttributes []jsonplan.ResourceAttr `json:"relevant_attributes,omitempty"` DeferredChanges []jsonplan.DeferredResourceChange `json:"deferred_changes,omitempty"` + QueryResults []jsonlist.QueryResult `json:"query_results,omitempty"` ProviderFormatVersion string `json:"provider_format_version"` ProviderSchemas map[string]*jsonprovider.Provider `json:"provider_schemas,omitempty"` @@ -49,12 +53,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/jsonlist/list.go b/internal/command/jsonlist/list.go index 95e811396fd1..62530e5826a2 100644 --- a/internal/command/jsonlist/list.go +++ b/internal/command/jsonlist/list.go @@ -4,29 +4,46 @@ package jsonlist import ( + "encoding/json" "fmt" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/terraform" - "github.com/hashicorp/terraform/internal/tfdiags" ) -func MarshalQueryInstances(resources []*plans.QueryInstanceSrc, schemas *terraform.Schemas) ([]string, error) { - var ret []string +type QueryResult struct { + Address string `json:"address,omitempty"` + + Identity json.RawMessage `json:"identity,omitempty"` + + Resource json.RawMessage `json:"resource,omitempty"` + + DisplayName string `json:"display_name,omitempty"` +} + +func MarshalForRenderer( + p *plans.Plan, + schemas *terraform.Schemas, +) ([]QueryResult, error) { + return MarshalQueryInstances(p.Changes.Queries, schemas) +} + +func MarshalQueryInstances(resources []*plans.QueryInstanceSrc, schemas *terraform.Schemas) ([]QueryResult, error) { + var ret []QueryResult for _, rc := range resources { r, err := marshalQueryInstance(rc, schemas) if err != nil { return nil, err } - ret = append(ret, r) + ret = append(ret, r...) } return ret, nil } -func marshalQueryInstance(rc *plans.QueryInstanceSrc, schemas *terraform.Schemas) (string, error) { - var r string +func marshalQueryInstance(rc *plans.QueryInstanceSrc, schemas *terraform.Schemas) ([]QueryResult, error) { + var ret []QueryResult addr := rc.Addr schema := schemas.ResourceTypeConfig( @@ -35,23 +52,25 @@ func marshalQueryInstance(rc *plans.QueryInstanceSrc, schemas *terraform.Schemas addr.Resource.Resource.Type, ) if schema.Body == nil { - return r, fmt.Errorf("no schema found for %s (in provider %s)", addr.String(), rc.ProviderAddr.Provider) + return ret, fmt.Errorf("no schema found for %s (in provider %s)", addr.String(), rc.ProviderAddr.Provider) } query, err := rc.Decode(schema) if err != nil { - return r, err + return ret, err } data := query.Results.GetAttr("data") for it := data.ElementIterator(); it.Next(); { - _, value := it.Element() + var r QueryResult + r.Address = addr.String() - name := value.GetAttr("display_name").AsString() - identity := value.GetAttr("identity") + _, value := it.Element() - fmt.Printf("%s.%s\t%s\t%s\n", addr.Resource.Resource.Type, addr.Resource.Resource.Name, tfdiags.ObjectToString(identity), name) + r.DisplayName = value.GetAttr("display_name").AsString() + // identity + // resource object } - return r, nil + return ret, nil } diff --git a/internal/command/query.go b/internal/command/query.go index 83ab6c4f179c..dd76bf0f9ffc 100644 --- a/internal/command/query.go +++ b/internal/command/query.go @@ -52,11 +52,11 @@ func (c *QueryCommand) Run(rawArgs []string) int { c.Meta.Color = c.Meta.color // Parse and validate flags - args, diags := arguments.ParsePlan(rawArgs) + 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.NewPlan(args.ViewType, c.View) + view := views.NewQuery(args.ViewType, c.View) if diags.HasErrors() { view.Diagnostics(diags) @@ -118,9 +118,6 @@ func (c *QueryCommand) Run(rawArgs []string) int { if op.Result != backendrun.OperationSuccess { return op.Result.ExitStatus() } - if args.DetailedExitCode && !op.PlanEmpty { - return 2 - } return op.Result.ExitStatus() } @@ -146,7 +143,7 @@ func (c *QueryCommand) PrepareBackend(args *arguments.State, viewType arguments. func (c *QueryCommand) OperationRequest( be backendrun.OperationsBackend, - view views.Plan, + view views.Query, viewType arguments.ViewType, ) (*backendrun.Operation, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics @@ -154,7 +151,6 @@ func (c *QueryCommand) OperationRequest( // Build the operation opReq := c.Operation(be, viewType) opReq.ConfigDir = "." - opReq.Hooks = view.Hooks() opReq.Type = backendrun.OperationTypePlan opReq.View = view.Operation() opReq.Query = true diff --git a/internal/command/views/json/message_types.go b/internal/command/views/json/message_types.go index 3c3ec299bb21..18fc594d1c80 100644 --- a/internal/command/views/json/message_types.go +++ b/internal/command/views/json/message_types.go @@ -46,4 +46,7 @@ const ( MessageTestInterrupt MessageType = "test_interrupt" MessageTestStatus MessageType = "test_status" MessageTestRetry MessageType = "test_retry" + + // List messages + 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 000000000000..d519a66c3f86 --- /dev/null +++ b/internal/command/views/json/query.go @@ -0,0 +1,39 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package json + +import ( + "fmt" + + "github.com/hashicorp/terraform/internal/plans" +) + +func NewQueryResults(change *plans.QueryInstanceSrc) []*QueryResult { + var ret []*QueryResult + addr := newResourceAddr(change.Addr) + + for _, _ = range change.Results { + r := &QueryResult{ + Addr: addr, + ResourceType: change.Addr.Resource.Resource.Type, + // DisplayName: result.DisplayName, + } + ret = append(ret, r) + } + + return ret +} + +type QueryResult struct { + Addr ResourceAddr `json:"addr"` + ResourceType string `json:"resource_type"` + DisplayName string `json:"display_name"` + Identity ResourceAddr `json:"identity"` + ResourceObject ResourceAddr `json:"resource_object,omitempty"` + GeneratedConfig string `json:"generated_config,omitempty"` +} + +func (r *QueryResult) String() string { + return fmt.Sprintf("%s: New result", r.Addr.Addr) +} diff --git a/internal/command/views/json_view.go b/internal/command/views/json_view.go index 085d0e703b44..337269895263 100644 --- a/internal/command/views/json_view.go +++ b/internal/command/views/json_view.go @@ -126,3 +126,11 @@ func (v *JSONView) Outputs(outputs json.Outputs) { "outputs", outputs, ) } + +func (v *JSONView) QueryResult(r *json.QueryResult) { + v.log.Info( + r.String(), + "type", json.MessageListResourceFound, + "result", r, + ) +} diff --git a/internal/command/views/query.go b/internal/command/views/query.go index 881b5e868d38..909350535c3a 100644 --- a/internal/command/views/query.go +++ b/internal/command/views/query.go @@ -7,14 +7,12 @@ 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() @@ -45,13 +43,7 @@ type QueryHuman struct { var _ Query = (*QueryHuman)(nil) func (v *QueryHuman) Operation() Operation { - return NewOperation(arguments.ViewHuman, v.inAutomation, v.view) -} - -func (v *QueryHuman) Hooks() []terraform.Hook { - return []terraform.Hook{ - NewUiHook(v.view), - } + return NewQueryOperation(arguments.ViewHuman, v.inAutomation, v.view) } func (v *QueryHuman) Diagnostics(diags tfdiags.Diagnostics) { @@ -69,13 +61,7 @@ type QueryJSON struct { var _ Query = (*QueryJSON)(nil) func (v *QueryJSON) Operation() Operation { - return &OperationJSON{view: v.view} -} - -func (v *QueryJSON) Hooks() []terraform.Hook { - return []terraform.Hook{ - newJSONHook(v.view), - } + return &QueryOperationJSON{view: v.view} } func (v *QueryJSON) Diagnostics(diags tfdiags.Diagnostics) { diff --git a/internal/command/views/query_operation.go b/internal/command/views/query_operation.go new file mode 100644 index 000000000000..701ae2b9a91b --- /dev/null +++ b/internal/command/views/query_operation.go @@ -0,0 +1,143 @@ +// 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/command/jsonformat" + "github.com/hashicorp/terraform/internal/command/jsonlist" + "github.com/hashicorp/terraform/internal/command/jsonplan" + "github.com/hashicorp/terraform/internal/command/jsonprovider" + "github.com/hashicorp/terraform/internal/command/views/json" + "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) { + results, err := jsonlist.MarshalForRenderer(plan, schemas) + if err != nil { + v.view.streams.Eprintf("Failed to marshal query results to json: %s", err) + return + } + + // TODO: Update to render list results + renderer := jsonformat.Renderer{ + Colorize: v.view.colorize, + Streams: v.view.streams, + RunningInAutomation: v.inAutomation, + } + + jplan := jsonformat.Plan{ + PlanFormatVersion: jsonplan.FormatVersion, + ProviderFormatVersion: jsonprovider.FormatVersion, + QueryResults: results, + ProviderSchemas: jsonprovider.MarshalForRenderer(schemas), + } + + renderer.RenderHumanPlan(jplan, plan.UIMode) +} + +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) { + for _, query := range plan.Changes.Queries { + results := json.NewQueryResults(query) + for _, result := range results { + v.view.QueryResult(result) + } + } +} + +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) +} From e0751ee378d705d14aa537cafeaf1aa0de4edac4 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Thu, 19 Jun 2025 12:14:05 +0200 Subject: [PATCH 07/15] Fix list references --- internal/plans/changes.go | 12 +++++++ internal/plans/changes_sync.go | 13 ++++++++ internal/terraform/context_plan_query_test.go | 32 +++++-------------- internal/terraform/evaluate.go | 18 +++++------ 4 files changed, 42 insertions(+), 33 deletions(-) diff --git a/internal/plans/changes.go b/internal/plans/changes.go index 64c467c6b5be..a55aa5da20c2 100644 --- a/internal/plans/changes.go +++ b/internal/plans/changes.go @@ -133,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. diff --git a/internal/plans/changes_sync.go b/internal/plans/changes_sync.go index 5c995557cc90..8a6b7e5cc35e 100644 --- a/internal/plans/changes_sync.go +++ b/internal/plans/changes_sync.go @@ -117,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 53c8bbf16826..b0cb75ddbcb3 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.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.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.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.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 5beb3f1c9bdd..71ca40dece1d 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 } } @@ -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 } } @@ -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 } } From d01b029b4040e4d0518c4d4af9916cd2f14fcc10 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Thu, 19 Jun 2025 18:10:21 +0200 Subject: [PATCH 08/15] 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. --- internal/command/jsonformat/plan.go | 16 +++++- internal/command/jsonformat/renderer.go | 4 ++ internal/command/jsonlist/list.go | 60 ++++++++++++++++------- internal/command/views/json/query.go | 24 +++------ internal/command/views/query_operation.go | 10 ++-- 5 files changed, 74 insertions(+), 40 deletions(-) diff --git a/internal/command/jsonformat/plan.go b/internal/command/jsonformat/plan.go index 74ee29275ef6..d14cbbd11414 100644 --- a/internal/command/jsonformat/plan.go +++ b/internal/command/jsonformat/plan.go @@ -34,7 +34,7 @@ type Plan struct { ResourceDrift []jsonplan.ResourceChange `json:"resource_drift,omitempty"` RelevantAttributes []jsonplan.ResourceAttr `json:"relevant_attributes,omitempty"` DeferredChanges []jsonplan.DeferredResourceChange `json:"deferred_changes,omitempty"` - QueryResults []jsonlist.QueryResult `json:"query_results,omitempty"` + QueryResults []jsonlist.Query `json:"query_results,omitempty"` ProviderFormatVersion string `json:"provider_format_version"` ProviderSchemas map[string]*jsonprovider.Provider `json:"provider_schemas,omitempty"` @@ -56,6 +56,20 @@ func (plan Plan) renderHuman(renderer Renderer, mode plans.Mode, opts ...plans.Q return slices.Contains(opts, target) } + if len(plan.QueryResults) > 0 { + for _, query := range plan.QueryResults { + renderer.Streams.Printf("Query for %s:\n", query.Address) + for _, result := range query.Results { + // TODO reformat identity, shorten addres + renderer.Streams.Printf("%s\t%s\t%s\n", query.Address, result.Identity, result.DisplayName) + } + renderer.Streams.Println() + } + + // Only render query results + return + } + diffs := precomputeDiffs(plan, mode) haveRefreshChanges := renderHumanDiffDrift(renderer, diffs, mode) diff --git a/internal/command/jsonformat/renderer.go b/internal/command/jsonformat/renderer.go index a3463ac8c556..e42e6e3d66f4 100644 --- a/internal/command/jsonformat/renderer.go +++ b/internal/command/jsonformat/renderer.go @@ -122,6 +122,10 @@ func (renderer Renderer) RenderHumanPlan(plan Plan, mode plans.Mode, opts ...pla plan.renderHuman(renderer, mode, opts...) } +func (renderer Renderer) RenderHumanList(plan Plan) { + plan.renderHuman(renderer, plans.NormalMode) +} + func (renderer Renderer) RenderHumanState(state State) { if incompatibleVersions(jsonstate.FormatVersion, state.StateFormatVersion) || incompatibleVersions(jsonprovider.FormatVersion, state.ProviderFormatVersion) { renderer.Streams.Println(format.WordWrap( diff --git a/internal/command/jsonlist/list.go b/internal/command/jsonlist/list.go index 62530e5826a2..08758c9bcd1e 100644 --- a/internal/command/jsonlist/list.go +++ b/internal/command/jsonlist/list.go @@ -7,44 +7,54 @@ import ( "encoding/json" "fmt" + ctyjson "github.com/zclconf/go-cty/cty/json" + "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/terraform" + "github.com/zclconf/go-cty/cty" ) -type QueryResult struct { +type Query struct { Address string `json:"address,omitempty"` - Identity json.RawMessage `json:"identity,omitempty"` + Results []QueryResult `json:"results"` +} - Resource json.RawMessage `json:"resource,omitempty"` +type QueryResult struct { + DisplayName string `json:"display_name"` + Identity map[string]json.RawMessage `json:"identity"` + Resource map[string]json.RawMessage `json:"resource,omitempty"` - DisplayName string `json:"display_name,omitempty"` + // TODO + // Address string `json:"address,omitempty"` + // Config string `json:"config,omitempty"` } func MarshalForRenderer( p *plans.Plan, schemas *terraform.Schemas, -) ([]QueryResult, error) { +) ([]Query, error) { return MarshalQueryInstances(p.Changes.Queries, schemas) } -func MarshalQueryInstances(resources []*plans.QueryInstanceSrc, schemas *terraform.Schemas) ([]QueryResult, error) { - var ret []QueryResult +func MarshalQueryInstances(resources []*plans.QueryInstanceSrc, schemas *terraform.Schemas) ([]Query, error) { + var ret []Query for _, rc := range resources { r, err := marshalQueryInstance(rc, schemas) if err != nil { return nil, err } - ret = append(ret, r...) + ret = append(ret, r) } return ret, nil } -func marshalQueryInstance(rc *plans.QueryInstanceSrc, schemas *terraform.Schemas) ([]QueryResult, error) { - var ret []QueryResult +func marshalQueryInstance(rc *plans.QueryInstanceSrc, schemas *terraform.Schemas) (Query, error) { + var ret Query addr := rc.Addr + ret.Address = addr.String() schema := schemas.ResourceTypeConfig( rc.ProviderAddr.Provider, @@ -52,7 +62,7 @@ func marshalQueryInstance(rc *plans.QueryInstanceSrc, schemas *terraform.Schemas addr.Resource.Resource.Type, ) if schema.Body == nil { - return ret, fmt.Errorf("no schema found for %s (in provider %s)", addr.String(), rc.ProviderAddr.Provider) + return ret, fmt.Errorf("no schema found for %s (in provider %s)", ret.Address, rc.ProviderAddr.Provider) } query, err := rc.Decode(schema) @@ -62,15 +72,31 @@ func marshalQueryInstance(rc *plans.QueryInstanceSrc, schemas *terraform.Schemas data := query.Results.GetAttr("data") for it := data.ElementIterator(); it.Next(); { - var r QueryResult - r.Address = addr.String() - _, value := it.Element() - r.DisplayName = value.GetAttr("display_name").AsString() - // identity - // resource object + result := QueryResult{ + DisplayName: value.GetAttr("display_name").AsString(), + Identity: marshalValues(value.GetAttr("identity")), + Resource: marshalValues(value.GetAttr("state")), + } + + ret.Results = append(ret.Results, result) } return ret, nil } + +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/json/query.go b/internal/command/views/json/query.go index d519a66c3f86..7a50c30d697b 100644 --- a/internal/command/views/json/query.go +++ b/internal/command/views/json/query.go @@ -9,31 +9,21 @@ import ( "github.com/hashicorp/terraform/internal/plans" ) -func NewQueryResults(change *plans.QueryInstanceSrc) []*QueryResult { - var ret []*QueryResult +func NewQueryResults(change *plans.QueryInstanceSrc) *QueryResult { addr := newResourceAddr(change.Addr) - for _, _ = range change.Results { - r := &QueryResult{ - Addr: addr, - ResourceType: change.Addr.Resource.Resource.Type, - // DisplayName: result.DisplayName, - } - ret = append(ret, r) + return &QueryResult{ + Addr: addr, + ResourceType: change.Addr.Resource.Resource.Type, } - return ret } type QueryResult struct { - Addr ResourceAddr `json:"addr"` - ResourceType string `json:"resource_type"` - DisplayName string `json:"display_name"` - Identity ResourceAddr `json:"identity"` - ResourceObject ResourceAddr `json:"resource_object,omitempty"` - GeneratedConfig string `json:"generated_config,omitempty"` + Addr ResourceAddr `json:"addr"` + ResourceType string `json:"resource_type"` } func (r *QueryResult) String() string { - return fmt.Sprintf("%s: New result", r.Addr.Addr) + return fmt.Sprintf("%s: Quering resources...", r.Addr.Addr) } diff --git a/internal/command/views/query_operation.go b/internal/command/views/query_operation.go index 701ae2b9a91b..e8fa2026ee81 100644 --- a/internal/command/views/query_operation.go +++ b/internal/command/views/query_operation.go @@ -84,7 +84,7 @@ func (v *QueryOperationHuman) Plan(plan *plans.Plan, schemas *terraform.Schemas) ProviderSchemas: jsonprovider.MarshalForRenderer(schemas), } - renderer.RenderHumanPlan(jplan, plan.UIMode) + renderer.RenderHumanList(jplan) } func (v *QueryOperationHuman) PlannedChange(change *plans.ResourceInstanceChangeSrc) { @@ -124,12 +124,12 @@ func (v *QueryOperationJSON) EmergencyDumpState(stateFile *statefile.File) error } func (v *QueryOperationJSON) Plan(plan *plans.Plan, schemas *terraform.Schemas) { + // TODO: log operation updates as structured logging for _, query := range plan.Changes.Queries { - results := json.NewQueryResults(query) - for _, result := range results { - v.view.QueryResult(result) - } + v.view.QueryResult(json.NewQueryResults(query)) } + + // TODO: show the plan as json with only list results } func (v *QueryOperationJSON) PlannedChange(change *plans.ResourceInstanceChangeSrc) { From b6376041338da55947ee9986a98dafeb00965ed6 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Mon, 23 Jun 2025 12:50:05 +0200 Subject: [PATCH 09/15] Remove matcher --- internal/configs/parser_file_matcher.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/internal/configs/parser_file_matcher.go b/internal/configs/parser_file_matcher.go index ba42affe06d4..cb282a1a7d35 100644 --- a/internal/configs/parser_file_matcher.go +++ b/internal/configs/parser_file_matcher.go @@ -142,13 +142,6 @@ func MatchTestFiles(dir string) Option { } } -// MatchQueryFiles adds a matcher for Terraform query files (.tfquery.hcl and .tfquery.json) -func MatchQueryFiles() Option { - return func(o *parserConfig) { - o.matchers = append(o.matchers, &queryFiles{}) - } -} - // moduleFiles matches regular Terraform configuration files (.tf and .tf.json) type moduleFiles struct{} From 55fa1d36b57838ff93ae49970d88ba55e9a542a2 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Mon, 23 Jun 2025 16:27:09 +0200 Subject: [PATCH 10/15] Store results in an extra struct --- internal/command/jsonlist/list.go | 2 +- internal/command/views/query_operation.go | 1 - internal/plans/changes.go | 8 ++++++-- internal/plans/changes_src.go | 6 ++++-- internal/terraform/context_plan_query_test.go | 8 ++++---- internal/terraform/evaluate.go | 6 +++--- internal/terraform/node_resource_plan_instance_query.go | 4 +++- 7 files changed, 21 insertions(+), 14 deletions(-) diff --git a/internal/command/jsonlist/list.go b/internal/command/jsonlist/list.go index 08758c9bcd1e..7f65e38d34b6 100644 --- a/internal/command/jsonlist/list.go +++ b/internal/command/jsonlist/list.go @@ -70,7 +70,7 @@ func marshalQueryInstance(rc *plans.QueryInstanceSrc, schemas *terraform.Schemas return ret, err } - data := query.Results.GetAttr("data") + data := query.Results.Value.GetAttr("data") for it := data.ElementIterator(); it.Next(); { _, value := it.Element() diff --git a/internal/command/views/query_operation.go b/internal/command/views/query_operation.go index e8fa2026ee81..a1000451f76b 100644 --- a/internal/command/views/query_operation.go +++ b/internal/command/views/query_operation.go @@ -129,7 +129,6 @@ func (v *QueryOperationJSON) Plan(plan *plans.Plan, schemas *terraform.Schemas) v.view.QueryResult(json.NewQueryResults(query)) } - // TODO: show the plan as json with only list results } func (v *QueryOperationJSON) PlannedChange(change *plans.ResourceInstanceChangeSrc) { diff --git a/internal/plans/changes.go b/internal/plans/changes.go index a55aa5da20c2..e2cb7dd1879c 100644 --- a/internal/plans/changes.go +++ b/internal/plans/changes.go @@ -247,7 +247,11 @@ type QueryInstance struct { ProviderAddr addrs.AbsProviderConfig - Results cty.Value + Results QueryResults +} + +type QueryResults struct { + Value cty.Value } func (qi *QueryInstance) DeepCopy() *QueryInstance { @@ -260,7 +264,7 @@ func (qi *QueryInstance) DeepCopy() *QueryInstance { } func (rc *QueryInstance) Encode(schema providers.Schema) (*QueryInstanceSrc, error) { - results, err := NewDynamicValue(rc.Results, schema.Body.ImpliedType()) + results, err := NewDynamicValue(rc.Results.Value, schema.Body.ImpliedType()) if err != nil { return nil, err } diff --git a/internal/plans/changes_src.go b/internal/plans/changes_src.go index c4fb3e1acf23..023dca14ec28 100644 --- a/internal/plans/changes_src.go +++ b/internal/plans/changes_src.go @@ -190,8 +190,10 @@ func (qis *QueryInstanceSrc) Decode(schema providers.Schema) (*QueryInstance, er } return &QueryInstance{ - Addr: qis.Addr, - Results: query, + Addr: qis.Addr, + Results: QueryResults{ + Value: query, + }, ProviderAddr: qis.ProviderAddr, }, nil } diff --git a/internal/terraform/context_plan_query_test.go b/internal/terraform/context_plan_query_test.go index b0cb75ddbcb3..5d6fc6f23771 100644 --- a/internal/terraform/context_plan_query_test.go +++ b/internal/terraform/context_plan_query_test.go @@ -121,7 +121,7 @@ func TestContext2Plan_queryList(t *testing.T) { // Verify instance types actualTypes := make([]string, 0) - obj := cs.Results.GetAttr("data") + obj := cs.Results.Value.GetAttr("data") if obj.IsNull() { t.Fatalf("Expected 'data' attribute to be present, but it is null") } @@ -236,7 +236,7 @@ func TestContext2Plan_queryList(t *testing.T) { // Verify instance types expectedTypes := []string{"ami-123456", "ami-654321"} actualTypes := make([]string, 0) - obj := cs.Results.GetAttr("data") + obj := cs.Results.Value.GetAttr("data") if obj.IsNull() { t.Fatalf("Expected 'data' attribute to be present, but it is null") } @@ -538,7 +538,7 @@ func TestContext2Plan_queryList(t *testing.T) { // Verify instance types expectedTypes := []string{"ami-123456"} actualTypes := make([]string, 0) - obj := cs.Results.GetAttr("data") + obj := cs.Results.Value.GetAttr("data") if obj.IsNull() { t.Fatalf("Expected 'data' attribute to be present, but it is null") } @@ -649,7 +649,7 @@ func TestContext2Plan_queryList(t *testing.T) { // Verify instance types expectedTypes := []string{"ami-123456"} actualTypes := make([]string, 0) - obj := cs.Results.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 71ca40dece1d..87ca97302600 100644 --- a/internal/terraform/evaluate.go +++ b/internal/terraform/evaluate.go @@ -876,7 +876,7 @@ func (d *evaluationStateData) getListResource(config *configs.Resource, rng tfdi for _, inst := range queries { key := inst.Addr.Resource.Key if intKey, ok := key.(addrs.IntKey); ok { - vals[int(intKey)] = inst.Results + vals[int(intKey)] = inst.Results.Value } } @@ -895,7 +895,7 @@ func (d *evaluationStateData) getListResource(config *configs.Resource, rng tfdi for _, inst := range queries { key := inst.Addr.Resource.Key if strKey, ok := key.(addrs.StringKey); ok { - vals[string(strKey)] = inst.Results + vals[string(strKey)] = inst.Results.Value } } @@ -915,7 +915,7 @@ func (d *evaluationStateData) getListResource(config *configs.Resource, rng tfdi "data": cty.EmptyTupleVal, }) } else { - ret = queries[0].Results + ret = queries[0].Results.Value } } diff --git a/internal/terraform/node_resource_plan_instance_query.go b/internal/terraform/node_resource_plan_instance_query.go index b921c26015a2..ffd1095eefe8 100644 --- a/internal/terraform/node_resource_plan_instance_query.go +++ b/internal/terraform/node_resource_plan_instance_query.go @@ -90,7 +90,9 @@ func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (di query := &plans.QueryInstance{ Addr: n.Addr, ProviderAddr: n.ResolvedProvider, - Results: resp.Result, + Results: plans.QueryResults{ + Value: resp.Result, + }, } ctx.Changes().AppendQueryInstance(query) From e4eb74d6513eabc1f04337748abce31b878f9970 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Mon, 23 Jun 2025 18:02:54 +0200 Subject: [PATCH 11/15] Structured list result logging --- internal/command/views/json/query.go | 46 ++++++++++++++++------- internal/command/views/json_view.go | 8 ---- internal/command/views/query_operation.go | 32 ++++++++++++++-- 3 files changed, 61 insertions(+), 25 deletions(-) diff --git a/internal/command/views/json/query.go b/internal/command/views/json/query.go index 7a50c30d697b..1e1c4b603bf3 100644 --- a/internal/command/views/json/query.go +++ b/internal/command/views/json/query.go @@ -4,26 +4,44 @@ package json import ( - "fmt" + "encoding/json" - "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/zclconf/go-cty/cty" + ctyjson "github.com/zclconf/go-cty/cty/json" ) -func NewQueryResults(change *plans.QueryInstanceSrc) *QueryResult { - addr := newResourceAddr(change.Addr) +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"` +} - return &QueryResult{ - Addr: addr, - ResourceType: change.Addr.Resource.Resource.Type, +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")), + // Config } - } -type QueryResult struct { - Addr ResourceAddr `json:"addr"` - ResourceType string `json:"resource_type"` -} +func marshalValues(value cty.Value) map[string]json.RawMessage { + if value == cty.NilVal || value.IsNull() { + return nil + } -func (r *QueryResult) String() string { - return fmt.Sprintf("%s: Quering resources...", r.Addr.Addr) + 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/json_view.go b/internal/command/views/json_view.go index 337269895263..085d0e703b44 100644 --- a/internal/command/views/json_view.go +++ b/internal/command/views/json_view.go @@ -126,11 +126,3 @@ func (v *JSONView) Outputs(outputs json.Outputs) { "outputs", outputs, ) } - -func (v *JSONView) QueryResult(r *json.QueryResult) { - v.log.Info( - r.String(), - "type", json.MessageListResourceFound, - "result", r, - ) -} diff --git a/internal/command/views/query_operation.go b/internal/command/views/query_operation.go index a1000451f76b..4d182677ce57 100644 --- a/internal/command/views/query_operation.go +++ b/internal/command/views/query_operation.go @@ -124,11 +124,37 @@ func (v *QueryOperationJSON) EmergencyDumpState(stateFile *statefile.File) error } func (v *QueryOperationJSON) Plan(plan *plans.Plan, schemas *terraform.Schemas) { - // TODO: log operation updates as structured logging for _, query := range plan.Changes.Queries { - v.view.QueryResult(json.NewQueryResults(query)) + addr := query.Addr + schema := schemas.ResourceTypeConfig( + query.ProviderAddr.Provider, + addr.Resource.Resource.Mode, + addr.Resource.Resource.Type, + ) + if schema.Body == nil { + // TODO: log a warning or error + continue + } + + queryInstance, err := query.Decode(schema) + if err != nil { + // TODO: log an error + continue + } + + data := queryInstance.Results.Value.GetAttr("data") + for it := data.ElementIterator(); it.Next(); { + _, value := it.Element() + + result := json.NewQueryResult(addr, value) + + v.view.log.Info( + fmt.Sprintf("%s: Result found", addr.String()), + "type", json.MessageListResourceFound, + json.MessageListResourceFound, result, + ) + } } - } func (v *QueryOperationJSON) PlannedChange(change *plans.ResourceInstanceChangeSrc) { From e7aac673dd78118f6ef9c2f946e149ccd344f55c Mon Sep 17 00:00:00 2001 From: Samsondeen Dare Date: Wed, 28 May 2025 13:24:52 +0200 Subject: [PATCH 12/15] Generate config for list results --- internal/genconfig/generate_config.go | 87 +++++++++ internal/genconfig/generate_config_test.go | 173 ++++++++++++++++++ .../node_resource_abstract_instance.go | 2 +- .../node_resource_plan_instance_query.go | 34 ++++ 4 files changed, 295 insertions(+), 1 deletion(-) diff --git a/internal/genconfig/generate_config.go b/internal/genconfig/generate_config.go index 1faf9939e722..7ed95cfe5064 100644 --- a/internal/genconfig/generate_config.go +++ b/internal/genconfig/generate_config.go @@ -4,6 +4,7 @@ package genconfig import ( + "bytes" "encoding/json" "fmt" "maps" @@ -65,6 +66,92 @@ func WrapResourceContents(addr addrs.AbsResourceInstance, config string) string return string(formatted) } +func GenerateListResourceContents(addr addrs.AbsResourceInstance, + schema *configschema.Block, + idSchema *configschema.Object, + pc addrs.LocalProviderConfig, + stateVal cty.Value, + id cty.Value, +) (string, tfdiags.Diagnostics) { + var buf strings.Builder + var diags tfdiags.Diagnostics + if !stateVal.CanIterateElements() { + diags = diags.Append( + hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid resource instance value", + Detail: fmt.Sprintf("Resource instance %s has nil or non-iterable value", addr), + }) + return "", diags + } + if !id.CanIterateElements() { + diags = diags.Append( + hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid resource instance identity value", + Detail: fmt.Sprintf("Resource instance %s has nil or non-iterable identity value", addr), + }) + return "", diags + } + + iter := stateVal.ElementIterator() + idIter := id.ElementIterator() + for idx := 0; iter.Next() && idIter.Next(); idx++ { + // Generate a unique resource name for each instance in the list. + resAddr := addrs.AbsResourceInstance{ + Module: addr.Module, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: addr.Resource.Resource.Type, + Name: fmt.Sprintf("%s_%d", addr.Resource.Resource.Name, idx), + }, + Key: addr.Resource.Key, + }, + } + _, val := iter.Element() + content, gDiags := GenerateResourceContents(resAddr, schema, pc, val) + if gDiags.HasErrors() { + diags = diags.Append(gDiags) + continue + } + content = WrapResourceContents(resAddr, content) + buf.WriteString(content) + buf.WriteString("\n") + + _, idVal := idIter.Element() + importContent, gDiags := generateImportBlock(resAddr, idSchema, pc, idVal) + if gDiags.HasErrors() { + diags = diags.Append(gDiags) + continue + } + + buf.WriteString(importContent) + buf.WriteString("\n\n") + + } + + formatted := hclwrite.Format([]byte(buf.String())) + return string(bytes.TrimSpace(formatted)), diags +} + +func generateImportBlock(addr addrs.AbsResourceInstance, idSchema *configschema.Object, pc addrs.LocalProviderConfig, identity cty.Value) (string, tfdiags.Diagnostics) { + var buf strings.Builder + var diags tfdiags.Diagnostics + + buf.WriteString("\n") + buf.WriteString("import {\n") + buf.WriteString(fmt.Sprintf(" to = %s\n", addr.String())) + buf.WriteString(fmt.Sprintf(" provider = %s\n", pc.StringCompact())) + buf.WriteString(fmt.Sprintf(" identity = {\n")) + diags = diags.Append(writeConfigAttributesFromExisting(addr, &buf, identity, idSchema.Attributes, 3)) + buf.WriteString(strings.Repeat(" ", 2)) + buf.WriteString("}\n}\n") + + formatted := hclwrite.Format([]byte(buf.String())) + return string(formatted), diags +} + func writeConfigAttributes(addr addrs.AbsResourceInstance, buf *strings.Builder, attrs map[string]*configschema.Attribute, indent int) tfdiags.Diagnostics { var diags tfdiags.Diagnostics diff --git a/internal/genconfig/generate_config_test.go b/internal/genconfig/generate_config_test.go index 87d3fb778f85..a22cbb119134 100644 --- a/internal/genconfig/generate_config_test.go +++ b/internal/genconfig/generate_config_test.go @@ -846,3 +846,176 @@ func sensitiveAttribute(t cty.Type) *configschema.Attribute { Sensitive: true, } } + +func TestGenerateResourceAndIDContents(t *testing.T) { + // Define a simple schema with some attributes + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "name": { + Type: cty.String, + Optional: true, + }, + "id": { + Type: cty.String, + Computed: true, + }, + "tags": { + Type: cty.Map(cty.String), + Optional: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "network_interface": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "subnet_id": { + Type: cty.String, + Required: true, + }, + "ip_address": { + Type: cty.String, + Optional: true, + }, + }, + }, + }, + }, + } + + // Define the identity schema + idSchema := &configschema.Object{ + Nesting: configschema.NestingSingle, + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Optional: true, + }, + }, + } + + // Create mock resource instance values + resourceVal := cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("instance-1"), + "id": cty.StringVal("i-abcdef"), + "tags": cty.MapVal(map[string]cty.Value{ + "Environment": cty.StringVal("Dev"), + "Owner": cty.StringVal("Team1"), + }), + "network_interface": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "subnet_id": cty.StringVal("subnet-123"), + "ip_address": cty.StringVal("10.0.0.1"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("instance-2"), + "id": cty.StringVal("i-123456"), + "tags": cty.MapVal(map[string]cty.Value{ + "Environment": cty.StringVal("Prod"), + "Owner": cty.StringVal("Team2"), + }), + "network_interface": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "subnet_id": cty.StringVal("subnet-456"), + "ip_address": cty.StringVal("10.0.0.2"), + }), + }), + }), + }) + + // Create mock identity values + identityVal := cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-abcdef"), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-123456"), + }), + }) + + // Create test resource address + addr := addrs.AbsResource{ + Module: addrs.RootModuleInstance, + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "aws_instance", + Name: "example", + }, + } + + // Create instance addresses for each instance + instAddr1 := addr.Instance(addrs.NoKey) + + // Create provider config + pc := addrs.LocalProviderConfig{ + LocalName: "aws", + } + + // Generate content + content, diags := GenerateListResourceContents(instAddr1, schema, idSchema, pc, resourceVal, identityVal) + // Check for diagnostics + if diags.HasErrors() { + t.Fatalf("unexpected diagnostics: %s", diags.Err()) + } + + // Check the generated content + expectedContent := `resource "aws_instance" "example_0" { + name = "instance-1" + tags = { + Environment = "Dev" + Owner = "Team1" + } + network_interface { + ip_address = "10.0.0.1" + subnet_id = "subnet-123" + } +} + +import { + to = aws_instance.example_0 + provider = aws + identity = { + id = "i-abcdef" + } +} + + +resource "aws_instance" "example_1" { + name = "instance-2" + tags = { + Environment = "Prod" + Owner = "Team2" + } + network_interface { + ip_address = "10.0.0.2" + subnet_id = "subnet-456" + } +} + +import { + to = aws_instance.example_1 + provider = aws + identity = { + id = "i-123456" + } +}` + // Normalize both strings by removing extra whitespace for comparison + normalizeString := func(s string) string { + // Remove spaces at the end of lines and replace multiple newlines with a single one + lines := strings.Split(s, "\n") + for i, line := range lines { + lines[i] = strings.TrimRight(line, " \t") + } + return strings.Join(lines, "\n") + } + + normalizedExpected := normalizeString(expectedContent) + normalizedActual := normalizeString(content) + + if diff := cmp.Diff(normalizedExpected, normalizedActual); diff != "" { + t.Errorf("Generated content doesn't match expected (-want +got):\n%s", diff) + } +} diff --git a/internal/terraform/node_resource_abstract_instance.go b/internal/terraform/node_resource_abstract_instance.go index b1a4b4626ae3..511b2558f9ba 100644 --- a/internal/terraform/node_resource_abstract_instance.go +++ b/internal/terraform/node_resource_abstract_instance.go @@ -46,7 +46,7 @@ type NodeAbstractResourceInstance struct { preDestroyRefresh bool - // During import we may generate configuration for a resource, which needs + // During import (or query) we may generate configuration for a resource, which needs // to be stored in the final change. generatedConfigHCL string diff --git a/internal/terraform/node_resource_plan_instance_query.go b/internal/terraform/node_resource_plan_instance_query.go index ffd1095eefe8..cf07057bc567 100644 --- a/internal/terraform/node_resource_plan_instance_query.go +++ b/internal/terraform/node_resource_plan_instance_query.go @@ -7,6 +7,9 @@ import ( "fmt" "log" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/genconfig" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/tfdiags" @@ -89,6 +92,7 @@ func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (di query := &plans.QueryInstance{ Addr: n.Addr, + PrevRunAddr: n.Addr, ProviderAddr: n.ResolvedProvider, Results: plans.QueryResults{ Value: resp.Result, @@ -98,3 +102,33 @@ func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (di ctx.Changes().AppendQueryInstance(query) return diags } + +func (n *NodePlannableResourceInstance) generateListConfig(obj, identity cty.Value) (generated string, diags tfdiags.Diagnostics) { + schema := n.Schema.Body + filteredSchema := schema.Filter( + configschema.FilterOr( + configschema.FilterReadOnlyAttribute, + configschema.FilterDeprecatedAttribute, + + // The legacy SDK adds an Optional+Computed "id" attribute to the + // resource schema even if not defined in provider code. + // During validation, however, the presence of an extraneous "id" + // attribute in config will cause an error. + // Remove this attribute so we do not generate an "id" attribute + // where there is a risk that it is not in the real resource schema. + // + // TRADEOFF: Resources in which there actually is an + // Optional+Computed "id" attribute in the schema will have that + // attribute missing from generated config. + configschema.FilterHelperSchemaIdAttribute, + ), + configschema.FilterDeprecatedBlock, + ) + + providerAddr := addrs.LocalProviderConfig{ + LocalName: n.ResolvedProvider.Provider.Type, + Alias: n.ResolvedProvider.Alias, + } + + return genconfig.GenerateListResourceContents(n.Addr, filteredSchema, n.Schema.Identity, providerAddr, obj, identity) +} From cb3e64521544f2ef360ccd0a6fa38046910e8bd7 Mon Sep 17 00:00:00 2001 From: Samsondeen Dare Date: Wed, 28 May 2025 13:53:36 +0200 Subject: [PATCH 13/15] allow all attributes for identity --- internal/genconfig/generate_config.go | 52 +++--- internal/genconfig/generate_config_test.go | 65 ++++--- internal/terraform/context_plan_query_test.go | 159 +++++++++++++++++- .../node_resource_plan_instance_query.go | 32 ++-- internal/terraform/transform_config.go | 4 + 5 files changed, 234 insertions(+), 78 deletions(-) diff --git a/internal/genconfig/generate_config.go b/internal/genconfig/generate_config.go index 7ed95cfe5064..7f34d37beb51 100644 --- a/internal/genconfig/generate_config.go +++ b/internal/genconfig/generate_config.go @@ -45,7 +45,7 @@ func GenerateResourceContents(addr addrs.AbsResourceInstance, diags = diags.Append(writeConfigAttributes(addr, &buf, schema.Attributes, 2)) diags = diags.Append(writeConfigBlocks(addr, &buf, schema.BlockTypes, 2)) } else { - diags = diags.Append(writeConfigAttributesFromExisting(addr, &buf, stateVal, schema.Attributes, 2)) + diags = diags.Append(writeConfigAttributesFromExisting(addr, &buf, stateVal, schema.Attributes, 2, optionalOrRequiredProcessor)) diags = diags.Append(writeConfigBlocksFromExisting(addr, &buf, stateVal, schema.BlockTypes, 2)) } @@ -71,7 +71,6 @@ func GenerateListResourceContents(addr addrs.AbsResourceInstance, idSchema *configschema.Object, pc addrs.LocalProviderConfig, stateVal cty.Value, - id cty.Value, ) (string, tfdiags.Diagnostics) { var buf strings.Builder var diags tfdiags.Diagnostics @@ -84,19 +83,9 @@ func GenerateListResourceContents(addr addrs.AbsResourceInstance, }) return "", diags } - if !id.CanIterateElements() { - diags = diags.Append( - hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid resource instance identity value", - Detail: fmt.Sprintf("Resource instance %s has nil or non-iterable identity value", addr), - }) - return "", diags - } iter := stateVal.ElementIterator() - idIter := id.ElementIterator() - for idx := 0; iter.Next() && idIter.Next(); idx++ { + for idx := 0; iter.Next(); idx++ { // Generate a unique resource name for each instance in the list. resAddr := addrs.AbsResourceInstance{ Module: addr.Module, @@ -110,7 +99,8 @@ func GenerateListResourceContents(addr addrs.AbsResourceInstance, }, } _, val := iter.Element() - content, gDiags := GenerateResourceContents(resAddr, schema, pc, val) + stateVal := val.GetAttr("state") + content, gDiags := GenerateResourceContents(resAddr, schema, pc, stateVal) if gDiags.HasErrors() { diags = diags.Append(gDiags) continue @@ -119,7 +109,7 @@ func GenerateListResourceContents(addr addrs.AbsResourceInstance, buf.WriteString(content) buf.WriteString("\n") - _, idVal := idIter.Element() + idVal := val.GetAttr("identity") importContent, gDiags := generateImportBlock(resAddr, idSchema, pc, idVal) if gDiags.HasErrors() { diags = diags.Append(gDiags) @@ -143,8 +133,8 @@ func generateImportBlock(addr addrs.AbsResourceInstance, idSchema *configschema. buf.WriteString("import {\n") buf.WriteString(fmt.Sprintf(" to = %s\n", addr.String())) buf.WriteString(fmt.Sprintf(" provider = %s\n", pc.StringCompact())) - buf.WriteString(fmt.Sprintf(" identity = {\n")) - diags = diags.Append(writeConfigAttributesFromExisting(addr, &buf, identity, idSchema.Attributes, 3)) + buf.WriteString(" identity = {\n") + diags = diags.Append(writeConfigAttributesFromExisting(addr, &buf, identity, idSchema.Attributes, 2, allowAllAttributesProcessor)) buf.WriteString(strings.Repeat(" ", 2)) buf.WriteString("}\n}\n") @@ -199,7 +189,16 @@ func writeConfigAttributes(addr addrs.AbsResourceInstance, buf *strings.Builder, return diags } -func writeConfigAttributesFromExisting(addr addrs.AbsResourceInstance, buf *strings.Builder, stateVal cty.Value, attrs map[string]*configschema.Attribute, indent int) tfdiags.Diagnostics { +func optionalOrRequiredProcessor(attr *configschema.Attribute) bool { + // Exclude computed-only attributes + return attr.Optional || attr.Required +} + +func allowAllAttributesProcessor(attr *configschema.Attribute) bool { + return true +} + +func writeConfigAttributesFromExisting(addr addrs.AbsResourceInstance, buf *strings.Builder, stateVal cty.Value, attrs map[string]*configschema.Attribute, indent int, processAttr func(*configschema.Attribute) bool) tfdiags.Diagnostics { var diags tfdiags.Diagnostics if len(attrs) == 0 { return diags @@ -213,8 +212,7 @@ func writeConfigAttributesFromExisting(addr addrs.AbsResourceInstance, buf *stri continue } - // Exclude computed-only attributes - if attrS.Required || attrS.Optional { + if processAttr != nil && processAttr(attrS) { buf.WriteString(strings.Repeat(" ", indent)) buf.WriteString(fmt.Sprintf("%s = ", name)) @@ -414,6 +412,7 @@ func writeConfigBlocksFromExisting(addr addrs.AbsResourceInstance, buf *strings. func writeConfigNestedTypeAttributeFromExisting(addr addrs.AbsResourceInstance, buf *strings.Builder, name string, schema *configschema.Attribute, stateVal cty.Value, indent int) tfdiags.Diagnostics { var diags tfdiags.Diagnostics + processor := optionalOrRequiredProcessor switch schema.NestedType.Nesting { case configschema.NestingSingle: @@ -441,7 +440,7 @@ func writeConfigNestedTypeAttributeFromExisting(addr addrs.AbsResourceInstance, buf.WriteString(strings.Repeat(" ", indent)) buf.WriteString(fmt.Sprintf("%s = {\n", name)) - diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, nestedVal, schema.NestedType.Attributes, indent+2)) + diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, nestedVal, schema.NestedType.Attributes, indent+2, processor)) buf.WriteString("}\n") return diags @@ -473,7 +472,7 @@ func writeConfigNestedTypeAttributeFromExisting(addr addrs.AbsResourceInstance, } buf.WriteString("{\n") - diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, listVals[i], schema.NestedType.Attributes, indent+4)) + diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, listVals[i], schema.NestedType.Attributes, indent+4, processor)) buf.WriteString(strings.Repeat(" ", indent+2)) buf.WriteString("},\n") } @@ -511,7 +510,7 @@ func writeConfigNestedTypeAttributeFromExisting(addr addrs.AbsResourceInstance, } buf.WriteString("\n") - diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, vals[key], schema.NestedType.Attributes, indent+4)) + diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, vals[key], schema.NestedType.Attributes, indent+4, processor)) buf.WriteString(strings.Repeat(" ", indent+2)) buf.WriteString("}\n") } @@ -527,6 +526,7 @@ func writeConfigNestedTypeAttributeFromExisting(addr addrs.AbsResourceInstance, func writeConfigNestedBlockFromExisting(addr addrs.AbsResourceInstance, buf *strings.Builder, name string, schema *configschema.NestedBlock, stateVal cty.Value, indent int) tfdiags.Diagnostics { var diags tfdiags.Diagnostics + processAttr := optionalOrRequiredProcessor switch schema.Nesting { case configschema.NestingSingle, configschema.NestingGroup: @@ -542,7 +542,7 @@ func writeConfigNestedBlockFromExisting(addr addrs.AbsResourceInstance, buf *str return diags } buf.WriteString("\n") - diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, stateVal, schema.Attributes, indent+2)) + diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, stateVal, schema.Attributes, indent+2, processAttr)) diags = diags.Append(writeConfigBlocksFromExisting(addr, buf, stateVal, schema.BlockTypes, indent+2)) buf.WriteString("}\n") return diags @@ -556,7 +556,7 @@ func writeConfigNestedBlockFromExisting(addr addrs.AbsResourceInstance, buf *str for i := range listVals { buf.WriteString(strings.Repeat(" ", indent)) buf.WriteString(fmt.Sprintf("%s {\n", name)) - diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, listVals[i], schema.Attributes, indent+2)) + diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, listVals[i], schema.Attributes, indent+2, processAttr)) diags = diags.Append(writeConfigBlocksFromExisting(addr, buf, listVals[i], schema.BlockTypes, indent+2)) buf.WriteString("}\n") } @@ -578,7 +578,7 @@ func writeConfigNestedBlockFromExisting(addr addrs.AbsResourceInstance, buf *str return diags } buf.WriteString("\n") - diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, vals[key], schema.Attributes, indent+2)) + diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, vals[key], schema.Attributes, indent+2, processAttr)) diags = diags.Append(writeConfigBlocksFromExisting(addr, buf, vals[key], schema.BlockTypes, indent+2)) buf.WriteString(strings.Repeat(" ", indent)) buf.WriteString("}\n") diff --git a/internal/genconfig/generate_config_test.go b/internal/genconfig/generate_config_test.go index a22cbb119134..14ca5c8f41d4 100644 --- a/internal/genconfig/generate_config_test.go +++ b/internal/genconfig/generate_config_test.go @@ -848,7 +848,6 @@ func sensitiveAttribute(t cty.Type) *configschema.Attribute { } func TestGenerateResourceAndIDContents(t *testing.T) { - // Define a simple schema with some attributes schema := &configschema.Block{ Attributes: map[string]*configschema.Attribute{ "name": { @@ -895,44 +894,44 @@ func TestGenerateResourceAndIDContents(t *testing.T) { } // Create mock resource instance values - resourceVal := cty.ListVal([]cty.Value{ + value := cty.TupleVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("instance-1"), - "id": cty.StringVal("i-abcdef"), - "tags": cty.MapVal(map[string]cty.Value{ - "Environment": cty.StringVal("Dev"), - "Owner": cty.StringVal("Team1"), - }), - "network_interface": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "subnet_id": cty.StringVal("subnet-123"), - "ip_address": cty.StringVal("10.0.0.1"), + "state": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("instance-1"), + "id": cty.StringVal("i-abcdef"), + "tags": cty.MapVal(map[string]cty.Value{ + "Environment": cty.StringVal("Dev"), + "Owner": cty.StringVal("Team1"), + }), + "network_interface": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "subnet_id": cty.StringVal("subnet-123"), + "ip_address": cty.StringVal("10.0.0.1"), + }), }), }), + "identity": cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-abcdef"), + }), }), cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("instance-2"), - "id": cty.StringVal("i-123456"), - "tags": cty.MapVal(map[string]cty.Value{ - "Environment": cty.StringVal("Prod"), - "Owner": cty.StringVal("Team2"), - }), - "network_interface": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "subnet_id": cty.StringVal("subnet-456"), - "ip_address": cty.StringVal("10.0.0.2"), + "state": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("instance-2"), + "id": cty.StringVal("i-123456"), + "tags": cty.MapVal(map[string]cty.Value{ + "Environment": cty.StringVal("Prod"), + "Owner": cty.StringVal("Team2"), + }), + "network_interface": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "subnet_id": cty.StringVal("subnet-456"), + "ip_address": cty.StringVal("10.0.0.2"), + }), }), }), - }), - }) - - // Create mock identity values - identityVal := cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("i-abcdef"), - }), - cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("i-123456"), + "identity": cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-123456"), + }), }), }) @@ -955,7 +954,7 @@ func TestGenerateResourceAndIDContents(t *testing.T) { } // Generate content - content, diags := GenerateListResourceContents(instAddr1, schema, idSchema, pc, resourceVal, identityVal) + content, diags := GenerateListResourceContents(instAddr1, schema, idSchema, pc, value) // Check for diagnostics if diags.HasErrors() { t.Fatalf("unexpected diagnostics: %s", diags.Err()) diff --git a/internal/terraform/context_plan_query_test.go b/internal/terraform/context_plan_query_test.go index 5d6fc6f23771..b7d22638f04d 100644 --- a/internal/terraform/context_plan_query_test.go +++ b/internal/terraform/context_plan_query_test.go @@ -22,10 +22,65 @@ import ( ) func TestContext2Plan_queryList(t *testing.T) { + schemaResp := getProviderSchemaResponseFromProviderSchema(&providerSchema{ + ResourceTypes: map[string]*configschema.Block{ + "list": { + Attributes: map[string]*configschema.Attribute{ + "attr": { + Type: cty.String, + Computed: true, + }, + }, + }, + "test_resource": { + Attributes: map[string]*configschema.Attribute{ + "instance_type": { + Type: cty.String, + Computed: true, + Optional: true, + }, + }, + }, + "test_child_resource": { + Attributes: map[string]*configschema.Attribute{ + "instance_type": { + Type: cty.String, + Computed: true, + }, + }, + }, + }, + ListResourceTypes: map[string]*configschema.Block{ + "test_resource": getQueryTestSchema(), + "test_child_resource": getQueryTestSchema(), + }, + IdentityTypes: map[string]*configschema.Object{ + "test_resource": { + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + }, + Nesting: configschema.NestingSingle, + }, + "test_child_resource": { + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + }, + Nesting: configschema.NestingSingle, + }, + }, + }) + cases := []struct { name string mainConfig string queryConfig string + generatedPath string diagCount int expectedErrMsg []string assertState func(*states.State) @@ -33,7 +88,7 @@ func TestContext2Plan_queryList(t *testing.T) { listResourceFn func(request providers.ListResourceRequest) providers.ListResourceResponse }{ { - name: "valid list reference", + name: "valid list reference - generates config", mainConfig: ` terraform { required_providers { @@ -71,6 +126,9 @@ func TestContext2Plan_queryList(t *testing.T) { } } `, + generatedPath: func() string { + return t.TempDir() + }(), listResourceFn: func(request providers.ListResourceRequest) providers.ListResourceResponse { madeUp := []cty.Value{ cty.ObjectVal(map[string]cty.Value{"instance_type": cty.StringVal("ami-123456")}), @@ -107,12 +165,12 @@ func TestContext2Plan_queryList(t *testing.T) { return providers.ListResourceResponse{Result: cty.ObjectVal(ret)} }, assertChanges: func(sch providers.ProviderSchema, changes *plans.ChangesSrc) { - expectedResources := map[string][]string{ - "list.test_resource.test": {"ami-123456", "ami-654321", "ami-789012"}, - "list.test_resource.test2": {}, - } - actualResources := map[string][]string{} + expectedResources := []string{"list.test_resource.test", "list.test_resource.test2"} + actualResources := make([]string, 0) + generatedCfgs := make([]string, 0) for _, change := range changes.Queries { + actualResources = append(actualResources, change.Addr.String()) + generatedCfgs = append(generatedCfgs, strings.ReplaceAll(change.GeneratedConfig, "\n", "\n")) schema := sch.ListResourceTypes[change.Addr.Resource.Resource.Type] cs, err := change.Decode(schema) if err != nil { @@ -147,6 +205,10 @@ func TestContext2Plan_queryList(t *testing.T) { if diff := cmp.Diff(expectedResources, actualResources); diff != "" { t.Fatalf("Expected resources to match, but they differ: %s", diff) } + + if diff := cmp.Diff([]string{testResourceCfg, testResourceCfg2}, generatedCfgs); diff != "" { + t.Fatalf("Expected generated configs to match, but they differ: %s", diff) + } }, }, { @@ -709,9 +771,10 @@ func TestContext2Plan_queryList(t *testing.T) { tfdiags.AssertNoDiagnostics(t, diags) plan, diags := ctx.Plan(mod, states.NewState(), &PlanOpts{ - Mode: plans.NormalMode, - SetVariables: testInputValuesUnset(mod.Module.Variables), - Query: true, + Mode: plans.NormalMode, + SetVariables: testInputValuesUnset(mod.Module.Variables), + Query: true, + GenerateConfigPath: tc.generatedPath, }) if len(diags) != tc.diagCount { t.Fatalf("expected %d diagnostics, got %d \n -diags: %s", tc.diagCount, len(diags), diags) @@ -962,3 +1025,81 @@ func getListProviderSchemaResp() *providers.GetProviderSchemaResponse { }, }) } + +var ( + testResourceCfg = `resource "test_resource" "test_0" { + instance_type = "ami-123456" +} + +import { + to = test_resource.test_0 + provider = test + identity = { + id = "i-v1" + } +} + + +resource "test_resource" "test_1" { + instance_type = "ami-654321" +} + +import { + to = test_resource.test_1 + provider = test + identity = { + id = "i-v2" + } +} + + +resource "test_resource" "test_2" { + instance_type = "ami-789012" +} + +import { + to = test_resource.test_2 + provider = test + identity = { + id = "i-v3" + } +}` + + testResourceCfg2 = `resource "test_resource" "test2_0" { + instance_type = "ami-123456" +} + +import { + to = test_resource.test2_0 + provider = test + identity = { + id = "i-v1" + } +} + + +resource "test_resource" "test2_1" { + instance_type = "ami-654321" +} + +import { + to = test_resource.test2_1 + provider = test + identity = { + id = "i-v2" + } +} + + +resource "test_resource" "test2_2" { + instance_type = "ami-789012" +} + +import { + to = test_resource.test2_2 + provider = test + identity = { + id = "i-v3" + } +}` +) diff --git a/internal/terraform/node_resource_plan_instance_query.go b/internal/terraform/node_resource_plan_instance_query.go index cf07057bc567..9d2604b00202 100644 --- a/internal/terraform/node_resource_plan_instance_query.go +++ b/internal/terraform/node_resource_plan_instance_query.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" ) func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (diags tfdiags.Diagnostics) { @@ -90,9 +91,19 @@ func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (di return diags } + // If a path is specified, generate the config for the resource + var generated string + if n.generateConfigPath != "" { + var gDiags tfdiags.Diagnostics + generated, gDiags = n.generateListConfig(resp.Result, providerSchema.ResourceTypes[n.Config.Type]) + diags = diags.Append(gDiags) + if diags.HasErrors() { + return diags + } + } + query := &plans.QueryInstance{ Addr: n.Addr, - PrevRunAddr: n.Addr, ProviderAddr: n.ResolvedProvider, Results: plans.QueryResults{ Value: resp.Result, @@ -103,9 +114,13 @@ func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (di return diags } -func (n *NodePlannableResourceInstance) generateListConfig(obj, identity cty.Value) (generated string, diags tfdiags.Diagnostics) { - schema := n.Schema.Body - filteredSchema := schema.Filter( +func (n *NodePlannableResourceInstance) generateListConfig(obj cty.Value, resourceSchema providers.Schema) (generated string, diags tfdiags.Diagnostics) { + providerAddr := addrs.LocalProviderConfig{ + LocalName: n.ResolvedProvider.Provider.Type, + Alias: n.ResolvedProvider.Alias, + } + + stateSchema := resourceSchema.Body.Filter( configschema.FilterOr( configschema.FilterReadOnlyAttribute, configschema.FilterDeprecatedAttribute, @@ -124,11 +139,8 @@ func (n *NodePlannableResourceInstance) generateListConfig(obj, identity cty.Val ), configschema.FilterDeprecatedBlock, ) + identitySchema := resourceSchema.Identity - providerAddr := addrs.LocalProviderConfig{ - LocalName: n.ResolvedProvider.Provider.Type, - Alias: n.ResolvedProvider.Alias, - } - - return genconfig.GenerateListResourceContents(n.Addr, filteredSchema, n.Schema.Identity, providerAddr, obj, identity) + data := obj.GetAttr("data") + return genconfig.GenerateListResourceContents(n.Addr, stateSchema, identitySchema, providerAddr, data) } diff --git a/internal/terraform/transform_config.go b/internal/terraform/transform_config.go index 735cc0ecf14f..df75c7c02b6a 100644 --- a/internal/terraform/transform_config.go +++ b/internal/terraform/transform_config.go @@ -181,6 +181,10 @@ func (t *ConfigTransformer) transformSingle(g *Graph, config *configs.Config) er importTargets: imports, } + if r.List != nil { + abstract.generateConfigPath = t.generateConfigPathForImportTargets + } + var node dag.Vertex = abstract if f := t.Concrete; f != nil { node = f(abstract) From a8a72e4f18c57605ea6c024e3228eec827f5ad0e Mon Sep 17 00:00:00 2001 From: Samsondeen Dare Date: Tue, 24 Jun 2025 13:02:19 +0200 Subject: [PATCH 14/15] generate individual config per managed resource --- internal/command/jsonlist/list.go | 8 +- internal/genconfig/generate_config.go | 44 ++++-- internal/genconfig/generate_config_test.go | 16 +- internal/plans/changes.go | 12 +- internal/plans/changes_src.go | 7 +- internal/terraform/context_plan_query_test.go | 137 +++++------------- .../node_resource_plan_instance_query.go | 11 +- 7 files changed, 102 insertions(+), 133 deletions(-) diff --git a/internal/command/jsonlist/list.go b/internal/command/jsonlist/list.go index 7f65e38d34b6..fc649f8dc11d 100644 --- a/internal/command/jsonlist/list.go +++ b/internal/command/jsonlist/list.go @@ -26,8 +26,11 @@ type QueryResult struct { Resource map[string]json.RawMessage `json:"resource,omitempty"` // TODO - // Address string `json:"address,omitempty"` + Address string `json:"address,omitempty"` // Config string `json:"config,omitempty"` + + ResourceConfig string `json:"resource_config,omitempty"` + ImportConfig string `json:"import_config,omitempty"` } func MarshalForRenderer( @@ -79,6 +82,9 @@ func marshalQueryInstance(rc *plans.QueryInstanceSrc, schemas *terraform.Schemas Identity: marshalValues(value.GetAttr("identity")), Resource: marshalValues(value.GetAttr("state")), } + config := query.Results.GeneratedConfig[result.Address] + result.ResourceConfig = string(config.Resource) + result.ImportConfig = string(config.Import) ret.Results = append(ret.Results, result) } diff --git a/internal/genconfig/generate_config.go b/internal/genconfig/generate_config.go index 7f34d37beb51..f17592d5f362 100644 --- a/internal/genconfig/generate_config.go +++ b/internal/genconfig/generate_config.go @@ -22,6 +22,24 @@ import ( "github.com/hashicorp/terraform/internal/tfdiags" ) +type QueryResult struct { + Resource []byte + Import []byte +} + +func (l QueryResult) String() string { + var buf strings.Builder + if len(l.Resource) > 0 { + buf.WriteString(string(l.Resource)) + buf.WriteString("\n") + } + if len(l.Import) > 0 { + buf.WriteString(string(l.Import)) + buf.WriteString("\n") + } + return buf.String() +} + // GenerateResourceContents generates HCL configuration code for the provided // resource and state value. // @@ -71,8 +89,11 @@ func GenerateListResourceContents(addr addrs.AbsResourceInstance, idSchema *configschema.Object, pc addrs.LocalProviderConfig, stateVal cty.Value, -) (string, tfdiags.Diagnostics) { - var buf strings.Builder +) (map[string]QueryResult, tfdiags.Diagnostics) { + hclFmt := func(s []byte) []byte { + return bytes.TrimSpace(hclwrite.Format(s)) + } + ret := make(map[string]QueryResult) var diags tfdiags.Diagnostics if !stateVal.CanIterateElements() { diags = diags.Append( @@ -81,11 +102,12 @@ func GenerateListResourceContents(addr addrs.AbsResourceInstance, Summary: "Invalid resource instance value", Detail: fmt.Sprintf("Resource instance %s has nil or non-iterable value", addr), }) - return "", diags + return ret, diags } iter := stateVal.ElementIterator() for idx := 0; iter.Next(); idx++ { + ls := QueryResult{} // Generate a unique resource name for each instance in the list. resAddr := addrs.AbsResourceInstance{ Module: addr.Module, @@ -99,15 +121,17 @@ func GenerateListResourceContents(addr addrs.AbsResourceInstance, }, } _, val := iter.Element() - stateVal := val.GetAttr("state") + stateVal := cty.NilVal + if val.Type().HasAttribute("state") { + stateVal = val.GetAttr("state") + } content, gDiags := GenerateResourceContents(resAddr, schema, pc, stateVal) if gDiags.HasErrors() { diags = diags.Append(gDiags) continue } content = WrapResourceContents(resAddr, content) - buf.WriteString(content) - buf.WriteString("\n") + ls.Resource = hclFmt([]byte(content)) idVal := val.GetAttr("identity") importContent, gDiags := generateImportBlock(resAddr, idSchema, pc, idVal) @@ -115,14 +139,12 @@ func GenerateListResourceContents(addr addrs.AbsResourceInstance, diags = diags.Append(gDiags) continue } + ls.Import = hclFmt([]byte(importContent)) - buf.WriteString(importContent) - buf.WriteString("\n\n") - + ret[resAddr.String()] = ls } - formatted := hclwrite.Format([]byte(buf.String())) - return string(bytes.TrimSpace(formatted)), diags + return ret, diags } func generateImportBlock(addr addrs.AbsResourceInstance, idSchema *configschema.Object, pc addrs.LocalProviderConfig, identity cty.Value) (string, tfdiags.Diagnostics) { diff --git a/internal/genconfig/generate_config_test.go b/internal/genconfig/generate_config_test.go index 14ca5c8f41d4..ad35e3feeec4 100644 --- a/internal/genconfig/generate_config_test.go +++ b/internal/genconfig/generate_config_test.go @@ -4,6 +4,8 @@ package genconfig import ( + "maps" + "slices" "strings" "testing" @@ -972,7 +974,6 @@ func TestGenerateResourceAndIDContents(t *testing.T) { subnet_id = "subnet-123" } } - import { to = aws_instance.example_0 provider = aws @@ -980,8 +981,6 @@ import { id = "i-abcdef" } } - - resource "aws_instance" "example_1" { name = "instance-2" tags = { @@ -993,14 +992,14 @@ resource "aws_instance" "example_1" { subnet_id = "subnet-456" } } - import { to = aws_instance.example_1 provider = aws identity = { id = "i-123456" } -}` +} +` // Normalize both strings by removing extra whitespace for comparison normalizeString := func(s string) string { // Remove spaces at the end of lines and replace multiple newlines with a single one @@ -1012,7 +1011,12 @@ import { } normalizedExpected := normalizeString(expectedContent) - normalizedActual := normalizeString(content) + + var merged string + for _, addr := range slices.Sorted(maps.Keys(content)) { + merged += content[addr].String() + } + normalizedActual := normalizeString(merged) if diff := cmp.Diff(normalizedExpected, normalizedActual); diff != "" { t.Errorf("Generated content doesn't match expected (-want +got):\n%s", diff) diff --git a/internal/plans/changes.go b/internal/plans/changes.go index e2cb7dd1879c..cc5a3a8bc0bd 100644 --- a/internal/plans/changes.go +++ b/internal/plans/changes.go @@ -9,6 +9,7 @@ import ( "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/genconfig" "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/schemarepo" @@ -251,7 +252,9 @@ type QueryInstance struct { } type QueryResults struct { - Value cty.Value + Value cty.Value + Results cty.Value + GeneratedConfig map[string]genconfig.QueryResult } func (qi *QueryInstance) DeepCopy() *QueryInstance { @@ -270,9 +273,10 @@ func (rc *QueryInstance) Encode(schema providers.Schema) (*QueryInstanceSrc, err } return &QueryInstanceSrc{ - Addr: rc.Addr, - Results: results, - ProviderAddr: rc.ProviderAddr, + Addr: rc.Addr, + Results: results, + ProviderAddr: rc.ProviderAddr, + GeneratedConfig: rc.Results.GeneratedConfig, }, nil } diff --git a/internal/plans/changes_src.go b/internal/plans/changes_src.go index 023dca14ec28..971e661d5e02 100644 --- a/internal/plans/changes_src.go +++ b/internal/plans/changes_src.go @@ -9,6 +9,7 @@ import ( "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/genconfig" "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/schemarepo" @@ -180,7 +181,8 @@ type QueryInstanceSrc struct { ProviderAddr addrs.AbsProviderConfig - Results DynamicValue + Results DynamicValue + GeneratedConfig map[string]genconfig.QueryResult } func (qis *QueryInstanceSrc) Decode(schema providers.Schema) (*QueryInstance, error) { @@ -192,7 +194,8 @@ func (qis *QueryInstanceSrc) Decode(schema providers.Schema) (*QueryInstance, er return &QueryInstance{ Addr: qis.Addr, Results: QueryResults{ - Value: query, + Value: query, + GeneratedConfig: qis.GeneratedConfig, }, ProviderAddr: qis.ProviderAddr, }, nil diff --git a/internal/terraform/context_plan_query_test.go b/internal/terraform/context_plan_query_test.go index b7d22638f04d..b2f3b9b9703d 100644 --- a/internal/terraform/context_plan_query_test.go +++ b/internal/terraform/context_plan_query_test.go @@ -5,6 +5,8 @@ package terraform import ( "fmt" + "maps" + "slices" "sort" "strings" "testing" @@ -22,59 +24,6 @@ import ( ) func TestContext2Plan_queryList(t *testing.T) { - schemaResp := getProviderSchemaResponseFromProviderSchema(&providerSchema{ - ResourceTypes: map[string]*configschema.Block{ - "list": { - Attributes: map[string]*configschema.Attribute{ - "attr": { - Type: cty.String, - Computed: true, - }, - }, - }, - "test_resource": { - Attributes: map[string]*configschema.Attribute{ - "instance_type": { - Type: cty.String, - Computed: true, - Optional: true, - }, - }, - }, - "test_child_resource": { - Attributes: map[string]*configschema.Attribute{ - "instance_type": { - Type: cty.String, - Computed: true, - }, - }, - }, - }, - ListResourceTypes: map[string]*configschema.Block{ - "test_resource": getQueryTestSchema(), - "test_child_resource": getQueryTestSchema(), - }, - IdentityTypes: map[string]*configschema.Object{ - "test_resource": { - Attributes: map[string]*configschema.Attribute{ - "id": { - Type: cty.String, - Computed: true, - }, - }, - Nesting: configschema.NestingSingle, - }, - "test_child_resource": { - Attributes: map[string]*configschema.Attribute{ - "id": { - Type: cty.String, - Computed: true, - }, - }, - Nesting: configschema.NestingSingle, - }, - }, - }) cases := []struct { name string @@ -126,9 +75,7 @@ func TestContext2Plan_queryList(t *testing.T) { } } `, - generatedPath: func() string { - return t.TempDir() - }(), + generatedPath: t.TempDir(), listResourceFn: func(request providers.ListResourceRequest) providers.ListResourceResponse { madeUp := []cty.Value{ cty.ObjectVal(map[string]cty.Value{"instance_type": cty.StringVal("ami-123456")}), @@ -143,24 +90,21 @@ func TestContext2Plan_queryList(t *testing.T) { } resp := []cty.Value{} - if request.IncludeResourceObject { - for i, v := range madeUp { - resp = append(resp, cty.ObjectVal(map[string]cty.Value{ - "state": v, - "identity": ids[i], - "display_name": cty.StringVal(fmt.Sprintf("Instance %d", i+1)), - })) + for i, v := range madeUp { + mp := map[string]cty.Value{ + "identity": ids[i], + "display_name": cty.StringVal(fmt.Sprintf("Instance %d", i+1)), } + if request.IncludeResourceObject { + mp["state"] = v + } + resp = append(resp, cty.ObjectVal(mp)) } - ret := map[string]cty.Value{ + ret := request.Config.AsValueMap() + maps.Copy(ret, map[string]cty.Value{ "data": cty.TupleVal(resp), - } - for k, v := range request.Config.AsValueMap() { - if k != "data" { - ret[k] = v - } - } + }) return providers.ListResourceResponse{Result: cty.ObjectVal(ret)} }, @@ -170,36 +114,34 @@ func TestContext2Plan_queryList(t *testing.T) { generatedCfgs := make([]string, 0) for _, change := range changes.Queries { actualResources = append(actualResources, change.Addr.String()) - generatedCfgs = append(generatedCfgs, strings.ReplaceAll(change.GeneratedConfig, "\n", "\n")) schema := sch.ListResourceTypes[change.Addr.Resource.Resource.Type] cs, err := change.Decode(schema) if err != nil { t.Fatalf("failed to decode change: %s", err) } - // Verify instance types - actualTypes := make([]string, 0) obj := cs.Results.Value.GetAttr("data") if obj.IsNull() { t.Fatalf("Expected 'data' attribute to be present, but it is null") } obj.ForEachElement(func(key cty.Value, val cty.Value) bool { - if !val.Type().HasAttribute("state") { - t.Fatalf("Expected 'state' attribute to be present, but it is missing") - } - - val = val.GetAttr("state") - if !val.IsNull() { - if val.GetAttr("instance_type").IsNull() { - t.Fatalf("Expected 'instance_type' attribute to be present, but it is missing") + if val.Type().HasAttribute("state") { + val = val.GetAttr("state") + if !val.IsNull() { + if val.GetAttr("instance_type").IsNull() { + t.Fatalf("Expected 'instance_type' attribute to be present, but it is missing") + } } - actualTypes = append(actualTypes, val.GetAttr("instance_type").AsString()) } return false }) - sort.Strings(actualTypes) - actualResources[change.Addr.String()] = actualTypes + var config string + for _, addr := range slices.Sorted(maps.Keys(change.GeneratedConfig)) { + curr := change.GeneratedConfig[addr] + config += curr.String() + } + generatedCfgs = append(generatedCfgs, config) } if diff := cmp.Diff(expectedResources, actualResources); diff != "" { @@ -987,6 +929,7 @@ func getListProviderSchemaResp() *providers.GetProviderSchemaResponse { "instance_type": { Type: cty.String, Computed: true, + Optional: true, }, }, }, @@ -1030,7 +973,6 @@ var ( testResourceCfg = `resource "test_resource" "test_0" { instance_type = "ami-123456" } - import { to = test_resource.test_0 provider = test @@ -1038,12 +980,9 @@ import { id = "i-v1" } } - - resource "test_resource" "test_1" { instance_type = "ami-654321" } - import { to = test_resource.test_1 provider = test @@ -1051,24 +990,21 @@ import { id = "i-v2" } } - - resource "test_resource" "test_2" { instance_type = "ami-789012" } - import { to = test_resource.test_2 provider = test identity = { id = "i-v3" } -}` +} +` testResourceCfg2 = `resource "test_resource" "test2_0" { - instance_type = "ami-123456" + instance_type = null # OPTIONAL string } - import { to = test_resource.test2_0 provider = test @@ -1076,12 +1012,9 @@ import { id = "i-v1" } } - - resource "test_resource" "test2_1" { - instance_type = "ami-654321" + instance_type = null # OPTIONAL string } - import { to = test_resource.test2_1 provider = test @@ -1089,17 +1022,15 @@ import { id = "i-v2" } } - - resource "test_resource" "test2_2" { - instance_type = "ami-789012" + instance_type = null # OPTIONAL string } - import { to = test_resource.test2_2 provider = test identity = { id = "i-v3" } -}` +} +` ) diff --git a/internal/terraform/node_resource_plan_instance_query.go b/internal/terraform/node_resource_plan_instance_query.go index 9d2604b00202..1a4a0e84991d 100644 --- a/internal/terraform/node_resource_plan_instance_query.go +++ b/internal/terraform/node_resource_plan_instance_query.go @@ -92,10 +92,10 @@ func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (di } // If a path is specified, generate the config for the resource - var generated string + var generated map[string]genconfig.QueryResult if n.generateConfigPath != "" { var gDiags tfdiags.Diagnostics - generated, gDiags = n.generateListConfig(resp.Result, providerSchema.ResourceTypes[n.Config.Type]) + generated, gDiags = n.generateListConfig(resp.Result.GetAttr("data"), providerSchema.ResourceTypes[n.Config.Type]) diags = diags.Append(gDiags) if diags.HasErrors() { return diags @@ -106,7 +106,8 @@ func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (di Addr: n.Addr, ProviderAddr: n.ResolvedProvider, Results: plans.QueryResults{ - Value: resp.Result, + Value: resp.Result, + GeneratedConfig: generated, }, } @@ -114,7 +115,7 @@ func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (di return diags } -func (n *NodePlannableResourceInstance) generateListConfig(obj cty.Value, resourceSchema providers.Schema) (generated string, diags tfdiags.Diagnostics) { +func (n *NodePlannableResourceInstance) generateListConfig(data cty.Value, resourceSchema providers.Schema) (generated map[string]genconfig.QueryResult, diags tfdiags.Diagnostics) { providerAddr := addrs.LocalProviderConfig{ LocalName: n.ResolvedProvider.Provider.Type, Alias: n.ResolvedProvider.Alias, @@ -140,7 +141,5 @@ func (n *NodePlannableResourceInstance) generateListConfig(obj cty.Value, resour configschema.FilterDeprecatedBlock, ) identitySchema := resourceSchema.Identity - - data := obj.GetAttr("data") return genconfig.GenerateListResourceContents(n.Addr, stateSchema, identitySchema, providerAddr, data) } From bbe30247261d4a550e4f2ea6c295cd525ecae305 Mon Sep 17 00:00:00 2001 From: Samsondeen Dare Date: Tue, 24 Jun 2025 16:41:23 +0200 Subject: [PATCH 15/15] generalize gen resources --- internal/command/jsonlist/list.go | 4 +- internal/genconfig/generate_config.go | 89 ++++++++++++------- internal/genconfig/generate_config_test.go | 16 ++-- internal/plans/changes.go | 13 ++- internal/plans/changes_src.go | 8 +- internal/terraform/context_plan_query_test.go | 12 ++- .../terraform/node_resource_plan_instance.go | 32 ++++--- .../node_resource_plan_instance_query.go | 40 +-------- 8 files changed, 106 insertions(+), 108 deletions(-) diff --git a/internal/command/jsonlist/list.go b/internal/command/jsonlist/list.go index fc649f8dc11d..38e97362afee 100644 --- a/internal/command/jsonlist/list.go +++ b/internal/command/jsonlist/list.go @@ -82,8 +82,8 @@ func marshalQueryInstance(rc *plans.QueryInstanceSrc, schemas *terraform.Schemas Identity: marshalValues(value.GetAttr("identity")), Resource: marshalValues(value.GetAttr("state")), } - config := query.Results.GeneratedConfig[result.Address] - result.ResourceConfig = string(config.Resource) + config := query.Results.Generated.Results[result.Address] + result.ResourceConfig = string(config.Body) result.ImportConfig = string(config.Import) ret.Results = append(ret.Results, result) diff --git a/internal/genconfig/generate_config.go b/internal/genconfig/generate_config.go index f17592d5f362..b94189623d7e 100644 --- a/internal/genconfig/generate_config.go +++ b/internal/genconfig/generate_config.go @@ -22,22 +22,49 @@ import ( "github.com/hashicorp/terraform/internal/tfdiags" ) -type QueryResult struct { - Resource []byte - Import []byte +type Resource struct { + // HCL Body of the resource, which is the attributes and blocks + // that are part of the resource. + Body []byte + + // Import is the HCL code for the import block. This is only + // generated for list resource results. + Import []byte + Addr addrs.AbsResourceInstance + Results map[string]*Resource } -func (l QueryResult) String() string { +func (r *Resource) String() string { var buf strings.Builder - if len(l.Resource) > 0 { - buf.WriteString(string(l.Resource)) - buf.WriteString("\n") - } - if len(l.Import) > 0 { - buf.WriteString(string(l.Import)) - buf.WriteString("\n") + switch r.Addr.Resource.Resource.Mode { + case addrs.ListResourceMode: + last := len(r.Results) - 1 + // sort the results by their keys so the output is consistent + for idx, key := range slices.Sorted(maps.Keys(r.Results)) { + managed := r.Results[key] + if managed.Body != nil { + buf.WriteString(managed.String()) + buf.WriteString("\n") + } + if managed.Import != nil { + buf.WriteString(string(managed.Import)) + buf.WriteString("\n") + } + if idx != last { + buf.WriteString("\n") + } + } + case addrs.ManagedResourceMode: + buf.WriteString(fmt.Sprintf("resource %q %q {\n", r.Addr.Resource.Resource.Type, r.Addr.Resource.Resource.Name)) + buf.Write(r.Body) + buf.WriteString("}") + default: + panic(fmt.Errorf("unsupported resource mode %s", r.Addr.Resource.Resource.Mode)) } - return buf.String() + + // The output better be valid HCL which can be parsed and formatted. + formatted := hclwrite.Format([]byte(buf.String())) + return string(formatted) } // GenerateResourceContents generates HCL configuration code for the provided @@ -49,7 +76,7 @@ func (l QueryResult) String() string { func GenerateResourceContents(addr addrs.AbsResourceInstance, schema *configschema.Block, pc addrs.LocalProviderConfig, - stateVal cty.Value) (string, tfdiags.Diagnostics) { + stateVal cty.Value) (*Resource, tfdiags.Diagnostics) { var buf strings.Builder var diags tfdiags.Diagnostics @@ -69,19 +96,10 @@ func GenerateResourceContents(addr addrs.AbsResourceInstance, // The output better be valid HCL which can be parsed and formatted. formatted := hclwrite.Format([]byte(buf.String())) - return string(formatted), diags -} - -func WrapResourceContents(addr addrs.AbsResourceInstance, config string) string { - var buf strings.Builder - - buf.WriteString(fmt.Sprintf("resource %q %q {\n", addr.Resource.Resource.Type, addr.Resource.Resource.Name)) - buf.WriteString(config) - buf.WriteString("}") - - // The output better be valid HCL which can be parsed and formatted. - formatted := hclwrite.Format([]byte(buf.String())) - return string(formatted) + return &Resource{ + Body: formatted, + Addr: addr, + }, diags } func GenerateListResourceContents(addr addrs.AbsResourceInstance, @@ -89,11 +107,11 @@ func GenerateListResourceContents(addr addrs.AbsResourceInstance, idSchema *configschema.Object, pc addrs.LocalProviderConfig, stateVal cty.Value, -) (map[string]QueryResult, tfdiags.Diagnostics) { +) (*Resource, tfdiags.Diagnostics) { hclFmt := func(s []byte) []byte { return bytes.TrimSpace(hclwrite.Format(s)) } - ret := make(map[string]QueryResult) + ret := make(map[string]*Resource) var diags tfdiags.Diagnostics if !stateVal.CanIterateElements() { diags = diags.Append( @@ -102,12 +120,11 @@ func GenerateListResourceContents(addr addrs.AbsResourceInstance, Summary: "Invalid resource instance value", Detail: fmt.Sprintf("Resource instance %s has nil or non-iterable value", addr), }) - return ret, diags + return nil, diags } iter := stateVal.ElementIterator() for idx := 0; iter.Next(); idx++ { - ls := QueryResult{} // Generate a unique resource name for each instance in the list. resAddr := addrs.AbsResourceInstance{ Module: addr.Module, @@ -120,7 +137,11 @@ func GenerateListResourceContents(addr addrs.AbsResourceInstance, Key: addr.Resource.Key, }, } + ls := &Resource{Addr: resAddr} + _, val := iter.Element() + // we still need to generate the resource block even if the state is not given, + // so that the import block can reference it. stateVal := cty.NilVal if val.Type().HasAttribute("state") { stateVal = val.GetAttr("state") @@ -130,8 +151,7 @@ func GenerateListResourceContents(addr addrs.AbsResourceInstance, diags = diags.Append(gDiags) continue } - content = WrapResourceContents(resAddr, content) - ls.Resource = hclFmt([]byte(content)) + ls.Body = content.Body idVal := val.GetAttr("identity") importContent, gDiags := generateImportBlock(resAddr, idSchema, pc, idVal) @@ -144,7 +164,10 @@ func GenerateListResourceContents(addr addrs.AbsResourceInstance, ret[resAddr.String()] = ls } - return ret, diags + return &Resource{ + Results: ret, + Addr: addr, + }, diags } func generateImportBlock(addr addrs.AbsResourceInstance, idSchema *configschema.Object, pc addrs.LocalProviderConfig, identity cty.Value) (string, tfdiags.Diagnostics) { diff --git a/internal/genconfig/generate_config_test.go b/internal/genconfig/generate_config_test.go index ad35e3feeec4..a8593d5670b7 100644 --- a/internal/genconfig/generate_config_test.go +++ b/internal/genconfig/generate_config_test.go @@ -827,12 +827,12 @@ resource "tfcoremock_sensitive_values" "values" { if err != nil { t.Fatalf("schema failed InternalValidate: %s", err) } - contents, diags := GenerateResourceContents(tc.addr, tc.schema, tc.provider, tc.value) + content, diags := GenerateResourceContents(tc.addr, tc.schema, tc.provider, tc.value) if len(diags) > 0 { t.Errorf("expected no diagnostics but found %s", diags) } - got := WrapResourceContents(tc.addr, contents) + got := content.String() want := strings.TrimSpace(tc.expected) if diff := cmp.Diff(got, want); len(diff) > 0 { t.Errorf("got:\n%s\nwant:\n%s\ndiff:\n%s", got, want, diff) @@ -941,7 +941,7 @@ func TestGenerateResourceAndIDContents(t *testing.T) { addr := addrs.AbsResource{ Module: addrs.RootModuleInstance, Resource: addrs.Resource{ - Mode: addrs.ManagedResourceMode, + Mode: addrs.ListResourceMode, Type: "aws_instance", Name: "example", }, @@ -981,6 +981,7 @@ import { id = "i-abcdef" } } + resource "aws_instance" "example_1" { name = "instance-2" tags = { @@ -1013,12 +1014,13 @@ import { normalizedExpected := normalizeString(expectedContent) var merged string - for _, addr := range slices.Sorted(maps.Keys(content)) { - merged += content[addr].String() + res := content.Results + for _, addr := range slices.Sorted(maps.Keys(res)) { + merged += res[addr].String() } - normalizedActual := normalizeString(merged) + normalizedActual := normalizeString(content.String()) if diff := cmp.Diff(normalizedExpected, normalizedActual); diff != "" { - t.Errorf("Generated content doesn't match expected (-want +got):\n%s", diff) + t.Errorf("Generated content doesn't match expected. want:\n%s\ngot:\n%s\ndiff:\n%s", normalizedExpected, normalizedActual, diff) } } diff --git a/internal/plans/changes.go b/internal/plans/changes.go index cc5a3a8bc0bd..5ed41122ace0 100644 --- a/internal/plans/changes.go +++ b/internal/plans/changes.go @@ -252,9 +252,8 @@ type QueryInstance struct { } type QueryResults struct { - Value cty.Value - Results cty.Value - GeneratedConfig map[string]genconfig.QueryResult + Value cty.Value + Generated *genconfig.Resource } func (qi *QueryInstance) DeepCopy() *QueryInstance { @@ -273,10 +272,10 @@ func (rc *QueryInstance) Encode(schema providers.Schema) (*QueryInstanceSrc, err } return &QueryInstanceSrc{ - Addr: rc.Addr, - Results: results, - ProviderAddr: rc.ProviderAddr, - GeneratedConfig: rc.Results.GeneratedConfig, + Addr: rc.Addr, + Results: results, + ProviderAddr: rc.ProviderAddr, + Generated: rc.Results.Generated, }, nil } diff --git a/internal/plans/changes_src.go b/internal/plans/changes_src.go index 971e661d5e02..1c62e6f30ab0 100644 --- a/internal/plans/changes_src.go +++ b/internal/plans/changes_src.go @@ -181,8 +181,8 @@ type QueryInstanceSrc struct { ProviderAddr addrs.AbsProviderConfig - Results DynamicValue - GeneratedConfig map[string]genconfig.QueryResult + Results DynamicValue + Generated *genconfig.Resource } func (qis *QueryInstanceSrc) Decode(schema providers.Schema) (*QueryInstance, error) { @@ -194,8 +194,8 @@ func (qis *QueryInstanceSrc) Decode(schema providers.Schema) (*QueryInstance, er return &QueryInstance{ Addr: qis.Addr, Results: QueryResults{ - Value: query, - GeneratedConfig: qis.GeneratedConfig, + Value: query, + Generated: qis.Generated, }, ProviderAddr: qis.ProviderAddr, }, nil diff --git a/internal/terraform/context_plan_query_test.go b/internal/terraform/context_plan_query_test.go index b2f3b9b9703d..ecbcc745acb2 100644 --- a/internal/terraform/context_plan_query_test.go +++ b/internal/terraform/context_plan_query_test.go @@ -6,7 +6,6 @@ package terraform import ( "fmt" "maps" - "slices" "sort" "strings" "testing" @@ -136,12 +135,7 @@ func TestContext2Plan_queryList(t *testing.T) { return false }) - var config string - for _, addr := range slices.Sorted(maps.Keys(change.GeneratedConfig)) { - curr := change.GeneratedConfig[addr] - config += curr.String() - } - generatedCfgs = append(generatedCfgs, config) + generatedCfgs = append(generatedCfgs, change.Generated.String()) } if diff := cmp.Diff(expectedResources, actualResources); diff != "" { @@ -980,6 +974,7 @@ import { id = "i-v1" } } + resource "test_resource" "test_1" { instance_type = "ami-654321" } @@ -990,6 +985,7 @@ import { id = "i-v2" } } + resource "test_resource" "test_2" { instance_type = "ami-789012" } @@ -1012,6 +1008,7 @@ import { id = "i-v1" } } + resource "test_resource" "test2_1" { instance_type = null # OPTIONAL string } @@ -1022,6 +1019,7 @@ import { id = "i-v2" } } + resource "test_resource" "test2_2" { instance_type = null # OPTIONAL string } diff --git a/internal/terraform/node_resource_plan_instance.go b/internal/terraform/node_resource_plan_instance.go index 2f38df5fa133..930d87ae39f9 100644 --- a/internal/terraform/node_resource_plan_instance.go +++ b/internal/terraform/node_resource_plan_instance.go @@ -839,16 +839,15 @@ func (n *NodePlannableResourceInstance) importState(ctx EvalContext, addr addrs. } // Generate the HCL string first, then parse the HCL body from it. - // First we generate the contents of the resource block for use within - // the planning node. Then we wrap it in an enclosing resource block to - // pass into the plan for rendering. - generatedHCLAttributes, generatedDiags := n.generateHCLStringAttributes(n.Addr, instanceRefreshState, schema.Body) + generatedResource, generatedDiags := n.generateHCLResourceDef(n.Addr, instanceRefreshState.Value, schema) diags = diags.Append(generatedDiags) - n.generatedConfigHCL = genconfig.WrapResourceContents(n.Addr, generatedHCLAttributes) + // This wraps the content of the resource block in an enclosing resource block + // to pass into the plan for rendering. + n.generatedConfigHCL = generatedResource.String() - // parse the "file" as HCL to get the hcl.Body - synthHCLFile, hclDiags := hclsyntax.ParseConfig([]byte(generatedHCLAttributes), filepath.Base(n.generateConfigPath), hcl.Pos{Byte: 0, Line: 1, Column: 1}) + // parse the "file" body as HCL to get the hcl.Body + synthHCLFile, hclDiags := hclsyntax.ParseConfig(generatedResource.Body, filepath.Base(n.generateConfigPath), hcl.Pos{Byte: 0, Line: 1, Column: 1}) diags = diags.Append(hclDiags) if hclDiags.HasErrors() { return instanceRefreshState, nil, diags @@ -883,10 +882,11 @@ func (n *NodePlannableResourceInstance) importState(ctx EvalContext, addr addrs. return instanceRefreshState, deferred, diags } -// generateHCLStringAttributes produces a string in HCL format for the given -// resource state and schema without the surrounding block. -func (n *NodePlannableResourceInstance) generateHCLStringAttributes(addr addrs.AbsResourceInstance, state *states.ResourceInstanceObject, schema *configschema.Block) (string, tfdiags.Diagnostics) { - filteredSchema := schema.Filter( +// generateHCLResourceDef generates the HCL definition for the resource +// instance, including the surrounding block. This is used to generate the +// configuration for the resource instance when importing or generating +func (n *NodePlannableResourceInstance) generateHCLResourceDef(addr addrs.AbsResourceInstance, state cty.Value, schema providers.Schema) (*genconfig.Resource, tfdiags.Diagnostics) { + filteredSchema := schema.Body.Filter( configschema.FilterOr( configschema.FilterReadOnlyAttribute, configschema.FilterDeprecatedAttribute, @@ -911,7 +911,15 @@ func (n *NodePlannableResourceInstance) generateHCLStringAttributes(addr addrs.A Alias: n.ResolvedProvider.Alias, } - return genconfig.GenerateResourceContents(addr, filteredSchema, providerAddr, state.Value) + switch addr.Resource.Resource.Mode { + case addrs.ManagedResourceMode: + return genconfig.GenerateResourceContents(addr, filteredSchema, providerAddr, state) + case addrs.ListResourceMode: + identitySchema := schema.Identity + return genconfig.GenerateListResourceContents(addr, filteredSchema, identitySchema, providerAddr, state) + default: + panic(fmt.Sprintf("unexpected resource mode %s for resource %s", addr.Resource.Resource.Mode, addr)) + } } // mergeDeps returns the union of 2 sets of dependencies diff --git a/internal/terraform/node_resource_plan_instance_query.go b/internal/terraform/node_resource_plan_instance_query.go index 1a4a0e84991d..39c6786c2c23 100644 --- a/internal/terraform/node_resource_plan_instance_query.go +++ b/internal/terraform/node_resource_plan_instance_query.go @@ -7,13 +7,10 @@ import ( "fmt" "log" - "github.com/hashicorp/terraform/internal/addrs" - "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/genconfig" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/tfdiags" - "github.com/zclconf/go-cty/cty" ) func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (diags tfdiags.Diagnostics) { @@ -92,10 +89,10 @@ func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (di } // If a path is specified, generate the config for the resource - var generated map[string]genconfig.QueryResult + var generated *genconfig.Resource if n.generateConfigPath != "" { var gDiags tfdiags.Diagnostics - generated, gDiags = n.generateListConfig(resp.Result.GetAttr("data"), providerSchema.ResourceTypes[n.Config.Type]) + generated, gDiags = n.generateHCLResourceDef(addr, resp.Result.GetAttr("data"), providerSchema.ResourceTypes[n.Config.Type]) diags = diags.Append(gDiags) if diags.HasErrors() { return diags @@ -106,40 +103,11 @@ func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (di Addr: n.Addr, ProviderAddr: n.ResolvedProvider, Results: plans.QueryResults{ - Value: resp.Result, - GeneratedConfig: generated, + Value: resp.Result, + Generated: generated, }, } ctx.Changes().AppendQueryInstance(query) return diags } - -func (n *NodePlannableResourceInstance) generateListConfig(data cty.Value, resourceSchema providers.Schema) (generated map[string]genconfig.QueryResult, diags tfdiags.Diagnostics) { - providerAddr := addrs.LocalProviderConfig{ - LocalName: n.ResolvedProvider.Provider.Type, - Alias: n.ResolvedProvider.Alias, - } - - stateSchema := resourceSchema.Body.Filter( - configschema.FilterOr( - configschema.FilterReadOnlyAttribute, - configschema.FilterDeprecatedAttribute, - - // The legacy SDK adds an Optional+Computed "id" attribute to the - // resource schema even if not defined in provider code. - // During validation, however, the presence of an extraneous "id" - // attribute in config will cause an error. - // Remove this attribute so we do not generate an "id" attribute - // where there is a risk that it is not in the real resource schema. - // - // TRADEOFF: Resources in which there actually is an - // Optional+Computed "id" attribute in the schema will have that - // attribute missing from generated config. - configschema.FilterHelperSchemaIdAttribute, - ), - configschema.FilterDeprecatedBlock, - ) - identitySchema := resourceSchema.Identity - return genconfig.GenerateListResourceContents(n.Addr, stateSchema, identitySchema, providerAddr, data) -}