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 }