├── .gitignore ├── .vscode ├── launch.json └── tasks.json ├── GitVersion.yml ├── LICENSE ├── README.md ├── Tasks ├── Terraform │ ├── icon.png │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── Index.ts │ │ ├── Options.ts │ │ ├── Provider │ │ │ ├── Azure │ │ │ │ ├── ARMAuthenticationMethod.ts │ │ │ │ ├── ARMConnectedServiceOptions.ts │ │ │ │ ├── AzureOptions.ts │ │ │ │ ├── AzureProvider.ts │ │ │ │ └── AzureStorageService.ts │ │ │ ├── Remote │ │ │ │ ├── RemoteConnectedServiceOptions.ts │ │ │ │ └── RemoteProvider.ts │ │ │ ├── TerraformProvider.ts │ │ │ └── TerraformProviderType.ts │ │ ├── TaskOptions.ts │ │ ├── TerraformCommandRunner.ts │ │ ├── TerraformTask.ts │ │ └── types.ts │ ├── task.json │ ├── test │ │ └── Provider │ │ │ └── Azure │ │ │ └── AzureProvider.spec.ts │ └── tsconfig.json ├── TerraformEnterprise │ ├── icon.png │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── Index.ts │ │ ├── TaskOptions.ts │ │ ├── TerraformApi.ts │ │ └── TerraformEnterpriseTask.ts │ ├── task.json │ └── tsconfig.json └── TerraformInstaller │ ├── icon.png │ ├── package-lock.json │ ├── package.json │ ├── src │ ├── Index.ts │ └── TerraformInstaller.ts │ ├── task.json │ └── tsconfig.json ├── azure-pipelines-extension-build.yml ├── azure-pipelines-extension-release.yml ├── azure-pipelines-task-build.yml ├── azure-pipelines.yml ├── images └── icon.png ├── tests ├── azure-pipelines.yml ├── test-cli.yml ├── test-init-validate-plan-apply-destroy.yml ├── test-installer.yml ├── test-template-azure │ ├── main.tf │ └── output.plan ├── test-template-no-backend │ └── main.tf └── test-template-remote │ └── main.tf └── vss-extension.json /.gitignore: -------------------------------------------------------------------------------- 1 | *.vsix 2 | node_modules/ 3 | .taskkey 4 | .env 5 | .bin/ 6 | .dist/ 7 | .terraform/ 8 | *.tfstate* -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | // Terraform init 8 | { 9 | "type": "node", 10 | "request": "launch", 11 | "name": "Terraform Init - Azure", 12 | "envFile": "${workspaceFolder}/Tasks/Terraform/.env", 13 | "env": { 14 | "INPUT_command": "init", 15 | "INPUT_cwd": "./tests/test-template-azure", 16 | "INPUT_backend": "Azure", 17 | "INPUT_backendAzureProviderStorageAccountName": "mtcdenterraformsandbox", 18 | "INPUT_providerAzureConnectedServiceName": "AzureProviderConnection", 19 | "INPUT_backendAzureUseProviderConnectedServiceForBackend": "true" 20 | }, 21 | "program": "${workspaceFolder}/Tasks/Terraform/src/Index.ts", 22 | "preLaunchTask": "Compile Terraform", 23 | "internalConsoleOptions": "openOnSessionStart", 24 | "sourceMaps": true, 25 | "outputCapture": "std", 26 | "outFiles": [ 27 | "${workspaceFolder}/Tasks/Terraform/.bin/**/*.js" 28 | ] 29 | }, 30 | 31 | { 32 | "type": "node", 33 | "request": "launch", 34 | "name": "Terraform Plan - Azure", 35 | "envFile": "${workspaceFolder}/Tasks/Terraform/.env", 36 | "env": { 37 | "INPUT_command": "plan", 38 | "INPUT_cwd": "./tests/test-template-azure", 39 | "INPUT_backend": "Azure", 40 | "INPUT_backendAzureProviderStorageAccountName": "mtcdenterraformsandbox", 41 | "INPUT_providerAzureConnectedServiceName": "AzureProviderConnection", 42 | "INPUT_backendAzureUseProviderConnectedServiceForBackend": "true", 43 | "INPUT_outputFile": "output.plan", 44 | "INPUT_variables": "{\"environment\": \"dev\"}" 45 | }, 46 | "program": "${workspaceFolder}/Tasks/Terraform/src/Index.ts", 47 | "preLaunchTask": "Compile Terraform", 48 | "internalConsoleOptions": "openOnSessionStart", 49 | "sourceMaps": true, 50 | "outputCapture": "std", 51 | "outFiles": [ 52 | "${workspaceFolder}/Tasks/Terraform/.bin/**/*.js" 53 | ] 54 | }, 55 | 56 | { 57 | "type": "node", 58 | "request": "launch", 59 | "name": "Terraform Apply - Azure", 60 | "envFile": "${workspaceFolder}/Tasks/Terraform/.env", 61 | "env": { 62 | "INPUT_command": "apply", 63 | "INPUT_cwd": "./tests/test-template-azure", 64 | "INPUT_backend": "Azure", 65 | "INPUT_backendAzureProviderStorageAccountName": "mtcdenterraformsandbox", 66 | "INPUT_providerAzureConnectedServiceName": "AzureProviderConnection", 67 | "INPUT_backendAzureUseProviderConnectedServiceForBackend": "true", 68 | "INPUT_planFile": "output.plan" 69 | }, 70 | "program": "${workspaceFolder}/Tasks/Terraform/src/Index.ts", 71 | "preLaunchTask": "Compile Terraform", 72 | "internalConsoleOptions": "openOnSessionStart", 73 | "sourceMaps": true, 74 | "outputCapture": "std", 75 | "outFiles": [ 76 | "${workspaceFolder}/Tasks/Terraform/.bin/**/*.js" 77 | ] 78 | }, 79 | 80 | // Terraform CLI 81 | { 82 | "type": "node", 83 | "request": "launch", 84 | "name": "Terraform CLI - Azure", 85 | "envFile": "${workspaceFolder}/Tasks/Terraform/.env", 86 | "env": { 87 | "INPUT_command": "CLI", 88 | "INPUT_script": "terraform init \n terraform plan", 89 | "INPUT_cwd": "./tests/test-template-azure", 90 | "INPUT_provider": "Azure", 91 | "INPUT_backend": "Azure", 92 | "INPUT_backendAzureProviderStorageAccountName": "mtcdenterraformsandbox", 93 | "INPUT_providerAzureConnectedServiceName": "AzureProviderConnection", 94 | "INPUT_backendAzureUseProviderConnectedServiceForBackend": "true" 95 | }, 96 | "program": "${workspaceFolder}/Tasks/Terraform/src/Index.ts", 97 | "preLaunchTask": "Compile Terraform", 98 | "internalConsoleOptions": "openOnSessionStart", 99 | "sourceMaps": true, 100 | "outputCapture": "std", 101 | "outFiles": [ 102 | "${workspaceFolder}/Tasks/Terraform/.bin/**/*.js" 103 | ] 104 | }, 105 | 106 | // Terraform Init Remote Backend 107 | { 108 | "type": "node", 109 | "request": "launch", 110 | "name": "Terraform Init - Remote", 111 | "envFile": "${workspaceFolder}/Tasks/Terraform/.env", 112 | "env": { 113 | "INPUT_command": "init", 114 | "INPUT_cwd": "./tests/test-template-remote", 115 | "INPUT_backend": "Remote", 116 | "INPUT_provider": "Remote" 117 | }, 118 | "program": "${workspaceFolder}/Tasks/Terraform/src/Index.ts", 119 | "preLaunchTask": "Compile Terraform", 120 | "internalConsoleOptions": "openOnSessionStart", 121 | "sourceMaps": true, 122 | "outputCapture": "std", 123 | "outFiles": [ 124 | "${workspaceFolder}/Tasks/Terraform/.bin/**/*.js" 125 | ] 126 | }, 127 | 128 | // Terraform Installer 129 | { 130 | "type": "node", 131 | "request": "launch", 132 | "name": "TerraformInstaller", 133 | "envFile": "${workspaceFolder}/Tasks/TerraformInstaller/.env", 134 | "program": "${workspaceFolder}/Tasks/TerraformInstaller/src/Index.ts", 135 | "preLaunchTask": "Compile TerraformInstaller", 136 | "internalConsoleOptions": "openOnSessionStart", 137 | "sourceMaps": true, 138 | //"smartStep": false, 139 | "outputCapture": "std", 140 | "outFiles": [ 141 | "${workspaceFolder}/Tasks/TerraformInstaller/.bin/**/*.js" 142 | ] 143 | } 144 | ] 145 | } 146 | 147 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Compile Terraform", 8 | "type": "typescript", 9 | "tsconfig": "Tasks/Terraform/tsconfig.json", 10 | "problemMatcher": [ 11 | "$tsc" 12 | ], 13 | "group": { 14 | "kind": "build", 15 | "isDefault": true 16 | } 17 | }, 18 | { 19 | "label": "Compile Terraform Installer", 20 | "type": "typescript", 21 | "tsconfig": "Tasks/TerraformInstaller/tsconfig.json", 22 | "problemMatcher": [ 23 | "$tsc" 24 | ], 25 | "group": { 26 | "kind": "build", 27 | "isDefault": true 28 | } 29 | }, 30 | { 31 | "label": "Compile Terraform Enterprise", 32 | "type": "typescript", 33 | "tsconfig": "Tasks/TerraformEnterprise/tsconfig.json", 34 | "problemMatcher": [ 35 | "$tsc" 36 | ], 37 | "group": { 38 | "kind": "build", 39 | "isDefault": true 40 | } 41 | } 42 | ] 43 | } -------------------------------------------------------------------------------- /GitVersion.yml: -------------------------------------------------------------------------------- 1 | mode: Mainline 2 | branches: {} 3 | ignore: 4 | sha: [] 5 | merge-message-formats: {} 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 HashiCorp, Inc. 2 | 3 | Mozilla Public License Version 2.0 4 | ================================== 5 | 6 | 1. Definitions 7 | -------------- 8 | 9 | 1.1. "Contributor" 10 | means each individual or legal entity that creates, contributes to 11 | the creation of, or owns Covered Software. 12 | 13 | 1.2. "Contributor Version" 14 | means the combination of the Contributions of others (if any) used 15 | by a Contributor and that particular Contributor's Contribution. 16 | 17 | 1.3. "Contribution" 18 | means Covered Software of a particular Contributor. 19 | 20 | 1.4. "Covered Software" 21 | means Source Code Form to which the initial Contributor has attached 22 | the notice in Exhibit A, the Executable Form of such Source Code 23 | Form, and Modifications of such Source Code Form, in each case 24 | including portions thereof. 25 | 26 | 1.5. "Incompatible With Secondary Licenses" 27 | means 28 | 29 | (a) that the initial Contributor has attached the notice described 30 | in Exhibit B to the Covered Software; or 31 | 32 | (b) that the Covered Software was made available under the terms of 33 | version 1.1 or earlier of the License, but not also under the 34 | terms of a Secondary License. 35 | 36 | 1.6. "Executable Form" 37 | means any form of the work other than Source Code Form. 38 | 39 | 1.7. "Larger Work" 40 | means a work that combines Covered Software with other material, in 41 | a separate file or files, that is not Covered Software. 42 | 43 | 1.8. "License" 44 | means this document. 45 | 46 | 1.9. "Licensable" 47 | means having the right to grant, to the maximum extent possible, 48 | whether at the time of the initial grant or subsequently, any and 49 | all of the rights conveyed by this License. 50 | 51 | 1.10. "Modifications" 52 | means any of the following: 53 | 54 | (a) any file in Source Code Form that results from an addition to, 55 | deletion from, or modification of the contents of Covered 56 | Software; or 57 | 58 | (b) any new file in Source Code Form that contains any Covered 59 | Software. 60 | 61 | 1.11. "Patent Claims" of a Contributor 62 | means any patent claim(s), including without limitation, method, 63 | process, and apparatus claims, in any patent Licensable by such 64 | Contributor that would be infringed, but for the grant of the 65 | License, by the making, using, selling, offering for sale, having 66 | made, import, or transfer of either its Contributions or its 67 | Contributor Version. 68 | 69 | 1.12. "Secondary License" 70 | means either the GNU General Public License, Version 2.0, the GNU 71 | Lesser General Public License, Version 2.1, the GNU Affero General 72 | Public License, Version 3.0, or any later versions of those 73 | licenses. 74 | 75 | 1.13. "Source Code Form" 76 | means the form of the work preferred for making modifications. 77 | 78 | 1.14. "You" (or "Your") 79 | means an individual or a legal entity exercising rights under this 80 | License. For legal entities, "You" includes any entity that 81 | controls, is controlled by, or is under common control with You. For 82 | purposes of this definition, "control" means (a) the power, direct 83 | or indirect, to cause the direction or management of such entity, 84 | whether by contract or otherwise, or (b) ownership of more than 85 | fifty percent (50%) of the outstanding shares or beneficial 86 | ownership of such entity. 87 | 88 | 2. License Grants and Conditions 89 | -------------------------------- 90 | 91 | 2.1. Grants 92 | 93 | Each Contributor hereby grants You a world-wide, royalty-free, 94 | non-exclusive license: 95 | 96 | (a) under intellectual property rights (other than patent or trademark) 97 | Licensable by such Contributor to use, reproduce, make available, 98 | modify, display, perform, distribute, and otherwise exploit its 99 | Contributions, either on an unmodified basis, with Modifications, or 100 | as part of a Larger Work; and 101 | 102 | (b) under Patent Claims of such Contributor to make, use, sell, offer 103 | for sale, have made, import, and otherwise transfer either its 104 | Contributions or its Contributor Version. 105 | 106 | 2.2. Effective Date 107 | 108 | The licenses granted in Section 2.1 with respect to any Contribution 109 | become effective for each Contribution on the date the Contributor first 110 | distributes such Contribution. 111 | 112 | 2.3. Limitations on Grant Scope 113 | 114 | The licenses granted in this Section 2 are the only rights granted under 115 | this License. No additional rights or licenses will be implied from the 116 | distribution or licensing of Covered Software under this License. 117 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 118 | Contributor: 119 | 120 | (a) for any code that a Contributor has removed from Covered Software; 121 | or 122 | 123 | (b) for infringements caused by: (i) Your and any other third party's 124 | modifications of Covered Software, or (ii) the combination of its 125 | Contributions with other software (except as part of its Contributor 126 | Version); or 127 | 128 | (c) under Patent Claims infringed by Covered Software in the absence of 129 | its Contributions. 130 | 131 | This License does not grant any rights in the trademarks, service marks, 132 | or logos of any Contributor (except as may be necessary to comply with 133 | the notice requirements in Section 3.4). 134 | 135 | 2.4. Subsequent Licenses 136 | 137 | No Contributor makes additional grants as a result of Your choice to 138 | distribute the Covered Software under a subsequent version of this 139 | License (see Section 10.2) or under the terms of a Secondary License (if 140 | permitted under the terms of Section 3.3). 141 | 142 | 2.5. Representation 143 | 144 | Each Contributor represents that the Contributor believes its 145 | Contributions are its original creation(s) or it has sufficient rights 146 | to grant the rights to its Contributions conveyed by this License. 147 | 148 | 2.6. Fair Use 149 | 150 | This License is not intended to limit any rights You have under 151 | applicable copyright doctrines of fair use, fair dealing, or other 152 | equivalents. 153 | 154 | 2.7. Conditions 155 | 156 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 157 | in Section 2.1. 158 | 159 | 3. Responsibilities 160 | ------------------- 161 | 162 | 3.1. Distribution of Source Form 163 | 164 | All distribution of Covered Software in Source Code Form, including any 165 | Modifications that You create or to which You contribute, must be under 166 | the terms of this License. You must inform recipients that the Source 167 | Code Form of the Covered Software is governed by the terms of this 168 | License, and how they can obtain a copy of this License. You may not 169 | attempt to alter or restrict the recipients' rights in the Source Code 170 | Form. 171 | 172 | 3.2. Distribution of Executable Form 173 | 174 | If You distribute Covered Software in Executable Form then: 175 | 176 | (a) such Covered Software must also be made available in Source Code 177 | Form, as described in Section 3.1, and You must inform recipients of 178 | the Executable Form how they can obtain a copy of such Source Code 179 | Form by reasonable means in a timely manner, at a charge no more 180 | than the cost of distribution to the recipient; and 181 | 182 | (b) You may distribute such Executable Form under the terms of this 183 | License, or sublicense it under different terms, provided that the 184 | license for the Executable Form does not attempt to limit or alter 185 | the recipients' rights in the Source Code Form under this License. 186 | 187 | 3.3. Distribution of a Larger Work 188 | 189 | You may create and distribute a Larger Work under terms of Your choice, 190 | provided that You also comply with the requirements of this License for 191 | the Covered Software. If the Larger Work is a combination of Covered 192 | Software with a work governed by one or more Secondary Licenses, and the 193 | Covered Software is not Incompatible With Secondary Licenses, this 194 | License permits You to additionally distribute such Covered Software 195 | under the terms of such Secondary License(s), so that the recipient of 196 | the Larger Work may, at their option, further distribute the Covered 197 | Software under the terms of either this License or such Secondary 198 | License(s). 199 | 200 | 3.4. Notices 201 | 202 | You may not remove or alter the substance of any license notices 203 | (including copyright notices, patent notices, disclaimers of warranty, 204 | or limitations of liability) contained within the Source Code Form of 205 | the Covered Software, except that You may alter any license notices to 206 | the extent required to remedy known factual inaccuracies. 207 | 208 | 3.5. Application of Additional Terms 209 | 210 | You may choose to offer, and to charge a fee for, warranty, support, 211 | indemnity or liability obligations to one or more recipients of Covered 212 | Software. However, You may do so only on Your own behalf, and not on 213 | behalf of any Contributor. You must make it absolutely clear that any 214 | such warranty, support, indemnity, or liability obligation is offered by 215 | You alone, and You hereby agree to indemnify every Contributor for any 216 | liability incurred by such Contributor as a result of warranty, support, 217 | indemnity or liability terms You offer. You may include additional 218 | disclaimers of warranty and limitations of liability specific to any 219 | jurisdiction. 220 | 221 | 4. Inability to Comply Due to Statute or Regulation 222 | --------------------------------------------------- 223 | 224 | If it is impossible for You to comply with any of the terms of this 225 | License with respect to some or all of the Covered Software due to 226 | statute, judicial order, or regulation then You must: (a) comply with 227 | the terms of this License to the maximum extent possible; and (b) 228 | describe the limitations and the code they affect. Such description must 229 | be placed in a text file included with all distributions of the Covered 230 | Software under this License. Except to the extent prohibited by statute 231 | or regulation, such description must be sufficiently detailed for a 232 | recipient of ordinary skill to be able to understand it. 233 | 234 | 5. Termination 235 | -------------- 236 | 237 | 5.1. The rights granted under this License will terminate automatically 238 | if You fail to comply with any of its terms. However, if You become 239 | compliant, then the rights granted under this License from a particular 240 | Contributor are reinstated (a) provisionally, unless and until such 241 | Contributor explicitly and finally terminates Your grants, and (b) on an 242 | ongoing basis, if such Contributor fails to notify You of the 243 | non-compliance by some reasonable means prior to 60 days after You have 244 | come back into compliance. Moreover, Your grants from a particular 245 | Contributor are reinstated on an ongoing basis if such Contributor 246 | notifies You of the non-compliance by some reasonable means, this is the 247 | first time You have received notice of non-compliance with this License 248 | from such Contributor, and You become compliant prior to 30 days after 249 | Your receipt of the notice. 250 | 251 | 5.2. If You initiate litigation against any entity by asserting a patent 252 | infringement claim (excluding declaratory judgment actions, 253 | counter-claims, and cross-claims) alleging that a Contributor Version 254 | directly or indirectly infringes any patent, then the rights granted to 255 | You by any and all Contributors for the Covered Software under Section 256 | 2.1 of this License shall terminate. 257 | 258 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 259 | end user license agreements (excluding distributors and resellers) which 260 | have been validly granted by You or Your distributors under this License 261 | prior to termination shall survive termination. 262 | 263 | ************************************************************************ 264 | * * 265 | * 6. Disclaimer of Warranty * 266 | * ------------------------- * 267 | * * 268 | * Covered Software is provided under this License on an "as is" * 269 | * basis, without warranty of any kind, either expressed, implied, or * 270 | * statutory, including, without limitation, warranties that the * 271 | * Covered Software is free of defects, merchantable, fit for a * 272 | * particular purpose or non-infringing. The entire risk as to the * 273 | * quality and performance of the Covered Software is with You. * 274 | * Should any Covered Software prove defective in any respect, You * 275 | * (not any Contributor) assume the cost of any necessary servicing, * 276 | * repair, or correction. This disclaimer of warranty constitutes an * 277 | * essential part of this License. No use of any Covered Software is * 278 | * authorized under this License except under this disclaimer. * 279 | * * 280 | ************************************************************************ 281 | 282 | ************************************************************************ 283 | * * 284 | * 7. Limitation of Liability * 285 | * -------------------------- * 286 | * * 287 | * Under no circumstances and under no legal theory, whether tort * 288 | * (including negligence), contract, or otherwise, shall any * 289 | * Contributor, or anyone who distributes Covered Software as * 290 | * permitted above, be liable to You for any direct, indirect, * 291 | * special, incidental, or consequential damages of any character * 292 | * including, without limitation, damages for lost profits, loss of * 293 | * goodwill, work stoppage, computer failure or malfunction, or any * 294 | * and all other commercial damages or losses, even if such party * 295 | * shall have been informed of the possibility of such damages. This * 296 | * limitation of liability shall not apply to liability for death or * 297 | * personal injury resulting from such party's negligence to the * 298 | * extent applicable law prohibits such limitation. Some * 299 | * jurisdictions do not allow the exclusion or limitation of * 300 | * incidental or consequential damages, so this exclusion and * 301 | * limitation may not apply to You. * 302 | * * 303 | ************************************************************************ 304 | 305 | 8. Litigation 306 | ------------- 307 | 308 | Any litigation relating to this License may be brought only in the 309 | courts of a jurisdiction where the defendant maintains its principal 310 | place of business and such litigation shall be governed by laws of that 311 | jurisdiction, without reference to its conflict-of-law provisions. 312 | Nothing in this Section shall prevent a party's ability to bring 313 | cross-claims or counter-claims. 314 | 315 | 9. Miscellaneous 316 | ---------------- 317 | 318 | This License represents the complete agreement concerning the subject 319 | matter hereof. If any provision of this License is held to be 320 | unenforceable, such provision shall be reformed only to the extent 321 | necessary to make it enforceable. Any law or regulation which provides 322 | that the language of a contract shall be construed against the drafter 323 | shall not be used to construe this License against a Contributor. 324 | 325 | 10. Versions of the License 326 | --------------------------- 327 | 328 | 10.1. New Versions 329 | 330 | Mozilla Foundation is the license steward. Except as provided in Section 331 | 10.3, no one other than the license steward has the right to modify or 332 | publish new versions of this License. Each version will be given a 333 | distinguishing version number. 334 | 335 | 10.2. Effect of New Versions 336 | 337 | You may distribute the Covered Software under the terms of the version 338 | of the License under which You originally received the Covered Software, 339 | or under the terms of any subsequent version published by the license 340 | steward. 341 | 342 | 10.3. Modified Versions 343 | 344 | If you create software not governed by this License, and you want to 345 | create a new license for such software, you may create and use a 346 | modified version of this License if you rename the license and remove 347 | any references to the name of the license steward (except to note that 348 | such modified license differs from this License). 349 | 350 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 351 | Licenses 352 | 353 | If You choose to distribute Source Code Form that is Incompatible With 354 | Secondary Licenses under the terms of this version of the License, the 355 | notice described in Exhibit B of this License must be attached. 356 | 357 | Exhibit A - Source Code Form License Notice 358 | ------------------------------------------- 359 | 360 | This Source Code Form is subject to the terms of the Mozilla Public 361 | License, v. 2.0. If a copy of the MPL was not distributed with this 362 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 363 | 364 | If it is not possible or desirable to put the notice in a particular 365 | file, then You may include the notice in a location (such as a LICENSE 366 | file in a relevant directory) where a recipient would be likely to look 367 | for such a notice. 368 | 369 | You may add additional accurate notices of copyright ownership. 370 | 371 | Exhibit B - "Incompatible With Secondary Licenses" Notice 372 | --------------------------------------------------------- 373 | 374 | This Source Code Form is "Incompatible With Secondary Licenses", as 375 | defined by the Mozilla Public License, v. 2.0. 376 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Azure Pipelines Extension for Terraform 2 | 3 | This repository contains the source for an Azure Pipelines extension that provides Tasks to easily install and use Terraform. 4 | 5 | This extension provides a `TerraformInstaller` task to ease in installing specific Terraform versions, as well as a `Terraform` task to help call Terraform without needing to manage authentication yourself. The `Terraform` task wraps `init`, `plan`, `validate`, `apply`, and `destroy` commands, as well as providing a `CLI` option. 6 | 7 | `CLI` is used to execute a custom script in a pre-authenticated environment. This can be a great option if you need to use more complex terraform scripts, such as gathering output and setting it to a Piplelines variable (see example below). 8 | 9 | Once this task has been added to your Organization from the Azure DevOps Marketplace you can use it in any Azure Pipelines build or release job. It is available in both the GUI pipeline editor as well as yaml templates. 10 | 11 | ## Options 12 | 13 | 14 | #### General 15 | *Common options available in most configurations* 16 | 17 | | Name | Type | Description | 18 | |-|-|-| 19 | | `command` | `pickList` | The Terraform command to run.
*Options*: `init`, `validate`, `plan`, `apply`, `destroy`, `cli` | 20 | | `provider` | `pickList` | The Cloud provider to authenticate with.
*Options*: `Azure`, `Remote` (`AWS` and `GCP` Coming soon) | 21 | | `backend` | `pickList` | Where to store the Terraform backend state.
*Options*: `Azure`, `Remote` (`AWS` and `GCP` Coming soon) 22 | 23 | 24 | #### Azure 25 | *Options which are available when Azure Backend and Providers are selected.* 26 | 27 | | Name | Type | Description | 28 | |-|-|-| 29 | | `providerAzureConnectedServiceName` | `serviceConnection` | The Azure Subscription to execute Terraform against| 30 | | `backendAzureUseProviderConnectedServiceForBackend` | bool | Should the specified provider connection be re-used to talk to the backend storage account? | 31 | | `backendAzureConnectedServiceName` | `serviceConnection` | The Azure Subscription to be used to talk to the backend storage accoutn | 32 | | `backendAzureStorageAccountName` | `serviceConnection` | If a separate backend connection is specified: the Storage Account to store the backend state in. | 33 | | `backendAzureProviderStorageAccountName` | `serviceConnection` | If no separate backend connection is specified: the Storage Account to store the backend state in. | 34 | | `backendAzureContainerName` | `string` | The Storage Account Container name | 35 | | `backendAzureStateFileKey` | `string` | The name of the terraform state file | 36 | 37 | #### CLI 38 | *Options which are available when command is set to CLI* 39 | 40 | | Name | Type | Description | 41 | |-|-|-| 42 | | `initialize` | `bool` | Should `terraform init` run before executing the CLI script | 43 | | `scriptLocation` | `pickList` | How will the CLI script be provided?
*Options*: `Inline script`, `Script path`| 44 | | `scriptPath` | `filePath` | The path to the CLI script to execute | 45 | | `script` | `string` | The inline script to execute | 46 | 47 | #### Advanced 48 | *Advanced options available for all non-CLI commands* 49 | 50 | | Name | Type | Description | 51 | |-|-|-| 52 | | `args` | `string` | Additional arguments to pass to the Terraform command being run | 53 | 54 | 55 | ## YAML Pipeline Examples 56 | 57 | #### Install Terraform 58 | 59 | ```yaml 60 | pool: 61 | vmImage: 'Ubuntu-16.04' 62 | 63 | steps: 64 | - task: terraformInstaller@0 65 | inputs: 66 | terraformVersion: '0.12.12' 67 | displayName: Install Terraform 68 | ``` 69 | 70 | #### Init, plan, and apply 71 | You can invoke the task in a yaml template using the following syntax: 72 | 73 | ```yaml 74 | pool: 75 | vmImage: 'Ubuntu-16.04' 76 | 77 | steps: 78 | - task: terraform@0 79 | inputs: 80 | command: 'init' 81 | providerAzureConnectedServiceName: 'MTC Denver Sandbox' 82 | backendAzureProviderStorageAccountName: 'mtcdenterraformsandbox' 83 | displayName: Terraform Init 84 | 85 | - task: terraform@0 86 | inputs: 87 | command: 'plan' 88 | providerAzureConnectedServiceName: 'MTC Denver Sandbox' 89 | args: -var=environment=demo -out=tfplan.out 90 | displayName: Terraform Plan 91 | 92 | - task: terraform@0 93 | inputs: 94 | command: 'apply' 95 | providerAzureConnectedServiceName: 'MTC Denver Sandbox' 96 | args: tfplan.out 97 | displayName: Terraform Apply 98 | ``` 99 | 100 | #### Execute a Terraform-authenticated CLI Script 101 | 102 | 103 | ```yaml 104 | pool: 105 | vmImage: 'Ubuntu-16.04' 106 | 107 | steps: 108 | - task: terraform@0 109 | inputs: 110 | command: 'CLI' 111 | providerAzureConnectedServiceName: 'MTC Denver Sandbox' 112 | backendAzureProviderStorageAccountName: 'mtcdenterraformsandbox' 113 | script: | 114 | # Validate 115 | terraform validate 116 | 117 | # Plan 118 | terraform plan -input=false -out=testplan.tf 119 | 120 | # Get output 121 | STORAGE_ACCOUNT=`terraform output storage_account` 122 | 123 | # Set storageAccountName variable from terraform output 124 | echo "##vso[task.setvariable variable=storageAccountName]$STORAGE_ACCOUNT" 125 | displayName: Execute Terraform CLI Script 126 | 127 | ``` 128 | -------------------------------------------------------------------------------- /Tasks/Terraform/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/azure-pipelines-extension-terraform/1f7b31dbc2ae32cb9b700b6f048ed7eb9ebddbaa/Tasks/Terraform/icon.png -------------------------------------------------------------------------------- /Tasks/Terraform/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "azure-pipelines-terraform-task", 3 | "version": "0.0.1", 4 | "description": "A Terraform task for Azure Pipelines", 5 | "main": ".bin/src/Index.js", 6 | "author": "", 7 | "license": "MPL-2.0", 8 | "scripts": { 9 | "build": "tsc --build", 10 | "start": "node -r dotenv/config .bin/src/Index.js", 11 | "test": "ts-mocha test/**/*.spec.ts" 12 | }, 13 | "dependencies": { 14 | "@azure/arm-storage": "^11.0.0", 15 | "@azure/ms-rest-nodeauth": "^3.0.3", 16 | "azure-pipelines-task-lib": "^2.9.3", 17 | "config": "^3.2.1", 18 | "inversify": "^5.0.1", 19 | "reflect-metadata": "^0.1.13", 20 | "tsyringe": "^3.3.0" 21 | }, 22 | "devDependencies": { 23 | "@types/chai": "^4.2.3", 24 | "@types/expect": "^1.20.4", 25 | "@types/mocha": "^5.2.7", 26 | "@types/node": "^12.6.8", 27 | "@types/q": "^1.5.2", 28 | "chai": "^4.2.0", 29 | "copyfiles": "^2.1.0", 30 | "dotenv": "^8.2.0", 31 | "mocha": "^5.2.0", 32 | "mocha-junit-reporter": "^1.18.0", 33 | "tfx-cli": "^0.6.4", 34 | "ts-mocha": "^6.0.0", 35 | "ts-node": "^8.4.1", 36 | "tsconfig-paths": "^3.9.0", 37 | "typescript": "^3.3.3333" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tasks/Terraform/src/Index.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | import { Container } from "inversify"; 3 | import { TaskResult } from "azure-pipelines-task-lib/task"; 4 | import task = require('azure-pipelines-task-lib/task'); 5 | 6 | import { TerraformTask } from './TerraformTask'; 7 | import { TerraformCommandRunner } from "./TerraformCommandRunner"; 8 | import { TaskOptions } from './TaskOptions'; 9 | import { Options } from './Options'; 10 | 11 | import { TerraformProvider } from "./Provider/TerraformProvider"; 12 | import { AzureProvider } from './Provider/Azure/AzureProvider' 13 | import { RemoteProvider } from './Provider/Remote/RemoteProvider' 14 | import { RemoteConnectedServiceOptions } from "./Provider/Remote/RemoteConnectedServiceOptions"; 15 | import { AzureOptions } from "./Provider/Azure/AzureOptions" 16 | 17 | // Configure DI 18 | let container = new Container(); 19 | 20 | // Bind Task 21 | container.bind(TerraformTask).toSelf(); 22 | container.bind(TerraformCommandRunner).toSelf(); 23 | 24 | // Bind Options 25 | container.bind(TaskOptions).toDynamicValue((context) => { 26 | return Options.load(TaskOptions); 27 | }); 28 | 29 | container.bind(AzureOptions).toDynamicValue((context) => { 30 | return Options.load(AzureOptions); 31 | }); 32 | 33 | container.bind(RemoteConnectedServiceOptions).toDynamicValue((context) => { 34 | return Options.load(RemoteConnectedServiceOptions); 35 | }); 36 | 37 | // Bind Terraform Provider 38 | let options = container.get(TaskOptions); 39 | 40 | switch (options.provider || options.backend) { 41 | case "Azure": 42 | container.bind(TerraformProvider).to(AzureProvider); 43 | break; 44 | case "Remote": 45 | container.bind(TerraformProvider).to(RemoteProvider) 46 | default: 47 | break; 48 | } 49 | 50 | 51 | // Get and run the task 52 | var terraformTask = container.get(TerraformTask); 53 | 54 | terraformTask.run().then(function() 55 | { 56 | task.setResult(TaskResult.Succeeded, "Terraform successfully ran"); 57 | }, function(reason) { 58 | task.setResult(TaskResult.Failed, "Terraform failed to run" + reason); 59 | }); 60 | -------------------------------------------------------------------------------- /Tasks/Terraform/src/Options.ts: -------------------------------------------------------------------------------- 1 | import task = require('azure-pipelines-task-lib/task'); 2 | import { injectable } from "inversify"; 3 | import "reflect-metadata"; 4 | 5 | const valueMetadataKey = Symbol("devopsTaskMetadataKey"); 6 | 7 | enum TaskInputType { 8 | Default = 1, 9 | TaskVariable, 10 | EndpointAuthorizationParameter, 11 | EndpointDataParameter, 12 | EndpointAuthorizationScheme 13 | } 14 | 15 | class OptionMetadata { 16 | constructor ( 17 | public type: TaskInputType = TaskInputType.Default, 18 | public id: string = "", 19 | public key : string = "", 20 | public required : boolean = false) { 21 | 22 | } 23 | } 24 | 25 | export function taskVariable(id : string, required : boolean = false) :any { 26 | return function(target : any, propertyKey: string, descriptor: PropertyDescriptor) { 27 | Reflect.defineMetadata(valueMetadataKey, new OptionMetadata(TaskInputType.TaskVariable, id, "", required), target, propertyKey) 28 | } 29 | } 30 | 31 | export function endpointAuthorizationParameter(id : string, key : string, required : boolean = false) :any { 32 | return function(target : any, propertyKey: string, descriptor: PropertyDescriptor) { 33 | Reflect.defineMetadata(valueMetadataKey, new OptionMetadata(TaskInputType.EndpointAuthorizationParameter, id, key, required), target, propertyKey) 34 | } 35 | } 36 | 37 | export function endpointDataParameter(id : string, key : string, required : boolean = false) :any { 38 | return function(target : any, propertyKey: string, descriptor: PropertyDescriptor) { 39 | Reflect.defineMetadata(valueMetadataKey, new OptionMetadata(TaskInputType.EndpointDataParameter, id, key, required), target, propertyKey) 40 | } 41 | } 42 | 43 | export function endpointAuthorizationScheme(id : string, required = false) :any { 44 | return function(target : any, propertyKey: string, descriptor: PropertyDescriptor) { 45 | Reflect.defineMetadata(valueMetadataKey, new OptionMetadata(TaskInputType.EndpointAuthorizationScheme, id, "", required), target, propertyKey) 46 | } 47 | } 48 | 49 | 50 | /** 51 | * Strong-type accessor for Task configuration 52 | */ 53 | @injectable() 54 | export class Options { 55 | 56 | private getProperty(o: T, name: K) { 57 | return o[name]; 58 | } 59 | 60 | private static getTypeofProperty(o: T, name: K) { 61 | return typeof o[name]; 62 | } 63 | 64 | private static setProperty(o: T, name: K, value : any) { 65 | return o[name] = value; 66 | } 67 | 68 | /** 69 | * Returns the parsed value of this config 70 | */ 71 | public static load(type: { new (): T }) : T { 72 | let options : T = new type(); 73 | 74 | for (let propertyKey in options) { 75 | let metadata = Reflect.getMetadata(valueMetadataKey, options, propertyKey) as OptionMetadata; 76 | 77 | let type = metadata && metadata.type || TaskInputType.Default; 78 | let value : any; 79 | 80 | switch (type) { 81 | case TaskInputType.Default: 82 | value = this.getInputVariableFromProperty(options, propertyKey) 83 | break; 84 | case TaskInputType.TaskVariable: 85 | value = task.getTaskVariable(metadata.id); 86 | break; 87 | case TaskInputType.EndpointAuthorizationScheme: 88 | value = task.getEndpointAuthorizationScheme(metadata.id, false); 89 | break; 90 | case TaskInputType.EndpointAuthorizationParameter: 91 | value = task.getEndpointAuthorizationParameter(metadata.id, metadata.key, false); 92 | break; 93 | case TaskInputType.EndpointDataParameter: 94 | value = task.getEndpointDataParameter(metadata.id, metadata.key, false); 95 | break; 96 | } 97 | 98 | this.setProperty(options, propertyKey, value); 99 | } 100 | 101 | return options; 102 | } 103 | 104 | private static getInputVariableFromProperty(options : any, id : string) : any{ 105 | let propertyType = this.getTypeofProperty(options, id); 106 | 107 | let value : any; 108 | 109 | switch (propertyType){ 110 | case "string": 111 | value = task.getInput(id); 112 | break; 113 | case "boolean": 114 | value = task.getBoolInput(id); 115 | break; 116 | default: 117 | value = ""; 118 | } 119 | 120 | return value 121 | } 122 | } -------------------------------------------------------------------------------- /Tasks/Terraform/src/Provider/Azure/ARMAuthenticationMethod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Different ways to authenticate from an ARM connected service 3 | */ 4 | export enum ARMAuthenticationMethod { 5 | Unknown = 0, 6 | ServicePrincipalKey, 7 | ServicePrincipalCertificate, 8 | ManagedIdentity, 9 | } -------------------------------------------------------------------------------- /Tasks/Terraform/src/Provider/Azure/ARMConnectedServiceOptions.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversify"; 2 | import task = require('azure-pipelines-task-lib/task'); 3 | import fs = require("fs"); 4 | import path = require("path"); 5 | import { ARMAuthenticationMethod } from "./ARMAuthenticationMethod"; 6 | 7 | /** 8 | * Loads connected service data for ARM from the Task into a strongly-typed object 9 | */ 10 | @injectable() 11 | export class ARMConnectedServiceOptions { 12 | public clientId: string = ""; 13 | public clientSecret: string = ""; 14 | public clientCertificatePath: string = ""; 15 | public tenantId: string = ""; 16 | public subscriptionId: string = ""; 17 | public authenticationMethod: ARMAuthenticationMethod = ARMAuthenticationMethod.Unknown; 18 | 19 | constructor(private connectedServiceName: string) { 20 | let authScheme = task.getEndpointAuthorizationScheme(connectedServiceName, true) as string; 21 | 22 | if (!authScheme) { 23 | throw Error("No authentication sceme provided"); 24 | } 25 | 26 | this.loadArmDetails(); 27 | 28 | switch(authScheme.toLowerCase()) { 29 | case "serviceprincipal": 30 | this.loadServicePrincipalDetails(); 31 | break; 32 | case "managedserviceidentity": 33 | this.loadManagedIdentityDetails(); 34 | break; 35 | } 36 | } 37 | 38 | /** 39 | * Sets ARM Tenant and subscription details based on the connected service 40 | */ 41 | private loadArmDetails() { 42 | this.tenantId = task.getEndpointAuthorizationParameter(this.connectedServiceName, "tenantid", true) as string; 43 | this.subscriptionId = task.getEndpointDataParameter(this.connectedServiceName, 'subscriptionid', true); 44 | } 45 | 46 | /** 47 | * Sets service principal details based on the connected service 48 | */ 49 | private loadServicePrincipalDetails() { 50 | let authType = task.getEndpointAuthorizationParameter(this.connectedServiceName, 'authenticationType', true) as string; 51 | this.clientId = task.getEndpointAuthorizationParameter(this.connectedServiceName, "serviceprincipalid", true) as string; 52 | 53 | switch(authType) { 54 | case "spnCertificate": 55 | this.loadServicePrincipalCertificateDetails(); 56 | break; 57 | default: 58 | this.loadServicePrincipalKeyDetails(); 59 | } 60 | } 61 | 62 | /** 63 | * Sets service principal key details based on the connected service 64 | */ 65 | private loadServicePrincipalKeyDetails() { 66 | this.authenticationMethod = ARMAuthenticationMethod.ServicePrincipalKey; 67 | this.clientSecret = task.getEndpointAuthorizationParameter(this.connectedServiceName, "serviceprincipalkey", true) as string; 68 | } 69 | 70 | /** 71 | * Sets service principal certificate details based on the connected service 72 | */ 73 | private loadServicePrincipalCertificateDetails() { 74 | this.authenticationMethod = ARMAuthenticationMethod.ServicePrincipalCertificate; 75 | let certificateContent = task.getEndpointAuthorizationParameter(this.connectedServiceName, "servicePrincipalCertificate", true); 76 | let certificatePath = path.join(task.getVariable('Agent.TempDirectory') as string || task.getVariable('system.DefaultWorkingDirectory') as string, 'spnCert.pem'); 77 | 78 | fs.writeFileSync(certificatePath, certificateContent); 79 | 80 | this.clientCertificatePath = certificatePath; 81 | } 82 | 83 | /** 84 | * Sets service principal certificate details based on the connected service 85 | */ 86 | private loadManagedIdentityDetails() { 87 | this.authenticationMethod = ARMAuthenticationMethod.ManagedIdentity; 88 | } 89 | } -------------------------------------------------------------------------------- /Tasks/Terraform/src/Provider/Azure/AzureOptions.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversify"; 2 | 3 | /** 4 | * Loads connected service data for ARM from the Task into a strongly-typed object 5 | */ 6 | @injectable() 7 | export class AzureOptions { 8 | public providerAzureConnectedServiceName : string = ""; 9 | public backendAzureUseProviderConnectedServiceForBackend : boolean = true; 10 | public backendAzureConnectedServiceName : string = ""; 11 | public backendAzureStorageAccountName : string = ""; 12 | public backendAzureProviderStorageAccountName : string = ""; 13 | public backendAzureContainerName : string = ""; 14 | public backendAzureStateFileKey : string = ""; 15 | } -------------------------------------------------------------------------------- /Tasks/Terraform/src/Provider/Azure/AzureProvider.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversify"; 2 | import task = require('azure-pipelines-task-lib/task'); 3 | import { ARMConnectedServiceOptions } from "./ARMConnectedServiceOptions"; 4 | import { ARMAuthenticationMethod } from "./ARMAuthenticationMethod"; 5 | import { TerraformProvider } from "../TerraformProvider"; 6 | import { AzureStorageService } from "./AzureStorageService" 7 | import { AzureOptions } from "./AzureOptions"; 8 | 9 | /** 10 | * Terraform Azure Provider and Backend 11 | */ 12 | @injectable() 13 | export class AzureProvider extends TerraformProvider { 14 | private armConnectedService: ARMConnectedServiceOptions | undefined = undefined; 15 | 16 | constructor(private options : AzureOptions) { 17 | super(); 18 | } 19 | 20 | /** 21 | * Loads the ARM connected service information into the environment 22 | */ 23 | public async authenticate() : Promise<{ [key: string]: string; }> { 24 | if (!this.options.providerAzureConnectedServiceName) { 25 | throw new Error("No Azure connection specified") 26 | } 27 | 28 | this.armConnectedService = new ARMConnectedServiceOptions(this.options.providerAzureConnectedServiceName); 29 | 30 | let env : { [key: string]: string; }; 31 | 32 | switch (this.armConnectedService.authenticationMethod) { 33 | case ARMAuthenticationMethod.ServicePrincipalKey: 34 | env = this.getServicePrincipalKeyEnv(this.armConnectedService); 35 | break; 36 | case ARMAuthenticationMethod.ServicePrincipalCertificate: 37 | env = this.getServicePrincipalCertificateEnv(this.armConnectedService); 38 | break; 39 | case ARMAuthenticationMethod.ManagedIdentity: 40 | env = this.getManagedIdentityEnv(); 41 | break; 42 | default: 43 | env = {}; 44 | } 45 | 46 | return { 47 | ARM_TENANT_ID: this.armConnectedService.tenantId, 48 | ARM_SUBSCRIPTION_ID: this.armConnectedService.subscriptionId, 49 | ...env 50 | }; 51 | } 52 | 53 | /** 54 | * Builds an object containing all the apporpriate values needed to set as backend-config 55 | * for Terraform to be use an Azure Storage Account as the backend 56 | */ 57 | public async getBackendConfigOptions(): Promise<{ [key: string]: string; }> { 58 | 59 | let connectedService : ARMConnectedServiceOptions; 60 | 61 | if (this.options.backendAzureUseProviderConnectedServiceForBackend) { 62 | if (!this.options.providerAzureConnectedServiceName) { 63 | throw new Error("No Azure provider connection speficied"); 64 | } 65 | 66 | connectedService = new ARMConnectedServiceOptions(this.options.providerAzureConnectedServiceName); 67 | } else { 68 | if (!this.options.backendAzureConnectedServiceName) { 69 | throw new Error("Backend connected service not specified"); 70 | } 71 | 72 | connectedService = new ARMConnectedServiceOptions(this.options.backendAzureConnectedServiceName); 73 | } 74 | 75 | let storage = new AzureStorageService(connectedService); 76 | let storageAccount = this.options.backendAzureUseProviderConnectedServiceForBackend ? this.options.backendAzureProviderStorageAccountName : this.options.backendAzureStorageAccountName; 77 | 78 | if (!storageAccount) { 79 | throw new Error("Storage account not specified"); 80 | } 81 | 82 | if (!this.options.backendAzureContainerName) { 83 | throw new Error("Storage container name not specified"); 84 | } 85 | 86 | if (!this.options.backendAzureStateFileKey) { 87 | throw new Error("State file key not specified"); 88 | } 89 | 90 | // I'd much prefer to use a SAS here but generating SAS isn't supported via the JS SDK without using a key 91 | let storage_key = await storage.getKey( 92 | storageAccount, 93 | this.options.backendAzureContainerName); 94 | 95 | return { 96 | storage_account_name: storageAccount, 97 | container_name: this.options.backendAzureContainerName, 98 | key: this.options.backendAzureStateFileKey, 99 | access_key: storage_key 100 | } 101 | } 102 | 103 | /** 104 | * Gets the appropraite ENV vars for Service Principal Key authentication 105 | */ 106 | private getServicePrincipalKeyEnv(armConnectedService : ARMConnectedServiceOptions): { [key: string]: string; } { 107 | return { 108 | ARM_CLIENT_ID: armConnectedService.clientId, 109 | ARM_CLIENT_SECRET: armConnectedService.clientSecret, 110 | }; 111 | } 112 | 113 | /** 114 | * Gets the appropraite ENV vars for Service Principal Cert authentication 115 | */ 116 | private getServicePrincipalCertificateEnv(armConnectedService : ARMConnectedServiceOptions): { [key: string]: string; } { 117 | return { 118 | ARM_CLIENT_ID: armConnectedService.clientId, 119 | ARM_CLIENT_SECRET: armConnectedService.clientSecret, 120 | }; 121 | } 122 | 123 | /** 124 | * Gets the appropraite ENV vars for Managed Identity authentication 125 | */ 126 | private getManagedIdentityEnv(): { [key: string]: string; } { 127 | return { 128 | ARM_USE_MSI: "true", 129 | }; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Tasks/Terraform/src/Provider/Azure/AzureStorageService.ts: -------------------------------------------------------------------------------- 1 | import * as msRestNodeAuth from "@azure/ms-rest-nodeauth"; 2 | import { injectable } from "inversify"; 3 | import { ARMAuthenticationMethod } from "./ARMAuthenticationMethod"; 4 | import { ARMConnectedServiceOptions } from "./ARMConnectedServiceOptions"; 5 | import { AzureTokenCredentialsOptions } from "@azure/ms-rest-nodeauth"; 6 | import { StorageManagementClient } from "@azure/arm-storage"; 7 | import { ServiceClientCredentials } from "@azure/ms-rest-js"; 8 | 9 | /** 10 | * Access Azure Storage to get a key for the account 11 | */ 12 | @injectable() 13 | export class AzureStorageService { 14 | private readonly StorageUrl = "https://management.azure.com/" 15 | 16 | constructor(private connectedService: ARMConnectedServiceOptions) { 17 | 18 | } 19 | 20 | public async getKey(storageAccountName: string, containerName: string) : Promise { 21 | var creds = await this.login(); 22 | var client = new StorageManagementClient(creds, this.connectedService.subscriptionId); 23 | var storageAccounts = await client.storageAccounts.list(); 24 | 25 | var account = storageAccounts.find(item => item.name == storageAccountName); 26 | 27 | if (account == null) { 28 | throw new Error("Storage account not found"); 29 | } 30 | 31 | var id = account.id || ""; 32 | var regex = new RegExp("resourceGroups\/([a-zA-Z0-9_-]+)\/"); 33 | var res = regex.exec(id) || []; 34 | var resourceGroupName = res[1]; 35 | 36 | var keysResult = await client.storageAccounts.listKeys(resourceGroupName, storageAccountName); 37 | 38 | if ( 39 | keysResult == null || 40 | keysResult.keys == null || 41 | keysResult.keys.length == 0 || 42 | keysResult.keys[0].value == null 43 | ) { 44 | throw new Error("Could not get storage account keys"); 45 | } 46 | 47 | return keysResult.keys[0].value; 48 | } 49 | 50 | private async login() : Promise { 51 | switch(this.connectedService.authenticationMethod) { 52 | case ARMAuthenticationMethod.ManagedIdentity: 53 | return this.loginWithManagedIdentity(); 54 | case ARMAuthenticationMethod.ServicePrincipalKey: 55 | return this.loginWithServicePrincipalKey(); 56 | case ARMAuthenticationMethod.ServicePrincipalCertificate: 57 | return this.loginWithServicePrincipalCertificate(); 58 | } 59 | 60 | throw new Error("No valid authentication method specified"); 61 | } 62 | 63 | private async loginWithManagedIdentity() : Promise { 64 | var creds = await msRestNodeAuth.loginWithVmMSI({ 65 | "resource": this.StorageUrl 66 | }); 67 | 68 | return creds; 69 | } 70 | 71 | private async loginWithServicePrincipalKey() : Promise { 72 | return msRestNodeAuth.loginWithServicePrincipalSecretWithAuthResponse( 73 | this.connectedService.clientId, 74 | this.connectedService.clientSecret, 75 | this.connectedService.tenantId, 76 | { 77 | "tokenAudience": this.StorageUrl 78 | } as AzureTokenCredentialsOptions 79 | ).then((authres) => { 80 | return authres.credentials; 81 | }); 82 | } 83 | 84 | private async loginWithServicePrincipalCertificate() : Promise { 85 | var creds = await msRestNodeAuth.loginWithServicePrincipalCertificate( 86 | this.connectedService.clientId, 87 | this.connectedService.clientCertificatePath, 88 | this.StorageUrl 89 | ); 90 | 91 | return creds; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Tasks/Terraform/src/Provider/Remote/RemoteConnectedServiceOptions.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversify"; 2 | import task = require('azure-pipelines-task-lib/task'); 3 | 4 | /** 5 | * Loads connected service data for ARM from the Task into a strongly-typed object 6 | */ 7 | @injectable() 8 | export class RemoteConnectedServiceOptions { 9 | public backendRemoteUrl: string = ""; 10 | public backendRemoteToken: string = ""; 11 | } -------------------------------------------------------------------------------- /Tasks/Terraform/src/Provider/Remote/RemoteProvider.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversify"; 2 | import path = require("path"); 3 | import fs = require("fs"); 4 | import task = require('azure-pipelines-task-lib/task'); 5 | import { TerraformProvider } from "../TerraformProvider"; 6 | import { TaskOptions } from "../../TaskOptions"; 7 | import { RemoteConnectedServiceOptions } from "./RemoteConnectedServiceOptions" 8 | /** 9 | * Terraform Remote Provider and Backend 10 | */ 11 | @injectable() 12 | export class RemoteProvider extends TerraformProvider { 13 | private cliConfigFileLocation : string = ""; 14 | 15 | constructor(private taskOptions : TaskOptions, private options : RemoteConnectedServiceOptions) { 16 | super(); 17 | } 18 | 19 | /** 20 | * Create a terraform.rc file in the temp directory with the appropriate credentials 21 | */ 22 | public async authenticate() : Promise<{ [key: string]: string; }> { 23 | var config = ` 24 | credentials "${this.options.backendRemoteUrl}" { 25 | token = "${this.options.backendRemoteToken}" 26 | } 27 | `; 28 | 29 | this.cliConfigFileLocation = path.join(this.taskOptions.tempDir as string, "terraform.rc"); 30 | 31 | fs.writeFileSync(this.cliConfigFileLocation, config); 32 | 33 | return { 34 | "TF_CLI_CONFIG_FILE ": this.cliConfigFileLocation 35 | } 36 | } 37 | 38 | /** 39 | * Returns an empty object as no config specification is needed with a Remote backend 40 | */ 41 | public async getBackendConfigOptions(): Promise<{ [key: string]: string; }> { 42 | return {} 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Tasks/Terraform/src/Provider/TerraformProvider.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversify"; 2 | 3 | /** 4 | * A Provider for Terraform 5 | */ 6 | @injectable() 7 | export abstract class TerraformProvider { 8 | /** 9 | * Configures the file system and process environment variables necessary to run Terraform 10 | * in an authenticated manner. 11 | * 12 | * @returns Variables to set for auth in the spawned process env 13 | */ 14 | abstract authenticate() : Promise<{ [key: string]: string; }>; 15 | 16 | /** 17 | * Get's a dictionary containing the backend-config parameters 18 | * to set on init 19 | */ 20 | abstract getBackendConfigOptions() : Promise<{ [key: string]: string; }>; 21 | } -------------------------------------------------------------------------------- /Tasks/Terraform/src/Provider/TerraformProviderType.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Different ways to authenticate from an ARM connected service 3 | */ 4 | export enum TerraformProviderType { 5 | Unknown = 0, 6 | Azure, 7 | Aws, 8 | Gcp, 9 | Remote 10 | } -------------------------------------------------------------------------------- /Tasks/Terraform/src/TaskOptions.ts: -------------------------------------------------------------------------------- 1 | import task = require('azure-pipelines-task-lib/task'); 2 | import { injectable } from "inversify"; 3 | import { taskVariable } from "./Options"; 4 | 5 | /** 6 | * Strong-type accessor for Task configuration 7 | */ 8 | @injectable() 9 | export class TaskOptions { 10 | 11 | // Basic 12 | public command : string = ""; 13 | public provider : string = ""; 14 | public backend : string = ""; 15 | 16 | // CLI 17 | public scriptLocation : string = ""; 18 | public scriptPath : string = ""; 19 | public script : string = ""; 20 | public initialize : boolean = true; 21 | 22 | // Plan 23 | public variables : string = ""; 24 | public outputFile : string = ""; 25 | public planFile : string = ""; 26 | 27 | // Advanced 28 | public cwd : string = ""; 29 | public args : string = ""; 30 | 31 | @taskVariable("Agent.TempDirectory") 32 | public tempDir : string = ""; 33 | } -------------------------------------------------------------------------------- /Tasks/Terraform/src/TerraformCommandRunner.ts: -------------------------------------------------------------------------------- 1 | import os = require("os"); 2 | import task = require('azure-pipelines-task-lib/task'); 3 | import fs = require("fs"); 4 | import { injectable } from "inversify"; 5 | import { ToolRunner, IExecOptions, IExecSyncResult } from "azure-pipelines-task-lib/toolrunner"; 6 | import { TaskOptions } from "./TaskOptions"; 7 | import { TerraformProvider } from "./Provider/TerraformProvider"; 8 | import { AzureProvider } from "./Provider/Azure/AzureProvider"; 9 | import path = require("path"); 10 | 11 | /** 12 | * Class to handle running Terraform commands 13 | */ 14 | @injectable() 15 | export class TerraformCommandRunner { 16 | private readonly terraform : ToolRunner; 17 | 18 | public constructor( 19 | private provider : TerraformProvider, 20 | private options: TaskOptions 21 | 22 | ) { 23 | this.terraform = this.createTerraformToolRunner(); 24 | } 25 | 26 | /** 27 | * Initializes Terraform with the backend configuration specified from the provider 28 | */ 29 | public async init(args: Array = [], authenticate: boolean = false) { 30 | let backendConfigOptions = await this.provider.getBackendConfigOptions(); 31 | 32 | // Set the backend configuration values 33 | // 34 | // Values are not quoted intentionally - the way node spawns processes it will 35 | // see quotes as part of the values 36 | for (let key in backendConfigOptions) { 37 | let value = backendConfigOptions[key]; 38 | args.push(`-backend-config=${key}=${value}`); 39 | } 40 | 41 | await this.exec(["init", ...args], authenticate); 42 | } 43 | 44 | /** 45 | * Initializes Terraform with the backend configuration specified from the provider 46 | */ 47 | public async plan(args: Array = [], variables : string, outputFile : string) { 48 | 49 | if (variables && variables != ""){ 50 | let parsedVariables = JSON.parse(variables); 51 | 52 | // Set the variables 53 | // 54 | // Values are not quoted intentionally - the way node spawns processes it will 55 | // see quotes as part of the values 56 | for (let key in parsedVariables) { 57 | let value = parsedVariables[key]; 58 | args.push(`-var=${key}=${value}`); 59 | } 60 | } 61 | 62 | if (outputFile != "") { 63 | args.push("-out=" + outputFile); 64 | } 65 | 66 | await this.exec(["plan", ...args], true); 67 | } 68 | 69 | /** 70 | * Executes a script within an authenticated Terraform environment 71 | * @param script The location of the script to run 72 | */ 73 | public async exec(args: Array = [], authenticate: boolean = true) { 74 | console.log("Executing terraform command"); 75 | 76 | if (!this.options.command) { 77 | throw new Error("No command specified"); 78 | } 79 | 80 | // Handle authentication for this command 81 | let authenticationEnv : { [key: string]: string; } = {}; 82 | 83 | if (authenticate) { 84 | authenticationEnv = await this.provider.authenticate(); 85 | } 86 | 87 | let command = this.terraform; 88 | 89 | for (let arg of args) { 90 | command.arg(arg); 91 | } 92 | 93 | if (this.options.args) { 94 | command.line(this.options.args); 95 | } 96 | 97 | let result = await command.exec({ 98 | cwd: path.join(process.cwd(), this.options.cwd || ""), 99 | env: { 100 | ...process.env, 101 | ...authenticationEnv 102 | }, 103 | windowsVerbatimArguments: true 104 | } as unknown as IExecOptions); 105 | 106 | if (result > 0) { 107 | throw new Error("Terraform initalize failed"); 108 | } 109 | } 110 | 111 | /** 112 | * Executes a script within an authenticated Terraform environment 113 | * @param script The location of the script to run 114 | */ 115 | public async cli(script: string) { 116 | // Handle authentication for this command 117 | let authenticationEnv = await this.provider.authenticate(); 118 | 119 | let content = fs.readFileSync(script,'utf8'); 120 | 121 | console.log(content); 122 | 123 | let tool = this.createCliToolRunner(script); 124 | 125 | let result = await tool.exec({ 126 | cwd: this.options.cwd, 127 | env: { 128 | ...process.env, 129 | ...authenticationEnv 130 | }, 131 | windowsVerbatimArguments: true 132 | } as unknown as IExecOptions); 133 | 134 | if (result > 0) { 135 | throw new Error("Terraform CLI failed"); 136 | } 137 | } 138 | 139 | /** 140 | * Creates an Azure Pipelines ToolRunner for Terraform 141 | */ 142 | private createTerraformToolRunner() : ToolRunner { 143 | let terraformPath = task.which("terraform", true); 144 | let terraform: ToolRunner = task.tool(terraformPath); 145 | 146 | return terraform; 147 | } 148 | 149 | /** 150 | * Creates an Azure Pipelines ToolRunner for Bash or CMD 151 | */ 152 | private createCliToolRunner(scriptPath : string) : ToolRunner { 153 | var tool; 154 | 155 | if (os.type() != "Windows_NT") { 156 | tool = task.tool(task.which("bash", true)); 157 | tool.arg(scriptPath); 158 | } else { 159 | tool = task.tool(task.which(scriptPath, true)); 160 | } 161 | 162 | return tool; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /Tasks/Terraform/src/TerraformTask.ts: -------------------------------------------------------------------------------- 1 | import path = require("path"); 2 | import fs = require("fs"); 3 | import os = require("os"); 4 | 5 | import { injectable } from "inversify"; 6 | import { TerraformCommandRunner } from "./TerraformCommandRunner"; 7 | import { TaskOptions } from './TaskOptions'; 8 | 9 | @injectable() 10 | export class TerraformTask { 11 | 12 | constructor( 13 | private terraform : TerraformCommandRunner, 14 | private options: TaskOptions) 15 | { 16 | 17 | } 18 | 19 | public async run() { 20 | switch(this.options.command) { 21 | case "init": 22 | let authenticate = this.options.provider == "Remote"; 23 | await this.terraform.init(["-input=false"], authenticate); 24 | break; 25 | case "validate": 26 | await this.terraform.exec(["validate"], false); 27 | break; 28 | case "plan": 29 | await this.terraform.plan(["-input=false"], this.options.variables, this.options.outputFile); 30 | break; 31 | case "apply": 32 | let args = ["apply", "-input=false", "-auto-approve"]; 33 | 34 | if (this.options.planFile != "") { 35 | args.push(this.options.planFile); 36 | } 37 | 38 | await this.terraform.exec(args); 39 | break; 40 | case "destroy": 41 | await this.terraform.exec(["destroy", "-auto-approve=true"]); 42 | break; 43 | case "CLI": 44 | if(this.options.initialize) { 45 | await this.terraform.init(["-input=false"]); 46 | } 47 | 48 | var path = this.initScriptAtPath(); 49 | await this.terraform.cli(path); 50 | break; 51 | default: 52 | throw new Error("Invalid command"); 53 | } 54 | } 55 | 56 | /** 57 | * Loads the specified CLI script into a file and returns the path 58 | */ 59 | private initScriptAtPath(): string { 60 | let scriptPath: string; 61 | 62 | if (this.options.scriptLocation === "scriptPath") { 63 | if (!this.options.scriptPath){ 64 | throw new Error("Script path not specified"); 65 | } 66 | 67 | scriptPath = this.options.scriptPath; 68 | } 69 | else { 70 | if (!this.options.script){ 71 | throw new Error("Script not specified"); 72 | } 73 | 74 | var tmpDir = this.options.tempDir || os.tmpdir(); 75 | 76 | if (os.type() != "Windows_NT") { 77 | scriptPath = path.join(tmpDir, "terraformclitaskscript" + new Date().getTime() + ".sh"); 78 | } 79 | else { 80 | scriptPath = path.join(tmpDir, "terraformclitaskscript" + new Date().getTime() + ".bat"); 81 | } 82 | 83 | this.createFile(scriptPath, this.options.script); 84 | } 85 | 86 | return scriptPath; 87 | } 88 | 89 | /** 90 | * Creates a file from a string at the given path 91 | */ 92 | private createFile(filePath: string, data: string) { 93 | try { 94 | fs.writeFileSync(filePath, data); 95 | } 96 | catch (err) { 97 | this.deleteFile(filePath); 98 | throw err; 99 | } 100 | } 101 | 102 | /** 103 | * Deletes a file at the given path if it exists 104 | */ 105 | private deleteFile(filePath: string): void { 106 | if (fs.existsSync(filePath)) { 107 | try { 108 | //delete the publishsetting file created earlier 109 | fs.unlinkSync(filePath); 110 | } 111 | catch (err) { 112 | //error while deleting should not result in task failure 113 | console.error(err.toString()); 114 | } 115 | } 116 | } 117 | } -------------------------------------------------------------------------------- /Tasks/Terraform/src/types.ts: -------------------------------------------------------------------------------- 1 | const TYPES = { 2 | TerraformProvider: Symbol.for("TerraformProvider") 3 | }; 4 | 5 | export { TYPES }; -------------------------------------------------------------------------------- /Tasks/Terraform/task.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "653507a3-d921-47dc-8fed-c5649d8e9e0e", 3 | "name": "terraform", 4 | "friendlyName": "Terraform", 5 | "description": "A task for running Terraform commands", 6 | "helpMarkDown": "", 7 | "category": "Tool", 8 | "author": "HashiCorp", 9 | "visibility": [ 10 | "Build", 11 | "Release" 12 | ], 13 | "version": { 14 | "Major": 0, 15 | "Minor": 0, 16 | "Patch": 1 17 | }, 18 | "instanceNameFormat": "Terraform", 19 | "groups": [ 20 | { 21 | "name": "backendAzure", 22 | "displayName": "Azure Backend Configuration", 23 | "isExpanded": true, 24 | "visibleRule": "backend = Azure" 25 | }, 26 | { 27 | "name": "Plan", 28 | "displayName": "Plan settings", 29 | "isExpanded": true, 30 | "visibleRule": "command = Plan" 31 | }, 32 | { 33 | "name": "Apply", 34 | "displayName": "Apply settings", 35 | "isExpanded": true, 36 | "visibleRule": "command = Apply" 37 | }, 38 | { 39 | "name": "CLI", 40 | "displayName": "CLI Script", 41 | "isExpanded": true, 42 | "visibleRule": "command = CLI" 43 | }, 44 | { 45 | "name": "advanced", 46 | "displayName": "Advanced", 47 | "isExpanded": false 48 | } 49 | ], 50 | "inputs": [ 51 | { 52 | "name": "command", 53 | "type": "picklist", 54 | "label": "Command", 55 | "defaultValue": "init", 56 | "required": true, 57 | "helpMarkDown": "Select the Terraform command to run, or select CLI to run a Terraform command line script in an authenticated environment.", 58 | "options": { 59 | "init": "init", 60 | "plan": "plan", 61 | "apply": "apply", 62 | "validate": "validate", 63 | "destroy": "destroy", 64 | "CLI": "CLI" 65 | } 66 | }, 67 | { 68 | "name": "provider", 69 | "type": "picklist", 70 | "label": "Provider", 71 | "defaultValue": "Azure", 72 | "required": true, 73 | "helpMarkDown": "Select a Terraform provider", 74 | "visibleRule": "command != validate", 75 | "options": { 76 | "Azure": "Azure", 77 | "AWS": "AWS", 78 | "GCP": "GCP", 79 | "Remote": "Remote" 80 | } 81 | }, 82 | 83 | { 84 | "name": "providerAzureConnectedServiceName", 85 | "type": "connectedService:AzureRM", 86 | "label": "Provider Azure subscription", 87 | "required": true, 88 | "helpMarkDown": "Select the Azure Resource Manager subscription your Terraform will execute against.", 89 | "visibleRule": "provider = Azure", 90 | "properties": { 91 | "EndpointFilterRule": "ScopeLevel != ManagementGroup" 92 | } 93 | }, 94 | { 95 | "name": "scriptLocation", 96 | "type": "pickList", 97 | "label": "Script Location", 98 | "defaultValue": "inlineScript", 99 | "required": false, 100 | "helpMarkDown": "Type of script: File path or Inline script", 101 | "options": { 102 | "inlineScript": "Inline script", 103 | "scriptPath": "Script path" 104 | }, 105 | "groupName": "CLI" 106 | }, 107 | { 108 | "name": "scriptPath", 109 | "type": "filePath", 110 | "label": "Script Path", 111 | "defaultValue": "", 112 | "required": false, 113 | "visibleRule": "scriptLocation = scriptPath", 114 | "helpMarkDown": "Fully qualified path of the script(.bat or .cmd when using Windows based agent and .sh when using linux based agent) or a path relative to the the default working directory", 115 | "groupName": "CLI" 116 | }, 117 | { 118 | "name": "script", 119 | "type": "multiLine", 120 | "label": "Inline Script", 121 | "defaultValue": "", 122 | "required": false, 123 | "visibleRule": "scriptLocation = inlineScript", 124 | "helpMarkDown": "You can write your terraform scripts inline here", 125 | "properties": { 126 | "resizable": "true", 127 | "rows": "10", 128 | "maxLength": "5000" 129 | }, 130 | "groupName": "CLI" 131 | }, 132 | { 133 | "name": "initialize", 134 | "type": "bool", 135 | "label": "Initialize Terraform", 136 | "defaultValue": "true", 137 | "visibleRule": "command = CLI", 138 | "required": false, 139 | "helpMarkDown": "Initializes terraform for the CLI" 140 | }, 141 | 142 | { 143 | "name": "backend", 144 | "type": "picklist", 145 | "label": "Backend", 146 | "defaultValue": "Azure", 147 | "required": true, 148 | "helpMarkDown": "Select a backend for your Terraform state", 149 | "visibleRule": "command = init || initialize = true", 150 | "options": { 151 | "Azure": "Azure", 152 | "AWS": "AWS", 153 | "GCP": "GCP", 154 | "Remote": "Remote" 155 | } 156 | }, 157 | 158 | 159 | { 160 | "name": "backendAzureUseProviderConnectedServiceForBackend", 161 | "type": "boolean", 162 | "label": "Use provider connection for backend", 163 | "defaultValue": "true", 164 | "required": false, 165 | "helpMarkDown": "Uses the providers connected service to access the storage backend", 166 | "groupName": "backendAzure" 167 | }, 168 | { 169 | "name": "backendAzureConnectedServiceName", 170 | "type": "connectedService:AzureRM", 171 | "label": "Backend Azure subscription", 172 | "required": true, 173 | "helpMarkDown": "Select the Azure Resource Manager subscription your Terraform backend will live in.", 174 | "visibleRule": "backendAzureUseProviderConnectedServiceForBackend = false", 175 | "properties": { 176 | "EndpointFilterRule": "ScopeLevel != ManagementGroup" 177 | }, 178 | "groupName": "backendAzure" 179 | }, 180 | { 181 | "name": "backendAzureStorageAccountName", 182 | "type": "pickList", 183 | "label": "Azure Storage Account", 184 | "defaultValue": "", 185 | "required": true, 186 | "helpMarkDown": "Specify a pre-existing ARM storage account. It will be used to store your backend Terraform state.", 187 | "visibleRule": "backendAzureUseProviderConnectedServiceForBackend = false", 188 | "properties": { 189 | "EditableOptions": "True" 190 | }, 191 | "groupName": "backendAzure" 192 | }, 193 | { 194 | "name": "backendAzureProviderStorageAccountName", 195 | "type": "pickList", 196 | "label": "Azure Storage Account", 197 | "defaultValue": "", 198 | "required": true, 199 | "helpMarkDown": "Specify a pre-existing ARM storage account. It will be used to store your backend Terraform state.", 200 | "visibleRule": "backendAzureUseProviderConnectedServiceForBackend = true", 201 | "properties": { 202 | "EditableOptions": "True" 203 | }, 204 | "groupName": "backendAzure" 205 | }, 206 | { 207 | "name": "backendAzureContainerName", 208 | "type": "string", 209 | "label": "Container name", 210 | "defaultValue": "tfstate", 211 | "required": true, 212 | "helpMarkDown": "Name of the storage container to keep the Terraform state.", 213 | "groupName": "backendAzure" 214 | }, 215 | { 216 | "name": "backendAzureStateFileKey", 217 | "type": "string", 218 | "label": "State file key", 219 | "defaultValue": "terraform.tfstate", 220 | "required": true, 221 | "helpMarkDown": "Name of the state file to use in the Terraform backend.", 222 | "groupName": "backendAzure" 223 | }, 224 | 225 | { 226 | "name": "variables", 227 | "type": "multiLine", 228 | "label": "Variables", 229 | "defaultValue": "", 230 | "required": false, 231 | "groupName": "Plan", 232 | "helpMarkDown": "A JSON key/value object to add as variables" 233 | }, 234 | { 235 | "name": "outputFile", 236 | "type": "string", 237 | "label": "Output file", 238 | "defaultValue": "", 239 | "required": false, 240 | "groupName": "Plan", 241 | "helpMarkDown": "The plan file to output. If left blank no plan file will be generated and output will only be visible in the logs." 242 | }, 243 | 244 | { 245 | "name": "planFile", 246 | "type": "filePath", 247 | "label": "Plan File", 248 | "defaultValue": "", 249 | "required": false, 250 | "groupName": "Apply", 251 | "helpMarkDown": "Path to the plan file to apply. If no plan file is specified the default values will be applied" 252 | }, 253 | 254 | 255 | { 256 | "name": "args", 257 | "type": "string", 258 | "label": "Additional Arguments", 259 | "defaultValue": "", 260 | "required": false, 261 | "groupName": "advanced", 262 | "helpMarkDown": "Additional arguments to pass" 263 | } 264 | ], 265 | "dataSourceBindings": [ 266 | { 267 | "target": "backendAzureProviderStorageAccountName", 268 | "endpointId": "$(providerAzureConnectedServiceName)", 269 | "dataSourceName": "AzureStorageAccountRMandClassic" 270 | }, 271 | { 272 | "target": "backendAzureStorageAccountName", 273 | "endpointId": "$(backendAzureConnectedServiceName)", 274 | "dataSourceName": "AzureStorageAccountRMandClassic" 275 | } 276 | ], 277 | "execution": { 278 | "Node": { 279 | "target": ".bin/src/Index.js" 280 | } 281 | } 282 | } -------------------------------------------------------------------------------- /Tasks/Terraform/test/Provider/Azure/AzureProvider.spec.ts: -------------------------------------------------------------------------------- 1 | import { AzureProvider } from '../../../src/Provider/Azure/AzureProvider'; 2 | 3 | // import { expect } from 'chai'; 4 | // if you used the '@types/mocha' method to install mocha type definitions, uncomment the following line 5 | // import 'mocha'; 6 | 7 | describe('Hello function', () => { 8 | it('should return hello world', () => { 9 | AzureProvider 10 | // const result = hello(); 11 | // expect(result).to.equal('Hello World!'); 12 | }); 13 | }); -------------------------------------------------------------------------------- /Tasks/Terraform/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "sourceMap": true, 7 | "strict": true, 8 | "outDir": ".bin", 9 | // "rootDir": "src", 10 | "experimentalDecorators": true, 11 | "emitDecoratorMetadata": true, 12 | "typeRoots": [ 13 | "node_modules/@types" 14 | ], 15 | "types": [ 16 | "node", 17 | "mocha", 18 | "q", 19 | "reflect-metadata" 20 | ] 21 | }, 22 | "exclude": [ 23 | "node_modules" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /Tasks/TerraformEnterprise/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/azure-pipelines-extension-terraform/1f7b31dbc2ae32cb9b700b6f048ed7eb9ebddbaa/Tasks/TerraformEnterprise/icon.png -------------------------------------------------------------------------------- /Tasks/TerraformEnterprise/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "azure-pipelines-terraform-task", 3 | "version": "0.0.1", 4 | "description": "A Terraform task for Azure Pipelines", 5 | "main": ".bin/src/Index.js", 6 | "author": "", 7 | "license": "MPL-2.0", 8 | "scripts": { 9 | "build": "tsc --build", 10 | "start": "node -r dotenv/config .bin/src/Index.js", 11 | "test": "ts-mocha test/**/*.spec.ts" 12 | }, 13 | "dependencies": { 14 | "@azure/arm-storage": "^9.0.1", 15 | "@azure/ms-rest-nodeauth": "^2.0.2", 16 | "azure-pipelines-task-lib": "^2.9.3", 17 | "config": "^3.2.1", 18 | "inversify": "^5.0.1", 19 | "reflect-metadata": "^0.1.13", 20 | "request-prom": "^4.0.1", 21 | "tsyringe": "^3.3.0" 22 | }, 23 | "devDependencies": { 24 | "@types/chai": "^4.2.3", 25 | "@types/expect": "^1.20.4", 26 | "@types/mocha": "^5.2.7", 27 | "@types/node": "^12.6.8", 28 | "@types/q": "^1.5.2", 29 | "chai": "^4.2.0", 30 | "copyfiles": "^2.1.0", 31 | "dotenv": "^8.2.0", 32 | "mocha": "^5.2.0", 33 | "mocha-junit-reporter": "^1.18.0", 34 | "tfx-cli": "^0.6.4", 35 | "ts-mocha": "^6.0.0", 36 | "ts-node": "^8.4.1", 37 | "tsconfig-paths": "^3.9.0", 38 | "typescript": "^3.3.3333" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Tasks/TerraformEnterprise/src/Index.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | import { Container } from "inversify"; 3 | import { TaskResult } from "azure-pipelines-task-lib/task"; 4 | import task = require('azure-pipelines-task-lib/task'); 5 | 6 | import { TerraformEnterpriseTask } from './TerraformEnterpriseTask'; 7 | import { TerraformApi } from "./TerraformApi"; 8 | import { TaskOptions } from './TaskOptions'; 9 | 10 | 11 | 12 | let container = new Container(); 13 | let options = new TaskOptions(); 14 | 15 | container.bind(TerraformEnterpriseTask).toSelf() 16 | container.bind(TaskOptions).toSelf() 17 | container.bind(TerraformApi).toSelf(); 18 | 19 | 20 | 21 | var terraformEnterpriseTask = container.get(TerraformEnterpriseTask); 22 | 23 | terraformEnterpriseTask.run().then(function() 24 | { 25 | task.setResult(TaskResult.Succeeded, "Terraform successfully ran"); 26 | }, function() { 27 | task.setResult(TaskResult.Failed, "Terraform failed to run"); 28 | }); 29 | -------------------------------------------------------------------------------- /Tasks/TerraformEnterprise/src/TaskOptions.ts: -------------------------------------------------------------------------------- 1 | import task = require('azure-pipelines-task-lib/task'); 2 | import { injectable } from "inversify"; 3 | 4 | /** 5 | * Strong-type accessor for Task configuration 6 | */ 7 | @injectable() 8 | export class TaskOptions { 9 | 10 | // Basic 11 | readonly command : string | undefined; 12 | readonly url : string | undefined; 13 | readonly organization : string | undefined; 14 | readonly token : string | undefined; 15 | readonly skipcertcheck : string | undefined; 16 | readonly workspace : string | undefined; 17 | 18 | // Lookup 19 | readonly lookupcommand : string | undefined; 20 | 21 | // Workspace 22 | readonly workspacecommand : string | undefined; 23 | readonly workspaceautoapply : boolean | undefined; 24 | readonly workspacedescription : string | undefined; 25 | readonly workspacefiletriggersenabled : boolean | undefined; 26 | readonly workspacequeueallruns : boolean | undefined; 27 | readonly workspacespeculativeenabled : boolean | undefined; 28 | readonly workspaceterraformversion : string | undefined; 29 | readonly workspacetriggerprefixes : string[] | undefined; 30 | readonly workspaceworkingdirectory : string | undefined; 31 | readonly workspacevcsrepo : boolean | undefined; 32 | 33 | // Workspace VCS Repo 34 | readonly vcsrepooauthtokenid : string | undefined; 35 | readonly vcsrepobranch : string | undefined; 36 | readonly vcsrepoingresssubmodules : boolean | undefined; 37 | readonly vcsrepoidentifier : string | undefined; 38 | 39 | // Runs 40 | readonly runcommand : string | undefined; 41 | readonly runisdestroy : boolean | undefined; 42 | readonly runmessage : string | undefined; 43 | readonly runconfigversion : string | undefined; 44 | readonly runid : string | undefined; 45 | readonly runapplycomment : string | undefined; 46 | 47 | // Variables 48 | readonly variablecommand : string | undefined; 49 | readonly variablekey : string | undefined; 50 | readonly variablevalue : string | undefined; 51 | readonly variablecategory : string | undefined; 52 | readonly variablehcl : boolean | undefined; 53 | readonly variablesensitive : boolean | undefined; 54 | 55 | /** 56 | * Creates and loads a well-formed options object 57 | */ 58 | constructor() { 59 | // This can be massively improved, it should be automatic 60 | this.command = task.getInput("command", true); 61 | this.url = task.getInput("url"); 62 | this.organization = task.getInput("organization"); 63 | this.workspace = task.getInput("workspace"); 64 | this.token = task.getInput("token"); 65 | this.skipcertcheck = task.getInput("skipcertcheck"); 66 | this.lookupcommand = task.getInput("lookupcommand"); 67 | this.workspacecommand = task.getInput("workspacecommand"); 68 | this.workspaceautoapply = task.getBoolInput("workspaceautoapply"); 69 | this.workspacedescription = task.getInput("workspacedescription"); 70 | this.workspacefiletriggersenabled = task.getBoolInput("workspacefiletriggersenabled"); 71 | this.workspacequeueallruns = task.getBoolInput("workspacequeueallruns"); 72 | this.workspacespeculativeenabled = task.getBoolInput("workspacespeculativeenabled"); 73 | this.workspaceterraformversion = task.getInput("workspaceterraformversion"); 74 | this.workspacetriggerprefixes = task.getDelimitedInput("workspacetriggerprefixs",","); 75 | this.workspaceworkingdirectory = task.getInput("workspaceworkingdirectory"); 76 | this.workspacevcsrepo = task.getBoolInput("workspacevcsrepo"); 77 | this.vcsrepooauthtokenid = task.getInput("vcsrepooauthtokenid"); 78 | this.vcsrepoingresssubmodules = task.getBoolInput("vcsrepoingresssubmodules"); 79 | this.vcsrepoidentifier = task.getInput("vcsrepoidentifier"); 80 | this.runcommand = task.getInput("runcommand"); 81 | this.runisdestroy = task.getBoolInput("runisdestroy"); 82 | this.runmessage = task.getInput("runmessage"); 83 | this.runconfigversion = task.getInput("runconfigversion"); 84 | this.runid = task.getInput("runid"); 85 | this.runapplycomment = task.getInput("runapplycomment"); 86 | this.variablecommand = task.getInput("variablecommand"); 87 | this.variablekey = task.getInput("variablekey"); 88 | this.variablevalue = task.getInput("variablevalue"); 89 | this.variablecategory = task.getInput("variablecategory"); 90 | this.variablehcl = task.getBoolInput("variablehcl"); 91 | this.variablesensitive = task.getBoolInput("variablesensitive"); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Tasks/TerraformEnterprise/src/TerraformApi.ts: -------------------------------------------------------------------------------- 1 | import task = require('azure-pipelines-task-lib/task'); 2 | import { injectable } from "inversify"; 3 | import { TaskOptions } from "./TaskOptions"; 4 | const axios = require('axios'); 5 | 6 | @injectable() 7 | export class TerraformApi { 8 | 9 | public constructor( 10 | private options: TaskOptions 11 | ) { 12 | } 13 | 14 | public async call(url: string, method: string, body: string = "") { 15 | const skipcertcheck = this.options.skipcertcheck; 16 | const baseUrl = this.options.url 17 | const requestUrl = url; 18 | const metadata = body; 19 | console.log(body); 20 | const requestMethod = method; 21 | console.log(requestUrl); 22 | const accessToken = this.options.token; 23 | console.log(accessToken); 24 | 25 | try { 26 | await axios({ 27 | method: requestMethod, 28 | baseURL: baseUrl, 29 | url: requestUrl, 30 | data: metadata, 31 | headers: { 32 | 'Authorization': 'Bearer ' + accessToken, 33 | 'Content-Type': 'application/vnd.api+json' 34 | } 35 | }).then((response: Response) => { 36 | console.log(response.statusText); 37 | }); 38 | } 39 | catch (error) { 40 | console.log("Unable to update Terraform Api, Error: " + error); 41 | throw new Error("Unable to update Terraform Api, Error: " + error); 42 | } 43 | } 44 | 45 | 46 | public async workspaceIdLookup(url: string) { 47 | const skipcertcheck = this.options.skipcertcheck; 48 | const baseUrl = this.options.url 49 | const requestUrl = url; 50 | console.log(requestUrl); 51 | const accessToken = this.options.token; 52 | interface ServerResponse { 53 | data: ServerDataWrapper 54 | } 55 | interface ServerDataWrapper { 56 | data: ServerData 57 | } 58 | interface ServerData { 59 | id: string 60 | } 61 | 62 | try { 63 | let response = await axios({ 64 | method: 'get', 65 | baseURL: baseUrl, 66 | url: requestUrl, 67 | headers: { 68 | 'Authorization': 'Bearer ' + accessToken, 69 | 'Content-Type': 'application/vnd.api+json' 70 | } 71 | }); 72 | console.log(response.data.data.id); 73 | return response.data.data.id; 74 | } 75 | catch (error) { 76 | console.log("Unable to update Terraform Api, Error: " + error); 77 | throw new Error("Unable to update Terraform Api, Error: " + error); 78 | } 79 | } 80 | 81 | public async latestRunIdLookup(url: string) { 82 | const skipcertcheck = this.options.skipcertcheck; 83 | const baseUrl = this.options.url 84 | const requestUrl = url; 85 | console.log(requestUrl); 86 | const accessToken = this.options.token; 87 | interface ServerResponse { 88 | data: ServerDataWrapper 89 | } 90 | interface ServerDataWrapper { 91 | data: ServerData 92 | } 93 | interface ServerData { 94 | id: string 95 | } 96 | 97 | try { 98 | let response = await axios({ 99 | method: 'get', 100 | baseURL: baseUrl, 101 | url: requestUrl, 102 | headers: { 103 | 'Authorization': 'Bearer ' + accessToken, 104 | 'Content-Type': 'application/vnd.api+json' 105 | } 106 | }); 107 | console.log(response.data.data[0].id); 108 | return response.data.data[0].id; 109 | } 110 | catch (error) { 111 | console.log("Unable to update Terraform Api, Error: " + error); 112 | throw new Error("Unable to update Terraform Api, Error: " + error); 113 | } 114 | } 115 | 116 | public async variableIdLookup(url: string, variable: string) { 117 | const skipcertcheck = this.options.skipcertcheck; 118 | const baseUrl = this.options.url 119 | const requestUrl = url; 120 | console.log(requestUrl); 121 | const accessToken = this.options.token; 122 | interface ServerResponse { 123 | data: ServerDataWrapper 124 | } 125 | interface ServerDataWrapper { 126 | data: ServerData 127 | } 128 | interface ServerData { 129 | id: string 130 | } 131 | 132 | try { 133 | let response = await axios({ 134 | method: 'get', 135 | baseURL: baseUrl, 136 | url: requestUrl, 137 | headers: { 138 | 'Authorization': 'Bearer ' + accessToken, 139 | 'Content-Type': 'application/vnd.api+json' 140 | } 141 | }); 142 | for (var i=0; i < response.data.data.length; i++) { 143 | if (response.data.data[i]['attributes']['key'] == variable) { 144 | console.log(response.data.data[i]['id']); 145 | return response.data.data[i]['id']; 146 | } 147 | } 148 | } 149 | catch (error) { 150 | console.log("Unable to update Terraform Api, Error: " + error); 151 | throw new Error("Unable to update Terraform Api, Error: " + error); 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /Tasks/TerraformEnterprise/src/TerraformEnterpriseTask.ts: -------------------------------------------------------------------------------- 1 | import path = require("path"); 2 | import fs = require("fs"); 3 | import os = require("os"); 4 | 5 | import { injectable } from "inversify"; 6 | import { TerraformApi } from "./TerraformApi"; 7 | import { TaskOptions } from './TaskOptions'; 8 | 9 | @injectable() 10 | export class TerraformEnterpriseTask { 11 | 12 | constructor( 13 | private terraformapi : TerraformApi, 14 | private options: TaskOptions) 15 | { 16 | 17 | } 18 | 19 | public async run() { 20 | console.log(this.options.command); 21 | switch(this.options.command) { 22 | case "lookup": 23 | switch(this.options.lookupcommand) { 24 | case "workspaceId": 25 | var workspacelookupurl = "/organizations/" + this.options.organization + "/workspaces/" + this.options.workspace; 26 | var workspaceId = await this.terraformapi.workspaceIdLookup(workspacelookupurl) 27 | return workspaceId; 28 | break; 29 | case "latestRunId": 30 | var workspacelookupurl = "/organizations/" + this.options.organization + "/workspaces/" + this.options.workspace; 31 | var workspaceId = await this.terraformapi.workspaceIdLookup(workspacelookupurl) 32 | var runlookupurl = "/workspaces/" + workspaceId + "/runs?page%5Bsize%5D=1"; 33 | var runId = await this.terraformapi.latestRunIdLookup(runlookupurl); 34 | console.log(runId); 35 | return runId; 36 | break; 37 | case "variableId": 38 | const variablekey = "" + this.options.variablekey; 39 | var variablelookupurl = "/vars?filter%5Borganization%5D%5Bname%5D=" + this.options.organization + "&filter%5Bworkspace%5D%5Bname%5D=" + this.options.workspace; 40 | var variableId = await this.terraformapi.variableIdLookup(variablelookupurl, variablekey); 41 | return variableId; 42 | default: 43 | console.log("Invalid lookup command"); 44 | throw new Error("Invalid lookup command"); 45 | break; 46 | } 47 | break; 48 | case "workspace": 49 | switch(this.options.workspacecommand) { 50 | case "create": 51 | console.log('create'); 52 | var method = 'post'; 53 | var endpoint = '/workspaces'; 54 | var payload = this.workspacePayload(); 55 | break; 56 | case "update": 57 | var method = 'patch'; 58 | var endpoint = '/workspaces/' + this.options.workspace; 59 | var payload = this.workspacePayload(); 60 | break; 61 | case "delete": 62 | var method = 'delete'; 63 | var endpoint = '/workspaces/' + this.options.workspace; 64 | var payload = this.workspacePayload(); 65 | break; 66 | default: 67 | console.log("Invalid workspace command"); 68 | throw new Error("Invalid workspace command"); 69 | break; 70 | } 71 | 72 | console.log('workspace'); 73 | console.log('payload'); 74 | var url = "/organizations/" + this.options.organization + endpoint; 75 | console.log(url); 76 | await this.terraformapi.call(url, method, JSON.stringify(payload)); 77 | break; 78 | case "run": 79 | console.log('run'); 80 | switch(this.options.runcommand) { 81 | case "create": 82 | console.log('create'); 83 | const runconfigversion = this.options.runconfigversion; 84 | const runisdestroy = this.options.runisdestroy; 85 | const runmessage = this.options.runmessage; 86 | var url = '/runs'; 87 | var workspacelookupurl = "/organizations/" + this.options.organization + "/workspaces/" + this.options.workspace; 88 | var method = 'post'; 89 | var attributes:any = {}; 90 | var relationshipsworkspacedata:any = {}; 91 | var relationshipsworkspace:any = {}; 92 | var relationshipsconfigversiondata:any = {}; 93 | var relationshipsconfigversion:any = {}; 94 | var relationships:any = {}; 95 | var payloaddata:any = {}; 96 | var payload:any = {}; 97 | console.log("Calling workspace lookup"); 98 | var workspaceId = await this.terraformapi.workspaceIdLookup(workspacelookupurl) 99 | console.log(workspaceId); 100 | relationshipsworkspacedata["type"] = "workspaces"; 101 | relationshipsworkspacedata["id"] = workspaceId; 102 | relationshipsworkspace["data"] = relationshipsworkspacedata; 103 | relationships["workspace"] = relationshipsworkspace; 104 | if ( runconfigversion ) { 105 | relationshipsconfigversiondata["type"] = "configuration-versions"; 106 | relationshipsconfigversiondata["id"] = runconfigversion; 107 | relationshipsconfigversion["data"] = relationshipsconfigversiondata; 108 | relationships["configuration-version"] = relationshipsconfigversion; 109 | } 110 | console.log(relationships); 111 | if ( runisdestroy === true ) { 112 | attributes["is-destroy"] = runisdestroy 113 | } 114 | if ( runmessage ) { 115 | attributes["message"] = runmessage 116 | } 117 | console.log(attributes); 118 | payloaddata["attributes"] = attributes; 119 | payloaddata["relationships"] = relationships; 120 | payloaddata["type"] = "runs"; 121 | payload["data"] = payloaddata; 122 | console.log(payload); 123 | await this.terraformapi.call(url, method, JSON.stringify(payload)); 124 | break; 125 | case "apply": 126 | console.log('apply'); 127 | const runid = this.options.runid; 128 | const runapplycomment = this.options.runapplycomment; 129 | var url = '/runs/' + runid + '/actions/apply'; 130 | var method = 'post'; 131 | var payload:any = {}; 132 | if ( runapplycomment ) { 133 | payload["comment"] = runapplycomment; 134 | } 135 | await this.terraformapi.call(url, method, JSON.stringify(payload)); 136 | break; 137 | default: 138 | console.log("Invalid command"); 139 | throw new Error("Invalid command"); 140 | break; 141 | } 142 | break; 143 | case "variable": 144 | console.log('variable'); 145 | switch(this.options.variablecommand) { 146 | case "create": 147 | console.log('create'); 148 | var method = 'post'; 149 | var url = '/vars'; 150 | var workspacelookupurl = "/organizations/" + this.options.organization + "/workspaces/" + this.options.workspace; 151 | var workspaceId = await this.terraformapi.workspaceIdLookup(workspacelookupurl) 152 | var payload = this.variablePayload(workspaceId); 153 | break; 154 | case "update": 155 | var method = 'patch'; 156 | var workspacelookupurl = "/organizations/" + this.options.organization + "/workspaces/" + this.options.workspace; 157 | var workspaceId = await this.terraformapi.workspaceIdLookup(workspacelookupurl) 158 | var variablekey = "" + this.options.variablekey; 159 | var variablelookupurl = "/vars?filter%5Borganization%5D%5Bname%5D=" + this.options.organization + "&filter%5Bworkspace%5D%5Bname%5D=" + this.options.workspace; 160 | var variableId = await this.terraformapi.variableIdLookup(variablelookupurl, variablekey); 161 | console.log(variableId); 162 | var url = '/vars/' + variableId; 163 | console.log('building payload'); 164 | var payload = this.variablePayload(workspaceId, variableId); 165 | console.log('finish payload'); 166 | break; 167 | case "delete": 168 | var method = 'delete'; 169 | var variablekey = "" + this.options.variablekey; 170 | var variablelookupurl = "/vars?filter%5Borganization%5D%5Bname%5D=" + this.options.organization + "&filter%5Bworkspace%5D%5Bname%5D=" + this.options.workspace; 171 | var variableId = await this.terraformapi.variableIdLookup(variablelookupurl, variablekey); 172 | var url = '/vars/' + variableId; 173 | var payload = this.workspacePayload(); 174 | break; 175 | default: 176 | console.log("Invalid workspace command"); 177 | throw new Error("Invalid workspace command"); 178 | break; 179 | } 180 | console.log('calling terraform api for variable'); 181 | await this.terraformapi.call(url, method, JSON.stringify(payload)); 182 | break; 183 | default: 184 | console.log("Invalid command"); 185 | throw new Error("Invalid command"); 186 | break; 187 | } 188 | } 189 | 190 | private workspacePayload() { 191 | const sourcename = 'Created by Azure DevOps Pipeline Extension for Terraform Enterprise'; 192 | const sourceurl = 'https://github.com/hashicorp/azure-pipelines-extension-terraform'; 193 | 194 | const workspace = this.options.workspace; 195 | const autoapply = this.options.workspaceautoapply; 196 | const description = this.options.workspacedescription; 197 | const filetriggersenabled = this.options.workspacefiletriggersenabled; 198 | const queueallruns = this.options.workspacequeueallruns; 199 | const speculativeenabled = this.options.workspacespeculativeenabled; 200 | const terraformversion = this.options.workspaceterraformversion; 201 | const triggerprefixes = this.options.workspacetriggerprefixes; 202 | const workingdirectory = this.options.workspaceworkingdirectory; 203 | const vcsrepo = this.options.workspacevcsrepo; 204 | const vcsrepotokenid = this.options.vcsrepooauthtokenid; 205 | const vcsrepobranch = this.options.vcsrepobranch; 206 | const vcsrepoingresssubmodules = this.options.vcsrepoingresssubmodules; 207 | const vcsrepoidentifier = this.options.vcsrepoidentifier; 208 | 209 | var args:any = {} 210 | 211 | args["name"] = workspace; 212 | args["source-name"] = sourcename; 213 | args["source-url"] = sourceurl; 214 | // this.getBoolInput seems to return false for undefined, so we are setting this to only run if the non-default value is set. 215 | if ( autoapply === true ) { 216 | args["auto-apply"] = autoapply 217 | } 218 | if ( description ) { 219 | args["description"] = description 220 | } 221 | // this.getBoolInput seems to return false for undefined, so we are setting this to only run if the non-default value is set. 222 | if ( filetriggersenabled === false ) { 223 | args["file-triggers-enabled"] = filetriggersenabled 224 | } 225 | // this.getBoolInput seems to return false for undefined, so we are setting this to only run if the non-default value is set. 226 | if ( queueallruns === true ) { 227 | args["queue-all-runs"] = queueallruns 228 | } 229 | // this.getBoolInput seems to return false for undefined, so we are setting this to only run if the non-default value is set. 230 | if ( speculativeenabled === false ) { 231 | args["speculative-enabled"] = speculativeenabled 232 | } 233 | if ( terraformversion ) { 234 | args["terraform-version"] = terraformversion 235 | } 236 | // this.getDelimintedInput seems to return an empty array for undefined, so we are checking for an array with data inside. 237 | if ( triggerprefixes != undefined && triggerprefixes.length > 0 ) { 238 | args["trigger-prefixes"] = triggerprefixes 239 | } 240 | if ( workingdirectory ) { 241 | args["working-directory"] = workingdirectory 242 | } 243 | // this.getBoolInput seems to return false for undefined, so we are setting this to only run if the non-default value is set. 244 | if ( vcsrepo === true ) { 245 | var vcsrepoPayload:any = {} 246 | if ( vcsrepotokenid ) { 247 | vcsrepoPayload["oauth-token-id"] = vcsrepotokenid 248 | } 249 | if ( vcsrepobranch ) { 250 | vcsrepoPayload["branch"] = vcsrepobranch 251 | } 252 | // this.getBoolInput seems to return false for undefined, so we are setting this to only run if the non-default value is set. 253 | if ( vcsrepoingresssubmodules === true ) { 254 | vcsrepoPayload["ingress-submodules"] = vcsrepoingresssubmodules 255 | } 256 | if ( vcsrepoidentifier ) { 257 | vcsrepoPayload["identifier"] = vcsrepoidentifier 258 | } 259 | args["vcs-repo"] = vcsrepoPayload; 260 | } 261 | console.log(args); 262 | var attributesPayload:any = {} 263 | console.log("created empty variable") 264 | attributesPayload["attributes"] = args 265 | console.log(attributesPayload); 266 | var payload:any = {} 267 | payload["data"] = attributesPayload; 268 | console.log(payload); 269 | 270 | return payload; 271 | } 272 | 273 | private variablePayload(workspaceId: string, variableId: string = "") { 274 | const variablekey = this.options.variablekey; 275 | const variablevalue = this.options.variablevalue; 276 | const variablecategory = this.options.variablecategory; 277 | const variablehcl = this.options.variablehcl; 278 | const variablesensitive = this.options.variablesensitive; 279 | var payload:any = {}; 280 | var payloaddata:any = {}; 281 | var attributes:any = {}; 282 | var relationships:any = {}; 283 | var relationshipsworkspace:any = {}; 284 | var relationshipsworkspacedata:any = {}; 285 | attributes["key"] = variablekey; 286 | attributes["value"] = variablevalue; 287 | attributes["category"] = variablecategory; 288 | // this.getBoolInput seems to return false for undefined, so we are setting this to only run if the non-default value is set. 289 | if ( variablehcl === true ) { 290 | attributes["hcl"] = variablehcl; 291 | } 292 | // this.getBoolInput seems to return false for undefined, so we are setting this to only run if the non-default value is set. 293 | if ( variablesensitive === true ) { 294 | attributes["sensitive"] = variablesensitive; 295 | } 296 | relationshipsworkspacedata["type"] = 'workspaces'; 297 | relationshipsworkspacedata["id"] = workspaceId; 298 | relationshipsworkspace["data"] = relationshipsworkspacedata; 299 | relationships["workspace"] = relationshipsworkspace; 300 | payloaddata["type"] = "vars"; 301 | if ( variableId != "" ) { 302 | payloaddata["id"] = variableId; 303 | } 304 | payloaddata["attributes"] = attributes; 305 | payloaddata["relationships"] = relationships; 306 | payload["data"] = payloaddata; 307 | 308 | return payload; 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /Tasks/TerraformEnterprise/task.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "506f3bf4-69c9-4812-a879-ccfc035f174c", 3 | "name": "terraformEnterprise", 4 | "friendlyName": "Terraform Enterprise", 5 | "description": "A Task for Terraform Enterprise to manage workspaces, run plan, and apply", 6 | "author": "HashiCorp", 7 | "helpMarkDown": "", 8 | "category": "Tool", 9 | "visibility": [ 10 | "Build", 11 | "Release" 12 | ], 13 | "demands": [], 14 | "version": { 15 | "Major": 0, 16 | "Minor": 0, 17 | "Patch": 1 18 | }, 19 | "instanceNameFormat": "Terraform Enterprise", 20 | "inputs": [ 21 | { 22 | "name": "terraformCloudServiceConnection", 23 | "type": "connectedService:terraformServiceEndpoint", 24 | "label": "Terraform Cloud Connection", 25 | "required": true, 26 | "helpMarkDown": "Select the Terraform Cloud/Enterprise endpoint." 27 | }, 28 | { 29 | "name": "command", 30 | "type": "picklist", 31 | "label": "Command", 32 | "defaultValue": "workspace", 33 | "required": true, 34 | "helpMarkDown": "Select the Terraform API command to call", 35 | "options": { 36 | "workspace": "Manage Workspace", 37 | "queuePlan": "Queue Plan", 38 | "confirmApply": "Confirm & Apply", 39 | "discardRun": "Discard Run", 40 | "addComment": "Add Comment", 41 | "cancelRun": "Cancel Run", 42 | "overrideContinue": "Override & Continue", 43 | "checkRun": "Check Run Status" 44 | } 45 | }, 46 | { 47 | "name": "workspaceName", 48 | "type": "string", 49 | "label": "Name of Workspace", 50 | "required": true, 51 | "helpMarkDown": "Name of Workspace.", 52 | "visibleRule": "command = workspace" 53 | }, 54 | { 55 | "name": "action", 56 | "type": "pickList", 57 | "label": "Action", 58 | "required": true, 59 | "helpMarkDown": "Action to take.", 60 | "visibleRule": "command = workspace", 61 | "options": { 62 | "create": "Create", 63 | "update": "Update", 64 | "delete": "Delete" 65 | } 66 | }, 67 | ], 68 | "execution": { 69 | "Node": { 70 | "target": ".bin/Index.js" 71 | } 72 | }, 73 | "outputVariables": [ 74 | ], 75 | "messages": { 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Tasks/TerraformEnterprise/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "sourceMap": true, 7 | "strict": true, 8 | "outDir": ".bin", 9 | // "rootDir": "src", 10 | "experimentalDecorators": true, 11 | "emitDecoratorMetadata": true, 12 | "typeRoots": [ 13 | "node_modules/@types" 14 | ], 15 | "types": [ 16 | "node", 17 | "mocha", 18 | "q", 19 | "reflect-metadata" 20 | ] 21 | }, 22 | "exclude": [ 23 | "node_modules" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /Tasks/TerraformInstaller/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/azure-pipelines-extension-terraform/1f7b31dbc2ae32cb9b700b6f048ed7eb9ebddbaa/Tasks/TerraformInstaller/icon.png -------------------------------------------------------------------------------- /Tasks/TerraformInstaller/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "azure-pipelines-terraform-installer", 3 | "version": "0.0.1", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@types/mocha": { 8 | "version": "5.2.7", 9 | "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-5.2.7.tgz", 10 | "integrity": "sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ==" 11 | }, 12 | "@types/node": { 13 | "version": "6.14.7", 14 | "resolved": "https://registry.npmjs.org/@types/node/-/node-6.14.7.tgz", 15 | "integrity": "sha512-YbPXbaynBTe0pVExPhL76TsWnxSPeFAvImIsmylpBWn/yfw+lHy+Q68aawvZHsgskT44ZAoeE67GM5f+Brekew==" 16 | }, 17 | "@types/q": { 18 | "version": "1.5.2", 19 | "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.2.tgz", 20 | "integrity": "sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw==" 21 | }, 22 | "@types/semver": { 23 | "version": "5.5.0", 24 | "resolved": "https://registry.npmjs.org/@types/semver/-/semver-5.5.0.tgz", 25 | "integrity": "sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ==" 26 | }, 27 | "@types/uuid": { 28 | "version": "3.4.5", 29 | "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-3.4.5.tgz", 30 | "integrity": "sha512-MNL15wC3EKyw1VLF+RoVO4hJJdk9t/Hlv3rt1OL65Qvuadm4BYo6g9ZJQqoq7X8NBFSsQXgAujWciovh2lpVjA==", 31 | "requires": { 32 | "@types/node": "*" 33 | } 34 | }, 35 | "azure-pipelines-task-lib": { 36 | "version": "2.8.0", 37 | "resolved": "https://registry.npmjs.org/azure-pipelines-task-lib/-/azure-pipelines-task-lib-2.8.0.tgz", 38 | "integrity": "sha512-PR8oap9z2j+o455W3PwAfB4SX1p4GdJc9OHQaQV0V+iQS1IBY6dVgcNSQMkHAXb0V1bbuLOFBLanXPe5eSgGTQ==", 39 | "requires": { 40 | "minimatch": "3.0.4", 41 | "mockery": "^1.7.0", 42 | "q": "^1.1.2", 43 | "semver": "^5.1.0", 44 | "shelljs": "^0.3.0", 45 | "uuid": "^3.0.1" 46 | } 47 | }, 48 | "azure-pipelines-tool-lib": { 49 | "version": "0.11.0", 50 | "resolved": "https://registry.npmjs.org/azure-pipelines-tool-lib/-/azure-pipelines-tool-lib-0.11.0.tgz", 51 | "integrity": "sha512-Bg3usPtOs/YvM5V9dOEwxvsructz/BTsO5mVgLQ013kJsQN9DJrTjZMAgxxdaeW1H8KPHMz/40ix1T7XZ++VCw==", 52 | "requires": { 53 | "@types/semver": "^5.3.0", 54 | "@types/uuid": "^3.0.1", 55 | "azure-pipelines-task-lib": "^2.7.1", 56 | "semver": "^5.3.0", 57 | "semver-compare": "^1.0.0", 58 | "typed-rest-client": "1.0.9", 59 | "uuid": "^3.0.1" 60 | } 61 | }, 62 | "balanced-match": { 63 | "version": "1.0.0", 64 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", 65 | "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" 66 | }, 67 | "brace-expansion": { 68 | "version": "1.1.11", 69 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", 70 | "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", 71 | "requires": { 72 | "balanced-match": "^1.0.0", 73 | "concat-map": "0.0.1" 74 | } 75 | }, 76 | "concat-map": { 77 | "version": "0.0.1", 78 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 79 | "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" 80 | }, 81 | "minimatch": { 82 | "version": "3.0.4", 83 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", 84 | "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", 85 | "requires": { 86 | "brace-expansion": "^1.1.7" 87 | } 88 | }, 89 | "mockery": { 90 | "version": "1.7.0", 91 | "resolved": "https://registry.npmjs.org/mockery/-/mockery-1.7.0.tgz", 92 | "integrity": "sha1-9O3g2HUMHJcnwnLqLGBiniyaHE8=" 93 | }, 94 | "q": { 95 | "version": "1.5.1", 96 | "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", 97 | "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=" 98 | }, 99 | "semver": { 100 | "version": "5.7.0", 101 | "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", 102 | "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==" 103 | }, 104 | "semver-compare": { 105 | "version": "1.0.0", 106 | "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", 107 | "integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=" 108 | }, 109 | "shelljs": { 110 | "version": "0.3.0", 111 | "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.3.0.tgz", 112 | "integrity": "sha1-NZbmMHp4FUT1kfN9phg2DzHbV7E=" 113 | }, 114 | "tunnel": { 115 | "version": "0.0.4", 116 | "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.4.tgz", 117 | "integrity": "sha1-LTeFoVjBdMmhbcLARuxfxfF0IhM=" 118 | }, 119 | "typed-rest-client": { 120 | "version": "1.0.9", 121 | "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.0.9.tgz", 122 | "integrity": "sha512-iOdwgmnP/tF6Qs+oY4iEtCf/3fnCDl7Gy9LGPJ4E3M4Wj3uaSko15FVwbsaBmnBqTJORnXBWVY5306D4HH8oiA==", 123 | "requires": { 124 | "tunnel": "0.0.4", 125 | "underscore": "1.8.3" 126 | }, 127 | "dependencies": { 128 | "underscore": { 129 | "version": "1.8.3", 130 | "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.8.3.tgz", 131 | "integrity": "sha1-Tz+1OxBuYJf8+ctBCfKl6b36UCI=" 132 | } 133 | } 134 | }, 135 | "typescript": { 136 | "version": "3.6.4", 137 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.6.4.tgz", 138 | "integrity": "sha512-unoCll1+l+YK4i4F8f22TaNVPRHcD9PA3yCuZ8g5e0qGqlVlJ/8FSateOLLSagn+Yg5+ZwuPkL8LFUc0Jcvksg==", 139 | "dev": true 140 | }, 141 | "uuid": { 142 | "version": "3.3.2", 143 | "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", 144 | "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /Tasks/TerraformInstaller/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "azure-pipelines-terraform-installer", 3 | "version": "0.0.1", 4 | "description": "A Terraform installer task for Azure Pipelines", 5 | "main": ".bin/Index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "MPL-2.0", 11 | "dependencies": { 12 | "@types/mocha": "^5.2.7", 13 | "@types/node": "^6.0.101", 14 | "@types/q": "^1.5.0", 15 | "azure-pipelines-task-lib": "2.8.0", 16 | "azure-pipelines-tool-lib": "0.11.0" 17 | }, 18 | "devDependencies": { 19 | "typescript": "^3.3.3333" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tasks/TerraformInstaller/src/Index.ts: -------------------------------------------------------------------------------- 1 | import tasks = require('azure-pipelines-task-lib/task'); 2 | import tools = require('azure-pipelines-tool-lib/tool'); 3 | import { ToolRunner } from 'azure-pipelines-task-lib/toolrunner'; 4 | import path = require('path'); 5 | import * as installer from './TerraformInstaller'; 6 | 7 | async function configureTerraform() { 8 | let inputVersion = tasks.getInput("terraformVersion", true); 9 | let terraformPath = await installer.downloadTerraform(inputVersion); 10 | let envPath = process.env['PATH']; 11 | 12 | // Prepend the tools path. Instructs the agent to prepend for future tasks 13 | if (envPath && !envPath.startsWith(path.dirname(terraformPath))) { 14 | tools.prependPath(path.dirname(terraformPath)); 15 | } 16 | } 17 | 18 | async function verifyTerraform() { 19 | console.log(tasks.loc("VerifyTerraformInstallation")); 20 | let terraformPath = tasks.which("terraform", true); 21 | let terraformTool : ToolRunner = tasks.tool(terraformPath); 22 | terraformTool.arg("version"); 23 | return terraformTool.exec(); 24 | } 25 | 26 | async function run() { 27 | tasks.setResourcePath(path.join(__dirname, '..', 'task.json')); 28 | 29 | try { 30 | await configureTerraform(); 31 | await verifyTerraform(); 32 | tasks.setResult(tasks.TaskResult.Succeeded, ""); 33 | } catch (error) { 34 | tasks.setResult(tasks.TaskResult.Failed, error); 35 | } 36 | } 37 | 38 | run(); -------------------------------------------------------------------------------- /Tasks/TerraformInstaller/src/TerraformInstaller.ts: -------------------------------------------------------------------------------- 1 | import tasks = require('azure-pipelines-task-lib/task'); 2 | import tools = require('azure-pipelines-tool-lib/tool'); 3 | import path = require('path'); 4 | import os = require('os'); 5 | import fs = require('fs'); 6 | 7 | const uuidV4 = require('uuid/v4'); 8 | const terraformToolName = "terraform"; 9 | const isWindows = os.type().match(/^Win/); 10 | 11 | export async function downloadTerraform(inputVersion: string): Promise { 12 | let version = tools.cleanVersion(inputVersion); 13 | if (!version) { 14 | throw new Error(tasks.loc("InputVersionNotValidSemanticVersion", inputVersion)); 15 | } 16 | 17 | let cachedToolPath = tools.findLocalTool(terraformToolName, version); 18 | if (!cachedToolPath) { 19 | let terraformDownloadUrl = getTerraformDownloadUrl(version); 20 | let fileName = `${terraformToolName}-${version}-${uuidV4()}.zip`; 21 | let terraformDownloadPath; 22 | 23 | try { 24 | terraformDownloadPath = await tools.downloadTool(terraformDownloadUrl, fileName); 25 | } catch (exception) { 26 | throw new Error(tasks.loc("TerraformDownloadFailed", terraformDownloadUrl, exception)); 27 | } 28 | 29 | let terraformUnzippedPath = await tools.extractZip(terraformDownloadPath); 30 | cachedToolPath = await tools.cacheDir(terraformUnzippedPath, terraformToolName, version); 31 | } 32 | 33 | let terraformPath = findTerraformExecutable(cachedToolPath); 34 | if (!terraformPath) { 35 | throw new Error(tasks.loc("TerraformNotFoundInFolder", cachedToolPath)); 36 | } 37 | 38 | if (!isWindows) { 39 | fs.chmodSync(terraformPath, "777"); 40 | } 41 | 42 | tasks.setVariable('terraformLocation', terraformPath); 43 | 44 | return terraformPath; 45 | } 46 | 47 | function getTerraformDownloadUrl(version: string): string { 48 | let platform: string; 49 | let architecture: string; 50 | 51 | switch(os.type()) { 52 | case "Darwin": 53 | platform = "darwin"; 54 | break; 55 | 56 | case "Linux": 57 | platform = "linux"; 58 | break; 59 | 60 | case "Windows_NT": 61 | platform = "windows"; 62 | break; 63 | 64 | default: 65 | throw new Error(tasks.loc("OperatingSystemNotSupported", os.type())); 66 | } 67 | 68 | switch(os.arch()) { 69 | case "x64": 70 | architecture = "amd64"; 71 | break; 72 | 73 | case "x32": 74 | architecture = "386"; 75 | break; 76 | 77 | default: 78 | throw new Error(tasks.loc("ArchitectureNotSupported", os.arch())); 79 | } 80 | 81 | return `https://releases.hashicorp.com/terraform/${version}/terraform_${version}_${platform}_${architecture}.zip`; 82 | } 83 | 84 | function findTerraformExecutable(rootFolder: string): string { 85 | let terraformPath = path.join(rootFolder, terraformToolName + getExecutableExtension()); 86 | var allPaths = tasks.find(rootFolder); 87 | var matchingResultFiles = tasks.match(allPaths, terraformPath, rootFolder); 88 | return matchingResultFiles[0]; 89 | } 90 | 91 | function getExecutableExtension(): string { 92 | if (isWindows) { 93 | return ".exe"; 94 | } 95 | 96 | return ""; 97 | } -------------------------------------------------------------------------------- /Tasks/TerraformInstaller/task.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "9b9f2e72-30c3-432b-a835-dd2b1160f3b5", 3 | "name": "terraformInstaller", 4 | "friendlyName": "Terraform tool installer", 5 | "description": "Find in cache or download a specific version of Terraform and prepend it to the PATH", 6 | "author": "HashiCorp", 7 | "helpMarkDown": "", 8 | "category": "Tool", 9 | "visibility": [ 10 | "Build", 11 | "Release" 12 | ], 13 | "demands": [], 14 | "version": { 15 | "Major": 0, 16 | "Minor": 0, 17 | "Patch": 1 18 | }, 19 | "instanceNameFormat": "Install Terraform $(terraformVersion)", 20 | "inputs": [ 21 | { 22 | "name": "terraformVersion", 23 | "type": "string", 24 | "label": "Version", 25 | "defaultValue": "0.12.3", 26 | "required": true, 27 | "helpMarkDown": "The version of Terraform which should be installed on the agent if not already present" 28 | } 29 | ], 30 | "execution": { 31 | "Node": { 32 | "target": ".bin/Index.js" 33 | } 34 | }, 35 | "outputVariables": [ 36 | { 37 | "name": "terraformLocation", 38 | "description": "The location of the terraform binary that was installed on the agent." 39 | } 40 | ], 41 | "messages": { 42 | "VerifyTerraformInstallation": "Verifying Terraform installation...", 43 | "InputVersionNotValidSemanticVersion": "Input version %s is not a valid semantic version", 44 | "TerraformNotFoundInFolder": "Terraform executable not found in path %s", 45 | "OperatingSystemNotSupported": "Operating system %s is not supported", 46 | "ArchitectureNotSupported": "Architecture %s is not supported", 47 | "TerraformDownloadFailed": "Failed to download Terraform from url %s. Error %s" 48 | } 49 | } -------------------------------------------------------------------------------- /Tasks/TerraformInstaller/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "sourceMap": true, 7 | "strict": true, 8 | "outDir": ".bin", 9 | "experimentalDecorators": true, 10 | "emitDecoratorMetadata": true, 11 | "typeRoots": [ 12 | "node_modules/@types" 13 | ], 14 | "types": [ 15 | "node", 16 | "mocha", 17 | "q" 18 | ] 19 | }, 20 | "exclude": [ 21 | "node_modules" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /azure-pipelines-extension-build.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | publisher: 3 | tasks: 4 | 5 | jobs: 6 | - job: Build 7 | displayName: Build and Test 8 | 9 | pool: 10 | vmImage: 'Ubuntu-16.04' 11 | 12 | steps: 13 | # Use GitVersion to determine the appropriate Semantic Version Number 14 | - task: GitVersion@5 15 | inputs: 16 | runtime: 'core' 17 | 18 | # Set the BuildNumber 19 | - script: | 20 | echo "##vso[build.updatebuildnumber]$(GitVersion.MajorMinorPatch)" 21 | displayName: 'Set Bulid Number' 22 | 23 | # Build and test each listed task 24 | - ${{ each taskName in parameters.tasks }}: 25 | - template: azure-pipelines-task-build.yml 26 | parameters: 27 | taskName: ${{ taskName }} 28 | 29 | # Package the extension manifest 30 | - script: | 31 | # Display command output and fail immediately on any errors 32 | set -e -x 33 | 34 | # Update the extension version 35 | sed -i -E 's/"version":\s"[0-9/.]+"/"version": "$(GitVersion.MajorMinorPatch)"/g' vss-extension.json 36 | 37 | # Update the publisher 38 | sed -i -E 's/"publisher":\s"[a-zA-Z0-9_-]+"/"publisher": "${{ parameters.publisher }}"/g' vss-extension.json 39 | 40 | # Copy the extension manifest to the output directory 41 | cp ./vss-extension.json $(Build.StagingDirectory)/ 42 | 43 | # Copy in any Image Assets 44 | rsync -avr --ignore-errors ./images $(Build.StagingDirectory) 45 | displayName: Package the extension manifest and metadata 46 | 47 | # Publish the finished extension to a pipeline artifact 48 | - publish: $(Build.StagingDirectory) 49 | artifact: extension -------------------------------------------------------------------------------- /azure-pipelines-extension-release.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | serviceUrl: 3 | 4 | jobs: 5 | - job: release 6 | displayName: Publish 7 | 8 | pool: 9 | vmImage: 'Ubuntu-16.04' 10 | 11 | steps: 12 | # Do not clone from git for this job 13 | - checkout: none 14 | 15 | # Download the published extension artifact to deploy 16 | - download: current 17 | artifact: extension 18 | 19 | # Publish the extension to Azure DevOps 20 | - script: | 21 | # Display command output and fail immediately on any errors 22 | set -e -x 23 | 24 | # Install the latext command line task for publishing extensions 25 | sudo npm install -g tfx-cli 26 | 27 | # Publish the extension 28 | tfx extension publish \ 29 | --token $PERSONAL_ACCESS_TOKEN \ 30 | --service-url https://marketplace.visualstudio.com \ 31 | --no-prompt 32 | env: 33 | PERSONAL_ACCESS_TOKEN: $(PersonalAccessToken) 34 | workingDirectory: $(Pipeline.Workspace)/extension 35 | displayName: Publish the extension 36 | -------------------------------------------------------------------------------- /azure-pipelines-task-build.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | tasksDirectory: Tasks 3 | taskName: 4 | 5 | steps: 6 | 7 | # Update package vertions 8 | - script: | 9 | # Display command output and fail immediately on any errors 10 | set -e -x 11 | 12 | # Set the package version 13 | sed -i -E 's/"version":\s"[0-9/.]+"/"version": "$(GitVersion.MajorMinorPatch)"/g' package.json 14 | 15 | # Set the task version 16 | sed -i -E 's/"Major":\s[0-9]+/"Major": $(GitVersion.Major)/g' task.json 17 | sed -i -E 's/"Minor":\s[0-9]+/"Minor": $(GitVersion.Minor)/g' task.json 18 | sed -i -E 's/"Patch":\s[0-9]+/"Patch": $(GitVersion.Patch)/g' task.json 19 | 20 | cat task.json 21 | 22 | workingDirectory: ./${{ parameters.tasksDirectory }}/${{ parameters.taskName }} 23 | displayName: Set versions for the ${{ parameters.taskName }} task 24 | 25 | # Built and test the extension 26 | - script: | 27 | # Display command output and fail immediately on any errors 28 | set -e -x 29 | 30 | # Install NPM Packages 31 | npm install --dev 32 | 33 | # Build typescript 34 | tsc --build 35 | 36 | # Run tests 37 | 38 | workingDirectory: ./${{ parameters.tasksDirectory }}/${{ parameters.taskName }} 39 | displayName: Build and test the ${{ parameters.taskName }} task 40 | 41 | # Prune and copy files to the artifact staging directory 42 | - script: | 43 | # Display command output and fail immediately on any errors 44 | set -e -x 45 | 46 | # Remove all dev dependencies prior to release 47 | npm prune --production 48 | 49 | # Ensure the destination path exists 50 | mkdir -p $(Build.StagingDirectory)/Tasks/ 51 | 52 | # Copy all task files to the staging output path 53 | rsync -avr \ 54 | --include=.bin/*** \ 55 | --include=node_modules/*** \ 56 | --include=package.json \ 57 | --include=package-lock.json \ 58 | --include=task.json \ 59 | --include=icon.png \ 60 | --exclude=* \ 61 | ./ $(Build.StagingDirectory)/Tasks/${{ parameters.taskName }} 62 | workingDirectory: ./${{ parameters.tasksDirectory }}/${{ parameters.taskName }} 63 | displayName: Package the ${{ parameters.taskName }} task for release 64 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | trigger: 2 | batch: true 3 | branches: 4 | include: 5 | - master 6 | paths: 7 | exclude: 8 | - tests/* 9 | - readme.md 10 | 11 | pr: 12 | autoCancel: true 13 | 14 | # Build and Test 15 | stages: 16 | - stage: Build 17 | displayName: Build and Test 18 | jobs: 19 | - template: azure-pipelines-extension-build.yml 20 | parameters: 21 | publisher: jlorich 22 | tasks: 23 | - TerraformInstaller 24 | - Terraform 25 | - TerraformEnterprise 26 | 27 | # Staging Extension release 28 | - stage: Release 29 | jobs: 30 | - template: azure-pipelines-extension-release.yml 31 | parameters: 32 | serviceUrl: https://dev.azure.com/jlorich 33 | -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/azure-pipelines-extension-terraform/1f7b31dbc2ae32cb9b700b6f048ed7eb9ebddbaa/images/icon.png -------------------------------------------------------------------------------- /tests/azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | trigger: 2 | batch: true 3 | branches: 4 | include: 5 | - master 6 | paths: 7 | include: 8 | - tests/* 9 | 10 | stages: 11 | - stage: Test 12 | jobs: 13 | - template: test-init-validate-plan-apply-destroy.yml 14 | - template: test-cli.yml 15 | - template: test-installer.yml 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/test-cli.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | # Validates a valid template 3 | - job: CLI 4 | displayName: Test CLI 5 | dependsOn: 6 | pool: 7 | vmImage: 'Ubuntu-16.04' 8 | 9 | steps: 10 | - task: terraformInstaller@0 11 | inputs: 12 | terraformVersion: '0.12.13' 13 | 14 | - task: terraform@0 15 | inputs: 16 | command: 'CLI' 17 | providerAzureConnectedServiceName: 'MTC Denver Sandbox' 18 | backendAzureProviderStorageAccountName: 'mtcdenterraformsandbox' 19 | backendAzureStateFileKey: 'tfclitest.tfstate' 20 | cwd: tests/test-template-azure 21 | script: | 22 | # Validate 23 | terraform validate 24 | 25 | # Plan 26 | terraform plan -input=false -out=testplan.tf 27 | 28 | displayName: Run Terraform CLI 29 | 30 | -------------------------------------------------------------------------------- /tests/test-init-validate-plan-apply-destroy.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | # Validates a valid template 3 | - job: InitPlanApplyDestroy 4 | displayName: Test init, plan, apply, and destroy 5 | dependsOn: 6 | pool: 7 | vmImage: 'Ubuntu-16.04' 8 | 9 | steps: 10 | - task: terraform@0 11 | inputs: 12 | command: 'init' 13 | cwd: 'tests/test-template-azure' 14 | providerAzureConnectedServiceName: 'MTC Denver Sandbox' 15 | backendAzureProviderStorageAccountName: 'mtcdenterraformsandbox' 16 | displayName: Terraform Init 17 | 18 | - task: terraform@0 19 | inputs: 20 | command: 'validate' 21 | cwd: 'tests/test-template-azure/' 22 | displayName: 'Terraform Validate' 23 | 24 | - task: terraform@0 25 | inputs: 26 | command: 'plan' 27 | cwd: 'tests/test-template-azure' 28 | providerAzureConnectedServiceName: 'MTC Denver Sandbox' 29 | displayName: Terraform Plan 30 | 31 | - task: terraform@0 32 | inputs: 33 | command: 'apply' 34 | cwd: 'tests/test-template-azure' 35 | providerAzureConnectedServiceName: 'MTC Denver Sandbox' 36 | displayName: Terraform Apply 37 | 38 | - task: terraform@0 39 | inputs: 40 | command: 'destroy' 41 | cwd: 'tests/test-template-azure' 42 | providerAzureConnectedServiceName: 'MTC Denver Sandbox' 43 | displayName: Terraform Destroy -------------------------------------------------------------------------------- /tests/test-installer.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | # Validates a valid template 3 | - job: Installer 4 | displayName: Test Installer 5 | dependsOn: 6 | pool: 7 | vmImage: 'Ubuntu-16.04' 8 | 9 | steps: 10 | # Run the Terraform installer. It self-validates so no need to check beyond this 11 | - task: terraformInstaller@0 12 | inputs: 13 | terraformVersion: '0.12.3' 14 | displayName: Install Terraform 15 | -------------------------------------------------------------------------------- /tests/test-template-azure/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "azurerm" {} 3 | } 4 | 5 | variable "environment" { 6 | default = "test" 7 | } 8 | 9 | 10 | provider "azurerm" { 11 | version = "=1.35.0" 12 | } 13 | 14 | resource "azurerm_resource_group" "test" { 15 | name = "testResourceGroup1" 16 | location = "West US" 17 | } -------------------------------------------------------------------------------- /tests/test-template-azure/output.plan: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/azure-pipelines-extension-terraform/1f7b31dbc2ae32cb9b700b6f048ed7eb9ebddbaa/tests/test-template-azure/output.plan -------------------------------------------------------------------------------- /tests/test-template-no-backend/main.tf: -------------------------------------------------------------------------------- 1 | provider "azurerm" { 2 | 3 | } 4 | 5 | resource "azurerm_resource_group" "test" { 6 | name = "testResourceGroup1" 7 | location = "West US" 8 | } -------------------------------------------------------------------------------- /tests/test-template-remote/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "remote" { 3 | hostname = "app.terraform.io" 4 | organization = "msus" 5 | 6 | workspaces { 7 | name = "tfcloud-test" 8 | } 9 | } 10 | } 11 | 12 | provider "azurerm" { 13 | version = "=1.35.0" 14 | } 15 | 16 | resource "azurerm_resource_group" "test" { 17 | name = "testResourceGroup1" 18 | location = "West US" 19 | } -------------------------------------------------------------------------------- /vss-extension.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifestVersion": 1, 3 | "id": "terraform-pipelines-extension-dev", 4 | "name": "Terraform", 5 | "version": "0.0.1", 6 | "publisher": "hashicorp", 7 | "targets": [ 8 | { 9 | "id": "Microsoft.VisualStudio.Services" 10 | } 11 | ], 12 | "description": "Task for running Terraform on Azure, AWS, GCP, and Terraform Cloud/Enterprise", 13 | "categories": [ 14 | "Azure Pipelines" 15 | ], 16 | "Tags": [ 17 | "Vault", 18 | "HashiCorp", 19 | "Secrets", 20 | "Release", 21 | "DevOps" 22 | ], 23 | "icons": { 24 | "default": "images/icon.png", 25 | "large": "images/icon.png" 26 | }, 27 | "files": [ 28 | { 29 | "path": "Tasks/TerraformInstaller" 30 | }, 31 | { 32 | "path": "Tasks/Terraform" 33 | }, 34 | { 35 | "path": "Tasks/TerraformEnterprise" 36 | } 37 | ], 38 | "contributions": [ 39 | { 40 | "id": "9b9f2e72-30c3-432b-a835-dd2b1160f3b5", 41 | "type": "ms.vss-distributed-task.task", 42 | "targets": [ 43 | "ms.vss-distributed-task.tasks" 44 | ], 45 | "properties": { 46 | "name": "Tasks/TerraformInstaller" 47 | } 48 | }, 49 | { 50 | "id": "653507a3-d921-47dc-8fed-c5649d8e9e0e", 51 | "type": "ms.vss-distributed-task.task", 52 | "targets": [ 53 | "ms.vss-distributed-task.tasks" 54 | ], 55 | "properties": { 56 | "name": "Tasks/Terraform" 57 | } 58 | }, 59 | { 60 | "id": "506f3bf4-69c9-4812-a879-ccfc035f174c", 61 | "type": "ms.vss-distributed-task.task", 62 | "targets": [ 63 | "ms.vss-distributed-task.tasks" 64 | ], 65 | "properties": { 66 | "name": "Tasks/TerraformEnterprise" 67 | } 68 | }, 69 | { 70 | 71 | "id": "terraform-cloud-endpoint-type", 72 | "description": "Credentials for tasks invoking Terraform Cloud and Enterprise", 73 | "type": "ms.vss-endpoint.service-endpoint-type", 74 | "targets": [ 75 | "ms.vss-endpoint.endpoint-types" 76 | ], 77 | "properties": { 78 | "name": "terraformServiceEndpoint", 79 | "displayName": "Terraform Cloud and Enterprise", 80 | "authenticationSchemes": [ 81 | { 82 | "id": "endpoint-auth-scheme-token", 83 | "name": "token", 84 | "displayName": "i18n:Access Token", 85 | "type": "ms.vss-endpoint.endpoint-auth-scheme-token", 86 | "description": "i18n:Token based endpoint authentication scheme", 87 | "inputDescriptors": [ 88 | { 89 | "id": "apitoken", 90 | "name": "i18n:Token", 91 | "description": "i18n:Token to use when accessing Terraform cloud and Enterprise", 92 | "inputMode": "textbox", 93 | "isConfidential": true, 94 | "validation": { 95 | "isRequired": true, 96 | "dataType": "string", 97 | "maxLength": 300 98 | } 99 | } 100 | ] 101 | } 102 | ], 103 | "url": { 104 | "displayName": "i18n:Terraform Instance URL", 105 | "required": true, 106 | "value": "https://app.terraform.io", 107 | "helpText": "i18n:Client connection endpoint for the Terraform Cloud or Enterprise instance. Prefix the value with \"https://\"." 108 | } 109 | } 110 | } 111 | ] 112 | } --------------------------------------------------------------------------------