github.com/hashicorp/terraform-plugin-sdk@v1.17.2/internal/addrs/provider.go (about)

     1  package addrs
     2  
     3  import (
     4  	"fmt"
     5  	"strings"
     6  
     7  	"golang.org/x/net/idna"
     8  
     9  	"github.com/hashicorp/hcl/v2"
    10  	"github.com/hashicorp/terraform-plugin-sdk/internal/tfdiags"
    11  	svchost "github.com/hashicorp/terraform-svchost"
    12  )
    13  
    14  // Provider encapsulates a single provider type. In the future this will be
    15  // extended to include additional fields including Namespace and SourceHost
    16  type Provider struct {
    17  	Type      string
    18  	Namespace string
    19  	Hostname  svchost.Hostname
    20  }
    21  
    22  // DefaultRegistryHost is the hostname used for provider addresses that do
    23  // not have an explicit hostname.
    24  const DefaultRegistryHost = svchost.Hostname("registry.terraform.io")
    25  
    26  // BuiltInProviderHost is the pseudo-hostname used for the "built-in" provider
    27  // namespace. Built-in provider addresses must also have their namespace set
    28  // to BuiltInProviderNamespace in order to be considered as built-in.
    29  const BuiltInProviderHost = svchost.Hostname("terraform.io")
    30  
    31  // BuiltInProviderNamespace is the provider namespace used for "built-in"
    32  // providers. Built-in provider addresses must also have their hostname
    33  // set to BuiltInProviderHost in order to be considered as built-in.
    34  //
    35  // The this namespace is literally named "builtin", in the hope that users
    36  // who see FQNs containing this will be able to infer the way in which they are
    37  // special, even if they haven't encountered the concept formally yet.
    38  const BuiltInProviderNamespace = "builtin"
    39  
    40  // LegacyProviderNamespace is the special string used in the Namespace field
    41  // of type Provider to mark a legacy provider address. This special namespace
    42  // value would normally be invalid, and can be used only when the hostname is
    43  // DefaultRegistryHost because that host owns the mapping from legacy name to
    44  // FQN.
    45  const LegacyProviderNamespace = "-"
    46  
    47  // String returns an FQN string, indended for use in machine-readable output.
    48  func (pt Provider) String() string {
    49  	if pt.IsZero() {
    50  		panic("called String on zero-value addrs.Provider")
    51  	}
    52  	return pt.Hostname.ForDisplay() + "/" + pt.Namespace + "/" + pt.Type
    53  }
    54  
    55  // ForDisplay returns a user-friendly FQN string, simplified for readability. If
    56  // the provider is using the default hostname, the hostname is omitted.
    57  func (pt Provider) ForDisplay() string {
    58  	if pt.IsZero() {
    59  		panic("called ForDisplay on zero-value addrs.Provider")
    60  	}
    61  
    62  	if pt.Hostname == DefaultRegistryHost {
    63  		return pt.Namespace + "/" + pt.Type
    64  	}
    65  	return pt.Hostname.ForDisplay() + "/" + pt.Namespace + "/" + pt.Type
    66  }
    67  
    68  // NewProvider constructs a provider address from its parts, and normalizes
    69  // the namespace and type parts to lowercase using unicode case folding rules
    70  // so that resulting addrs.Provider values can be compared using standard
    71  // Go equality rules (==).
    72  //
    73  // The hostname is given as a svchost.Hostname, which is required by the
    74  // contract of that type to have already been normalized for equality testing.
    75  //
    76  // This function will panic if the given namespace or type name are not valid.
    77  // When accepting namespace or type values from outside the program, use
    78  // ParseProviderPart first to check that the given value is valid.
    79  func NewProvider(hostname svchost.Hostname, namespace, typeName string) Provider {
    80  	if namespace == LegacyProviderNamespace {
    81  		// Legacy provider addresses must always be created via
    82  		// NewLegacyProvider so that we can use static analysis to find
    83  		// codepaths still working with those.
    84  		panic("attempt to create legacy provider address using NewProvider; use NewLegacyProvider instead")
    85  	}
    86  
    87  	return Provider{
    88  		Type:      MustParseProviderPart(typeName),
    89  		Namespace: MustParseProviderPart(namespace),
    90  		Hostname:  hostname,
    91  	}
    92  }
    93  
    94  // ImpliedProviderForUnqualifiedType represents the rules for inferring what
    95  // provider FQN a user intended when only a naked type name is available.
    96  //
    97  // For all except the type name "terraform" this returns a so-called "default"
    98  // provider, which is under the registry.terraform.io/hashicorp/ namespace.
    99  //
   100  // As a special case, the string "terraform" maps to
   101  // "terraform.io/builtin/terraform" because that is the more likely user
   102  // intent than the now-unmaintained "registry.terraform.io/hashicorp/terraform"
   103  // which remains only for compatibility with older Terraform versions.
   104  func ImpliedProviderForUnqualifiedType(typeName string) Provider {
   105  	switch typeName {
   106  	case "terraform":
   107  		// Note for future maintainers: any additional strings we add here
   108  		// as implied to be builtin must never also be use as provider names
   109  		// in the registry.terraform.io/hashicorp/... namespace, because
   110  		// otherwise older versions of Terraform could implicitly select
   111  		// the registry name instead of the internal one.
   112  		return NewBuiltInProvider(typeName)
   113  	default:
   114  		return NewDefaultProvider(typeName)
   115  	}
   116  }
   117  
   118  // NewDefaultProvider returns the default address of a HashiCorp-maintained,
   119  // Registry-hosted provider.
   120  func NewDefaultProvider(name string) Provider {
   121  	return Provider{
   122  		Type:      MustParseProviderPart(name),
   123  		Namespace: "hashicorp",
   124  		Hostname:  DefaultRegistryHost,
   125  	}
   126  }
   127  
   128  // NewBuiltInProvider returns the address of a "built-in" provider. See
   129  // the docs for Provider.IsBuiltIn for more information.
   130  func NewBuiltInProvider(name string) Provider {
   131  	return Provider{
   132  		Type:      MustParseProviderPart(name),
   133  		Namespace: BuiltInProviderNamespace,
   134  		Hostname:  BuiltInProviderHost,
   135  	}
   136  }
   137  
   138  // NewLegacyProvider returns a mock address for a provider.
   139  // This will be removed when ProviderType is fully integrated.
   140  func NewLegacyProvider(name string) Provider {
   141  	return Provider{
   142  		// We intentionally don't normalize and validate the legacy names,
   143  		// because existing code expects legacy provider names to pass through
   144  		// verbatim, even if not compliant with our new naming rules.
   145  		Type:      name,
   146  		Namespace: LegacyProviderNamespace,
   147  		Hostname:  DefaultRegistryHost,
   148  	}
   149  }
   150  
   151  // LegacyString returns the provider type, which is frequently used
   152  // interchangeably with provider name. This function can and should be removed
   153  // when provider type is fully integrated. As a safeguard for future
   154  // refactoring, this function panics if the Provider is not a legacy provider.
   155  func (pt Provider) LegacyString() string {
   156  	if pt.IsZero() {
   157  		panic("called LegacyString on zero-value addrs.Provider")
   158  	}
   159  	if pt.Namespace != LegacyProviderNamespace && pt.Namespace != BuiltInProviderNamespace {
   160  		panic(pt.String() + " cannot be represented as a legacy string")
   161  	}
   162  	return pt.Type
   163  }
   164  
   165  // IsZero returns true if the receiver is the zero value of addrs.Provider.
   166  //
   167  // The zero value is not a valid addrs.Provider and calling other methods on
   168  // such a value is likely to either panic or otherwise misbehave.
   169  func (pt Provider) IsZero() bool {
   170  	return pt == Provider{}
   171  }
   172  
   173  // IsBuiltIn returns true if the receiver is the address of a "built-in"
   174  // provider. That is, a provider under terraform.io/builtin/ which is
   175  // included as part of the Terraform binary itself rather than one to be
   176  // installed from elsewhere.
   177  //
   178  // These are ignored by the provider installer because they are assumed to
   179  // already be available without any further installation.
   180  func (pt Provider) IsBuiltIn() bool {
   181  	return pt.Hostname == BuiltInProviderHost && pt.Namespace == BuiltInProviderNamespace
   182  }
   183  
   184  // LessThan returns true if the receiver should sort before the other given
   185  // address in an ordered list of provider addresses.
   186  //
   187  // This ordering is an arbitrary one just to allow deterministic results from
   188  // functions that would otherwise have no natural ordering. It's subject
   189  // to change in future.
   190  func (pt Provider) LessThan(other Provider) bool {
   191  	switch {
   192  	case pt.Hostname != other.Hostname:
   193  		return pt.Hostname < other.Hostname
   194  	case pt.Namespace != other.Namespace:
   195  		return pt.Namespace < other.Namespace
   196  	default:
   197  		return pt.Type < other.Type
   198  	}
   199  }
   200  
   201  // IsLegacy returns true if the provider is a legacy-style provider
   202  func (pt Provider) IsLegacy() bool {
   203  	if pt.IsZero() {
   204  		panic("called IsLegacy() on zero-value addrs.Provider")
   205  	}
   206  
   207  	return pt.Hostname == DefaultRegistryHost && pt.Namespace == LegacyProviderNamespace
   208  
   209  }
   210  
   211  // IsDefault returns true if the provider is a default hashicorp provider
   212  func (pt Provider) IsDefault() bool {
   213  	if pt.IsZero() {
   214  		panic("called IsDefault() on zero-value addrs.Provider")
   215  	}
   216  
   217  	return pt.Hostname == DefaultRegistryHost && pt.Namespace == "hashicorp"
   218  }
   219  
   220  // Equals returns true if the receiver and other provider have the same attributes.
   221  func (pt Provider) Equals(other Provider) bool {
   222  	return pt == other
   223  }
   224  
   225  // ParseProviderSourceString parses the source attribute and returns a provider.
   226  // This is intended primarily to parse the FQN-like strings returned by
   227  // terraform-config-inspect.
   228  //
   229  // The following are valid source string formats:
   230  // 		name
   231  // 		namespace/name
   232  // 		hostname/namespace/name
   233  func ParseProviderSourceString(str string) (Provider, tfdiags.Diagnostics) {
   234  	var ret Provider
   235  	var diags tfdiags.Diagnostics
   236  
   237  	// split the source string into individual components
   238  	parts := strings.Split(str, "/")
   239  	if len(parts) == 0 || len(parts) > 3 {
   240  		diags = diags.Append(&hcl.Diagnostic{
   241  			Severity: hcl.DiagError,
   242  			Summary:  "Invalid provider source string",
   243  			Detail:   `The "source" attribute must be in the format "[hostname/][namespace/]name"`,
   244  		})
   245  		return ret, diags
   246  	}
   247  
   248  	// check for an invalid empty string in any part
   249  	for i := range parts {
   250  		if parts[i] == "" {
   251  			diags = diags.Append(&hcl.Diagnostic{
   252  				Severity: hcl.DiagError,
   253  				Summary:  "Invalid provider source string",
   254  				Detail:   `The "source" attribute must be in the format "[hostname/][namespace/]name"`,
   255  			})
   256  			return ret, diags
   257  		}
   258  	}
   259  
   260  	// check the 'name' portion, which is always the last part
   261  	givenName := parts[len(parts)-1]
   262  	name, err := ParseProviderPart(givenName)
   263  	if err != nil {
   264  		diags = diags.Append(&hcl.Diagnostic{
   265  			Severity: hcl.DiagError,
   266  			Summary:  "Invalid provider type",
   267  			Detail:   fmt.Sprintf(`Invalid provider type %q in source %q: %s"`, givenName, str, err),
   268  		})
   269  		return ret, diags
   270  	}
   271  	ret.Type = name
   272  	ret.Hostname = DefaultRegistryHost
   273  
   274  	if len(parts) == 1 {
   275  		return NewDefaultProvider(parts[0]), diags
   276  	}
   277  
   278  	if len(parts) >= 2 {
   279  		// the namespace is always the second-to-last part
   280  		givenNamespace := parts[len(parts)-2]
   281  		if givenNamespace == LegacyProviderNamespace {
   282  			// For now we're tolerating legacy provider addresses until we've
   283  			// finished updating the rest of the codebase to no longer use them,
   284  			// or else we'd get errors round-tripping through legacy subsystems.
   285  			ret.Namespace = LegacyProviderNamespace
   286  		} else {
   287  			namespace, err := ParseProviderPart(givenNamespace)
   288  			if err != nil {
   289  				diags = diags.Append(&hcl.Diagnostic{
   290  					Severity: hcl.DiagError,
   291  					Summary:  "Invalid provider namespace",
   292  					Detail:   fmt.Sprintf(`Invalid provider namespace %q in source %q: %s"`, namespace, str, err),
   293  				})
   294  				return Provider{}, diags
   295  			}
   296  			ret.Namespace = namespace
   297  		}
   298  	}
   299  
   300  	// Final Case: 3 parts
   301  	if len(parts) == 3 {
   302  		// the namespace is always the first part in a three-part source string
   303  		hn, err := svchost.ForComparison(parts[0])
   304  		if err != nil {
   305  			diags = diags.Append(&hcl.Diagnostic{
   306  				Severity: hcl.DiagError,
   307  				Summary:  "Invalid provider source hostname",
   308  				Detail:   fmt.Sprintf(`Invalid provider source hostname namespace %q in source %q: %s"`, hn, str, err),
   309  			})
   310  			return Provider{}, diags
   311  		}
   312  		ret.Hostname = hn
   313  	}
   314  
   315  	if ret.Namespace == LegacyProviderNamespace && ret.Hostname != DefaultRegistryHost {
   316  		// Legacy provider addresses must always be on the default registry
   317  		// host, because the default registry host decides what actual FQN
   318  		// each one maps to.
   319  		diags = diags.Append(&hcl.Diagnostic{
   320  			Severity: hcl.DiagError,
   321  			Summary:  "Invalid provider namespace",
   322  			Detail:   "The legacy provider namespace \"-\" can be used only with hostname " + DefaultRegistryHost.ForDisplay() + ".",
   323  		})
   324  		return Provider{}, diags
   325  	}
   326  
   327  	// Due to how plugin executables are named and provider git repositories
   328  	// are conventionally named, it's a reasonable and
   329  	// apparently-somewhat-common user error to incorrectly use the
   330  	// "terraform-provider-" prefix in a provider source address. There is
   331  	// no good reason for a provider to have the prefix "terraform-" anyway,
   332  	// so we've made that invalid from the start both so we can give feedback
   333  	// to provider developers about the terraform- prefix being redundant
   334  	// and give specialized feedback to folks who incorrectly use the full
   335  	// terraform-provider- prefix to help them self-correct.
   336  	const redundantPrefix = "terraform-"
   337  	const userErrorPrefix = "terraform-provider-"
   338  	if strings.HasPrefix(ret.Type, redundantPrefix) {
   339  		if strings.HasPrefix(ret.Type, userErrorPrefix) {
   340  			// Likely user error. We only return this specialized error if
   341  			// whatever is after the prefix would otherwise be a
   342  			// syntactically-valid provider type, so we don't end up advising
   343  			// the user to try something that would be invalid for another
   344  			// reason anyway.
   345  			// (This is mainly just for robustness, because the validation
   346  			// we already did above should've rejected most/all ways for
   347  			// the suggestedType to end up invalid here.)
   348  			suggestedType := ret.Type[len(userErrorPrefix):]
   349  			if _, err := ParseProviderPart(suggestedType); err == nil {
   350  				suggestedAddr := ret
   351  				suggestedAddr.Type = suggestedType
   352  				diags = diags.Append(tfdiags.Sourceless(
   353  					tfdiags.Error,
   354  					"Invalid provider type",
   355  					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()),
   356  				))
   357  				return Provider{}, diags
   358  			}
   359  		}
   360  		// Otherwise, probably instead an incorrectly-named provider, perhaps
   361  		// arising from a similar instinct to what causes there to be
   362  		// thousands of Python packages on PyPI with "python-"-prefixed
   363  		// names.
   364  		diags = diags.Append(tfdiags.Sourceless(
   365  			tfdiags.Error,
   366  			"Invalid provider type",
   367  			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),
   368  		))
   369  		return Provider{}, diags
   370  	}
   371  
   372  	return ret, diags
   373  }
   374  
   375  // MustParseProviderSourceString is a wrapper around ParseProviderSourceString that panics if
   376  // it returns an error.
   377  func MustParseProviderSourceString(str string) Provider {
   378  	result, diags := ParseProviderSourceString(str)
   379  	if diags.HasErrors() {
   380  		panic(diags.Err().Error())
   381  	}
   382  	return result
   383  }
   384  
   385  // ParseProviderPart processes an addrs.Provider namespace or type string
   386  // provided by an end-user, producing a normalized version if possible or
   387  // an error if the string contains invalid characters.
   388  //
   389  // A provider part is processed in the same way as an individual label in a DNS
   390  // domain name: it is transformed to lowercase per the usual DNS case mapping
   391  // and normalization rules and may contain only letters, digits, and dashes.
   392  // Additionally, dashes may not appear at the start or end of the string.
   393  //
   394  // These restrictions are intended to allow these names to appear in fussy
   395  // contexts such as directory/file names on case-insensitive filesystems,
   396  // repository names on GitHub, etc. We're using the DNS rules in particular,
   397  // rather than some similar rules defined locally, because the hostname part
   398  // of an addrs.Provider is already a hostname and it's ideal to use exactly
   399  // the same case folding and normalization rules for all of the parts.
   400  //
   401  // In practice a provider type string conventionally does not contain dashes
   402  // either. Such names are permitted, but providers with such type names will be
   403  // hard to use because their resource type names will not be able to contain
   404  // the provider type name and thus each resource will need an explicit provider
   405  // address specified. (A real-world example of such a provider is the
   406  // "google-beta" variant of the GCP provider, which has resource types that
   407  // start with the "google_" prefix instead.)
   408  //
   409  // It's valid to pass the result of this function as the argument to a
   410  // subsequent call, in which case the result will be identical.
   411  func ParseProviderPart(given string) (string, error) {
   412  	if len(given) == 0 {
   413  		return "", fmt.Errorf("must have at least one character")
   414  	}
   415  
   416  	// We're going to process the given name using the same "IDNA" library we
   417  	// use for the hostname portion, since it already implements the case
   418  	// folding rules we want.
   419  	//
   420  	// The idna library doesn't expose individual label parsing directly, but
   421  	// once we've verified it doesn't contain any dots we can just treat it
   422  	// like a top-level domain for this library's purposes.
   423  	if strings.ContainsRune(given, '.') {
   424  		return "", fmt.Errorf("dots are not allowed")
   425  	}
   426  
   427  	// We don't allow names containing multiple consecutive dashes, just as
   428  	// a matter of preference: they look weird, confusing, or incorrect.
   429  	// This also, as a side-effect, prevents the use of the "punycode"
   430  	// indicator prefix "xn--" that would cause the IDNA library to interpret
   431  	// the given name as punycode, because that would be weird and unexpected.
   432  	if strings.Contains(given, "--") {
   433  		return "", fmt.Errorf("cannot use multiple consecutive dashes")
   434  	}
   435  
   436  	result, err := idna.Lookup.ToUnicode(given)
   437  	if err != nil {
   438  		return "", fmt.Errorf("must contain only letters, digits, and dashes, and may not use leading or trailing dashes")
   439  	}
   440  
   441  	return result, nil
   442  }
   443  
   444  // MustParseProviderPart is a wrapper around ParseProviderPart that panics if
   445  // it returns an error.
   446  func MustParseProviderPart(given string) string {
   447  	result, err := ParseProviderPart(given)
   448  	if err != nil {
   449  		panic(err.Error())
   450  	}
   451  	return result
   452  }
   453  
   454  // IsProviderPartNormalized compares a given string to the result of ParseProviderPart(string)
   455  func IsProviderPartNormalized(str string) (bool, error) {
   456  	normalized, err := ParseProviderPart(str)
   457  	if err != nil {
   458  		return false, err
   459  	}
   460  	if str == normalized {
   461  		return true, nil
   462  	}
   463  	return false, nil
   464  }