├── .github ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE.md ├── dependabot.yaml ├── dependabot.yml └── workflows │ ├── codecov.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── Makefile ├── README.md ├── docs ├── data-sources │ ├── team.md │ └── user.md ├── index.md └── resources │ ├── dns.md │ ├── domain.md │ ├── env.md │ ├── project.md │ └── secret.md ├── examples ├── README.md ├── data-sources │ └── data_user │ │ └── data-source.tf ├── e2e │ └── main.tf ├── provider │ └── provider.tf └── resources │ ├── vercel_dns │ └── resource.tf │ ├── vercel_domain │ └── resource.tf │ ├── vercel_env │ └── resource.tf │ ├── vercel_project │ └── resource.tf │ └── vercel_secret │ └── resource.tf ├── go.mod ├── go.sum ├── internal └── provider │ ├── data_source_team.go │ ├── data_source_user.go │ ├── data_source_user_test.go │ ├── provider.go │ ├── provider_test.go │ ├── resource_dns.go │ ├── resource_domain.go │ ├── resource_domain_test.go │ ├── resource_env.go │ ├── resource_env_test.go │ ├── resource_project.go │ ├── resource_project_test.go │ ├── resource_secret.go │ └── resource_secret_test.go ├── main.go ├── pkg ├── util │ ├── env.go │ ├── util.go │ └── util_test.go └── vercel │ ├── alias │ └── alias.go │ ├── client.go │ ├── dns │ └── dns.go │ ├── domain │ └── domain.go │ ├── env │ └── env.go │ ├── httpApi │ └── httpApi.go │ ├── project │ ├── project.go │ └── types.go │ ├── secret │ └── secret.go │ ├── team │ └── team.go │ └── user │ └── user.go └── tools └── tools.go /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | HashiCorp Community Guidelines apply to you when interacting with the community here on GitHub and contributing code. 4 | 5 | Please read the full text at https://www.hashicorp.com/community-guidelines 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Hi there, 2 | 3 | Thank you for opening an issue! 4 | 5 | ### Terraform Version 6 | Run `terraform -v` to show the version. We support terraform v0.13+ 7 | 8 | ### Affected Resource(s) 9 | Please list the resources as a list, for example: 10 | - vercel_project 11 | - vercel_domain 12 | 13 | If this issue appears to affect multiple resources, it may be an issue with Terraform's core, so please mention this. 14 | 15 | ### Terraform Configuration Files 16 | ```hcl 17 | # Copy-paste your Terraform configurations here - for large Terraform configs, 18 | # please use a service like Dropbox and share a link to the ZIP file. For 19 | # security, you can also encrypt the files using our GPG public key. 20 | ``` 21 | 22 | ### Debug Output 23 | Please provider a link to a GitHub Gist containing the complete debug output: https://www.terraform.io/docs/internals/debugging.html. Please do NOT paste the debug output in the issue; just paste a link to the Gist. 24 | 25 | ### Panic Output 26 | If Terraform produced a panic, please provide a link to a GitHub Gist containing the output of the `crash.log`. 27 | 28 | ### Expected Behavior 29 | What should have happened? 30 | 31 | ### Actual Behavior 32 | What actually happened? 33 | 34 | ### Steps to Reproduce 35 | Please list the steps required to reproduce the issue, for example: 36 | 1. `terraform apply` 37 | 38 | ### Important Factoids 39 | Are there anything atypical about your accounts that we should know? For example: Running in EC2 Classic? Custom version of OpenStack? Tight ACLs? 40 | 41 | ### References 42 | Are there any other GitHub issues (open or closed) or Pull Requests that should be linked here? For example: 43 | - GH-1234 44 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | assignees: 8 | - chronark -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # See GitHub's docs for more information on this file: 2 | # https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/configuration-options-for-dependency-updates 3 | version: 2 4 | updates: 5 | # Maintain dependencies for GitHub Actions 6 | - package-ecosystem: "github-actions" 7 | directory: "/" 8 | schedule: 9 | # Check for updates to GitHub Actions every weekday 10 | interval: "daily" 11 | 12 | # Maintain dependencies for Go modules 13 | - package-ecosystem: "gomod" 14 | directory: "/" 15 | schedule: 16 | # Check for updates to Go modules every weekday 17 | interval: "daily" 18 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | name: Report coverage 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | 8 | jobs: 9 | collect-coverage: 10 | runs-on: ubuntu-latest 11 | steps: 12 | 13 | - name: Set up Go 14 | uses: actions/setup-go@v2 15 | with: 16 | go-version: 1.16 17 | 18 | - name: Check out code into the Go module directory 19 | uses: actions/checkout@v2 20 | 21 | - name: Get dependencies 22 | run: go mod download 23 | 24 | - name: Test 25 | run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... 26 | env: 27 | TF_ACC: "1" 28 | VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} 29 | 30 | 31 | - name: Run codecov coverage reporter 32 | run: bash <(curl -s https://codecov.io/bash) 33 | env: 34 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 35 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | jobs: 7 | goreleaser: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - 11 | name: Checkout 12 | uses: actions/checkout@v2 13 | - 14 | name: Unshallow 15 | run: git fetch --prune --unshallow 16 | - 17 | name: Set up Go 18 | uses: actions/setup-go@v2 19 | with: 20 | go-version: 1.16 21 | - 22 | name: Import GPG key 23 | id: import_gpg 24 | uses: paultyng/ghaction-import-gpg@v2.1.0 25 | env: 26 | GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} 27 | PASSPHRASE: ${{ secrets.PASSPHRASE }} 28 | - 29 | name: Run GoReleaser 30 | uses: goreleaser/goreleaser-action@v2 31 | with: 32 | version: latest 33 | args: release --rm-dist 34 | env: 35 | GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This GitHub action runs your tests for each commit push and/or PR. Optionally 2 | # you can turn it on using a cron schedule for regular testing. 3 | # 4 | name: Tests 5 | on: 6 | pull_request: 7 | # For systems with an upstream API that could drift unexpectedly (like most SaaS systems, etc.), 8 | # we recommend testing at a regular interval not necessarily tied to code changes. This will 9 | # ensure you are alerted to something breaking due to an API change, even if the code did not 10 | # change. 11 | schedule: 12 | - cron: "0 23 * * *" 13 | jobs: 14 | # ensure the code builds... 15 | build: 16 | name: Build 17 | runs-on: ubuntu-latest 18 | timeout-minutes: 5 19 | steps: 20 | - name: Set up Go 21 | uses: actions/setup-go@v2.1.3 22 | with: 23 | go-version: "1.16" 24 | id: go 25 | 26 | - name: Check out code into the Go module directory 27 | uses: actions/checkout@v2.3.3 28 | 29 | - name: Get dependencies 30 | run: | 31 | go mod download 32 | 33 | - name: Build 34 | run: | 35 | go build -v . 36 | 37 | lint: 38 | name: Lint 39 | runs-on: ubuntu-latest 40 | timeout-minutes: 5 41 | steps: 42 | - name: Set up Go 43 | uses: actions/setup-go@v2.1.3 44 | with: 45 | go-version: "1.16" 46 | 47 | - name: Check out code into the Go module directory 48 | uses: actions/checkout@v2.3.3 49 | 50 | - name: golangci-lint 51 | uses: golangci/golangci-lint-action@v2 52 | with: 53 | version: latest 54 | 55 | # run acceptance tests in a matrix with Terraform core versions 56 | test: 57 | name: Acceptance test 58 | needs: 59 | - build 60 | - lint 61 | runs-on: ubuntu-latest 62 | timeout-minutes: 15 63 | strategy: 64 | max-parallel: 1 65 | fail-fast: false 66 | matrix: 67 | terraform: ["1.1.4"] 68 | steps: 69 | - name: Set up Go 70 | uses: actions/setup-go@v2.1.3 71 | with: 72 | go-version: 1.16 73 | 74 | - name: Check out code into the Go module directory 75 | uses: actions/checkout@v2.3.3 76 | 77 | - name: Get dependencies 78 | run: go mod download 79 | 80 | - name: Set up terraform 81 | uses: hashicorp/setup-terraform@v1 82 | with: 83 | terraform_version: ${{ matrix.terraform }} 84 | terraform_wrapper: false 85 | 86 | - name: TF acceptance tests 87 | timeout-minutes: 10 88 | env: 89 | TF_ACC: "1" 90 | VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} 91 | 92 | run: go test -v ./... 93 | 94 | # Get a single check for the branch protection rules. 95 | tests_ok: 96 | name: Tests OK 97 | needs: test 98 | runs-on: ubuntu-latest 99 | steps: 100 | - run: echo Success! 101 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.dll 2 | *.exe 3 | .DS_Store 4 | example.tf 5 | terraform.tfplan 6 | terraform.tfstate 7 | bin/ 8 | dist/ 9 | modules-dev/ 10 | website/.vagrant 11 | website/.bundle 12 | website/build 13 | website/node_modules 14 | .vagrant/ 15 | *.backup 16 | ./*.tfstate 17 | .terraform/ 18 | *.log 19 | *.bak 20 | *~ 21 | .*.swp 22 | .idea 23 | *.iml 24 | *.test 25 | *.iml 26 | 27 | website/vendor 28 | 29 | # Test exclusions 30 | !command/test-fixtures/**/*.tfstate 31 | !command/test-fixtures/**/.terraform/ 32 | 33 | # Keep windows files with windows line endings 34 | *.winfile eol=crlf 35 | .terraform.lock.hcl 36 | terraform.tfstate* 37 | vendor/ 38 | .vscode/ -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # Visit https://goreleaser.com for documentation on how to customize this 2 | # behavior. 3 | before: 4 | hooks: 5 | # this is just an example and not a requirement for provider building/publishing 6 | - go mod tidy 7 | - go generate 8 | builds: 9 | - env: 10 | # goreleaser does not work with CGO, it could also complicate 11 | # usage by users in CI/CD systems like Terraform Cloud where 12 | # they are unable to install libraries. 13 | - CGO_ENABLED=0 14 | mod_timestamp: '{{ .CommitTimestamp }}' 15 | flags: 16 | - -trimpath 17 | ldflags: 18 | - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' 19 | goos: 20 | - freebsd 21 | - windows 22 | - linux 23 | - darwin 24 | goarch: 25 | - amd64 26 | - '386' 27 | - arm 28 | - arm64 29 | binary: '{{ .ProjectName }}_v{{ .Version }}' 30 | archives: 31 | - format: zip 32 | name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' 33 | checksum: 34 | name_template: '{{ .ProjectName }}_{{ .Version }}_SHA256SUMS' 35 | algorithm: sha256 36 | signs: 37 | - artifacts: checksum 38 | args: 39 | # if you are using this in a GitHub action or some other automated pipeline, you 40 | # need to pass the batch flag to indicate its not interactive. 41 | - "--batch" 42 | - "--local-user" 43 | - "{{ .Env.GPG_FINGERPRINT }}" # set this environment variable for your signing key 44 | - "--output" 45 | - "${signature}" 46 | - "--detach-sign" 47 | - "${artifact}" 48 | release: 49 | # If you want to manually examine the release before its live, uncomment this line: 50 | # draft: true 51 | changelog: 52 | skip: false 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | 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, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: testacc 2 | 3 | # Run acceptance tests 4 | .PHONY: testacc 5 | testacc: 6 | TF_ACC=1 go test ./... -v $(TESTARGS) -timeout 2m -race -covermode=atomic 7 | 8 | test: 9 | go test ./... -v 10 | 11 | build: fmt 12 | go build -o terraform-provider-vercel 13 | mkdir -p ~/.terraform.d/plugins/hashicorp.com/chronark/vercel/9000.1/linux_amd64 14 | mv terraform-provider-vercel ~/.terraform.d/plugins/hashicorp.com/chronark/vercel/9000.1/linux_amd64 15 | 16 | 17 | fmt: 18 | 19 | go generate -v ./... 20 | golangci-lint run -v 21 | go fmt ./... 22 | terraform fmt -recursive . 23 | 24 | rm-state: 25 | rm -rf examples/e2e/terraform.tfstate* 26 | 27 | 28 | init: build 29 | rm -rf examples/e2e/.terraform* 30 | terraform -chdir=examples/e2e init -upgrade 31 | e2e: init 32 | terraform -chdir=examples/e2e apply 33 | 34 | 35 | release: 36 | @go get github.com/caarlos0/svu 37 | @echo "Releasing $$(svu next)..." 38 | 39 | @git tag $$(svu next) && git push --tags 40 | @echo "Done" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Terraform logo 4 | 5 | 6 | 7 | 8 | # DEPRECATED, please use the [official provider by vercel](https://github.com/vercel/terraform-provider-vercel) 9 | 10 | 11 | # Terraform Provider for Vercel 12 | 13 | Configure vercel resources such as projects, deployments and secrets as code with terraform. 14 | 15 |
16 | 17 |
18 | 19 | 20 | 21 | ## Features 22 | 23 | This provider has not reached feauture parity with the vercel api yet. I am adding new features as I need them. 24 | Please create an issue if you requrie a certain feature, I will work on them asap. 25 | 26 | Available features can be found [here](https://registry.terraform.io/providers/chronark/vercel/latest/docs). 27 | 28 | ## Quickstart 29 | 30 | 1. Create a token [here](https://vercel.com/account/tokens) 31 | 2. Create a `vercel.tf` file with the following content. 32 | - Replace `` with the token from step 1. Alternatively you can set the `VERCEL_TOKEN` environment variable 33 | - Change the `git_repository` to whatever you want to deploy. 34 | 35 | ```tf 36 | terraform { 37 | required_providers { 38 | vercel = { 39 | source = "registry.terraform.io/chronark/vercel" 40 | version = ">=0.10.3" 41 | } 42 | } 43 | } 44 | 45 | provider "vercel" { 46 | token = "" 47 | } 48 | 49 | resource "vercel_project" "my_project" { 50 | name = "mercury-via-terraform" 51 | git_repository { 52 | type = "github" 53 | repo = "chronark/terraform-provider-vercel" 54 | } 55 | } 56 | ``` 57 | 58 | 3. Run 59 | ```sh 60 | terraform init 61 | terraform apply 62 | ``` 63 | 64 | 65 | 4. Check vercel's [dashboard](https://vercel.com/dashboard) to see your project. 66 | 5. Push to the default branch of your repository to create your first deployment. 67 | 68 | ## Documentation 69 | 70 | Documentation can be found [here](https://registry.terraform.io/providers/chronark/vercel/latest/docs) 71 | 72 | ## Development Requirements 73 | 74 | - [Terraform](https://www.terraform.io/downloads.html) >= 0.13.x 75 | - [Go](https://golang.org/doc/install) >= 1.15 76 | -------------------------------------------------------------------------------- /docs/data-sources/team.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "vercel_team Data Source - terraform-provider-vercel" 4 | subcategory: "" 5 | description: |- 6 | Retrieves information related to an existing team. https://vercel.com/docs/api#endpoints/teams 7 | --- 8 | 9 | # vercel_team (Data Source) 10 | 11 | Retrieves information related to an existing team. https://vercel.com/docs/api#endpoints/teams 12 | 13 | 14 | 15 | 16 | ## Schema 17 | 18 | ### Required 19 | 20 | - **slug** (String) 21 | 22 | ### Read-Only 23 | 24 | - **avatar** (String) 25 | - **created** (Number) 26 | - **creator_id** (String) 27 | - **id** (String) The ID of this resource. 28 | - **name** (String) 29 | 30 | 31 | -------------------------------------------------------------------------------- /docs/data-sources/user.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "vercel_user Data Source - terraform-provider-vercel" 4 | subcategory: "" 5 | description: |- 6 | Retrieves information related to the authenticated user. https://vercel.com/docs/api#endpoints/user/get-the-authenticated-user 7 | --- 8 | 9 | # vercel_user (Data Source) 10 | 11 | Retrieves information related to the authenticated user. https://vercel.com/docs/api#endpoints/user/get-the-authenticated-user 12 | 13 | 14 | 15 | 16 | ## Schema 17 | 18 | ### Read-Only 19 | 20 | - **avatar** (String) 21 | - **bio** (String) 22 | - **email** (String) 23 | - **id** (String) The ID of this resource. 24 | - **name** (String) 25 | - **platformversion** (Number) 26 | - **profiles** (List of Object) (see [below for nested schema](#nestedatt--profiles)) 27 | - **username** (String) 28 | - **website** (String) 29 | 30 | 31 | ### Nested Schema for `profiles` 32 | 33 | Read-Only: 34 | 35 | - **link** (String) 36 | - **service** (String) 37 | 38 | 39 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "vercel Provider" 4 | subcategory: "" 5 | description: |- 6 | 7 | --- 8 | 9 | # vercel Provider 10 | 11 | 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | terraform { 17 | required_providers { 18 | vercel = { 19 | source = "hashicorp.com/chronark/vercel" 20 | version = "9000.1" 21 | } 22 | } 23 | } 24 | 25 | provider "vercel" { 26 | token = "" 27 | } 28 | ``` 29 | 30 | 31 | ## Schema 32 | 33 | ### Optional 34 | 35 | - **token** (String, Sensitive) 36 | -------------------------------------------------------------------------------- /docs/resources/dns.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "vercel_dns Resource - terraform-provider-vercel" 4 | subcategory: "" 5 | description: |- 6 | https://vercel.com/docs/api#endpoints/dns 7 | Currently this can only fetch the last 1000 records. Please create an issue if you require more. 8 | --- 9 | 10 | # vercel_dns (Resource) 11 | 12 | https://vercel.com/docs/api#endpoints/dns 13 | Currently this can only fetch the last 1000 records. Please create an issue if you require more. 14 | 15 | ## Example Usage 16 | 17 | ```terraform 18 | resource "vercel_domain" "chronark_com" { 19 | name = "chronark.com" 20 | } 21 | 22 | 23 | resource "vercel_dns" "www" { 24 | domain = vercel_domain.chronark_com.name 25 | type = "CNAME" 26 | value = "www.${vercel_domain.chronark_com.name}" 27 | name = "www" 28 | } 29 | ``` 30 | 31 | 32 | ## Schema 33 | 34 | ### Required 35 | 36 | - **domain** (String) The domain for this DNS record 37 | - **name** (String) A subdomain name or an empty string for the root domain. 38 | - **type** (String) The type of record, it could be any valid DNS record. 39 | - **value** (String) The record value. 40 | 41 | ### Optional 42 | 43 | - **team_id** (String) By default, you can access resources contained within your own user account. To access resources owned by a team, you can pass in the team ID 44 | - **ttl** (Number) The TTL value. Must be a number between 60 and 2147483647. Default value is 60. 45 | 46 | ### Read-Only 47 | 48 | - **created** (Number) The date when the record was created. 49 | - **created_at** (Number) The date when the record was created in milliseconds since the UNIX epoch. 50 | - **creator** (String) The ID of the user who created the record or system if the record is an automatic record. 51 | - **id** (String) The unique identifier of the dns record. 52 | - **updated** (Number) The date when the record was updated. 53 | - **updated_at** (Number) The date when the record was updated in milliseconds since the UNIX epoch. 54 | 55 | 56 | -------------------------------------------------------------------------------- /docs/resources/domain.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "vercel_domain Resource - terraform-provider-vercel" 4 | subcategory: "" 5 | description: |- 6 | Creates account wide domains to be used in your projects. 7 | --- 8 | 9 | # vercel_domain (Resource) 10 | 11 | Creates account wide domains to be used in your projects. 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | resource "vercel_domain" "google-com" { 17 | name = "google.com" 18 | } 19 | ``` 20 | 21 | 22 | ## Schema 23 | 24 | ### Required 25 | 26 | - **name** (String) The name of the production domain. 27 | 28 | ### Optional 29 | 30 | - **team_id** (String) By default, you can access resources contained within your own user account. To access resources owned by a team, you can pass in the team ID 31 | 32 | ### Read-Only 33 | 34 | - **bought_at** (Number) If it was purchased through Vercel, the date when it was purchased. 35 | - **cdn_enabled** (Boolean) Whether the domain has the Vercel Edge Network enabled or not. 36 | - **created_at** (Number) The date when the domain was created in the registry. 37 | - **expires_at** (Number) The date at which the domain is set to expire. null if not bought with Vercel. 38 | - **id** (String) Unique id for this variable. 39 | - **intended_nameservers** (List of String) A list of the intended nameservers for the domain to point to Vercel DNS. 40 | - **nameservers** (List of String) A list of the current nameservers of the domain. 41 | - **ns_verified_at** (Number) The date at which the domain's nameservers were verified based on the intended set. 42 | - **service_type** (String) The type of service the domain is handled by. external if the DNS is externally handled, or zeit.world if handled with Vercel. 43 | - **transfer_started_at** (Number) If transferred into Vercel, The date when the domain transfer was initiated 44 | - **transferred_at** (Number) The date at which the domain was successfully transferred into Vercel. null if the transfer is still processing or was never transferred in. 45 | - **txt_verified_at** (Number) The date at which the domain's TXT DNS record was verified. 46 | - **verification_record** (String) The ID of the verification record in the registry. 47 | - **verified** (Boolean) If the domain has the ownership verified. 48 | 49 | 50 | ## Importing 51 | 52 | Domains can be imported from Vercel using the domain name itself, e.g: 53 | 54 | ``` 55 | $ terraform import vercel_domain.domain example.com 56 | ``` 57 | 58 | For team domains, prefix the domain name with the team slug followed by a slash (`/`), e.g: 59 | 60 | ``` 61 | $ terraform import vercel_domain.domain my-team/example.com 62 | ``` 63 | 64 | The team slug can be found in the URL for any team screen in Vercel's web interface. 65 | 66 | Slashes in the team slug may be URL-escaped (percent-encoded) if needed. 67 | 68 | -------------------------------------------------------------------------------- /docs/resources/env.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "vercel_env Resource - terraform-provider-vercel" 4 | subcategory: "" 5 | description: |- 6 | https://vercel.com/docs/api#endpoints/projects/get-project-environment-variables 7 | --- 8 | 9 | # vercel_env (Resource) 10 | 11 | https://vercel.com/docs/api#endpoints/projects/get-project-environment-variables 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | resource "vercel_project" "my_project" { 17 | // ... 18 | } 19 | 20 | resource "vercel_env" "my_env" { 21 | project_id = vercel_project.my_project.id // or use a hardcoded value of an existing project 22 | type = "plain" 23 | key = "hello" 24 | value = "world" 25 | target = ["production", "preview", "development"] 26 | } 27 | ``` 28 | 29 | 30 | ## Schema 31 | 32 | ### Required 33 | 34 | - **key** (String) The name of the environment variable. 35 | - **project_id** (String) The unique project identifier. 36 | - **target** (List of String) The target can be a list of `development`, `preview`, or `production`. 37 | - **type** (String) The type can be `plain`, `secret`, or `system`. 38 | - **value** (String) If the type is `plain`, a string representing the value of the environment variable. If the type is `secret`, the secret ID of the secret attached to the environment variable. If the type is `system`, the name of the System Environment Variable. 39 | 40 | ### Optional 41 | 42 | - **team_id** (String) By default, you can access resources contained within your own user account. To access resources owned by a team, you can pass in the team ID 43 | 44 | ### Read-Only 45 | 46 | - **created_at** (Number) A number containing the date when the variable was created in milliseconds. 47 | - **id** (String) Unique id for this variable. 48 | - **updated_at** (Number) A number containing the date when the variable was updated in milliseconds. 49 | 50 | ## Importing 51 | 52 | Use the project and variable names separated by a slash (`/`), e.g: 53 | 54 | ``` 55 | $ terraform import vercel_env.env my-project/THE_VARIABLE 56 | ``` 57 | 58 | For team projects, include the team slug as a prefix, e.g: 59 | 60 | ``` 61 | $ terraform import vercel_project.team_app my-team/my-project/THE_VARIABLE 62 | ``` 63 | 64 | The team slug can be found in the URL for any team screen in Vercel's web interface. 65 | 66 | Slashes in the project name or team slug may be URL-escaped (percent-encoded) if needed. 67 | 68 | -------------------------------------------------------------------------------- /docs/resources/project.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "vercel_project Resource - terraform-provider-vercel" 4 | subcategory: "" 5 | description: |- 6 | https://vercel.com/docs/api#endpoints/projects 7 | --- 8 | 9 | # vercel_project (Resource) 10 | 11 | https://vercel.com/docs/api#endpoints/projects 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | resource "vercel_project" "my_project" { 17 | name = "mercury" 18 | git_repository { 19 | type = "github" 20 | repo = "chronark/terraform-provider-vercel" 21 | } 22 | } 23 | ``` 24 | 25 | 26 | ## Schema 27 | 28 | ### Required 29 | 30 | - **git_repository** (Block List, Min: 1, Max: 1) The git repository that will be connected to the project. Any pushes to the specified connected git repository will be automatically deployed. (see [below for nested schema](#nestedblock--git_repository)) 31 | - **name** (String) The name of the project. 32 | 33 | ### Optional 34 | 35 | - **alias** (List of String) A list of production domains for the project. 36 | - **build_command** (String) The build command for this project. When null is used this value will be automatically detected. 37 | - **dev_command** (String) The dev command for this project. When null is used this value will be automatically detected. 38 | - **domain** (Block List) Add a domain to the project by passing the project. (see [below for nested schema](#nestedblock--domain)) 39 | - **framework** (String) The framework that is being used for this project. When null is used no framework is selected. 40 | - **install_command** (String) The install command for this project. When null is used this value will be automatically detected. 41 | - **node_version** (String) The Node.js Version for this project. 42 | - **output_directory** (String) The output directory of the project. When null is used this value will be automatically detected. 43 | - **public_source** (Boolean) Specifies whether the source code and logs of the deployments for this project should be public or not. 44 | - **root_directory** (String) The name of a directory or relative path to the source code of your project. When null is used it will default to the project root. 45 | - **serverless_function_region** (String) The region to deploy Serverless Functions in this project. 46 | - **team_id** (String) By default, you can access resources contained within your own user account. To access resources owned by a team, you can pass in the team ID 47 | 48 | ### Read-Only 49 | 50 | - **account_id** (String) The unique ID of the user or team the project belongs to. 51 | - **created_at** (Number) A number containing the date when the project was created in milliseconds. 52 | - **id** (String) Internal id of this project 53 | - **updated_at** (Number) A number containing the date when the project was updated in milliseconds. 54 | 55 | 56 | ### Nested Schema for `git_repository` 57 | 58 | Required: 59 | 60 | - **repo** (String) The name of the git repository. For example: `chronark/terraform-provider-vercel` 61 | - **type** (String) The git provider of the repository. Must be either `github`, `gitlab`, or `bitbucket`. 62 | 63 | 64 | ### Nested Schema for `domain` 65 | 66 | Required: 67 | 68 | - **name** (String) The name of the production domain. 69 | 70 | Optional: 71 | 72 | - **git_branch** (String) it branch for the domain to be auto assigned to. The Project's production branch is the default (null). 73 | - **redirect** (String) Target destination domain for redirect. 74 | - **redirect_status_code** (Number) The redirect status code (301, 302, 307, 308). 75 | 76 | 77 | ## Importing 78 | 79 | Project resources can be imported using the project name, e.g: 80 | 81 | ``` 82 | $ terraform import vercel_project.app my-project 83 | ``` 84 | 85 | For team projects, prefix the project name with the team slug followed by a slash (`/`), e.g: 86 | 87 | ``` 88 | $ terraform import vercel_project.team_app my-team/my-project 89 | ``` 90 | 91 | The team slug can be found in the URL for any team screen in Vercel's web interface. 92 | 93 | Slashes in the project name or team slug may be URL-escaped (percent-encoded) if needed. 94 | -------------------------------------------------------------------------------- /docs/resources/secret.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "vercel_secret Resource - terraform-provider-vercel" 4 | subcategory: "" 5 | description: |- 6 | https://vercel.com/docs/api#endpoints/secrets 7 | --- 8 | 9 | # vercel_secret (Resource) 10 | 11 | https://vercel.com/docs/api#endpoints/secrets 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | // Create a new secret 17 | resource "vercel_secret" "my_secret" { 18 | name = "my_secret_name" // `@` will be prefixed automatically 19 | value = "super secret" 20 | } 21 | 22 | // Use the secret 23 | resource "vercel_env" "env" { 24 | type = "secret" 25 | key = "Hello" 26 | value = vercel_secret.my_secret.id 27 | 28 | // irrelevant values omitted 29 | } 30 | ``` 31 | 32 | 33 | ## Schema 34 | 35 | ### Required 36 | 37 | - **name** (String) The name of the secret. 38 | - **value** (String, Sensitive) The value of the new secret. 39 | 40 | ### Optional 41 | 42 | - **team_id** (String) By default, you can access resources contained within your own user account. To access resources owned by a team, you can pass in the team ID 43 | 44 | ### Read-Only 45 | 46 | - **created_at** (Number) A number containing the date when the variable was created in milliseconds. 47 | - **id** (String) The unique identifier of the secret. 48 | - **user_id** (String) The unique identifier of the user who created the secret. 49 | 50 | ## Importing 51 | 52 | Use the Vercel secret ID: 53 | 54 | ``` 55 | $ terraform import vercel_secret.thesecret sec_CTjwzonA7MCjQnDDP0yQbDc8 56 | ``` 57 | 58 | Please note that after secrets are written, Vercel does not allow reading the value. 59 | Subsequent operations may overwrite or replace the resource because of that. -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | This directory contains examples that are mostly used for documentation, but can also be run/tested manually via the Terraform CLI. 4 | 5 | The document generation tool looks for files in the following locations by default. All other *.tf files besides the ones mentioned below are ignored by the documentation tool. This is useful for creating examples that can run and/or ar testable even if some parts are not relevant for the documentation. 6 | 7 | * **provider/provider.tf** example file for the provider index page 8 | * **data-sources//data-source.tf** example file for the named data source page 9 | * **resources//resource.tf** example file for the named data source page 10 | -------------------------------------------------------------------------------- /examples/data-sources/data_user/data-source.tf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | data "vercel_user" "user" {} -------------------------------------------------------------------------------- /examples/e2e/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | vercel = { 4 | source = "hashicorp.com/chronark/vercel" 5 | version = "9000.1" 6 | } 7 | } 8 | } 9 | 10 | provider "vercel" { 11 | } 12 | 13 | 14 | 15 | resource "vercel_project" "my_project" { 16 | name = "test" 17 | git_repository { 18 | type = "github" 19 | repo = "chronark/terraform-provider-vercel" 20 | } 21 | 22 | domain { 23 | name = "one.chronark.com" 24 | } 25 | domain { 26 | name = "two.chronark.com" 27 | } 28 | 29 | 30 | } 31 | 32 | 33 | resource "vercel_domain" "chronark_com" { 34 | name = "chronark.com" 35 | } 36 | 37 | 38 | resource "vercel_dns" "www" { 39 | domain = vercel_domain.chronark_com.name 40 | type = "CNAME" 41 | value = "www.${vercel_domain.chronark_com.name}" 42 | name = "www" 43 | } -------------------------------------------------------------------------------- /examples/provider/provider.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | vercel = { 4 | source = "hashicorp.com/chronark/vercel" 5 | version = "9000.1" 6 | } 7 | } 8 | } 9 | 10 | provider "vercel" { 11 | token = "" 12 | } 13 | -------------------------------------------------------------------------------- /examples/resources/vercel_dns/resource.tf: -------------------------------------------------------------------------------- 1 | 2 | 3 | resource "vercel_domain" "chronark_com" { 4 | name = "chronark.com" 5 | } 6 | 7 | 8 | resource "vercel_dns" "www" { 9 | domain = vercel_domain.chronark_com.name 10 | type = "CNAME" 11 | value = "www.${vercel_domain.chronark_com.name}" 12 | name = "www" 13 | } -------------------------------------------------------------------------------- /examples/resources/vercel_domain/resource.tf: -------------------------------------------------------------------------------- 1 | 2 | resource "vercel_domain" "google-com" { 3 | name = "google.com" 4 | } -------------------------------------------------------------------------------- /examples/resources/vercel_env/resource.tf: -------------------------------------------------------------------------------- 1 | 2 | resource "vercel_project" "my_project" { 3 | // ... 4 | } 5 | 6 | resource "vercel_env" "my_env" { 7 | project_id = vercel_project.my_project.id // or use a hardcoded value of an existing project 8 | type = "plain" 9 | key = "hello" 10 | value = "world" 11 | target = ["production", "preview", "development"] 12 | } -------------------------------------------------------------------------------- /examples/resources/vercel_project/resource.tf: -------------------------------------------------------------------------------- 1 | 2 | resource "vercel_project" "my_project" { 3 | name = "mercury" 4 | git_repository { 5 | type = "github" 6 | repo = "chronark/terraform-provider-vercel" 7 | } 8 | } -------------------------------------------------------------------------------- /examples/resources/vercel_secret/resource.tf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | // Create a new secret 6 | resource "vercel_secret" "my_secret" { 7 | name = "my_secret_name" // `@` will be prefixed automatically 8 | value = "super secret" 9 | } 10 | 11 | // Use the secret 12 | resource "vercel_env" "env" { 13 | type = "secret" 14 | key = "Hello" 15 | value = vercel_secret.my_secret.id 16 | 17 | // irrelevant values omitted 18 | } 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/chronark/terraform-provider-vercel 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/hashicorp/go-uuid v1.0.2 7 | github.com/hashicorp/terraform-plugin-docs v0.4.0 8 | github.com/hashicorp/terraform-plugin-sdk/v2 v2.8.0 9 | github.com/matryer/is v1.4.0 // indirect 10 | github.com/stretchr/testify v1.7.0 11 | golang.org/x/tools v0.1.1-0.20210504170620-03ebc2c9fca8 // indirect 12 | gopkg.in/yaml.v2 v2.4.0 // indirect 13 | ) 14 | -------------------------------------------------------------------------------- /internal/provider/data_source_team.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/chronark/terraform-provider-vercel/pkg/vercel" 7 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 8 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 9 | ) 10 | 11 | func dataSourceTeam() *schema.Resource { 12 | return &schema.Resource{ 13 | Description: "Retrieves information related to an existing team. https://vercel.com/docs/api#endpoints/teams", 14 | ReadContext: dataSourceTeamRead, 15 | Schema: map[string]*schema.Schema{ 16 | "id": { 17 | Computed: true, 18 | Type: schema.TypeString, 19 | }, 20 | "slug": { 21 | Required: true, 22 | Type: schema.TypeString, 23 | }, 24 | "name": { 25 | Computed: true, 26 | Type: schema.TypeString, 27 | }, 28 | "creator_id": { 29 | Computed: true, 30 | Type: schema.TypeString, 31 | }, 32 | "avatar": { 33 | Computed: true, 34 | Type: schema.TypeString, 35 | }, 36 | "created": { 37 | Computed: true, 38 | Type: schema.TypeInt, 39 | }, 40 | }, 41 | } 42 | } 43 | 44 | func dataSourceTeamRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { 45 | 46 | client := meta.(*vercel.Client) 47 | 48 | slug := d.Get("slug").(string) 49 | 50 | team, err := client.Team.Read(slug) 51 | if err != nil { 52 | return diag.FromErr(err) 53 | } 54 | err = d.Set("name", team.Name) 55 | if err != nil { 56 | return diag.FromErr(err) 57 | } 58 | 59 | err = d.Set("creator_id", team.CreatorId) 60 | if err != nil { 61 | return diag.FromErr(err) 62 | } 63 | 64 | err = d.Set("avatar", team.Avatar) 65 | if err != nil { 66 | return diag.FromErr(err) 67 | } 68 | 69 | err = d.Set("created", team.Created.Unix()) 70 | if err != nil { 71 | return diag.FromErr(err) 72 | } 73 | 74 | d.SetId(team.Id) 75 | 76 | return diag.Diagnostics{} 77 | } 78 | -------------------------------------------------------------------------------- /internal/provider/data_source_user.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/chronark/terraform-provider-vercel/pkg/vercel" 7 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 8 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 9 | ) 10 | 11 | func dataSourceUser() *schema.Resource { 12 | return &schema.Resource{ 13 | // This description is used by the documentation generator and the language server. 14 | Description: "Retrieves information related to the authenticated user. https://vercel.com/docs/api#endpoints/user/get-the-authenticated-user", 15 | ReadContext: dataSourceUserRead, 16 | Schema: map[string]*schema.Schema{ 17 | "id": { 18 | Computed: true, 19 | Type: schema.TypeString, 20 | }, 21 | "email": { 22 | Computed: true, 23 | Type: schema.TypeString, 24 | }, 25 | "name": { 26 | Computed: true, 27 | Type: schema.TypeString, 28 | }, 29 | "username": { 30 | Computed: true, 31 | Type: schema.TypeString, 32 | }, 33 | "avatar": { 34 | Computed: true, 35 | Type: schema.TypeString, 36 | }, 37 | "platformversion": { 38 | Computed: true, 39 | Type: schema.TypeInt, 40 | }, 41 | 42 | "bio": { 43 | Computed: true, 44 | Type: schema.TypeString, 45 | }, 46 | "website": { 47 | Computed: true, 48 | Type: schema.TypeString, 49 | }, 50 | "profiles": { 51 | Computed: true, 52 | Type: schema.TypeList, 53 | Elem: &schema.Resource{ 54 | Schema: map[string]*schema.Schema{ 55 | "service": { 56 | Computed: true, 57 | Type: schema.TypeString, 58 | }, 59 | "link": { 60 | Computed: true, 61 | Type: schema.TypeString, 62 | }, 63 | }, 64 | }, 65 | }, 66 | }, 67 | } 68 | } 69 | 70 | func dataSourceUserRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { 71 | 72 | client := meta.(*vercel.Client) 73 | 74 | user, err := client.User.Read() 75 | if err != nil { 76 | return diag.FromErr(err) 77 | } 78 | err = d.Set("email", user.Email) 79 | if err != nil { 80 | return diag.FromErr(err) 81 | } 82 | 83 | err = d.Set("name", user.Name) 84 | if err != nil { 85 | return diag.FromErr(err) 86 | } 87 | 88 | err = d.Set("username", user.Username) 89 | if err != nil { 90 | return diag.FromErr(err) 91 | } 92 | 93 | err = d.Set("avatar", user.Avatar) 94 | if err != nil { 95 | return diag.FromErr(err) 96 | } 97 | err = d.Set("platformversion", user.PlatformVersion) 98 | if err != nil { 99 | return diag.FromErr(err) 100 | } 101 | 102 | err = d.Set("bio", user.Bio) 103 | if err != nil { 104 | return diag.FromErr(err) 105 | } 106 | 107 | err = d.Set("website", user.Website) 108 | if err != nil { 109 | return diag.FromErr(err) 110 | } 111 | 112 | err = d.Set("profiles", user.Profiles) 113 | if err != nil { 114 | return diag.FromErr(err) 115 | } 116 | 117 | d.SetId(user.UID) 118 | 119 | return diag.Diagnostics{} 120 | } 121 | -------------------------------------------------------------------------------- /internal/provider/data_source_user_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 5 | "testing" 6 | ) 7 | 8 | func TestAccDataSourceUser(t *testing.T) { 9 | resource.UnitTest(t, resource.TestCase{ 10 | PreCheck: func() { testAccPreCheck(t) }, 11 | ProviderFactories: providerFactories, 12 | Steps: []resource.TestStep{ 13 | { 14 | Config: testAccDataSourceUser, 15 | Check: resource.ComposeTestCheckFunc( 16 | resource.TestCheckResourceAttr("data.vercel_user.me", "username", "chronark"), 17 | ), 18 | }, 19 | }, 20 | }) 21 | } 22 | 23 | const testAccDataSourceUser = `data "vercel_user" "me" {}` 24 | -------------------------------------------------------------------------------- /internal/provider/provider.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/chronark/terraform-provider-vercel/pkg/vercel" 8 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 9 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 10 | ) 11 | 12 | func init() { 13 | // Set descriptions to support markdown syntax, this will be used in document generation 14 | // and the language server. 15 | schema.DescriptionKind = schema.StringMarkdown 16 | 17 | // Customize the content of descriptions when output. For example you can add defaults on 18 | // to the exported descriptions if present. 19 | // schema.SchemaDescriptionBuilder = func(s *schema.Schema) string { 20 | // desc := s.Description 21 | // if s.Default != nil { 22 | // desc += fmt.Sprintf(" Defaults to `%v`.", s.Default) 23 | // } 24 | // return strings.TrimSpace(desc) 25 | // } 26 | } 27 | 28 | func New(version string) func() *schema.Provider { 29 | return func() *schema.Provider { 30 | p := &schema.Provider{ 31 | Schema: map[string]*schema.Schema{ 32 | "token": { 33 | Type: schema.TypeString, 34 | Required: true, 35 | Sensitive: true, 36 | DefaultFunc: schema.EnvDefaultFunc("VERCEL_TOKEN", nil), 37 | }, 38 | }, 39 | DataSourcesMap: map[string]*schema.Resource{ 40 | "vercel_user": dataSourceUser(), 41 | "vercel_team": dataSourceTeam(), 42 | }, 43 | ResourcesMap: map[string]*schema.Resource{ 44 | "vercel_env": resourceEnv(), 45 | "vercel_project": resourceProject(), 46 | "vercel_secret": resourceSecret(), 47 | "vercel_domain": resourceDomain(), 48 | "vercel_dns": resourceDNS(), 49 | }, 50 | } 51 | 52 | p.ConfigureContextFunc = configure(version, p) 53 | 54 | return p 55 | } 56 | } 57 | 58 | func configure(version string, p *schema.Provider) func(context.Context, *schema.ResourceData) (interface{}, diag.Diagnostics) { 59 | return func(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) { 60 | token := d.Get("token").(string) 61 | 62 | if token == "" { 63 | return nil, diag.FromErr(fmt.Errorf("vercel token is not set, set manually or via `VERCEL_TOKEN` ")) 64 | } 65 | 66 | client := vercel.New(token) 67 | 68 | return client, diag.Diagnostics{} 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /internal/provider/provider_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "github.com/stretchr/testify/require" 5 | "os" 6 | "testing" 7 | 8 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 9 | ) 10 | 11 | var providerFactories = map[string]func() (*schema.Provider, error){ 12 | "vercel": func() (*schema.Provider, error) { 13 | return New("dev")(), nil 14 | }, 15 | } 16 | 17 | func TestProvider(t *testing.T) { 18 | if err := New("dev")().InternalValidate(); err != nil { 19 | t.Fatalf("Unable to create new provider: %s", err) 20 | } 21 | } 22 | 23 | func testAccPreCheck(t *testing.T) { 24 | require.NotEmpty(t, os.Getenv("VERCEL_TOKEN")) 25 | } 26 | -------------------------------------------------------------------------------- /internal/provider/resource_dns.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "github.com/chronark/terraform-provider-vercel/pkg/vercel" 6 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/dns" 7 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 8 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 9 | ) 10 | 11 | func resourceDNS() *schema.Resource { 12 | return &schema.Resource{ 13 | Description: "https://vercel.com/docs/api#endpoints/dns\nCurrently this can only fetch the last 1000 records. Please create an issue if you require more.", 14 | 15 | CreateContext: resourceDNSCreate, 16 | ReadContext: resourceDNSRead, 17 | DeleteContext: resourceDNSDelete, 18 | 19 | Schema: map[string]*schema.Schema{ 20 | "domain": { 21 | Description: "The domain for this DNS record", 22 | Type: schema.TypeString, 23 | Required: true, 24 | ForceNew: true, 25 | }, 26 | "id": { 27 | Description: "The unique identifier of the dns record.", 28 | Type: schema.TypeString, 29 | Computed: true, 30 | }, 31 | "team_id": { 32 | Description: "By default, you can access resources contained within your own user account. To access resources owned by a team, you can pass in the team ID", 33 | Type: schema.TypeString, 34 | Optional: true, 35 | ForceNew: true, 36 | Default: "", 37 | }, 38 | "type": { 39 | Description: "The type of record, it could be any valid DNS record.", 40 | Type: schema.TypeString, 41 | Required: true, 42 | ForceNew: true, 43 | }, 44 | "name": { 45 | Description: "A subdomain name or an empty string for the root domain.", 46 | Type: schema.TypeString, 47 | Required: true, 48 | ForceNew: true, 49 | }, 50 | 51 | "value": { 52 | Description: "The record value.", 53 | Type: schema.TypeString, 54 | Required: true, 55 | ForceNew: true, 56 | }, 57 | "ttl": { 58 | Description: "The TTL value. Must be a number between 60 and 2147483647. Default value is 60.", 59 | Type: schema.TypeInt, 60 | Optional: true, 61 | Default: 60, 62 | ForceNew: true, 63 | }, 64 | "creator": { 65 | Description: "The ID of the user who created the record or system if the record is an automatic record.", 66 | Type: schema.TypeString, 67 | Computed: true, 68 | }, 69 | "created": { 70 | Description: "The date when the record was created.", 71 | Type: schema.TypeInt, 72 | Computed: true, 73 | }, 74 | "updated": { 75 | Description: "The date when the record was updated.", 76 | Type: schema.TypeInt, 77 | Computed: true, 78 | }, 79 | "created_at": { 80 | Description: "The date when the record was created in milliseconds since the UNIX epoch.", 81 | Type: schema.TypeInt, 82 | Computed: true, 83 | }, 84 | "updated_at": { 85 | Description: "The date when the record was updated in milliseconds since the UNIX epoch.", 86 | Type: schema.TypeInt, 87 | Computed: true, 88 | }, 89 | }, 90 | } 91 | } 92 | 93 | func resourceDNSCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { 94 | 95 | client := meta.(*vercel.Client) 96 | 97 | payload := dns.CreateRecord{ 98 | Name: d.Get("name").(string), 99 | Type: d.Get("type").(string), 100 | Value: d.Get("value").(string), 101 | TTL: d.Get("ttl").(int), 102 | } 103 | domain := d.Get("domain").(string) 104 | teamId := d.Get("team_id").(string) 105 | dnsId, err := client.DNS.Create(domain, payload, teamId) 106 | if err != nil { 107 | return diag.FromErr(err) 108 | } 109 | 110 | d.SetId(dnsId) 111 | 112 | return resourceDNSRead(ctx, d, meta) 113 | } 114 | 115 | func resourceDNSRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { 116 | client := meta.(*vercel.Client) 117 | 118 | record, err := client.DNS.Read(d.Get("domain").(string), d.Id(), d.Get("team_id").(string)) 119 | if err != nil { 120 | return diag.FromErr(err) 121 | } 122 | 123 | err = d.Set("name", record.Name) 124 | if err != nil { 125 | return diag.FromErr(err) 126 | } 127 | err = d.Set("type", record.Type) 128 | if err != nil { 129 | return diag.FromErr(err) 130 | } 131 | err = d.Set("value", record.Value) 132 | if err != nil { 133 | return diag.FromErr(err) 134 | } 135 | err = d.Set("creator", record.Creator) 136 | if err != nil { 137 | return diag.FromErr(err) 138 | } 139 | err = d.Set("created", record.Created) 140 | if err != nil { 141 | return diag.FromErr(err) 142 | } 143 | err = d.Set("updated", record.Updated) 144 | if err != nil { 145 | return diag.FromErr(err) 146 | } 147 | err = d.Set("created_at", record.CreatedAt) 148 | if err != nil { 149 | return diag.FromErr(err) 150 | } 151 | err = d.Set("updated_at", record.UpdatedAt) 152 | if err != nil { 153 | return diag.FromErr(err) 154 | } 155 | 156 | return diag.Diagnostics{} 157 | } 158 | 159 | func resourceDNSDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { 160 | 161 | client := meta.(*vercel.Client) 162 | 163 | err := client.DNS.Delete(d.Get("domain").(string), d.Id(), d.Get("team_id").(string)) 164 | if err != nil { 165 | return diag.FromErr(err) 166 | } 167 | d.SetId("") 168 | return diag.Diagnostics{} 169 | } 170 | -------------------------------------------------------------------------------- /internal/provider/resource_domain.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/chronark/terraform-provider-vercel/pkg/vercel" 7 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 8 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 9 | ) 10 | 11 | func resourceDomain() *schema.Resource { 12 | return &schema.Resource{ 13 | Description: "Creates account wide domains to be used in your projects.", 14 | 15 | CreateContext: resourceDomainCreate, 16 | ReadContext: resourceDomainRead, 17 | DeleteContext: resourceDomainDelete, 18 | 19 | Importer: &schema.ResourceImporter{ 20 | StateContext: resourceDomainImportState, 21 | }, 22 | 23 | Schema: map[string]*schema.Schema{ 24 | "team_id": { 25 | Description: "By default, you can access resources contained within your own user account. To access resources owned by a team, you can pass in the team ID", 26 | Type: schema.TypeString, 27 | Optional: true, 28 | ForceNew: true, 29 | Default: "", 30 | }, 31 | "name": { 32 | Description: "The name of the production domain.", 33 | Type: schema.TypeString, 34 | ForceNew: true, 35 | Required: true, 36 | }, 37 | "id": { 38 | Description: "Unique id for this variable.", 39 | Type: schema.TypeString, 40 | Computed: true, 41 | }, 42 | "service_type": { 43 | Description: "The type of service the domain is handled by. external if the DNS is externally handled, or zeit.world if handled with Vercel.", 44 | Type: schema.TypeString, 45 | Computed: true, 46 | }, 47 | 48 | "ns_verified_at": { 49 | Description: "The date at which the domain's nameservers were verified based on the intended set.", 50 | Type: schema.TypeInt, 51 | Computed: true, 52 | }, 53 | 54 | "txt_verified_at": { 55 | Description: "The date at which the domain's TXT DNS record was verified.", 56 | Type: schema.TypeInt, 57 | Computed: true, 58 | }, 59 | 60 | "cdn_enabled": { 61 | Description: "Whether the domain has the Vercel Edge Network enabled or not.", 62 | Type: schema.TypeBool, 63 | Computed: true, 64 | }, 65 | 66 | "created_at": { 67 | Description: "The date when the domain was created in the registry.", 68 | Type: schema.TypeInt, 69 | Computed: true, 70 | }, 71 | 72 | "expires_at": { 73 | Description: "The date at which the domain is set to expire. null if not bought with Vercel.", 74 | Type: schema.TypeInt, 75 | Computed: true, 76 | }, 77 | 78 | "bought_at": { 79 | Description: "If it was purchased through Vercel, the date when it was purchased.", 80 | Type: schema.TypeInt, 81 | Computed: true, 82 | }, 83 | 84 | "transfer_started_at": { 85 | Description: "If transferred into Vercel, The date when the domain transfer was initiated", 86 | Type: schema.TypeInt, 87 | Computed: true, 88 | }, 89 | 90 | "transferred_at": { 91 | Description: "The date at which the domain was successfully transferred into Vercel. null if the transfer is still processing or was never transferred in.", 92 | Type: schema.TypeInt, 93 | Computed: true, 94 | }, 95 | 96 | "verification_record": { 97 | Description: "The ID of the verification record in the registry.", 98 | Type: schema.TypeString, 99 | Computed: true, 100 | }, 101 | 102 | "verified": { 103 | Description: "If the domain has the ownership verified.", 104 | Type: schema.TypeBool, 105 | Computed: true, 106 | }, 107 | 108 | "nameservers": { 109 | Description: "A list of the current nameservers of the domain.", 110 | Type: schema.TypeList, 111 | Computed: true, 112 | Elem: &schema.Schema{ 113 | Type: schema.TypeString, 114 | }, 115 | }, 116 | 117 | "intended_nameservers": { 118 | Description: "A list of the intended nameservers for the domain to point to Vercel DNS.", 119 | Type: schema.TypeList, 120 | Computed: true, 121 | Elem: &schema.Schema{ 122 | Type: schema.TypeString, 123 | }, 124 | }, 125 | }, 126 | } 127 | } 128 | 129 | func resourceDomainImportState(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { 130 | parts, err := partsFromID(d.Id()) 131 | if err != nil { 132 | return []*schema.ResourceData{}, err 133 | } 134 | domainName := parts[0] 135 | if len(parts) > 1 { 136 | client := meta.(*vercel.Client) 137 | 138 | teamSlug := parts[0] 139 | team, err := client.Team.Read(teamSlug) 140 | if err != nil { 141 | return []*schema.ResourceData{}, err 142 | } 143 | 144 | err = d.Set("team_id", team.Id) 145 | if err != nil { 146 | return []*schema.ResourceData{}, err 147 | } 148 | 149 | domainName = parts[1] 150 | } 151 | err = d.Set("name", domainName) 152 | if err != nil { 153 | return []*schema.ResourceData{}, err 154 | } 155 | return []*schema.ResourceData{d}, nil 156 | } 157 | 158 | func resourceDomainCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { 159 | 160 | client := meta.(*vercel.Client) 161 | 162 | id, err := client.Domain.Create(d.Get("name").(string), d.Get("team_id").(string)) 163 | if err != nil { 164 | return diag.FromErr(err) 165 | } 166 | 167 | d.SetId(id) 168 | 169 | return resourceDomainRead(ctx, d, meta) 170 | } 171 | 172 | func resourceDomainRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { 173 | client := meta.(*vercel.Client) 174 | 175 | domain, err := client.Domain.Read(d.Get("name").(string), d.Get("team_id").(string)) 176 | if err != nil { 177 | return diag.FromErr(err) 178 | } 179 | d.SetId(domain.ID) 180 | err = d.Set("service_type", domain.ServiceType) 181 | if err != nil { 182 | return diag.FromErr(err) 183 | } 184 | err = d.Set("ns_verified_at", domain.NsVerifiedAt) 185 | if err != nil { 186 | return diag.FromErr(err) 187 | } 188 | err = d.Set("txt_verified_at", domain.TxtVerifiedAt) 189 | if err != nil { 190 | return diag.FromErr(err) 191 | } 192 | err = d.Set("cdn_enabled", domain.CdnEnabled) 193 | if err != nil { 194 | return diag.FromErr(err) 195 | } 196 | err = d.Set("created_at", domain.CreatedAt) 197 | if err != nil { 198 | return diag.FromErr(err) 199 | } 200 | err = d.Set("expires_at", domain.ExpiresAt) 201 | if err != nil { 202 | return diag.FromErr(err) 203 | } 204 | 205 | err = d.Set("bought_at", domain.BoughtAt) 206 | if err != nil { 207 | return diag.FromErr(err) 208 | } 209 | err = d.Set("transfer_started_at", domain.TransferredAt) 210 | if err != nil { 211 | return diag.FromErr(err) 212 | } 213 | err = d.Set("transferred_at", domain.TransferredAt) 214 | if err != nil { 215 | return diag.FromErr(err) 216 | } 217 | err = d.Set("verification_record", domain.VerificationRecord) 218 | if err != nil { 219 | return diag.FromErr(err) 220 | } 221 | err = d.Set("verified", domain.Verified) 222 | if err != nil { 223 | return diag.FromErr(err) 224 | } 225 | err = d.Set("nameservers", domain.Nameservers) 226 | if err != nil { 227 | return diag.FromErr(err) 228 | } 229 | err = d.Set("intended_nameservers", domain.IntendedNameservers) 230 | if err != nil { 231 | return diag.FromErr(err) 232 | } 233 | return diag.Diagnostics{} 234 | } 235 | 236 | func resourceDomainDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { 237 | 238 | client := meta.(*vercel.Client) 239 | 240 | domainName := d.Get("name").(string) 241 | 242 | err := client.Domain.Delete(domainName, d.Get("team_id").(string)) 243 | if err != nil { 244 | return diag.FromErr(err) 245 | } 246 | d.SetId("") 247 | return diag.Diagnostics{} 248 | } 249 | -------------------------------------------------------------------------------- /internal/provider/resource_domain_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "testing" 8 | 9 | "github.com/chronark/terraform-provider-vercel/pkg/vercel" 10 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/domain" 11 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 12 | "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" 13 | ) 14 | 15 | func TestAccVercelDomain(t *testing.T) { 16 | 17 | domainName := "acceptancetestdomainone.com" 18 | updatedDomainName := "acceptancetestdomaintwo.com" 19 | var ( 20 | 21 | // Holds the domain fetched from vercel when we create it at the beginning 22 | actualDomainAfterCreation domain.Domain 23 | 24 | // Renaming or changing a variable should not result in the recreation of the domain, so we expect to have the same id. 25 | actualDomainAfterUpdate domain.Domain 26 | ) 27 | resource.Test(t, resource.TestCase{ 28 | PreCheck: func() { testAccPreCheck(t) }, 29 | ProviderFactories: providerFactories, 30 | CheckDestroy: testAccCheckVercelDomainDestroy(domainName), 31 | Steps: []resource.TestStep{ 32 | { 33 | Config: testAccCheckVercelDomainConfig(domainName), 34 | Check: resource.ComposeTestCheckFunc( 35 | testAccCheckDomainStateHasValues( 36 | "vercel_domain.new", domain.Domain{Name: domainName}, 37 | ), 38 | testAccCheckVercelDomainExists("vercel_domain.new", &actualDomainAfterCreation), 39 | testAccCheckActualDomainHasValues(&actualDomainAfterCreation, &domain.Domain{Name: domainName}), 40 | ), 41 | }, 42 | { 43 | Config: testAccCheckVercelDomainConfig(updatedDomainName), 44 | Check: resource.ComposeTestCheckFunc( 45 | testAccCheckVercelDomainExists("vercel_domain.new", &actualDomainAfterUpdate), 46 | testAccCheckDomainStateHasValues( 47 | "vercel_domain.new", domain.Domain{Name: updatedDomainName}, 48 | ), 49 | testAccCheckActualDomainHasValues(&actualDomainAfterUpdate, &domain.Domain{Name: updatedDomainName}), 50 | testAccCheckDomainWasRecreated(&actualDomainAfterCreation, &actualDomainAfterUpdate), 51 | ), 52 | }, 53 | }, 54 | }) 55 | } 56 | 57 | func TestAccVercelDomain_import(t *testing.T) { 58 | 59 | domainName := "acceptancetestdomainone.com" 60 | var ( 61 | actualDomain domain.Domain 62 | ) 63 | resource.Test(t, resource.TestCase{ 64 | PreCheck: func() { testAccPreCheck(t) }, 65 | ProviderFactories: providerFactories, 66 | CheckDestroy: testAccCheckVercelDomainDestroy(domainName), 67 | Steps: []resource.TestStep{ 68 | { 69 | Config: testAccCheckVercelDomainConfig(domainName), 70 | Check: resource.ComposeTestCheckFunc( 71 | testAccCheckDomainStateHasValues( 72 | "vercel_domain.new", domain.Domain{Name: domainName}, 73 | ), 74 | testAccCheckVercelDomainExists("vercel_domain.new", &actualDomain), 75 | testAccVercelDomainImport(&actualDomain), 76 | ), 77 | }, 78 | }, 79 | }) 80 | } 81 | 82 | // Combines multiple `resource.TestCheckResourceAttr` calls 83 | func testAccCheckDomainStateHasValues(name string, want domain.Domain) resource.TestCheckFunc { 84 | return func(s *terraform.State) error { 85 | tests := []resource.TestCheckFunc{ 86 | 87 | resource.TestCheckResourceAttr( 88 | name, "name", want.Name), 89 | } 90 | 91 | for _, test := range tests { 92 | err := test(s) 93 | if err != nil { 94 | return err 95 | } 96 | } 97 | return nil 98 | } 99 | } 100 | 101 | func testAccCheckDomainWasRecreated(s1, s2 *domain.Domain) resource.TestCheckFunc { 102 | return func(s *terraform.State) error { 103 | if s1.ID == s2.ID { 104 | return fmt.Errorf("Expected different IDs but they are the same.") 105 | } 106 | return nil 107 | } 108 | } 109 | 110 | func testAccCheckActualDomainHasValues(actual *domain.Domain, want *domain.Domain) resource.TestCheckFunc { 111 | return func(s *terraform.State) error { 112 | if actual.Name != want.Name { 113 | return fmt.Errorf("name does not match, expected: %s, got: %s", want.Name, actual.Name) 114 | } 115 | if actual.ID == "" { 116 | return fmt.Errorf("ID is empty") 117 | } 118 | 119 | return nil 120 | } 121 | } 122 | 123 | // Test whether the domain was destroyed properly and finishes the job if necessary 124 | func testAccCheckVercelDomainDestroy(name string) resource.TestCheckFunc { 125 | return func(s *terraform.State) error { 126 | client := vercel.New(os.Getenv("VERCEL_TOKEN")) 127 | 128 | for _, rs := range s.RootModule().Resources { 129 | if rs.Type != name { 130 | continue 131 | } 132 | 133 | domain, err := client.Domain.Read(rs.Primary.ID, "") 134 | if err == nil { 135 | message := "Domain was not deleted from vercel during terraform destroy." 136 | deleteErr := client.Domain.Delete(domain.Name, "") 137 | if deleteErr != nil { 138 | return fmt.Errorf(message+" Automated removal did not succeed. Please manually remove @%s. Error: %w", domain.Name, err) 139 | } 140 | return fmt.Errorf(message + " It was removed now.") 141 | } 142 | 143 | } 144 | return nil 145 | } 146 | 147 | } 148 | func testAccCheckVercelDomainConfig(name string) string { 149 | return fmt.Sprintf(` 150 | resource "vercel_domain" "new" { 151 | name = "%s" 152 | } 153 | `, name) 154 | } 155 | 156 | func testAccCheckVercelDomainExists(n string, actual *domain.Domain) resource.TestCheckFunc { 157 | return func(s *terraform.State) error { 158 | rs, ok := s.RootModule().Resources[n] 159 | 160 | if !ok { 161 | return fmt.Errorf("Not found: %s in %+v", n, s.RootModule().Resources) 162 | } 163 | 164 | if rs.Primary.ID == "" { 165 | return fmt.Errorf("No domain set") 166 | } 167 | 168 | domain, err := vercel.New(os.Getenv("VERCEL_TOKEN")).Domain.Read(rs.Primary.Attributes["name"], "") 169 | if err != nil { 170 | return err 171 | } 172 | *actual = domain 173 | return nil 174 | } 175 | } 176 | 177 | func testAccVercelDomainImport(source *domain.Domain) resource.TestCheckFunc { 178 | return func(s *terraform.State) error { 179 | data := resourceDomain().Data(nil) 180 | data.SetId(source.Name) 181 | ds, err := resourceDomain().Importer.StateContext(context.Background(), data, vercel.New(os.Getenv("VERCEL_TOKEN"))) 182 | if err != nil { 183 | return err 184 | } 185 | if len(ds) != 1 { 186 | return fmt.Errorf("Expected 1 instance state from importer function. Got %d", len(ds)) 187 | } 188 | 189 | if ds[0].Get("name") != source.Name { 190 | return fmt.Errorf("Imported domain name. Expected '%s'. Actual '%s'.", source.Name, ds[0].Get("name").(string)) 191 | } 192 | return nil 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /internal/provider/resource_env.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/chronark/terraform-provider-vercel/pkg/vercel" 8 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/env" 9 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 10 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 11 | ) 12 | 13 | func resourceEnv() *schema.Resource { 14 | return &schema.Resource{ 15 | Description: "https://vercel.com/docs/api#endpoints/projects/get-project-environment-variables", 16 | 17 | CreateContext: resourceEnvCreate, 18 | ReadContext: resourceEnvRead, 19 | UpdateContext: resourceEnvUpdate, 20 | DeleteContext: resourceEnvDelete, 21 | 22 | Importer: &schema.ResourceImporter{ 23 | StateContext: resourceEnvImportState, 24 | }, 25 | 26 | Schema: map[string]*schema.Schema{ 27 | "project_id": { 28 | Description: "The unique project identifier.", 29 | Type: schema.TypeString, 30 | Required: true, 31 | }, 32 | "team_id": { 33 | Description: "By default, you can access resources contained within your own user account. To access resources owned by a team, you can pass in the team ID", 34 | Type: schema.TypeString, 35 | Optional: true, 36 | ForceNew: true, 37 | Default: "", 38 | }, 39 | "type": { 40 | Description: "The type can be `plain`, `secret`, or `system`.", 41 | Type: schema.TypeString, 42 | Required: true, 43 | }, 44 | "id": { 45 | Description: "Unique id for this variable.", 46 | Type: schema.TypeString, 47 | Computed: true, 48 | }, 49 | "key": { 50 | Description: "The name of the environment variable.", 51 | Type: schema.TypeString, 52 | Required: true, 53 | }, 54 | "value": { 55 | Description: "If the type is `plain`, a string representing the value of the environment variable. If the type is `secret`, the secret ID of the secret attached to the environment variable. If the type is `system`, the name of the System Environment Variable.", 56 | Type: schema.TypeString, 57 | Required: true, 58 | }, 59 | "target": { 60 | Description: "The target can be a list of `development`, `preview`, or `production`.", 61 | Type: schema.TypeList, 62 | Required: true, 63 | MaxItems: 3, 64 | Elem: &schema.Schema{ 65 | Type: schema.TypeString, 66 | }, 67 | }, 68 | "created_at": { 69 | Description: "A number containing the date when the variable was created in milliseconds.", 70 | Type: schema.TypeInt, 71 | Computed: true, 72 | }, 73 | "updated_at": { 74 | Description: "A number containing the date when the variable was updated in milliseconds.", 75 | Type: schema.TypeInt, 76 | Computed: true, 77 | }, 78 | }, 79 | } 80 | } 81 | 82 | func resourceEnvImportState(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { 83 | parts, err := partsFromID(d.Id()) 84 | if err != nil { 85 | return []*schema.ResourceData{}, err 86 | } 87 | if len(parts) < 2 { 88 | return []*schema.ResourceData{}, fmt.Errorf("Invalid import ID (use project_id/VARIABLE_NAME") 89 | } 90 | 91 | projectID, key := parts[0], parts[1] 92 | 93 | client := meta.(*vercel.Client) 94 | 95 | teamID := "" 96 | if len(parts) > 2 { 97 | var teamSlug string 98 | teamSlug, projectID = projectID, key 99 | key = parts[2] 100 | 101 | team, err := client.Team.Read(teamSlug) 102 | if err != nil { 103 | return []*schema.ResourceData{}, err 104 | } 105 | teamID = team.Id 106 | } 107 | 108 | // Read project ID from project name 109 | project, err := client.Project.Read(projectID, teamID) 110 | if err != nil { 111 | return []*schema.ResourceData{}, err 112 | } 113 | projectID = project.ID 114 | 115 | allEnvVariables, err := client.Env.Read(projectID, teamID) 116 | if err != nil { 117 | return []*schema.ResourceData{}, err 118 | } 119 | 120 | // Filter the current variable out of all existing ones 121 | for _, envVar := range allEnvVariables { 122 | if envVar.Key == key { 123 | d.SetId(envVar.ID) 124 | err = d.Set("project_id", projectID) 125 | if err != nil { 126 | return []*schema.ResourceData{}, err 127 | } 128 | return []*schema.ResourceData{d}, nil 129 | } 130 | } 131 | 132 | return []*schema.ResourceData{d}, fmt.Errorf("No '%s' environment variable in project", key) 133 | } 134 | 135 | func toCreateOrUpdateEnv(d *schema.ResourceData) env.CreateOrUpdateEnv { 136 | 137 | // Casting each target because go does not allow typecasting from interface{} to []string 138 | targetList := d.Get("target").([]interface{}) 139 | target := make([]string, len(targetList)) 140 | for i := 0; i < len(target); i++ { 141 | target[i] = targetList[i].(string) 142 | } 143 | 144 | return env.CreateOrUpdateEnv{ 145 | Type: d.Get("type").(string), 146 | Key: d.Get("key").(string), 147 | Value: d.Get("value").(string), 148 | Target: target, 149 | } 150 | } 151 | 152 | func resourceEnvCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { 153 | 154 | client := meta.(*vercel.Client) 155 | 156 | payload := toCreateOrUpdateEnv(d) 157 | 158 | envID, err := client.Env.Create(d.Get("project_id").(string), payload, d.Get("team_id").(string)) 159 | if err != nil { 160 | return diag.FromErr(err) 161 | } 162 | 163 | d.SetId(envID) 164 | 165 | return resourceEnvRead(ctx, d, meta) 166 | } 167 | 168 | func resourceEnvRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { 169 | client := meta.(*vercel.Client) 170 | 171 | id := d.Id() 172 | allEnvVariables, err := client.Env.Read(d.Get("project_id").(string), d.Get("team_id").(string)) 173 | if err != nil { 174 | return diag.FromErr(err) 175 | } 176 | 177 | // Filter the current variable out of all existing ones 178 | var currentVar env.Env 179 | for _, envVar := range allEnvVariables { 180 | if envVar.ID == id { 181 | currentVar = envVar 182 | break 183 | } 184 | } 185 | 186 | err = d.Set("type", currentVar.Type) 187 | if err != nil { 188 | return diag.FromErr(err) 189 | } 190 | err = d.Set("key", currentVar.Key) 191 | if err != nil { 192 | return diag.FromErr(err) 193 | } 194 | err = d.Set("value", currentVar.Value) 195 | if err != nil { 196 | return diag.FromErr(err) 197 | } 198 | err = d.Set("target", currentVar.Target) 199 | if err != nil { 200 | return diag.FromErr(err) 201 | } 202 | err = d.Set("updated_at", currentVar.UpdatedAt) 203 | if err != nil { 204 | return diag.FromErr(err) 205 | } 206 | err = d.Set("created_at", currentVar.CreatedAt) 207 | if err != nil { 208 | return diag.FromErr(err) 209 | } 210 | 211 | return diag.Diagnostics{} 212 | } 213 | 214 | func resourceEnvUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { 215 | 216 | client := meta.(*vercel.Client) 217 | 218 | // Vercel expects an object with all 4 keys, so there's not point in checking for individual changes. 219 | if d.HasChanges("type", "key", "value", "taget") { 220 | 221 | projectID := d.Get("project_id").(string) 222 | envID := d.Id() 223 | payload := toCreateOrUpdateEnv(d) 224 | 225 | err := client.Env.Update(projectID, envID, payload, d.Get("team_id").(string)) 226 | if err != nil { 227 | return diag.FromErr(err) 228 | } 229 | } 230 | return resourceEnvRead(ctx, d, meta) 231 | } 232 | 233 | func resourceEnvDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { 234 | 235 | client := meta.(*vercel.Client) 236 | 237 | projectID := d.Get("project_id").(string) 238 | envID := d.Get("id").(string) 239 | 240 | err := client.Env.Delete(projectID, envID, d.Get("team_id").(string)) 241 | if err != nil { 242 | return diag.FromErr(err) 243 | } 244 | d.SetId("") 245 | return diag.Diagnostics{} 246 | } 247 | -------------------------------------------------------------------------------- /internal/provider/resource_env_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "testing" 8 | 9 | "github.com/chronark/terraform-provider-vercel/pkg/vercel" 10 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/env" 11 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/project" 12 | "github.com/hashicorp/go-uuid" 13 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 14 | "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" 15 | ) 16 | 17 | func TestAccVercelEnv_import(t *testing.T) { 18 | 19 | projectName, _ := uuid.GenerateUUID() 20 | var ( 21 | actualProject project.Project 22 | actualEnv env.Env 23 | ) 24 | resource.Test(t, resource.TestCase{ 25 | PreCheck: func() { testAccPreCheck(t) }, 26 | ProviderFactories: providerFactories, 27 | CheckDestroy: testAccCheckVercelProjectDestroy(projectName), 28 | Steps: []resource.TestStep{ 29 | { 30 | Config: testAccCheckVercelEnvConfig(projectName), 31 | Check: resource.ComposeTestCheckFunc( 32 | testAccCheckProjectStateHasValues( 33 | "vercel_project.new", project.Project{Name: projectName}, 34 | ), 35 | testAccCheckVercelProjectExists("vercel_project.new", &actualProject), 36 | testAccCheckVercelEnvExists("vercel_env.new", &actualEnv), 37 | testAccVercelEnvImport(&actualProject, &actualEnv), 38 | ), 39 | }, 40 | }, 41 | }) 42 | } 43 | 44 | func testAccCheckVercelEnvConfig(name string) string { 45 | return fmt.Sprintf(` 46 | resource "vercel_project" "new" { 47 | name = "%s" 48 | git_repository { 49 | type = "github" 50 | repo = "chronark/terraform-provider-vercel" 51 | } 52 | } 53 | 54 | resource "vercel_env" "new" { 55 | key = "FOO" 56 | value = "BAR" 57 | 58 | project_id = vercel_project.new.id 59 | target = [ "production" ] 60 | type = "plain" 61 | } 62 | `, name) 63 | } 64 | 65 | func testAccCheckVercelEnvExists(n string, actual *env.Env) resource.TestCheckFunc { 66 | return func(s *terraform.State) error { 67 | rs, ok := s.RootModule().Resources[n] 68 | 69 | if !ok { 70 | return fmt.Errorf("Not found: %s in %+v", n, s.RootModule().Resources) 71 | } 72 | 73 | if rs.Primary.ID == "" { 74 | return fmt.Errorf("No project set") 75 | } 76 | 77 | projectID := rs.Primary.Attributes["project_id"] 78 | teamID := rs.Primary.Attributes["team_id"] 79 | 80 | envs, err := vercel.New(os.Getenv("VERCEL_TOKEN")).Env.Read(projectID, teamID) 81 | if err != nil { 82 | return err 83 | } 84 | for _, env := range envs { 85 | if env.ID == rs.Primary.ID { 86 | *actual = env 87 | return nil 88 | } 89 | } 90 | 91 | return fmt.Errorf("Could not find env '%s' in project '%s'", rs.Primary.ID, projectID) 92 | } 93 | } 94 | 95 | func testAccVercelEnvImport(srcProject *project.Project, srcEnv *env.Env) resource.TestCheckFunc { 96 | return func(s *terraform.State) error { 97 | data := resourceEnv().Data(nil) 98 | data.SetId(srcProject.Name + "/" + srcEnv.Key) 99 | ds, err := resourceEnv().Importer.StateContext(context.Background(), data, vercel.New(os.Getenv("VERCEL_TOKEN"))) 100 | if err != nil { 101 | return err 102 | } 103 | if len(ds) != 1 { 104 | return fmt.Errorf("Expected 1 instance state from importer function. Got %d", len(ds)) 105 | } 106 | 107 | if ds[0].Id() != srcEnv.ID { 108 | return fmt.Errorf("Imported env ID. Expected '%s'. Actual '%s'.", srcEnv.ID, ds[0].Id()) 109 | } 110 | if ds[0].Get("project_id") != srcProject.ID { 111 | return fmt.Errorf("Imported env project ID. Expected '%s'. Actual '%s'.", srcProject.ID, ds[0].Get("project_id").(string)) 112 | } 113 | return nil 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /internal/provider/resource_project.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | 9 | "github.com/chronark/terraform-provider-vercel/pkg/util" 10 | "github.com/chronark/terraform-provider-vercel/pkg/vercel" 11 | projectApi "github.com/chronark/terraform-provider-vercel/pkg/vercel/project" 12 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 13 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 14 | ) 15 | 16 | func resourceProject() *schema.Resource { 17 | return &schema.Resource{ 18 | Description: "https://vercel.com/docs/api#endpoints/projects", 19 | 20 | CreateContext: resourceProjectCreate, 21 | ReadContext: resourceProjectRead, 22 | UpdateContext: resourceProjectUpdate, 23 | DeleteContext: resourceProjectDelete, 24 | 25 | Importer: &schema.ResourceImporter{ 26 | StateContext: resourceProjectImportState, 27 | }, 28 | 29 | Schema: map[string]*schema.Schema{ 30 | "id": { 31 | Description: "Internal id of this project", 32 | Type: schema.TypeString, 33 | Computed: true, 34 | }, 35 | "name": { 36 | Description: "The name of the project.", 37 | Type: schema.TypeString, 38 | Required: true, 39 | }, 40 | "team_id": { 41 | Description: "By default, you can access resources contained within your own user account. To access resources owned by a team, you can pass in the team ID", 42 | Type: schema.TypeString, 43 | Optional: true, 44 | ForceNew: true, 45 | Default: "", 46 | }, 47 | "git_repository": { 48 | Description: "The git repository that will be connected to the project. Any pushes to the specified connected git repository will be automatically deployed.", 49 | Optional: true, 50 | ForceNew: true, 51 | Type: schema.TypeList, 52 | MaxItems: 1, 53 | Elem: &schema.Resource{ 54 | Schema: map[string]*schema.Schema{ 55 | "type": { 56 | Description: "The git provider of the repository. Must be either `github`, `gitlab`, or `bitbucket`.", 57 | Type: schema.TypeString, 58 | Required: true, 59 | }, 60 | "repo": { 61 | Description: "The name of the git repository. For example: `chronark/terraform-provider-vercel`", 62 | Type: schema.TypeString, 63 | Required: true, 64 | }, 65 | }, 66 | }, 67 | }, 68 | "domain": { 69 | Description: "Add a domain to the project by passing the project.", 70 | Optional: true, 71 | Type: schema.TypeList, 72 | Elem: &schema.Resource{ 73 | Schema: map[string]*schema.Schema{ 74 | "name": { 75 | Description: "The name of the production domain.", 76 | Type: schema.TypeString, 77 | Required: true, 78 | }, 79 | "redirect": { 80 | Description: "Target destination domain for redirect.", 81 | Type: schema.TypeString, 82 | Optional: true, 83 | }, "redirect_status_code": { 84 | Description: "The redirect status code (301, 302, 307, 308).", 85 | Type: schema.TypeInt, 86 | Optional: true, 87 | }, "git_branch": { 88 | Description: "it branch for the domain to be auto assigned to. The Project's production branch is the default (null).", 89 | Type: schema.TypeString, 90 | Optional: true, 91 | }, 92 | }, 93 | }, 94 | }, 95 | "account_id": { 96 | Description: "The unique ID of the user or team the project belongs to.", 97 | Type: schema.TypeString, 98 | Computed: true, 99 | }, 100 | "created_at": { 101 | Description: "A number containing the date when the project was created in milliseconds.", 102 | Type: schema.TypeInt, 103 | Computed: true, 104 | }, 105 | "updated_at": { 106 | Description: "A number containing the date when the project was updated in milliseconds.", 107 | Type: schema.TypeInt, 108 | Computed: true, 109 | }, 110 | "framework": { 111 | Description: "The framework that is being used for this project. When null is used no framework is selected.", 112 | Type: schema.TypeString, 113 | Optional: true, 114 | }, 115 | "public_source": { 116 | Description: " Specifies whether the source code and logs of the deployments for this project should be public or not.", 117 | Type: schema.TypeBool, 118 | Optional: true, 119 | }, 120 | 121 | "install_command": { 122 | Description: "The install command for this project. When null is used this value will be automatically detected.", 123 | Type: schema.TypeString, 124 | Optional: true, 125 | }, 126 | "build_command": { 127 | Description: "The build command for this project. When null is used this value will be automatically detected.", 128 | Type: schema.TypeString, 129 | Optional: true, 130 | }, 131 | "dev_command": { 132 | Description: "The dev command for this project. When null is used this value will be automatically detected.", 133 | Type: schema.TypeString, 134 | Optional: true, 135 | }, 136 | "output_directory": { 137 | Description: "The output directory of the project. When null is used this value will be automatically detected.", 138 | Type: schema.TypeString, 139 | Optional: true, 140 | Default: nil, 141 | }, 142 | "serverless_function_region": { 143 | Description: "The region to deploy Serverless Functions in this project.", 144 | Type: schema.TypeString, 145 | Optional: true, 146 | Computed: true, 147 | }, 148 | "root_directory": { 149 | Description: "The name of a directory or relative path to the source code of your project. When null is used it will default to the project root.", 150 | Type: schema.TypeString, 151 | Optional: true, 152 | Default: nil, 153 | }, 154 | "node_version": { 155 | Description: "The Node.js Version for this project.", 156 | Type: schema.TypeString, 157 | Optional: true, 158 | Computed: true, 159 | }, 160 | "alias": { 161 | Description: "A list of production domains for the project.", 162 | Type: schema.TypeList, 163 | Computed: true, 164 | Optional: true, 165 | Elem: &schema.Schema{ 166 | Type: schema.TypeString, 167 | }, 168 | }, 169 | }, 170 | } 171 | } 172 | 173 | func partsFromID(id string) ([]string, error) { 174 | parts := strings.Split(id, "/") 175 | results := make([]string, len(parts)) 176 | for i, part := range parts { 177 | result, err := url.QueryUnescape(part) 178 | if err != nil { 179 | return results, err 180 | } 181 | 182 | results[i] = result 183 | } 184 | 185 | return results, nil 186 | } 187 | 188 | func resourceProjectImportState(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { 189 | parts, err := partsFromID(d.Id()) 190 | if err != nil { 191 | return []*schema.ResourceData{}, err 192 | } 193 | projectID := parts[0] 194 | if len(parts) > 1 { 195 | teamSlug := parts[0] 196 | client := meta.(*vercel.Client) 197 | team, err := client.Team.Read(teamSlug) 198 | if err != nil { 199 | return []*schema.ResourceData{}, err 200 | } 201 | 202 | err = d.Set("team_id", team.Id) 203 | if err != nil { 204 | return []*schema.ResourceData{}, err 205 | } 206 | 207 | projectID = parts[1] 208 | } 209 | d.SetId(projectID) 210 | return []*schema.ResourceData{d}, nil 211 | } 212 | 213 | func resourceProjectCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { 214 | 215 | client := meta.(*vercel.Client) 216 | 217 | var project projectApi.CreateProject 218 | _, repoSet := d.GetOk("git_repository") 219 | if repoSet { 220 | // Terraform does not have nested objects with different types yet, so I am using a `TypeList` 221 | // Here we have to typecast to list first and then take the first item and cast again. 222 | repo := d.Get("git_repository").([]interface{})[0].(map[string]interface{}) 223 | project = projectApi.CreateProject{ 224 | Name: d.Get("name").(string), 225 | GitRepository: &struct { 226 | Type string `json:"type"` 227 | Repo string `json:"repo"` 228 | }{ 229 | Type: repo["type"].(string), 230 | Repo: repo["repo"].(string), 231 | }, 232 | } 233 | } else { 234 | project = projectApi.CreateProject{ 235 | Name: d.Get("name").(string), 236 | } 237 | } 238 | 239 | framework, frameworkSet := d.GetOk("framework") 240 | if frameworkSet { 241 | project.Framework = framework.(string) 242 | } 243 | publicSource, publicSourceSet := d.GetOk("public_source") 244 | if publicSourceSet { 245 | project.PublicSource = publicSource.(bool) 246 | } 247 | installCommand, installCommandSet := d.GetOk("install_command") 248 | if installCommandSet { 249 | project.InstallCommand = installCommand.(string) 250 | } 251 | buildCommand, buildCommandSet := d.GetOk("build_command") 252 | if buildCommandSet { 253 | project.BuildCommand = buildCommand.(string) 254 | } 255 | devCommand, devCommandSet := d.GetOk("dev_command") 256 | if devCommandSet { 257 | project.DevCommand = devCommand.(string) 258 | } 259 | outputDirectory, outputDirectorySet := d.GetOk("output_directory") 260 | if outputDirectorySet { 261 | project.OutputDirectory = outputDirectory.(string) 262 | } 263 | 264 | serverlessFunctionRegion, serverlessFunctionRegionSet := d.GetOk("serverless_function_region") 265 | if serverlessFunctionRegionSet { 266 | project.ServerlessFunctionRegion = serverlessFunctionRegion.(string) 267 | } 268 | rootDirectory, rootDirectorySet := d.GetOk("root_directory") 269 | if rootDirectorySet { 270 | project.RootDirectory = rootDirectory.(string) 271 | } 272 | nodeVersion, nodeVersionSet := d.GetOk("node_version") 273 | if nodeVersionSet { 274 | project.NodeVersion = nodeVersion.(string) 275 | 276 | } 277 | 278 | id, err := client.Project.Create(project, d.Get("team_id").(string)) 279 | if err != nil { 280 | return diag.FromErr(err) 281 | } 282 | // repo := d.Get("git_repository").([]interface{})[0].(map[string]interface{}) 283 | 284 | _, domainSet := d.GetOk("domain") 285 | if domainSet { 286 | 287 | rawDomain := d.Get("domain").([]interface{})[0].(map[string]interface{}) 288 | domain := projectApi.Domain{ 289 | Name: rawDomain["name"].(string), 290 | // Redirect: rawDomain["redirect"].(string), 291 | // RedirectStatusCode: rawDomain["redirect_status_code"].(int), 292 | } 293 | 294 | err = client.Project.AddDomain(id, domain, d.Get("team_id").(string)) 295 | if err != nil { 296 | return diag.FromErr(err) 297 | } 298 | } 299 | 300 | d.SetId(id) 301 | 302 | return resourceProjectRead(ctx, d, meta) 303 | } 304 | 305 | func resourceProjectRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { 306 | client := meta.(*vercel.Client) 307 | 308 | id := d.Id() 309 | 310 | project, err := client.Project.Read(id, d.Get("team_id").(string)) 311 | if err != nil { 312 | return diag.FromErr(err) 313 | } 314 | 315 | d.SetId(project.ID) 316 | 317 | err = d.Set("name", project.Name) 318 | if err != nil { 319 | return diag.FromErr(err) 320 | } 321 | err = d.Set("account_id", project.AccountID) 322 | if err != nil { 323 | return diag.FromErr(err) 324 | } 325 | err = d.Set("created_at", project.CreatedAt) 326 | if err != nil { 327 | return diag.FromErr(err) 328 | } 329 | err = d.Set("updated_at", project.UpdatedAt) 330 | if err != nil { 331 | return diag.FromErr(err) 332 | } 333 | 334 | err = d.Set("framework", project.Framework) 335 | if err != nil { 336 | return diag.FromErr(err) 337 | } 338 | err = d.Set("public_source", project.PublicSource) 339 | if err != nil { 340 | return diag.FromErr(err) 341 | } 342 | 343 | err = d.Set("install_command", project.InstallCommand) 344 | if err != nil { 345 | return diag.FromErr(err) 346 | } 347 | err = d.Set("build_command", project.BuildCommand) 348 | if err != nil { 349 | return diag.FromErr(err) 350 | } 351 | err = d.Set("dev_command", project.DevCommand) 352 | if err != nil { 353 | return diag.FromErr(err) 354 | } 355 | err = d.Set("output_directory", project.OutputDirectory) 356 | if err != nil { 357 | return diag.FromErr(err) 358 | } 359 | err = d.Set("serverless_function_region", project.ServerlessFunctionRegion) 360 | if err != nil { 361 | return diag.FromErr(err) 362 | } 363 | err = d.Set("root_directory", project.RootDirectory) 364 | if err != nil { 365 | return diag.FromErr(err) 366 | } 367 | err = d.Set("node_version", project.NodeVersion) 368 | if err != nil { 369 | return diag.FromErr(err) 370 | } 371 | 372 | aliases := make([]string, 0) 373 | for i := 0; i < len(project.Alias); i++ { 374 | aliases = append(aliases, project.Alias[i].Domain) 375 | } 376 | err = d.Set("alias", aliases) 377 | if err != nil { 378 | return diag.FromErr(err) 379 | } 380 | 381 | gitRepository := make([]map[string]interface{}, 1) 382 | gitRepository[0] = map[string]interface{}{ 383 | "type": project.Link.Type, 384 | } 385 | switch project.Link.Type { 386 | case "gitlab": 387 | gitRepository[0]["repo"] = fmt.Sprintf("%s/%s", project.Link.ProjectNamespace, project.Link.ProjectName) 388 | case "github": 389 | gitRepository[0]["repo"] = fmt.Sprintf("%s/%s", project.Link.Org, project.Link.Repo) 390 | case "bitbucket": 391 | gitRepository[0]["repo"] = fmt.Sprintf("%s/%s", project.Link.Owner, project.Link.Slug) 392 | default: 393 | return diag.Diagnostics{} 394 | } 395 | 396 | err = d.Set("git_repository", gitRepository) 397 | if err != nil { 398 | return diag.FromErr(err) 399 | } 400 | 401 | return diag.Diagnostics{} 402 | } 403 | 404 | func resourceProjectUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { 405 | 406 | client := meta.(*vercel.Client) 407 | var update projectApi.UpdateProject 408 | 409 | if d.HasChange("name") { 410 | update.Name = d.Get("name").(string) 411 | } 412 | if d.HasChange("framework") { 413 | update.Framework = d.Get("framework").(string) 414 | } 415 | if d.HasChange("public_source") { 416 | update.PublicSource = d.Get("public_source").(bool) 417 | } 418 | if d.HasChange("install_command") { 419 | update.InstallCommand = d.Get("install_command").(string) 420 | } 421 | if d.HasChange("build_command") { 422 | update.BuildCommand = d.Get("build_command").(string) 423 | } 424 | if d.HasChange("dev_command") { 425 | update.DevCommand = d.Get("dev_command").(string) 426 | } 427 | if d.HasChange("output_directory") { 428 | update.OutputDirectory = d.Get("output_directory").(string) 429 | } 430 | if d.HasChange("serverless_function_region") { 431 | update.ServerlessFunctionRegion = d.Get("serverless_function_region").(string) 432 | } 433 | if d.HasChange("root_directory") { 434 | update.RootDirectory = d.Get("root_directory").(string) 435 | } 436 | if d.HasChange("node_version") { 437 | update.NodeVersion = d.Get("node_version").(string) 438 | } 439 | 440 | err := client.Project.Update(d.Id(), update, d.Get("team_id").(string)) 441 | if err != nil { 442 | return diag.FromErr(err) 443 | } 444 | 445 | if d.HasChange("domain") { 446 | rawOldDomains, rawNewDomains := d.GetChange("domain") 447 | 448 | var ( 449 | oldDomains []string 450 | newDomains []string 451 | ) 452 | 453 | for _, d := range rawNewDomains.([]interface{}) { 454 | newDomains = append(newDomains, d.(map[string]interface{})["name"].(string)) 455 | } 456 | 457 | for _, d := range rawOldDomains.([]interface{}) { 458 | oldDomains = append(oldDomains, d.(map[string]interface{})["name"].(string)) 459 | } 460 | 461 | toAdd := util.Difference(newDomains, oldDomains) 462 | toRemove := util.Difference(oldDomains, newDomains) 463 | 464 | for _, dom := range toAdd { 465 | err := client.Project.AddDomain(d.Id(), projectApi.Domain{Name: dom}, d.Get("team_id").(string)) 466 | if err != nil { 467 | return diag.FromErr(err) 468 | } 469 | } 470 | 471 | for _, dom := range toRemove { 472 | err := client.Project.RemoveDomain(d.Id(), dom, d.Get("team_id").(string)) 473 | if err != nil { 474 | return diag.FromErr(err) 475 | } 476 | } 477 | } 478 | 479 | return resourceProjectRead(ctx, d, meta) 480 | } 481 | 482 | func resourceProjectDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { 483 | 484 | client := meta.(*vercel.Client) 485 | err := client.Project.Delete(d.Id(), d.Get("team_id").(string)) 486 | if err != nil { 487 | return diag.FromErr(err) 488 | } 489 | return diag.Diagnostics{} 490 | } 491 | -------------------------------------------------------------------------------- /internal/provider/resource_project_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "testing" 8 | 9 | "github.com/chronark/terraform-provider-vercel/pkg/util" 10 | "github.com/chronark/terraform-provider-vercel/pkg/vercel" 11 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/project" 12 | "github.com/hashicorp/go-uuid" 13 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 14 | "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" 15 | ) 16 | 17 | var ( 18 | // The domain to add to the project. The TLD should be authorized in your test Vercel account. 19 | domainAlias = util.GetEnv("DOMAIN_ALIAS", "domain-alias.chronark.com") 20 | 21 | // The repository to test project creation with. Your Vercel account should have access to this. 22 | repository = util.GetEnv("REPOSITORY", "chronark/terraform-provider-vercel") 23 | ) 24 | 25 | func TestAccVercelProject(t *testing.T) { 26 | 27 | projectName, _ := uuid.GenerateUUID() 28 | updatedProjectName, _ := uuid.GenerateUUID() 29 | var ( 30 | 31 | // Holds the project fetched from vercel when we create it at the beginning 32 | actualProjectAfterCreation project.Project 33 | 34 | // Renaming or changing a variable should not result in the recreation of the project, so we expect to have the same id. 35 | actualProjectAfterUpdate project.Project 36 | 37 | // Used everywhere else 38 | actualProject project.Project 39 | ) 40 | resource.Test(t, resource.TestCase{ 41 | PreCheck: func() { testAccPreCheck(t) }, 42 | ProviderFactories: providerFactories, 43 | CheckDestroy: testAccCheckVercelProjectDestroy(projectName), 44 | Steps: []resource.TestStep{ 45 | { 46 | Config: testAccCheckVercelProjectConfig(projectName), 47 | Check: resource.ComposeTestCheckFunc( 48 | testAccCheckProjectStateHasValues( 49 | "vercel_project.new", project.Project{Name: projectName}, 50 | ), 51 | testAccCheckVercelProjectExists("vercel_project.new", &actualProjectAfterCreation), 52 | testAccCheckActualProjectHasValues(&actualProjectAfterCreation, &project.Project{Name: projectName}), 53 | ), 54 | }, 55 | { 56 | Config: testAccCheckVercelProjectConfig(updatedProjectName), 57 | Check: resource.ComposeTestCheckFunc( 58 | testAccCheckVercelProjectExists("vercel_project.new", &actualProjectAfterUpdate), 59 | testAccCheckProjectStateHasValues( 60 | "vercel_project.new", project.Project{Name: updatedProjectName}, 61 | ), 62 | testAccCheckActualProjectHasValues(&actualProjectAfterUpdate, &project.Project{Name: updatedProjectName}), 63 | testAccCheckProjectWasNotRecreated(&actualProjectAfterCreation, &actualProjectAfterUpdate), 64 | ), 65 | }, 66 | { 67 | Config: testAccCheckVercelProjectConfigWithDomain(updatedProjectName), 68 | Check: resource.ComposeTestCheckFunc( 69 | testAccCheckVercelProjectExists("vercel_project.new", &actualProjectAfterUpdate), 70 | testAccCheckProjectStateHasDomain("vercel_project.new"), 71 | testAccCheckActualProjectHasDomain(&actualProjectAfterUpdate), 72 | ), 73 | }, 74 | { 75 | Config: testAccCheckVercelProjectConfigWithOverridenCommands(projectName), 76 | Check: resource.ComposeTestCheckFunc( 77 | testAccCheckVercelProjectExists("vercel_project.new", &actualProject), 78 | testAccCheckProjectStateHasValues( 79 | "vercel_project.new", project.Project{ 80 | Name: projectName, 81 | InstallCommand: "echo install", 82 | BuildCommand: "echo build", 83 | DevCommand: "echo dev", 84 | OutputDirectory: "out", 85 | }, 86 | ), 87 | testAccCheckActualProjectHasValues(&actualProject, &project.Project{ 88 | Name: projectName, 89 | InstallCommand: "echo install", 90 | BuildCommand: "echo build", 91 | DevCommand: "echo dev", 92 | OutputDirectory: "out", 93 | }, 94 | ), 95 | ), 96 | }, 97 | }, 98 | }) 99 | } 100 | 101 | func TestAccVercelProject_import(t *testing.T) { 102 | 103 | projectName, _ := uuid.GenerateUUID() 104 | var ( 105 | // Used everywhere else 106 | actualProject project.Project 107 | ) 108 | resource.Test(t, resource.TestCase{ 109 | PreCheck: func() { testAccPreCheck(t) }, 110 | ProviderFactories: providerFactories, 111 | CheckDestroy: testAccCheckVercelProjectDestroy(projectName), 112 | Steps: []resource.TestStep{ 113 | { 114 | Config: testAccCheckVercelProjectConfig(projectName), 115 | Check: resource.ComposeTestCheckFunc( 116 | testAccCheckProjectStateHasValues( 117 | "vercel_project.new", project.Project{Name: projectName}, 118 | ), 119 | testAccCheckVercelProjectExists("vercel_project.new", &actualProject), 120 | testAccVercelProjectImport(&actualProject), 121 | ), 122 | }, 123 | }, 124 | }) 125 | } 126 | 127 | // Combines multiple `resource.TestCheckResourceAttr` calls 128 | func testAccCheckProjectStateHasValues(name string, want project.Project) resource.TestCheckFunc { 129 | return func(s *terraform.State) error { 130 | tests := []resource.TestCheckFunc{ 131 | 132 | resource.TestCheckResourceAttr( 133 | name, "install_command", want.InstallCommand), 134 | resource.TestCheckResourceAttr( 135 | name, "build_command", want.BuildCommand), 136 | resource.TestCheckResourceAttr( 137 | name, "dev_command", want.DevCommand), 138 | resource.TestCheckResourceAttr( 139 | name, "output_directory", want.OutputDirectory), 140 | } 141 | 142 | for _, test := range tests { 143 | err := test(s) 144 | if err != nil { 145 | return err 146 | } 147 | } 148 | return nil 149 | } 150 | } 151 | 152 | func testAccCheckProjectStateHasDomain(n string) resource.TestCheckFunc { 153 | return func(s *terraform.State) error { 154 | rs := s.RootModule().Resources[n] 155 | 156 | actual := rs.Primary.Attributes["alias.1"] 157 | want := domainAlias 158 | 159 | if actual != want { 160 | return fmt.Errorf("domain alias does not match, expected: %s, got: %s", want, actual) 161 | } 162 | 163 | return nil 164 | } 165 | } 166 | 167 | // Chaning the name or value of a project should not result in a recreation meaning the UID assigned by vercel 168 | // should not have changed. 169 | func testAccCheckProjectWasNotRecreated(s1, s2 *project.Project) resource.TestCheckFunc { 170 | return func(s *terraform.State) error { 171 | if s1.ID != s2.ID { 172 | return fmt.Errorf("Expected same IDs but they are not the same.") 173 | } 174 | return nil 175 | } 176 | } 177 | 178 | func testAccCheckActualProjectHasValues(actual *project.Project, want *project.Project) resource.TestCheckFunc { 179 | return func(s *terraform.State) error { 180 | if actual.Name != want.Name { 181 | return fmt.Errorf("name does not match, expected: %s, got: %s", want.Name, actual.Name) 182 | } 183 | if actual.ID == "" { 184 | return fmt.Errorf("ID is empty") 185 | } 186 | 187 | if actual.InstallCommand != want.InstallCommand { 188 | return fmt.Errorf("install_command does not match: expected: %s, got: %s", want.InstallCommand, actual.InstallCommand) 189 | } 190 | if actual.BuildCommand != want.BuildCommand { 191 | return fmt.Errorf("build_command does not match: expected: %s, got: %s", want.BuildCommand, actual.BuildCommand) 192 | } 193 | if actual.DevCommand != want.DevCommand { 194 | return fmt.Errorf("dev_command does not match: expected: %s, got: %s", want.DevCommand, actual.DevCommand) 195 | } 196 | if actual.OutputDirectory != want.OutputDirectory { 197 | return fmt.Errorf("output_directory does not match: expected: %s, got: %s", want.OutputDirectory, actual.OutputDirectory) 198 | } 199 | 200 | return nil 201 | } 202 | } 203 | 204 | func testAccCheckActualProjectHasDomain(actual *project.Project) resource.TestCheckFunc { 205 | return func(s *terraform.State) error { 206 | want := domainAlias 207 | 208 | if actual.Alias[1].Domain != want { 209 | return fmt.Errorf("name does not match, expected: %s, got: %s", want, actual.Alias[1].Domain) 210 | } 211 | 212 | return nil 213 | } 214 | } 215 | 216 | // Test whether the project was destroyed properly and finishes the job if necessary 217 | func testAccCheckVercelProjectDestroy(name string) resource.TestCheckFunc { 218 | return func(s *terraform.State) error { 219 | client := vercel.New(os.Getenv("VERCEL_TOKEN")) 220 | 221 | for _, rs := range s.RootModule().Resources { 222 | if rs.Type != name { 223 | continue 224 | } 225 | 226 | project, err := client.Project.Read(rs.Primary.ID, "") 227 | if err == nil { 228 | message := "Project was not deleted from vercel during terraform destroy." 229 | deleteErr := client.Project.Delete(project.Name, "") 230 | if deleteErr != nil { 231 | return fmt.Errorf(message+" Automated removal did not succeed. Please manually remove @%s. Error: %w", project.Name, err) 232 | } 233 | return fmt.Errorf(message + " It was removed now.") 234 | } 235 | 236 | } 237 | return nil 238 | } 239 | 240 | } 241 | 242 | func testAccCheckVercelProjectConfig(name string) string { 243 | return fmt.Sprintf(` 244 | resource "vercel_project" "new" { 245 | name = "%s" 246 | git_repository { 247 | type = "github" 248 | repo = "%s" 249 | } 250 | } 251 | `, name, repository) 252 | } 253 | 254 | func testAccCheckVercelProjectConfigWithDomain(name string) string { 255 | return fmt.Sprintf(` 256 | resource "vercel_project" "new" { 257 | name = "%s" 258 | 259 | git_repository { 260 | type = "github" 261 | repo = "%s" 262 | } 263 | 264 | domain { 265 | git_branch = "main" 266 | name = "%s" 267 | } 268 | } 269 | `, name, repository, domainAlias) 270 | } 271 | 272 | func testAccCheckVercelProjectConfigWithOverridenCommands(name string) string { 273 | return fmt.Sprintf(` 274 | resource "vercel_project" "new" { 275 | name = "%s" 276 | git_repository { 277 | type = "github" 278 | repo = "%s" 279 | } 280 | install_command = "echo install" 281 | build_command = "echo build" 282 | dev_command = "echo dev" 283 | output_directory = "out" 284 | } 285 | `, name, repository) 286 | } 287 | 288 | func testAccCheckVercelProjectExists(n string, actual *project.Project) resource.TestCheckFunc { 289 | return func(s *terraform.State) error { 290 | rs, ok := s.RootModule().Resources[n] 291 | 292 | if !ok { 293 | return fmt.Errorf("Not found: %s in %+v", n, s.RootModule().Resources) 294 | } 295 | 296 | if rs.Primary.ID == "" { 297 | return fmt.Errorf("No project set") 298 | } 299 | 300 | project, err := vercel.New(os.Getenv("VERCEL_TOKEN")).Project.Read(rs.Primary.ID, "") 301 | if err != nil { 302 | return err 303 | } 304 | *actual = project 305 | return nil 306 | } 307 | } 308 | 309 | func testAccVercelProjectImport(source *project.Project) resource.TestCheckFunc { 310 | return func(s *terraform.State) error { 311 | data := resourceProject().Data(nil) 312 | data.SetId(source.Name) 313 | ds, err := resourceProject().Importer.StateContext(context.Background(), data, vercel.New(os.Getenv("VERCEL_TOKEN"))) 314 | if err != nil { 315 | return err 316 | } 317 | if len(ds) != 1 { 318 | return fmt.Errorf("Expected 1 instance state from importer function. Got %d", len(ds)) 319 | } 320 | 321 | if ds[0].Id() != source.Name { 322 | return fmt.Errorf("Imported project ID. Expected '%s'. Actual '%s'.", source.Name, ds[0].Id()) 323 | } 324 | return nil 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /internal/provider/resource_secret.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/chronark/terraform-provider-vercel/pkg/vercel" 7 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/secret" 8 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 9 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 10 | ) 11 | 12 | func resourceSecret() *schema.Resource { 13 | return &schema.Resource{ 14 | Description: "https://vercel.com/docs/api#endpoints/secrets", 15 | 16 | CreateContext: resourceSecretCreate, 17 | ReadContext: resourceSecretRead, 18 | DeleteContext: resourceSecretDelete, 19 | 20 | Importer: &schema.ResourceImporter{ 21 | StateContext: schema.ImportStatePassthroughContext, 22 | }, 23 | 24 | Schema: map[string]*schema.Schema{ 25 | "id": { 26 | Description: "The unique identifier of the secret.", 27 | Type: schema.TypeString, 28 | Computed: true, 29 | }, 30 | "team_id": { 31 | Description: "By default, you can access resources contained within your own user account. To access resources owned by a team, you can pass in the team ID", 32 | Type: schema.TypeString, 33 | Optional: true, 34 | ForceNew: true, 35 | Default: "", 36 | }, 37 | "name": { 38 | Description: "The name of the secret.", 39 | Type: schema.TypeString, 40 | Required: true, 41 | ForceNew: true, 42 | }, 43 | "value": { 44 | Description: "The value of the new secret.", 45 | Type: schema.TypeString, 46 | Required: true, 47 | Sensitive: true, 48 | ForceNew: true, 49 | }, 50 | 51 | "user_id": { 52 | Description: "The unique identifier of the user who created the secret.", 53 | Type: schema.TypeString, 54 | Computed: true, 55 | }, 56 | "created_at": { 57 | Description: "A number containing the date when the variable was created in milliseconds.", 58 | Type: schema.TypeInt, 59 | Computed: true, 60 | }, 61 | }, 62 | } 63 | } 64 | 65 | func resourceSecretCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { 66 | 67 | client := meta.(*vercel.Client) 68 | 69 | payload := secret.CreateSecret{ 70 | Name: d.Get("name").(string), 71 | Value: d.Get("value").(string), 72 | } 73 | 74 | secretID, err := client.Secret.Create(payload) 75 | if err != nil { 76 | return diag.FromErr(err) 77 | } 78 | 79 | d.SetId(secretID) 80 | 81 | return resourceSecretRead(ctx, d, meta) 82 | } 83 | 84 | func resourceSecretRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { 85 | client := meta.(*vercel.Client) 86 | 87 | id := d.Id() 88 | 89 | secret, err := client.Secret.Read(id, d.Get("team_id").(string)) 90 | if err != nil { 91 | return diag.FromErr(err) 92 | } 93 | 94 | err = d.Set("name", secret.Name) 95 | if err != nil { 96 | return diag.FromErr(err) 97 | } 98 | err = d.Set("team_id", secret.TeamID) 99 | if err != nil { 100 | return diag.FromErr(err) 101 | } 102 | err = d.Set("user_id", secret.UserID) 103 | if err != nil { 104 | return diag.FromErr(err) 105 | } 106 | err = d.Set("created_at", secret.CreatedAt) 107 | if err != nil { 108 | return diag.FromErr(err) 109 | } 110 | 111 | return diag.Diagnostics{} 112 | } 113 | 114 | func resourceSecretDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { 115 | 116 | client := meta.(*vercel.Client) 117 | 118 | err := client.Secret.Delete(d.Get("name").(string), d.Get("team_id").(string)) 119 | if err != nil { 120 | return diag.FromErr(err) 121 | } 122 | d.SetId("") 123 | return diag.Diagnostics{} 124 | } 125 | -------------------------------------------------------------------------------- /internal/provider/resource_secret_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "testing" 8 | 9 | "github.com/chronark/terraform-provider-vercel/pkg/vercel" 10 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/secret" 11 | "github.com/hashicorp/go-uuid" 12 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 13 | "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" 14 | ) 15 | 16 | func TestAccVercelSecret(t *testing.T) { 17 | 18 | secretName, _ := uuid.GenerateUUID() 19 | secretValue, _ := uuid.GenerateUUID() 20 | updatedSecretValue, _ := uuid.GenerateUUID() 21 | var ( 22 | 23 | // Holds the secret fetched from vercel when we create it at the beginning 24 | actualSecretAfterCreation secret.Secret 25 | 26 | // Renaming or chaning a variable results in the recreation of the secret, so we expect this value to have a different id. 27 | actualSecretAfterUpdate secret.Secret 28 | ) 29 | resource.Test(t, resource.TestCase{ 30 | PreCheck: func() { testAccPreCheck(t) }, 31 | ProviderFactories: providerFactories, 32 | CheckDestroy: testAccCheckVercelSecretDestroy(secretName), 33 | Steps: []resource.TestStep{ 34 | { 35 | Config: testAccCheckVercelSecretConfig(secretName, secretValue), 36 | Check: resource.ComposeTestCheckFunc( 37 | testAccCheckSecretStateHasValues( 38 | "vercel_secret.new", secret.CreateSecret{Name: secretName, Value: secretValue}, 39 | ), 40 | testAccCheckVercelSecretExists("vercel_secret.new", &actualSecretAfterCreation), 41 | testAccCheckActualSecretHasValues(&actualSecretAfterCreation, &secret.Secret{Name: secretName}), 42 | ), 43 | }, 44 | { 45 | Config: testAccCheckVercelSecretConfig(secretName, updatedSecretValue), 46 | Check: resource.ComposeTestCheckFunc( 47 | testAccCheckVercelSecretExists("vercel_secret.new", &actualSecretAfterUpdate), 48 | testAccCheckSecretStateHasValues( 49 | "vercel_secret.new", secret.CreateSecret{Name: secretName, Value: updatedSecretValue}, 50 | ), 51 | testAccCheckActualSecretHasValues(&actualSecretAfterUpdate, &secret.Secret{Name: secretName}), 52 | testAccCheckSecretWasRecreated(&actualSecretAfterCreation, &actualSecretAfterUpdate), 53 | ), 54 | }, 55 | }, 56 | }) 57 | } 58 | 59 | func TestAccVercelSecret_import(t *testing.T) { 60 | 61 | secretName, _ := uuid.GenerateUUID() 62 | secretValue, _ := uuid.GenerateUUID() 63 | var ( 64 | // Holds the secret fetched from vercel when we create it at the beginning 65 | actualSecretAfterCreation secret.Secret 66 | ) 67 | resource.Test(t, resource.TestCase{ 68 | PreCheck: func() { testAccPreCheck(t) }, 69 | ProviderFactories: providerFactories, 70 | CheckDestroy: testAccCheckVercelSecretDestroy(secretName), 71 | Steps: []resource.TestStep{ 72 | { 73 | Config: testAccCheckVercelSecretConfig(secretName, secretValue), 74 | Check: resource.ComposeTestCheckFunc( 75 | testAccCheckVercelSecretExists("vercel_secret.new", &actualSecretAfterCreation), 76 | testAccVercelSecretImport(&actualSecretAfterCreation), 77 | ), 78 | }, 79 | }, 80 | }) 81 | } 82 | 83 | // Combines multiple `resource.TestCheckResourceAttr` calls 84 | func testAccCheckSecretStateHasValues(name string, want secret.CreateSecret) resource.TestCheckFunc { 85 | return func(s *terraform.State) error { 86 | tests := []resource.TestCheckFunc{ 87 | resource.TestCheckResourceAttr( 88 | name, "name", want.Name), 89 | resource.TestCheckResourceAttr( 90 | name, "value", want.Value), 91 | } 92 | 93 | for _, test := range tests { 94 | err := test(s) 95 | if err != nil { 96 | return err 97 | } 98 | } 99 | return nil 100 | } 101 | } 102 | 103 | // Chaning the name or value of a secret results in a recreation meaning the UID assigned by vercel 104 | // should have changed. 105 | func testAccCheckSecretWasRecreated(s1, s2 *secret.Secret) resource.TestCheckFunc { 106 | return func(s *terraform.State) error { 107 | if s1.UID == s2.UID { 108 | return fmt.Errorf("Expected different UIDs but they are the same.") 109 | } 110 | return nil 111 | } 112 | } 113 | 114 | func testAccCheckActualSecretHasValues(actual *secret.Secret, want *secret.Secret) resource.TestCheckFunc { 115 | return func(s *terraform.State) error { 116 | if actual.Name != want.Name { 117 | return fmt.Errorf("name is not correct, expected: %s, got: %s", want.Name, actual.Name) 118 | } 119 | if actual.UID == "" { 120 | return fmt.Errorf("UID is empty") 121 | } 122 | 123 | return nil 124 | } 125 | } 126 | 127 | // Test whether the secret was destroyed properly and finishes the job if necessary 128 | func testAccCheckVercelSecretDestroy(name string) resource.TestCheckFunc { 129 | return func(s *terraform.State) error { 130 | client := vercel.New(os.Getenv("VERCEL_TOKEN")) 131 | 132 | for _, rs := range s.RootModule().Resources { 133 | if rs.Type != name { 134 | continue 135 | } 136 | 137 | secret, err := client.Secret.Read(rs.Primary.ID, "") 138 | if err == nil { 139 | message := "Secret was not deleted from vercel during terraform destroy." 140 | deleteErr := client.Secret.Delete(secret.Name, "") 141 | if deleteErr != nil { 142 | return fmt.Errorf(message+" Automated removal did not succeed. Please manually remove @%s. Error: %w", secret.Name, err) 143 | } 144 | return fmt.Errorf(message + " It was removed now.") 145 | } 146 | 147 | } 148 | return nil 149 | } 150 | 151 | } 152 | func testAccCheckVercelSecretConfig(name string, value string) string { 153 | return fmt.Sprintf(` 154 | resource "vercel_secret" "new" { 155 | name = "%s" 156 | value = "%s" 157 | } 158 | `, name, value) 159 | } 160 | 161 | func testAccCheckVercelSecretExists(n string, actual *secret.Secret) resource.TestCheckFunc { 162 | return func(s *terraform.State) error { 163 | rs, ok := s.RootModule().Resources[n] 164 | 165 | if !ok { 166 | return fmt.Errorf("Not found: %s in %+v", n, s.RootModule().Resources) 167 | } 168 | 169 | if rs.Primary.ID == "" { 170 | return fmt.Errorf("No secret set") 171 | } 172 | 173 | secret, err := vercel.New(os.Getenv("VERCEL_TOKEN")).Secret.Read(rs.Primary.ID, "") 174 | if err != nil { 175 | return err 176 | } 177 | *actual = secret 178 | return nil 179 | } 180 | } 181 | 182 | func testAccVercelSecretImport(source *secret.Secret) resource.TestCheckFunc { 183 | return func(s *terraform.State) error { 184 | data := resourceSecret().Data(nil) 185 | data.SetId(source.UID) 186 | ds, err := resourceSecret().Importer.StateContext(context.Background(), data, nil) 187 | if err != nil { 188 | return err 189 | } 190 | if len(ds) != 1 { 191 | return fmt.Errorf("Expected 1 instance state from importer function. Got %d", len(ds)) 192 | } 193 | 194 | if ds[0].Id() != source.UID { 195 | return fmt.Errorf("Imported secret ID. Expected '%s'. Actual '%s'.", source.UID, ds[0].Id()) 196 | } 197 | return nil 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "log" 7 | 8 | "github.com/chronark/terraform-provider-vercel/internal/provider" 9 | "github.com/hashicorp/terraform-plugin-sdk/v2/plugin" 10 | ) 11 | 12 | // Run "go generate" to format example terraform files and generate the docs for the registry/website 13 | 14 | // If you do not have terraform installed, you can remove the formatting command, but its suggested to 15 | // ensure the documentation is formatted properly. 16 | //go:generate terraform fmt -recursive ./examples/ 17 | 18 | // Run the docs generation tool, check its repository for more information on how it works and how docs 19 | // can be customized. 20 | //go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs 21 | 22 | var ( 23 | // these will be set by the goreleaser configuration 24 | // to appropriate values for the compiled binary 25 | version string = "dev" 26 | 27 | // goreleaser can also pass the specific commit if you want 28 | // commit string = "" 29 | ) 30 | 31 | func main() { 32 | var debugMode bool 33 | 34 | flag.BoolVar(&debugMode, "debug", false, "set to true to run the provider with support for debuggers like delve") 35 | flag.Parse() 36 | 37 | opts := &plugin.ServeOpts{ProviderFunc: provider.New(version)} 38 | 39 | if debugMode { 40 | err := plugin.Debug(context.Background(), "registry.terraform.io/chronark/vercel", opts) 41 | if err != nil { 42 | log.Fatal(err.Error()) 43 | } 44 | return 45 | } 46 | 47 | plugin.Serve(opts) 48 | } 49 | -------------------------------------------------------------------------------- /pkg/util/env.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "os" 4 | 5 | func GetEnv(key string, fallback string) string { 6 | value := os.Getenv(key) 7 | if len(value) == 0 { 8 | return fallback 9 | } 10 | return value 11 | } 12 | -------------------------------------------------------------------------------- /pkg/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | // Difference returns a slice containing all of the elements of 'a' that aren't present in 'b'. 4 | // 5 | // See https://siongui.github.io/2018/03/14/go-set-difference-of-two-arrays/ 6 | func Difference(a, b []string) (diff []string) { 7 | m := make(map[string]bool) 8 | 9 | for _, item := range b { 10 | m[item] = true 11 | } 12 | 13 | for _, item := range a { 14 | if _, ok := m[item]; !ok { 15 | diff = append(diff, item) 16 | } 17 | } 18 | 19 | return diff 20 | } 21 | -------------------------------------------------------------------------------- /pkg/util/util_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestDifference(t *testing.T) { 9 | tests := []struct { 10 | input1 []string 11 | input2 []string 12 | expected []string 13 | }{ 14 | {[]string{"a", "b", "c"}, []string{"a", "b", "d"}, []string{"c"}}, 15 | {[]string{"a", "b", "d"}, []string{"a", "b", "c"}, []string{"d"}}, 16 | {[]string{"a", "b", "c"}, []string{"d", "e", "f"}, []string{"a", "b", "c"}}, 17 | {[]string{"a", "b", "c"}, []string{}, []string{"a", "b", "c"}}, 18 | } 19 | 20 | for _, test := range tests { 21 | actual := Difference(test.input1, test.input2) 22 | 23 | if !reflect.DeepEqual(test.expected, actual) { 24 | t.Errorf("expected: %v, received: %v", test.expected, actual) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pkg/vercel/alias/alias.go: -------------------------------------------------------------------------------- 1 | package alias 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/httpApi" 10 | ) 11 | 12 | type CreateOrUpdateAlias struct { 13 | Domain string `json:"domain"` 14 | Redirect string `json:"redirect"` 15 | } 16 | 17 | type Alias struct { 18 | CreatedAt int64 `json:"createdAt"` 19 | Domain string `json:"domain"` 20 | Target string `json:"target"` 21 | ConfiguredBy string `json:"configuredBy"` 22 | ConfiguredChangedAt int64 `json:"configuredChangedAt"` 23 | } 24 | 25 | type Handler struct { 26 | Api httpApi.API 27 | } 28 | 29 | func (h *Handler) Create(projectId string, alias CreateOrUpdateAlias, teamId string) error { 30 | url := fmt.Sprintf("/v1/projects/%s/alias", projectId) 31 | if teamId != "" { 32 | url = fmt.Sprintf("%s/?teamId=%s", url, teamId) 33 | } 34 | res, err := h.Api.Request(http.MethodPost, url, alias) 35 | if err != nil { 36 | return err 37 | } 38 | defer res.Body.Close() 39 | 40 | var createdAliases []Alias 41 | err = json.NewDecoder(res.Body).Decode(&createdAliases) 42 | if err != nil { 43 | return err 44 | } 45 | log.Printf("%+v\n\n", createdAliases) 46 | 47 | return nil 48 | } 49 | 50 | func (h *Handler) Update(projectId string, alias CreateOrUpdateAlias, teamId string) error { 51 | url := fmt.Sprintf("/v1/projects/%s/alias", projectId) 52 | if teamId != "" { 53 | url = fmt.Sprintf("%s/?teamId=%s", url, teamId) 54 | } 55 | 56 | res, err := h.Api.Request("PATCH", url, alias) 57 | if err != nil { 58 | return fmt.Errorf("Unable to update env: %w", err) 59 | } 60 | defer res.Body.Close() 61 | return nil 62 | } 63 | func (h *Handler) Delete(projectId, envKey string, teamId string) error { 64 | url := fmt.Sprintf("/v1/projects/%s/alias?domain", projectId) 65 | if teamId != "" { 66 | url = fmt.Sprintf("%s/?teamId=%s", url, teamId) 67 | } 68 | res, err := h.Api.Request("DELETE", url, nil) 69 | if err != nil { 70 | return fmt.Errorf("Unable to delete domain: %w", err) 71 | } 72 | defer res.Body.Close() 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /pkg/vercel/client.go: -------------------------------------------------------------------------------- 1 | package vercel 2 | 3 | import ( 4 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/env" 5 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/httpApi" 6 | 7 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/alias" 8 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/dns" 9 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/domain" 10 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/project" 11 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/secret" 12 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/team" 13 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/user" 14 | ) 15 | 16 | type Client struct { 17 | Project *project.ProjectHandler 18 | User *user.UserHandler 19 | Env *env.Handler 20 | Secret *secret.Handler 21 | Team *team.Handler 22 | Alias *alias.Handler 23 | Domain *domain.Handler 24 | DNS *dns.Handler 25 | } 26 | 27 | func New(token string) *Client { 28 | api := httpApi.New(token) 29 | 30 | return &Client{ 31 | Project: &project.ProjectHandler{ 32 | Api: api, 33 | }, 34 | User: &user.UserHandler{ 35 | Api: api, 36 | }, 37 | Env: &env.Handler{Api: api}, 38 | Secret: &secret.Handler{Api: api}, 39 | Team: &team.Handler{Api: api}, 40 | Alias: &alias.Handler{Api: api}, 41 | Domain: &domain.Handler{Api: api}, 42 | DNS: &dns.Handler{Api: api}, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /pkg/vercel/dns/dns.go: -------------------------------------------------------------------------------- 1 | package dns 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/httpApi" 9 | ) 10 | 11 | type CreateRecord struct { 12 | // The type of record, it could be any valid DNS record. 13 | // See https://vercel.com/docs/api#endpoints/dns for details 14 | Type string `json:"type"` 15 | 16 | // A subdomain name or an empty string for the root domain. 17 | Name string `json:"name"` 18 | 19 | // The record value. 20 | Value string `json:"value"` 21 | 22 | // The TTL value. Must be a number between 60 and 2147483647. Default value is 60. 23 | TTL int `json:"ttl"` 24 | } 25 | 26 | type Record struct { 27 | // The unique ID of the DNS record. Always prepended with rec_. 28 | Id string `json:"id"` 29 | 30 | // The type of record, it could be any valid DNS record. 31 | // See https://vercel.com/docs/api#endpoints/dns for details 32 | Type string `json:"type"` 33 | 34 | // A subdomain name or an empty string for the root domain. 35 | Name string `json:"name"` 36 | 37 | // The record value. 38 | Value string `json:"value"` 39 | 40 | // The ID of the user who created the record or system if the record is an automatic record. 41 | Creator string `json:"creator"` 42 | 43 | // The date when the record was created. 44 | Created int `json:"created"` 45 | 46 | // The date when the record was updated. 47 | Updated int `json:"updated"` 48 | 49 | // The date when the record was created in milliseconds since the UNIX epoch. 50 | CreatedAt int `json:"createdAt"` 51 | 52 | // The date when the record was updated in milliseconds since the UNIX epoch. 53 | UpdatedAt int `json:"updatedAt"` 54 | } 55 | 56 | type Handler struct { 57 | Api httpApi.API 58 | } 59 | 60 | func (h *Handler) Create(domain string, record CreateRecord, teamId string) (string, error) { 61 | url := fmt.Sprintf("/v2/domains/%s/records", domain) 62 | if teamId != "" { 63 | url = fmt.Sprintf("%s/?teamId=%s", url, teamId) 64 | } 65 | res, err := h.Api.Request(http.MethodPost, url, record) 66 | if err != nil { 67 | return "", err 68 | } 69 | defer res.Body.Close() 70 | 71 | type CreateResponse struct { 72 | UID string `json:"uid"` 73 | } 74 | 75 | var createResponse CreateResponse 76 | 77 | err = json.NewDecoder(res.Body).Decode(&createResponse) 78 | if err != nil { 79 | return "", err 80 | } 81 | 82 | return createResponse.UID, nil 83 | } 84 | 85 | func (h *Handler) Read(domain, recordId, teamId string) (Record, error) { 86 | url := fmt.Sprintf("/v2/domains/%s/records?limit=1000", domain) 87 | if teamId != "" { 88 | url = fmt.Sprintf("%s&teamId=%s", url, teamId) 89 | } 90 | res, err := h.Api.Request("GET", url, nil) 91 | if err != nil { 92 | return Record{}, fmt.Errorf("Unable to fetch dns records: %w", err) 93 | } 94 | defer res.Body.Close() 95 | 96 | type Response struct { 97 | Records []Record `json:"records"` 98 | } 99 | var response Response 100 | err = json.NewDecoder(res.Body).Decode(&response) 101 | if err != nil { 102 | return Record{}, err 103 | } 104 | 105 | for _, record := range response.Records { 106 | if record.Id == recordId { 107 | return record, nil 108 | } 109 | } 110 | return Record{}, fmt.Errorf("Record with id %s was not found", recordId) 111 | 112 | } 113 | 114 | func (h *Handler) Delete(domain, recordId string, teamId string) error { 115 | url := fmt.Sprintf("/v2/domains/%s/records/%s", domain, recordId) 116 | if teamId != "" { 117 | url = fmt.Sprintf("%s/?teamId=%s", url, teamId) 118 | } 119 | res, err := h.Api.Request("DELETE", url, nil) 120 | if err != nil { 121 | return fmt.Errorf("Unable to delete dns record: %w", err) 122 | } 123 | defer res.Body.Close() 124 | return nil 125 | } 126 | -------------------------------------------------------------------------------- /pkg/vercel/domain/domain.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/httpApi" 7 | "net/http" 8 | ) 9 | 10 | type CreateDomain struct { 11 | Name string `json:"name"` 12 | } 13 | type Domain struct { 14 | ID string `json:"id"` 15 | Name string `json:"name"` 16 | ServiceType string `json:"serviceType"` 17 | NsVerifiedAt int64 `json:"nsVerifiedAt"` 18 | TxtVerifiedAt int64 `json:"txtVerifiedAt"` 19 | CdnEnabled bool `json:"cdnEnabled"` 20 | CreatedAt int64 `json:"createdAt"` 21 | ExpiresAt int64 `json:"expiresAt"` 22 | BoughtAt int64 `json:"boughtAt"` 23 | TransferredAt int64 `json:"transferredAt"` 24 | VerificationRecord string `json:"verificationRecord"` 25 | Verified bool `json:"verified"` 26 | Nameservers []string `json:"nameservers"` 27 | IntendedNameservers []string `json:"intendedNameservers"` 28 | Creator struct { 29 | ID string `json:"id"` 30 | Username string `json:"username"` 31 | Email string `json:"email"` 32 | } `json:"creator"` 33 | } 34 | 35 | type Handler struct { 36 | Api httpApi.API 37 | } 38 | 39 | func (h *Handler) Create(name string, teamId string) (string, error) { 40 | url := "/v5/domains" 41 | if teamId != "" { 42 | url = fmt.Sprintf("%s?teamId=%s", url, teamId) 43 | } 44 | res, err := h.Api.Request(http.MethodPost, url, CreateDomain{Name: name}) 45 | if err != nil { 46 | return "", err 47 | } 48 | defer res.Body.Close() 49 | 50 | var createdDomain Domain 51 | err = json.NewDecoder(res.Body).Decode(&createdDomain) 52 | if err != nil { 53 | return "", nil 54 | } 55 | 56 | return createdDomain.ID, nil 57 | } 58 | 59 | // Read returns metadata about a domain 60 | func (h *Handler) Read(domainName string, teamId string) (domain Domain, err error) { 61 | url := fmt.Sprintf("/v5/domains/%s", domainName) 62 | if teamId != "" { 63 | url = fmt.Sprintf("%s?teamId=%s", url, teamId) 64 | } 65 | 66 | res, err := h.Api.Request("GET", url, nil) 67 | if err != nil { 68 | return Domain{}, fmt.Errorf("Unable to fetch domain from vercel: %w", err) 69 | } 70 | defer res.Body.Close() 71 | 72 | type GetDomainResponse struct { 73 | Domain Domain `json:"domain"` 74 | } 75 | getDomainResponse := GetDomainResponse{} 76 | err = json.NewDecoder(res.Body).Decode(&getDomainResponse) 77 | if err != nil { 78 | return Domain{}, fmt.Errorf("Unable to unmarshal domain response: %w", err) 79 | } 80 | 81 | return getDomainResponse.Domain, nil 82 | } 83 | 84 | func (h *Handler) Delete(domainName string, teamId string) error { 85 | url := fmt.Sprintf("/v5/domains/%s", domainName) 86 | if teamId != "" { 87 | url = fmt.Sprintf("%s?teamId=%s", url, teamId) 88 | } 89 | res, err := h.Api.Request("DELETE", url, nil) 90 | if err != nil { 91 | return fmt.Errorf("Unable to delete domain: %w", err) 92 | } 93 | defer res.Body.Close() 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /pkg/vercel/env/env.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/httpApi" 10 | ) 11 | 12 | type CreateOrUpdateEnv struct { 13 | // The type can be `plain`, `secret`, or `system`. 14 | Type string `json:"type"` 15 | 16 | // The name of the environment variable. 17 | Key string `json:"key"` 18 | 19 | // If the type is `plain`, a string representing the value of the environment variable. 20 | // If the type is `secret`, the secret ID of the secret attached to the environment variable. 21 | // If the type is `system`, the name of the System Environment Variable. 22 | Value string `json:"value"` 23 | 24 | // The target can be a list of `development`, `preview`, or `production`. 25 | Target []string `json:"target"` 26 | } 27 | 28 | type Env struct { 29 | Type string `json:"type"` 30 | ID string `json:"id"` 31 | Key string `json:"key"` 32 | Value string `json:"value"` 33 | Target []string `json:"target"` 34 | ConfigurationID interface{} `json:"configurationId"` 35 | UpdatedAt int64 `json:"updatedAt"` 36 | CreatedAt int64 `json:"createdAt"` 37 | } 38 | 39 | type Handler struct { 40 | Api httpApi.API 41 | } 42 | 43 | func (h *Handler) Create(projectID string, env CreateOrUpdateEnv, teamId string) (string, error) { 44 | url := fmt.Sprintf("/v6/projects/%s/env", projectID) 45 | if teamId != "" { 46 | url = fmt.Sprintf("%s/?teamId=%s", url, teamId) 47 | } 48 | res, err := h.Api.Request(http.MethodPost, url, env) 49 | if err != nil { 50 | return "", err 51 | } 52 | defer res.Body.Close() 53 | 54 | var createdEnv Env 55 | err = json.NewDecoder(res.Body).Decode(&createdEnv) 56 | if err != nil { 57 | return "", nil 58 | } 59 | log.Printf("%+v\n\n", createdEnv) 60 | 61 | return createdEnv.ID, nil 62 | } 63 | 64 | // Read returns environment variables associated with a project 65 | func (h *Handler) Read(projectID string, teamId string) (envs []Env, err error) { 66 | url := fmt.Sprintf("/v6/projects/%s/env", projectID) 67 | if teamId != "" { 68 | url = fmt.Sprintf("%s/?teamId=%s", url, teamId) 69 | } 70 | res, err := h.Api.Request("GET", url, nil) 71 | if err != nil { 72 | return []Env{}, fmt.Errorf("Unable to fetch environment variables from vercel: %w", err) 73 | } 74 | defer res.Body.Close() 75 | 76 | // EnvResponse is only a subset of available data but all we care about 77 | // See https://vercel.com/docs/api#endpoints/projects/get-project-environment-variables 78 | type EnvResponse struct { 79 | Envs []Env `json:"envs"` 80 | } 81 | 82 | var envResponse EnvResponse 83 | 84 | err = json.NewDecoder(res.Body).Decode(&envResponse) 85 | if err != nil { 86 | return []Env{}, fmt.Errorf("Unable to unmarshal environment variables response: %w", err) 87 | } 88 | log.Printf("%+v\n\n", envResponse) 89 | return envResponse.Envs, nil 90 | } 91 | func (h *Handler) Update(projectID string, envID string, env CreateOrUpdateEnv, teamId string) error { 92 | url := fmt.Sprintf("/v6/projects/%s/env/%s", projectID, envID) 93 | if teamId != "" { 94 | url = fmt.Sprintf("%s/?teamId=%s", url, teamId) 95 | } 96 | 97 | res, err := h.Api.Request("PATCH", url, env) 98 | if err != nil { 99 | return fmt.Errorf("Unable to update env: %w", err) 100 | } 101 | defer res.Body.Close() 102 | return nil 103 | } 104 | func (h *Handler) Delete(projectID, envID string, teamId string) error { 105 | url := fmt.Sprintf("/v8/projects/%s/env/%s", projectID, envID) 106 | if teamId != "" { 107 | url = fmt.Sprintf("%s/?teamId=%s", url, teamId) 108 | } 109 | res, err := h.Api.Request("DELETE", url, nil) 110 | if err != nil { 111 | return fmt.Errorf("Unable to delete env: %w", err) 112 | } 113 | defer res.Body.Close() 114 | return nil 115 | } 116 | -------------------------------------------------------------------------------- /pkg/vercel/httpApi/httpApi.go: -------------------------------------------------------------------------------- 1 | package httpApi 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | ) 10 | 11 | type API interface { 12 | Request(method string, path string, body interface{}) (*http.Response, error) 13 | } 14 | 15 | type Api struct { 16 | url string 17 | httpClient *http.Client 18 | userAgent string 19 | token string 20 | } 21 | 22 | func New(token string) API { 23 | return &Api{ 24 | url: "https://api.vercel.com", 25 | httpClient: &http.Client{}, 26 | userAgent: "chronark/terraform-provider-vercel", 27 | token: token, 28 | } 29 | } 30 | 31 | // https://vercel.com/docs/api#api-basics/errors 32 | type VercelError struct { 33 | Error struct { 34 | Code string `json:"code"` 35 | Message string `json:"message"` 36 | } `json:"error"` 37 | } 38 | 39 | func (c *Api) setHeaders(req *http.Request) { 40 | req.Header.Set("User-Agent", c.userAgent) 41 | req.Header.Set("Content-Type", "application/json") 42 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token)) 43 | } 44 | 45 | func (c *Api) do(req *http.Request) (*http.Response, error) { 46 | c.setHeaders(req) 47 | res, err := c.httpClient.Do(req) 48 | if err != nil { 49 | return nil, fmt.Errorf("Unable to perform request: %w", err) 50 | } 51 | if res.StatusCode < 200 || res.StatusCode >= 300 { 52 | defer res.Body.Close() 53 | 54 | var x map[string]interface{} 55 | _ = json.NewDecoder(res.Body).Decode(&x) 56 | 57 | // var vercelError VercelError 58 | // err = json.NewDecoder(res.Body).Decode(&vercelError) 59 | // if err != nil { 60 | // return nil, fmt.Errorf("Error during http request: Unable to extract error: %w", err) 61 | 62 | // } 63 | return nil, fmt.Errorf("Error during http request: %+v", x) 64 | } 65 | return res, nil 66 | } 67 | 68 | func (c *Api) Request(method string, path string, body interface{}) (*http.Response, error) { 69 | var payload io.Reader = nil 70 | if body != nil { 71 | b, err := json.Marshal(body) 72 | if err != nil { 73 | return nil, err 74 | } 75 | payload = bytes.NewBuffer(b) 76 | } 77 | 78 | req, err := http.NewRequest(method, fmt.Sprintf("%s%s", c.url, path), payload) 79 | if err != nil { 80 | return nil, err 81 | } 82 | res, err := c.do(req) 83 | 84 | if err != nil { 85 | return &http.Response{}, fmt.Errorf("Unable to request resource: [%s] %s with payload {%+v}: %w", method, path, payload, err) 86 | } 87 | return res, nil 88 | 89 | } 90 | -------------------------------------------------------------------------------- /pkg/vercel/project/project.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/httpApi" 7 | ) 8 | 9 | type ProjectHandler struct { 10 | Api httpApi.API 11 | } 12 | 13 | func (p *ProjectHandler) Create(project CreateProject, teamId string) (string, error) { 14 | url := "/v6/projects" 15 | if teamId != "" { 16 | url = fmt.Sprintf("%s/?teamId=%s", url, teamId) 17 | } 18 | 19 | res, err := p.Api.Request("POST", url, project) 20 | if err != nil { 21 | return "", err 22 | } 23 | defer res.Body.Close() 24 | 25 | var createdProject Project 26 | err = json.NewDecoder(res.Body).Decode(&createdProject) 27 | if err != nil { 28 | return "", nil 29 | } 30 | 31 | return createdProject.ID, nil 32 | } 33 | func (p *ProjectHandler) Read(id string, teamId string) (project Project, err error) { 34 | url := fmt.Sprintf("/v1/projects/%s", id) 35 | if teamId != "" { 36 | url = fmt.Sprintf("%s/?teamId=%s", url, teamId) 37 | } 38 | 39 | res, err := p.Api.Request("GET", url, nil) 40 | if err != nil { 41 | return Project{}, fmt.Errorf("Unable to fetch project from vercel: %w", err) 42 | } 43 | defer res.Body.Close() 44 | 45 | err = json.NewDecoder(res.Body).Decode(&project) 46 | if err != nil { 47 | return Project{}, fmt.Errorf("Unable to unmarshal project: %w", err) 48 | } 49 | return project, nil 50 | } 51 | func (p *ProjectHandler) Update(id string, project UpdateProject, teamId string) error { 52 | url := fmt.Sprintf("/v2/projects/%s", id) 53 | if teamId != "" { 54 | url = fmt.Sprintf("%s/?teamId=%s", url, teamId) 55 | } 56 | 57 | res, err := p.Api.Request("PATCH", url, project) 58 | if err != nil { 59 | return fmt.Errorf("Unable to update project: %w", err) 60 | } 61 | defer res.Body.Close() 62 | return nil 63 | } 64 | func (p *ProjectHandler) Delete(id string, teamId string) error { 65 | url := fmt.Sprintf("/v1/projects/%s", id) 66 | if teamId != "" { 67 | url = fmt.Sprintf("%s?teamId=%s", url, teamId) 68 | } 69 | 70 | res, err := p.Api.Request("DELETE", url, nil) 71 | if err != nil { 72 | return fmt.Errorf("Unable to delete project: %w", err) 73 | } 74 | defer res.Body.Close() 75 | return nil 76 | } 77 | 78 | func (p *ProjectHandler) AddDomain(projectId string, domain Domain, teamId string) error { 79 | url := fmt.Sprintf("/v8/projects/%s/domains", projectId) 80 | if teamId != "" { 81 | url = fmt.Sprintf("%s?teamId=%s", url, teamId) 82 | } 83 | res, err := p.Api.Request("POST", url, domain) 84 | if err != nil { 85 | return err 86 | } 87 | defer res.Body.Close() 88 | 89 | if res.StatusCode != 200 { 90 | return fmt.Errorf("Unable to add domain") 91 | } 92 | 93 | return nil 94 | } 95 | 96 | func (p *ProjectHandler) RemoveDomain(projectId string, domain string, teamId string) error { 97 | url := fmt.Sprintf("/v8/projects/%s/domains/%s", projectId, domain) 98 | if teamId != "" { 99 | url = fmt.Sprintf("%s?teamId=%s", url, teamId) 100 | } 101 | res, err := p.Api.Request("DELETE", url, domain) 102 | if err != nil { 103 | return err 104 | } 105 | defer res.Body.Close() 106 | 107 | if res.StatusCode != 200 { 108 | return fmt.Errorf("Unable to add domain") 109 | } 110 | 111 | return nil 112 | } 113 | -------------------------------------------------------------------------------- /pkg/vercel/project/types.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import "github.com/chronark/terraform-provider-vercel/pkg/vercel/env" 4 | 5 | type Domain struct { 6 | Name string `json:"name"` 7 | // Redirect string `json:"redirect"` 8 | // RedirectStatusCode int `json:"redirectStatusCode"` 9 | // GitBranch string `json:"gitBranch"` 10 | } 11 | 12 | // Project houses all the information vercel offers about a project via their api 13 | type Project struct { 14 | AccountID string `json:"accountId"` 15 | Alias []struct { 16 | ConfiguredBy string `json:"configuredBy"` 17 | ConfiguredChangedAt int64 `json:"configuredChangedAt"` 18 | CreatedAt int64 `json:"createdAt"` 19 | Deployment struct { 20 | Alias []string `json:"alias"` 21 | AliasAssigned int64 `json:"aliasAssigned"` 22 | Builds []interface{} `json:"builds"` 23 | CreatedAt int64 `json:"createdAt"` 24 | CreatedIn string `json:"createdIn"` 25 | Creator struct { 26 | UID string `json:"uid"` 27 | Email string `json:"email"` 28 | Username string `json:"username"` 29 | GithubLogin string `json:"githubLogin"` 30 | } `json:"creator"` 31 | DeploymentHostname string `json:"deploymentHostname"` 32 | Forced bool `json:"forced"` 33 | ID string `json:"id"` 34 | Meta struct { 35 | GithubCommitRef string `json:"githubCommitRef"` 36 | GithubRepo string `json:"githubRepo"` 37 | GithubOrg string `json:"githubOrg"` 38 | GithubCommitSha string `json:"githubCommitSha"` 39 | GithubRepoID string `json:"githubRepoId"` 40 | GithubCommitMessage string `json:"githubCommitMessage"` 41 | GithubCommitAuthorLogin string `json:"githubCommitAuthorLogin"` 42 | GithubDeployment string `json:"githubDeployment"` 43 | GithubCommitOrg string `json:"githubCommitOrg"` 44 | GithubCommitAuthorName string `json:"githubCommitAuthorName"` 45 | GithubCommitRepo string `json:"githubCommitRepo"` 46 | GithubCommitRepoID string `json:"githubCommitRepoId"` 47 | } `json:"meta"` 48 | Name string `json:"name"` 49 | Plan string `json:"plan"` 50 | Private bool `json:"private"` 51 | ReadyState string `json:"readyState"` 52 | Target string `json:"target"` 53 | TeamID string `json:"teamId"` 54 | Type string `json:"type"` 55 | URL string `json:"url"` 56 | UserID string `json:"userId"` 57 | WithCache bool `json:"withCache"` 58 | } `json:"deployment"` 59 | Domain string `json:"domain"` 60 | Environment string `json:"environment"` 61 | Target string `json:"target"` 62 | } `json:"alias"` 63 | Analytics struct { 64 | ID string `json:"id"` 65 | EnabledAt int64 `json:"enabledAt"` 66 | DisabledAt int64 `json:"disabledAt"` 67 | CanceledAt int64 `json:"canceledAt"` 68 | } `json:"analytics"` 69 | AutoExposeSystemEnvs bool `json:"autoExposeSystemEnvs"` 70 | BuildCommand string `json:"buildCommand"` 71 | CreatedAt int64 `json:"createdAt"` 72 | DevCommand string `json:"devCommand"` 73 | DirectoryListing bool `json:"directoryListing"` 74 | Env []env.Env `json:"env"` 75 | Framework string `json:"framework"` 76 | ID string `json:"id"` 77 | InstallCommand string `json:"installCommand"` 78 | Name string `json:"name"` 79 | NodeVersion string `json:"nodeVersion"` 80 | OutputDirectory string `json:"outputDirectory"` 81 | PublicSource bool `json:"publicSource"` 82 | RootDirectory string `json:"rootDirectory"` 83 | ServerlessFunctionRegion string `json:"serverlessFunctionRegion"` 84 | SourceFilesOutsideRootDirectory bool `json:"sourceFilesOutsideRootDirectory"` 85 | UpdatedAt int64 `json:"updatedAt"` 86 | Link struct { 87 | Type string `json:"type"` 88 | Repo string `json:"repo"` 89 | RepoID int `json:"repoId"` 90 | Org string `json:"org"` 91 | GitCredentialID string `json:"gitCredentialId"` 92 | CreatedAt int64 `json:"createdAt"` 93 | UpdatedAt int64 `json:"updatedAt"` 94 | Sourceless bool `json:"sourceless"` 95 | ProductionBranch string `json:"productionBranch"` 96 | DeployHooks []interface{} `json:"deployHooks"` 97 | ProjectName string `json:"projectName"` 98 | ProjectNamespace string `json:"projectNamespace"` 99 | Owner string `json:"owner"` 100 | Slug string `json:"slug"` 101 | } `json:"link"` 102 | LatestDeployments []struct { 103 | Alias []string `json:"alias"` 104 | AliasAssigned int64 `json:"aliasAssigned"` 105 | Builds []interface{} `json:"builds"` 106 | CreatedAt int64 `json:"createdAt"` 107 | CreatedIn string `json:"createdIn"` 108 | Creator struct { 109 | UID string `json:"uid"` 110 | Email string `json:"email"` 111 | Username string `json:"username"` 112 | GithubLogin string `json:"githubLogin"` 113 | } `json:"creator"` 114 | DeploymentHostname string `json:"deploymentHostname"` 115 | Forced bool `json:"forced"` 116 | ID string `json:"id"` 117 | Meta struct { 118 | GithubCommitRef string `json:"githubCommitRef"` 119 | GithubRepo string `json:"githubRepo"` 120 | GithubOrg string `json:"githubOrg"` 121 | GithubCommitSha string `json:"githubCommitSha"` 122 | GithubCommitAuthorLogin string `json:"githubCommitAuthorLogin"` 123 | GithubCommitMessage string `json:"githubCommitMessage"` 124 | GithubRepoID string `json:"githubRepoId"` 125 | GithubDeployment string `json:"githubDeployment"` 126 | GithubCommitOrg string `json:"githubCommitOrg"` 127 | GithubCommitAuthorName string `json:"githubCommitAuthorName"` 128 | GithubCommitRepo string `json:"githubCommitRepo"` 129 | GithubCommitRepoID string `json:"githubCommitRepoId"` 130 | } `json:"meta"` 131 | Name string `json:"name"` 132 | Plan string `json:"plan"` 133 | Private bool `json:"private"` 134 | ReadyState string `json:"readyState"` 135 | Target interface{} `json:"target"` 136 | TeamID string `json:"teamId"` 137 | Type string `json:"type"` 138 | URL string `json:"url"` 139 | UserID string `json:"userId"` 140 | WithCache bool `json:"withCache"` 141 | } `json:"latestDeployments"` 142 | Targets struct { 143 | Production struct { 144 | Alias []string `json:"alias"` 145 | AliasAssigned int64 `json:"aliasAssigned"` 146 | Builds []interface{} `json:"builds"` 147 | CreatedAt int64 `json:"createdAt"` 148 | CreatedIn string `json:"createdIn"` 149 | Creator struct { 150 | UID string `json:"uid"` 151 | Email string `json:"email"` 152 | Username string `json:"username"` 153 | GithubLogin string `json:"githubLogin"` 154 | } `json:"creator"` 155 | DeploymentHostname string `json:"deploymentHostname"` 156 | Forced bool `json:"forced"` 157 | ID string `json:"id"` 158 | Meta struct { 159 | GithubCommitRef string `json:"githubCommitRef"` 160 | GithubRepo string `json:"githubRepo"` 161 | GithubOrg string `json:"githubOrg"` 162 | GithubCommitSha string `json:"githubCommitSha"` 163 | GithubRepoID string `json:"githubRepoId"` 164 | GithubCommitMessage string `json:"githubCommitMessage"` 165 | GithubCommitAuthorLogin string `json:"githubCommitAuthorLogin"` 166 | GithubDeployment string `json:"githubDeployment"` 167 | GithubCommitOrg string `json:"githubCommitOrg"` 168 | GithubCommitAuthorName string `json:"githubCommitAuthorName"` 169 | GithubCommitRepo string `json:"githubCommitRepo"` 170 | GithubCommitRepoID string `json:"githubCommitRepoId"` 171 | } `json:"meta"` 172 | Name string `json:"name"` 173 | Plan string `json:"plan"` 174 | Private bool `json:"private"` 175 | ReadyState string `json:"readyState"` 176 | Target string `json:"target"` 177 | TeamID string `json:"teamId"` 178 | Type string `json:"type"` 179 | URL string `json:"url"` 180 | UserID string `json:"userId"` 181 | WithCache bool `json:"withCache"` 182 | } `json:"production"` 183 | } `json:"targets"` 184 | } 185 | 186 | // CreateProject has all the fields the user can set when creating a new project 187 | type CreateProject struct { 188 | Name string `json:"name"` 189 | GitRepository *struct { 190 | Type string `json:"type"` 191 | Repo string `json:"repo"` 192 | } `json:"gitRepository,omitempty"` 193 | UpdateProject 194 | } 195 | 196 | // UpdateProject has all the values a user can update without recreating a project 197 | // https://vercel.com/docs/api#endpoints/projects/update-a-single-project 198 | type UpdateProject struct { 199 | // The framework that is being used for this project. When null is used no framework is selected. 200 | Framework string `json:"framework,omitempty"` 201 | 202 | // Specifies whether the source code and logs of the deployments for this project should be public or not. 203 | PublicSource bool `json:"publicSource,omitempty"` 204 | 205 | // The install command for this project. When null is used this value will be automatically detected. 206 | InstallCommand string `json:"installCommand,omitempty"` 207 | 208 | // The build command for this project. When null is used this value will be automatically detected. 209 | BuildCommand string `json:"buildCommand,omitempty"` 210 | 211 | // The dev command for this project. When null is used this value will be automatically detected. 212 | DevCommand string `json:"devCommand,omitempty"` 213 | 214 | // The output directory of the project. When null is used this value will be automatically detected. 215 | OutputDirectory string `json:"outputDirectory,omitempty"` 216 | 217 | // The region to deploy Serverless Functions in this project. 218 | ServerlessFunctionRegion string `json:"serverlessFunctionRegion,omitempty"` 219 | 220 | // The name of a directory or relative path to the source code of your project. When null is used it will default to the project root. 221 | RootDirectory string `json:"rootDirectory,omitempty"` 222 | 223 | // A new name for this project. 224 | Name string `json:"name,omitempty"` 225 | 226 | // The Node.js Version for this project. 227 | NodeVersion string `json:"nodeVersion,omitempty"` 228 | } 229 | -------------------------------------------------------------------------------- /pkg/vercel/secret/secret.go: -------------------------------------------------------------------------------- 1 | package secret 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/httpApi" 7 | "time" 8 | ) 9 | 10 | type Secret struct { 11 | UID string `json:"uid"` 12 | Name string `json:"name"` 13 | TeamID string `json:"teamId"` 14 | UserID string `json:"userId"` 15 | Created time.Time `json:"created"` 16 | CreatedAt int64 `json:"createdAt"` 17 | } 18 | 19 | type CreateSecret struct { 20 | Name string `json:"name"` 21 | Value string `json:"value"` 22 | } 23 | 24 | type Handler struct { 25 | Api httpApi.API 26 | } 27 | 28 | func (h *Handler) Create(secret CreateSecret) (string, error) { 29 | res, err := h.Api.Request("POST", "/v2/now/secrets", secret) 30 | if err != nil { 31 | return "", err 32 | } 33 | defer res.Body.Close() 34 | 35 | var createdSecret Secret 36 | err = json.NewDecoder(res.Body).Decode(&createdSecret) 37 | if err != nil { 38 | return "", nil 39 | } 40 | 41 | return createdSecret.UID, nil 42 | } 43 | 44 | // Read returns environment variables associated with a project 45 | func (h *Handler) Read(secretID, teamId string) (secret Secret, err error) { 46 | url := fmt.Sprintf("/v3/now/secrets/%s", secretID) 47 | if teamId != "" { 48 | url = fmt.Sprintf("%s/?teamId=%s", url, teamId) 49 | } 50 | 51 | res, err := h.Api.Request("GET", url, nil) 52 | if err != nil { 53 | return Secret{}, fmt.Errorf("Unable to fetch secret from vercel: %w", err) 54 | } 55 | defer res.Body.Close() 56 | 57 | err = json.NewDecoder(res.Body).Decode(&secret) 58 | if err != nil { 59 | return Secret{}, fmt.Errorf("Unable to unmarshal environment variables response: %w", err) 60 | } 61 | return secret, nil 62 | } 63 | func (h *Handler) Update(oldName, newName, teamId string) error { 64 | url := fmt.Sprintf("/v2/now/secrets/%s", oldName) 65 | if teamId != "" { 66 | url = fmt.Sprintf("%s/?teamId=%s", url, teamId) 67 | } 68 | payload := struct { 69 | Name string `json:"name"` 70 | }{ 71 | Name: newName, 72 | } 73 | 74 | res, err := h.Api.Request("PATCH", url, payload) 75 | if err != nil { 76 | return fmt.Errorf("Unable to update secret: %w", err) 77 | } 78 | defer res.Body.Close() 79 | return nil 80 | } 81 | func (h *Handler) Delete(secretName, teamId string) error { 82 | url := fmt.Sprintf("/v2/now/secrets/%s", secretName) 83 | if teamId != "" { 84 | url = fmt.Sprintf("%s/?teamId=%s", url, teamId) 85 | } 86 | res, err := h.Api.Request("DELETE", url, nil) 87 | if err != nil { 88 | return fmt.Errorf("Unable to delete secret: %w", err) 89 | } 90 | defer res.Body.Close() 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /pkg/vercel/team/team.go: -------------------------------------------------------------------------------- 1 | package team 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/httpApi" 9 | ) 10 | 11 | type Team struct { 12 | Id string `json:"id"` 13 | Slug string `json:"slug"` 14 | Name string `json:"name"` 15 | CreatorId string `json:"creatorId"` 16 | Created time.Time `json:"created"` 17 | Avatar string `json:"avatar"` 18 | } 19 | 20 | type Handler struct { 21 | Api httpApi.API 22 | } 23 | 24 | func (h *Handler) Read(slug string) (user Team, err error) { 25 | res, err := h.Api.Request("GET", fmt.Sprintf("/v1/teams/?slug=%s", slug), nil) 26 | if err != nil { 27 | return Team{}, fmt.Errorf("Unable to fetch team from vercel: %w", err) 28 | } 29 | defer res.Body.Close() 30 | 31 | var team Team 32 | err = json.NewDecoder(res.Body).Decode(&team) 33 | if err != nil { 34 | return Team{}, fmt.Errorf("Unable to unmarshal team: %w", err) 35 | } 36 | return team, nil 37 | } 38 | -------------------------------------------------------------------------------- /pkg/vercel/user/user.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/chronark/terraform-provider-vercel/pkg/vercel/httpApi" 7 | ) 8 | 9 | type User struct { 10 | UID string `json:"uid"` 11 | Email string `json:"email"` 12 | Name string `json:"name"` 13 | Username string `json:"username"` 14 | Avatar string `json:"avatar"` 15 | PlatformVersion int `json:"platformVersion"` 16 | Billing struct { 17 | Plan string `json:"plan"` 18 | Period interface{} `json:"period"` 19 | Trial interface{} `json:"trial"` 20 | Cancelation interface{} `json:"cancelation"` 21 | Addons interface{} `json:"addons"` 22 | } `json:"billing"` 23 | Bio string `json:"bio"` 24 | Website string `json:"website"` 25 | Profiles []struct { 26 | Service string `json:"service"` 27 | Link string `json:"link"` 28 | } `json:"profiles"` 29 | } 30 | 31 | type UserHandler struct { 32 | Api httpApi.API 33 | } 34 | 35 | func (p *UserHandler) Read() (user User, err error) { 36 | res, err := p.Api.Request("GET", "/www/user", nil) 37 | if err != nil { 38 | return User{}, fmt.Errorf("Unable to fetch user from vercel: %w", err) 39 | } 40 | defer res.Body.Close() 41 | 42 | type UserResponse struct { 43 | User User `json:"user"` 44 | } 45 | 46 | var userResponse UserResponse 47 | err = json.NewDecoder(res.Body).Decode(&userResponse) 48 | if err != nil { 49 | return User{}, fmt.Errorf("Unable to unmarshal project: %w", err) 50 | } 51 | return userResponse.User, nil 52 | } 53 | -------------------------------------------------------------------------------- /tools/tools.go: -------------------------------------------------------------------------------- 1 | // +build tools 2 | 3 | package tools 4 | 5 | import ( 6 | // document generation 7 | _ "github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs" 8 | ) 9 | --------------------------------------------------------------------------------