├── .copywrite.hcl ├── .github ├── CODEOWNERS ├── dependabot.yml ├── pull_request_template.md └── workflows │ └── ci.yml ├── .go-version ├── LICENSE ├── README.md ├── errors.go ├── go.mod ├── go.sum ├── module.go ├── module_package.go ├── module_test.go ├── provider.go └── provider_test.go /.copywrite.hcl: -------------------------------------------------------------------------------- 1 | schema_version = 1 2 | 3 | project { 4 | license = "MPL-2.0" 5 | copyright_year = 2021 6 | header_ignore = [] 7 | } 8 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This codebase has shared ownership and responsibility. 2 | 3 | * @hashicorp/terraform-core @hashicorp/terraform-devex @hashicorp/tf-editor-experience-engineers 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | labels: ["dependencies"] 8 | - package-ecosystem: github-actions 9 | directory: / 10 | schedule: 11 | interval: weekly 12 | labels: 13 | - dependencies 14 | groups: 15 | github-actions-breaking: 16 | update-types: 17 | - major 18 | github-actions-backward-compatible: 19 | update-types: 20 | - minor 21 | - patch 22 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Related Issue 2 | 3 | Fixes # 4 | 5 | ## Description 6 | 7 | In plain English, describe your approach to addressing the issue linked above. For example, if you made a particular design decision, let us know why you chose this path instead of another solution. 8 | 9 | 10 | ## Rollback Plan 11 | 12 | - [ ] If a change needs to be reverted, we will roll out an update to the code within 7 days. 13 | 14 | ## Changes to Security Controls 15 | 16 | Are there any changes to security controls (access controls, encryption, logging) in this pull request? If so, explain. 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | env: 12 | GOPROXY: https://proxy.golang.org/ 13 | 14 | jobs: 15 | copywrite: 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 3 18 | steps: 19 | - name: Checkout Repo 20 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 21 | - name: Install copywrite 22 | uses: hashicorp/setup-copywrite@32638da2d4e81d56a0764aa1547882fc4d209636 # v1.1.3 23 | - name: Validate Header Compliance 24 | run: copywrite headers --plan 25 | 26 | test: 27 | runs-on: ${{ matrix.os }} 28 | strategy: 29 | matrix: 30 | os: 31 | - ubuntu-latest 32 | - windows-latest 33 | - macos-latest 34 | go: 35 | - '1.23' 36 | - '1.24' 37 | steps: 38 | - 39 | name: Checkout 40 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 41 | - 42 | name: Unshallow 43 | run: git fetch --prune --unshallow 44 | - 45 | name: Set up Go 46 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 47 | with: 48 | go-version: ${{ matrix.go }} 49 | - 50 | name: Go mod download 51 | run: go mod download -x 52 | - 53 | name: Go mod verify 54 | run: go mod verify 55 | - 56 | name: Run tests 57 | run: go test -v ./... 58 | -------------------------------------------------------------------------------- /.go-version: -------------------------------------------------------------------------------- 1 | 1.19 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 HashiCorp, Inc. 2 | 3 | Mozilla Public License, version 2.0 4 | 5 | 1. Definitions 6 | 7 | 1.1. “Contributor” 8 | 9 | means each individual or legal entity that creates, contributes to the 10 | creation of, or owns Covered Software. 11 | 12 | 1.2. “Contributor Version” 13 | 14 | means the combination of the Contributions of others (if any) used by a 15 | Contributor and that particular Contributor’s Contribution. 16 | 17 | 1.3. “Contribution” 18 | 19 | means Covered Software of a particular Contributor. 20 | 21 | 1.4. “Covered Software” 22 | 23 | means Source Code Form to which the initial Contributor has attached the 24 | notice in Exhibit A, the Executable Form of such Source Code Form, and 25 | Modifications of such Source Code Form, in each case including portions 26 | thereof. 27 | 28 | 1.5. “Incompatible With Secondary Licenses” 29 | means 30 | 31 | a. that the initial Contributor has attached the notice described in 32 | Exhibit B to the Covered Software; or 33 | 34 | b. that the Covered Software was made available under the terms of version 35 | 1.1 or earlier of the License, but not also under the terms of a 36 | Secondary License. 37 | 38 | 1.6. “Executable Form” 39 | 40 | means any form of the work other than Source Code Form. 41 | 42 | 1.7. “Larger Work” 43 | 44 | means a work that combines Covered Software with other material, in a separate 45 | file or files, that is not Covered Software. 46 | 47 | 1.8. “License” 48 | 49 | means this document. 50 | 51 | 1.9. “Licensable” 52 | 53 | means having the right to grant, to the maximum extent possible, whether at the 54 | time of the initial grant or subsequently, any and all of the rights conveyed by 55 | this License. 56 | 57 | 1.10. “Modifications” 58 | 59 | means any of the following: 60 | 61 | a. any file in Source Code Form that results from an addition to, deletion 62 | from, or modification of the contents of Covered Software; or 63 | 64 | b. any new file in Source Code Form that contains any Covered Software. 65 | 66 | 1.11. “Patent Claims” of a Contributor 67 | 68 | means any patent claim(s), including without limitation, method, process, 69 | and apparatus claims, in any patent Licensable by such Contributor that 70 | would be infringed, but for the grant of the License, by the making, 71 | using, selling, offering for sale, having made, import, or transfer of 72 | either its Contributions or its Contributor Version. 73 | 74 | 1.12. “Secondary License” 75 | 76 | means either the GNU General Public License, Version 2.0, the GNU Lesser 77 | General Public License, Version 2.1, the GNU Affero General Public 78 | License, Version 3.0, or any later versions of those licenses. 79 | 80 | 1.13. “Source Code Form” 81 | 82 | means the form of the work preferred for making modifications. 83 | 84 | 1.14. “You” (or “Your”) 85 | 86 | means an individual or a legal entity exercising rights under this 87 | License. For legal entities, “You” includes any entity that controls, is 88 | controlled by, or is under common control with You. For purposes of this 89 | definition, “control” means (a) the power, direct or indirect, to cause 90 | the direction or management of such entity, whether by contract or 91 | otherwise, or (b) ownership of more than fifty percent (50%) of the 92 | outstanding shares or beneficial ownership of such entity. 93 | 94 | 95 | 2. License Grants and Conditions 96 | 97 | 2.1. Grants 98 | 99 | Each Contributor hereby grants You a world-wide, royalty-free, 100 | non-exclusive license: 101 | 102 | a. under intellectual property rights (other than patent or trademark) 103 | Licensable by such Contributor to use, reproduce, make available, 104 | modify, display, perform, distribute, and otherwise exploit its 105 | Contributions, either on an unmodified basis, with Modifications, or as 106 | part of a Larger Work; and 107 | 108 | b. under Patent Claims of such Contributor to make, use, sell, offer for 109 | sale, have made, import, and otherwise transfer either its Contributions 110 | or its Contributor Version. 111 | 112 | 2.2. Effective Date 113 | 114 | The licenses granted in Section 2.1 with respect to any Contribution become 115 | effective for each Contribution on the date the Contributor first distributes 116 | such Contribution. 117 | 118 | 2.3. Limitations on Grant Scope 119 | 120 | The licenses granted in this Section 2 are the only rights granted under this 121 | License. No additional rights or licenses will be implied from the distribution 122 | or licensing of Covered Software under this License. Notwithstanding Section 123 | 2.1(b) above, no patent license is granted by a Contributor: 124 | 125 | a. for any code that a Contributor has removed from Covered Software; or 126 | 127 | b. for infringements caused by: (i) Your and any other third party’s 128 | modifications of Covered Software, or (ii) the combination of its 129 | Contributions with other software (except as part of its Contributor 130 | Version); or 131 | 132 | c. under Patent Claims infringed by Covered Software in the absence of its 133 | Contributions. 134 | 135 | This License does not grant any rights in the trademarks, service marks, or 136 | logos of any Contributor (except as may be necessary to comply with the 137 | notice requirements in Section 3.4). 138 | 139 | 2.4. Subsequent Licenses 140 | 141 | No Contributor makes additional grants as a result of Your choice to 142 | distribute the Covered Software under a subsequent version of this License 143 | (see Section 10.2) or under the terms of a Secondary License (if permitted 144 | under the terms of Section 3.3). 145 | 146 | 2.5. Representation 147 | 148 | Each Contributor represents that the Contributor believes its Contributions 149 | are its original creation(s) or it has sufficient rights to grant the 150 | rights to its Contributions conveyed by this License. 151 | 152 | 2.6. Fair Use 153 | 154 | This License is not intended to limit any rights You have under applicable 155 | copyright doctrines of fair use, fair dealing, or other equivalents. 156 | 157 | 2.7. Conditions 158 | 159 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in 160 | Section 2.1. 161 | 162 | 163 | 3. Responsibilities 164 | 165 | 3.1. Distribution of Source Form 166 | 167 | All distribution of Covered Software in Source Code Form, including any 168 | Modifications that You create or to which You contribute, must be under the 169 | terms of this License. You must inform recipients that the Source Code Form 170 | of the Covered Software is governed by the terms of this License, and how 171 | they can obtain a copy of this License. You may not attempt to alter or 172 | restrict the recipients’ rights in the Source Code Form. 173 | 174 | 3.2. Distribution of Executable Form 175 | 176 | If You distribute Covered Software in Executable Form then: 177 | 178 | a. such Covered Software must also be made available in Source Code Form, 179 | as described in Section 3.1, and You must inform recipients of the 180 | Executable Form how they can obtain a copy of such Source Code Form by 181 | reasonable means in a timely manner, at a charge no more than the cost 182 | of distribution to the recipient; and 183 | 184 | b. You may distribute such Executable Form under the terms of this License, 185 | or sublicense it under different terms, provided that the license for 186 | the Executable Form does not attempt to limit or alter the recipients’ 187 | rights in the Source Code Form under this License. 188 | 189 | 3.3. Distribution of a Larger Work 190 | 191 | You may create and distribute a Larger Work under terms of Your choice, 192 | provided that You also comply with the requirements of this License for the 193 | Covered Software. If the Larger Work is a combination of Covered Software 194 | with a work governed by one or more Secondary Licenses, and the Covered 195 | Software is not Incompatible With Secondary Licenses, this License permits 196 | You to additionally distribute such Covered Software under the terms of 197 | such Secondary License(s), so that the recipient of the Larger Work may, at 198 | their option, further distribute the Covered Software under the terms of 199 | either this License or such Secondary License(s). 200 | 201 | 3.4. Notices 202 | 203 | You may not remove or alter the substance of any license notices (including 204 | copyright notices, patent notices, disclaimers of warranty, or limitations 205 | of liability) contained within the Source Code Form of the Covered 206 | Software, except that You may alter any license notices to the extent 207 | required to remedy known factual inaccuracies. 208 | 209 | 3.5. Application of Additional Terms 210 | 211 | You may choose to offer, and to charge a fee for, warranty, support, 212 | indemnity or liability obligations to one or more recipients of Covered 213 | Software. However, You may do so only on Your own behalf, and not on behalf 214 | of any Contributor. You must make it absolutely clear that any such 215 | warranty, support, indemnity, or liability obligation is offered by You 216 | alone, and You hereby agree to indemnify every Contributor for any 217 | liability incurred by such Contributor as a result of warranty, support, 218 | indemnity or liability terms You offer. You may include additional 219 | disclaimers of warranty and limitations of liability specific to any 220 | jurisdiction. 221 | 222 | 4. Inability to Comply Due to Statute or Regulation 223 | 224 | If it is impossible for You to comply with any of the terms of this License 225 | with respect to some or all of the Covered Software due to statute, judicial 226 | order, or regulation then You must: (a) comply with the terms of this License 227 | to the maximum extent possible; and (b) describe the limitations and the code 228 | they affect. Such description must be placed in a text file included with all 229 | distributions of the Covered Software under this License. Except to the 230 | extent prohibited by statute or regulation, such description must be 231 | sufficiently detailed for a recipient of ordinary skill to be able to 232 | understand it. 233 | 234 | 5. Termination 235 | 236 | 5.1. The rights granted under this License will terminate automatically if You 237 | fail to comply with any of its terms. However, if You become compliant, 238 | then the rights granted under this License from a particular Contributor 239 | are reinstated (a) provisionally, unless and until such Contributor 240 | explicitly and finally terminates Your grants, and (b) on an ongoing basis, 241 | if such Contributor fails to notify You of the non-compliance by some 242 | reasonable means prior to 60 days after You have come back into compliance. 243 | Moreover, Your grants from a particular Contributor are reinstated on an 244 | ongoing basis if such Contributor notifies You of the non-compliance by 245 | some reasonable means, this is the first time You have received notice of 246 | non-compliance with this License from such Contributor, and You become 247 | compliant prior to 30 days after Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, counter-claims, 251 | and cross-claims) alleging that a Contributor Version directly or 252 | indirectly infringes any patent, then the rights granted to You by any and 253 | all Contributors for the Covered Software under Section 2.1 of this License 254 | shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user 257 | license agreements (excluding distributors and resellers) which have been 258 | validly granted by You or Your distributors under this License prior to 259 | termination shall survive termination. 260 | 261 | 6. Disclaimer of Warranty 262 | 263 | Covered Software is provided under this License on an “as is” basis, without 264 | warranty of any kind, either expressed, implied, or statutory, including, 265 | without limitation, warranties that the Covered Software is free of defects, 266 | merchantable, fit for a particular purpose or non-infringing. The entire 267 | risk as to the quality and performance of the Covered Software is with You. 268 | Should any Covered Software prove defective in any respect, You (not any 269 | Contributor) assume the cost of any necessary servicing, repair, or 270 | correction. This disclaimer of warranty constitutes an essential part of this 271 | License. No use of any Covered Software is authorized under this License 272 | except under this disclaimer. 273 | 274 | 7. Limitation of Liability 275 | 276 | Under no circumstances and under no legal theory, whether tort (including 277 | negligence), contract, or otherwise, shall any Contributor, or anyone who 278 | distributes Covered Software as permitted above, be liable to You for any 279 | direct, indirect, special, incidental, or consequential damages of any 280 | character including, without limitation, damages for lost profits, loss of 281 | goodwill, work stoppage, computer failure or malfunction, or any and all 282 | other commercial damages or losses, even if such party shall have been 283 | informed of the possibility of such damages. This limitation of liability 284 | shall not apply to liability for death or personal injury resulting from such 285 | party’s negligence to the extent applicable law prohibits such limitation. 286 | Some jurisdictions do not allow the exclusion or limitation of incidental or 287 | consequential damages, so this exclusion and limitation may not apply to You. 288 | 289 | 8. Litigation 290 | 291 | Any litigation relating to this License may be brought only in the courts of 292 | a jurisdiction where the defendant maintains its principal place of business 293 | and such litigation shall be governed by laws of that jurisdiction, without 294 | reference to its conflict-of-law provisions. Nothing in this Section shall 295 | prevent a party’s ability to bring cross-claims or counter-claims. 296 | 297 | 9. Miscellaneous 298 | 299 | This License represents the complete agreement concerning the subject matter 300 | hereof. If any provision of this License is held to be unenforceable, such 301 | provision shall be reformed only to the extent necessary to make it 302 | enforceable. Any law or regulation which provides that the language of a 303 | contract shall be construed against the drafter shall not be used to construe 304 | this License against a Contributor. 305 | 306 | 307 | 10. Versions of the License 308 | 309 | 10.1. New Versions 310 | 311 | Mozilla Foundation is the license steward. Except as provided in Section 312 | 10.3, no one other than the license steward has the right to modify or 313 | publish new versions of this License. Each version will be given a 314 | distinguishing version number. 315 | 316 | 10.2. Effect of New Versions 317 | 318 | You may distribute the Covered Software under the terms of the version of 319 | the License under which You originally received the Covered Software, or 320 | under the terms of any subsequent version published by the license 321 | steward. 322 | 323 | 10.3. Modified Versions 324 | 325 | If you create software not governed by this License, and you want to 326 | create a new license for such software, you may create and use a modified 327 | version of this License if you rename the license and remove any 328 | references to the name of the license steward (except to note that such 329 | modified license differs from this License). 330 | 331 | 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses 332 | If You choose to distribute Source Code Form that is Incompatible With 333 | Secondary Licenses under the terms of this version of the License, the 334 | notice described in Exhibit B of this License must be attached. 335 | 336 | Exhibit A - Source Code Form License Notice 337 | 338 | This Source Code Form is subject to the 339 | terms of the Mozilla Public License, v. 340 | 2.0. If a copy of the MPL was not 341 | distributed with this file, You can 342 | obtain one at 343 | http://mozilla.org/MPL/2.0/. 344 | 345 | If it is not possible or desirable to put the notice in a particular file, then 346 | You may include the notice in a location (such as a LICENSE file in a relevant 347 | directory) where a recipient would be likely to look for such a notice. 348 | 349 | You may add additional accurate notices of copyright ownership. 350 | 351 | Exhibit B - “Incompatible With Secondary Licenses” Notice 352 | 353 | This Source Code Form is “Incompatible 354 | With Secondary Licenses”, as defined by 355 | the Mozilla Public License, v. 2.0. 356 | 357 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # terraform-registry-address 2 | 3 | This module enables parsing, comparison and canonical representation of 4 | [Terraform Registry](https://registry.terraform.io/) **provider** addresses 5 | (such as `registry.terraform.io/grafana/grafana` or `hashicorp/aws`) 6 | and **module** addresses (such as `hashicorp/subnets/cidr`). 7 | 8 | **Provider** addresses can be found in 9 | 10 | - [`terraform show -json `](https://www.terraform.io/internals/json-format#configuration-representation) (`full_name`) 11 | - [`terraform version -json`](https://www.terraform.io/cli/commands/version#example) (`provider_selections`) 12 | - [`terraform providers schema -json`](https://www.terraform.io/cli/commands/providers/schema#providers-schema-representation) (keys of `provider_schemas`) 13 | - within `required_providers` block in Terraform configuration (`*.tf`) 14 | - Terraform [CLI configuration file](https://www.terraform.io/cli/config/config-file#provider-installation) 15 | - Plugin [reattach configurations](https://www.terraform.io/plugin/debugging#running-terraform-with-a-provider-in-debug-mode) 16 | 17 | **Module** addresses can be found within `source` argument 18 | of `module` block in Terraform configuration (`*.tf`) 19 | and parts of the address (namespace and name) in the Registry API. 20 | 21 | ## Compatibility 22 | 23 | The module assumes compatibility with Terraform v0.12 and later, 24 | which have the mentioned JSON output produced by corresponding CLI flags. 25 | 26 | We recommend carefully reading the [ambigouous provider addresses](#Ambiguous-Provider-Addresses) 27 | section below which may impact versions `0.12` and `0.13`. 28 | 29 | ## Related Libraries 30 | 31 | Other libraries which may help with consuming most of the above Terraform 32 | outputs in automation: 33 | 34 | - [`hashicorp/terraform-exec`](https://github.com/hashicorp/terraform-exec) 35 | - [`hashicorp/terraform-json`](https://github.com/hashicorp/terraform-json) 36 | 37 | ## Usage 38 | 39 | ### Provider 40 | 41 | ```go 42 | pAddr, err := ParseProviderSource("hashicorp/aws") 43 | if err != nil { 44 | // deal with error 45 | } 46 | 47 | // pAddr == Provider{ 48 | // Type: "aws", 49 | // Namespace: "hashicorp", 50 | // Hostname: DefaultProviderRegistryHost, 51 | // } 52 | ``` 53 | 54 | ### Module 55 | 56 | ```go 57 | mAddr, err := ParseModuleSource("hashicorp/consul/aws//modules/consul-cluster") 58 | if err != nil { 59 | // deal with error 60 | } 61 | 62 | // mAddr == Module{ 63 | // Package: ModulePackage{ 64 | // Host: DefaultProviderRegistryHost, 65 | // Namespace: "hashicorp", 66 | // Name: "consul", 67 | // TargetSystem: "aws", 68 | // }, 69 | // Subdir: "modules/consul-cluster", 70 | // }, 71 | ``` 72 | 73 | ## Other Module Address Formats 74 | 75 | Modules can also be sourced from [other sources](https://www.terraform.io/language/modules/sources) 76 | and these other sources (outside of Terraform Registry) 77 | have different address formats, such as `./local` or 78 | `github.com/hashicorp/example`. 79 | 80 | This library does _not_ recognize such other address formats 81 | and it will return error upon parsing these. 82 | 83 | ## Ambiguous Provider Addresses 84 | 85 | Qualified addresses with namespace (such as `hashicorp/aws`) 86 | are used exclusively in all recent versions (`0.14+`) of Terraform. 87 | If you only work with Terraform `v0.14.0+` configuration/output, you may 88 | safely ignore the rest of this section and related part of the API. 89 | 90 | There are a few types of ambiguous addresses you may comes accross: 91 | 92 | - Terraform `v0.12` uses "namespace-less address", such as `aws`. 93 | - Terraform `v0.13` may use `-` as a placeholder for the unknown namespace, 94 | resulting in address such as `-/aws`. 95 | - Terraform `v0.14+` _configuration_ still allows ambiguous providers 96 | through `provider "" {}` block _without_ corresponding 97 | entry inside `required_providers`, but these providers are always 98 | resolved as `hashicorp/` and all JSON outputs only use that 99 | resolved address. 100 | 101 | Both ambiguous address formats are accepted by `ParseProviderSource()` 102 | 103 | ```go 104 | pAddr, err := ParseProviderSource("aws") 105 | if err != nil { 106 | // deal with error 107 | } 108 | 109 | // pAddr == Provider{ 110 | // Type: "aws", 111 | // Namespace: UnknownProviderNamespace, // "?" 112 | // Hostname: DefaultProviderRegistryHost, // "registry.terraform.io" 113 | // } 114 | pAddr.HasKnownNamespace() // == false 115 | pAddr.IsLegacy() // == false 116 | ``` 117 | ```go 118 | pAddr, err := ParseProviderSource("-/aws") 119 | if err != nil { 120 | // deal with error 121 | } 122 | 123 | // pAddr == Provider{ 124 | // Type: "aws", 125 | // Namespace: LegacyProviderNamespace, // "-" 126 | // Hostname: DefaultProviderRegistryHost, // "registry.terraform.io" 127 | // } 128 | pAddr.HasKnownNamespace() // == true 129 | pAddr.IsLegacy() // == true 130 | ``` 131 | 132 | However `NewProvider()` will panic if you pass an empty namespace 133 | or any placeholder indicating unknown namespace. 134 | 135 | ```go 136 | NewProvider(DefaultProviderRegistryHost, "", "aws") // panic 137 | NewProvider(DefaultProviderRegistryHost, "-", "aws") // panic 138 | NewProvider(DefaultProviderRegistryHost, "?", "aws") // panic 139 | ``` 140 | 141 | If you come across an ambiguous address, you should resolve 142 | it to a fully qualified one and use that one instead. 143 | 144 | ### Resolving Ambiguous Address 145 | 146 | The Registry API provides the safest way of resolving an ambiguous address. 147 | 148 | ```sh 149 | # grafana (redirected to its own namespace) 150 | $ curl -s https://registry.terraform.io/v1/providers/-/grafana/versions | jq '(.id, .moved_to)' 151 | "terraform-providers/grafana" 152 | "grafana/grafana" 153 | 154 | # aws (provider without redirection) 155 | $ curl -s https://registry.terraform.io/v1/providers/-/aws/versions | jq '(.id, .moved_to)' 156 | "hashicorp/aws" 157 | null 158 | ``` 159 | 160 | When you cache results, ensure you have invalidation 161 | mechanism in place as target (migrated) namespace may change. 162 | 163 | #### `terraform` provider 164 | 165 | Like any other legacy address `terraform` is also ambiguous. Such address may 166 | (most unlikely) represent a custom-built provider called `terraform`, 167 | or the now archived [`hashicorp/terraform` provider in the registry](https://registry.terraform.io/providers/hashicorp/terraform/latest), 168 | or (most likely) the `terraform` provider built into 0.11+, which is 169 | represented via a dedicated FQN of `terraform.io/builtin/terraform` in 0.13+. 170 | 171 | You may be able to differentiate between these different providers if you 172 | know the version of Terraform. 173 | 174 | Alternatively you may just treat the address as the builtin provider, 175 | i.e. assume all of its logic including schema is contained within 176 | Terraform Core. 177 | 178 | In such case you should construct the address in the following way 179 | ```go 180 | pAddr := NewProvider(BuiltInProviderHost, BuiltInProviderNamespace, "terraform") 181 | ``` 182 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfaddr 5 | 6 | import ( 7 | "fmt" 8 | ) 9 | 10 | type ParserError struct { 11 | Summary string 12 | Detail string 13 | } 14 | 15 | func (pe *ParserError) Error() string { 16 | return fmt.Sprintf("%s: %s", pe.Summary, pe.Detail) 17 | } 18 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hashicorp/terraform-registry-address 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/google/go-cmp v0.7.0 9 | github.com/hashicorp/terraform-svchost v0.1.1 10 | golang.org/x/net v0.40.0 11 | ) 12 | 13 | require golang.org/x/text v0.25.0 // indirect 14 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 2 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 3 | github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= 4 | github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= 5 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 6 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 7 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 8 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 9 | -------------------------------------------------------------------------------- /module.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfaddr 5 | 6 | import ( 7 | "fmt" 8 | "path" 9 | "regexp" 10 | "strings" 11 | 12 | svchost "github.com/hashicorp/terraform-svchost" 13 | ) 14 | 15 | // Module is representing a module listed in a Terraform module 16 | // registry. 17 | type Module struct { 18 | // Package is the registry package that the target module belongs to. 19 | // The module installer must translate this into a ModuleSourceRemote 20 | // using the registry API and then take that underlying address's 21 | // Package in order to find the actual package location. 22 | Package ModulePackage 23 | 24 | // If Subdir is non-empty then it represents a sub-directory within the 25 | // remote package that the registry address eventually resolves to. 26 | // This will ultimately become the suffix of the Subdir of the 27 | // ModuleSourceRemote that the registry address translates to. 28 | // 29 | // Subdir uses a normalized forward-slash-based path syntax within the 30 | // virtual filesystem represented by the final package. It will never 31 | // include `../` or `./` sequences. 32 | Subdir string 33 | } 34 | 35 | // DefaultModuleRegistryHost is the hostname used for registry-based module 36 | // source addresses that do not have an explicit hostname. 37 | const DefaultModuleRegistryHost = svchost.Hostname("registry.terraform.io") 38 | 39 | var moduleRegistryNamePattern = regexp.MustCompile("^[0-9A-Za-z](?:[0-9A-Za-z-_]{0,62}[0-9A-Za-z])?$") 40 | var moduleRegistryTargetSystemPattern = regexp.MustCompile("^[0-9a-z]{1,64}$") 41 | 42 | // ParseModuleSource only accepts module registry addresses, and 43 | // will reject any other address type. 44 | func ParseModuleSource(raw string) (Module, error) { 45 | var err error 46 | 47 | var subDir string 48 | raw, subDir = splitPackageSubdir(raw) 49 | if strings.HasPrefix(subDir, "../") { 50 | return Module{}, fmt.Errorf("subdirectory path %q leads outside of the module package", subDir) 51 | } 52 | 53 | parts := strings.Split(raw, "/") 54 | // A valid registry address has either three or four parts, because the 55 | // leading hostname part is optional. 56 | if len(parts) != 3 && len(parts) != 4 { 57 | return Module{}, fmt.Errorf("a module registry source address must have either three or four slash-separated components") 58 | } 59 | 60 | host := DefaultModuleRegistryHost 61 | if len(parts) == 4 { 62 | host, err = svchost.ForComparison(parts[0]) 63 | if err != nil { 64 | // The svchost library doesn't produce very good error messages to 65 | // return to an end-user, so we'll use some custom ones here. 66 | switch { 67 | case strings.Contains(parts[0], "--"): 68 | // Looks like possibly punycode, which we don't allow here 69 | // to ensure that source addresses are written readably. 70 | return Module{}, fmt.Errorf("invalid module registry hostname %q; internationalized domain names must be given as direct unicode characters, not in punycode", parts[0]) 71 | default: 72 | return Module{}, fmt.Errorf("invalid module registry hostname %q", parts[0]) 73 | } 74 | } 75 | if !strings.Contains(host.String(), ".") { 76 | return Module{}, fmt.Errorf("invalid module registry hostname: must contain at least one dot") 77 | } 78 | // Discard the hostname prefix now that we've processed it 79 | parts = parts[1:] 80 | } 81 | 82 | ret := Module{ 83 | Package: ModulePackage{ 84 | Host: host, 85 | }, 86 | 87 | Subdir: subDir, 88 | } 89 | 90 | if host == svchost.Hostname("github.com") || host == svchost.Hostname("bitbucket.org") { 91 | 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) 92 | } 93 | 94 | if ret.Package.Namespace, err = parseModuleRegistryName(parts[0]); err != nil { 95 | if strings.Contains(parts[0], ".") { 96 | // Seems like the user omitted one of the latter components in 97 | // an address with an explicit hostname. 98 | return ret, fmt.Errorf("source address must have three more components after the hostname: the namespace, the name, and the target system") 99 | } 100 | return ret, fmt.Errorf("invalid namespace %q: %s", parts[0], err) 101 | } 102 | if ret.Package.Name, err = parseModuleRegistryName(parts[1]); err != nil { 103 | return ret, fmt.Errorf("invalid module name %q: %s", parts[1], err) 104 | } 105 | if ret.Package.TargetSystem, err = parseModuleRegistryTargetSystem(parts[2]); err != nil { 106 | if strings.Contains(parts[2], "?") { 107 | // The user was trying to include a query string, probably? 108 | return ret, fmt.Errorf("module registry addresses may not include a query string portion") 109 | } 110 | return ret, fmt.Errorf("invalid target system %q: %s", parts[2], err) 111 | } 112 | 113 | return ret, nil 114 | } 115 | 116 | // MustParseModuleSource is a wrapper around ParseModuleSource that panics if 117 | // it returns an error. 118 | func MustParseModuleSource(raw string) (Module) { 119 | mod, err := ParseModuleSource(raw) 120 | if err != nil { 121 | panic(err) 122 | } 123 | return mod 124 | } 125 | 126 | // parseModuleRegistryName validates and normalizes a string in either the 127 | // "namespace" or "name" position of a module registry source address. 128 | func parseModuleRegistryName(given string) (string, error) { 129 | // Similar to the names in provider source addresses, we defined these 130 | // to be compatible with what filesystems and typical remote systems 131 | // like GitHub allow in names. Unfortunately we didn't end up defining 132 | // these exactly equivalently: provider names can only use dashes as 133 | // punctuation, whereas module names can use underscores. So here we're 134 | // using some regular expressions from the original module source 135 | // implementation, rather than using the IDNA rules as we do in 136 | // ParseProviderPart. 137 | 138 | if !moduleRegistryNamePattern.MatchString(given) { 139 | 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") 140 | } 141 | 142 | // We also skip normalizing the name to lowercase, because we historically 143 | // didn't do that and so existing module registries might be doing 144 | // case-sensitive matching. 145 | return given, nil 146 | } 147 | 148 | // parseModuleRegistryTargetSystem validates and normalizes a string in the 149 | // "target system" position of a module registry source address. This is 150 | // what we historically called "provider" but never actually enforced as 151 | // being a provider address, and now _cannot_ be a provider address because 152 | // provider addresses have three slash-separated components of their own. 153 | func parseModuleRegistryTargetSystem(given string) (string, error) { 154 | // Similar to the names in provider source addresses, we defined these 155 | // to be compatible with what filesystems and typical remote systems 156 | // like GitHub allow in names. Unfortunately we didn't end up defining 157 | // these exactly equivalently: provider names can't use dashes or 158 | // underscores. So here we're using some regular expressions from the 159 | // original module source implementation, rather than using the IDNA rules 160 | // as we do in ParseProviderPart. 161 | 162 | if !moduleRegistryTargetSystemPattern.MatchString(given) { 163 | return "", fmt.Errorf("must be between one and 64 ASCII letters or digits") 164 | } 165 | 166 | // We also skip normalizing the name to lowercase, because we historically 167 | // didn't do that and so existing module registries might be doing 168 | // case-sensitive matching. 169 | return given, nil 170 | } 171 | 172 | // String returns a full representation of the address, including any 173 | // additional components that are typically implied by omission in 174 | // user-written addresses. 175 | // 176 | // We typically use this longer representation in error message, in case 177 | // the inclusion of normally-omitted components is helpful in debugging 178 | // unexpected behavior. 179 | func (s Module) String() string { 180 | if s.Subdir != "" { 181 | return s.Package.String() + "//" + s.Subdir 182 | } 183 | return s.Package.String() 184 | } 185 | 186 | // ForDisplay is similar to String but instead returns a representation of 187 | // the idiomatic way to write the address in configuration, omitting 188 | // components that are commonly just implied in addresses written by 189 | // users. 190 | // 191 | // We typically use this shorter representation in informational messages, 192 | // such as the note that we're about to start downloading a package. 193 | func (s Module) ForDisplay() string { 194 | if s.Subdir != "" { 195 | return s.Package.ForDisplay() + "//" + s.Subdir 196 | } 197 | return s.Package.ForDisplay() 198 | } 199 | 200 | // splitPackageSubdir detects whether the given address string has a 201 | // subdirectory portion, and if so returns a non-empty subDir string 202 | // along with the trimmed package address. 203 | // 204 | // If the given string doesn't have a subdirectory portion then it'll 205 | // just be returned verbatim in packageAddr, with an empty subDir value. 206 | func splitPackageSubdir(given string) (packageAddr, subDir string) { 207 | packageAddr, subDir = sourceDirSubdir(given) 208 | if subDir != "" { 209 | subDir = path.Clean(subDir) 210 | } 211 | return packageAddr, subDir 212 | } 213 | 214 | // sourceDirSubdir takes a source URL and returns a tuple of the URL without 215 | // the subdir and the subdir. 216 | // 217 | // ex: 218 | // dom.com/path/?q=p => dom.com/path/?q=p, "" 219 | // proto://dom.com/path//*?q=p => proto://dom.com/path?q=p, "*" 220 | // proto://dom.com/path//path2?q=p => proto://dom.com/path?q=p, "path2" 221 | func sourceDirSubdir(src string) (string, string) { 222 | // URL might contains another url in query parameters 223 | stop := len(src) 224 | if idx := strings.Index(src, "?"); idx > -1 { 225 | stop = idx 226 | } 227 | 228 | // Calculate an offset to avoid accidentally marking the scheme 229 | // as the dir. 230 | var offset int 231 | if idx := strings.Index(src[:stop], "://"); idx > -1 { 232 | offset = idx + 3 233 | } 234 | 235 | // First see if we even have an explicit subdir 236 | idx := strings.Index(src[offset:stop], "//") 237 | if idx == -1 { 238 | return src, "" 239 | } 240 | 241 | idx += offset 242 | subdir := src[idx+2:] 243 | src = src[:idx] 244 | 245 | // Next, check if we have query parameters and push them onto the 246 | // URL. 247 | if idx = strings.Index(subdir, "?"); idx > -1 { 248 | query := subdir[idx:] 249 | subdir = subdir[:idx] 250 | src += query 251 | } 252 | 253 | return src, subdir 254 | } 255 | -------------------------------------------------------------------------------- /module_package.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfaddr 5 | 6 | import ( 7 | "strings" 8 | 9 | svchost "github.com/hashicorp/terraform-svchost" 10 | ) 11 | 12 | // A ModulePackage is an extra indirection over a ModulePackage where 13 | // we use a module registry to translate a more symbolic address (and 14 | // associated version constraint given out of band) into a physical source 15 | // location. 16 | // 17 | // ModulePackage is distinct from ModulePackage because they have 18 | // disjoint use-cases: registry package addresses are only used to query a 19 | // registry in order to find a real module package address. These being 20 | // distinct is intended to help future maintainers more easily follow the 21 | // series of steps in the module installer, with the help of the type checker. 22 | type ModulePackage struct { 23 | Host svchost.Hostname 24 | Namespace string 25 | Name string 26 | TargetSystem string 27 | } 28 | 29 | func (s ModulePackage) String() string { 30 | // Note: we're using the "display" form of the hostname here because 31 | // for our service hostnames "for display" means something different: 32 | // it means to render non-ASCII characters directly as Unicode 33 | // characters, rather than using the "punycode" representation we 34 | // use for internal processing, and so the "display" representation 35 | // is actually what users would write in their configurations. 36 | return s.Host.ForDisplay() + "/" + s.ForRegistryProtocol() 37 | } 38 | 39 | func (s ModulePackage) ForDisplay() string { 40 | if s.Host == DefaultModuleRegistryHost { 41 | return s.ForRegistryProtocol() 42 | } 43 | return s.Host.ForDisplay() + "/" + s.ForRegistryProtocol() 44 | } 45 | 46 | // ForRegistryProtocol returns a string representation of just the namespace, 47 | // name, and target system portions of the address, always omitting the 48 | // registry hostname and the subdirectory portion, if any. 49 | // 50 | // This is primarily intended for generating addresses to send to the 51 | // registry in question via the registry protocol, since the protocol 52 | // skips sending the registry its own hostname as part of identifiers. 53 | func (s ModulePackage) ForRegistryProtocol() string { 54 | var buf strings.Builder 55 | buf.WriteString(s.Namespace) 56 | buf.WriteByte('/') 57 | buf.WriteString(s.Name) 58 | buf.WriteByte('/') 59 | buf.WriteString(s.TargetSystem) 60 | return buf.String() 61 | } 62 | -------------------------------------------------------------------------------- /module_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfaddr 5 | 6 | import ( 7 | "fmt" 8 | "log" 9 | "testing" 10 | 11 | "github.com/google/go-cmp/cmp" 12 | svchost "github.com/hashicorp/terraform-svchost" 13 | ) 14 | 15 | func TestParseModuleSource_simple(t *testing.T) { 16 | tests := map[string]struct { 17 | input string 18 | want Module 19 | wantErr string 20 | }{ 21 | "main registry implied": { 22 | input: "hashicorp/subnets/cidr", 23 | want: Module{ 24 | Package: ModulePackage{ 25 | Host: svchost.Hostname("registry.terraform.io"), 26 | Namespace: "hashicorp", 27 | Name: "subnets", 28 | TargetSystem: "cidr", 29 | }, 30 | Subdir: "", 31 | }, 32 | }, 33 | "main registry implied, subdir": { 34 | input: "hashicorp/subnets/cidr//examples/foo", 35 | want: Module{ 36 | Package: ModulePackage{ 37 | Host: svchost.Hostname("registry.terraform.io"), 38 | Namespace: "hashicorp", 39 | Name: "subnets", 40 | TargetSystem: "cidr", 41 | }, 42 | Subdir: "examples/foo", 43 | }, 44 | }, 45 | "custom registry": { 46 | input: "example.com/awesomecorp/network/happycloud", 47 | want: Module{ 48 | Package: ModulePackage{ 49 | Host: svchost.Hostname("example.com"), 50 | Namespace: "awesomecorp", 51 | Name: "network", 52 | TargetSystem: "happycloud", 53 | }, 54 | Subdir: "", 55 | }, 56 | }, 57 | "custom registry, subdir": { 58 | input: "example.com/awesomecorp/network/happycloud//examples/foo", 59 | want: Module{ 60 | Package: ModulePackage{ 61 | Host: svchost.Hostname("example.com"), 62 | Namespace: "awesomecorp", 63 | Name: "network", 64 | TargetSystem: "happycloud", 65 | }, 66 | Subdir: "examples/foo", 67 | }, 68 | }, 69 | } 70 | 71 | for name, test := range tests { 72 | t.Run(name, func(t *testing.T) { 73 | addr, err := ParseModuleSource(test.input) 74 | 75 | if test.wantErr != "" { 76 | switch { 77 | case err == nil: 78 | t.Errorf("unexpected success\nwant error: %s", test.wantErr) 79 | case err.Error() != test.wantErr: 80 | t.Errorf("wrong error messages\ngot: %s\nwant: %s", err.Error(), test.wantErr) 81 | } 82 | return 83 | } 84 | 85 | if err != nil { 86 | t.Fatalf("unexpected error: %s", err.Error()) 87 | } 88 | 89 | if diff := cmp.Diff(addr, test.want); diff != "" { 90 | t.Errorf("wrong result\n%s", diff) 91 | } 92 | }) 93 | } 94 | 95 | } 96 | 97 | func TestParseModuleSource(t *testing.T) { 98 | tests := map[string]struct { 99 | input string 100 | wantString string 101 | wantForDisplay string 102 | wantForProtocol string 103 | wantErr string 104 | }{ 105 | "public registry": { 106 | input: `hashicorp/consul/aws`, 107 | wantString: `registry.terraform.io/hashicorp/consul/aws`, 108 | wantForDisplay: `hashicorp/consul/aws`, 109 | wantForProtocol: `hashicorp/consul/aws`, 110 | }, 111 | "public registry with subdir": { 112 | input: `hashicorp/consul/aws//foo`, 113 | wantString: `registry.terraform.io/hashicorp/consul/aws//foo`, 114 | wantForDisplay: `hashicorp/consul/aws//foo`, 115 | wantForProtocol: `hashicorp/consul/aws`, 116 | }, 117 | "public registry using explicit hostname": { 118 | input: `registry.terraform.io/hashicorp/consul/aws`, 119 | wantString: `registry.terraform.io/hashicorp/consul/aws`, 120 | wantForDisplay: `hashicorp/consul/aws`, 121 | wantForProtocol: `hashicorp/consul/aws`, 122 | }, 123 | "public registry with mixed case names": { 124 | input: `HashiCorp/Consul/aws`, 125 | wantString: `registry.terraform.io/HashiCorp/Consul/aws`, 126 | wantForDisplay: `HashiCorp/Consul/aws`, 127 | wantForProtocol: `HashiCorp/Consul/aws`, 128 | }, 129 | "private registry with non-standard port": { 130 | input: `Example.com:1234/HashiCorp/Consul/aws`, 131 | wantString: `example.com:1234/HashiCorp/Consul/aws`, 132 | wantForDisplay: `example.com:1234/HashiCorp/Consul/aws`, 133 | wantForProtocol: `HashiCorp/Consul/aws`, 134 | }, 135 | "private registry with IDN hostname": { 136 | input: `Испытание.com/HashiCorp/Consul/aws`, 137 | wantString: `испытание.com/HashiCorp/Consul/aws`, 138 | wantForDisplay: `испытание.com/HashiCorp/Consul/aws`, 139 | wantForProtocol: `HashiCorp/Consul/aws`, 140 | }, 141 | "private registry with IDN hostname and non-standard port": { 142 | input: `Испытание.com:1234/HashiCorp/Consul/aws//Foo`, 143 | wantString: `испытание.com:1234/HashiCorp/Consul/aws//Foo`, 144 | wantForDisplay: `испытание.com:1234/HashiCorp/Consul/aws//Foo`, 145 | wantForProtocol: `HashiCorp/Consul/aws`, 146 | }, 147 | "invalid hostname": { 148 | input: `---.com/HashiCorp/Consul/aws`, 149 | wantErr: `invalid module registry hostname "---.com"; internationalized domain names must be given as direct unicode characters, not in punycode`, 150 | }, 151 | "hostname with only one label": { 152 | // This was historically forbidden in our initial implementation, 153 | // so we keep it forbidden to avoid newly interpreting such 154 | // addresses as registry addresses rather than remote source 155 | // addresses. 156 | input: `foo/var/baz/qux`, 157 | wantErr: `invalid module registry hostname: must contain at least one dot`, 158 | }, 159 | "invalid target system characters": { 160 | input: `foo/var/no-no-no`, 161 | wantErr: `invalid target system "no-no-no": must be between one and 64 ASCII letters or digits`, 162 | }, 163 | "invalid target system length": { 164 | input: `foo/var/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaah`, 165 | wantErr: `invalid target system "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaah": must be between one and 64 ASCII letters or digits`, 166 | }, 167 | "invalid namespace": { 168 | input: `boop!/var/baz`, 169 | 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`, 170 | }, 171 | "missing part with explicit hostname": { 172 | input: `foo.com/var/baz`, 173 | wantErr: `source address must have three more components after the hostname: the namespace, the name, and the target system`, 174 | }, 175 | "errant query string": { 176 | input: `foo/var/baz?otherthing`, 177 | wantErr: `module registry addresses may not include a query string portion`, 178 | }, 179 | "github.com": { 180 | // We don't allow using github.com like a module registry because 181 | // that conflicts with the historically-supported shorthand for 182 | // installing directly from GitHub-hosted git repositories. 183 | input: `github.com/HashiCorp/Consul/aws`, 184 | wantErr: `can't use "github.com" as a module registry host, because it's reserved for installing directly from version control repositories`, 185 | }, 186 | "bitbucket.org": { 187 | // We don't allow using bitbucket.org like a module registry because 188 | // that conflicts with the historically-supported shorthand for 189 | // installing directly from BitBucket-hosted git repositories. 190 | input: `bitbucket.org/HashiCorp/Consul/aws`, 191 | wantErr: `can't use "bitbucket.org" as a module registry host, because it's reserved for installing directly from version control repositories`, 192 | }, 193 | "local path from current dir": { 194 | // Can't use a local path when we're specifically trying to parse 195 | // a _registry_ source address. 196 | input: `./boop`, 197 | wantErr: `a module registry source address must have either three or four slash-separated components`, 198 | }, 199 | "local path from parent dir": { 200 | // Can't use a local path when we're specifically trying to parse 201 | // a _registry_ source address. 202 | input: `../boop`, 203 | wantErr: `a module registry source address must have either three or four slash-separated components`, 204 | }, 205 | "main registry implied, escaping subdir": { 206 | input: "hashicorp/subnets/cidr//../nope", 207 | wantErr: `subdirectory path "../nope" leads outside of the module package`, 208 | }, 209 | "relative path without the needed prefix": { 210 | input: "boop/bloop", 211 | wantErr: "a module registry source address must have either three or four slash-separated components", 212 | }, 213 | } 214 | 215 | for name, test := range tests { 216 | t.Run(name, func(t *testing.T) { 217 | addr, err := ParseModuleSource(test.input) 218 | 219 | if test.wantErr != "" { 220 | switch { 221 | case err == nil: 222 | t.Errorf("unexpected success\nwant error: %s", test.wantErr) 223 | case err.Error() != test.wantErr: 224 | t.Errorf("wrong error messages\ngot: %s\nwant: %s", err.Error(), test.wantErr) 225 | } 226 | return 227 | } 228 | 229 | if err != nil { 230 | t.Fatalf("unexpected error: %s", err.Error()) 231 | } 232 | 233 | if got, want := addr.String(), test.wantString; got != want { 234 | t.Errorf("wrong String() result\ngot: %s\nwant: %s", got, want) 235 | } 236 | if got, want := addr.ForDisplay(), test.wantForDisplay; got != want { 237 | t.Errorf("wrong ForDisplay() result\ngot: %s\nwant: %s", got, want) 238 | } 239 | if got, want := addr.Package.ForRegistryProtocol(), test.wantForProtocol; got != want { 240 | t.Errorf("wrong ForRegistryProtocol() result\ngot: %s\nwant: %s", got, want) 241 | } 242 | }) 243 | } 244 | } 245 | 246 | func ExampleParseModuleSource() { 247 | mAddr, err := ParseModuleSource("hashicorp/consul/aws//modules/consul-cluster") 248 | if err != nil { 249 | log.Fatal(err) 250 | } 251 | fmt.Printf("%#v", mAddr) 252 | // Output: tfaddr.Module{Package:tfaddr.ModulePackage{Host:svchost.Hostname("registry.terraform.io"), Namespace:"hashicorp", Name:"consul", TargetSystem:"aws"}, Subdir:"modules/consul-cluster"} 253 | } 254 | -------------------------------------------------------------------------------- /provider.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfaddr 5 | 6 | import ( 7 | "fmt" 8 | "strings" 9 | 10 | svchost "github.com/hashicorp/terraform-svchost" 11 | "golang.org/x/net/idna" 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 | // DefaultProviderRegistryHost is the hostname used for provider addresses that do 23 | // not have an explicit hostname. 24 | const DefaultProviderRegistryHost = 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 | // UnknownProviderNamespace is the special string used to indicate 41 | // unknown namespace, e.g. in "aws". This is equivalent to 42 | // LegacyProviderNamespace for <0.12 style address. This namespace 43 | // would never be produced by Terraform itself explicitly, it is 44 | // only an internal placeholder. 45 | const UnknownProviderNamespace = "?" 46 | 47 | // LegacyProviderNamespace is the special string used in the Namespace field 48 | // of type Provider to mark a legacy provider address. This special namespace 49 | // value would normally be invalid, and can be used only when the hostname is 50 | // DefaultProviderRegistryHost because that host owns the mapping from legacy name to 51 | // FQN. This may be produced by Terraform 0.13. 52 | const LegacyProviderNamespace = "-" 53 | 54 | // String returns an FQN string, indended for use in machine-readable output. 55 | func (pt Provider) String() string { 56 | if pt.IsZero() { 57 | panic("called String on zero-value addrs.Provider") 58 | } 59 | return pt.Hostname.ForDisplay() + "/" + pt.Namespace + "/" + pt.Type 60 | } 61 | 62 | // ForDisplay returns a user-friendly FQN string, simplified for readability. If 63 | // the provider is using the default hostname, the hostname is omitted. 64 | func (pt Provider) ForDisplay() string { 65 | if pt.IsZero() { 66 | panic("called ForDisplay on zero-value addrs.Provider") 67 | } 68 | 69 | if pt.Hostname == DefaultProviderRegistryHost { 70 | return pt.Namespace + "/" + pt.Type 71 | } 72 | return pt.Hostname.ForDisplay() + "/" + pt.Namespace + "/" + pt.Type 73 | } 74 | 75 | // NewProvider constructs a provider address from its parts, and normalizes 76 | // the namespace and type parts to lowercase using unicode case folding rules 77 | // so that resulting addrs.Provider values can be compared using standard 78 | // Go equality rules (==). 79 | // 80 | // The hostname is given as a svchost.Hostname, which is required by the 81 | // contract of that type to have already been normalized for equality testing. 82 | // 83 | // This function will panic if the given namespace or type name are not valid. 84 | // When accepting namespace or type values from outside the program, use 85 | // ParseProviderPart first to check that the given value is valid. 86 | func NewProvider(hostname svchost.Hostname, namespace, typeName string) Provider { 87 | if namespace == LegacyProviderNamespace { 88 | // Legacy provider addresses must always be created via struct 89 | panic("attempt to create legacy provider address using NewProvider; use Provider{} instead") 90 | } 91 | if namespace == UnknownProviderNamespace { 92 | // Provider addresses with unknown namespace must always 93 | // be created via struct 94 | panic("attempt to create provider address with unknown namespace using NewProvider; use Provider{} instead") 95 | } 96 | if namespace == "" { 97 | // This case is already handled by MustParseProviderPart() below, 98 | // but we catch it early to provide more helpful message. 99 | panic("attempt to create provider address with empty namespace") 100 | } 101 | 102 | return Provider{ 103 | Type: MustParseProviderPart(typeName), 104 | Namespace: MustParseProviderPart(namespace), 105 | Hostname: hostname, 106 | } 107 | } 108 | 109 | // LegacyString returns the provider type, which is frequently used 110 | // interchangeably with provider name. This function can and should be removed 111 | // when provider type is fully integrated. As a safeguard for future 112 | // refactoring, this function panics if the Provider is not a legacy provider. 113 | func (pt Provider) LegacyString() string { 114 | if pt.IsZero() { 115 | panic("called LegacyString on zero-value addrs.Provider") 116 | } 117 | if pt.Namespace != LegacyProviderNamespace && pt.Namespace != BuiltInProviderNamespace { 118 | panic(pt.String() + " cannot be represented as a legacy string") 119 | } 120 | return pt.Type 121 | } 122 | 123 | // IsZero returns true if the receiver is the zero value of addrs.Provider. 124 | // 125 | // The zero value is not a valid addrs.Provider and calling other methods on 126 | // such a value is likely to either panic or otherwise misbehave. 127 | func (pt Provider) IsZero() bool { 128 | return pt == Provider{} 129 | } 130 | 131 | // HasKnownNamespace returns true if the provider namespace is known 132 | // (also if it is legacy namespace) 133 | func (pt Provider) HasKnownNamespace() bool { 134 | return pt.Namespace != UnknownProviderNamespace 135 | } 136 | 137 | // IsBuiltIn returns true if the receiver is the address of a "built-in" 138 | // provider. That is, a provider under terraform.io/builtin/ which is 139 | // included as part of the Terraform binary itself rather than one to be 140 | // installed from elsewhere. 141 | // 142 | // These are ignored by the provider installer because they are assumed to 143 | // already be available without any further installation. 144 | func (pt Provider) IsBuiltIn() bool { 145 | return pt.Hostname == BuiltInProviderHost && pt.Namespace == BuiltInProviderNamespace 146 | } 147 | 148 | // LessThan returns true if the receiver should sort before the other given 149 | // address in an ordered list of provider addresses. 150 | // 151 | // This ordering is an arbitrary one just to allow deterministic results from 152 | // functions that would otherwise have no natural ordering. It's subject 153 | // to change in future. 154 | func (pt Provider) LessThan(other Provider) bool { 155 | switch { 156 | case pt.Hostname != other.Hostname: 157 | return pt.Hostname < other.Hostname 158 | case pt.Namespace != other.Namespace: 159 | return pt.Namespace < other.Namespace 160 | default: 161 | return pt.Type < other.Type 162 | } 163 | } 164 | 165 | // IsLegacy returns true if the provider is a legacy-style provider 166 | func (pt Provider) IsLegacy() bool { 167 | if pt.IsZero() { 168 | panic("called IsLegacy() on zero-value addrs.Provider") 169 | } 170 | 171 | return pt.Hostname == DefaultProviderRegistryHost && pt.Namespace == LegacyProviderNamespace 172 | 173 | } 174 | 175 | // Equals returns true if the receiver and other provider have the same attributes. 176 | func (pt Provider) Equals(other Provider) bool { 177 | return pt == other 178 | } 179 | 180 | // ParseProviderSource parses the source attribute and returns a provider. 181 | // This is intended primarily to parse the FQN-like strings returned by 182 | // terraform-config-inspect. 183 | // 184 | // The following are valid source string formats: 185 | // name 186 | // namespace/name 187 | // hostname/namespace/name 188 | // 189 | // "name"-only format is parsed as -/name (i.e. legacy namespace) 190 | // requiring further identification of the namespace via Registry API 191 | func ParseProviderSource(str string) (Provider, error) { 192 | var ret Provider 193 | parts, err := parseSourceStringParts(str) 194 | if err != nil { 195 | return ret, err 196 | } 197 | 198 | name := parts[len(parts)-1] 199 | ret.Type = name 200 | ret.Hostname = DefaultProviderRegistryHost 201 | 202 | if len(parts) == 1 { 203 | return Provider{ 204 | Hostname: DefaultProviderRegistryHost, 205 | Namespace: UnknownProviderNamespace, 206 | Type: name, 207 | }, nil 208 | } 209 | 210 | if len(parts) >= 2 { 211 | // the namespace is always the second-to-last part 212 | givenNamespace := parts[len(parts)-2] 213 | if givenNamespace == LegacyProviderNamespace { 214 | // For now we're tolerating legacy provider addresses until we've 215 | // finished updating the rest of the codebase to no longer use them, 216 | // or else we'd get errors round-tripping through legacy subsystems. 217 | ret.Namespace = LegacyProviderNamespace 218 | } else { 219 | namespace, err := ParseProviderPart(givenNamespace) 220 | if err != nil { 221 | return Provider{}, &ParserError{ 222 | Summary: "Invalid provider namespace", 223 | Detail: fmt.Sprintf(`Invalid provider namespace %q in source %q: %s"`, namespace, str, err), 224 | } 225 | } 226 | ret.Namespace = namespace 227 | } 228 | } 229 | 230 | // Final Case: 3 parts 231 | if len(parts) == 3 { 232 | // the namespace is always the first part in a three-part source string 233 | hn, err := svchost.ForComparison(parts[0]) 234 | if err != nil { 235 | return Provider{}, &ParserError{ 236 | Summary: "Invalid provider source hostname", 237 | Detail: fmt.Sprintf(`Invalid provider source hostname namespace %q in source %q: %s"`, hn, str, err), 238 | } 239 | } 240 | ret.Hostname = hn 241 | } 242 | 243 | if ret.Namespace == LegacyProviderNamespace && ret.Hostname != DefaultProviderRegistryHost { 244 | // Legacy provider addresses must always be on the default registry 245 | // host, because the default registry host decides what actual FQN 246 | // each one maps to. 247 | return Provider{}, &ParserError{ 248 | Summary: "Invalid provider namespace", 249 | Detail: "The legacy provider namespace \"-\" can be used only with hostname " + DefaultProviderRegistryHost.ForDisplay() + ".", 250 | } 251 | } 252 | 253 | // Due to how plugin executables are named and provider git repositories 254 | // are conventionally named, it's a reasonable and 255 | // apparently-somewhat-common user error to incorrectly use the 256 | // "terraform-provider-" prefix in a provider source address. There is 257 | // no good reason for a provider to have the prefix "terraform-" anyway, 258 | // so we've made that invalid from the start both so we can give feedback 259 | // to provider developers about the terraform- prefix being redundant 260 | // and give specialized feedback to folks who incorrectly use the full 261 | // terraform-provider- prefix to help them self-correct. 262 | const redundantPrefix = "terraform-" 263 | const userErrorPrefix = "terraform-provider-" 264 | if strings.HasPrefix(ret.Type, redundantPrefix) { 265 | if strings.HasPrefix(ret.Type, userErrorPrefix) { 266 | // Likely user error. We only return this specialized error if 267 | // whatever is after the prefix would otherwise be a 268 | // syntactically-valid provider type, so we don't end up advising 269 | // the user to try something that would be invalid for another 270 | // reason anyway. 271 | // (This is mainly just for robustness, because the validation 272 | // we already did above should've rejected most/all ways for 273 | // the suggestedType to end up invalid here.) 274 | suggestedType := ret.Type[len(userErrorPrefix):] 275 | if _, err := ParseProviderPart(suggestedType); err == nil { 276 | suggestedAddr := ret 277 | suggestedAddr.Type = suggestedType 278 | return Provider{}, &ParserError{ 279 | Summary: "Invalid provider type", 280 | 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()), 281 | } 282 | } 283 | } 284 | // Otherwise, probably instead an incorrectly-named provider, perhaps 285 | // arising from a similar instinct to what causes there to be 286 | // thousands of Python packages on PyPI with "python-"-prefixed 287 | // names. 288 | return Provider{}, &ParserError{ 289 | Summary: "Invalid provider type", 290 | 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), 291 | } 292 | } 293 | 294 | return ret, nil 295 | } 296 | 297 | // MustParseProviderSource is a wrapper around ParseProviderSource that panics if 298 | // it returns an error. 299 | func MustParseProviderSource(raw string) (Provider) { 300 | p, err := ParseProviderSource(raw) 301 | if err != nil { 302 | panic(err) 303 | } 304 | return p 305 | } 306 | 307 | // ValidateProviderAddress returns error if the given address is not FQN, 308 | // that is if it is missing any of the three components from 309 | // hostname/namespace/name. 310 | func ValidateProviderAddress(raw string) error { 311 | parts, err := parseSourceStringParts(raw) 312 | if err != nil { 313 | return err 314 | } 315 | 316 | if len(parts) != 3 { 317 | return &ParserError{ 318 | Summary: "Invalid provider address format", 319 | Detail: `Expected FQN in the format "hostname/namespace/name"`, 320 | } 321 | } 322 | 323 | p, err := ParseProviderSource(raw) 324 | if err != nil { 325 | return err 326 | } 327 | 328 | if !p.HasKnownNamespace() { 329 | return &ParserError{ 330 | Summary: "Unknown provider namespace", 331 | Detail: `Expected FQN in the format "hostname/namespace/name"`, 332 | } 333 | } 334 | 335 | if !p.IsLegacy() { 336 | return &ParserError{ 337 | Summary: "Invalid legacy provider namespace", 338 | Detail: `Expected FQN in the format "hostname/namespace/name"`, 339 | } 340 | } 341 | 342 | return nil 343 | } 344 | 345 | func parseSourceStringParts(str string) ([]string, error) { 346 | // split the source string into individual components 347 | parts := strings.Split(str, "/") 348 | if len(parts) == 0 || len(parts) > 3 { 349 | return nil, &ParserError{ 350 | Summary: "Invalid provider source string", 351 | Detail: `The "source" attribute must be in the format "[hostname/][namespace/]name"`, 352 | } 353 | } 354 | 355 | // check for an invalid empty string in any part 356 | for i := range parts { 357 | if parts[i] == "" { 358 | return nil, &ParserError{ 359 | Summary: "Invalid provider source string", 360 | Detail: `The "source" attribute must be in the format "[hostname/][namespace/]name"`, 361 | } 362 | } 363 | } 364 | 365 | // check the 'name' portion, which is always the last part 366 | givenName := parts[len(parts)-1] 367 | name, err := ParseProviderPart(givenName) 368 | if err != nil { 369 | return nil, &ParserError{ 370 | Summary: "Invalid provider type", 371 | Detail: fmt.Sprintf(`Invalid provider type %q in source %q: %s"`, givenName, str, err), 372 | } 373 | } 374 | parts[len(parts)-1] = name 375 | 376 | return parts, nil 377 | } 378 | 379 | // ParseProviderPart processes an addrs.Provider namespace or type string 380 | // provided by an end-user, producing a normalized version if possible or 381 | // an error if the string contains invalid characters. 382 | // 383 | // A provider part is processed in the same way as an individual label in a DNS 384 | // domain name: it is transformed to lowercase per the usual DNS case mapping 385 | // and normalization rules and may contain only letters, digits, and dashes. 386 | // Additionally, dashes may not appear at the start or end of the string. 387 | // 388 | // These restrictions are intended to allow these names to appear in fussy 389 | // contexts such as directory/file names on case-insensitive filesystems, 390 | // repository names on GitHub, etc. We're using the DNS rules in particular, 391 | // rather than some similar rules defined locally, because the hostname part 392 | // of an addrs.Provider is already a hostname and it's ideal to use exactly 393 | // the same case folding and normalization rules for all of the parts. 394 | // 395 | // In practice a provider type string conventionally does not contain dashes 396 | // either. Such names are permitted, but providers with such type names will be 397 | // hard to use because their resource type names will not be able to contain 398 | // the provider type name and thus each resource will need an explicit provider 399 | // address specified. (A real-world example of such a provider is the 400 | // "google-beta" variant of the GCP provider, which has resource types that 401 | // start with the "google_" prefix instead.) 402 | // 403 | // It's valid to pass the result of this function as the argument to a 404 | // subsequent call, in which case the result will be identical. 405 | func ParseProviderPart(given string) (string, error) { 406 | if len(given) == 0 { 407 | return "", fmt.Errorf("must have at least one character") 408 | } 409 | 410 | // We're going to process the given name using the same "IDNA" library we 411 | // use for the hostname portion, since it already implements the case 412 | // folding rules we want. 413 | // 414 | // The idna library doesn't expose individual label parsing directly, but 415 | // once we've verified it doesn't contain any dots we can just treat it 416 | // like a top-level domain for this library's purposes. 417 | if strings.ContainsRune(given, '.') { 418 | return "", fmt.Errorf("dots are not allowed") 419 | } 420 | 421 | // We don't allow names containing multiple consecutive dashes, just as 422 | // a matter of preference: they look weird, confusing, or incorrect. 423 | // This also, as a side-effect, prevents the use of the "punycode" 424 | // indicator prefix "xn--" that would cause the IDNA library to interpret 425 | // the given name as punycode, because that would be weird and unexpected. 426 | if strings.Contains(given, "--") { 427 | return "", fmt.Errorf("cannot use multiple consecutive dashes") 428 | } 429 | 430 | result, err := idna.Lookup.ToUnicode(given) 431 | if err != nil { 432 | return "", fmt.Errorf("must contain only letters, digits, and dashes, and may not use leading or trailing dashes") 433 | } 434 | 435 | return result, nil 436 | } 437 | 438 | // MustParseProviderPart is a wrapper around ParseProviderPart that panics if 439 | // it returns an error. 440 | func MustParseProviderPart(given string) string { 441 | result, err := ParseProviderPart(given) 442 | if err != nil { 443 | panic(err.Error()) 444 | } 445 | return result 446 | } 447 | -------------------------------------------------------------------------------- /provider_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfaddr 5 | 6 | import ( 7 | "fmt" 8 | "log" 9 | "testing" 10 | 11 | "github.com/google/go-cmp/cmp" 12 | svchost "github.com/hashicorp/terraform-svchost" 13 | ) 14 | 15 | func TestProviderString(t *testing.T) { 16 | tests := []struct { 17 | Input Provider 18 | Want string 19 | }{ 20 | { 21 | Provider{ 22 | Type: "test", 23 | Hostname: DefaultProviderRegistryHost, 24 | Namespace: "hashicorp", 25 | }, 26 | NewProvider(DefaultProviderRegistryHost, "hashicorp", "test").String(), 27 | }, 28 | { 29 | Provider{ 30 | Type: "test-beta", 31 | Hostname: DefaultProviderRegistryHost, 32 | Namespace: "hashicorp", 33 | }, 34 | NewProvider(DefaultProviderRegistryHost, "hashicorp", "test-beta").String(), 35 | }, 36 | { 37 | Provider{ 38 | Type: "test", 39 | Hostname: "registry.terraform.com", 40 | Namespace: "hashicorp", 41 | }, 42 | "registry.terraform.com/hashicorp/test", 43 | }, 44 | { 45 | Provider{ 46 | Type: "test", 47 | Hostname: DefaultProviderRegistryHost, 48 | Namespace: "othercorp", 49 | }, 50 | DefaultProviderRegistryHost.ForDisplay() + "/othercorp/test", 51 | }, 52 | } 53 | 54 | for _, test := range tests { 55 | got := test.Input.String() 56 | if got != test.Want { 57 | t.Errorf("wrong result for %s\n", test.Input.String()) 58 | } 59 | } 60 | } 61 | 62 | func TestProviderLegacyString(t *testing.T) { 63 | tests := []struct { 64 | Input Provider 65 | Want string 66 | }{ 67 | { 68 | Provider{ 69 | Type: "test", 70 | Hostname: DefaultProviderRegistryHost, 71 | Namespace: LegacyProviderNamespace, 72 | }, 73 | "test", 74 | }, 75 | { 76 | Provider{ 77 | Type: "terraform", 78 | Hostname: BuiltInProviderHost, 79 | Namespace: BuiltInProviderNamespace, 80 | }, 81 | "terraform", 82 | }, 83 | } 84 | 85 | for _, test := range tests { 86 | got := test.Input.LegacyString() 87 | if got != test.Want { 88 | t.Errorf("wrong result for %s\ngot: %s\nwant: %s", test.Input.String(), got, test.Want) 89 | } 90 | } 91 | } 92 | 93 | func TestProviderDisplay(t *testing.T) { 94 | tests := []struct { 95 | Input Provider 96 | Want string 97 | }{ 98 | { 99 | Provider{ 100 | Type: "test", 101 | Hostname: DefaultProviderRegistryHost, 102 | Namespace: "hashicorp", 103 | }, 104 | "hashicorp/test", 105 | }, 106 | { 107 | Provider{ 108 | Type: "test", 109 | Hostname: "registry.terraform.com", 110 | Namespace: "hashicorp", 111 | }, 112 | "registry.terraform.com/hashicorp/test", 113 | }, 114 | { 115 | Provider{ 116 | Type: "test", 117 | Hostname: DefaultProviderRegistryHost, 118 | Namespace: "othercorp", 119 | }, 120 | "othercorp/test", 121 | }, 122 | { 123 | Provider{ 124 | Type: "terraform", 125 | Namespace: BuiltInProviderNamespace, 126 | Hostname: BuiltInProviderHost, 127 | }, 128 | "terraform.io/builtin/terraform", 129 | }, 130 | } 131 | 132 | for _, test := range tests { 133 | got := test.Input.ForDisplay() 134 | if got != test.Want { 135 | t.Errorf("wrong result for %s: %q\n", test.Input.String(), got) 136 | } 137 | } 138 | } 139 | 140 | func TestProviderIsBuiltIn(t *testing.T) { 141 | tests := []struct { 142 | Input Provider 143 | Want bool 144 | }{ 145 | { 146 | Provider{ 147 | Type: "test", 148 | Hostname: BuiltInProviderHost, 149 | Namespace: BuiltInProviderNamespace, 150 | }, 151 | true, 152 | }, 153 | { 154 | Provider{ 155 | Type: "terraform", 156 | Hostname: BuiltInProviderHost, 157 | Namespace: BuiltInProviderNamespace, 158 | }, 159 | true, 160 | }, 161 | { 162 | Provider{ 163 | Type: "test", 164 | Hostname: BuiltInProviderHost, 165 | Namespace: "boop", 166 | }, 167 | false, 168 | }, 169 | { 170 | Provider{ 171 | Type: "test", 172 | Hostname: DefaultProviderRegistryHost, 173 | Namespace: BuiltInProviderNamespace, 174 | }, 175 | false, 176 | }, 177 | { 178 | Provider{ 179 | Type: "test", 180 | Hostname: DefaultProviderRegistryHost, 181 | Namespace: "hashicorp", 182 | }, 183 | false, 184 | }, 185 | { 186 | Provider{ 187 | Type: "test", 188 | Hostname: "registry.terraform.com", 189 | Namespace: "hashicorp", 190 | }, 191 | false, 192 | }, 193 | { 194 | Provider{ 195 | Type: "test", 196 | Hostname: DefaultProviderRegistryHost, 197 | Namespace: "othercorp", 198 | }, 199 | false, 200 | }, 201 | } 202 | 203 | for _, test := range tests { 204 | got := test.Input.IsBuiltIn() 205 | if got != test.Want { 206 | t.Errorf("wrong result for %s\ngot: %#v\nwant: %#v", test.Input.String(), got, test.Want) 207 | } 208 | } 209 | } 210 | 211 | func TestProviderIsLegacy(t *testing.T) { 212 | tests := []struct { 213 | Input Provider 214 | Want bool 215 | }{ 216 | { 217 | Provider{ 218 | Type: "test", 219 | Hostname: DefaultProviderRegistryHost, 220 | Namespace: LegacyProviderNamespace, 221 | }, 222 | true, 223 | }, 224 | { 225 | Provider{ 226 | Type: "test", 227 | Hostname: "registry.terraform.com", 228 | Namespace: LegacyProviderNamespace, 229 | }, 230 | false, 231 | }, 232 | { 233 | Provider{ 234 | Type: "test", 235 | Hostname: DefaultProviderRegistryHost, 236 | Namespace: "hashicorp", 237 | }, 238 | false, 239 | }, 240 | } 241 | 242 | for _, test := range tests { 243 | got := test.Input.IsLegacy() 244 | if got != test.Want { 245 | t.Errorf("wrong result for %s\n", test.Input.String()) 246 | } 247 | } 248 | } 249 | 250 | func ExampleParseProviderSource() { 251 | pAddr, err := ParseProviderSource("hashicorp/aws") 252 | if err != nil { 253 | log.Fatal(err) 254 | } 255 | fmt.Printf("%#v", pAddr) 256 | // Output: tfaddr.Provider{Type:"aws", Namespace:"hashicorp", Hostname:svchost.Hostname("registry.terraform.io")} 257 | } 258 | 259 | func TestParseProviderSource(t *testing.T) { 260 | tests := map[string]struct { 261 | Want Provider 262 | Err bool 263 | }{ 264 | "registry.terraform.io/hashicorp/aws": { 265 | Provider{ 266 | Type: "aws", 267 | Namespace: "hashicorp", 268 | Hostname: DefaultProviderRegistryHost, 269 | }, 270 | false, 271 | }, 272 | "registry.Terraform.io/HashiCorp/AWS": { 273 | Provider{ 274 | Type: "aws", 275 | Namespace: "hashicorp", 276 | Hostname: DefaultProviderRegistryHost, 277 | }, 278 | false, 279 | }, 280 | "terraform.io/builtin/terraform": { 281 | Provider{ 282 | Type: "terraform", 283 | Namespace: BuiltInProviderNamespace, 284 | Hostname: BuiltInProviderHost, 285 | }, 286 | false, 287 | }, 288 | // v0.12 representation 289 | // In most cases this would *likely* be the same 'terraform' provider 290 | // we otherwise represent as builtin, but we cannot be sure 291 | // in the context of the source string alone. 292 | "terraform": { 293 | Provider{ 294 | Type: "terraform", 295 | Namespace: UnknownProviderNamespace, 296 | Hostname: DefaultProviderRegistryHost, 297 | }, 298 | false, 299 | }, 300 | "hashicorp/aws": { 301 | Provider{ 302 | Type: "aws", 303 | Namespace: "hashicorp", 304 | Hostname: DefaultProviderRegistryHost, 305 | }, 306 | false, 307 | }, 308 | "HashiCorp/AWS": { 309 | Provider{ 310 | Type: "aws", 311 | Namespace: "hashicorp", 312 | Hostname: DefaultProviderRegistryHost, 313 | }, 314 | false, 315 | }, 316 | "aws": { 317 | Provider{ 318 | Type: "aws", 319 | Namespace: UnknownProviderNamespace, 320 | Hostname: DefaultProviderRegistryHost, 321 | }, 322 | false, 323 | }, 324 | "AWS": { 325 | Provider{ 326 | Type: "aws", 327 | Namespace: UnknownProviderNamespace, 328 | Hostname: DefaultProviderRegistryHost, 329 | }, 330 | false, 331 | }, 332 | "example.com/foo-bar/baz-boop": { 333 | Provider{ 334 | Type: "baz-boop", 335 | Namespace: "foo-bar", 336 | Hostname: svchost.Hostname("example.com"), 337 | }, 338 | false, 339 | }, 340 | "foo-bar/baz-boop": { 341 | Provider{ 342 | Type: "baz-boop", 343 | Namespace: "foo-bar", 344 | Hostname: DefaultProviderRegistryHost, 345 | }, 346 | false, 347 | }, 348 | "localhost:8080/foo/bar": { 349 | Provider{ 350 | Type: "bar", 351 | Namespace: "foo", 352 | Hostname: svchost.Hostname("localhost:8080"), 353 | }, 354 | false, 355 | }, 356 | "example.com/too/many/parts/here": { 357 | Provider{}, 358 | true, 359 | }, 360 | "/too///many//slashes": { 361 | Provider{}, 362 | true, 363 | }, 364 | "///": { 365 | Provider{}, 366 | true, 367 | }, 368 | "/ / /": { // empty strings 369 | Provider{}, 370 | true, 371 | }, 372 | "badhost!/hashicorp/aws": { 373 | Provider{}, 374 | true, 375 | }, 376 | "example.com/badnamespace!/aws": { 377 | Provider{}, 378 | true, 379 | }, 380 | "example.com/bad--namespace/aws": { 381 | Provider{}, 382 | true, 383 | }, 384 | "example.com/-badnamespace/aws": { 385 | Provider{}, 386 | true, 387 | }, 388 | "example.com/badnamespace-/aws": { 389 | Provider{}, 390 | true, 391 | }, 392 | "example.com/bad.namespace/aws": { 393 | Provider{}, 394 | true, 395 | }, 396 | "example.com/hashicorp/badtype!": { 397 | Provider{}, 398 | true, 399 | }, 400 | "example.com/hashicorp/bad--type": { 401 | Provider{}, 402 | true, 403 | }, 404 | "example.com/hashicorp/-badtype": { 405 | Provider{}, 406 | true, 407 | }, 408 | "example.com/hashicorp/badtype-": { 409 | Provider{}, 410 | true, 411 | }, 412 | "example.com/hashicorp/bad.type": { 413 | Provider{}, 414 | true, 415 | }, 416 | 417 | // We forbid the terraform- prefix both because it's redundant to 418 | // include "terraform" in a Terraform provider name and because we use 419 | // the longer prefix terraform-provider- to hint for users who might be 420 | // accidentally using the git repository name or executable file name 421 | // instead of the provider type. 422 | "example.com/hashicorp/terraform-provider-bad": { 423 | Provider{}, 424 | true, 425 | }, 426 | "example.com/hashicorp/terraform-bad": { 427 | Provider{}, 428 | true, 429 | }, 430 | } 431 | 432 | for name, test := range tests { 433 | got, err := ParseProviderSource(name) 434 | if diff := cmp.Diff(test.Want, got); diff != "" { 435 | t.Errorf("mismatch (%q): %s", name, diff) 436 | } 437 | if err != nil { 438 | if test.Err == false { 439 | t.Errorf("got error: %s, expected success", err) 440 | } 441 | } else { 442 | if test.Err { 443 | t.Errorf("got success, expected error") 444 | } 445 | } 446 | } 447 | } 448 | 449 | func TestParseProviderPart(t *testing.T) { 450 | tests := map[string]struct { 451 | Want string 452 | Error string 453 | }{ 454 | `foo`: { 455 | `foo`, 456 | ``, 457 | }, 458 | `FOO`: { 459 | `foo`, 460 | ``, 461 | }, 462 | `Foo`: { 463 | `foo`, 464 | ``, 465 | }, 466 | `abc-123`: { 467 | `abc-123`, 468 | ``, 469 | }, 470 | `Испытание`: { 471 | `испытание`, 472 | ``, 473 | }, 474 | `münchen`: { // this is a precomposed u with diaeresis 475 | `münchen`, // this is a precomposed u with diaeresis 476 | ``, 477 | }, 478 | `münchen`: { // this is a separate u and combining diaeresis 479 | `münchen`, // this is a precomposed u with diaeresis 480 | ``, 481 | }, 482 | `abc--123`: { 483 | ``, 484 | `cannot use multiple consecutive dashes`, 485 | }, 486 | `xn--80akhbyknj4f`: { // this is the punycode form of "испытание", but we don't accept punycode here 487 | ``, 488 | `cannot use multiple consecutive dashes`, 489 | }, 490 | `abc.123`: { 491 | ``, 492 | `dots are not allowed`, 493 | }, 494 | `-abc123`: { 495 | ``, 496 | `must contain only letters, digits, and dashes, and may not use leading or trailing dashes`, 497 | }, 498 | `abc123-`: { 499 | ``, 500 | `must contain only letters, digits, and dashes, and may not use leading or trailing dashes`, 501 | }, 502 | ``: { 503 | ``, 504 | `must have at least one character`, 505 | }, 506 | } 507 | 508 | for given, test := range tests { 509 | t.Run(given, func(t *testing.T) { 510 | got, err := ParseProviderPart(given) 511 | if test.Error != "" { 512 | if err == nil { 513 | t.Errorf("unexpected success\ngot: %s\nwant: %s", err, test.Error) 514 | } else if got := err.Error(); got != test.Error { 515 | t.Errorf("wrong error\ngot: %s\nwant: %s", got, test.Error) 516 | } 517 | } else { 518 | if err != nil { 519 | t.Errorf("unexpected error\ngot: %s\nwant: ", err) 520 | } else if got != test.Want { 521 | t.Errorf("wrong result\ngot: %s\nwant: %s", got, test.Want) 522 | } 523 | } 524 | }) 525 | } 526 | } 527 | 528 | func TestProviderEquals(t *testing.T) { 529 | tests := []struct { 530 | InputP Provider 531 | OtherP Provider 532 | Want bool 533 | }{ 534 | { 535 | NewProvider(DefaultProviderRegistryHost, "foo", "test"), 536 | NewProvider(DefaultProviderRegistryHost, "foo", "test"), 537 | true, 538 | }, 539 | { 540 | NewProvider(DefaultProviderRegistryHost, "foo", "test"), 541 | NewProvider(DefaultProviderRegistryHost, "bar", "test"), 542 | false, 543 | }, 544 | { 545 | NewProvider(DefaultProviderRegistryHost, "foo", "test"), 546 | NewProvider(DefaultProviderRegistryHost, "foo", "my-test"), 547 | false, 548 | }, 549 | { 550 | NewProvider(DefaultProviderRegistryHost, "foo", "test"), 551 | NewProvider("example.com", "foo", "test"), 552 | false, 553 | }, 554 | } 555 | for _, test := range tests { 556 | t.Run(test.InputP.String(), func(t *testing.T) { 557 | got := test.InputP.Equals(test.OtherP) 558 | if got != test.Want { 559 | t.Errorf("wrong result\ngot: %v\nwant: %v", got, test.Want) 560 | } 561 | }) 562 | } 563 | } 564 | 565 | func TestValidateProviderAddress(t *testing.T) { 566 | t.Skip("TODO") 567 | } 568 | --------------------------------------------------------------------------------