├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .nvmrc ├── .travis.yml ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── EULA.rtf ├── LICENSE ├── README.md ├── SECURITY.md ├── images ├── overview.png └── terraform.png ├── package-lock.json ├── package.json ├── src ├── auth.ts ├── azure-account.api.d.ts ├── baseShell.ts ├── build │ └── downloader.ts ├── clientHandler.ts ├── cloudFile.ts ├── cloudShell.ts ├── constants.ts ├── extension.ts ├── integratedShell.ts ├── serverPath.ts ├── shared.ts ├── survey.ts ├── telemetry.ts ├── terraformChannel.ts ├── terraformExport.ts ├── terraformShellManager.ts ├── test │ ├── helper.ts │ ├── integration │ │ ├── completion.test.ts │ │ └── index.ts │ └── runTest.ts ├── types.ts ├── utils │ ├── cloudShellUtils.ts │ ├── cpUtils.ts │ ├── dockerUtils.ts │ ├── dotUtils.ts │ ├── icon.ts │ ├── settingUtils.ts │ ├── terraformUtils.ts │ ├── uiUtils.ts │ └── workspaceUtils.ts └── vscodeUtils.ts ├── testFixture ├── properties-completion.tf └── templates-completion.tf ├── thirdpartynotices.txt ├── tsconfig.json └── tslint.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | out 3 | src/test 4 | testFixture 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { "browser": true, "es6": true, "node": true }, 3 | "root": true, 4 | "parser": "@typescript-eslint/parser", 5 | "plugins": ["@typescript-eslint", "prettier"], 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/eslint-recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:prettier/recommended", 11 | "prettier" 12 | ], 13 | "rules": { 14 | "@typescript-eslint/no-explicit-any": ["warn", { "ignoreRestArgs": true }] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 3 15 | steps: 16 | - name: Checkout Repo 17 | uses: actions/checkout@v2 18 | - uses: actions/setup-node@v2 19 | with: 20 | node-version-file: '.nvmrc' 21 | - name: npm install 22 | run: npm install 23 | - name: lint 24 | run: npm run lint 25 | 26 | test: 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | vscode: 31 | - 'stable' 32 | os: 33 | - ubuntu-latest 34 | runs-on: ${{ matrix.os }} 35 | timeout-minutes: 10 36 | 37 | steps: 38 | - name: Checkout Repo 39 | uses: actions/checkout@v2 40 | - uses: actions/setup-node@v2 41 | with: 42 | node-version-file: '.nvmrc' 43 | - name: Set up Xvfb (Ubuntu) 44 | run: | 45 | /usr/bin/Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & 46 | echo ">>> Started xvfb" 47 | if: matrix.os == 'ubuntu-latest' 48 | - name: Clean Install Dependencies 49 | run: npm ci 50 | - name: Run Tests 51 | run: npm test 52 | env: 53 | CI: true 54 | DISPLAY: ':99.0' 55 | VSCODE_VERSION: ${{ matrix.vscode }} 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | .vscode-test/ 4 | *.vsix 5 | .idea 6 | 7 | 8 | bin 9 | ms-terraform-lsp 10 | ms-terraform-lsp.exe 11 | .DS_Store 12 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 23.3.0 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 'stable' 5 | 6 | dist: xenial 7 | addons: 8 | apt: 9 | packages: 10 | - libsecret-1-dev 11 | sudo: required 12 | services: 13 | - xvfb 14 | 15 | before_install: 16 | - export CXX="g++-4.9" CC="gcc-4.9" DISPLAY=:99.0; 17 | 18 | install: 19 | - npm install -g vsce 20 | - npm install 21 | 22 | script: 23 | - npm run lint 24 | - npm run test 25 | - vsce package 26 | 27 | notifications: 28 | email: 29 | on_success: never 30 | on_failure: always -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | { 3 | "version": "0.1.0", 4 | "configurations": [ 5 | { 6 | "name": "Extension", 7 | "type": "extensionHost", 8 | "request": "launch", 9 | "runtimeExecutable": "${execPath}", 10 | "args": ["--extensionDevelopmentPath=${workspaceRoot}" ], 11 | "stopOnEntry": false, 12 | "sourceMaps": true, 13 | "outFiles": [ "${workspaceRoot}/out/**/*.js" ], 14 | "preLaunchTask": "npm: watch" 15 | }, 16 | { 17 | "name": "Extension Tests", 18 | "type": "extensionHost", 19 | "request": "launch", 20 | "runtimeExecutable": "${execPath}", 21 | "args": ["--extensionDevelopmentPath=${workspaceRoot}", "--extensionTestsPath=${workspaceRoot}/out/test" ], 22 | "stopOnEntry": false, 23 | "sourceMaps": true, 24 | "outFiles": [ "${workspaceRoot}/out/test/**/*.js" ], 25 | "preLaunchTask": "npm: watch" 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | } 9 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | out/test/** 4 | src/** 5 | **/*.map 6 | .gitignore 7 | .travis.yml 8 | package-lock.json 9 | tsconfig.json 10 | tslint.json 11 | EULA.rtf -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to the "Microsoft Terraform" extension will be documented in this file. 4 | 5 | Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. 6 | 7 | ## [0.3.3] 8 | ### Changed 9 | - Removed Azure Account extension dependency 10 | - [#254](https://github.com/Azure/vscode-azureterraform/pull/254) 11 | - CloudShell functionality is temporarily unavailable 12 | - Default terminal setting now falls back to integrated terminal when CloudShell is selected 13 | 14 | ## [0.3.2] 15 | ### Fixed 16 | - fix dependency issues 17 | - [#212](https://github.com/Azure/vscode-azureterraform/issues/212) 18 | - [#222](https://github.com/Azure/vscode-azureterraform/issues/222) 19 | 20 | ## [0.3.1] 21 | ### Fixed 22 | - fix some security issues 23 | - [#220](https://github.com/Azure/vscode-azureterraform/pull/220) 24 | 25 | ## [0.3.0] 26 | ### Fixed 27 | - fix some bugs and improve user experience 28 | - [#139](https://github.com/Azure/vscode-azureterraform/issues/139) 29 | - [#179](https://github.com/Azure/vscode-azureterraform/issues/179) 30 | - [#181](https://github.com/Azure/vscode-azureterraform/issues/181) 31 | - [#203](https://github.com/Azure/vscode-azureterraform/issues/203) 32 | 33 | ## [0.2.0] 34 | ### Added 35 | - Support linting and end to end test for terraform module. ([#166](https://github.com/Azure/vscode-azureterraform/issues/166)) 36 | 37 | ### Changed 38 | - Combine push command with init, plan, apply and validate. ([#148](https://github.com/Azure/vscode-azureterraform/issues/148)) 39 | 40 | ## [0.1.1] 41 | ### Changed 42 | - Leverage Azure Account extension to provision Cloud Shell. ([#145](https://github.com/Azure/vscode-azureterraform/issues/145)) 43 | 44 | ### Fixed 45 | - Fix the Cloud Shell cannot be connected error if last session is closed because of socket timeout. ([#144](https://github.com/Azure/vscode-azureterraform/issues/144)) 46 | 47 | ## [0.1.0] 48 | ### Added 49 | - Support Terraform commands: init, plan, apply, validate, refresh and destroy. 50 | - Support visualizing the terraform module. 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. All rights reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Microsoft Terraform 2 | 3 | The VSCode Microsoft Terraform extension is designed to increase developer productivity authoring, testing and using Terraform with Azure. The extension provides terraform command support and resource graph visualization. 4 | 5 | ![overview](https://raw.githubusercontent.com/Azure/vscode-azureterraform/master/images/overview.png) 6 | 7 | ## Important Note 8 | ⚠️ **CloudShell integration is currently unavailable** in this version of the extension. All Terraform commands will run in your local integrated terminal. 9 | 10 | ## Requirements 11 | 12 | This extension requires: 13 | 14 | - [Terraform](https://www.terraform.io/downloads.html) - Required for executing terraform commands 15 | - [GraphViz](http://www.graphviz.org) - Required for the visualize feature 16 | 17 | > NOTE: Please make sure these requirements are in your PATH environment variable. 18 | 19 | ## Features 20 | 21 | This extension supports the following features: 22 | 23 | - Terraform commands: init, plan, apply, validate, refresh and destroy 24 | - Visualize the terraform module 25 | 26 | ## Commands 27 | 28 | Open the Command Palette (`Command`+`Shift`+`P` on macOS and `Ctrl`+`Shift`+`P` on Windows/Linux) and type in one of the following commands: 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 50 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 69 | 70 | 71 |
CommandDescription
40 | Basic commands:
41 |
    42 |
  • Microsoft Terraform: init
  • 43 |
  • Microsoft Terraform: plan
  • 44 |
  • Microsoft Terraform: apply
  • 45 |
  • Microsoft Terraform: validate
  • 46 |
  • Microsoft Terraform: refresh
  • 47 |
  • Microsoft Terraform: destroy
  • 48 |
49 |
51 | Execute terraform command against the current project workspace. 52 |

53 | Note: All commands run in your local integrated terminal. 54 |
Microsoft Terraform: visualizeCreate a visual representation of the components of the module and save it in graph.png.
Microsoft Terraform: Execute Test 63 | Run one of the following test against the current module using a test container:
64 |
    65 |
  • lint: This command will check the formating of the code of the Terraform module.
  • 66 |
  • e2e: This command will deploy the current module with the settings specified in the .tfvars file, verify that the deployment pass the controls and destroy the resources that have been created.
  • 67 |
