mirror of
https://github.com/opentofu/opentofu.git
synced 2024-12-27 09:21:14 -06:00
0a46ded3e7
The main motivation here is to produce a helpful error if a user incorrectly uses the terraform-provider- prefix (which we see on provider VCS repositories and plugin executables) as part of the source address. However, this also more broadly blocks "terraform-" as a prefix in anticipation of whatever instinct causes the phenomenon where e.g. Python's PyPI has thousands of packages whose names start with "python-", even though everything on PyPI is for Python by definition. This is definitely not _necessary_, but it's better to be restrictive at first and weaken later as needed.
465 lines
18 KiB
Go
465 lines
18 KiB
Go
package addrs
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"golang.org/x/net/idna"
|
|
|
|
"github.com/hashicorp/hcl/v2"
|
|
svchost "github.com/hashicorp/terraform-svchost"
|
|
"github.com/hashicorp/terraform/tfdiags"
|
|
)
|
|
|
|
// Provider encapsulates a single provider type. In the future this will be
|
|
// extended to include additional fields including Namespace and SourceHost
|
|
type Provider struct {
|
|
Type string
|
|
Namespace string
|
|
Hostname svchost.Hostname
|
|
}
|
|
|
|
// DefaultRegistryHost is the hostname used for provider addresses that do
|
|
// not have an explicit hostname.
|
|
const DefaultRegistryHost = svchost.Hostname("registry.terraform.io")
|
|
|
|
// BuiltInProviderHost is the pseudo-hostname used for the "built-in" provider
|
|
// namespace. Built-in provider addresses must also have their namespace set
|
|
// to BuiltInProviderNamespace in order to be considered as built-in.
|
|
const BuiltInProviderHost = svchost.Hostname("terraform.io")
|
|
|
|
// BuiltInProviderNamespace is the provider namespace used for "built-in"
|
|
// providers. Built-in provider addresses must also have their hostname
|
|
// set to BuiltInProviderHost in order to be considered as built-in.
|
|
//
|
|
// The this namespace is literally named "builtin", in the hope that users
|
|
// who see FQNs containing this will be able to infer the way in which they are
|
|
// special, even if they haven't encountered the concept formally yet.
|
|
const BuiltInProviderNamespace = "builtin"
|
|
|
|
// LegacyProviderNamespace is the special string used in the Namespace field
|
|
// of type Provider to mark a legacy provider address. This special namespace
|
|
// value would normally be invalid, and can be used only when the hostname is
|
|
// DefaultRegistryHost because that host owns the mapping from legacy name to
|
|
// FQN.
|
|
const LegacyProviderNamespace = "-"
|
|
|
|
// String returns an FQN string, indended for use in machine-readable output.
|
|
func (pt Provider) String() string {
|
|
if pt.IsZero() {
|
|
panic("called String on zero-value addrs.Provider")
|
|
}
|
|
return pt.Hostname.ForDisplay() + "/" + pt.Namespace + "/" + pt.Type
|
|
}
|
|
|
|
// ForDisplay returns a user-friendly FQN string, simplified for readability. If
|
|
// the provider is using the default hostname, the hostname is omitted.
|
|
func (pt Provider) ForDisplay() string {
|
|
if pt.IsZero() {
|
|
panic("called ForDisplay on zero-value addrs.Provider")
|
|
}
|
|
|
|
if pt.Hostname == DefaultRegistryHost {
|
|
return pt.Namespace + "/" + pt.Type
|
|
}
|
|
return pt.Hostname.ForDisplay() + "/" + pt.Namespace + "/" + pt.Type
|
|
}
|
|
|
|
// NewProvider constructs a provider address from its parts, and normalizes
|
|
// the namespace and type parts to lowercase using unicode case folding rules
|
|
// so that resulting addrs.Provider values can be compared using standard
|
|
// Go equality rules (==).
|
|
//
|
|
// The hostname is given as a svchost.Hostname, which is required by the
|
|
// contract of that type to have already been normalized for equality testing.
|
|
//
|
|
// This function will panic if the given namespace or type name are not valid.
|
|
// When accepting namespace or type values from outside the program, use
|
|
// ParseProviderPart first to check that the given value is valid.
|
|
func NewProvider(hostname svchost.Hostname, namespace, typeName string) Provider {
|
|
if namespace == LegacyProviderNamespace {
|
|
// Legacy provider addresses must always be created via
|
|
// NewLegacyProvider so that we can use static analysis to find
|
|
// codepaths still working with those.
|
|
panic("attempt to create legacy provider address using NewProvider; use NewLegacyProvider instead")
|
|
}
|
|
|
|
return Provider{
|
|
Type: MustParseProviderPart(typeName),
|
|
Namespace: MustParseProviderPart(namespace),
|
|
Hostname: hostname,
|
|
}
|
|
}
|
|
|
|
// ImpliedProviderForUnqualifiedType represents the rules for inferring what
|
|
// provider FQN a user intended when only a naked type name is available.
|
|
//
|
|
// For all except the type name "terraform" this returns a so-called "default"
|
|
// provider, which is under the registry.terraform.io/hashicorp/ namespace.
|
|
//
|
|
// As a special case, the string "terraform" maps to
|
|
// "terraform.io/builtin/terraform" because that is the more likely user
|
|
// intent than the now-unmaintained "registry.terraform.io/hashicorp/terraform"
|
|
// which remains only for compatibility with older Terraform versions.
|
|
func ImpliedProviderForUnqualifiedType(typeName string) Provider {
|
|
switch typeName {
|
|
case "terraform":
|
|
// Note for future maintainers: any additional strings we add here
|
|
// as implied to be builtin must never also be use as provider names
|
|
// in the registry.terraform.io/hashicorp/... namespace, because
|
|
// otherwise older versions of Terraform could implicitly select
|
|
// the registry name instead of the internal one.
|
|
return NewBuiltInProvider(typeName)
|
|
default:
|
|
return NewDefaultProvider(typeName)
|
|
}
|
|
}
|
|
|
|
// NewDefaultProvider returns the default address of a HashiCorp-maintained,
|
|
// Registry-hosted provider.
|
|
func NewDefaultProvider(name string) Provider {
|
|
return Provider{
|
|
Type: MustParseProviderPart(name),
|
|
Namespace: "hashicorp",
|
|
Hostname: DefaultRegistryHost,
|
|
}
|
|
}
|
|
|
|
// NewBuiltInProvider returns the address of a "built-in" provider. See
|
|
// the docs for Provider.IsBuiltIn for more information.
|
|
func NewBuiltInProvider(name string) Provider {
|
|
return Provider{
|
|
Type: MustParseProviderPart(name),
|
|
Namespace: BuiltInProviderNamespace,
|
|
Hostname: BuiltInProviderHost,
|
|
}
|
|
}
|
|
|
|
// NewLegacyProvider returns a mock address for a provider.
|
|
// This will be removed when ProviderType is fully integrated.
|
|
func NewLegacyProvider(name string) Provider {
|
|
return Provider{
|
|
// We intentionally don't normalize and validate the legacy names,
|
|
// because existing code expects legacy provider names to pass through
|
|
// verbatim, even if not compliant with our new naming rules.
|
|
Type: name,
|
|
Namespace: LegacyProviderNamespace,
|
|
Hostname: DefaultRegistryHost,
|
|
}
|
|
}
|
|
|
|
// LegacyString returns the provider type, which is frequently used
|
|
// interchangeably with provider name. This function can and should be removed
|
|
// when provider type is fully integrated. As a safeguard for future
|
|
// refactoring, this function panics if the Provider is not a legacy provider.
|
|
func (pt Provider) LegacyString() string {
|
|
if pt.IsZero() {
|
|
panic("called LegacyString on zero-value addrs.Provider")
|
|
}
|
|
if pt.Namespace != LegacyProviderNamespace {
|
|
panic(pt.String() + " is not a legacy addrs.Provider")
|
|
}
|
|
return pt.Type
|
|
}
|
|
|
|
// IsZero returns true if the receiver is the zero value of addrs.Provider.
|
|
//
|
|
// The zero value is not a valid addrs.Provider and calling other methods on
|
|
// such a value is likely to either panic or otherwise misbehave.
|
|
func (pt Provider) IsZero() bool {
|
|
return pt == Provider{}
|
|
}
|
|
|
|
// IsBuiltIn returns true if the receiver is the address of a "built-in"
|
|
// provider. That is, a provider under terraform.io/builtin/ which is
|
|
// included as part of the Terraform binary itself rather than one to be
|
|
// installed from elsewhere.
|
|
//
|
|
// These are ignored by the provider installer because they are assumed to
|
|
// already be available without any further installation.
|
|
func (pt Provider) IsBuiltIn() bool {
|
|
return pt.Hostname == BuiltInProviderHost && pt.Namespace == BuiltInProviderNamespace
|
|
}
|
|
|
|
// LessThan returns true if the receiver should sort before the other given
|
|
// address in an ordered list of provider addresses.
|
|
//
|
|
// This ordering is an arbitrary one just to allow deterministic results from
|
|
// functions that would otherwise have no natural ordering. It's subject
|
|
// to change in future.
|
|
func (pt Provider) LessThan(other Provider) bool {
|
|
switch {
|
|
case pt.Hostname != other.Hostname:
|
|
return pt.Hostname < other.Hostname
|
|
case pt.Namespace != other.Namespace:
|
|
return pt.Namespace < other.Namespace
|
|
default:
|
|
return pt.Type < other.Type
|
|
}
|
|
}
|
|
|
|
// IsLegacy returns true if the provider is a legacy-style provider
|
|
func (pt Provider) IsLegacy() bool {
|
|
if pt.IsZero() {
|
|
panic("called IsLegacy() on zero-value addrs.Provider")
|
|
}
|
|
|
|
return pt.Hostname == DefaultRegistryHost && pt.Namespace == LegacyProviderNamespace
|
|
|
|
}
|
|
|
|
// IsDefault returns true if the provider is a default hashicorp provider
|
|
func (pt Provider) IsDefault() bool {
|
|
if pt.IsZero() {
|
|
panic("called IsDefault() on zero-value addrs.Provider")
|
|
}
|
|
|
|
return pt.Hostname == DefaultRegistryHost && pt.Namespace == "hashicorp"
|
|
}
|
|
|
|
// Equals returns true if the receiver and other provider have the same attributes.
|
|
func (pt Provider) Equals(other Provider) bool {
|
|
return pt == other
|
|
}
|
|
|
|
// ParseProviderSourceString parses the source attribute and returns a provider.
|
|
// This is intended primarily to parse the FQN-like strings returned by
|
|
// terraform-config-inspect.
|
|
//
|
|
// The following are valid source string formats:
|
|
// name
|
|
// namespace/name
|
|
// hostname/namespace/name
|
|
func ParseProviderSourceString(str string) (Provider, tfdiags.Diagnostics) {
|
|
var ret Provider
|
|
var diags tfdiags.Diagnostics
|
|
|
|
// split the source string into individual components
|
|
parts := strings.Split(str, "/")
|
|
if len(parts) == 0 || len(parts) > 3 {
|
|
diags = diags.Append(&hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Invalid provider source string",
|
|
Detail: `The "source" attribute must be in the format "[hostname/][namespace/]name"`,
|
|
})
|
|
return ret, diags
|
|
}
|
|
|
|
// check for an invalid empty string in any part
|
|
for i := range parts {
|
|
if parts[i] == "" {
|
|
diags = diags.Append(&hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Invalid provider source string",
|
|
Detail: `The "source" attribute must be in the format "[hostname/][namespace/]name"`,
|
|
})
|
|
return ret, diags
|
|
}
|
|
}
|
|
|
|
// check the 'name' portion, which is always the last part
|
|
givenName := parts[len(parts)-1]
|
|
name, err := ParseProviderPart(givenName)
|
|
if err != nil {
|
|
diags = diags.Append(&hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Invalid provider type",
|
|
Detail: fmt.Sprintf(`Invalid provider type %q in source %q: %s"`, givenName, str, err),
|
|
})
|
|
return ret, diags
|
|
}
|
|
ret.Type = name
|
|
ret.Hostname = DefaultRegistryHost
|
|
|
|
if len(parts) == 1 {
|
|
return NewDefaultProvider(parts[0]), diags
|
|
}
|
|
|
|
if len(parts) >= 2 {
|
|
// the namespace is always the second-to-last part
|
|
givenNamespace := parts[len(parts)-2]
|
|
if givenNamespace == LegacyProviderNamespace {
|
|
// For now we're tolerating legacy provider addresses until we've
|
|
// finished updating the rest of the codebase to no longer use them,
|
|
// or else we'd get errors round-tripping through legacy subsystems.
|
|
ret.Namespace = LegacyProviderNamespace
|
|
} else {
|
|
namespace, err := ParseProviderPart(givenNamespace)
|
|
if err != nil {
|
|
diags = diags.Append(&hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Invalid provider namespace",
|
|
Detail: fmt.Sprintf(`Invalid provider namespace %q in source %q: %s"`, namespace, str, err),
|
|
})
|
|
return Provider{}, diags
|
|
}
|
|
ret.Namespace = namespace
|
|
}
|
|
}
|
|
|
|
// Final Case: 3 parts
|
|
if len(parts) == 3 {
|
|
// the namespace is always the first part in a three-part source string
|
|
hn, err := svchost.ForComparison(parts[0])
|
|
if err != nil {
|
|
diags = diags.Append(&hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Invalid provider source hostname",
|
|
Detail: fmt.Sprintf(`Invalid provider source hostname namespace %q in source %q: %s"`, hn, str, err),
|
|
})
|
|
return Provider{}, diags
|
|
}
|
|
ret.Hostname = hn
|
|
}
|
|
|
|
if ret.Namespace == LegacyProviderNamespace && ret.Hostname != DefaultRegistryHost {
|
|
// Legacy provider addresses must always be on the default registry
|
|
// host, because the default registry host decides what actual FQN
|
|
// each one maps to.
|
|
diags = diags.Append(&hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Invalid provider namespace",
|
|
Detail: "The legacy provider namespace \"-\" can be used only with hostname " + DefaultRegistryHost.ForDisplay() + ".",
|
|
})
|
|
return Provider{}, diags
|
|
}
|
|
|
|
// Due to how plugin executables are named and provider git repositories
|
|
// are conventionally named, it's a reasonable and
|
|
// apparently-somewhat-common user error to incorrectly use the
|
|
// "terraform-provider-" prefix in a provider source address. There is
|
|
// no good reason for a provider to have the prefix "terraform-" anyway,
|
|
// so we've made that invalid from the start both so we can give feedback
|
|
// to provider developers about the terraform- prefix being redundant
|
|
// and give specialized feedback to folks who incorrectly use the full
|
|
// terraform-provider- prefix to help them self-correct.
|
|
const redundantPrefix = "terraform-"
|
|
const userErrorPrefix = "terraform-provider-"
|
|
if strings.HasPrefix(ret.Type, redundantPrefix) {
|
|
if strings.HasPrefix(ret.Type, userErrorPrefix) {
|
|
// Likely user error. We only return this specialized error if
|
|
// whatever is after the prefix would otherwise be a
|
|
// syntactically-valid provider type, so we don't end up advising
|
|
// the user to try something that would be invalid for another
|
|
// reason anyway.
|
|
// (This is mainly just for robustness, because the validation
|
|
// we already did above should've rejected most/all ways for
|
|
// the suggestedType to end up invalid here.)
|
|
suggestedType := ret.Type[len(userErrorPrefix):]
|
|
if _, err := ParseProviderPart(suggestedType); err == nil {
|
|
suggestedAddr := ret
|
|
suggestedAddr.Type = suggestedType
|
|
diags = diags.Append(tfdiags.Sourceless(
|
|
tfdiags.Error,
|
|
"Invalid provider type",
|
|
fmt.Sprintf("Provider source %q has a type with the prefix %q, which isn't valid. Although that prefix is often used in the names of version control repositories for Terraform providers, provider source strings should not include it.\n\nDid you mean %q?", ret.ForDisplay(), userErrorPrefix, suggestedAddr.ForDisplay()),
|
|
))
|
|
return Provider{}, diags
|
|
}
|
|
}
|
|
// Otherwise, probably instead an incorrectly-named provider, perhaps
|
|
// arising from a similar instinct to what causes there to be
|
|
// thousands of Python packages on PyPI with "python-"-prefixed
|
|
// names.
|
|
diags = diags.Append(tfdiags.Sourceless(
|
|
tfdiags.Error,
|
|
"Invalid provider type",
|
|
fmt.Sprintf("Provider source %q has a type with the prefix %q, which isn't allowed because it would be redundant to name a Terraform provider with that prefix. If you are the author of this provider, rename it to not include the prefix.", ret, redundantPrefix),
|
|
))
|
|
return Provider{}, diags
|
|
}
|
|
|
|
return ret, diags
|
|
}
|
|
|
|
// MustParseProviderSourceString is a wrapper around ParseProviderSourceString that panics if
|
|
// it returns an error.
|
|
func MustParseProviderSourceString(str string) Provider {
|
|
result, diags := ParseProviderSourceString(str)
|
|
if diags.HasErrors() {
|
|
panic(diags.Err().Error())
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ParseProviderPart processes an addrs.Provider namespace or type string
|
|
// provided by an end-user, producing a normalized version if possible or
|
|
// an error if the string contains invalid characters.
|
|
//
|
|
// A provider part is processed in the same way as an individual label in a DNS
|
|
// domain name: it is transformed to lowercase per the usual DNS case mapping
|
|
// and normalization rules and may contain only letters, digits, and dashes.
|
|
// Additionally, dashes may not appear at the start or end of the string.
|
|
//
|
|
// These restrictions are intended to allow these names to appear in fussy
|
|
// contexts such as directory/file names on case-insensitive filesystems,
|
|
// repository names on GitHub, etc. We're using the DNS rules in particular,
|
|
// rather than some similar rules defined locally, because the hostname part
|
|
// of an addrs.Provider is already a hostname and it's ideal to use exactly
|
|
// the same case folding and normalization rules for all of the parts.
|
|
//
|
|
// In practice a provider type string conventionally does not contain dashes
|
|
// either. Such names are permitted, but providers with such type names will be
|
|
// hard to use because their resource type names will not be able to contain
|
|
// the provider type name and thus each resource will need an explicit provider
|
|
// address specified. (A real-world example of such a provider is the
|
|
// "google-beta" variant of the GCP provider, which has resource types that
|
|
// start with the "google_" prefix instead.)
|
|
//
|
|
// It's valid to pass the result of this function as the argument to a
|
|
// subsequent call, in which case the result will be identical.
|
|
func ParseProviderPart(given string) (string, error) {
|
|
if len(given) == 0 {
|
|
return "", fmt.Errorf("must have at least one character")
|
|
}
|
|
|
|
// We're going to process the given name using the same "IDNA" library we
|
|
// use for the hostname portion, since it already implements the case
|
|
// folding rules we want.
|
|
//
|
|
// The idna library doesn't expose individual label parsing directly, but
|
|
// once we've verified it doesn't contain any dots we can just treat it
|
|
// like a top-level domain for this library's purposes.
|
|
if strings.ContainsRune(given, '.') {
|
|
return "", fmt.Errorf("dots are not allowed")
|
|
}
|
|
|
|
// We don't allow names containing multiple consecutive dashes, just as
|
|
// a matter of preference: they look weird, confusing, or incorrect.
|
|
// This also, as a side-effect, prevents the use of the "punycode"
|
|
// indicator prefix "xn--" that would cause the IDNA library to interpret
|
|
// the given name as punycode, because that would be weird and unexpected.
|
|
if strings.Contains(given, "--") {
|
|
return "", fmt.Errorf("cannot use multiple consecutive dashes")
|
|
}
|
|
|
|
result, err := idna.Lookup.ToUnicode(given)
|
|
if err != nil {
|
|
return "", fmt.Errorf("must contain only letters, digits, and dashes, and may not use leading or trailing dashes")
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// MustParseProviderPart is a wrapper around ParseProviderPart that panics if
|
|
// it returns an error.
|
|
func MustParseProviderPart(given string) string {
|
|
result, err := ParseProviderPart(given)
|
|
if err != nil {
|
|
panic(err.Error())
|
|
}
|
|
return result
|
|
}
|
|
|
|
// IsProviderPartNormalized compares a given string to the result of ParseProviderPart(string)
|
|
func IsProviderPartNormalized(str string) (bool, error) {
|
|
normalized, err := ParseProviderPart(str)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if str == normalized {
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|