Skip to content

Commit 2274026

Browse files
authored
query: add -query flag to validate command (#37671)
1 parent 8986651 commit 2274026

File tree

11 files changed

+540
-273
lines changed

11 files changed

+540
-273
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
kind: BUG FIXES
2+
body: 'query: generate unique resource identifiers for results of expanded list resources'
3+
time: 2025-09-26T11:33:18.241184+02:00
4+
custom:
5+
Issue: "37681"
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
kind: ENHANCEMENTS
2+
body: "query: support offline validation of query files via -query flag in the validate command"
3+
time: 2025-09-25T15:12:37.198573+02:00
4+
custom:
5+
Issue: "37671"

internal/command/arguments/validate.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ type Validate struct {
2424

2525
// ViewType specifies which output format to use: human, JSON, or "raw".
2626
ViewType ViewType
27+
28+
// Query indicates that Terraform should also validate .tfquery files.
29+
Query bool
2730
}
2831

2932
// ParseValidate processes CLI arguments, returning a Validate value and errors.
@@ -40,6 +43,7 @@ func ParseValidate(args []string) (*Validate, tfdiags.Diagnostics) {
4043
cmdFlags.BoolVar(&jsonOutput, "json", false, "json")
4144
cmdFlags.StringVar(&validate.TestDirectory, "test-directory", "tests", "test-directory")
4245
cmdFlags.BoolVar(&validate.NoTests, "no-tests", false, "no-tests")
46+
cmdFlags.BoolVar(&validate.Query, "query", false, "query")
4347

4448
if err := cmdFlags.Parse(args); err != nil {
4549
diags = diags.Append(tfdiags.Sourceless(
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
terraform {
2+
required_providers {
3+
test = {
4+
source = "hashicorp/test"
5+
}
6+
}
7+
}
8+
9+
provider "test" {}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
variable "input" {
2+
type = string
3+
default = "foo"
4+
}
5+
6+
list "test_instance" "test" {
7+
provider = test
8+
9+
config {
10+
ami = var.input
11+
}
12+
}
13+
14+
list "test_instance" "test2" {
15+
provider = test
16+
17+
config {
18+
// this traversal is invalid for a list resource
19+
ami = list.test_instance.test.state.instance_type
20+
}
21+
}

internal/command/validate.go

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import (
1919
// ValidateCommand is a Command implementation that validates the terraform files
2020
type ValidateCommand struct {
2121
Meta
22+
23+
ParsedArgs *arguments.Validate
2224
}
2325

2426
func (c *ValidateCommand) Run(rawArgs []string) int {
@@ -34,6 +36,7 @@ func (c *ValidateCommand) Run(rawArgs []string) int {
3436
return 1
3537
}
3638

39+
c.ParsedArgs = args
3740
view := views.NewValidate(args.ViewType, c.View)
3841

3942
// After this point, we must only produce JSON output if JSON mode is
@@ -54,7 +57,7 @@ func (c *ValidateCommand) Run(rawArgs []string) int {
5457
return view.Results(diags)
5558
}
5659

57-
validateDiags := c.validate(dir, args.TestDirectory, args.NoTests)
60+
validateDiags := c.validate(dir)
5861
diags = diags.Append(validateDiags)
5962

6063
// Validating with dev overrides in effect means that the result might
@@ -66,47 +69,54 @@ func (c *ValidateCommand) Run(rawArgs []string) int {
6669
return view.Results(diags)
6770
}
6871

69-
func (c *ValidateCommand) validate(dir, testDir string, noTests bool) tfdiags.Diagnostics {
72+
func (c *ValidateCommand) validate(dir string) tfdiags.Diagnostics {
7073
var diags tfdiags.Diagnostics
7174
var cfg *configs.Config
7275

73-
if noTests {
76+
// If the query flag is set, include query files in the validation.
77+
c.includeQueryFiles = c.ParsedArgs.Query
78+
79+
if c.ParsedArgs.NoTests {
7480
cfg, diags = c.loadConfig(dir)
7581
} else {
76-
cfg, diags = c.loadConfigWithTests(dir, testDir)
82+
cfg, diags = c.loadConfigWithTests(dir, c.ParsedArgs.TestDirectory)
7783
}
7884
if diags.HasErrors() {
7985
return diags
8086
}
8187

82-
validate := func(cfg *configs.Config) tfdiags.Diagnostics {
83-
var diags tfdiags.Diagnostics
88+
diags = diags.Append(c.validateConfig(cfg))
8489

85-
opts, err := c.contextOpts()
86-
if err != nil {
87-
diags = diags.Append(err)
88-
return diags
89-
}
90+
// Unless excluded, we'll also do a quick validation of the Terraform test files. These live
91+
// outside the Terraform graph so we have to do this separately.
92+
if !c.ParsedArgs.NoTests {
93+
diags = diags.Append(c.validateTestFiles(cfg))
94+
}
9095

91-
tfCtx, ctxDiags := terraform.NewContext(opts)
92-
diags = diags.Append(ctxDiags)
93-
if ctxDiags.HasErrors() {
94-
return diags
95-
}
96+
return diags
97+
}
9698

97-
return diags.Append(tfCtx.Validate(cfg, nil))
98-
}
99+
func (c *ValidateCommand) validateConfig(cfg *configs.Config) tfdiags.Diagnostics {
100+
var diags tfdiags.Diagnostics
99101

100-
diags = diags.Append(validate(cfg))
102+
opts, err := c.contextOpts()
103+
if err != nil {
104+
diags = diags.Append(err)
105+
return diags
106+
}
101107

102-
if noTests {
108+
tfCtx, ctxDiags := terraform.NewContext(opts)
109+
diags = diags.Append(ctxDiags)
110+
if ctxDiags.HasErrors() {
103111
return diags
104112
}
105113

106-
validatedModules := make(map[string]bool)
114+
return diags.Append(tfCtx.Validate(cfg, nil))
115+
}
107116

108-
// We'll also do a quick validation of the Terraform test files. These live
109-
// outside the Terraform graph so we have to do this separately.
117+
func (c *ValidateCommand) validateTestFiles(cfg *configs.Config) tfdiags.Diagnostics {
118+
diags := tfdiags.Diagnostics{}
119+
validatedModules := make(map[string]bool)
110120
for _, file := range cfg.Module.Tests {
111121

112122
// The file validation only returns warnings so we'll just add them
@@ -131,7 +141,7 @@ func (c *ValidateCommand) validate(dir, testDir string, noTests bool) tfdiags.Di
131141
// not validate the same thing multiple times.
132142

133143
validatedModules[run.Module.Source.String()] = true
134-
diags = diags.Append(validate(run.ConfigUnderTest))
144+
diags = diags.Append(c.validateConfig(run.ConfigUnderTest))
135145
}
136146

137147
}
@@ -188,6 +198,8 @@ Options:
188198
-no-tests If specified, Terraform will not validate test files.
189199
190200
-test-directory=path Set the Terraform test directory, defaults to "tests".
201+
202+
-query If specified, the command will also validate .tfquery.hcl files.
191203
`
192204
return strings.TrimSpace(helpText)
193205
}

internal/command/validate_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,3 +449,85 @@ func TestValidate_json(t *testing.T) {
449449
})
450450
}
451451
}
452+
453+
func TestValidateWithInvalidListResource(t *testing.T) {
454+
td := t.TempDir()
455+
cases := []struct {
456+
name string
457+
path string
458+
wantError string
459+
args []string
460+
code int
461+
}{
462+
{
463+
name: "invalid-traversal with validate -query command",
464+
path: "query/invalid-traversal",
465+
wantError: `
466+
Error: Invalid list resource traversal
467+
468+
on main.tfquery.hcl line 19, in list "test_instance" "test2":
469+
19: ami = list.test_instance.test.state.instance_type
470+
471+
The first step in the traversal for a list resource must be an attribute
472+
"data".
473+
`,
474+
args: []string{"-query"},
475+
code: 1,
476+
},
477+
{
478+
name: "invalid-traversal with no -query",
479+
path: "query/invalid-traversal",
480+
},
481+
}
482+
for _, tc := range cases {
483+
t.Run(tc.name, func(t *testing.T) {
484+
testCopyDir(t, testFixturePath(tc.path), td)
485+
t.Chdir(td)
486+
487+
streams, done := terminal.StreamsForTesting(t)
488+
view := views.NewView(streams)
489+
ui := new(cli.MockUi)
490+
491+
provider := queryFixtureProvider()
492+
providerSource, close := newMockProviderSource(t, map[string][]string{
493+
"test": {"1.0.0"},
494+
})
495+
defer close()
496+
497+
meta := Meta{
498+
testingOverrides: metaOverridesForProvider(provider),
499+
Ui: ui,
500+
View: view,
501+
Streams: streams,
502+
ProviderSource: providerSource,
503+
}
504+
505+
init := &InitCommand{
506+
Meta: meta,
507+
}
508+
509+
if code := init.Run(nil); code != 0 {
510+
t.Fatalf("expected status code 0 but got %d: %s", code, ui.ErrorWriter)
511+
}
512+
513+
c := &ValidateCommand{
514+
Meta: meta,
515+
}
516+
517+
var args []string
518+
args = append(args, "-no-color")
519+
args = append(args, tc.args...)
520+
521+
code := c.Run(args)
522+
output := done(t)
523+
524+
if code != tc.code {
525+
t.Fatalf("Expected status code %d but got %d: %s", tc.code, code, output.Stderr())
526+
}
527+
528+
if diff := cmp.Diff(tc.wantError, output.Stderr()); diff != "" {
529+
t.Fatalf("Expected error string %q but got %q\n\ndiff: \n%s", tc.wantError, output.Stderr(), diff)
530+
}
531+
})
532+
}
533+
}

internal/genconfig/generate_config.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ type ResourceListElement struct {
138138
Config cty.Value
139139

140140
Identity cty.Value
141+
142+
// ExpansionEnum is a unique enumeration of the list resource address relative to its expanded siblings.
143+
ExpansionEnum int
141144
}
142145

143146
func GenerateListResourceContents(addr addrs.AbsResourceInstance,
@@ -158,12 +161,18 @@ func GenerateListResourceContents(addr addrs.AbsResourceInstance,
158161
Resource: addrs.Resource{
159162
Mode: addrs.ManagedResourceMode,
160163
Type: addr.Resource.Resource.Type,
161-
Name: fmt.Sprintf("%s_%d", addr.Resource.Resource.Name, idx),
162164
},
163-
Key: addr.Resource.Key,
164165
},
165166
}
166167

168+
// If the list resource instance is keyed, the expansion counter is included in the address
169+
// to ensure uniqueness across the entire configuration.
170+
if addr.Resource.Key == addrs.NoKey {
171+
resAddr.Resource.Resource.Name = fmt.Sprintf("%s_%d", addr.Resource.Resource.Name, idx)
172+
} else {
173+
resAddr.Resource.Resource.Name = fmt.Sprintf("%s_%d_%d", addr.Resource.Resource.Name, res.ExpansionEnum, idx)
174+
}
175+
167176
content, gDiags := GenerateResourceContents(resAddr, schema, pc, res.Config, true)
168177
if gDiags.HasErrors() {
169178
diags = diags.Append(gDiags)

internal/instances/expander.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package instances
55

66
import (
77
"fmt"
8+
"slices"
89
"sort"
910
"sync"
1011

@@ -338,6 +339,16 @@ func (e *Expander) ExpandResource(resourceAddr addrs.AbsResource) []addrs.AbsRes
338339
return ret
339340
}
340341

342+
// ResourceExpansionEnum returns the expansion enum for the given resource instance address
343+
// within the sorted list of resource instances belonging to the same resource config within
344+
// the same module instance.
345+
func (e *Expander) ResourceExpansionEnum(resourceAddr addrs.AbsResourceInstance) int {
346+
res := e.ExpandResource(resourceAddr.ContainingResource())
347+
return slices.IndexFunc(res, func(addr addrs.AbsResourceInstance) bool {
348+
return addr.Equal(resourceAddr)
349+
})
350+
}
351+
341352
// UnknownResourceInstances finds a set of patterns that collectively cover
342353
// all of the possible resource instance addresses that could appear for the
343354
// given static resource once all of the intermediate module expansions are

0 commit comments

Comments
 (0)