68 |
72 | 73 | ## Extension Settings 74 | 75 | - `azureTerraform.terminal` - Specifies terminal used to run Terraform commands. Currently only `integrated` terminal is supported. 76 | - `azureTerraform.test.imageName` - Indicates the container to use to run the tests. By default: `microsoft/terraform-test`. 77 | - `azureTerraform.test.aciName` - Indicates the name of the Azure Container Instance to use for testing. By default: `tf-test-aci`. 78 | - `azureTerraform.test.aciResourceGroup` - Indicates the name of the Resource Group to use for the ACI instance. By default: `tfTestResourceGroup`. 79 | - `azureTerraform.aciContainerGroup` - Indicates the name of the Container Group that host the ACI instance. By default: `tfTestContainerGroup` 80 | - `azureTerraform.test.location` - Indicates the location where to deploy the test container instance. By default: `westus`. 81 | 82 | ## Release Notes 83 | 84 | Refer to [CHANGELOG](CHANGELOG.md) 85 | 86 | ## Telemetry 87 | VS Code collects usage data and sends it to Microsoft to help improve our products and services. Read our [privacy statement](https://go.microsoft.com/fwlink/?LinkID=528096&clcid=0x409) to learn more. If you would like to opt out of sending telemetry data to Microsoft, update the `telemetry.enableTelemetry` setting to `false` in the **File** -> **Preferences** -> **Settings**. Read our [FAQ](https://code.visualstudio.com/docs/supporting/faq#_how-to-disable-telemetry-reporting) to learn more. 88 | 89 | ## License 90 | [MIT](LICENSE.md) 91 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /images/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure/vscode-azureterraform/e186f58870c64f01156e7f7280f31f52da52525b/images/overview.png -------------------------------------------------------------------------------- /images/terraform.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure/vscode-azureterraform/e186f58870c64f01156e7f7280f31f52da52525b/images/terraform.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vscode-azureterraform", 3 | "displayName": "Microsoft Terraform", 4 | "description": "VS Code extension for developing with Terraform on Azure", 5 | "version": "0.3.5", 6 | "publisher": "ms-azuretools", 7 | "aiKey": "ae482601-060f-4c71-8567-ebd5085483c9", 8 | "appInsightsConnectionString": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255", 9 | "icon": "images/terraform.png", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/Azure/vscode-azureterraform.git" 13 | }, 14 | "license": "MIT", 15 | "engines": { 16 | "vscode": "^1.82.0" 17 | }, 18 | "preview": true, 19 | "categories": [ 20 | "Azure", 21 | "Other" 22 | ], 23 | "keywords": [ 24 | "cloudshell", 25 | "devops", 26 | "terraform", 27 | "azure" 28 | ], 29 | "activationEvents": [ 30 | "workspaceContains:**/*.tf", 31 | "onCommand:azureTerraform.init", 32 | "onCommand:azureTerraform.plan", 33 | "onCommand:azureTerraform.apply", 34 | "onCommand:azureTerraform.validate", 35 | "onCommand:azureTerraform.refresh", 36 | "onCommand:azureTerraform.destroy", 37 | "onCommand:azureTerraform.visualize", 38 | "onCommand:azureTerraform.exectest", 39 | "onCommand:azureTerraform.push", 40 | "onCommand:azureTerraform.showSurvey", 41 | "onCommand:azureTerraform.exportResource" 42 | ], 43 | "main": "./out/extension", 44 | "contributes": { 45 | "languages": [ 46 | { 47 | "id": "terraform", 48 | "aliases": [ 49 | "Terraform", 50 | "terraform" 51 | ], 52 | "extensions": [ 53 | ".tf" 54 | ], 55 | "configuration": "./language-configuration.json" 56 | } 57 | ], 58 | "configuration": { 59 | "title": "Microsoft Terraform", 60 | "properties": { 61 | "azureTerraform.terminal": { 62 | "type": "string", 63 | "default": "integrated", 64 | "enum": [ 65 | "cloudshell", 66 | "integrated" 67 | ], 68 | "description": "Specifies terminal used to run Terraform commands. Valid setting is `integrated`." 69 | }, 70 | "azureTerraform.checkTerraformCmd": { 71 | "type": "boolean", 72 | "default": "true", 73 | "description": "Specifies whether or not check terraform installed in the PATH." 74 | }, 75 | "azureTerraform.files": { 76 | "type": "string", 77 | "default": "**/*.{rb,sh,tf,tfvars,txt,yml}", 78 | "description": "Indicates the files that should be synchronized to Azure Cloud Shell using the glob pattern string. By default: '**/*.{tf,txt,yml,tfvars,rb}'." 79 | }, 80 | "azureTerraform.test.imageName": { 81 | "type": "string", 82 | "default": "microsoft/terraform-test", 83 | "description": "Indicates the container that you want to use to run the tests. By default: 'microsoft/terraform-test'." 84 | }, 85 | "azureTerraform.test.aciName": { 86 | "type": "string", 87 | "default": "tf-test-aci", 88 | "description": "Indicates the name of the Azure Container Instance to use for testing. By default: 'tf-test-aci'." 89 | }, 90 | "azureTerraform.test.aciResourceGroup": { 91 | "type": "string", 92 | "default": "tfTestResourceGroup", 93 | "description": "Indicates the name of the Resource Group to use for the ACI instance. By default: 'tfTestResourceGroup'." 94 | }, 95 | "azureTerraform.aciContainerGroup": { 96 | "type": "string", 97 | "default": "tfTestContainerGroup", 98 | "description": "Indicates the name of the Container Group that host the ACI instance. By default: 'tfTestContainerGroup'." 99 | }, 100 | "azureTerraform.test.location": { 101 | "type": "string", 102 | "default": "westus", 103 | "description": "Indicates the location where to deploy the test container instance. By default: 'westus'." 104 | }, 105 | "azureTerraform.survey": { 106 | "surveyPromptDate": { 107 | "type": "string", 108 | "default": "none", 109 | "description": "Date of the AzureRM survey will be prompted to the user" 110 | }, 111 | "surveyPromptIgnoredCount": { 112 | "type": "number", 113 | "default": 0, 114 | "description": "Number of times the survey prompt has been ignored" 115 | } 116 | }, 117 | "azureTerraform.languageServer": { 118 | "type": "object", 119 | "description": "Language Server settings", 120 | "properties": { 121 | "external": { 122 | "type": "boolean", 123 | "default": true, 124 | "description": "Whether an external language server binary should be launched." 125 | }, 126 | "pathToBinary": { 127 | "scope": "resource", 128 | "type": "string", 129 | "default": "", 130 | "description": "Path to language server binary (optional)" 131 | }, 132 | "args": { 133 | "scope": "resource", 134 | "type": "array", 135 | "default": [ 136 | "serve" 137 | ], 138 | "description": "Arguments to pass to language server binary" 139 | }, 140 | "trace.server": { 141 | "scope": "window", 142 | "type": "string", 143 | "enum": [ 144 | "off", 145 | "messages", 146 | "verbose" 147 | ], 148 | "default": "off", 149 | "description": "Traces the communication between VS Code and the language server." 150 | } 151 | }, 152 | "default": { 153 | "external": true, 154 | "pathToBinary": "", 155 | "args": [ 156 | "serve" 157 | ], 158 | "trace.server": "off" 159 | } 160 | } 161 | } 162 | }, 163 | "commands": [ 164 | { 165 | "command": "azureTerraform.plan", 166 | "title": "Plan", 167 | "category": "Microsoft Terraform" 168 | }, 169 | { 170 | "command": "azureTerraform.apply", 171 | "title": "Apply", 172 | "category": "Microsoft Terraform" 173 | }, 174 | { 175 | "command": "azureTerraform.init", 176 | "title": "Init", 177 | "category": "Microsoft Terraform" 178 | }, 179 | { 180 | "command": "azureTerraform.validate", 181 | "title": "Validate", 182 | "category": "Microsoft Terraform" 183 | }, 184 | { 185 | "command": "azureTerraform.refresh", 186 | "title": "Refresh", 187 | "category": "Microsoft Terraform" 188 | }, 189 | { 190 | "command": "azureTerraform.destroy", 191 | "title": "Destroy", 192 | "category": "Microsoft Terraform" 193 | }, 194 | { 195 | "command": "azureTerraform.visualize", 196 | "title": "Visualize", 197 | "category": "Microsoft Terraform" 198 | }, 199 | { 200 | "command": "azureTerraform.exectest", 201 | "title": "Execute Test", 202 | "category": "Microsoft Terraform" 203 | }, 204 | { 205 | "command": "azureTerraform.push", 206 | "title": "Push", 207 | "category": "Microsoft Terraform" 208 | }, 209 | { 210 | "command": "azureTerraform.showSurvey", 211 | "title": "Microsoft Terraform: Show Survey" 212 | }, 213 | { 214 | "command": "azureTerraform.exportResource", 215 | "title": "Export Azure Resource as Terraform", 216 | "category": "Microsoft Terraform" 217 | }, 218 | { 219 | "command": "azureTerraform.enableLanguageServer", 220 | "title": "Microsoft Terraform: Enable Language Server" 221 | }, 222 | { 223 | "command": "azureTerraform.disableLanguageServer", 224 | "title": "Microsoft Terraform: Disable Language Server" 225 | } 226 | ] 227 | }, 228 | "scripts": { 229 | "esbuild-base": "esbuild ./src/extension.ts --bundle --outfile=out/extension.js --external:vscode --format=cjs --platform=node", 230 | "esbuild": "npm run esbuild-base -- --sourcemap", 231 | "esbuild-watch": "npm run esbuild-base -- --sourcemap --watch", 232 | "compile": "npm run esbuild", 233 | "watch": "npm run download:ls && npm run esbuild-watch", 234 | "download:ls": "ts-node ./src/build/downloader.ts", 235 | "vscode:prepublish": "npm run download:ls && npm run esbuild-base -- --minify", 236 | "package": "vsce package", 237 | "test-compile": "tsc -p ./", 238 | "pretest": "npm run download:ls && npm run test-compile && npm run lint", 239 | "test": "npm run compile && node ./out/test/runTest.js", 240 | "lint": "eslint src --ext ts", 241 | "prettier": "prettier \"./src/*.+(js|json|ts)\" --fix", 242 | "format": "npm run prettier -- --write", 243 | "check-format": "npm run prettier -- --check", 244 | "preview": "ts-node ./build/preview.ts" 245 | }, 246 | "devDependencies": { 247 | "@types/fs-extra": "^5.0.0", 248 | "@types/keytar": "^4.0.1", 249 | "@types/lodash": "^4.14.121", 250 | "@types/mocha": "^5.2.7", 251 | "@types/node": "^7.0.43", 252 | "@types/opn": "^5.1.0", 253 | "@types/request-promise-native": "^1.0.21", 254 | "@types/semver": "^5.4.0", 255 | "@types/unzip-stream": "^0.3.1", 256 | "@types/vscode": "^1.82.0", 257 | "@vscode/test-electron": "^2.4.1", 258 | "diff": ">=3.5.0", 259 | "mocha": "^11.1.0", 260 | "ts-node": "^10.4.0", 261 | "tslint": "^5.20.1", 262 | "typescript": "^4.5.4", 263 | "unzip-stream": "^0.3.1", 264 | "vscode-test": "^1.3.0" 265 | }, 266 | "dependencies": { 267 | "@azure/arm-resources": "^6.1.0", 268 | "@azure/identity": "^4.9.1", 269 | "@microsoft/vscode-azext-azureauth": "^4.1.1", 270 | "@types/chai": "^4.2.22", 271 | "@types/mocha": "^9.0.0", 272 | "@typescript-eslint/eslint-plugin": "^3.9.0", 273 | "@typescript-eslint/parser": "^3.9.0", 274 | "@vscode/extension-telemetry": "^1.0.0", 275 | "axios": "^1.9.0", 276 | "azure-storage": "^2.10.1", 277 | "chai": "^4.3.4", 278 | "esbuild": "^0.25.2", 279 | "eslint": "^7.32.0", 280 | "eslint-config-prettier": "^8.3.0", 281 | "eslint-plugin-prettier": "^3.4.1", 282 | "fs-extra": "^4.0.2", 283 | "lodash": "^4.17.21", 284 | "mocha": "^11.0.1", 285 | "opn": "5.1.0", 286 | "prettier": "^2.3.2", 287 | "request-promise-native": "^1.0.9", 288 | "vscode-extension-telemetry-wrapper": "^0.8.0", 289 | "vscode-languageclient": "^9.0.1" 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GenericResourceExpanded, 3 | ResourceGroup, 4 | ResourceManagementClient, 5 | } from "@azure/arm-resources"; 6 | import { AccessToken, TokenCredential } from "@azure/core-auth"; 7 | import { 8 | AzureSubscription, 9 | VSCodeAzureSubscriptionProvider, 10 | } from "@microsoft/vscode-azext-azureauth"; 11 | import * as vscode from "vscode"; 12 | 13 | let currentSubscription: AzureSubscription | undefined; 14 | 15 | export async function selectSubscription(): Promise< 16 | AzureSubscription | undefined 17 | > { 18 | const provider = new VSCodeAzureSubscriptionProvider(); 19 | 20 | let newlySelectedSubscription: AzureSubscription | undefined; 21 | 22 | await vscode.window.withProgress( 23 | { 24 | location: vscode.ProgressLocation.Notification, 25 | title: "Azure Sign-in & Subscription Selection", 26 | cancellable: false, 27 | }, 28 | async (progress) => { 29 | progress.report({ message: "Checking Azure sign-in status..." }); 30 | let isSignedIn = await provider.isSignedIn(); 31 | if (!isSignedIn) { 32 | progress.report({ message: "Attempting Azure sign-in..." }); 33 | try { 34 | await provider.signIn(); 35 | isSignedIn = await provider.isSignedIn(); 36 | if (!isSignedIn) { 37 | vscode.window.showErrorMessage( 38 | "Azure sign-in failed or was cancelled." 39 | ); 40 | return; 41 | } 42 | progress.report({ 43 | message: "Sign-in successful. Fetching subscriptions...", 44 | }); 45 | } catch (error) { 46 | vscode.window.showErrorMessage( 47 | `Azure sign-in error: ${error.message || error}` 48 | ); 49 | return; 50 | } 51 | progress.report({ message: "Fetching subscriptions..." }); 52 | } else { 53 | progress.report({ 54 | message: "Already signed in. Fetching subscriptions...", 55 | }); 56 | } 57 | 58 | let subscriptions: AzureSubscription[] | undefined; 59 | try { 60 | subscriptions = await provider.getSubscriptions(false); 61 | } catch (error) { 62 | vscode.window.showErrorMessage( 63 | `Error fetching subscriptions: ${error.message || error}` 64 | ); 65 | return; 66 | } 67 | 68 | if (!subscriptions || subscriptions.length === 0) { 69 | vscode.window.showErrorMessage( 70 | "No Azure subscriptions found for this account." 71 | ); 72 | return; 73 | } 74 | 75 | progress.report({ message: "Waiting for subscription selection..." }); 76 | 77 | const subPicks: Array< 78 | vscode.QuickPickItem & { 79 | subscription: AzureSubscription | null; 80 | isSignOut: boolean; 81 | } 82 | > = subscriptions.map((sub) => ({ 83 | label: `$(azure) ${sub.name}`, 84 | description: sub.subscriptionId, 85 | detail: `$(key) Tenant: ${sub.tenantId} (${sub.environment.name})`, 86 | subscription: sub, 87 | isSignOut: false, 88 | })); 89 | 90 | subPicks.unshift({ 91 | label: "$(sign-out) Sign into another account", 92 | description: "", 93 | subscription: null, 94 | isSignOut: true, 95 | }); 96 | 97 | const selectedSubPick = await vscode.window.showQuickPick(subPicks, { 98 | placeHolder: "Select the Azure subscription", 99 | matchOnDescription: true, 100 | matchOnDetail: true, 101 | ignoreFocusOut: true, 102 | }); 103 | 104 | if (!selectedSubPick) { 105 | vscode.window.showWarningMessage("Subscription selection cancelled."); 106 | return; 107 | } 108 | 109 | if (selectedSubPick.isSignOut) { 110 | vscode.window.showInformationMessage("Signing out of Azure account..."); 111 | currentSubscription = undefined; 112 | await selectSubscription(); 113 | return; 114 | } 115 | 116 | newlySelectedSubscription = selectedSubPick.subscription; 117 | } 118 | ); 119 | 120 | if (newlySelectedSubscription) { 121 | currentSubscription = newlySelectedSubscription; 122 | vscode.window.showInformationMessage( 123 | `Using Tenant: ${currentSubscription.tenantId}, Subscription: ${currentSubscription.subscriptionId}` 124 | ); 125 | return currentSubscription; 126 | } else { 127 | return undefined; 128 | } 129 | } 130 | 131 | export async function listAzureResourcesGrouped( 132 | credential: TokenCredential, 133 | subscriptionId: string 134 | ): Promise> { 135 | const groupedResources = new Map(); 136 | 137 | await vscode.window.withProgress( 138 | { 139 | location: vscode.ProgressLocation.Notification, 140 | title: "Listing Azure Resources", 141 | cancellable: false, // Listing isn't easily cancellable mid-flight 142 | }, 143 | async (progress) => { 144 | try { 145 | const resourceClient = new ResourceManagementClient( 146 | credential, 147 | subscriptionId 148 | ); 149 | 150 | // 1. List all resource groups 151 | progress.report({ message: "Fetching resource groups..." }); 152 | const resourceGroups: ResourceGroup[] = []; 153 | for await (const page of resourceClient.resourceGroups 154 | .list() 155 | .byPage()) { 156 | resourceGroups.push(...page); 157 | } 158 | 159 | if (resourceGroups.length === 0) { 160 | progress.report({ 161 | message: "No resource groups found in the selected subscription.", 162 | increment: 100, 163 | }); 164 | return; 165 | } 166 | 167 | // 2. For each group, list resources within it 168 | const totalGroups = resourceGroups.length; 169 | let groupsProcessed = 0; 170 | progress.report({ 171 | message: `Found ${totalGroups} resource groups. Fetching resources...`, 172 | }); 173 | 174 | const promises = resourceGroups.map(async (group) => { 175 | if (group.name) { 176 | try { 177 | const resourcesInGroup: GenericResourceExpanded[] = []; 178 | for await (const page of resourceClient.resources 179 | .listByResourceGroup(group.name) 180 | .byPage()) { 181 | resourcesInGroup.push(...page); 182 | } 183 | // Use a temporary map to avoid race conditions on the shared map 184 | if (resourcesInGroup.length > 0) { 185 | return { groupName: group.name, resources: resourcesInGroup }; 186 | } 187 | } catch (listError) { 188 | console.error( 189 | `Error listing resources in group ${group.name}:`, 190 | listError 191 | ); 192 | vscode.window.showWarningMessage( 193 | `Could not list resources in group '${group.name}'. Error: ${listError.message}. See console for details.` 194 | ); 195 | } 196 | } 197 | return null; 198 | }); 199 | 200 | // Process results concurrently 201 | const results = await Promise.all(promises); 202 | 203 | // Populate the final map and update progress 204 | results.forEach((result) => { 205 | if (result) { 206 | groupedResources.set(result.groupName, result.resources); 207 | } 208 | groupsProcessed++; 209 | const progressPercentage = (groupsProcessed / totalGroups) * 100; 210 | // Update progress less frequently to avoid flickering 211 | if (groupsProcessed % 5 === 0 || groupsProcessed === totalGroups) { 212 | progress.report({ 213 | message: `Processing resource groups (${groupsProcessed}/${totalGroups})...`, 214 | increment: progressPercentage, 215 | }); 216 | } 217 | }); 218 | 219 | progress.report({ 220 | message: "Finished listing resources.", 221 | increment: 100, 222 | }); 223 | } catch (error) { 224 | vscode.window.showErrorMessage( 225 | `Failed to list Azure resources by group: ${error.message}` 226 | ); 227 | console.error("Resource Group Listing Error:", error); 228 | } 229 | } 230 | ); 231 | 232 | return groupedResources; 233 | } 234 | 235 | export async function getAccessTokenFromSubscription( 236 | subscription: AzureSubscription, 237 | scope: string 238 | ): Promise { 239 | if (!subscription) { 240 | vscode.window.showErrorMessage( 241 | "Cannot get access token: No Azure subscription provided." 242 | ); 243 | return undefined; 244 | } 245 | try { 246 | // Use the credential from the specific subscription object 247 | return await subscription.credential.getToken(scope); 248 | } catch (error) { 249 | vscode.window.showErrorMessage( 250 | `Failed to get access token for subscription '${subscription.name}': ${error.message}` 251 | ); 252 | console.error("Access Token Error:", error); 253 | return undefined; 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /src/azure-account.api.d.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import { Event, Terminal } from "vscode"; 7 | import { ServiceClientCredentials } from "ms-rest"; 8 | import { AzureEnvironment } from "ms-rest-azure"; 9 | import { SubscriptionModels } from "azure-arm-resource"; 10 | 11 | export type AzureLoginStatus = 12 | | "Initializing" 13 | | "LoggingIn" 14 | | "LoggedIn" 15 | | "LoggedOut"; 16 | 17 | export interface AzureAccount { 18 | readonly status: AzureLoginStatus; 19 | readonly onStatusChanged: Event; 20 | readonly waitForLogin: () => Promise; 21 | readonly sessions: AzureSession[]; 22 | readonly onSessionsChanged: Event; 23 | readonly subscriptions: AzureSubscription[]; 24 | readonly onSubscriptionsChanged: Event; 25 | readonly waitForSubscriptions: () => Promise; 26 | readonly filters: AzureResourceFilter[]; 27 | readonly onFiltersChanged: Event; 28 | readonly waitForFilters: () => Promise; 29 | createCloudShell(os: "Linux" | "Windows"): CloudShell; 30 | } 31 | 32 | export interface AzureSession { 33 | readonly environment: AzureEnvironment; 34 | readonly userId: string; 35 | readonly tenantId: string; 36 | readonly credentials: ServiceClientCredentials; 37 | } 38 | 39 | export interface AzureSubscription { 40 | readonly session: AzureSession; 41 | readonly subscription: SubscriptionModels.Subscription; 42 | } 43 | 44 | export type AzureResourceFilter = AzureSubscription; 45 | 46 | export type CloudShellStatus = "Connecting" | "Connected" | "Disconnected"; 47 | 48 | export interface CloudShell { 49 | readonly status: CloudShellStatus; 50 | readonly onStatusChanged: Event; 51 | readonly waitForConnection: () => Promise; 52 | readonly terminal: Promise; 53 | readonly session: Promise; 54 | } 55 | -------------------------------------------------------------------------------- /src/baseShell.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | "use strict"; 7 | 8 | import * as vscode from "vscode"; 9 | import { terraformChannel } from "./terraformChannel"; 10 | 11 | export abstract class BaseShell { 12 | public terminal: vscode.Terminal | undefined; 13 | 14 | constructor() { 15 | this.initShellInternal(); 16 | } 17 | 18 | public abstract runTerraformCmd(tfCommand: string); 19 | 20 | public abstract runTerraformTests(testType: string, workingDirectory: string); 21 | 22 | public dispose(): void { 23 | terraformChannel.appendLine( 24 | `Terraform terminal: ${this.terminal.name} closed` 25 | ); 26 | this.terminal.dispose(); 27 | this.terminal = undefined; 28 | } 29 | 30 | protected abstract initShellInternal(); 31 | } 32 | -------------------------------------------------------------------------------- /src/build/downloader.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import * as unzip from "unzip-stream"; 4 | import axios from "axios"; 5 | 6 | interface Build { 7 | name: string; 8 | downloadUrl: string; 9 | } 10 | 11 | interface Release { 12 | version: string; 13 | assets: Build[]; 14 | } 15 | 16 | function getPlatform(platform: string) { 17 | if (platform === "win32") { 18 | return "windows"; 19 | } 20 | return platform; 21 | } 22 | 23 | function getArch(arch: string) { 24 | // platform | terraform-ls | extension platform | vs code editor 25 | // -- | -- | -- | -- 26 | // macOS | darwin_amd64 | darwin_x64 | ✅ 27 | // macOS | darwin_arm64 | darwin_arm64 | ✅ 28 | // Linux | linux_amd64 | linux_x64 | ✅ 29 | // Linux | linux_arm | linux_armhf | ✅ 30 | // Linux | linux_arm64 | linux_arm64 | ✅ 31 | // Windows | windows_386 | win32_ia32 | ✅ 32 | // Windows | windows_amd64 | win32_x64 | ✅ 33 | // Windows | windows_arm64 | win32_arm64 | ✅ 34 | if (arch === "ia32") { 35 | return "386"; 36 | } 37 | if (arch === "x64") { 38 | return "amd64"; 39 | } 40 | if (arch === "armhf") { 41 | return "arm"; 42 | } 43 | return arch; 44 | } 45 | 46 | async function getRelease(): Promise { 47 | const response = await axios.get( 48 | "https://api.github.com/repos/Azure/ms-terraform-lsp/releases", 49 | { 50 | headers: {}, 51 | } 52 | ); 53 | if (response.status == 200 && response.data.length != 0) { 54 | const assets: Build[] = []; 55 | for (const i in response.data[0].assets) { 56 | assets.push({ 57 | name: response.data[0].assets[i].name, 58 | downloadUrl: response.data[0].assets[i].browser_download_url, 59 | }); 60 | } 61 | return { 62 | version: response.data[0].name, 63 | assets: assets, 64 | }; 65 | } 66 | throw new Error("no valid release"); 67 | } 68 | 69 | async function run(platform: string, architecture: string) { 70 | const cwd = path.resolve(__dirname); 71 | 72 | const buildDir = path.basename(cwd); 73 | const repoDir = cwd.replace(buildDir, ""); 74 | const installPath = path.join(repoDir, "..", "bin"); 75 | if (fs.existsSync(installPath)) { 76 | console.log("ms-terraform-lsp path exists. Exiting"); 77 | return; 78 | } 79 | 80 | fs.mkdirSync(installPath); 81 | 82 | const release = await getRelease(); 83 | 84 | const os = getPlatform(platform); 85 | const arch = getArch(architecture); 86 | let build: Build | undefined; 87 | for (const i in release.assets) { 88 | if (release.assets[i].name.endsWith(`${os}_${arch}.zip`)) { 89 | build = release.assets[i]; 90 | break; 91 | } 92 | } 93 | 94 | if (!build) { 95 | throw new Error( 96 | `Install error: no matching ms-terraform-lsp binary for ${os}/${arch}` 97 | ); 98 | } 99 | 100 | console.log(build); 101 | 102 | // download zip 103 | const zipfile = path.resolve( 104 | installPath, 105 | `ms-terraform-lsp_${release.version}.zip` 106 | ); 107 | await axios 108 | .get(build!.downloadUrl, { responseType: "stream" }) 109 | .then(function (response) { 110 | const fileWritePipe = fs.createWriteStream(zipfile); 111 | response.data.pipe(fileWritePipe); 112 | return new Promise((resolve, reject) => { 113 | fileWritePipe.on("close", () => resolve()); 114 | response.data.on("error", reject); 115 | }); 116 | }); 117 | 118 | // unzip 119 | const fileExtension = os === "windows" ? ".exe" : ""; 120 | const binaryName = path.resolve( 121 | installPath, 122 | `ms-terraform-lsp${fileExtension}` 123 | ); 124 | const fileReadStream = fs.createReadStream(zipfile); 125 | const unzipPipe = unzip.Extract({ path: installPath }); 126 | fileReadStream.pipe(unzipPipe); 127 | await new Promise((resolve, reject) => { 128 | unzipPipe.on("close", () => { 129 | fs.chmodSync(binaryName, "755"); 130 | return resolve(); 131 | }); 132 | fileReadStream.on("error", reject); 133 | }); 134 | 135 | fs.unlinkSync(zipfile); 136 | } 137 | 138 | let os = process.platform.toString(); 139 | let arch = process.arch; 140 | 141 | // ls_target=linux_amd64 npm run package -- --target=linux-x64 142 | const lsTarget = process.env.ls_target; 143 | if (lsTarget !== undefined) { 144 | const tgt = lsTarget.split("_"); 145 | os = tgt[0]; 146 | arch = tgt[1]; 147 | } 148 | 149 | // npm run download:ls --target=darwin-x64 150 | const target = process.env.npm_config_target; 151 | if (target !== undefined) { 152 | const tgt = target.split("-"); 153 | os = tgt[0]; 154 | arch = tgt[1]; 155 | } 156 | 157 | run(os, arch); 158 | -------------------------------------------------------------------------------- /src/clientHandler.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { TelemetryReporter } from "@vscode/extension-telemetry"; 3 | import { 4 | DocumentSelector, 5 | Executable, 6 | LanguageClient, 7 | LanguageClientOptions, 8 | RevealOutputChannelOn, 9 | ServerOptions, 10 | State, 11 | } from "vscode-languageclient/node"; 12 | import { ServerPath } from "./serverPath"; 13 | import { config } from "./vscodeUtils"; 14 | import { TelemetryFeature } from "./telemetry"; 15 | 16 | export interface TerraformLanguageClient { 17 | client: LanguageClient; 18 | } 19 | 20 | /** 21 | * ClientHandler maintains lifecycles of language clients 22 | * based on the server's capabilities 23 | */ 24 | export class ClientHandler { 25 | private tfClient: TerraformLanguageClient | undefined; 26 | 27 | constructor( 28 | private lsPath: ServerPath, 29 | private outputChannel: vscode.OutputChannel, 30 | private reporter: TelemetryReporter 31 | ) {} 32 | 33 | public async startClient(): Promise { 34 | console.log("Starting client"); 35 | 36 | this.tfClient = await this.createTerraformClient(); 37 | const disposable = this.tfClient.client.start(); 38 | 39 | this.reporter.sendRawTelemetryEvent("startClient", { 40 | usePathToBinary: `${this.lsPath.hasCustomBinPath()}`, 41 | }); 42 | 43 | return disposable; 44 | } 45 | 46 | private async createTerraformClient(): Promise { 47 | const serverOptions = await this.getServerOptions(); 48 | 49 | const documentSelector: DocumentSelector = [ 50 | { scheme: "file", language: "terraform" }, 51 | ]; 52 | 53 | const clientOptions: LanguageClientOptions = { 54 | documentSelector: documentSelector, 55 | initializationFailedHandler: (error) => { 56 | this.reporter.sendTelemetryErrorEvent("initializationFailed", { 57 | err: `${error}`, 58 | }); 59 | return false; 60 | }, 61 | outputChannel: this.outputChannel, 62 | revealOutputChannelOn: RevealOutputChannelOn.Never, 63 | }; 64 | 65 | const id = `terraform`; 66 | const client = new LanguageClient(id, serverOptions, clientOptions); 67 | 68 | if (vscode.env.isTelemetryEnabled) { 69 | client.registerFeature(new TelemetryFeature(client, this.reporter)); 70 | } 71 | 72 | client.onDidChangeState((event) => { 73 | console.log( 74 | `Client: ${State[event.oldState]} --> ${State[event.newState]}` 75 | ); 76 | if (event.newState === State.Stopped) { 77 | this.reporter.sendRawTelemetryEvent("stopClient"); 78 | } 79 | }); 80 | 81 | return { client }; 82 | } 83 | 84 | private async getServerOptions(): Promise { 85 | const cmd = await this.lsPath.resolvedPathToBinary(); 86 | const serverArgs = config("azureTerraform").get( 87 | "languageServer.args", 88 | [] 89 | ); 90 | const executable: Executable = { 91 | command: cmd, 92 | args: serverArgs, 93 | options: {}, 94 | }; 95 | const serverOptions: ServerOptions = { 96 | run: executable, 97 | debug: executable, 98 | }; 99 | this.outputChannel.appendLine( 100 | `Launching language server: ${cmd} ${serverArgs.join(" ")}` 101 | ); 102 | return serverOptions; 103 | } 104 | 105 | public async stopClient(): Promise { 106 | if (this.tfClient?.client === undefined) { 107 | return; 108 | } 109 | await this.tfClient.client.stop(); 110 | console.log("Client stopped"); 111 | } 112 | 113 | public getClient(): TerraformLanguageClient | undefined { 114 | return this.tfClient; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/cloudFile.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | "use strict"; 7 | 8 | import * as path from "path"; 9 | import * as vscode from "vscode"; 10 | import { FileSystem } from "./shared"; 11 | 12 | export class CloudFile { 13 | public cloudShellUri: string; 14 | public localUri: string; 15 | public workspaceName: string; 16 | public fileShareName: string; 17 | public fileSystem: FileSystem; 18 | public cloudShellDir: string; 19 | 20 | constructor( 21 | workspaceName: string, // vscode workspace name 22 | fileShareName: string, // name of file share in storage account 23 | fileUri: string, // full path to file 24 | fileSystem: FileSystem 25 | ) { 26 | // env that is authoritative for file. 27 | // Ex. local file you want to push to cloudshell, env would be local 28 | 29 | this.workspaceName = workspaceName; 30 | this.fileShareName = fileShareName; 31 | this.fileSystem = fileSystem; 32 | 33 | if (fileSystem === FileSystem.cloudshell) { 34 | this.cloudShellUri = fileUri; 35 | this.localUri = this.convertCSToLocalPath(fileUri); 36 | console.log(this.localUri); 37 | } else if (fileSystem === FileSystem.local) { 38 | this.localUri = fileUri; 39 | this.cloudShellUri = this.convertLocalToCSPath(fileUri); 40 | console.log(this.cloudShellUri); 41 | } 42 | this.cloudShellDir = path.dirname(this.cloudShellUri); 43 | } 44 | 45 | // Convert Local file path to cloudshell 46 | private convertLocalToCSPath(localPath: string): string { 47 | const localDir = path.dirname( 48 | path.relative( 49 | vscode.workspace.workspaceFolders.find( 50 | (ws) => ws.name === this.workspaceName 51 | ).uri.fsPath, 52 | localPath 53 | ) 54 | ); 55 | const cloudShellDir = this.workspaceName + "/" + localDir; 56 | return cloudShellDir + "/" + path.basename(localPath); 57 | } 58 | 59 | private convertCSToLocalPath(csPath: string): string { 60 | const dirArray = path.dirname(csPath).split(path.sep); 61 | const fileName = path.basename(csPath); 62 | if (dirArray.length === 0) { 63 | throw new RangeError("{csPath} is invalid cloudshell path."); 64 | } 65 | 66 | const wsFolder = vscode.workspace.workspaceFolders.find( 67 | (ws) => ws.name === dirArray[0] 68 | ); 69 | if (wsFolder === undefined) { 70 | throw new RangeError( 71 | "{csPath} does not contain valid workspace name in path" 72 | ); 73 | } 74 | 75 | this.workspaceName = wsFolder.name; 76 | let baseFsPath = wsFolder.uri.fsPath; 77 | for (let i = 1; i < dirArray.length; i++) { 78 | baseFsPath = baseFsPath + "/" + dirArray[i]; 79 | } 80 | 81 | return baseFsPath + "/" + fileName; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/cloudShell.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | "use strict"; 7 | 8 | import * as fsExtra from "fs-extra"; 9 | import * as path from "path"; 10 | import { MessageItem } from "vscode"; 11 | import * as vscode from "vscode"; 12 | import * as TelemetryWrapper from "vscode-extension-telemetry-wrapper"; 13 | import { AzureAccount, CloudShell } from "./azure-account.api"; 14 | import { BaseShell } from "./baseShell"; 15 | import { 16 | aciConfig, 17 | Constants, 18 | exportContainerCmd, 19 | exportTestScript, 20 | } from "./constants"; 21 | import { 22 | azFileDelete, 23 | azFilePush, 24 | escapeFile, 25 | TerraformCommand, 26 | TestOption, 27 | } from "./shared"; 28 | import { terraformChannel } from "./terraformChannel"; 29 | import { 30 | getStorageAccountforCloudShell, 31 | IStorageAccount, 32 | } from "./utils/cloudShellUtils"; 33 | import * as settingUtils from "./utils/settingUtils"; 34 | import { 35 | DialogOption, 36 | DialogType, 37 | promptForOpenOutputChannel, 38 | } from "./utils/uiUtils"; 39 | import { selectWorkspaceFolder } from "./utils/workspaceUtils"; 40 | 41 | export class AzureCloudShell extends BaseShell { 42 | private cloudShell: CloudShell; 43 | private resourceGroup: string; 44 | private storageAccountName: string; 45 | private storageAccountKey: string; 46 | private fileShareName: string; 47 | 48 | public async pushFiles(files: vscode.Uri[]): Promise { 49 | terraformChannel.appendLine("Attempting to upload files to CloudShell..."); 50 | 51 | if (await this.connectedToCloudShell()) { 52 | const promises: Array> = []; 53 | for (const file of files.map((a) => a.fsPath)) { 54 | promises.push(this.pushFilePromise(file)); 55 | } 56 | 57 | try { 58 | await Promise.all(promises); 59 | vscode.window.showInformationMessage( 60 | "Synced all matched files in the current workspace to CloudShell" 61 | ); 62 | } catch (error) { 63 | terraformChannel.appendLine(error); 64 | await promptForOpenOutputChannel( 65 | "Failed to push files to the cloud. Please open the output channel for more details.", 66 | DialogType.error 67 | ); 68 | } 69 | } 70 | } 71 | 72 | public async deleteFiles(files: vscode.Uri[]): Promise { 73 | const RETRY_TIMES = 3; 74 | 75 | if (!(await this.connectedToCloudShell())) { 76 | terraformChannel.appendLine( 77 | `cloud shell can not be opened, file deleting operation is not synced` 78 | ); 79 | return; 80 | } 81 | 82 | for (const file of files.map((a) => a.fsPath)) { 83 | for (let i = 0; i < RETRY_TIMES; i++) { 84 | try { 85 | terraformChannel.appendLine(`Deleting file ${file} from cloud shell`); 86 | await azFileDelete( 87 | vscode.workspace.getWorkspaceFolder(vscode.Uri.file(file)).name, 88 | this.storageAccountName, 89 | this.storageAccountKey, 90 | this.fileShareName, 91 | file 92 | ); 93 | break; 94 | } catch (err) { 95 | terraformChannel.appendLine(err); 96 | } 97 | } 98 | } 99 | } 100 | 101 | public async runTerraformTests(testType: string, workingDirectory: string) { 102 | if (await this.connectedToCloudShell()) { 103 | const workspaceName: string = path.basename(workingDirectory); 104 | const setupFilesFolder = `${workspaceName}/.TFTesting`; 105 | const localPath: string = path.join(workingDirectory, ".TFTesting"); 106 | const createAciScript = "createacitest.sh"; 107 | const containerCommandScript = "containercmd.sh"; 108 | const resourceGroup: string = settingUtils.getResourceGroupForTest(); 109 | const aciName: string = settingUtils.getAciNameForTest(); 110 | const aciGroup: string = settingUtils.getAciGroupForTest(); 111 | 112 | const tfConfiguration = escapeFile( 113 | aciConfig( 114 | resourceGroup, 115 | aciName, 116 | aciGroup, 117 | this.storageAccountName, 118 | this.fileShareName, 119 | settingUtils.getLocationForTest(), 120 | settingUtils.getImageNameForTest(), 121 | workspaceName 122 | ) 123 | ); 124 | 125 | const shellscript = exportTestScript( 126 | tfConfiguration, 127 | this.resourceGroup, 128 | this.storageAccountName, 129 | setupFilesFolder 130 | ); 131 | 132 | await Promise.all([ 133 | fsExtra.outputFile(path.join(localPath, createAciScript), shellscript), 134 | fsExtra.outputFile( 135 | path.join(localPath, containerCommandScript), 136 | exportContainerCmd( 137 | workspaceName, 138 | await this.resolveContainerCmd(testType) 139 | ) 140 | ), 141 | ]); 142 | 143 | await Promise.all([ 144 | azFilePush( 145 | workspaceName, 146 | this.storageAccountName, 147 | this.storageAccountKey, 148 | this.fileShareName, 149 | path.join(localPath, createAciScript) 150 | ), 151 | azFilePush( 152 | workspaceName, 153 | this.storageAccountName, 154 | this.storageAccountKey, 155 | this.fileShareName, 156 | path.join(localPath, containerCommandScript) 157 | ), 158 | ]); 159 | 160 | await vscode.commands.executeCommand("azureTerraform.push"); 161 | 162 | const sentToTerminal: boolean = await this.runTFCommand( 163 | `source ${createAciScript} && terraform fmt && terraform init && terraform apply -auto-approve && terraform taint azurerm_container_group.TFTest && \ 164 | echo "\nRun the following command to get the logs from the ACI container: az container logs -g ${resourceGroup} -n ${aciGroup}\n"`, 165 | `${Constants.clouddrive}/${setupFilesFolder}` 166 | ); 167 | if (sentToTerminal) { 168 | vscode.window.showInformationMessage( 169 | `An Azure Container Instance will be created in the Resource Group '${resourceGroup}' if the command executes successfully.` 170 | ); 171 | } else { 172 | vscode.window.showErrorMessage( 173 | "Failed to send the command to terminal, please try it again." 174 | ); 175 | } 176 | } 177 | } 178 | 179 | public async runTerraformCmd(tfCommand: string): Promise { 180 | if (await this.connectedToCloudShell()) { 181 | const workingDirectory: string = await selectWorkspaceFolder(); 182 | await this.runTFCommand( 183 | tfCommand, 184 | workingDirectory 185 | ? `${Constants.clouddrive}/${path.basename(workingDirectory)}` 186 | : "" 187 | ); 188 | } 189 | } 190 | 191 | public dispose(): void { 192 | super.dispose(); 193 | this.cloudShell = undefined; 194 | this.resourceGroup = undefined; 195 | this.storageAccountName = undefined; 196 | this.storageAccountKey = undefined; 197 | this.fileShareName = undefined; 198 | } 199 | 200 | protected initShellInternal() { 201 | vscode.window.onDidCloseTerminal(async (terminal) => { 202 | if (terminal === this.terminal) { 203 | this.dispose(); 204 | } 205 | }); 206 | } 207 | 208 | protected async runTFCommand( 209 | command: string, 210 | workdir: string 211 | ): Promise { 212 | if (this.terminal) { 213 | this.terminal.show(); 214 | if (!(await this.cloudShell.waitForConnection())) { 215 | vscode.window.showErrorMessage( 216 | "Establish connection to Cloud Shell failed, please try again later." 217 | ); 218 | TelemetryWrapper.sendError(Error("connectFail")); 219 | return false; 220 | } 221 | 222 | if (workdir) { 223 | this.terminal.sendText(`cd "${workdir}"`); 224 | } 225 | if (this.isCombinedWithPush(command)) { 226 | await vscode.commands.executeCommand("azureTerraform.push"); 227 | } 228 | this.terminal.sendText(`${command}`); 229 | return true; 230 | } 231 | TelemetryWrapper.sendError(Error("sendToTerminalFail")); 232 | return false; 233 | } 234 | 235 | private async connectedToCloudShell(): Promise { 236 | if (this.terminal) { 237 | return true; 238 | } 239 | 240 | const message = "Do you want to open CloudShell?"; 241 | const response: MessageItem = await vscode.window.showWarningMessage( 242 | message, 243 | DialogOption.ok, 244 | DialogOption.cancel 245 | ); 246 | if (response === DialogOption.ok) { 247 | // TODO: Azure Account API is deprecated and need to be replaced once support for Azure Account API is migrated. 248 | const accountAPI: AzureAccount = 249 | vscode.extensions.getExtension( 250 | "ms-vscode.azure-account" 251 | )!.exports; 252 | 253 | this.cloudShell = accountAPI.createCloudShell("Linux"); 254 | 255 | this.terminal = await this.cloudShell.terminal; 256 | this.terminal.show(); 257 | const storageAccount: IStorageAccount = 258 | await getStorageAccountforCloudShell(this.cloudShell); 259 | if (!storageAccount) { 260 | vscode.window.showErrorMessage( 261 | "Failed to get the Storage Account information for Cloud Shell, please try again later." 262 | ); 263 | return false; 264 | } 265 | 266 | this.resourceGroup = storageAccount.resourceGroup; 267 | this.storageAccountName = storageAccount.storageAccountName; 268 | this.storageAccountKey = storageAccount.storageAccountKey; 269 | this.fileShareName = storageAccount.fileShareName; 270 | 271 | terraformChannel.appendLine("Cloudshell terminal opened."); 272 | 273 | return true; 274 | } 275 | 276 | console.log("Open CloudShell cancelled by user."); 277 | return false; 278 | } 279 | 280 | private async pushFilePromise(file: string): Promise { 281 | if (await fsExtra.pathExists(file)) { 282 | terraformChannel.appendLine(`Uploading file ${file} to cloud shell`); 283 | await azFilePush( 284 | vscode.workspace.getWorkspaceFolder(vscode.Uri.file(file)).name, 285 | this.storageAccountName, 286 | this.storageAccountKey, 287 | this.fileShareName, 288 | file 289 | ); 290 | } 291 | } 292 | 293 | private isCombinedWithPush(command: string): boolean { 294 | switch (command) { 295 | case TerraformCommand.Init: 296 | case TerraformCommand.Plan: 297 | case TerraformCommand.Apply: 298 | case TerraformCommand.Validate: 299 | return true; 300 | 301 | default: 302 | return false; 303 | } 304 | } 305 | 306 | private async resolveContainerCmd(TestType: string): Promise { 307 | switch (TestType) { 308 | case TestOption.lint: 309 | return "rake -f ../Rakefile build"; 310 | case TestOption.e2e: 311 | return "ssh-keygen -t rsa -b 2048 -C terraformTest -f /root/.ssh/id_rsa -N ''; rake -f ../Rakefile e2e"; 312 | case TestOption.custom: { 313 | const cmd: string = await vscode.window.showInputBox({ 314 | prompt: "Type your custom test command", 315 | value: "rake -f ../Rakefile build", 316 | }); 317 | return cmd ? cmd : ""; 318 | } 319 | default: 320 | return ""; 321 | } 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | "use strict"; 7 | 8 | export class Constants { 9 | public static TerraformTerminalName = "Terraform"; 10 | public static TestContainer = "microsoft/terraform-test"; 11 | public static clouddrive = "$HOME/clouddrive"; 12 | } 13 | 14 | export function aciConfig( 15 | resourceGroup: string, 16 | aciName: string, 17 | aciGroup: string, 18 | storageAccountName: string, 19 | storageAccountShare: string, 20 | location: string, 21 | testContainer: string, 22 | projectName: string 23 | ): string { 24 | // TODO - add a check on the location where ACI is available - if (['westus', eastus].indexOf(location) == -1) { 25 | const TFConfiguration = `variable "location" { 26 | default = "${location}" 27 | } 28 | 29 | variable "storage_account_key" { } 30 | 31 | resource "azurerm_resource_group" "TFTest" { 32 | name = "${resourceGroup}" 33 | location = "${location}" 34 | } 35 | resource "azurerm_container_group" "TFTest" { 36 | depends_on = ["azurerm_resource_group.TFTest"] 37 | name = "${aciGroup}" 38 | location = "${location}" 39 | resource_group_name = "${resourceGroup}" 40 | ip_address_type = "public" 41 | os_type = "linux" 42 | restart_policy = "Never" 43 | 44 | container { 45 | name = "${aciName}" 46 | image = "${testContainer}" 47 | cpu = "1" 48 | memory = "2" 49 | port = "80" 50 | 51 | environment_variables { 52 | "ARM_TEST_LOCATION"="${location}" 53 | "ARM_TEST_LOCATION_ALT"="${location}" 54 | } 55 | 56 | command = "/bin/bash -c '/module/${projectName}/.TFTesting/containercmd.sh'" 57 | 58 | volume { 59 | name = "module" 60 | mount_path = "/module" 61 | read_only = false 62 | share_name = "${storageAccountShare}" 63 | storage_account_name = "${storageAccountName}" 64 | storage_account_key = "\${var.storage_account_key}" 65 | } 66 | } 67 | }`; 68 | 69 | return TFConfiguration; 70 | } 71 | 72 | export function exportTestScript( 73 | TFConfiguration: string, 74 | resoureGroupName: string, 75 | storageAccountName: string, 76 | testDirectory: string 77 | ): string { 78 | const testScript = ` 79 | #!/bin/bash 80 | mkdir -p $HOME/clouddrive/${testDirectory} 81 | 82 | echo -e "${TFConfiguration}" > $HOME/clouddrive/${testDirectory}/testfile.tf 83 | 84 | export TF_VAR_storage_account_key=$(az storage account keys list -g ${resoureGroupName} -n ${storageAccountName} | jq '.[0].value') 85 | 86 | mkdir -p $HOME/clouddrive/${testDirectory}/.azure 87 | 88 | az account list --refresh &> /dev/null 89 | 90 | cp $HOME/.azure/*.json $HOME/clouddrive/${testDirectory}/.azure 91 | 92 | `; 93 | 94 | return testScript; 95 | } 96 | 97 | export function exportContainerCmd( 98 | moduleDir: string, 99 | containerCommand: string 100 | ): string { 101 | const containerScript = `#!/bin/bash 102 | 103 | echo "Copying terraform project..." 104 | cp -a /module/${moduleDir}/. /tf-test/module/ 105 | 106 | echo "Initializing environment..." 107 | mkdir /root/.azure 108 | cp /module/${moduleDir}/.TFTesting/.azure/*.json /root/.azure 109 | 110 | echo "Starting to Run test task..." 111 | ${containerCommand} 112 | echo "Container test operation completed - read the logs for status"`; 113 | 114 | return containerScript; 115 | } 116 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | "use strict"; 7 | 8 | import { GenericResourceExpanded } from "@azure/arm-resources"; 9 | import * as _ from "lodash"; 10 | import * as vscode from "vscode"; 11 | import * as TelemetryWrapper from "vscode-extension-telemetry-wrapper"; 12 | import { listAzureResourcesGrouped, selectSubscription } from "./auth"; 13 | import { TerraformCommand } from "./shared"; 14 | import { TestOption } from "./shared"; 15 | import { ShouldShowSurvey, ShowSurvey } from "./survey"; 16 | import { ExportResourceGroup, ExportSingleResource } from "./terraformExport"; 17 | import { terraformShellManager } from "./terraformShellManager"; 18 | import { getIconForResourceType } from "./utils/icon"; 19 | import { 20 | getSyncFileBlobPattern, 21 | isTerminalSetToCloudShell, 22 | } from "./utils/settingUtils"; 23 | import { checkTerraformInstalled } from "./utils/terraformUtils"; 24 | import { DialogOption } from "./utils/uiUtils"; 25 | import { selectWorkspaceFolder } from "./utils/workspaceUtils"; 26 | import { ClientHandler } from "./clientHandler"; 27 | import { ServerPath } from "./serverPath"; 28 | import { config } from "./vscodeUtils"; 29 | import { TelemetryReporter } from "@vscode/extension-telemetry"; 30 | import { terraformChannel } from "./terraformChannel"; 31 | 32 | let fileWatcher: vscode.FileSystemWatcher; 33 | 34 | let reporter: TelemetryReporter; 35 | let clientHandler: ClientHandler; 36 | 37 | export async function activate(ctx: vscode.ExtensionContext) { 38 | const manifest = ctx.extension.packageJSON; 39 | reporter = new TelemetryReporter(manifest.appInsightsConnectionString); 40 | await checkTerraformInstalled(); 41 | await TelemetryWrapper.initializeFromJsonFile( 42 | ctx.asAbsolutePath("./package.json") 43 | ); 44 | initFileWatcher(ctx); 45 | 46 | const lsPath = new ServerPath(ctx); 47 | const outputChanel = terraformChannel.getChannel(); 48 | clientHandler = new ClientHandler(lsPath, outputChanel, reporter); 49 | 50 | ctx.subscriptions.push( 51 | TelemetryWrapper.instrumentOperationAsVsCodeCommand( 52 | "azureTerraform.init", 53 | () => { 54 | terraformShellManager.getShell().runTerraformCmd(TerraformCommand.Init); 55 | } 56 | ) 57 | ); 58 | 59 | ctx.subscriptions.push( 60 | TelemetryWrapper.instrumentOperationAsVsCodeCommand( 61 | "azureTerraform.plan", 62 | () => { 63 | terraformShellManager.getShell().runTerraformCmd(TerraformCommand.Plan); 64 | } 65 | ) 66 | ); 67 | 68 | ctx.subscriptions.push( 69 | TelemetryWrapper.instrumentOperationAsVsCodeCommand( 70 | "azureTerraform.apply", 71 | () => { 72 | terraformShellManager 73 | .getShell() 74 | .runTerraformCmd(TerraformCommand.Apply); 75 | } 76 | ) 77 | ); 78 | 79 | ctx.subscriptions.push( 80 | TelemetryWrapper.instrumentOperationAsVsCodeCommand( 81 | "azureTerraform.destroy", 82 | () => { 83 | terraformShellManager 84 | .getShell() 85 | .runTerraformCmd(TerraformCommand.Destroy); 86 | } 87 | ) 88 | ); 89 | 90 | ctx.subscriptions.push( 91 | TelemetryWrapper.instrumentOperationAsVsCodeCommand( 92 | "azureTerraform.refresh", 93 | () => { 94 | terraformShellManager 95 | .getShell() 96 | .runTerraformCmd(TerraformCommand.Refresh); 97 | } 98 | ) 99 | ); 100 | 101 | ctx.subscriptions.push( 102 | TelemetryWrapper.instrumentOperationAsVsCodeCommand( 103 | "azureTerraform.validate", 104 | () => { 105 | terraformShellManager 106 | .getShell() 107 | .runTerraformCmd(TerraformCommand.Validate); 108 | } 109 | ) 110 | ); 111 | 112 | ctx.subscriptions.push( 113 | TelemetryWrapper.instrumentOperationAsVsCodeCommand( 114 | "azureTerraform.visualize", 115 | async () => { 116 | if (isTerminalSetToCloudShell()) { 117 | const choice: vscode.MessageItem = 118 | await vscode.window.showInformationMessage( 119 | "Visualization only works locally. Would you like to run it in the integrated terminal?", 120 | DialogOption.ok, 121 | DialogOption.cancel 122 | ); 123 | if (choice === DialogOption.cancel) { 124 | return; 125 | } 126 | } 127 | await terraformShellManager.getIntegratedShell().visualize(); 128 | } 129 | ) 130 | ); 131 | 132 | ctx.subscriptions.push( 133 | vscode.commands.registerCommand("azureTerraform.exectest", async () => { 134 | const pick: string = await vscode.window.showQuickPick( 135 | [TestOption.lint, TestOption.e2e, TestOption.custom], 136 | { placeHolder: "Select the type of test that you want to run" } 137 | ); 138 | if (!pick) { 139 | return; 140 | } 141 | const workingDirectory: string = await selectWorkspaceFolder(); 142 | if (!workingDirectory) { 143 | return; 144 | } 145 | await terraformShellManager 146 | .getShell() 147 | .runTerraformTests(pick, workingDirectory); 148 | }) 149 | ); 150 | 151 | ctx.subscriptions.push( 152 | TelemetryWrapper.instrumentOperationAsVsCodeCommand( 153 | "azureTerraform.push", 154 | async () => { 155 | if (!isTerminalSetToCloudShell()) { 156 | vscode.window.showErrorMessage( 157 | "Push function only available when using cloudshell. Which is not currently supported." 158 | ); 159 | return; 160 | } 161 | if (_.isEmpty(vscode.workspace.workspaceFolders)) { 162 | vscode.window.showInformationMessage( 163 | "Please open a workspace in VS Code first." 164 | ); 165 | return; 166 | } 167 | await terraformShellManager 168 | .getCloudShell() 169 | .pushFiles( 170 | await vscode.workspace.findFiles(getSyncFileBlobPattern()) 171 | ); 172 | } 173 | ) 174 | ); 175 | 176 | ctx.subscriptions.push( 177 | TelemetryWrapper.instrumentOperationAsVsCodeCommand( 178 | "azureTerraform.showSurvey", 179 | async () => { 180 | await ShowSurvey(); 181 | } 182 | ) 183 | ); 184 | 185 | ctx.subscriptions.push( 186 | TelemetryWrapper.instrumentOperationAsVsCodeCommand( 187 | "azureTerraform.selectSubscription", 188 | async () => { 189 | const subscription = await selectSubscription(); 190 | if (subscription) { 191 | vscode.window.showInformationMessage( 192 | `Set active Azure subscription to: ${subscription.name}` 193 | ); 194 | } 195 | } 196 | ) 197 | ); 198 | 199 | ctx.subscriptions.push( 200 | TelemetryWrapper.instrumentOperationAsVsCodeCommand( 201 | "azureTerraform.exportResource", 202 | async () => { 203 | const subscription = await selectSubscription(); 204 | if (!subscription) { 205 | // Message already shown by ensureTenantAndSubscriptionSelected if cancelled/failed 206 | return; 207 | } 208 | 209 | // Get credential from the validated subscription object 210 | const credential = subscription.credential; 211 | if (!credential) { 212 | vscode.window.showErrorMessage( 213 | "Could not get credentials for the selected subscription." 214 | ); 215 | return; 216 | } 217 | 218 | const groupedResources = await listAzureResourcesGrouped( 219 | credential, 220 | subscription.subscriptionId 221 | ); 222 | 223 | if (!groupedResources || groupedResources.size === 0) { 224 | vscode.window.showInformationMessage( 225 | `No resource groups with exportable resources found in subscription "${subscription.name}".` 226 | ); 227 | return; 228 | } 229 | 230 | // --- Level 1: Select Resource Group --- 231 | const groupPicks: Array< 232 | vscode.QuickPickItem & { 233 | groupName: string; 234 | resources: GenericResourceExpanded[]; 235 | isChangeSubscription?: boolean; 236 | } 237 | > = [ 238 | { 239 | label: `$(symbol-namespace) Select another subscription`, 240 | groupName: "", 241 | resources: [], 242 | isChangeSubscription: true, 243 | }, 244 | ].concat( 245 | Array.from(groupedResources.entries()).map( 246 | ([groupName, resources]) => ({ 247 | label: `$(symbol-namespace) ${groupName}`, 248 | detail: `$(location) Location: ${ 249 | resources[0]?.location || "N/A" 250 | } | $(list-unordered) Resources: ${resources.length}`, 251 | groupName, 252 | resources, 253 | isChangeSubscription: false, 254 | }) 255 | ) 256 | ); 257 | 258 | const selectedGroupPick = await vscode.window.showQuickPick( 259 | groupPicks, 260 | { 261 | placeHolder: 262 | "Select the Resource Group containing the resource(s) to export", 263 | matchOnDetail: true, 264 | ignoreFocusOut: true, 265 | } 266 | ); 267 | 268 | if (!selectedGroupPick) { 269 | vscode.window.showInformationMessage( 270 | "Resource group selection cancelled." 271 | ); 272 | return; 273 | } 274 | 275 | if (selectedGroupPick.isChangeSubscription) { 276 | await selectSubscription(); 277 | await vscode.commands.executeCommand("azureTerraform.exportResource"); 278 | return; 279 | } 280 | 281 | type ResourcePickItem = vscode.QuickPickItem & 282 | ( 283 | | { 284 | resource: GenericResourceExpanded; 285 | isGroupExport?: false; 286 | isChangeSubscription?: boolean; 287 | } 288 | | { 289 | resource?: undefined; 290 | isGroupExport: true; 291 | isChangeSubscription?: false; 292 | } 293 | ); 294 | 295 | const resourcePicks: ResourcePickItem[] = [ 296 | // Option to select another subscription 297 | { 298 | label: `$(symbol-namespace) Select another subscription`, 299 | description: "", 300 | detail: "", 301 | resource: undefined, 302 | isGroupExport: false, 303 | isChangeSubscription: true, 304 | }, 305 | // Option to export the entire group 306 | { 307 | label: `$(folder-opened) Export ALL resources in group '${selectedGroupPick.groupName}'`, 308 | description: `(${selectedGroupPick.resources.length} resources)`, 309 | detail: `Exports the entire resource group configuration.`, 310 | isGroupExport: true, 311 | }, 312 | ...selectedGroupPick.resources.map( 313 | (res): ResourcePickItem => ({ 314 | label: `${getIconForResourceType(res.type)} ${ 315 | res.name || "Unnamed Resource" 316 | }`, 317 | description: res.type || "Unknown Type", 318 | detail: `$(location) Location: ${ 319 | res.location || "N/A" 320 | } | $(key) ID: ${res.id || "N/A"}`, 321 | resource: res, 322 | isGroupExport: false, 323 | }) 324 | ), 325 | ]; 326 | 327 | const selectedResourceOrGroupPick = 328 | await vscode.window.showQuickPick(resourcePicks, { 329 | placeHolder: `Select a resource OR export all from group "${selectedGroupPick.groupName}"`, 330 | matchOnDescription: true, 331 | matchOnDetail: true, 332 | ignoreFocusOut: true, 333 | }); 334 | 335 | if (!selectedResourceOrGroupPick) { 336 | vscode.window.showInformationMessage("Resource selection cancelled."); 337 | return; 338 | } 339 | 340 | if (selectedResourceOrGroupPick.isChangeSubscription) { 341 | await selectSubscription(); 342 | await vscode.commands.executeCommand("azureTerraform.exportResource"); 343 | return; 344 | } 345 | 346 | if (selectedResourceOrGroupPick.isGroupExport) { 347 | vscode.window.showInformationMessage( 348 | `Starting export for all resources in group: ${selectedGroupPick.groupName}` 349 | ); 350 | const targetProvider = await promptForTargetProvider(); 351 | await ExportResourceGroup( 352 | subscription, 353 | selectedGroupPick.resources, 354 | selectedGroupPick.groupName, 355 | targetProvider 356 | ); 357 | } else if (selectedResourceOrGroupPick.resource?.id) { 358 | vscode.window.showInformationMessage( 359 | `Starting export for resource: ${selectedResourceOrGroupPick.resource.name}` 360 | ); 361 | const targetProvider = await promptForTargetProvider(); 362 | await ExportSingleResource( 363 | subscription, 364 | selectedResourceOrGroupPick.resource, 365 | targetProvider 366 | ); 367 | } else { 368 | vscode.window.showErrorMessage( 369 | "Invalid selection or the selected resource is missing a required ID." 370 | ); 371 | } 372 | } 373 | ) 374 | ); 375 | 376 | ctx.subscriptions.push( 377 | TelemetryWrapper.instrumentOperationAsVsCodeCommand( 378 | "azureTerraform.enableLanguageServer", 379 | async () => { 380 | if (!enabled()) { 381 | const currentConfig: any = 382 | config("azureTerraform").get("languageServer"); 383 | currentConfig.external = true; 384 | await config("azureTerraform").update( 385 | "languageServer", 386 | currentConfig, 387 | vscode.ConfigurationTarget.Global 388 | ); 389 | startLanguageServer(); 390 | } 391 | } 392 | ) 393 | ); 394 | 395 | ctx.subscriptions.push( 396 | TelemetryWrapper.instrumentOperationAsVsCodeCommand( 397 | "azureTerraform.disableLanguageServer", 398 | async () => { 399 | if (enabled()) { 400 | const currentConfig: any = 401 | config("azureTerraform").get("languageServer"); 402 | currentConfig.external = false; 403 | await config("azureTerraform").update( 404 | "languageServer", 405 | currentConfig, 406 | vscode.ConfigurationTarget.Global 407 | ); 408 | stopLanguageServer(); 409 | } 410 | } 411 | ) 412 | ); 413 | 414 | ctx.subscriptions.push( 415 | vscode.workspace.onDidChangeConfiguration( 416 | async (event: vscode.ConfigurationChangeEvent) => { 417 | if (event.affectsConfiguration("azureTerraform.languageServer")) { 418 | const reloadMsg = 419 | "Reload VSCode window to apply language server changes"; 420 | const selected = await vscode.window.showInformationMessage( 421 | reloadMsg, 422 | "Reload" 423 | ); 424 | if (selected === "Reload") { 425 | vscode.commands.executeCommand("workbench.action.reloadWindow"); 426 | } 427 | } 428 | } 429 | ) 430 | ); 431 | 432 | if (enabled()) { 433 | startLanguageServer(); 434 | } 435 | 436 | if (await ShouldShowSurvey()) { 437 | await ShowSurvey(); 438 | } 439 | } 440 | 441 | async function promptForTargetProvider(): Promise { 442 | const providerPicks: vscode.QuickPickItem[] = [ 443 | { label: "azurerm", description: "AzureRM Provider" }, 444 | { label: "azapi", description: "Azure API Provider" }, 445 | ]; 446 | 447 | const selectedProvider = await vscode.window.showQuickPick(providerPicks, { 448 | placeHolder: "Select the target provider for the export", 449 | ignoreFocusOut: true, 450 | }); 451 | 452 | return selectedProvider ? selectedProvider.label : "azurerm"; 453 | } 454 | 455 | export function deactivate(): void { 456 | terraformShellManager.dispose(); 457 | if (fileWatcher) { 458 | fileWatcher.dispose(); 459 | } 460 | 461 | if (clientHandler) { 462 | clientHandler.stopClient(); 463 | } 464 | } 465 | 466 | function initFileWatcher(ctx: vscode.ExtensionContext): void { 467 | fileWatcher = vscode.workspace.createFileSystemWatcher( 468 | getSyncFileBlobPattern() 469 | ); 470 | ctx.subscriptions.push( 471 | fileWatcher.onDidDelete((deletedUri) => { 472 | if (isTerminalSetToCloudShell()) { 473 | terraformShellManager.getCloudShell().deleteFiles([deletedUri]); 474 | } 475 | }) 476 | ); 477 | } 478 | 479 | async function startLanguageServer() { 480 | try { 481 | await clientHandler.startClient(); 482 | } catch (error) { 483 | console.log(error); // for test failure reporting 484 | reporter.sendTelemetryErrorEvent("startLanguageServer", { 485 | err: `${error}`, 486 | }); 487 | if (error instanceof Error) { 488 | vscode.window.showErrorMessage( 489 | error instanceof Error ? error.message : error 490 | ); 491 | } else if (typeof error === "string") { 492 | vscode.window.showErrorMessage(error); 493 | } 494 | } 495 | } 496 | 497 | async function stopLanguageServer() { 498 | try { 499 | await clientHandler.stopClient(); 500 | } catch (error) { 501 | console.log(error); // for test failure reporting 502 | reporter.sendTelemetryErrorEvent("stopLanguageServer", { 503 | err: `${error}`, 504 | }); 505 | if (error instanceof Error) { 506 | vscode.window.showErrorMessage( 507 | error instanceof Error ? error.message : error 508 | ); 509 | } else if (typeof error === "string") { 510 | vscode.window.showErrorMessage(error); 511 | } 512 | } 513 | } 514 | 515 | function enabled(): boolean { 516 | return config("azureTerraform").get("languageServer.external", true); 517 | } 518 | -------------------------------------------------------------------------------- /src/integratedShell.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | "use strict"; 7 | 8 | import * as fse from "fs-extra"; 9 | import * as os from "os"; 10 | import * as path from "path"; 11 | import * as vscode from "vscode"; 12 | import { commands, Uri, ViewColumn } from "vscode"; 13 | import * as TelemetryWrapper from "vscode-extension-telemetry-wrapper"; 14 | import { BaseShell } from "./baseShell"; 15 | import { Constants } from "./constants"; 16 | import { TestOption } from "./shared"; 17 | import { executeCommand } from "./utils/cpUtils"; 18 | import { 19 | isDockerInstalled, 20 | runCustomCommandInDocker, 21 | runE2EInDocker, 22 | runLintInDocker, 23 | } from "./utils/dockerUtils"; 24 | import { drawGraph } from "./utils/dotUtils"; 25 | import { isDotInstalled } from "./utils/dotUtils"; 26 | import * as settingUtils from "./utils/settingUtils"; 27 | import { selectWorkspaceFolder } from "./utils/workspaceUtils"; 28 | 29 | export class IntegratedShell extends BaseShell { 30 | private static readonly GRAPH_FILE_NAME = "graph.png"; 31 | 32 | // Creates a png of terraform resource graph to visualize the resources under management. 33 | public async visualize(): Promise { 34 | if (!(await isDotInstalled())) { 35 | TelemetryWrapper.sendError(Error("dotNotInstalled")); 36 | return; 37 | } 38 | const cwd: string = await selectWorkspaceFolder(); 39 | if (!cwd) { 40 | TelemetryWrapper.sendError(Error("noWorkspaceSelected")); 41 | return; 42 | } 43 | await this.deletePng(cwd); 44 | await executeCommand("terraform", ["init"], { 45 | shell: true, 46 | cwd, 47 | }); 48 | const output: string = await executeCommand("terraform", ["graph"], { 49 | shell: true, 50 | cwd, 51 | }); 52 | const tmpFile: string = path.join(os.tmpdir(), "terraformgraph.output"); 53 | await fse.writeFile(tmpFile, output); 54 | await drawGraph(cwd, tmpFile); 55 | await commands.executeCommand( 56 | "vscode.open", 57 | Uri.file(path.join(cwd, IntegratedShell.GRAPH_FILE_NAME)), 58 | ViewColumn.Two 59 | ); 60 | } 61 | 62 | public async runTerraformTests(TestType: string, workingDirectory: string) { 63 | if (!(await isDockerInstalled())) { 64 | TelemetryWrapper.sendError(Error("dockerNotInstalled")); 65 | return; 66 | } 67 | const containerName: string = settingUtils.getImageNameForTest(); 68 | 69 | switch (TestType) { 70 | case TestOption.lint: 71 | await runLintInDocker( 72 | workingDirectory + ":/tf-test/module", 73 | containerName 74 | ); 75 | break; 76 | case TestOption.e2e: 77 | await runE2EInDocker( 78 | workingDirectory + ":/tf-test/module", 79 | containerName 80 | ); 81 | break; 82 | 83 | case TestOption.custom: { 84 | const cmd: string = await vscode.window.showInputBox({ 85 | prompt: "Type your custom test command", 86 | value: `run -v ${workingDirectory}:/tf-test/module --rm ${containerName} rake -f ../Rakefile build`, 87 | }); 88 | if (!cmd) { 89 | return; 90 | } 91 | await runCustomCommandInDocker(cmd, containerName); 92 | break; 93 | } 94 | default: 95 | break; 96 | } 97 | } 98 | 99 | public runTerraformCmd(tfCommand: string): void { 100 | this.checkCreateTerminal(); 101 | this.terminal.show(); 102 | this.terminal.sendText(tfCommand); 103 | } 104 | 105 | protected initShellInternal() { 106 | vscode.window.onDidCloseTerminal((terminal) => { 107 | if (terminal === this.terminal) { 108 | this.dispose(); 109 | } 110 | }); 111 | } 112 | 113 | private async deletePng(cwd: string): Promise { 114 | const graphPath: string = path.join(cwd, IntegratedShell.GRAPH_FILE_NAME); 115 | if (await fse.pathExists(graphPath)) { 116 | await fse.remove(graphPath); 117 | } 118 | } 119 | 120 | private checkCreateTerminal(): void { 121 | if (!this.terminal) { 122 | this.terminal = vscode.window.createTerminal( 123 | Constants.TerraformTerminalName 124 | ); 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/serverPath.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import * as vscode from "vscode"; 3 | import * as which from "which"; 4 | 5 | const INSTALL_FOLDER_NAME = "bin"; 6 | export const CUSTOM_BIN_PATH_OPTION_NAME = "languageServer.pathToBinary"; 7 | 8 | export class ServerPath { 9 | private customBinPath: string | undefined; 10 | 11 | constructor(private context: vscode.ExtensionContext) { 12 | this.customBinPath = vscode.workspace 13 | .getConfiguration("azureTerraform") 14 | .get(CUSTOM_BIN_PATH_OPTION_NAME); 15 | } 16 | 17 | public installPath(): string { 18 | return path.join(this.context.extensionPath, INSTALL_FOLDER_NAME); 19 | } 20 | 21 | public hasCustomBinPath(): boolean { 22 | return !!this.customBinPath; 23 | } 24 | 25 | public binPath(): string { 26 | if (this.customBinPath) { 27 | return this.customBinPath; 28 | } 29 | 30 | return path.resolve(this.installPath(), this.binName()); 31 | } 32 | 33 | public binName(): string { 34 | if (this.customBinPath) { 35 | return path.basename(this.customBinPath); 36 | } 37 | 38 | if (process.platform === "win32") { 39 | return "ms-terraform-lsp.exe"; 40 | } 41 | return "ms-terraform-lsp"; 42 | } 43 | 44 | public async resolvedPathToBinary(): Promise { 45 | const pathToBinary = this.binPath(); 46 | let cmd: string; 47 | try { 48 | if (path.isAbsolute(pathToBinary)) { 49 | await vscode.workspace.fs.stat(vscode.Uri.file(pathToBinary)); 50 | cmd = pathToBinary; 51 | } else { 52 | cmd = which.sync(pathToBinary); 53 | } 54 | console.log(`Found server at ${cmd}`); 55 | } catch (err) { 56 | let extraHint = ""; 57 | if (this.customBinPath) { 58 | extraHint = `. Check "${CUSTOM_BIN_PATH_OPTION_NAME}" in your settings.`; 59 | } 60 | throw new Error( 61 | `Unable to launch language server: ${ 62 | err instanceof Error ? err.message : err 63 | }${extraHint}` 64 | ); 65 | } 66 | 67 | return cmd; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/shared.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | "use strict"; 7 | 8 | import * as azureStorage from "azure-storage"; 9 | import * as path from "path"; 10 | import { CloudFile } from "./cloudFile"; 11 | 12 | export enum TerminalType { 13 | Integrated = "integrated", 14 | CloudShell = "cloudshell", 15 | } 16 | 17 | export enum FileSystem { 18 | docker = "docker", 19 | local = "local", 20 | cloudshell = "cloudshell", 21 | } 22 | 23 | export enum TestOption { 24 | lint = "lint", 25 | e2e = "end to end", 26 | custom = "custom", 27 | } 28 | 29 | export enum TerraformCommand { 30 | Init = "terraform init", 31 | Plan = "terraform plan", 32 | Apply = "terraform apply", 33 | Destroy = "terraform destroy", 34 | Refresh = "terraform refresh", 35 | Validate = "terraform validate", 36 | } 37 | 38 | export function escapeFile(data: string): string { 39 | return data.replace(/"/g, '\\"').replace(/\$/g, "\\$"); 40 | } 41 | 42 | export async function azFileDelete( 43 | workspaceName: string, 44 | storageAccountName: string, 45 | storageAccountKey: string, 46 | fileShareName: string, 47 | localFileUri: string 48 | ): Promise { 49 | const fs = azureStorage.createFileService( 50 | storageAccountName, 51 | storageAccountKey 52 | ); 53 | const cf = new CloudFile( 54 | workspaceName, 55 | fileShareName, 56 | localFileUri, 57 | FileSystem.local 58 | ); 59 | 60 | await deleteFile(cf, fs); 61 | } 62 | 63 | export async function azFilePull( 64 | workspaceName: string, 65 | storageAccountName: string, 66 | storageAccountKey: string, 67 | fileShareName: string, 68 | cloudShellFileName: string 69 | ): Promise { 70 | const fs = azureStorage.createFileService( 71 | storageAccountName, 72 | storageAccountKey 73 | ); 74 | const cf = new CloudFile( 75 | workspaceName, 76 | fileShareName, 77 | cloudShellFileName, 78 | FileSystem.cloudshell 79 | ); 80 | 81 | await readFile(cf, fs); 82 | } 83 | 84 | export async function azFilePush( 85 | workspaceName: string, 86 | storageAccountName: string, 87 | storageAccountKey: string, 88 | fileShareName: string, 89 | fileName: string 90 | ): Promise { 91 | const fs = azureStorage.createFileService( 92 | storageAccountName, 93 | storageAccountKey 94 | ); 95 | const cf = new CloudFile( 96 | workspaceName, 97 | fileShareName, 98 | fileName, 99 | FileSystem.local 100 | ); 101 | const pathCount = cf.cloudShellDir.split(path.sep).length; 102 | 103 | // try create file share if it does not exist 104 | try { 105 | await createShare(cf, fs); 106 | } catch (error) { 107 | console.log(`Error creating FileShare: ${cf.fileShareName}\n\n${error}`); 108 | return; 109 | } 110 | 111 | // try create directory path if it does not exist, dirs need to be created at each level 112 | try { 113 | for (let i = 0; i < pathCount; i++) { 114 | await createDirectoryPath(cf, i, fs); 115 | } 116 | } catch (error) { 117 | console.log(`Error creating directory: ${cf.cloudShellDir}\n\n${error}`); 118 | return; 119 | } 120 | 121 | // try create file if not exist 122 | try { 123 | await createFile(cf, fs); 124 | } catch (error) { 125 | console.log(`Error creating file: ${cf.localUri}\n\n${error}`); 126 | } 127 | 128 | return; 129 | } 130 | 131 | function createShare( 132 | cf: CloudFile, 133 | fs: azureStorage.FileService 134 | ): Promise { 135 | return new Promise((resolve, reject) => { 136 | fs.createShareIfNotExists(cf.fileShareName, (error, result) => { 137 | if (!error) { 138 | if (result && result.created) { 139 | // only log if created 140 | console.log(`FileShare: ${cf.fileShareName} created.`); 141 | } 142 | resolve(); 143 | } else { 144 | reject(error); 145 | } 146 | }); 147 | }); 148 | } 149 | 150 | function createDirectoryPath( 151 | cf: CloudFile, 152 | i: number, 153 | fs: azureStorage.FileService 154 | ): Promise { 155 | return new Promise((resolve, reject) => { 156 | let tempDir = ""; 157 | const dirArray = cf.cloudShellDir.split(path.sep); 158 | for (let j = 0; j < dirArray.length && j <= i; j++) { 159 | tempDir = tempDir + dirArray[j] + "/"; 160 | } 161 | fs.createDirectoryIfNotExists( 162 | cf.fileShareName, 163 | tempDir, 164 | (error, result) => { 165 | if (!error) { 166 | if (result && result.created) { 167 | // only log if created TODO: This check is buggy, open issue in Azure Storage NODE SDK. 168 | console.log(`Created dir: ${tempDir}`); 169 | } 170 | resolve(); 171 | } else { 172 | reject(error); 173 | } 174 | } 175 | ); 176 | }); 177 | } 178 | 179 | function createFile( 180 | cf: CloudFile, 181 | fs: azureStorage.FileService 182 | ): Promise { 183 | return new Promise((resolve, reject) => { 184 | const fileName = path.basename(cf.localUri); 185 | fs.createFileFromLocalFile( 186 | cf.fileShareName, 187 | cf.cloudShellDir, 188 | fileName, 189 | cf.localUri, 190 | (error) => { 191 | if (!error) { 192 | console.log(`File synced to cloud: ${fileName}`); 193 | resolve(); 194 | } else { 195 | reject(error); 196 | } 197 | } 198 | ); 199 | }); 200 | } 201 | 202 | function readFile(cf: CloudFile, fs: azureStorage.FileService): Promise { 203 | return new Promise((resolve, reject) => { 204 | const fileName = path.basename(cf.cloudShellUri); 205 | fs.getFileToLocalFile( 206 | cf.fileShareName, 207 | cf.cloudShellDir, 208 | fileName, 209 | cf.localUri, 210 | (error) => { 211 | if (!error) { 212 | console.log(`File synced to local workspace: ${cf.localUri}`); 213 | resolve(); 214 | } else { 215 | reject(error); 216 | } 217 | } 218 | ); 219 | }); 220 | } 221 | 222 | function deleteFile( 223 | cf: CloudFile, 224 | fs: azureStorage.FileService 225 | ): Promise { 226 | return new Promise((resolve, reject) => { 227 | const fileName = path.basename(cf.localUri); 228 | fs.deleteFileIfExists( 229 | cf.fileShareName, 230 | cf.cloudShellDir, 231 | fileName, 232 | (error, result) => { 233 | if (!error) { 234 | if (result) { 235 | console.log(`File deleted from cloudshell: ${cf.cloudShellUri}`); 236 | } else { 237 | console.log( 238 | `File does not exist in cloudshell: ${cf.cloudShellUri}` 239 | ); 240 | } 241 | resolve(); 242 | } else { 243 | reject(error); 244 | } 245 | } 246 | ); 247 | }); 248 | } 249 | -------------------------------------------------------------------------------- /src/survey.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { getSurvey, setSurvey } from "./utils/settingUtils"; 3 | 4 | export async function ShouldShowSurvey(): Promise { 5 | let currentConfig: any = getSurvey(); 6 | 7 | if ( 8 | !currentConfig || 9 | !currentConfig.surveyPromptDate || 10 | currentConfig.surveyPromptDate === "none" 11 | ) { 12 | currentConfig = {}; 13 | // first time, remind after 10 days 14 | const surveyPromptDate = new Date(); 15 | surveyPromptDate.setDate(surveyPromptDate.getDate() + 10); 16 | currentConfig.surveyPromptDate = surveyPromptDate.toISOString(); 17 | currentConfig.surveyPromptIgnoredCount = 0; 18 | setSurvey(currentConfig); 19 | return false; 20 | } 21 | 22 | if (currentConfig.surveyPromptDate === "never") { 23 | return false; 24 | } 25 | 26 | const currentDate = new Date(); 27 | const promptDate = new Date(currentConfig.surveyPromptDate); 28 | 29 | return currentDate >= promptDate; 30 | } 31 | 32 | export async function ShowSurvey(): Promise { 33 | const reloadMsg = 34 | "Looks like you are using Terraform AzureRM Provider. We’d love to hear from you! Could you help us improve product usability by filling out a 2-3 minute survey about your experience with it?"; 35 | const selected = await vscode.window.showInformationMessage( 36 | reloadMsg, 37 | "Yes", 38 | "Not Now", 39 | "Never" 40 | ); 41 | let currentConfig: any = getSurvey(); 42 | 43 | if (currentConfig === undefined) { 44 | currentConfig = {}; 45 | currentConfig.surveyPromptDate = "none"; 46 | currentConfig.surveyPromptIgnoredCount = 0; 47 | } 48 | 49 | const nextPromptDate = new Date(); 50 | 51 | switch (selected) { 52 | case "Yes": 53 | vscode.commands.executeCommand( 54 | "vscode.open", 55 | vscode.Uri.parse( 56 | "https://microsoft.qualtrics.com/jfe/form/SV_cImsrdNc4uF3LBc" 57 | ) 58 | ); 59 | // reset the survey prompt date and ignored count, remind after 180 days 60 | currentConfig.surveyPromptIgnoredCount = 0; 61 | nextPromptDate.setDate(nextPromptDate.getDate() + 180); 62 | currentConfig.surveyPromptDate = nextPromptDate.toISOString(); 63 | break; 64 | case "Never": 65 | currentConfig.surveyPromptDate = "never"; 66 | currentConfig.surveyPromptIgnoredCount = 0; 67 | break; 68 | case "Not Now": 69 | case undefined: 70 | currentConfig.surveyPromptIgnoredCount++; 71 | if (currentConfig.surveyPromptIgnoredCount === 1) { 72 | // first time ignore, remind after 7 days 73 | nextPromptDate.setDate(nextPromptDate.getDate() + 7); 74 | currentConfig.surveyPromptDate = nextPromptDate.toISOString(); 75 | } else { 76 | // second time ignore, remind after 30 days 77 | nextPromptDate.setDate(nextPromptDate.getDate() + 30); 78 | currentConfig.surveyPromptDate = nextPromptDate.toISOString(); 79 | } 80 | break; 81 | } 82 | await setSurvey(currentConfig); 83 | } 84 | -------------------------------------------------------------------------------- /src/telemetry.ts: -------------------------------------------------------------------------------- 1 | import { TelemetryReporter } from "@vscode/extension-telemetry"; 2 | import { 3 | BaseLanguageClient, 4 | ClientCapabilities, 5 | FeatureState, 6 | StaticFeature, 7 | } from "vscode-languageclient"; 8 | 9 | import { ExperimentalClientCapabilities } from "./types"; 10 | 11 | const TELEMETRY_VERSION = 1; 12 | 13 | type TelemetryEvent = { 14 | v: number; 15 | name: string; 16 | properties: { [key: string]: string }; 17 | }; 18 | 19 | export class TelemetryFeature implements StaticFeature { 20 | constructor(private client: BaseLanguageClient, reporter: TelemetryReporter) { 21 | this.client.onTelemetry((event: TelemetryEvent) => { 22 | if (event.v != TELEMETRY_VERSION) { 23 | console.log(`unsupported telemetry event: ${event}`); 24 | return; 25 | } 26 | 27 | reporter.sendRawTelemetryEvent(event.name, event.properties); 28 | console.log("Telemetry event:", event); 29 | }); 30 | } 31 | 32 | getState(): FeatureState { 33 | return { 34 | kind: "static", 35 | }; 36 | } 37 | clear(): void { 38 | return; 39 | } 40 | 41 | public fillClientCapabilities( 42 | capabilities: ClientCapabilities & ExperimentalClientCapabilities 43 | ): void { 44 | if (!capabilities["experimental"]) { 45 | capabilities["experimental"] = {}; 46 | } 47 | capabilities["experimental"]["telemetryVersion"] = TELEMETRY_VERSION; 48 | } 49 | 50 | public initialize(): void { 51 | return; 52 | } 53 | 54 | public dispose(): void { 55 | return; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/terraformChannel.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | "use strict"; 7 | 8 | import * as vscode from "vscode"; 9 | 10 | export interface ITerraformChannel { 11 | appendLine(message: any, title?: string): void; 12 | append(message: any): void; 13 | show(): void; 14 | getChannel(): vscode.OutputChannel; 15 | } 16 | 17 | class TerraformChannel implements ITerraformChannel { 18 | private readonly channel: vscode.OutputChannel = 19 | vscode.window.createOutputChannel("Microsoft Terraform"); 20 | 21 | public appendLine(message: any, title?: string): void { 22 | if (title) { 23 | const simplifiedTime = new Date() 24 | .toISOString() 25 | .replace(/z|t/gi, " ") 26 | .trim(); // YYYY-MM-DD HH:mm:ss.sss 27 | const hightlightingTitle = `[${title} ${simplifiedTime}]`; 28 | this.channel.appendLine(hightlightingTitle); 29 | } 30 | this.channel.appendLine(message); 31 | } 32 | 33 | public append(message: any): void { 34 | this.channel.append(message); 35 | } 36 | 37 | public show(): void { 38 | this.channel.show(); 39 | } 40 | 41 | public getChannel(): vscode.OutputChannel { 42 | return this.channel; 43 | } 44 | } 45 | 46 | export const terraformChannel: ITerraformChannel = new TerraformChannel(); 47 | -------------------------------------------------------------------------------- /src/terraformExport.ts: -------------------------------------------------------------------------------- 1 | import { GenericResourceExpanded } from "@azure/arm-resources"; 2 | import { AzureSubscription } from "@microsoft/vscode-azext-azureauth"; 3 | import axios, { AxiosError } from "axios"; 4 | import * as vscode from "vscode"; 5 | import { getAccessTokenFromSubscription } from "./auth"; 6 | 7 | interface IExportRequestBody { 8 | type: "ExportResource" | "ExportResourceGroup"; 9 | targetProvider: string; 10 | resourceIds?: string[]; 11 | resourceGroupName?: string; 12 | resourceName?: string; 13 | } 14 | 15 | interface IPollingStatusResponse { 16 | status: string; 17 | id?: string; 18 | name?: string; 19 | startTime?: string; 20 | endTime?: string; 21 | error?: { 22 | code: string; 23 | message: string; 24 | }; 25 | properties?: { 26 | configuration?: string; 27 | import?: string; 28 | skippedResources?: Array<{ 29 | id?: string; 30 | reason?: string; 31 | [key: string]: any; 32 | }>; // More specific type 33 | errors?: Array<{ 34 | code?: string; 35 | message?: string; 36 | target?: string; 37 | [key: string]: any; 38 | }>; // More specific type 39 | }; 40 | } 41 | 42 | function sleep(ms: number): Promise { 43 | return new Promise((resolve) => setTimeout(resolve, ms)); 44 | } 45 | 46 | async function requestTerraformExportWithPolling( 47 | subscription: AzureSubscription, 48 | requestBody: IExportRequestBody, 49 | progress: vscode.Progress<{ message?: string; increment?: number }>, 50 | token: vscode.CancellationToken, 51 | operationDescription: string 52 | ): Promise { 53 | progress.report({ 54 | message: `Requesting access token for ${operationDescription}...`, 55 | }); 56 | const accessToken = await getAccessTokenFromSubscription( 57 | subscription, 58 | "https://management.azure.com/.default" 59 | ); 60 | if (!accessToken) { 61 | throw new Error("Failed to acquire Azure access token."); 62 | } 63 | 64 | const exportApiUrl = `https://management.azure.com/subscriptions/${subscription.subscriptionId}/providers/Microsoft.AzureTerraform/exportTerraform?api-version=2023-07-01-preview`; 65 | const headers = { 66 | Authorization: `Bearer ${accessToken.token}`, 67 | "Content-Type": "application/json", 68 | }; 69 | 70 | progress.report({ 71 | message: `Initiating Terraform export for ${operationDescription}...`, 72 | }); 73 | let response; 74 | try { 75 | response = await axios.post(exportApiUrl, requestBody, { 76 | headers, 77 | timeout: 45000, // Increased timeout for the initial request 78 | validateStatus: (status) => status === 200 || status === 202, 79 | }); 80 | } catch (error) { 81 | handleExportError(error, operationDescription); 82 | throw error; 83 | } 84 | 85 | if (response.status === 200) { 86 | progress.report({ 87 | message: `Export for ${operationDescription} completed synchronously.`, 88 | increment: 100, 89 | }); 90 | // Ensure structure aligns with IPollingStatusResponse for consistent handling in handleExportSuccess 91 | return { 92 | status: "Succeeded", // Explicitly set status 93 | id: response.data?.id, // Pass along if available 94 | name: response.data?.name, // Pass along if available 95 | startTime: response.data?.startTime, // Pass along if available 96 | endTime: response.data?.endTime, // Pass along if available 97 | properties: { 98 | configuration: response.data?.properties?.configuration, 99 | import: response.data?.properties?.import, 100 | skippedResources: response.data?.properties?.skippedResources || [], // Default to empty array 101 | errors: response.data?.properties?.errors || [], // Default to empty array 102 | }, 103 | }; 104 | } 105 | 106 | // --- Start Polling --- 107 | if (response.status === 202) { 108 | const locationUrl = response.headers["location"]; 109 | let retryAfter = parseInt(response.headers["retry-after"] || "15", 10); // Default to 15 seconds 110 | 111 | if (!locationUrl) { 112 | throw new Error( 113 | `Export for ${operationDescription} started (202 Accepted) but no Location header received for polling.` 114 | ); 115 | } 116 | 117 | progress.report({ 118 | message: `Export for ${operationDescription} in progress, polling for results...`, 119 | increment: 10, 120 | }); 121 | const maxPollingTime = Date.now() + 5 * 60 * 1000; // 5 minutes timeout for polling 122 | 123 | while (Date.now() < maxPollingTime) { 124 | if (token.isCancellationRequested) { 125 | throw new Error( 126 | `Export operation for ${operationDescription} cancelled by user.` 127 | ); 128 | } 129 | 130 | await sleep(retryAfter * 1000); 131 | 132 | progress.report({ 133 | message: `Polling status for ${operationDescription}...`, 134 | increment: 5, 135 | }); // Simplified message 136 | 137 | try { 138 | const pollResponse = await axios.get( 139 | locationUrl, 140 | { 141 | headers, 142 | timeout: 30000, 143 | } 144 | ); 145 | 146 | const operationStatus = pollResponse.data?.status; 147 | 148 | if (operationStatus === "Succeeded") { 149 | progress.report({ 150 | message: `Export for ${operationDescription} succeeded.`, 151 | increment: 100, 152 | }); 153 | return pollResponse.data; 154 | } else if (operationStatus === "Failed") { 155 | const errorDetails = 156 | pollResponse.data?.error?.message || 157 | "Unknown error during async operation."; 158 | // Attempt to get more specific errors from properties if available 159 | let detailedErrorsMessage = ""; 160 | if ( 161 | pollResponse.data?.properties?.errors && 162 | Array.isArray(pollResponse.data.properties.errors) && 163 | pollResponse.data.properties.errors.length > 0 164 | ) { 165 | detailedErrorsMessage = pollResponse.data.properties.errors 166 | .map( 167 | (e) => 168 | `${e.code || "Error"}: ${e.message || "Details not provided"}` 169 | ) 170 | .join("; "); 171 | } 172 | throw new Error( 173 | `Export operation for ${operationDescription} failed on Azure: ${errorDetails}${ 174 | detailedErrorsMessage ? `. Details: ${detailedErrorsMessage}` : "" 175 | }` 176 | ); 177 | } else if (operationStatus === "Canceled") { 178 | throw new Error( 179 | `Export operation for ${operationDescription} was canceled on the server.` 180 | ); 181 | } 182 | 183 | progress.report({ 184 | message: `Export for ${operationDescription} still running...`, 185 | increment: 2, 186 | }); // Small increment while running 187 | 188 | retryAfter = parseInt( 189 | pollResponse.headers["retry-after"] || `${retryAfter}`, 190 | 10 191 | ); 192 | } catch (pollError) { 193 | if (axios.isAxiosError(pollError)) { 194 | if ( 195 | pollError.response?.status === 404 && 196 | Date.now() > maxPollingTime - 60000 197 | ) { 198 | console.warn( 199 | `Polling URL for ${operationDescription} returned 404 near timeout, assuming failure.` 200 | ); 201 | throw new Error( 202 | `Polling for ${operationDescription} failed (URL not found near timeout).` 203 | ); 204 | } 205 | // Include status code for other polling errors 206 | const statusText = pollError.response?.status 207 | ? ` (Status: ${pollError.response.status})` 208 | : ""; 209 | throw new Error( 210 | `Error during polling for ${operationDescription}${statusText}: ${pollError.message}` 211 | ); 212 | } 213 | // Re-throw non-Axios errors 214 | throw pollError; 215 | } 216 | } 217 | // If loop finishes without success 218 | throw new Error( 219 | `Export operation for ${operationDescription} timed out after 5 minutes.` 220 | ); 221 | } 222 | 223 | // Fallback for unexpected status codes 224 | throw new Error( 225 | `Unexpected initial response status for ${operationDescription}: ${response.status}` 226 | ); 227 | } 228 | 229 | async function handleExportSuccess( 230 | pollingResult: IPollingStatusResponse, 231 | operationDescription: string 232 | ): Promise { 233 | const terraformConfig = pollingResult.properties?.configuration; 234 | const importBlock = pollingResult.properties?.import; 235 | // Ensure skippedResources and errors are arrays for consistent processing 236 | const skippedResources = Array.isArray( 237 | pollingResult.properties?.skippedResources 238 | ) 239 | ? pollingResult.properties.skippedResources 240 | : []; 241 | const errors = Array.isArray(pollingResult.properties?.errors) 242 | ? pollingResult.properties.errors 243 | : []; 244 | 245 | if (!terraformConfig) { 246 | vscode.window.showWarningMessage( 247 | `Export for ${operationDescription} completed, but the response did not contain Terraform configuration.` 248 | ); 249 | console.warn( 250 | "Export Success Response (missing configuration):", 251 | pollingResult 252 | ); 253 | return; 254 | } 255 | 256 | let commentBlock = `# Terraform Export Details for: ${operationDescription}\n`; 257 | commentBlock += `# Operation ID: ${pollingResult.id || "N/A"}\n`; 258 | commentBlock += `# Operation Name: ${pollingResult.name || "N/A"}\n`; 259 | commentBlock += `# Start Time: ${pollingResult.startTime || "N/A"}\n`; 260 | commentBlock += `# End Time: ${pollingResult.endTime || "N/A"}\n`; 261 | 262 | if (skippedResources.length > 0) { 263 | commentBlock += `# Skipped Resources: ${JSON.stringify( 264 | skippedResources 265 | )}\n`; 266 | } else { 267 | commentBlock += `# Skipped Resources: None\n`; 268 | } 269 | 270 | if (errors.length > 0) { 271 | commentBlock += `# Errors Encountered (non-fatal or informational): ${JSON.stringify( 272 | errors 273 | )}\n`; 274 | } else { 275 | commentBlock += `# Errors Encountered: None\n`; 276 | } 277 | 278 | if (importBlock) { 279 | commentBlock += `#\n# Associated Import Block:\n`; 280 | commentBlock += importBlock 281 | .split("\n") 282 | .map((line) => `# ${line}`) 283 | .join("\n"); 284 | commentBlock += "\n"; 285 | } 286 | commentBlock += "# --- End of Export Details ---\n\n"; 287 | 288 | const fullSnippet = commentBlock + terraformConfig; 289 | 290 | try { 291 | const doc = await vscode.workspace.openTextDocument({ 292 | content: fullSnippet, 293 | language: "terraform", 294 | }); 295 | await vscode.window.showTextDocument(doc, { preview: false }); 296 | 297 | vscode.window.showInformationMessage( 298 | `Terraform export for ${operationDescription} complete. Snippet opened in a new tab.` 299 | ); 300 | 301 | // Optionally show warnings if resources were skipped or non-fatal errors occurred 302 | if (skippedResources.length > 0) { 303 | vscode.window.showWarningMessage( 304 | `Some resources were skipped during the export for ${operationDescription}. See comments in the generated file.` 305 | ); 306 | } 307 | // Note: 'errors' here are from the 'properties' block, typically non-fatal if status is 'Succeeded'. 308 | // Fatal errors would have thrown an exception earlier. 309 | if (errors.length > 0) { 310 | vscode.window.showWarningMessage( 311 | `Some non-fatal issues or informational messages were reported during the export for ${operationDescription}. See comments in the generated file.` 312 | ); 313 | } 314 | } catch (error) { 315 | const typedError = error as Error; 316 | console.error( 317 | `Failed to open exported snippet for ${operationDescription}:`, 318 | typedError 319 | ); 320 | vscode.window.showErrorMessage( 321 | `Failed to open snippet for ${operationDescription} in a new tab: ${typedError.message}. It has been copied to your clipboard instead.` 322 | ); 323 | await vscode.env.clipboard.writeText(fullSnippet); 324 | } 325 | } 326 | 327 | function handleExportError(error: any, operationDescription: string): void { 328 | console.error(`Terraform Export Error (${operationDescription}):`, error); 329 | let userMessage = `Failed to export ${operationDescription}.`; 330 | let detailedMessage = error instanceof Error ? error.message : String(error); // Default to error.message 331 | 332 | if (axios.isAxiosError(error)) { 333 | const axiosError = error as AxiosError; 334 | userMessage = `Network or API error during export for ${operationDescription}.`; 335 | detailedMessage = `Request to ${ 336 | axiosError.config?.url || "Azure API" 337 | } failed`; 338 | if (axiosError.response) { 339 | detailedMessage += ` with status ${axiosError.response.status}.`; 340 | const responseData = axiosError.response.data; 341 | const azureError = responseData?.error?.message || responseData?.Message; 342 | let specificDetails = ""; 343 | // Check for Azure's structured error details within properties.errors as well 344 | if ( 345 | responseData?.properties?.errors && 346 | Array.isArray(responseData.properties.errors) && 347 | responseData.properties.errors.length > 0 348 | ) { 349 | specificDetails = responseData.properties.errors 350 | .map( 351 | (e: any) => 352 | `${e.code || "Error"}: ${e.message || "Details not provided"}` 353 | ) 354 | .join("; "); 355 | } else if (azureError) { 356 | specificDetails = azureError; 357 | } 358 | 359 | if (specificDetails) { 360 | detailedMessage += ` Azure Error: ${specificDetails}`; 361 | } else if ( 362 | typeof responseData === "string" && 363 | responseData.length < 200 && 364 | responseData.length > 0 365 | ) { 366 | detailedMessage += ` Details: ${responseData}`; 367 | } else { 368 | detailedMessage += ` Check the extension's output channel or console log for full response details.`; 369 | console.error("Full Axios Error Response Data:", responseData); 370 | } 371 | } else if (axiosError.request) { 372 | detailedMessage += `. No response received. Check network or Azure service status.`; 373 | } else { 374 | detailedMessage += `. Error setting up request: ${axiosError.message}`; 375 | } 376 | } else if (error instanceof Error) { 377 | // This will now catch errors thrown from polling logic with more details 378 | userMessage = `Export process for ${operationDescription} encountered an issue.`; 379 | // detailedMessage is already set from error.message which should be more specific now 380 | } else { 381 | userMessage = `An unexpected issue occurred during export for ${operationDescription}.`; 382 | detailedMessage = `Check the extension's output channel or console log for details.`; 383 | console.error("Unexpected Export Error Type:", error); 384 | } 385 | 386 | vscode.window.showErrorMessage(`${userMessage} ${detailedMessage}`, { 387 | modal: false, 388 | }); 389 | } 390 | 391 | /** 392 | * Exports a single Azure resource to Terraform. 393 | */ 394 | export async function ExportSingleResource( 395 | subscription: AzureSubscription, 396 | resource: GenericResourceExpanded, 397 | targetProvider: string 398 | ): Promise { 399 | const operationType = `resource '${resource.name || "unnamed-resource"}'`; 400 | await vscode.window.withProgress( 401 | { 402 | location: vscode.ProgressLocation.Notification, 403 | title: `Exporting Azure resource to Terraform`, 404 | cancellable: true, // Allow cancellation during polling 405 | }, 406 | async (progress, token) => { 407 | try { 408 | if (!resource.id) { 409 | throw new Error("Selected resource is missing a valid ID."); 410 | } 411 | 412 | progress.report({ 413 | message: `Preparing export for ${operationType}...`, 414 | }); 415 | 416 | const requestBody: IExportRequestBody = { 417 | type: "ExportResource", 418 | targetProvider: targetProvider, 419 | resourceIds: [resource.id], 420 | resourceName: resource.name || "unnamed-resource", 421 | }; 422 | 423 | // Call the function that handles polling 424 | const finalPollingData = await requestTerraformExportWithPolling( 425 | subscription, 426 | requestBody, 427 | progress, 428 | token, 429 | operationType 430 | ); 431 | // Handle the successful result 432 | await handleExportSuccess(finalPollingData, operationType); 433 | } catch (error) { 434 | // Handle errors from preparation or polling 435 | handleExportError(error, operationType); 436 | } 437 | } 438 | ); 439 | } 440 | 441 | /** 442 | * Exports all resources within a specified Azure resource group to Terraform. 443 | */ 444 | export async function ExportResourceGroup( 445 | subscription: AzureSubscription, 446 | resources: GenericResourceExpanded[], 447 | resourceGroupName: string, 448 | targetProvider: string 449 | ): Promise { 450 | const operationType = `resource group '${resourceGroupName}'`; 451 | await vscode.window.withProgress( 452 | { 453 | location: vscode.ProgressLocation.Notification, 454 | title: `Exporting Azure resource group to Terraform`, 455 | cancellable: true, // Allow cancellation during polling 456 | }, 457 | async (progress, token) => { 458 | try { 459 | if (!resourceGroupName) { 460 | throw new Error( 461 | "Resource group name is required for exporting an entire group." 462 | ); 463 | } 464 | if (resources.length === 0) { 465 | // Report progress instead of just showing info message 466 | progress.report({ 467 | message: `Resource group '${resourceGroupName}' is empty. Nothing to export.`, 468 | increment: 100, 469 | }); 470 | vscode.window.showInformationMessage( 471 | `Resource group '${resourceGroupName}' is empty. Nothing to export.` 472 | ); 473 | return; 474 | } 475 | 476 | progress.report({ 477 | message: `Preparing export for ${resources.length} resources in ${operationType}...`, 478 | }); 479 | 480 | const requestBody: IExportRequestBody = { 481 | type: "ExportResourceGroup", 482 | targetProvider: targetProvider, 483 | resourceGroupName, 484 | }; 485 | 486 | // Call the function that handles polling 487 | const finalPollingData = await requestTerraformExportWithPolling( 488 | subscription, 489 | requestBody, 490 | progress, 491 | token, 492 | operationType 493 | ); 494 | // Handle the successful result 495 | await handleExportSuccess(finalPollingData, operationType); 496 | } catch (error) { 497 | // Handle errors from preparation or polling 498 | handleExportError(error, operationType); 499 | } 500 | } 501 | ); 502 | } 503 | -------------------------------------------------------------------------------- /src/terraformShellManager.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | "use strict"; 7 | 8 | import * as TelemetryWrapper from "vscode-extension-telemetry-wrapper"; 9 | import { BaseShell } from "./baseShell"; 10 | import { AzureCloudShell } from "./cloudShell"; 11 | import { IntegratedShell } from "./integratedShell"; 12 | import { isTerminalSetToCloudShell } from "./utils/settingUtils"; 13 | 14 | export interface ITerraformShellManager { 15 | getShell(): BaseShell; 16 | getCloudShell(): AzureCloudShell; 17 | getIntegratedShell(): IntegratedShell; 18 | dispose(): void; 19 | } 20 | 21 | class TerraformShellManager implements ITerraformShellManager { 22 | private readonly cloudShell = new AzureCloudShell(); 23 | private readonly integratedShell = new IntegratedShell(); 24 | 25 | public getShell(): BaseShell { 26 | const isCloudShell: boolean = isTerminalSetToCloudShell(); 27 | 28 | TelemetryWrapper.addContextProperty( 29 | "isCloudShell", 30 | isCloudShell.toString() 31 | ); 32 | if (isCloudShell) { 33 | return this.cloudShell; 34 | } 35 | return this.integratedShell; 36 | } 37 | 38 | public getCloudShell(): AzureCloudShell { 39 | return this.cloudShell; 40 | } 41 | 42 | public getIntegratedShell(): IntegratedShell { 43 | return this.integratedShell; 44 | } 45 | 46 | public dispose(): void { 47 | this.cloudShell.dispose(); 48 | this.integratedShell.dispose(); 49 | } 50 | } 51 | 52 | export const terraformShellManager: ITerraformShellManager = 53 | new TerraformShellManager(); 54 | -------------------------------------------------------------------------------- /src/test/helper.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as path from 'path'; 3 | 4 | export let doc: vscode.TextDocument; 5 | export let editor: vscode.TextEditor; 6 | export let documentEol: string; 7 | export let platformEol: string; 8 | 9 | export async function open(docUri: vscode.Uri): Promise { 10 | try { 11 | doc = await vscode.workspace.openTextDocument(docUri); 12 | editor = await vscode.window.showTextDocument(doc); 13 | } catch (e) { 14 | console.error(e); 15 | throw e; 16 | } 17 | } 18 | 19 | export function getExtensionId(): string { 20 | var pjson = require('../../package.json'); 21 | return `${pjson.publisher}.${pjson.name}`; 22 | } 23 | 24 | export const testFolderPath = path.resolve(__dirname, '..', '..', 'testFixture'); 25 | 26 | export const getDocPath = (p: string): string => { 27 | return path.resolve(__dirname, '../../testFixture', p); 28 | }; 29 | export const getDocUri = (p: string): vscode.Uri => { 30 | return vscode.Uri.file(getDocPath(p)); 31 | }; 32 | 33 | export async function setTestContent(content: string): Promise { 34 | const all = new vscode.Range(doc.positionAt(0), doc.positionAt(doc.getText().length)); 35 | return editor.edit((eb) => eb.replace(all, content)); 36 | } 37 | -------------------------------------------------------------------------------- /src/test/integration/completion.test.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as assert from 'assert'; 3 | import { expect } from 'chai'; 4 | import { getDocUri, open } from '../helper'; 5 | 6 | suite('completion', () => { 7 | teardown(async () => { 8 | await vscode.commands.executeCommand('workbench.action.closeAllEditors'); 9 | }); 10 | 11 | test('templates completion', async () => { 12 | const docUri = getDocUri('templates-completion.tf'); 13 | await open(docUri); 14 | await new Promise((r) => setTimeout(r, 1000 * 15)); 15 | const list = await vscode.commands.executeCommand( 16 | 'vscode.executeCompletionItemProvider', 17 | docUri, 18 | new vscode.Position(22 - 1, 9 - 1), 19 | ); 20 | 21 | assert.ok(list); 22 | expect(list).not.to.be.undefined; 23 | expect(list.items).not.to.be.undefined; 24 | expect(list.items.length).to.be.greaterThanOrEqual(100); 25 | }); 26 | }); -------------------------------------------------------------------------------- /src/test/integration/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as Mocha from 'mocha'; 3 | import * as glob from 'glob'; 4 | 5 | export async function run(): Promise { 6 | // Create the mocha test 7 | const mocha = new Mocha({ 8 | ui: 'tdd', 9 | }); 10 | // integration tests require long activation time 11 | mocha.timeout(100000); 12 | 13 | // const testsRoot = path.resolve(__dirname, '..'); 14 | const testsRoot = path.resolve(__dirname); 15 | 16 | return new Promise((resolve, reject) => { 17 | glob('**/**.test.js', { cwd: testsRoot }, (err, files) => { 18 | if (err) { 19 | return reject(err); 20 | } 21 | 22 | // Add files to the test suite 23 | files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))); 24 | 25 | try { 26 | // Run the mocha test 27 | mocha.run((failures) => { 28 | if (failures > 0) { 29 | reject(new Error(`${failures} tests failed.`)); 30 | } else { 31 | resolve(); 32 | } 33 | }); 34 | } catch (err) { 35 | console.error(err); 36 | reject(err); 37 | } 38 | }); 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /src/test/runTest.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { runTests } from '@vscode/test-electron'; 3 | import { TestOptions } from '@vscode/test-electron/out/runTest'; 4 | 5 | async function main(): Promise { 6 | // The folder containing the Extension Manifest package.json 7 | // Passed to `--extensionDevelopmentPath` 8 | // this is also the process working dir, even if vscode opens another folder 9 | const extensionDevelopmentPath = path.resolve(__dirname, '../../'); 10 | 11 | // The path to the extension test runner script 12 | // Passed to --extensionTestsPath 13 | const extensionTestsPath = path.resolve(__dirname, './integration/index'); 14 | 15 | // common options for all runners 16 | const options: TestOptions = { 17 | extensionDevelopmentPath, 18 | extensionTestsPath, 19 | launchArgs: ['testFixture', '--disable-extensions', '--disable-workspace-trust'], 20 | }; 21 | 22 | try { 23 | // Download VS Code, unzip it and run the integration test 24 | // start in the fixtures folder to prevent the language server from walking all the 25 | // project root folders, like node_modules 26 | await runTests(options); 27 | } catch (err) { 28 | console.error(err); 29 | console.error('Failed to run tests'); 30 | process.exitCode = 1; 31 | } 32 | } 33 | 34 | main(); 35 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Defines our experimental capabilities provided by the client. 3 | */ 4 | export interface ExperimentalClientCapabilities { 5 | experimental: { 6 | telemetryVersion?: number; 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/cloudShellUtils.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | "use strict"; 7 | 8 | import * as request from "request-promise-native"; 9 | import * as TelemetryWrapper from "vscode-extension-telemetry-wrapper"; 10 | import { AzureSession, CloudShell } from "../azure-account.api"; 11 | 12 | export interface IStorageAccount { 13 | resourceGroup: string; 14 | storageAccountName: string; 15 | fileShareName: string; 16 | storageAccountKey: string; 17 | } 18 | 19 | export async function getStorageAccountforCloudShell( 20 | cloudShell: CloudShell 21 | ): Promise { 22 | const session: AzureSession = await cloudShell.session; 23 | const token: IToken = await acquireToken(session); 24 | const userSettings: IUserSettings | undefined = await getUserSettings( 25 | token.accessToken, 26 | session.environment.resourceManagerEndpointUrl 27 | ); 28 | if (!userSettings) { 29 | TelemetryWrapper.sendError(Error("getUserSettingsFail")); 30 | return; 31 | } 32 | const storageProfile: any = userSettings.storageProfile; 33 | const storageAccountSettings: any = storageProfile.storageAccountResourceId 34 | .substr(1, storageProfile.storageAccountResourceId.length) 35 | .split("/"); 36 | const storageAccountKey: string | undefined = await getStorageAccountKey( 37 | token.accessToken, 38 | storageAccountSettings[1], 39 | storageAccountSettings[3], 40 | storageAccountSettings[7] 41 | ); 42 | 43 | if (!storageAccountKey) { 44 | TelemetryWrapper.sendError(Error("getStorageAccountKeyFail")); 45 | return; 46 | } 47 | 48 | return { 49 | resourceGroup: storageAccountSettings[3], 50 | storageAccountName: storageAccountSettings[7], 51 | fileShareName: storageProfile.fileShareName, 52 | storageAccountKey, 53 | }; 54 | } 55 | 56 | interface IUserSettings { 57 | preferredLocation: string; 58 | preferredOsType: string; // The last OS chosen in the portal. 59 | storageProfile: any; 60 | } 61 | 62 | interface IToken { 63 | session: AzureSession; 64 | accessToken: string; 65 | refreshToken: string; 66 | } 67 | 68 | async function acquireToken(session: AzureSession): Promise { 69 | return new Promise((resolve, reject) => { 70 | const credentials: any = session.credentials; 71 | const environment: any = session.environment; 72 | credentials.context.acquireToken( 73 | environment.activeDirectoryResourceId, 74 | credentials.username, 75 | credentials.clientId, 76 | (err: any, result: any) => { 77 | if (err) { 78 | reject(err); 79 | } else { 80 | resolve({ 81 | session, 82 | accessToken: result.accessToken, 83 | refreshToken: result.refreshToken, 84 | }); 85 | } 86 | } 87 | ); 88 | }); 89 | } 90 | 91 | const consoleApiVersion = "2017-08-01-preview"; 92 | async function getUserSettings( 93 | accessToken: string, 94 | armEndpoint: string 95 | ): Promise { 96 | const targetUri = `${armEndpoint}/providers/Microsoft.Portal/userSettings/cloudconsole?api-version=${consoleApiVersion}`; 97 | const response = await request({ 98 | uri: targetUri, 99 | method: "GET", 100 | headers: { 101 | Accept: "application/json", 102 | "Content-Type": "application/json", 103 | Authorization: `Bearer ${accessToken}`, 104 | }, 105 | simple: false, 106 | resolveWithFullResponse: true, 107 | json: true, 108 | }); 109 | 110 | if (response.statusCode < 200 || response.statusCode > 299) { 111 | return; 112 | } 113 | 114 | return response.body && response.body.properties; 115 | } 116 | 117 | async function getStorageAccountKey( 118 | accessToken: string, 119 | subscriptionId: string, 120 | resourceGroup: string, 121 | storageAccountName: string 122 | ): Promise { 123 | const response = await request({ 124 | uri: `https://management.azure.com/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Storage/storageAccounts/${storageAccountName}/listKeys?api-version=2017-06-01`, 125 | method: "POST", 126 | headers: { 127 | "Content-Type": "application/json", 128 | Authorization: `Bearer ${accessToken}`, 129 | }, 130 | simple: false, 131 | resolveWithFullResponse: true, 132 | json: true, 133 | }); 134 | 135 | if (response.statusCode < 200 || response.statusCode > 299) { 136 | return; 137 | } 138 | 139 | return ( 140 | response.body && 141 | response.body.keys && 142 | response.body.keys[0] && 143 | response.body.keys[0].value 144 | ); 145 | } 146 | -------------------------------------------------------------------------------- /src/utils/cpUtils.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | "use strict"; 7 | 8 | import * as cp from "child_process"; 9 | import { terraformChannel } from "../terraformChannel"; 10 | 11 | export async function executeCommand( 12 | command: string, 13 | args: string[], 14 | options: cp.SpawnOptions 15 | ): Promise { 16 | return new Promise( 17 | (resolve: (res: string) => void, reject: (e: Error) => void): void => { 18 | let result = ""; 19 | const childProc: cp.ChildProcess = cp.spawn(command, args, options); 20 | 21 | childProc.stdout.on("data", (data: string | Buffer) => { 22 | data = data.toString(); 23 | result = result.concat(data); 24 | terraformChannel.append(data); 25 | }); 26 | 27 | childProc.stderr.on("data", (data: string | Buffer) => 28 | terraformChannel.append(data.toString()) 29 | ); 30 | 31 | childProc.on("error", reject); 32 | childProc.on("close", (code: number) => { 33 | if (code !== 0) { 34 | reject( 35 | new Error( 36 | `Command "${command} ${args.toString()}" failed with exit code "${code}".` 37 | ) 38 | ); 39 | } else { 40 | resolve(result); 41 | } 42 | }); 43 | } 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/utils/dockerUtils.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | "use strict"; 7 | 8 | import { terraformShellManager } from "../terraformShellManager"; 9 | import { executeCommand } from "./cpUtils"; 10 | import { DialogType, openUrlHint, promptForOpenOutputChannel } from "./uiUtils"; 11 | 12 | export async function isDockerInstalled(): Promise { 13 | try { 14 | await executeCommand("docker", ["-v"], { shell: true }); 15 | return true; 16 | } catch (error) { 17 | openUrlHint( 18 | "Docker is not installed, please install Docker to continue.", 19 | "https://www.docker.com" 20 | ); 21 | return false; 22 | } 23 | } 24 | 25 | export async function runLintInDocker( 26 | volumn: string, 27 | containerName: string 28 | ): Promise { 29 | try { 30 | if (!(await pullLatestImage(containerName))) { 31 | return false; 32 | } 33 | const cmd = `docker run -v ${volumn} --rm ${containerName} rake -f ../Rakefile build`; 34 | terraformShellManager.getIntegratedShell().runTerraformCmd(cmd); 35 | return true; 36 | } catch (error) { 37 | promptForOpenOutputChannel( 38 | "Failed to run lint task in Docker. Please open the output channel for more details.", 39 | DialogType.error 40 | ); 41 | return false; 42 | } 43 | } 44 | 45 | export async function runE2EInDocker( 46 | volumn: string, 47 | containerName: string 48 | ): Promise { 49 | try { 50 | if (!(await pullLatestImage(containerName))) { 51 | return false; 52 | } 53 | const cmd: string = 54 | `docker run -v ${volumn} ` + 55 | `-e ARM_CLIENT_ID ` + 56 | `-e ARM_TENANT_ID ` + 57 | `-e ARM_SUBSCRIPTION_ID ` + 58 | `-e ARM_CLIENT_SECRET ` + 59 | `-e ARM_TEST_LOCATION ` + 60 | `-e ARM_TEST_LOCATION_ALT ` + 61 | `--rm ${containerName} /bin/bash -c ` + 62 | `"ssh-keygen -t rsa -b 2048 -C terraformTest -f /root/.ssh/id_rsa -N ''; rake -f ../Rakefile e2e"`; 63 | terraformShellManager.getIntegratedShell().runTerraformCmd(cmd); 64 | return true; 65 | } catch (error) { 66 | promptForOpenOutputChannel( 67 | "Failed to run end to end tests in Docker. Please open the output channel for more details.", 68 | DialogType.error 69 | ); 70 | return false; 71 | } 72 | } 73 | 74 | export async function runCustomCommandInDocker( 75 | cmd: string, 76 | containerName: string 77 | ): Promise { 78 | try { 79 | if (!(await pullLatestImage(containerName))) { 80 | return false; 81 | } 82 | await executeCommand("docker", cmd.split(" "), { shell: true }); 83 | return true; 84 | } catch (error) { 85 | promptForOpenOutputChannel( 86 | "Failed to run the custom command in Docker. Please open the output channel for more details.", 87 | DialogType.error 88 | ); 89 | return false; 90 | } 91 | } 92 | 93 | async function pullLatestImage(image: string): Promise { 94 | try { 95 | await executeCommand("docker", ["pull", `${image}:latest`], { 96 | shell: true, 97 | }); 98 | return true; 99 | } catch (error) { 100 | promptForOpenOutputChannel( 101 | `Failed to pull the latest image: ${image}. Please open the output channel for more details.`, 102 | DialogType.error 103 | ); 104 | return false; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/utils/dotUtils.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | "use strict"; 7 | 8 | import { executeCommand } from "./cpUtils"; 9 | import { openUrlHint } from "./uiUtils"; 10 | 11 | export async function isDotInstalled(): Promise { 12 | try { 13 | await executeCommand("dot", ["-V"], { shell: true }); 14 | return true; 15 | } catch (error) { 16 | openUrlHint( 17 | "GraphViz is not installed, please make sure GraphViz is in the PATH environment variable.", 18 | "https://aka.ms/azTerraform-requirement" 19 | ); 20 | return false; 21 | } 22 | } 23 | 24 | export async function drawGraph( 25 | workingDirectory: string, 26 | inputFile: string 27 | ): Promise { 28 | await executeCommand("dot", ["-Tpng", "-o", "graph.png", inputFile], { 29 | cwd: workingDirectory, 30 | shell: true, 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/icon.ts: -------------------------------------------------------------------------------- 1 | export function getIconForResourceType(resourceType?: string): string { 2 | if (!resourceType) { 3 | return "$(symbol-misc)"; 4 | } // Default icon 5 | 6 | const typeLower = resourceType.toLowerCase(); 7 | 8 | if (typeLower.includes("virtualmachines")) { 9 | return "$(vm)"; 10 | } 11 | if (typeLower.includes("storageaccounts")) { 12 | return "$(database)"; 13 | } 14 | if (typeLower.includes("networkinterfaces")) { 15 | return "$(plug)"; 16 | } 17 | if (typeLower.includes("virtualnetworks")) { 18 | return "$(circuit-board)"; 19 | } 20 | if (typeLower.includes("publicipaddresses")) { 21 | return "$(globe)"; 22 | } 23 | if (typeLower.includes("sql/servers")) { 24 | return "$(server-process)"; 25 | } 26 | if (typeLower.includes("keyvaults")) { 27 | return "$(key)"; 28 | } 29 | if (typeLower.includes("appservice/plans")) { 30 | return "$(server-environment)"; 31 | } 32 | if (typeLower.includes("web/sites")) { 33 | return "$(browser)"; 34 | } 35 | 36 | return "$(symbol-misc)"; // Default for unmapped types 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/settingUtils.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import * as vscode from "vscode"; 7 | import { ConfigurationTarget } from "vscode"; 8 | 9 | export function isTerminalSetToCloudShell(): boolean { 10 | if ( 11 | vscode.workspace.getConfiguration().get("azureTerraform.terminal") === 12 | "cloudshell" 13 | ) { 14 | vscode.window.showInformationMessage( 15 | "Cloud Shell is no longer supported by Microsoft Terraform extension due to deprecation of Azure Account extension. Defaulting to integrated terminal." 16 | ); 17 | vscode.workspace 18 | .getConfiguration() 19 | .update( 20 | "azureTerraform.terminal", 21 | "integrated", 22 | ConfigurationTarget.Global 23 | ); 24 | } 25 | 26 | return false; 27 | } 28 | 29 | export function getSyncFileBlobPattern(): vscode.GlobPattern { 30 | return vscode.workspace.getConfiguration().get("azureTerraform.files"); 31 | } 32 | 33 | export function getResourceGroupForTest(): string { 34 | return vscode.workspace 35 | .getConfiguration() 36 | .get("azureTerraform.test.aciResourceGroup"); 37 | } 38 | 39 | export function getAciNameForTest(): string { 40 | return vscode.workspace.getConfiguration().get("azureTerraform.test.aciName"); 41 | } 42 | 43 | export function getAciGroupForTest(): string { 44 | return vscode.workspace 45 | .getConfiguration() 46 | .get("azureTerraform.aciContainerGroup"); 47 | } 48 | 49 | export function getLocationForTest(): string { 50 | return vscode.workspace 51 | .getConfiguration() 52 | .get("azureTerraform.test.location"); 53 | } 54 | 55 | export function getImageNameForTest(): string { 56 | return vscode.workspace 57 | .getConfiguration() 58 | .get("azureTerraform.test.imageName"); 59 | } 60 | 61 | export function getCheckTerraformCmd(): boolean { 62 | return vscode.workspace 63 | .getConfiguration() 64 | .get("azureTerraform.checkTerraformCmd"); 65 | } 66 | 67 | export function setCheckTerraformCmd(checked: boolean): void { 68 | vscode.workspace 69 | .getConfiguration() 70 | .update("azureTerraform.checkTerraformCmd", checked); 71 | } 72 | 73 | export function getSurvey(): any { 74 | return vscode.workspace.getConfiguration().get("azureTerraform.survey"); 75 | } 76 | 77 | export function setSurvey(survey: any): void { 78 | vscode.workspace 79 | .getConfiguration() 80 | .update("azureTerraform.survey", survey, ConfigurationTarget.Global); 81 | } 82 | -------------------------------------------------------------------------------- /src/utils/terraformUtils.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | "use strict"; 7 | 8 | import { executeCommand } from "./cpUtils"; 9 | import * as settingUtils from "./settingUtils"; 10 | import { openUrlHintOrNotShowAgain } from "./uiUtils"; 11 | 12 | export async function checkTerraformInstalled(): Promise { 13 | if ( 14 | settingUtils.isTerminalSetToCloudShell() || 15 | !settingUtils.getCheckTerraformCmd() 16 | ) { 17 | return; 18 | } 19 | try { 20 | await executeCommand("terraform", ["-v"], { shell: true }); 21 | } catch (error) { 22 | openUrlHintOrNotShowAgain( 23 | "Terraform is not installed, please make sure Terraform is in the PATH environment variable.", 24 | "https://aka.ms/azTerraform-requirement", 25 | () => { 26 | settingUtils.setCheckTerraformCmd(false); 27 | } 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/uiUtils.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | "use strict"; 7 | 8 | import * as opn from "opn"; 9 | import * as vscode from "vscode"; 10 | import { terraformChannel } from "../terraformChannel"; 11 | 12 | export async function openUrlHintOrNotShowAgain( 13 | message: string, 14 | url: string, 15 | notShowCallback: () => void 16 | ): Promise { 17 | const response = await vscode.window.showInformationMessage( 18 | message, 19 | DialogOption.learnMore, 20 | DialogOption.notShownAgain 21 | ); 22 | if (response === DialogOption.learnMore && url) { 23 | opn(url); 24 | } else if (response === DialogOption.notShownAgain) { 25 | notShowCallback(); 26 | } 27 | } 28 | 29 | export async function openUrlHint(message: string, url: string): Promise { 30 | const response = await vscode.window.showInformationMessage( 31 | message, 32 | DialogOption.learnMore, 33 | DialogOption.cancel 34 | ); 35 | if (response === DialogOption.learnMore && url) { 36 | opn(url); 37 | } 38 | } 39 | 40 | export async function showFolderDialog(): Promise { 41 | const defaultUri: vscode.Uri | undefined = vscode.workspace.rootPath 42 | ? vscode.Uri.file(vscode.workspace.rootPath) 43 | : undefined; 44 | const options: vscode.OpenDialogOptions = { 45 | canSelectFiles: false, 46 | canSelectFolders: true, 47 | canSelectMany: false, 48 | openLabel: "Select", 49 | defaultUri, 50 | }; 51 | const result: vscode.Uri[] | undefined = await vscode.window.showOpenDialog( 52 | options 53 | ); 54 | if (!result || result.length === 0) { 55 | return undefined; 56 | } 57 | return result[0]; 58 | } 59 | 60 | export async function promptForOpenOutputChannel( 61 | message: string, 62 | type: DialogType 63 | ): Promise { 64 | let result: vscode.MessageItem; 65 | switch (type) { 66 | case DialogType.info: 67 | result = await vscode.window.showInformationMessage( 68 | message, 69 | DialogOption.open, 70 | DialogOption.cancel 71 | ); 72 | break; 73 | case DialogType.warning: 74 | result = await vscode.window.showWarningMessage( 75 | message, 76 | DialogOption.open, 77 | DialogOption.cancel 78 | ); 79 | break; 80 | case DialogType.error: 81 | result = await vscode.window.showErrorMessage( 82 | message, 83 | DialogOption.open, 84 | DialogOption.cancel 85 | ); 86 | break; 87 | default: 88 | break; 89 | } 90 | 91 | if (result === DialogOption.open) { 92 | terraformChannel.show(); 93 | } 94 | } 95 | 96 | // eslint-disable-next-line @typescript-eslint/no-namespace 97 | export namespace DialogOption { 98 | export const ok: vscode.MessageItem = { title: "OK" }; 99 | export const cancel: vscode.MessageItem = { 100 | title: "Cancel", 101 | isCloseAffordance: true, 102 | }; 103 | export const open: vscode.MessageItem = { title: "Open" }; 104 | export const learnMore: vscode.MessageItem = { title: "Learn More" }; 105 | export const notShownAgain: vscode.MessageItem = { 106 | title: "Don't show again", 107 | }; 108 | } 109 | 110 | export enum DialogType { 111 | info = "info", 112 | warning = "warning", 113 | error = "error", 114 | } 115 | -------------------------------------------------------------------------------- /src/utils/workspaceUtils.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | "use strict"; 7 | 8 | import * as _ from "lodash"; 9 | import * as vscode from "vscode"; 10 | import { DialogOption, showFolderDialog } from "./uiUtils"; 11 | 12 | export async function selectWorkspaceFolder(): Promise { 13 | let folder: vscode.WorkspaceFolder; 14 | if (!_.isEmpty(vscode.workspace.workspaceFolders)) { 15 | if (vscode.workspace.workspaceFolders.length > 1) { 16 | folder = await vscode.window.showWorkspaceFolderPick({ 17 | placeHolder: "Select the working directory you wish to use", 18 | ignoreFocusOut: true, 19 | }); 20 | } else { 21 | folder = vscode.workspace.workspaceFolders[0]; 22 | } 23 | } else { 24 | const response = await vscode.window.showInformationMessage( 25 | "There is no folder opened in current workspace, would you like to open a folder?", 26 | DialogOption.open, 27 | DialogOption.cancel 28 | ); 29 | if (response === DialogOption.open) { 30 | const selectedFolder: vscode.Uri = await showFolderDialog(); 31 | if (selectedFolder) { 32 | /** 33 | * Open the selected folder in a workspace. 34 | * NOTE: this will restart the extension host. 35 | * See: https://github.com/Microsoft/vscode/issues/58 36 | */ 37 | await vscode.commands.executeCommand( 38 | "vscode.openFolder", 39 | selectedFolder, 40 | false /* forceNewWindow */ 41 | ); 42 | } 43 | } 44 | } 45 | return folder ? folder.uri.fsPath : undefined; 46 | } 47 | -------------------------------------------------------------------------------- /src/vscodeUtils.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | export function config( 4 | section: string, 5 | scope?: vscode.ConfigurationScope 6 | ): vscode.WorkspaceConfiguration { 7 | return vscode.workspace.getConfiguration(section, scope); 8 | } 9 | 10 | export function getFolderName(folder: vscode.WorkspaceFolder): string { 11 | return normalizeFolderName(folder.uri.toString()); 12 | } 13 | 14 | // Make sure that folder uris always end with a slash 15 | export function normalizeFolderName(folderName: string): string { 16 | if (folderName.charAt(folderName.length - 1) !== "/") { 17 | folderName = folderName + "/"; 18 | } 19 | return folderName; 20 | } 21 | 22 | export function getWorkspaceFolder( 23 | folderName: string 24 | ): vscode.WorkspaceFolder | undefined { 25 | return vscode.workspace.getWorkspaceFolder(vscode.Uri.parse(folderName)); 26 | } 27 | 28 | // getActiveTextEditor returns an active (visible and focused) TextEditor 29 | // We intentionally do *not* use vscode.window.activeTextEditor here 30 | // because it also contains Output panes which are considered editors 31 | // see also https://github.com/microsoft/vscode/issues/58869 32 | export function getActiveTextEditor(): vscode.TextEditor | undefined { 33 | return vscode.window.visibleTextEditors.find( 34 | (textEditor) => !!textEditor.viewColumn 35 | ); 36 | } 37 | 38 | /* 39 | Detects whether this is a Terraform file we can perform operations on 40 | */ 41 | export function isTerraformFile(document?: vscode.TextDocument): boolean { 42 | if (document === undefined) { 43 | return false; 44 | } 45 | 46 | if (document.isUntitled) { 47 | // Untitled files are files which haven't been saved yet, so we don't know if they 48 | // are terraform so we return false 49 | return false; 50 | } 51 | 52 | if (document.fileName.endsWith("tf")) { 53 | // For the purposes of this extension, anything with the tf file 54 | // extension is a Terraform file 55 | return true; 56 | } 57 | 58 | // be safe and default to false 59 | return false; 60 | } 61 | 62 | export function sortedWorkspaceFolders(): string[] { 63 | const workspaceFolders = vscode.workspace.workspaceFolders; 64 | if (workspaceFolders) { 65 | return workspaceFolders 66 | .map((f) => getFolderName(f)) 67 | .sort((a, b) => { 68 | return a.length - b.length; 69 | }); 70 | } 71 | return []; 72 | } 73 | -------------------------------------------------------------------------------- /testFixture/properties-completion.tf: -------------------------------------------------------------------------------- 1 | resource "azurerm_resource_group" "example" { 2 | name = "henglu22114-resources" 3 | location = "West Europe" 4 | 5 | } 6 | -------------------------------------------------------------------------------- /testFixture/templates-completion.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | azapi = { 4 | source = "Azure/azapi" 5 | } 6 | } 7 | } 8 | 9 | provider "azapi" { 10 | } 11 | 12 | provider "azurerm" { 13 | features{} 14 | } 15 | 16 | 17 | resource "azurerm_resource_group" "example" { 18 | name = "henglu22114-resources" 19 | location = "West Europe" 20 | } 21 | 22 | azurerm_ -------------------------------------------------------------------------------- /thirdpartynotices.txt: -------------------------------------------------------------------------------- 1 | THIRD-PARTY SOFTWARE NOTICES AND INFORMATION 2 | For vscode-azureterraform extension 3 | 4 | This extension uses Open Source components. You can find the source code of their 5 | open source projects along with the license information below. We acknowledge and 6 | are grateful to these developers for their contribution to open source. 7 | 8 | 1. fs-extra (https://github.com/jprichardson/node-fs-extra) 9 | 2. lodash (https://github.com/lodash/lodash) 10 | 3. opn (https://github.com/sindresorhus/opn) 11 | 4. request-promise (https://github.com/request/request-promise) 12 | 13 | fs-extra NOTICES BEGIN HERE 14 | ============================= 15 | 16 | (The MIT License) 17 | 18 | Copyright (c) 2011-2017 JP Richardson 19 | 20 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files 21 | (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, 22 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is 23 | furnished to do so, subject to the following conditions: 24 | 25 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 26 | 27 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 28 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 29 | OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 30 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 31 | 32 | END OF fs-extra NOTICES AND INFORMATION 33 | ================================== 34 | 35 | lodash NOTICES BEGIN HERE 36 | ============================= 37 | 38 | The MIT License 39 | 40 | Copyright JS Foundation and other contributors 41 | 42 | Based on Underscore.js, copyright Jeremy Ashkenas, 43 | DocumentCloud and Investigative Reporters & Editors 44 | 45 | This software consists of voluntary contributions made by many 46 | individuals. For exact contribution history, see the revision history 47 | available at https://github.com/lodash/lodash 48 | 49 | The following license applies to all parts of this software except as 50 | documented below: 51 | 52 | ==== 53 | 54 | Permission is hereby granted, free of charge, to any person obtaining 55 | a copy of this software and associated documentation files (the 56 | "Software"), to deal in the Software without restriction, including 57 | without limitation the rights to use, copy, modify, merge, publish, 58 | distribute, sublicense, and/or sell copies of the Software, and to 59 | permit persons to whom the Software is furnished to do so, subject to 60 | the following conditions: 61 | 62 | The above copyright notice and this permission notice shall be 63 | included in all copies or substantial portions of the Software. 64 | 65 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 66 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 67 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 68 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 69 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 70 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 71 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 72 | 73 | ==== 74 | 75 | Copyright and related rights for sample code are waived via CC0. Sample 76 | code is defined as all source code displayed within the prose of the 77 | documentation. 78 | 79 | CC0: http://creativecommons.org/publicdomain/zero/1.0/ 80 | 81 | ==== 82 | 83 | Files located in the node_modules and vendor directories are externally 84 | maintained libraries used by this software which have their own 85 | licenses; we recommend you read them, as their terms may differ from the 86 | terms above. 87 | 88 | END OF lodash NOTICES AND INFORMATION 89 | ================================== 90 | 91 | opn NOTICES BEGIN HERE 92 | ============================= 93 | 94 | MIT License 95 | 96 | Copyright (c) Sindre Sorhus (sindresorhus.com) 97 | 98 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 99 | 100 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 101 | 102 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 103 | 104 | END OF opn NOTICES AND INFORMATION 105 | ================================== 106 | 107 | request-promise NOTICES BEGIN HERE 108 | ============================= 109 | 110 | ISC License 111 | 112 | Copyright (c) 2017, Nicolai Kamenzky, Ty Abonil, and contributors 113 | 114 | Permission to use, copy, modify, and/or distribute this software for any 115 | purpose with or without fee is hereby granted, provided that the above 116 | copyright notice and this permission notice appear in all copies. 117 | 118 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 119 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 120 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 121 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 122 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 123 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 124 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 125 | 126 | END OF request-promise NOTICES AND INFORMATION 127 | ================================== 128 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "outDir": "out", 6 | "lib": [ 7 | "es6" 8 | ], 9 | "sourceMap": true, 10 | "rootDir": "src", 11 | "noUnusedLocals": true, 12 | "noImplicitThis": true, 13 | "noUnusedParameters": true, 14 | "skipLibCheck": true 15 | }, 16 | "exclude": [ 17 | "node_modules", 18 | ".vscode-test" 19 | ] 20 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "object-literal-sort-keys": false, 9 | "indent": [false, "spaces"], 10 | "no-console": [false, "log", "error"], 11 | "no-string-literal": false, 12 | "no-namespace": false, 13 | "max-line-length": [false, 120] 14 | }, 15 | "rulesDirectory": [] 16 | } --------------------------------------------------------------------------------