Skip to content

Commit affe2c3

Browse files
addrs: Expose the registry address parser's error messages
Previously we ended up losing all of the error message detail produced by the registry address parser, because we treated any registry address failure as cause to parse the address as a go-getter-style remote address instead. That led to terrible feedback in the situation where the user _was_ trying to write a module address but it was invalid in some way. Although we can't really tighten this up in the default case due to our compatibility promises, it's never been valid to use the "version" argument with anything other than a registry address and so as a compromise here we'll use the presence of "version" as a heuristic for user intent to parse the source address as a registry address, and thus we can return a registry-address-specific error message in that case and thus give more direct feedback about what was wrong. This unfortunately won't help someone trying to install from the registry _without_ a version constraint, but I didn't want to let perfect be the enemy of the good here, particularly since we recommend using version constraints with registry modules anyway; indeed, that's one of the main benefits of using a registry rather than a remote source directly.
1 parent 8f923ce commit affe2c3

8 files changed

Lines changed: 180 additions & 45 deletions

File tree

internal/addrs/module_source.go

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,18 +46,36 @@ var moduleSourceLocalPrefixes = []string{
4646
"..\\",
4747
}
4848

49+
// ParseModuleSource parses a module source address as given in the "source"
50+
// argument inside a "module" block in the configuration.
51+
//
52+
// For historical reasons this syntax is a bit overloaded, supporting three
53+
// different address types:
54+
// - Local paths starting with either ./ or ../, which are special because
55+
// Terraform considers them to belong to the same "package" as the caller.
56+
// - Module registry addresses, given as either NAMESPACE/NAME/SYSTEM or
57+
// HOST/NAMESPACE/NAME/SYSTEM, in which case the remote registry serves
58+
// as an indirection over the third address type that follows.
59+
// - Various URL-like and other heuristically-recognized strings which
60+
// we currently delegate to the external library go-getter.
61+
//
62+
// There is some ambiguity between the module registry addresses and go-getter's
63+
// very liberal heuristics and so this particular function will typically treat
64+
// an invalid registry address as some other sort of remote source address
65+
// rather than returning an error. If you know that you're expecting a
66+
// registry address in particular, use ParseModuleSourceRegistry instead, which
67+
// can therefore expose more detailed error messages about registry address
68+
// parsing in particular.
4969
func ParseModuleSource(raw string) (ModuleSource, error) {
50-
for _, prefix := range moduleSourceLocalPrefixes {
51-
if strings.HasPrefix(raw, prefix) {
52-
localAddr, err := parseModuleSourceLocal(raw)
53-
if err != nil {
54-
// This is to make sure we really return a nil ModuleSource in
55-
// this case, rather than an interface containing the zero
56-
// value of ModuleSourceLocal.
57-
return nil, err
58-
}
59-
return localAddr, nil
70+
if isModuleSourceLocal(raw) {
71+
localAddr, err := parseModuleSourceLocal(raw)
72+
if err != nil {
73+
// This is to make sure we really return a nil ModuleSource in
74+
// this case, rather than an interface containing the zero
75+
// value of ModuleSourceLocal.
76+
return nil, err
6077
}
78+
return localAddr, nil
6179
}
6280

6381
// For historical reasons, whether an address is a registry
@@ -71,7 +89,7 @@ func ParseModuleSource(raw string) (ModuleSource, error) {
7189
// the registry source parse error gets returned to the caller,
7290
// which is annoying but has been true for many releases
7391
// without it posing a serious problem in practice.)
74-
if ret, err := parseModuleSourceRegistry(raw); err == nil {
92+
if ret, err := ParseModuleSourceRegistry(raw); err == nil {
7593
return ret, nil
7694
}
7795

@@ -150,6 +168,15 @@ func parseModuleSourceLocal(raw string) (ModuleSourceLocal, error) {
150168
return ModuleSourceLocal(clean), nil
151169
}
152170

171+
func isModuleSourceLocal(raw string) bool {
172+
for _, prefix := range moduleSourceLocalPrefixes {
173+
if strings.HasPrefix(raw, prefix) {
174+
return true
175+
}
176+
}
177+
return false
178+
}
179+
153180
func (s ModuleSourceLocal) moduleSource() {}
154181

155182
func (s ModuleSourceLocal) String() string {
@@ -195,6 +222,30 @@ const DefaultModuleRegistryHost = svchost.Hostname("registry.terraform.io")
195222
var moduleRegistryNamePattern = regexp.MustCompile("^[0-9A-Za-z](?:[0-9A-Za-z-_]{0,62}[0-9A-Za-z])?$")
196223
var moduleRegistryTargetSystemPattern = regexp.MustCompile("^[0-9a-z]{1,64}$")
197224

225+
// ParseModuleSourceRegistry is a variant of ParseModuleSource which only
226+
// accepts module registry addresses, and will reject any other address type.
227+
//
228+
// Use this instead of ParseModuleSource if you know from some other surrounding
229+
// context that an address is intended to be a registry address rather than
230+
// some other address type, which will then allow for better error reporting
231+
// due to the additional information about user intent.
232+
func ParseModuleSourceRegistry(raw string) (ModuleSource, error) {
233+
// Before we delegate to the "real" function we'll just make sure this
234+
// doesn't look like a local source address, so we can return a better
235+
// error message for that situation.
236+
if isModuleSourceLocal(raw) {
237+
return ModuleSourceRegistry{}, fmt.Errorf("can't use local directory %q as a module registry address", raw)
238+
}
239+
240+
ret, err := parseModuleSourceRegistry(raw)
241+
if err != nil {
242+
// This is to make sure we return a nil ModuleSource, rather than
243+
// a non-nil ModuleSource containing a zero-value ModuleSourceRegistry.
244+
return nil, err
245+
}
246+
return ret, nil
247+
}
248+
198249
func parseModuleSourceRegistry(raw string) (ModuleSourceRegistry, error) {
199250
var err error
200251

@@ -298,11 +349,10 @@ func parseModuleRegistryTargetSystem(given string) (string, error) {
298349
// Similar to the names in provider source addresses, we defined these
299350
// to be compatible with what filesystems and typical remote systems
300351
// like GitHub allow in names. Unfortunately we didn't end up defining
301-
// these exactly equivalently: provider names can only use dashes as
302-
// punctuation, whereas module names can use underscores. So here we're
303-
// using some regular expressions from the original module source
304-
// implementation, rather than using the IDNA rules as we do in
305-
// ParseProviderPart.
352+
// these exactly equivalently: provider names can't use dashes or
353+
// underscores. So here we're using some regular expressions from the
354+
// original module source implementation, rather than using the IDNA rules
355+
// as we do in ParseProviderPart.
306356

307357
if !moduleRegistryTargetSystemPattern.MatchString(given) {
308358
return "", fmt.Errorf("must be between one and 64 ASCII letters or digits")

internal/addrs/module_source_test.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -488,10 +488,14 @@ func TestParseModuleSourceRegistry(t *testing.T) {
488488
input: `foo/var/baz/qux`,
489489
wantErr: `invalid module registry hostname: must contain at least one dot`,
490490
},
491-
"invalid target system": {
491+
"invalid target system characters": {
492492
input: `foo/var/no-no-no`,
493493
wantErr: `invalid target system "no-no-no": must be between one and 64 ASCII letters or digits`,
494494
},
495+
"invalid target system length": {
496+
input: `foo/var/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaah`,
497+
wantErr: `invalid target system "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaah": must be between one and 64 ASCII letters or digits`,
498+
},
495499
"invalid namespace": {
496500
input: `boop!/var/baz`,
497501
wantErr: `invalid namespace "boop!": must be between one and 64 characters, including ASCII letters, digits, dashes, and underscores, where dashes and underscores may not be the prefix or suffix`,
@@ -518,11 +522,23 @@ func TestParseModuleSourceRegistry(t *testing.T) {
518522
input: `bitbucket.org/HashiCorp/Consul/aws`,
519523
wantErr: `can't use "bitbucket.org" as a module registry host, because it's reserved for installing directly from version control repositories`,
520524
},
525+
"local path from current dir": {
526+
// Can't use a local path when we're specifically trying to parse
527+
// a _registry_ source address.
528+
input: `./boop`,
529+
wantErr: `can't use local directory "./boop" as a module registry address`,
530+
},
531+
"local path from parent dir": {
532+
// Can't use a local path when we're specifically trying to parse
533+
// a _registry_ source address.
534+
input: `../boop`,
535+
wantErr: `can't use local directory "../boop" as a module registry address`,
536+
},
521537
}
522538

523539
for name, test := range tests {
524540
t.Run(name, func(t *testing.T) {
525-
addr, err := parseModuleSourceRegistry(test.input)
541+
addrI, err := ParseModuleSourceRegistry(test.input)
526542

527543
if test.wantErr != "" {
528544
switch {
@@ -538,6 +554,11 @@ func TestParseModuleSourceRegistry(t *testing.T) {
538554
t.Fatalf("unexpected error: %s", err.Error())
539555
}
540556

557+
addr, ok := addrI.(ModuleSourceRegistry)
558+
if !ok {
559+
t.Fatalf("wrong address type %T; want %T", addrI, addr)
560+
}
561+
541562
if got, want := addr.String(), test.wantString; got != want {
542563
t.Errorf("wrong String() result\ngot: %s\nwant: %s", got, want)
543564
}

internal/configs/module_call.go

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,35 @@ func decodeModuleBlock(block *hcl.Block, override bool) (*ModuleCall, hcl.Diagno
5959
})
6060
}
6161

62+
haveVersionArg := false
63+
if attr, exists := content.Attributes["version"]; exists {
64+
var versionDiags hcl.Diagnostics
65+
mc.Version, versionDiags = decodeVersionConstraint(attr)
66+
diags = append(diags, versionDiags...)
67+
haveVersionArg = true
68+
}
69+
6270
if attr, exists := content.Attributes["source"]; exists {
6371
mc.SourceSet = true
6472
mc.SourceAddrRange = attr.Expr.Range()
6573
valDiags := gohcl.DecodeExpression(attr.Expr, nil, &mc.SourceAddrRaw)
6674
diags = append(diags, valDiags...)
6775
if !valDiags.HasErrors() {
68-
addr, err := addrs.ParseModuleSource(mc.SourceAddrRaw)
76+
var addr addrs.ModuleSource
77+
var err error
78+
if haveVersionArg {
79+
addr, err = addrs.ParseModuleSourceRegistry(mc.SourceAddrRaw)
80+
} else {
81+
addr, err = addrs.ParseModuleSource(mc.SourceAddrRaw)
82+
}
6983
mc.SourceAddr = addr
7084
if err != nil {
85+
// NOTE: We leave mc.SourceAddr as nil for any situation where the
86+
// source attribute is invalid, so any code which tries to carefully
87+
// use the partial result of a failed config decode must be
88+
// resilient to that.
89+
mc.SourceAddr = nil
90+
7191
// NOTE: In practice it's actually very unlikely to end up here,
7292
// because our source address parser can turn just about any string
7393
// into some sort of remote package address, and so for most errors
@@ -87,25 +107,27 @@ func decodeModuleBlock(block *hcl.Block, override bool) (*ModuleCall, hcl.Diagno
87107
Subject: mc.SourceAddrRange.Ptr(),
88108
})
89109
default:
90-
diags = append(diags, &hcl.Diagnostic{
91-
Severity: hcl.DiagError,
92-
Summary: "Invalid module source address",
93-
Detail: fmt.Sprintf("Failed to parse module source address: %s.", err),
94-
Subject: mc.SourceAddrRange.Ptr(),
95-
})
110+
if haveVersionArg {
111+
// In this case we'll include some extra context that
112+
// we assumed a registry source address due to the
113+
// version argument.
114+
diags = append(diags, &hcl.Diagnostic{
115+
Severity: hcl.DiagError,
116+
Summary: "Invalid registry module source address",
117+
Detail: fmt.Sprintf("Failed to parse module registry address: %s.\n\nTerraform assumed that you intended a module registry source address because you also set the argument \"version\", which applies only to registry modules.", err),
118+
Subject: mc.SourceAddrRange.Ptr(),
119+
})
120+
} else {
121+
diags = append(diags, &hcl.Diagnostic{
122+
Severity: hcl.DiagError,
123+
Summary: "Invalid module source address",
124+
Detail: fmt.Sprintf("Failed to parse module source address: %s.", err),
125+
Subject: mc.SourceAddrRange.Ptr(),
126+
})
127+
}
96128
}
97129
}
98130
}
99-
// NOTE: We leave mc.SourceAddr as nil for any situation where the
100-
// source attribute is invalid, so any code which tries to carefully
101-
// use the partial result of a failed config decode must be
102-
// resilient to that.
103-
}
104-
105-
if attr, exists := content.Attributes["version"]; exists {
106-
var versionDiags hcl.Diagnostics
107-
mc.Version, versionDiags = decodeVersionConstraint(attr)
108-
diags = append(diags, versionDiags...)
109131
}
110132

111133
if attr, exists := content.Attributes["count"]; exists {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
2+
module "test" {
3+
source = "---.com/HashiCorp/Consul/aws" # ERROR: Invalid registry module source address
4+
version = "1.0.0" # Makes Terraform assume "source" is a module address
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
2+
module "test" {
3+
source = "../boop" # ERROR: Invalid registry module source address
4+
version = "1.0.0" # Makes Terraform assume "source" is a module address
5+
}

internal/earlyconfig/config_build.go

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ func buildChildModules(parent *Config, walker ModuleWalker) (map[string]*Config,
4343
path[len(path)-1] = call.Name
4444

4545
var vc version.Constraints
46+
haveVersionArg := false
4647
if strings.TrimSpace(call.Version) != "" {
48+
haveVersionArg = true
49+
4750
var err error
4851
vc, err = version.NewConstraint(call.Version)
4952
if err != nil {
@@ -56,13 +59,27 @@ func buildChildModules(parent *Config, walker ModuleWalker) (map[string]*Config,
5659
}
5760
}
5861

59-
sourceAddr, err := addrs.ParseModuleSource(call.Source)
62+
var sourceAddr addrs.ModuleSource
63+
var err error
64+
if haveVersionArg {
65+
sourceAddr, err = addrs.ParseModuleSourceRegistry(call.Source)
66+
} else {
67+
sourceAddr, err = addrs.ParseModuleSource(call.Source)
68+
}
6069
if err != nil {
61-
diags = diags.Append(wrapDiagnostic(tfconfig.Diagnostic{
62-
Severity: tfconfig.DiagError,
63-
Summary: "Invalid module source address",
64-
Detail: fmt.Sprintf("Module %q (declared at %s line %d) has invalid source address %q: %s.", callName, call.Pos.Filename, call.Pos.Line, call.Source, err),
65-
}))
70+
if haveVersionArg {
71+
diags = diags.Append(wrapDiagnostic(tfconfig.Diagnostic{
72+
Severity: tfconfig.DiagError,
73+
Summary: "Invalid registry module source address",
74+
Detail: fmt.Sprintf("Module %q (declared at %s line %d) has invalid source address %q: %s.\n\nTerraform assumed that you intended a module registry source address because you also set the argument \"version\", which applies only to registry modules.", callName, call.Pos.Filename, call.Pos.Line, call.Source, err),
75+
}))
76+
} else {
77+
diags = diags.Append(wrapDiagnostic(tfconfig.Diagnostic{
78+
Severity: tfconfig.DiagError,
79+
Summary: "Invalid module source address",
80+
Detail: fmt.Sprintf("Module %q (declared at %s line %d) has invalid source address %q: %s.", callName, call.Pos.Filename, call.Pos.Line, call.Source, err),
81+
}))
82+
}
6683
// If we didn't have a valid source address then we can't continue
6784
// down the module tree with this one.
6885
continue

internal/initwd/module_install.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,7 @@ func (i *ModuleInstaller) installGoGetterModule(ctx context.Context, req *earlyc
547547
diags = diags.Append(tfdiags.Sourceless(
548548
tfdiags.Error,
549549
"Invalid version constraint",
550-
fmt.Sprintf("Cannot apply a version constraint to module %q (at %s:%d) because it has a non Registry URL.", req.Name, req.CallPos.Filename, req.CallPos.Line),
550+
fmt.Sprintf("Cannot apply a version constraint to module %q (at %s:%d) because it doesn't come from a module registry.", req.Name, req.CallPos.Filename, req.CallPos.Line),
551551
))
552552
return nil, diags
553553
}

internal/initwd/module_install_test.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,12 @@ func TestModuleInstaller_invalid_version_constraint_error(t *testing.T) {
192192
if !diags.HasErrors() {
193193
t.Fatal("expected error")
194194
} else {
195-
assertDiagnosticSummary(t, diags, "Invalid version constraint")
195+
// We use the presence of the "version" argument as a heuristic for
196+
// user intent to use a registry module, and so we intentionally catch
197+
// this as an invalid registry module address rather than an invalid
198+
// version constraint, so we can surface the specific address parsing
199+
// error instead of a generic version constraint error.
200+
assertDiagnosticSummary(t, diags, "Invalid registry module source address")
196201
}
197202
}
198203

@@ -210,7 +215,12 @@ func TestModuleInstaller_invalidVersionConstraintGetter(t *testing.T) {
210215
if !diags.HasErrors() {
211216
t.Fatal("expected error")
212217
} else {
213-
assertDiagnosticSummary(t, diags, "Invalid version constraint")
218+
// We use the presence of the "version" argument as a heuristic for
219+
// user intent to use a registry module, and so we intentionally catch
220+
// this as an invalid registry module address rather than an invalid
221+
// version constraint, so we can surface the specific address parsing
222+
// error instead of a generic version constraint error.
223+
assertDiagnosticSummary(t, diags, "Invalid registry module source address")
214224
}
215225
}
216226

@@ -228,7 +238,12 @@ func TestModuleInstaller_invalidVersionConstraintLocal(t *testing.T) {
228238
if !diags.HasErrors() {
229239
t.Fatal("expected error")
230240
} else {
231-
assertDiagnosticSummary(t, diags, "Invalid version constraint")
241+
// We use the presence of the "version" argument as a heuristic for
242+
// user intent to use a registry module, and so we intentionally catch
243+
// this as an invalid registry module address rather than an invalid
244+
// version constraint, so we can surface the specific address parsing
245+
// error instead of a generic version constraint error.
246+
assertDiagnosticSummary(t, diags, "Invalid registry module source address")
232247
}
233248
}
234249

0 commit comments

Comments
 (0)