Skip to content

Commit 4cf680c

Browse files
Introduce Component and ComponentPackage types (#107)
This provides a structured way to handle component addresses in the Terraform registry, enabling validation and error reporting. The new types and functions facilitate the installation workflow for components. - Added Component and ComponentPackage structs to represent registry components and their addresses. - Implemented ParseComponentSource function to parse and validate component registry addresses. - Created unit tests for ParseComponentSource to ensure correct parsing and error handling. Co-authored-by: Michael Yocca <[email protected]>
1 parent b2e36dd commit 4cf680c

File tree

4 files changed

+566
-2
lines changed

4 files changed

+566
-2
lines changed

README.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
This module enables parsing, comparison and canonical representation of
44
[Terraform Registry](https://registry.terraform.io/) **provider** addresses
5-
(such as `registry.terraform.io/grafana/grafana` or `hashicorp/aws`)
6-
and **module** addresses (such as `hashicorp/subnets/cidr`).
5+
(such as `registry.terraform.io/grafana/grafana` or `hashicorp/aws`),
6+
**module** addresses (such as `hashicorp/subnets/cidr`),
7+
and **component** addresses (such as `hashicorp/aws` or `example.com/myorg/mycomponent`).
78

89
**Provider** addresses can be found in
910

@@ -18,6 +19,10 @@ and **module** addresses (such as `hashicorp/subnets/cidr`).
1819
of `module` block in Terraform configuration (`*.tf`)
1920
and parts of the address (namespace and name) in the Registry API.
2021

22+
**Component** addresses can be found within the `source` argument
23+
of the `component` block in Terraform Stack configuration (`*.tfcomponent.hcl`)
24+
and parts of the address (namespace and name) in the Registry API.
25+
2126
## Compatibility
2227

2328
The module assumes compatibility with Terraform v0.12 and later,
@@ -70,6 +75,24 @@ if err != nil {
7075
// },
7176
```
7277

78+
### Component
79+
80+
```go
81+
cAddr, err := ParseComponentSource("hashicorp/aws//modules/vpc")
82+
if err != nil {
83+
// deal with error
84+
}
85+
86+
// cAddr == Component{
87+
// Package: ComponentPackage{
88+
// Host: DefaultModuleRegistryHost,
89+
// Namespace: "hashicorp",
90+
// Name: "aws",
91+
// },
92+
// Subdir: "modules/vpc",
93+
// }
94+
```
95+
7396
## Other Module Address Formats
7497

7598
Modules can also be sourced from [other sources](https://www.terraform.io/language/modules/sources)

component.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package tfaddr
5+
6+
import (
7+
"fmt"
8+
"strings"
9+
10+
svchost "github.com/hashicorp/terraform-svchost"
11+
)
12+
13+
// Component represents a component listed in a Terraform component registry.
14+
// It combines a registry package address with an optional subdirectory path
15+
// to specify the exact location of a component within a registry package.
16+
//
17+
// Components are resolved through a two-step process:
18+
// 1. The registry package address is used to query the registry API
19+
// 2. The registry returns a ComponentSourceRemote with the actual source location
20+
//
21+
// Example component addresses:
22+
// - registry.terraform.io/hashicorp/aws
23+
// - registry.terraform.io/hashicorp/aws//modules/vpc
24+
// - example.com/myorg/mycomponent//subdir/component
25+
type Component struct {
26+
// Package is the registry package that the target component belongs to.
27+
// The component installer must translate this into a ComponentSourceRemote
28+
// using the registry API and then take that underlying address's
29+
// Package in order to find the actual package location.
30+
Package ComponentPackage
31+
32+
// If Subdir is non-empty then it represents a sub-directory within the
33+
// remote package that the registry address eventually resolves to.
34+
// This will ultimately become the suffix of the Subdir of the
35+
// ComponentSourceRemote that the registry address translates to.
36+
//
37+
// Subdir uses a normalized forward-slash-based path syntax within the
38+
// virtual filesystem represented by the final package. It will never
39+
// include `../` or `./` sequences.
40+
Subdir string
41+
}
42+
43+
// ParseComponentSource parses a string representation of a component registry
44+
// address and returns a Component struct. This function only accepts component
45+
// registry addresses and will reject any other address type.
46+
//
47+
// Supported address formats:
48+
// - "namespace/name" (uses default registry host)
49+
// - "hostname/namespace/name" (explicit registry host)
50+
// - "hostname/namespace/name//subdirectory" (with subdirectory)
51+
//
52+
// Examples:
53+
// - "hashicorp/aws" -> registry.terraform.io/hashicorp/aws
54+
// - "example.com/myorg/component"
55+
// - "registry.terraform.io/hashicorp/aws//modules/vpc"
56+
//
57+
// The function validates that:
58+
// - The address has 2 or 3 slash-separated segments
59+
// - Subdirectories don't escape the package with "../" paths
60+
// - The hostname (if provided) is not a reserved VCS host
61+
// - Namespace and component names follow registry naming conventions
62+
func ParseComponentSource(raw string) (Component, error) {
63+
var err error
64+
65+
// Extract subdirectory path if present (indicated by "//" separator)
66+
var subDir string
67+
raw, subDir = splitPackageSubdir(raw)
68+
if strings.HasPrefix(subDir, "../") {
69+
return Component{}, fmt.Errorf("subdirectory path %q leads outside of the component package", subDir)
70+
}
71+
72+
// Split the main address into its components
73+
parts := strings.Split(raw, "/")
74+
// A valid registry component address has either two or three parts, because the leading hostname part is optional.
75+
if len(parts) != 2 && len(parts) != 3 {
76+
return Component{}, fmt.Errorf("a component registry source address must have either two or three slash-separated segments")
77+
}
78+
79+
// Default to the standard Terraform registry if no hostname is specified
80+
host := DefaultModuleRegistryHost
81+
// Check if hostname segment is present
82+
if len(parts) == 3 {
83+
host, err = svchost.ForComparison(parts[0])
84+
if err != nil {
85+
// The svchost library doesn't produce very good error messages to
86+
// return to an end-user, so we'll use some custom ones here.
87+
switch {
88+
case strings.Contains(parts[0], "--"):
89+
// Looks like possibly punycode, which we don't allow here
90+
// to ensure that source addresses are written readably.
91+
return Component{}, fmt.Errorf("invalid component registry hostname %q; internationalized domain names must be given as direct unicode characters, not in punycode", parts[0])
92+
default:
93+
return Component{}, fmt.Errorf("invalid component registry hostname %q", parts[0])
94+
}
95+
}
96+
if !strings.Contains(host.String(), ".") {
97+
return Component{}, fmt.Errorf("invalid component registry hostname: must contain at least one dot")
98+
}
99+
// Discard the hostname prefix now that we've processed it
100+
parts = parts[1:]
101+
}
102+
103+
ret := Component{
104+
Package: ComponentPackage{
105+
Host: host,
106+
},
107+
Subdir: subDir,
108+
}
109+
110+
// Prevent potential parsing collisions with known VCS hosts
111+
// These hostnames are reserved for direct VCS installation
112+
if host == svchost.Hostname("github.com") || host == svchost.Hostname("bitbucket.org") || host == svchost.Hostname("gitlab.com") {
113+
// NOTE: This may change in the future if we allow VCS installations
114+
// from these hosts to be registered in the component registry.
115+
// For now, we disallow them to avoid confusion.
116+
return ret, fmt.Errorf("can't use %q as a component registry host, because it's reserved for installing directly from version control repositories", host)
117+
}
118+
119+
// Validate and assign the namespace segment
120+
if ret.Package.Namespace, err = parseModuleRegistryName(parts[0]); err != nil {
121+
if strings.Contains(parts[0], ".") {
122+
// Seems like the user omitted one of the latter components in
123+
// an address with an explicit hostname.
124+
return ret, fmt.Errorf("source address must have two more components after the hostname: the namespace and the name")
125+
}
126+
return ret, fmt.Errorf("invalid namespace %q: %s", parts[0], err)
127+
}
128+
129+
// Validate and assign the component name segment
130+
if ret.Package.Name, err = parseModuleRegistryName(parts[1]); err != nil {
131+
if strings.Contains(parts[1], "?") {
132+
// The user was trying to include a query string, probably?
133+
return ret, fmt.Errorf("component registry addresses may not include a query string portion")
134+
}
135+
return ret, fmt.Errorf("invalid component name %q: %s", parts[1], err)
136+
}
137+
138+
return ret, nil
139+
}
140+
141+
// String returns a full representation of the component address, including any
142+
// additional components that are typically implied by omission in
143+
// user-written addresses.
144+
//
145+
// We typically use this longer representation in error messages, in case
146+
// the inclusion of normally-omitted components is helpful in debugging
147+
// unexpected behavior.
148+
func (c Component) String() string {
149+
if c.Subdir != "" {
150+
return c.Package.String() + "//" + c.Subdir
151+
}
152+
return c.Package.String()
153+
}
154+
155+
// ForDisplay is similar to String but instead returns a representation of
156+
// the idiomatic way to write the address in configuration, omitting
157+
// components that are commonly just implied in addresses written by
158+
// users.
159+
//
160+
// We typically use this shorter representation in informational messages,
161+
// such as the note that we're about to start downloading a package.
162+
func (c Component) ForDisplay() string {
163+
if c.Subdir != "" {
164+
return c.Package.ForDisplay() + "//" + c.Subdir
165+
}
166+
return c.Package.ForDisplay()
167+
}

component_package.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package tfaddr
5+
6+
import (
7+
"strings"
8+
9+
svchost "github.com/hashicorp/terraform-svchost"
10+
)
11+
12+
// ComponentPackage represents a symbolic address for a component in a Terraform
13+
// component registry. It serves as an indirection layer that allows the component
14+
// installer to query a registry and translate this symbolic address (along with
15+
// version constraints provided separately) into a concrete physical source location.
16+
//
17+
// ComponentPackage is intentionally distinct from other package address types
18+
// because they serve different purposes: registry package addresses are exclusively
19+
// used for registry queries to discover the actual component package location.
20+
// This separation helps maintainers understand the component installation workflow
21+
// and enables the type system to enforce proper usage patterns.
22+
//
23+
// Example registry addresses:
24+
// - registry.terraform.io/hashicorp/aws
25+
// - example.com/myorg/mycomponent
26+
type ComponentPackage struct {
27+
// Host is the hostname of the component registry (e.g., "registry.terraform.io")
28+
Host svchost.Hostname
29+
30+
// Namespace identifies the organization or user that published the component
31+
Namespace string
32+
33+
// Name is the component's name within the namespace
34+
Name string
35+
}
36+
37+
// String returns the full registry address as a human-readable string.
38+
// This method formats the address for display purposes, using the Unicode
39+
// representation of hostnames rather than punycode encoding.
40+
//
41+
// The returned format is: "hostname/namespace/name"
42+
// For example: "registry.terraform.io/hashicorp/aws"
43+
func (s ComponentPackage) String() string {
44+
// Note: we're using the "display" form of the hostname here because
45+
// for our service hostnames "for display" means something different:
46+
// it means to render non-ASCII characters directly as Unicode
47+
// characters, rather than using the "punycode" representation we
48+
// use for internal processing, and so the "display" representation
49+
// is actually what users would write in their configurations.
50+
return s.Host.ForDisplay() + "/" + s.ForRegistryProtocol()
51+
}
52+
53+
// ForDisplay returns a string representation suitable for display to users,
54+
// omitting the registry hostname when it's the default registry host.
55+
// This is the format users would typically write in their configurations.
56+
//
57+
// For the default registry, returns: "namespace/name"
58+
// For custom registries, returns: "hostname/namespace/name"
59+
func (s ComponentPackage) ForDisplay() string {
60+
if s.Host == DefaultModuleRegistryHost {
61+
return s.ForRegistryProtocol()
62+
}
63+
return s.Host.ForDisplay() + "/" + s.ForRegistryProtocol()
64+
}
65+
66+
// ForRegistryProtocol returns a string representation of just the namespace,
67+
// name, and target system portions of the address, always omitting the
68+
// registry hostname and the subdirectory portion, if any.
69+
//
70+
// This is primarily intended for generating addresses to send to the
71+
// registry in question via the registry protocol, since the protocol
72+
// skips sending the registry its own hostname as part of identifiers.
73+
//
74+
// The returned format is: "namespace/name"
75+
// For example: "hashicorp/aws"
76+
func (s ComponentPackage) ForRegistryProtocol() string {
77+
var buf strings.Builder
78+
buf.WriteString(s.Namespace)
79+
buf.WriteByte('/')
80+
buf.WriteString(s.Name)
81+
return buf.String()
82+
}

0 commit comments

Comments
 (0)