pax_global_header00006660000000000000000000000064144066660760014531gustar00rootroot0000000000000052 comment=44df8d504dd9da883c19343e61d2f56ff5451133 terraform-registry-address-0.2.0/000077500000000000000000000000001440666607600170225ustar00rootroot00000000000000terraform-registry-address-0.2.0/.github/000077500000000000000000000000001440666607600203625ustar00rootroot00000000000000terraform-registry-address-0.2.0/.github/dependabot.yml000066400000000000000000000004101440666607600232050ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "gomod" directory: "/" schedule: interval: "daily" labels: ["dependencies"] - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" labels: ["dependencies"] terraform-registry-address-0.2.0/.github/workflows/000077500000000000000000000000001440666607600224175ustar00rootroot00000000000000terraform-registry-address-0.2.0/.github/workflows/ci.yml000066400000000000000000000017551440666607600235450ustar00rootroot00000000000000name: ci on: pull_request: branches: - main push: branches: - main env: GOPROXY: https://proxy.golang.org/ jobs: test: runs-on: ${{ matrix.os }} strategy: matrix: os: - ubuntu-latest - windows-latest - macos-latest go: - '1.19' - '1.20' steps: - name: Checkout uses: actions/checkout@24cb9080177205b6e8c946b17badbe402adc938f # https://github.com/actions/checkout/releases/tag/v3.4.0 - name: Unshallow run: git fetch --prune --unshallow - name: Set up Go uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 # https://github.com/actions/setup-go/releases/tag/v4.0.0 with: go-version: ${{ matrix.go }} - name: Go mod download run: go mod download -x - name: Go mod verify run: go mod verify - name: Run tests run: go test -v ./... terraform-registry-address-0.2.0/.go-version000066400000000000000000000000051440666607600211060ustar00rootroot000000000000001.19 terraform-registry-address-0.2.0/LICENSE000066400000000000000000000372151440666607600200370ustar00rootroot00000000000000Copyright (c) 2021 HashiCorp, Inc. Mozilla Public License, version 2.0 1. Definitions 1.1. “Contributor” means each individual or legal entity that creates, contributes to the creation of, or owns Covered Software. 1.2. “Contributor Version” means the combination of the Contributions of others (if any) used by a Contributor and that particular Contributor’s Contribution. 1.3. “Contribution” means Covered Software of a particular Contributor. 1.4. “Covered Software” means Source Code Form to which the initial Contributor has attached the notice in Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source Code Form, in each case including portions thereof. 1.5. “Incompatible With Secondary Licenses” means a. that the initial Contributor has attached the notice described in Exhibit B to the Covered Software; or b. that the Covered Software was made available under the terms of version 1.1 or earlier of the License, but not also under the terms of a Secondary License. 1.6. “Executable Form” means any form of the work other than Source Code Form. 1.7. “Larger Work” means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software. 1.8. “License” means this document. 1.9. “Licensable” means having the right to grant, to the maximum extent possible, whether at the time of the initial grant or subsequently, any and all of the rights conveyed by this License. 1.10. “Modifications” means any of the following: a. any file in Source Code Form that results from an addition to, deletion from, or modification of the contents of Covered Software; or b. any new file in Source Code Form that contains any Covered Software. 1.11. “Patent Claims” of a Contributor means any patent claim(s), including without limitation, method, process, and apparatus claims, in any patent Licensable by such Contributor that would be infringed, but for the grant of the License, by the making, using, selling, offering for sale, having made, import, or transfer of either its Contributions or its Contributor Version. 1.12. “Secondary License” means either the GNU General Public License, Version 2.0, the GNU Lesser General Public License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later versions of those licenses. 1.13. “Source Code Form” means the form of the work preferred for making modifications. 1.14. “You” (or “Your”) means an individual or a legal entity exercising rights under this License. For legal entities, “You” includes any entity that controls, is controlled by, or is under common control with You. For purposes of this definition, “control” means (a) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (b) ownership of more than fifty percent (50%) of the outstanding shares or beneficial ownership of such entity. 2. License Grants and Conditions 2.1. Grants Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license: a. under intellectual property rights (other than patent or trademark) Licensable by such Contributor to use, reproduce, make available, modify, display, perform, distribute, and otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or as part of a Larger Work; and b. under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, import, and otherwise transfer either its Contributions or its Contributor Version. 2.2. Effective Date The licenses granted in Section 2.1 with respect to any Contribution become effective for each Contribution on the date the Contributor first distributes such Contribution. 2.3. Limitations on Grant Scope The licenses granted in this Section 2 are the only rights granted under this License. No additional rights or licenses will be implied from the distribution or licensing of Covered Software under this License. Notwithstanding Section 2.1(b) above, no patent license is granted by a Contributor: a. for any code that a Contributor has removed from Covered Software; or b. for infringements caused by: (i) Your and any other third party’s modifications of Covered Software, or (ii) the combination of its Contributions with other software (except as part of its Contributor Version); or c. under Patent Claims infringed by Covered Software in the absence of its Contributions. This License does not grant any rights in the trademarks, service marks, or logos of any Contributor (except as may be necessary to comply with the notice requirements in Section 3.4). 2.4. Subsequent Licenses No Contributor makes additional grants as a result of Your choice to distribute the Covered Software under a subsequent version of this License (see Section 10.2) or under the terms of a Secondary License (if permitted under the terms of Section 3.3). 2.5. Representation Each Contributor represents that the Contributor believes its Contributions are its original creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by this License. 2.6. Fair Use This License is not intended to limit any rights You have under applicable copyright doctrines of fair use, fair dealing, or other equivalents. 2.7. Conditions Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1. 3. Responsibilities 3.1. Distribution of Source Form All distribution of Covered Software in Source Code Form, including any Modifications that You create or to which You contribute, must be under the terms of this License. You must inform recipients that the Source Code Form of the Covered Software is governed by the terms of this License, and how they can obtain a copy of this License. You may not attempt to alter or restrict the recipients’ rights in the Source Code Form. 3.2. Distribution of Executable Form If You distribute Covered Software in Executable Form then: a. such Covered Software must also be made available in Source Code Form, as described in Section 3.1, and You must inform recipients of the Executable Form how they can obtain a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more than the cost of distribution to the recipient; and b. You may distribute such Executable Form under the terms of this License, or sublicense it under different terms, provided that the license for the Executable Form does not attempt to limit or alter the recipients’ rights in the Source Code Form under this License. 3.3. Distribution of a Larger Work You may create and distribute a Larger Work under terms of Your choice, provided that You also comply with the requirements of this License for the Covered Software. If the Larger Work is a combination of Covered Software with a work governed by one or more Secondary Licenses, and the Covered Software is not Incompatible With Secondary Licenses, this License permits You to additionally distribute such Covered Software under the terms of such Secondary License(s), so that the recipient of the Larger Work may, at their option, further distribute the Covered Software under the terms of either this License or such Secondary License(s). 3.4. Notices You may not remove or alter the substance of any license notices (including copyright notices, patent notices, disclaimers of warranty, or limitations of liability) contained within the Source Code Form of the Covered Software, except that You may alter any license notices to the extent required to remedy known factual inaccuracies. 3.5. Application of Additional Terms You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability obligations to one or more recipients of Covered Software. However, You may do so only on Your own behalf, and not on behalf of any Contributor. You must make it absolutely clear that any such warranty, support, indemnity, or liability obligation is offered by You alone, and You hereby agree to indemnify every Contributor for any liability incurred by such Contributor as a result of warranty, support, indemnity or liability terms You offer. You may include additional disclaimers of warranty and limitations of liability specific to any jurisdiction. 4. Inability to Comply Due to Statute or Regulation If it is impossible for You to comply with any of the terms of this License with respect to some or all of the Covered Software due to statute, judicial order, or regulation then You must: (a) comply with the terms of this License to the maximum extent possible; and (b) describe the limitations and the code they affect. Such description must be placed in a text file included with all distributions of the Covered Software under this License. Except to the extent prohibited by statute or regulation, such description must be sufficiently detailed for a recipient of ordinary skill to be able to understand it. 5. Termination 5.1. The rights granted under this License will terminate automatically if You fail to comply with any of its terms. However, if You become compliant, then the rights granted under this License from a particular Contributor are reinstated (a) provisionally, unless and until such Contributor explicitly and finally terminates Your grants, and (b) on an ongoing basis, if such Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days after You have come back into compliance. Moreover, Your grants from a particular Contributor are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by some reasonable means, this is the first time You have received notice of non-compliance with this License from such Contributor, and You become compliant prior to 30 days after Your receipt of the notice. 5.2. If You initiate litigation against any entity by asserting a patent infringement claim (excluding declaratory judgment actions, counter-claims, and cross-claims) alleging that a Contributor Version directly or indirectly infringes any patent, then the rights granted to You by any and all Contributors for the Covered Software under Section 2.1 of this License shall terminate. 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user license agreements (excluding distributors and resellers) which have been validly granted by You or Your distributors under this License prior to termination shall survive termination. 6. Disclaimer of Warranty Covered Software is provided under this License on an “as is” basis, without warranty of any kind, either expressed, implied, or statutory, including, without limitation, warranties that the Covered Software is free of defects, merchantable, fit for a particular purpose or non-infringing. The entire risk as to the quality and performance of the Covered Software is with You. Should any Covered Software prove defective in any respect, You (not any Contributor) assume the cost of any necessary servicing, repair, or correction. This disclaimer of warranty constitutes an essential part of this License. No use of any Covered Software is authorized under this License except under this disclaimer. 7. Limitation of Liability Under no circumstances and under no legal theory, whether tort (including negligence), contract, or otherwise, shall any Contributor, or anyone who distributes Covered Software as permitted above, be liable to You for any direct, indirect, special, incidental, or consequential damages of any character including, without limitation, damages for lost profits, loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses, even if such party shall have been informed of the possibility of such damages. This limitation of liability shall not apply to liability for death or personal injury resulting from such party’s negligence to the extent applicable law prohibits such limitation. Some jurisdictions do not allow the exclusion or limitation of incidental or consequential damages, so this exclusion and limitation may not apply to You. 8. Litigation Any litigation relating to this License may be brought only in the courts of a jurisdiction where the defendant maintains its principal place of business and such litigation shall be governed by laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this Section shall prevent a party’s ability to bring cross-claims or counter-claims. 9. Miscellaneous This License represents the complete agreement concerning the subject matter hereof. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. Any law or regulation which provides that the language of a contract shall be construed against the drafter shall not be used to construe this License against a Contributor. 10. Versions of the License 10.1. New Versions Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other than the license steward has the right to modify or publish new versions of this License. Each version will be given a distinguishing version number. 10.2. Effect of New Versions You may distribute the Covered Software under the terms of the version of the License under which You originally received the Covered Software, or under the terms of any subsequent version published by the license steward. 10.3. Modified Versions If you create software not governed by this License, and you want to create a new license for such software, you may create and use a modified version of this License if you rename the license and remove any references to the name of the license steward (except to note that such modified license differs from this License). 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under the terms of this version of the License, the notice described in Exhibit B of this License must be attached. Exhibit A - Source Code Form License Notice This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. If it is not possible or desirable to put the notice in a particular file, then You may include the notice in a location (such as a LICENSE file in a relevant directory) where a recipient would be likely to look for such a notice. You may add additional accurate notices of copyright ownership. Exhibit B - “Incompatible With Secondary Licenses” Notice This Source Code Form is “Incompatible With Secondary Licenses”, as defined by the Mozilla Public License, v. 2.0. terraform-registry-address-0.2.0/README.md000066400000000000000000000142501440666607600203030ustar00rootroot00000000000000# terraform-registry-address This module enables parsing, comparison and canonical representation of [Terraform Registry](https://registry.terraform.io/) **provider** addresses (such as `registry.terraform.io/grafana/grafana` or `hashicorp/aws`) and **module** addresses (such as `hashicorp/subnets/cidr`). **Provider** addresses can be found in - [`terraform show -json `](https://www.terraform.io/internals/json-format#configuration-representation) (`full_name`) - [`terraform version -json`](https://www.terraform.io/cli/commands/version#example) (`provider_selections`) - [`terraform providers schema -json`](https://www.terraform.io/cli/commands/providers/schema#providers-schema-representation) (keys of `provider_schemas`) - within `required_providers` block in Terraform configuration (`*.tf`) - Terraform [CLI configuration file](https://www.terraform.io/cli/config/config-file#provider-installation) - Plugin [reattach configurations](https://www.terraform.io/plugin/debugging#running-terraform-with-a-provider-in-debug-mode) **Module** addresses can be found within `source` argument of `module` block in Terraform configuration (`*.tf`) and parts of the address (namespace and name) in the Registry API. ## Compatibility The module assumes compatibility with Terraform v0.12 and later, which have the mentioned JSON output produced by corresponding CLI flags. We recommend carefully reading the [ambigouous provider addresses](#Ambiguous-Provider-Addresses) section below which may impact versions `0.12` and `0.13`. ## Related Libraries Other libraries which may help with consuming most of the above Terraform outputs in automation: - [`hashicorp/terraform-exec`](https://github.com/hashicorp/terraform-exec) - [`hashicorp/terraform-json`](https://github.com/hashicorp/terraform-json) ## Usage ### Provider ```go pAddr, err := ParseProviderSource("hashicorp/aws") if err != nil { // deal with error } // pAddr == Provider{ // Type: "aws", // Namespace: "hashicorp", // Hostname: DefaultProviderRegistryHost, // } ``` ### Module ```go mAddr, err := ParseModuleSource("hashicorp/consul/aws//modules/consul-cluster") if err != nil { // deal with error } // mAddr == Module{ // Package: ModulePackage{ // Host: DefaultProviderRegistryHost, // Namespace: "hashicorp", // Name: "consul", // TargetSystem: "aws", // }, // Subdir: "modules/consul-cluster", // }, ``` ## Other Module Address Formats Modules can also be sourced from [other sources](https://www.terraform.io/language/modules/sources) and these other sources (outside of Terraform Registry) have different address formats, such as `./local` or `github.com/hashicorp/example`. This library does _not_ recognize such other address formats and it will return error upon parsing these. ## Ambiguous Provider Addresses Qualified addresses with namespace (such as `hashicorp/aws`) are used exclusively in all recent versions (`0.14+`) of Terraform. If you only work with Terraform `v0.14.0+` configuration/output, you may safely ignore the rest of this section and related part of the API. There are a few types of ambiguous addresses you may comes accross: - Terraform `v0.12` uses "namespace-less address", such as `aws`. - Terraform `v0.13` may use `-` as a placeholder for the unknown namespace, resulting in address such as `-/aws`. - Terraform `v0.14+` _configuration_ still allows ambiguous providers through `provider "" {}` block _without_ corresponding entry inside `required_providers`, but these providers are always resolved as `hashicorp/` and all JSON outputs only use that resolved address. Both ambiguous address formats are accepted by `ParseProviderSource()` ```go pAddr, err := ParseProviderSource("aws") if err != nil { // deal with error } // pAddr == Provider{ // Type: "aws", // Namespace: UnknownProviderNamespace, // "?" // Hostname: DefaultProviderRegistryHost, // "registry.terraform.io" // } pAddr.HasKnownNamespace() // == false pAddr.IsLegacy() // == false ``` ```go pAddr, err := ParseProviderSource("-/aws") if err != nil { // deal with error } // pAddr == Provider{ // Type: "aws", // Namespace: LegacyProviderNamespace, // "-" // Hostname: DefaultProviderRegistryHost, // "registry.terraform.io" // } pAddr.HasKnownNamespace() // == true pAddr.IsLegacy() // == true ``` However `NewProvider()` will panic if you pass an empty namespace or any placeholder indicating unknown namespace. ```go NewProvider(DefaultProviderRegistryHost, "", "aws") // panic NewProvider(DefaultProviderRegistryHost, "-", "aws") // panic NewProvider(DefaultProviderRegistryHost, "?", "aws") // panic ``` If you come across an ambiguous address, you should resolve it to a fully qualified one and use that one instead. ### Resolving Ambiguous Address The Registry API provides the safest way of resolving an ambiguous address. ```sh # grafana (redirected to its own namespace) $ curl -s https://registry.terraform.io/v1/providers/-/grafana/versions | jq '(.id, .moved_to)' "terraform-providers/grafana" "grafana/grafana" # aws (provider without redirection) $ curl -s https://registry.terraform.io/v1/providers/-/aws/versions | jq '(.id, .moved_to)' "hashicorp/aws" null ``` When you cache results, ensure you have invalidation mechanism in place as target (migrated) namespace may change. #### `terraform` provider Like any other legacy address `terraform` is also ambiguous. Such address may (most unlikely) represent a custom-built provider called `terraform`, or the now archived [`hashicorp/terraform` provider in the registry](https://registry.terraform.io/providers/hashicorp/terraform/latest), or (most likely) the `terraform` provider built into 0.11+, which is represented via a dedicated FQN of `terraform.io/builtin/terraform` in 0.13+. You may be able to differentiate between these different providers if you know the version of Terraform. Alternatively you may just treat the address as the builtin provider, i.e. assume all of its logic including schema is contained within Terraform Core. In such case you should construct the address in the following way ```go pAddr := NewProvider(BuiltInProviderHost, BuiltInProviderNamespace, "terraform") ``` terraform-registry-address-0.2.0/errors.go000066400000000000000000000002771440666607600206730ustar00rootroot00000000000000package tfaddr import ( "fmt" ) type ParserError struct { Summary string Detail string } func (pe *ParserError) Error() string { return fmt.Sprintf("%s: %s", pe.Summary, pe.Detail) } terraform-registry-address-0.2.0/go.mod000066400000000000000000000003441440666607600201310ustar00rootroot00000000000000module github.com/hashicorp/terraform-registry-address go 1.19 require ( github.com/google/go-cmp v0.5.9 github.com/hashicorp/terraform-svchost v0.0.1 golang.org/x/net v0.5.0 ) require golang.org/x/text v0.6.0 // indirect terraform-registry-address-0.2.0/go.sum000066400000000000000000000012321440666607600201530ustar00rootroot00000000000000github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/terraform-svchost v0.0.1 h1:Zj6fR5wnpOHnJUmLyWozjMeDaVuE+cstMPj41/eKmSQ= github.com/hashicorp/terraform-svchost v0.0.1/go.mod h1:ut8JaH0vumgdCfJaihdcZULqkAwHdQNwNH7taIDdsZM= golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= terraform-registry-address-0.2.0/module.go000066400000000000000000000225201440666607600206370ustar00rootroot00000000000000package tfaddr import ( "fmt" "path" "regexp" "strings" svchost "github.com/hashicorp/terraform-svchost" ) // Module is representing a module listed in a Terraform module // registry. type Module struct { // Package is the registry package that the target module belongs to. // The module installer must translate this into a ModuleSourceRemote // using the registry API and then take that underlying address's // Package in order to find the actual package location. Package ModulePackage // If Subdir is non-empty then it represents a sub-directory within the // remote package that the registry address eventually resolves to. // This will ultimately become the suffix of the Subdir of the // ModuleSourceRemote that the registry address translates to. // // Subdir uses a normalized forward-slash-based path syntax within the // virtual filesystem represented by the final package. It will never // include `../` or `./` sequences. Subdir string } // DefaultModuleRegistryHost is the hostname used for registry-based module // source addresses that do not have an explicit hostname. const DefaultModuleRegistryHost = svchost.Hostname("registry.terraform.io") var moduleRegistryNamePattern = regexp.MustCompile("^[0-9A-Za-z](?:[0-9A-Za-z-_]{0,62}[0-9A-Za-z])?$") var moduleRegistryTargetSystemPattern = regexp.MustCompile("^[0-9a-z]{1,64}$") // ParseModuleSource only accepts module registry addresses, and // will reject any other address type. func ParseModuleSource(raw string) (Module, error) { var err error var subDir string raw, subDir = splitPackageSubdir(raw) if strings.HasPrefix(subDir, "../") { return Module{}, fmt.Errorf("subdirectory path %q leads outside of the module package", subDir) } parts := strings.Split(raw, "/") // A valid registry address has either three or four parts, because the // leading hostname part is optional. if len(parts) != 3 && len(parts) != 4 { return Module{}, fmt.Errorf("a module registry source address must have either three or four slash-separated components") } host := DefaultModuleRegistryHost if len(parts) == 4 { host, err = svchost.ForComparison(parts[0]) if err != nil { // The svchost library doesn't produce very good error messages to // return to an end-user, so we'll use some custom ones here. switch { case strings.Contains(parts[0], "--"): // Looks like possibly punycode, which we don't allow here // to ensure that source addresses are written readably. return Module{}, fmt.Errorf("invalid module registry hostname %q; internationalized domain names must be given as direct unicode characters, not in punycode", parts[0]) default: return Module{}, fmt.Errorf("invalid module registry hostname %q", parts[0]) } } if !strings.Contains(host.String(), ".") { return Module{}, fmt.Errorf("invalid module registry hostname: must contain at least one dot") } // Discard the hostname prefix now that we've processed it parts = parts[1:] } ret := Module{ Package: ModulePackage{ Host: host, }, Subdir: subDir, } if host == svchost.Hostname("github.com") || host == svchost.Hostname("bitbucket.org") { return ret, fmt.Errorf("can't use %q as a module registry host, because it's reserved for installing directly from version control repositories", host) } if ret.Package.Namespace, err = parseModuleRegistryName(parts[0]); err != nil { if strings.Contains(parts[0], ".") { // Seems like the user omitted one of the latter components in // an address with an explicit hostname. return ret, fmt.Errorf("source address must have three more components after the hostname: the namespace, the name, and the target system") } return ret, fmt.Errorf("invalid namespace %q: %s", parts[0], err) } if ret.Package.Name, err = parseModuleRegistryName(parts[1]); err != nil { return ret, fmt.Errorf("invalid module name %q: %s", parts[1], err) } if ret.Package.TargetSystem, err = parseModuleRegistryTargetSystem(parts[2]); err != nil { if strings.Contains(parts[2], "?") { // The user was trying to include a query string, probably? return ret, fmt.Errorf("module registry addresses may not include a query string portion") } return ret, fmt.Errorf("invalid target system %q: %s", parts[2], err) } return ret, nil } // MustParseModuleSource is a wrapper around ParseModuleSource that panics if // it returns an error. func MustParseModuleSource(raw string) (Module) { mod, err := ParseModuleSource(raw) if err != nil { panic(err) } return mod } // parseModuleRegistryName validates and normalizes a string in either the // "namespace" or "name" position of a module registry source address. func parseModuleRegistryName(given string) (string, error) { // Similar to the names in provider source addresses, we defined these // to be compatible with what filesystems and typical remote systems // like GitHub allow in names. Unfortunately we didn't end up defining // these exactly equivalently: provider names can only use dashes as // punctuation, whereas module names can use underscores. So here we're // using some regular expressions from the original module source // implementation, rather than using the IDNA rules as we do in // ParseProviderPart. if !moduleRegistryNamePattern.MatchString(given) { return "", fmt.Errorf("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") } // We also skip normalizing the name to lowercase, because we historically // didn't do that and so existing module registries might be doing // case-sensitive matching. return given, nil } // parseModuleRegistryTargetSystem validates and normalizes a string in the // "target system" position of a module registry source address. This is // what we historically called "provider" but never actually enforced as // being a provider address, and now _cannot_ be a provider address because // provider addresses have three slash-separated components of their own. func parseModuleRegistryTargetSystem(given string) (string, error) { // Similar to the names in provider source addresses, we defined these // to be compatible with what filesystems and typical remote systems // like GitHub allow in names. Unfortunately we didn't end up defining // these exactly equivalently: provider names can't use dashes or // underscores. So here we're using some regular expressions from the // original module source implementation, rather than using the IDNA rules // as we do in ParseProviderPart. if !moduleRegistryTargetSystemPattern.MatchString(given) { return "", fmt.Errorf("must be between one and 64 ASCII letters or digits") } // We also skip normalizing the name to lowercase, because we historically // didn't do that and so existing module registries might be doing // case-sensitive matching. return given, nil } // String returns a full representation of the address, including any // additional components that are typically implied by omission in // user-written addresses. // // We typically use this longer representation in error message, in case // the inclusion of normally-omitted components is helpful in debugging // unexpected behavior. func (s Module) String() string { if s.Subdir != "" { return s.Package.String() + "//" + s.Subdir } return s.Package.String() } // ForDisplay is similar to String but instead returns a representation of // the idiomatic way to write the address in configuration, omitting // components that are commonly just implied in addresses written by // users. // // We typically use this shorter representation in informational messages, // such as the note that we're about to start downloading a package. func (s Module) ForDisplay() string { if s.Subdir != "" { return s.Package.ForDisplay() + "//" + s.Subdir } return s.Package.ForDisplay() } // splitPackageSubdir detects whether the given address string has a // subdirectory portion, and if so returns a non-empty subDir string // along with the trimmed package address. // // If the given string doesn't have a subdirectory portion then it'll // just be returned verbatim in packageAddr, with an empty subDir value. func splitPackageSubdir(given string) (packageAddr, subDir string) { packageAddr, subDir = sourceDirSubdir(given) if subDir != "" { subDir = path.Clean(subDir) } return packageAddr, subDir } // sourceDirSubdir takes a source URL and returns a tuple of the URL without // the subdir and the subdir. // // ex: // dom.com/path/?q=p => dom.com/path/?q=p, "" // proto://dom.com/path//*?q=p => proto://dom.com/path?q=p, "*" // proto://dom.com/path//path2?q=p => proto://dom.com/path?q=p, "path2" func sourceDirSubdir(src string) (string, string) { // URL might contains another url in query parameters stop := len(src) if idx := strings.Index(src, "?"); idx > -1 { stop = idx } // Calculate an offset to avoid accidentally marking the scheme // as the dir. var offset int if idx := strings.Index(src[:stop], "://"); idx > -1 { offset = idx + 3 } // First see if we even have an explicit subdir idx := strings.Index(src[offset:stop], "//") if idx == -1 { return src, "" } idx += offset subdir := src[idx+2:] src = src[:idx] // Next, check if we have query parameters and push them onto the // URL. if idx = strings.Index(subdir, "?"); idx > -1 { query := subdir[idx:] subdir = subdir[:idx] src += query } return src, subdir } terraform-registry-address-0.2.0/module_package.go000066400000000000000000000041601440666607600223120ustar00rootroot00000000000000package tfaddr import ( "strings" svchost "github.com/hashicorp/terraform-svchost" ) // A ModulePackage is an extra indirection over a ModulePackage where // we use a module registry to translate a more symbolic address (and // associated version constraint given out of band) into a physical source // location. // // ModulePackage is distinct from ModulePackage because they have // disjoint use-cases: registry package addresses are only used to query a // registry in order to find a real module package address. These being // distinct is intended to help future maintainers more easily follow the // series of steps in the module installer, with the help of the type checker. type ModulePackage struct { Host svchost.Hostname Namespace string Name string TargetSystem string } func (s ModulePackage) String() string { // Note: we're using the "display" form of the hostname here because // for our service hostnames "for display" means something different: // it means to render non-ASCII characters directly as Unicode // characters, rather than using the "punycode" representation we // use for internal processing, and so the "display" representation // is actually what users would write in their configurations. return s.Host.ForDisplay() + "/" + s.ForRegistryProtocol() } func (s ModulePackage) ForDisplay() string { if s.Host == DefaultModuleRegistryHost { return s.ForRegistryProtocol() } return s.Host.ForDisplay() + "/" + s.ForRegistryProtocol() } // ForRegistryProtocol returns a string representation of just the namespace, // name, and target system portions of the address, always omitting the // registry hostname and the subdirectory portion, if any. // // This is primarily intended for generating addresses to send to the // registry in question via the registry protocol, since the protocol // skips sending the registry its own hostname as part of identifiers. func (s ModulePackage) ForRegistryProtocol() string { var buf strings.Builder buf.WriteString(s.Namespace) buf.WriteByte('/') buf.WriteString(s.Name) buf.WriteByte('/') buf.WriteString(s.TargetSystem) return buf.String() } terraform-registry-address-0.2.0/module_test.go000066400000000000000000000215671440666607600217100ustar00rootroot00000000000000package tfaddr import ( "fmt" "log" "testing" "github.com/google/go-cmp/cmp" svchost "github.com/hashicorp/terraform-svchost" ) func TestParseModuleSource_simple(t *testing.T) { tests := map[string]struct { input string want Module wantErr string }{ "main registry implied": { input: "hashicorp/subnets/cidr", want: Module{ Package: ModulePackage{ Host: svchost.Hostname("registry.terraform.io"), Namespace: "hashicorp", Name: "subnets", TargetSystem: "cidr", }, Subdir: "", }, }, "main registry implied, subdir": { input: "hashicorp/subnets/cidr//examples/foo", want: Module{ Package: ModulePackage{ Host: svchost.Hostname("registry.terraform.io"), Namespace: "hashicorp", Name: "subnets", TargetSystem: "cidr", }, Subdir: "examples/foo", }, }, "custom registry": { input: "example.com/awesomecorp/network/happycloud", want: Module{ Package: ModulePackage{ Host: svchost.Hostname("example.com"), Namespace: "awesomecorp", Name: "network", TargetSystem: "happycloud", }, Subdir: "", }, }, "custom registry, subdir": { input: "example.com/awesomecorp/network/happycloud//examples/foo", want: Module{ Package: ModulePackage{ Host: svchost.Hostname("example.com"), Namespace: "awesomecorp", Name: "network", TargetSystem: "happycloud", }, Subdir: "examples/foo", }, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { addr, err := ParseModuleSource(test.input) if test.wantErr != "" { switch { case err == nil: t.Errorf("unexpected success\nwant error: %s", test.wantErr) case err.Error() != test.wantErr: t.Errorf("wrong error messages\ngot: %s\nwant: %s", err.Error(), test.wantErr) } return } if err != nil { t.Fatalf("unexpected error: %s", err.Error()) } if diff := cmp.Diff(addr, test.want); diff != "" { t.Errorf("wrong result\n%s", diff) } }) } } func TestParseModuleSource(t *testing.T) { tests := map[string]struct { input string wantString string wantForDisplay string wantForProtocol string wantErr string }{ "public registry": { input: `hashicorp/consul/aws`, wantString: `registry.terraform.io/hashicorp/consul/aws`, wantForDisplay: `hashicorp/consul/aws`, wantForProtocol: `hashicorp/consul/aws`, }, "public registry with subdir": { input: `hashicorp/consul/aws//foo`, wantString: `registry.terraform.io/hashicorp/consul/aws//foo`, wantForDisplay: `hashicorp/consul/aws//foo`, wantForProtocol: `hashicorp/consul/aws`, }, "public registry using explicit hostname": { input: `registry.terraform.io/hashicorp/consul/aws`, wantString: `registry.terraform.io/hashicorp/consul/aws`, wantForDisplay: `hashicorp/consul/aws`, wantForProtocol: `hashicorp/consul/aws`, }, "public registry with mixed case names": { input: `HashiCorp/Consul/aws`, wantString: `registry.terraform.io/HashiCorp/Consul/aws`, wantForDisplay: `HashiCorp/Consul/aws`, wantForProtocol: `HashiCorp/Consul/aws`, }, "private registry with non-standard port": { input: `Example.com:1234/HashiCorp/Consul/aws`, wantString: `example.com:1234/HashiCorp/Consul/aws`, wantForDisplay: `example.com:1234/HashiCorp/Consul/aws`, wantForProtocol: `HashiCorp/Consul/aws`, }, "private registry with IDN hostname": { input: `Испытание.com/HashiCorp/Consul/aws`, wantString: `испытание.com/HashiCorp/Consul/aws`, wantForDisplay: `испытание.com/HashiCorp/Consul/aws`, wantForProtocol: `HashiCorp/Consul/aws`, }, "private registry with IDN hostname and non-standard port": { input: `Испытание.com:1234/HashiCorp/Consul/aws//Foo`, wantString: `испытание.com:1234/HashiCorp/Consul/aws//Foo`, wantForDisplay: `испытание.com:1234/HashiCorp/Consul/aws//Foo`, wantForProtocol: `HashiCorp/Consul/aws`, }, "invalid hostname": { input: `---.com/HashiCorp/Consul/aws`, wantErr: `invalid module registry hostname "---.com"; internationalized domain names must be given as direct unicode characters, not in punycode`, }, "hostname with only one label": { // This was historically forbidden in our initial implementation, // so we keep it forbidden to avoid newly interpreting such // addresses as registry addresses rather than remote source // addresses. input: `foo/var/baz/qux`, wantErr: `invalid module registry hostname: must contain at least one dot`, }, "invalid target system characters": { input: `foo/var/no-no-no`, wantErr: `invalid target system "no-no-no": must be between one and 64 ASCII letters or digits`, }, "invalid target system length": { input: `foo/var/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaah`, wantErr: `invalid target system "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaah": must be between one and 64 ASCII letters or digits`, }, "invalid namespace": { input: `boop!/var/baz`, 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`, }, "missing part with explicit hostname": { input: `foo.com/var/baz`, wantErr: `source address must have three more components after the hostname: the namespace, the name, and the target system`, }, "errant query string": { input: `foo/var/baz?otherthing`, wantErr: `module registry addresses may not include a query string portion`, }, "github.com": { // We don't allow using github.com like a module registry because // that conflicts with the historically-supported shorthand for // installing directly from GitHub-hosted git repositories. input: `github.com/HashiCorp/Consul/aws`, wantErr: `can't use "github.com" as a module registry host, because it's reserved for installing directly from version control repositories`, }, "bitbucket.org": { // We don't allow using bitbucket.org like a module registry because // that conflicts with the historically-supported shorthand for // installing directly from BitBucket-hosted git repositories. input: `bitbucket.org/HashiCorp/Consul/aws`, wantErr: `can't use "bitbucket.org" as a module registry host, because it's reserved for installing directly from version control repositories`, }, "local path from current dir": { // Can't use a local path when we're specifically trying to parse // a _registry_ source address. input: `./boop`, wantErr: `a module registry source address must have either three or four slash-separated components`, }, "local path from parent dir": { // Can't use a local path when we're specifically trying to parse // a _registry_ source address. input: `../boop`, wantErr: `a module registry source address must have either three or four slash-separated components`, }, "main registry implied, escaping subdir": { input: "hashicorp/subnets/cidr//../nope", wantErr: `subdirectory path "../nope" leads outside of the module package`, }, "relative path without the needed prefix": { input: "boop/bloop", wantErr: "a module registry source address must have either three or four slash-separated components", }, } for name, test := range tests { t.Run(name, func(t *testing.T) { addr, err := ParseModuleSource(test.input) if test.wantErr != "" { switch { case err == nil: t.Errorf("unexpected success\nwant error: %s", test.wantErr) case err.Error() != test.wantErr: t.Errorf("wrong error messages\ngot: %s\nwant: %s", err.Error(), test.wantErr) } return } if err != nil { t.Fatalf("unexpected error: %s", err.Error()) } if got, want := addr.String(), test.wantString; got != want { t.Errorf("wrong String() result\ngot: %s\nwant: %s", got, want) } if got, want := addr.ForDisplay(), test.wantForDisplay; got != want { t.Errorf("wrong ForDisplay() result\ngot: %s\nwant: %s", got, want) } if got, want := addr.Package.ForRegistryProtocol(), test.wantForProtocol; got != want { t.Errorf("wrong ForRegistryProtocol() result\ngot: %s\nwant: %s", got, want) } }) } } func ExampleParseModuleSource() { mAddr, err := ParseModuleSource("hashicorp/consul/aws//modules/consul-cluster") if err != nil { log.Fatal(err) } fmt.Printf("%#v", mAddr) // Output: tfaddr.Module{Package:tfaddr.ModulePackage{Host:svchost.Hostname("registry.terraform.io"), Namespace:"hashicorp", Name:"consul", TargetSystem:"aws"}, Subdir:"modules/consul-cluster"} } terraform-registry-address-0.2.0/provider.go000066400000000000000000000412621440666607600212100ustar00rootroot00000000000000package tfaddr import ( "fmt" "strings" svchost "github.com/hashicorp/terraform-svchost" "golang.org/x/net/idna" ) // 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 } // DefaultProviderRegistryHost is the hostname used for provider addresses that do // not have an explicit hostname. const DefaultProviderRegistryHost = 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" // UnknownProviderNamespace is the special string used to indicate // unknown namespace, e.g. in "aws". This is equivalent to // LegacyProviderNamespace for <0.12 style address. This namespace // would never be produced by Terraform itself explicitly, it is // only an internal placeholder. const UnknownProviderNamespace = "?" // 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 // DefaultProviderRegistryHost because that host owns the mapping from legacy name to // FQN. This may be produced by Terraform 0.13. 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 == DefaultProviderRegistryHost { 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 struct panic("attempt to create legacy provider address using NewProvider; use Provider{} instead") } if namespace == UnknownProviderNamespace { // Provider addresses with unknown namespace must always // be created via struct panic("attempt to create provider address with unknown namespace using NewProvider; use Provider{} instead") } if namespace == "" { // This case is already handled by MustParseProviderPart() below, // but we catch it early to provide more helpful message. panic("attempt to create provider address with empty namespace") } return Provider{ Type: MustParseProviderPart(typeName), Namespace: MustParseProviderPart(namespace), Hostname: hostname, } } // 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 && pt.Namespace != BuiltInProviderNamespace { panic(pt.String() + " cannot be represented as a legacy string") } 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{} } // HasKnownNamespace returns true if the provider namespace is known // (also if it is legacy namespace) func (pt Provider) HasKnownNamespace() bool { return pt.Namespace != UnknownProviderNamespace } // 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 == DefaultProviderRegistryHost && pt.Namespace == LegacyProviderNamespace } // Equals returns true if the receiver and other provider have the same attributes. func (pt Provider) Equals(other Provider) bool { return pt == other } // ParseProviderSource 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 // // "name"-only format is parsed as -/name (i.e. legacy namespace) // requiring further identification of the namespace via Registry API func ParseProviderSource(str string) (Provider, error) { var ret Provider parts, err := parseSourceStringParts(str) if err != nil { return ret, err } name := parts[len(parts)-1] ret.Type = name ret.Hostname = DefaultProviderRegistryHost if len(parts) == 1 { return Provider{ Hostname: DefaultProviderRegistryHost, Namespace: UnknownProviderNamespace, Type: name, }, nil } 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 { return Provider{}, &ParserError{ Summary: "Invalid provider namespace", Detail: fmt.Sprintf(`Invalid provider namespace %q in source %q: %s"`, namespace, str, err), } } 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 { return Provider{}, &ParserError{ Summary: "Invalid provider source hostname", Detail: fmt.Sprintf(`Invalid provider source hostname namespace %q in source %q: %s"`, hn, str, err), } } ret.Hostname = hn } if ret.Namespace == LegacyProviderNamespace && ret.Hostname != DefaultProviderRegistryHost { // Legacy provider addresses must always be on the default registry // host, because the default registry host decides what actual FQN // each one maps to. return Provider{}, &ParserError{ Summary: "Invalid provider namespace", Detail: "The legacy provider namespace \"-\" can be used only with hostname " + DefaultProviderRegistryHost.ForDisplay() + ".", } } // 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 return Provider{}, &ParserError{ Summary: "Invalid provider type", Detail: 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()), } } } // 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. return Provider{}, &ParserError{ Summary: "Invalid provider type", Detail: 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 ret, nil } // MustParseProviderSource is a wrapper around ParseProviderSource that panics if // it returns an error. func MustParseProviderSource(raw string) (Provider) { p, err := ParseProviderSource(raw) if err != nil { panic(err) } return p } // ValidateProviderAddress returns error if the given address is not FQN, // that is if it is missing any of the three components from // hostname/namespace/name. func ValidateProviderAddress(raw string) error { parts, err := parseSourceStringParts(raw) if err != nil { return err } if len(parts) != 3 { return &ParserError{ Summary: "Invalid provider address format", Detail: `Expected FQN in the format "hostname/namespace/name"`, } } p, err := ParseProviderSource(raw) if err != nil { return err } if !p.HasKnownNamespace() { return &ParserError{ Summary: "Unknown provider namespace", Detail: `Expected FQN in the format "hostname/namespace/name"`, } } if !p.IsLegacy() { return &ParserError{ Summary: "Invalid legacy provider namespace", Detail: `Expected FQN in the format "hostname/namespace/name"`, } } return nil } func parseSourceStringParts(str string) ([]string, error) { // split the source string into individual components parts := strings.Split(str, "/") if len(parts) == 0 || len(parts) > 3 { return nil, &ParserError{ Summary: "Invalid provider source string", Detail: `The "source" attribute must be in the format "[hostname/][namespace/]name"`, } } // check for an invalid empty string in any part for i := range parts { if parts[i] == "" { return nil, &ParserError{ Summary: "Invalid provider source string", Detail: `The "source" attribute must be in the format "[hostname/][namespace/]name"`, } } } // check the 'name' portion, which is always the last part givenName := parts[len(parts)-1] name, err := ParseProviderPart(givenName) if err != nil { return nil, &ParserError{ Summary: "Invalid provider type", Detail: fmt.Sprintf(`Invalid provider type %q in source %q: %s"`, givenName, str, err), } } parts[len(parts)-1] = name return parts, nil } // 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 } terraform-registry-address-0.2.0/provider_test.go000066400000000000000000000261671440666607600222560ustar00rootroot00000000000000package tfaddr import ( "fmt" "log" "testing" "github.com/google/go-cmp/cmp" svchost "github.com/hashicorp/terraform-svchost" ) func TestProviderString(t *testing.T) { tests := []struct { Input Provider Want string }{ { Provider{ Type: "test", Hostname: DefaultProviderRegistryHost, Namespace: "hashicorp", }, NewProvider(DefaultProviderRegistryHost, "hashicorp", "test").String(), }, { Provider{ Type: "test-beta", Hostname: DefaultProviderRegistryHost, Namespace: "hashicorp", }, NewProvider(DefaultProviderRegistryHost, "hashicorp", "test-beta").String(), }, { Provider{ Type: "test", Hostname: "registry.terraform.com", Namespace: "hashicorp", }, "registry.terraform.com/hashicorp/test", }, { Provider{ Type: "test", Hostname: DefaultProviderRegistryHost, Namespace: "othercorp", }, DefaultProviderRegistryHost.ForDisplay() + "/othercorp/test", }, } for _, test := range tests { got := test.Input.String() if got != test.Want { t.Errorf("wrong result for %s\n", test.Input.String()) } } } func TestProviderLegacyString(t *testing.T) { tests := []struct { Input Provider Want string }{ { Provider{ Type: "test", Hostname: DefaultProviderRegistryHost, Namespace: LegacyProviderNamespace, }, "test", }, { Provider{ Type: "terraform", Hostname: BuiltInProviderHost, Namespace: BuiltInProviderNamespace, }, "terraform", }, } for _, test := range tests { got := test.Input.LegacyString() if got != test.Want { t.Errorf("wrong result for %s\ngot: %s\nwant: %s", test.Input.String(), got, test.Want) } } } func TestProviderDisplay(t *testing.T) { tests := []struct { Input Provider Want string }{ { Provider{ Type: "test", Hostname: DefaultProviderRegistryHost, Namespace: "hashicorp", }, "hashicorp/test", }, { Provider{ Type: "test", Hostname: "registry.terraform.com", Namespace: "hashicorp", }, "registry.terraform.com/hashicorp/test", }, { Provider{ Type: "test", Hostname: DefaultProviderRegistryHost, Namespace: "othercorp", }, "othercorp/test", }, { Provider{ Type: "terraform", Namespace: BuiltInProviderNamespace, Hostname: BuiltInProviderHost, }, "terraform.io/builtin/terraform", }, } for _, test := range tests { got := test.Input.ForDisplay() if got != test.Want { t.Errorf("wrong result for %s: %q\n", test.Input.String(), got) } } } func TestProviderIsBuiltIn(t *testing.T) { tests := []struct { Input Provider Want bool }{ { Provider{ Type: "test", Hostname: BuiltInProviderHost, Namespace: BuiltInProviderNamespace, }, true, }, { Provider{ Type: "terraform", Hostname: BuiltInProviderHost, Namespace: BuiltInProviderNamespace, }, true, }, { Provider{ Type: "test", Hostname: BuiltInProviderHost, Namespace: "boop", }, false, }, { Provider{ Type: "test", Hostname: DefaultProviderRegistryHost, Namespace: BuiltInProviderNamespace, }, false, }, { Provider{ Type: "test", Hostname: DefaultProviderRegistryHost, Namespace: "hashicorp", }, false, }, { Provider{ Type: "test", Hostname: "registry.terraform.com", Namespace: "hashicorp", }, false, }, { Provider{ Type: "test", Hostname: DefaultProviderRegistryHost, Namespace: "othercorp", }, false, }, } for _, test := range tests { got := test.Input.IsBuiltIn() if got != test.Want { t.Errorf("wrong result for %s\ngot: %#v\nwant: %#v", test.Input.String(), got, test.Want) } } } func TestProviderIsLegacy(t *testing.T) { tests := []struct { Input Provider Want bool }{ { Provider{ Type: "test", Hostname: DefaultProviderRegistryHost, Namespace: LegacyProviderNamespace, }, true, }, { Provider{ Type: "test", Hostname: "registry.terraform.com", Namespace: LegacyProviderNamespace, }, false, }, { Provider{ Type: "test", Hostname: DefaultProviderRegistryHost, Namespace: "hashicorp", }, false, }, } for _, test := range tests { got := test.Input.IsLegacy() if got != test.Want { t.Errorf("wrong result for %s\n", test.Input.String()) } } } func ExampleParseProviderSource() { pAddr, err := ParseProviderSource("hashicorp/aws") if err != nil { log.Fatal(err) } fmt.Printf("%#v", pAddr) // Output: tfaddr.Provider{Type:"aws", Namespace:"hashicorp", Hostname:svchost.Hostname("registry.terraform.io")} } func TestParseProviderSource(t *testing.T) { tests := map[string]struct { Want Provider Err bool }{ "registry.terraform.io/hashicorp/aws": { Provider{ Type: "aws", Namespace: "hashicorp", Hostname: DefaultProviderRegistryHost, }, false, }, "registry.Terraform.io/HashiCorp/AWS": { Provider{ Type: "aws", Namespace: "hashicorp", Hostname: DefaultProviderRegistryHost, }, false, }, "terraform.io/builtin/terraform": { Provider{ Type: "terraform", Namespace: BuiltInProviderNamespace, Hostname: BuiltInProviderHost, }, false, }, // v0.12 representation // In most cases this would *likely* be the same 'terraform' provider // we otherwise represent as builtin, but we cannot be sure // in the context of the source string alone. "terraform": { Provider{ Type: "terraform", Namespace: UnknownProviderNamespace, Hostname: DefaultProviderRegistryHost, }, false, }, "hashicorp/aws": { Provider{ Type: "aws", Namespace: "hashicorp", Hostname: DefaultProviderRegistryHost, }, false, }, "HashiCorp/AWS": { Provider{ Type: "aws", Namespace: "hashicorp", Hostname: DefaultProviderRegistryHost, }, false, }, "aws": { Provider{ Type: "aws", Namespace: UnknownProviderNamespace, Hostname: DefaultProviderRegistryHost, }, false, }, "AWS": { Provider{ Type: "aws", Namespace: UnknownProviderNamespace, Hostname: DefaultProviderRegistryHost, }, false, }, "example.com/foo-bar/baz-boop": { Provider{ Type: "baz-boop", Namespace: "foo-bar", Hostname: svchost.Hostname("example.com"), }, false, }, "foo-bar/baz-boop": { Provider{ Type: "baz-boop", Namespace: "foo-bar", Hostname: DefaultProviderRegistryHost, }, false, }, "localhost:8080/foo/bar": { Provider{ Type: "bar", Namespace: "foo", Hostname: svchost.Hostname("localhost:8080"), }, false, }, "example.com/too/many/parts/here": { Provider{}, true, }, "/too///many//slashes": { Provider{}, true, }, "///": { Provider{}, true, }, "/ / /": { // empty strings Provider{}, true, }, "badhost!/hashicorp/aws": { Provider{}, true, }, "example.com/badnamespace!/aws": { Provider{}, true, }, "example.com/bad--namespace/aws": { Provider{}, true, }, "example.com/-badnamespace/aws": { Provider{}, true, }, "example.com/badnamespace-/aws": { Provider{}, true, }, "example.com/bad.namespace/aws": { Provider{}, true, }, "example.com/hashicorp/badtype!": { Provider{}, true, }, "example.com/hashicorp/bad--type": { Provider{}, true, }, "example.com/hashicorp/-badtype": { Provider{}, true, }, "example.com/hashicorp/badtype-": { Provider{}, true, }, "example.com/hashicorp/bad.type": { Provider{}, true, }, // We forbid the terraform- prefix both because it's redundant to // include "terraform" in a Terraform provider name and because we use // the longer prefix terraform-provider- to hint for users who might be // accidentally using the git repository name or executable file name // instead of the provider type. "example.com/hashicorp/terraform-provider-bad": { Provider{}, true, }, "example.com/hashicorp/terraform-bad": { Provider{}, true, }, } for name, test := range tests { got, err := ParseProviderSource(name) if diff := cmp.Diff(test.Want, got); diff != "" { t.Errorf("mismatch (%q): %s", name, diff) } if err != nil { if test.Err == false { t.Errorf("got error: %s, expected success", err) } } else { if test.Err { t.Errorf("got success, expected error") } } } } func TestParseProviderPart(t *testing.T) { tests := map[string]struct { Want string Error string }{ `foo`: { `foo`, ``, }, `FOO`: { `foo`, ``, }, `Foo`: { `foo`, ``, }, `abc-123`: { `abc-123`, ``, }, `Испытание`: { `испытание`, ``, }, `münchen`: { // this is a precomposed u with diaeresis `münchen`, // this is a precomposed u with diaeresis ``, }, `münchen`: { // this is a separate u and combining diaeresis `münchen`, // this is a precomposed u with diaeresis ``, }, `abc--123`: { ``, `cannot use multiple consecutive dashes`, }, `xn--80akhbyknj4f`: { // this is the punycode form of "испытание", but we don't accept punycode here ``, `cannot use multiple consecutive dashes`, }, `abc.123`: { ``, `dots are not allowed`, }, `-abc123`: { ``, `must contain only letters, digits, and dashes, and may not use leading or trailing dashes`, }, `abc123-`: { ``, `must contain only letters, digits, and dashes, and may not use leading or trailing dashes`, }, ``: { ``, `must have at least one character`, }, } for given, test := range tests { t.Run(given, func(t *testing.T) { got, err := ParseProviderPart(given) if test.Error != "" { if err == nil { t.Errorf("unexpected success\ngot: %s\nwant: %s", err, test.Error) } else if got := err.Error(); got != test.Error { t.Errorf("wrong error\ngot: %s\nwant: %s", got, test.Error) } } else { if err != nil { t.Errorf("unexpected error\ngot: %s\nwant: ", err) } else if got != test.Want { t.Errorf("wrong result\ngot: %s\nwant: %s", got, test.Want) } } }) } } func TestProviderEquals(t *testing.T) { tests := []struct { InputP Provider OtherP Provider Want bool }{ { NewProvider(DefaultProviderRegistryHost, "foo", "test"), NewProvider(DefaultProviderRegistryHost, "foo", "test"), true, }, { NewProvider(DefaultProviderRegistryHost, "foo", "test"), NewProvider(DefaultProviderRegistryHost, "bar", "test"), false, }, { NewProvider(DefaultProviderRegistryHost, "foo", "test"), NewProvider(DefaultProviderRegistryHost, "foo", "my-test"), false, }, { NewProvider(DefaultProviderRegistryHost, "foo", "test"), NewProvider("example.com", "foo", "test"), false, }, } for _, test := range tests { t.Run(test.InputP.String(), func(t *testing.T) { got := test.InputP.Equals(test.OtherP) if got != test.Want { t.Errorf("wrong result\ngot: %v\nwant: %v", got, test.Want) } }) } } func TestValidateProviderAddress(t *testing.T) { t.Skip("TODO") }