diff --git a/.azure-pipelines/common/build.yml b/.azure-pipelines/common/build.yml new file mode 100644 index 0000000000..8f29e12086 --- /dev/null +++ b/.azure-pipelines/common/build.yml @@ -0,0 +1,9 @@ +steps: +- task: Npm@1 + displayName: 'npm install' + +- task: Npm@1 + displayName: 'Build' + inputs: + command: custom + customCommand: run build diff --git a/.azure-pipelines/common/lint.yml b/.azure-pipelines/common/lint.yml new file mode 100644 index 0000000000..66d82cb8bb --- /dev/null +++ b/.azure-pipelines/common/lint.yml @@ -0,0 +1,10 @@ +steps: +- task: Npm@1 + displayName: 'Lint' + inputs: + command: custom + customCommand: run lint + +- task: ComponentGovernanceComponentDetection@0 + displayName: 'Component Detection' + condition: ne(variables['System.PullRequest.IsFork'], 'True') diff --git a/.azure-pipelines/common/publish-test-results.yml b/.azure-pipelines/common/publish-test-results.yml new file mode 100644 index 0000000000..338a8ba2a3 --- /dev/null +++ b/.azure-pipelines/common/publish-test-results.yml @@ -0,0 +1,7 @@ +steps: +- task: PublishTestResults@2 + displayName: 'Publish Test Results' + inputs: + testResultsFiles: '*-results.xml' + testRunTitle: '$(Agent.OS)' + condition: succeededOrFailed() diff --git a/.azure-pipelines/common/publish-vsix.yml b/.azure-pipelines/common/publish-vsix.yml new file mode 100644 index 0000000000..f83b384102 --- /dev/null +++ b/.azure-pipelines/common/publish-vsix.yml @@ -0,0 +1,19 @@ +steps: +- task: Npm@1 + displayName: 'Package' + inputs: + command: custom + customCommand: run package + +- task: CopyFiles@2 + displayName: 'Copy vsix to staging directory' + inputs: + Contents: '**/*.vsix' + TargetFolder: '$(build.artifactstagingdirectory)' + +- task: PublishBuildArtifacts@1 + displayName: 'Publish artifacts: vsix' + inputs: + PathtoPublish: '$(build.artifactstagingdirectory)' + ArtifactName: vsix + condition: ne(variables['System.PullRequest.IsFork'], 'True') diff --git a/.azure-pipelines/common/test.yml b/.azure-pipelines/common/test.yml new file mode 100644 index 0000000000..da4a9900dc --- /dev/null +++ b/.azure-pipelines/common/test.yml @@ -0,0 +1,11 @@ +steps: +- task: Gulp@0 + displayName: 'Test' + inputs: + targets: 'test' + env: + SERVICE_PRINCIPAL_CLIENT_ID: $(SERVICE_PRINCIPAL_CLIENT_ID) + SERVICE_PRINCIPAL_SECRET: $(SERVICE_PRINCIPAL_SECRET) + SERVICE_PRINCIPAL_DOMAIN: $(SERVICE_PRINCIPAL_DOMAIN) + +- template: publish-test-results.yml diff --git a/.azure-pipelines/linux/test-linux.yml b/.azure-pipelines/linux/test-linux.yml new file mode 100644 index 0000000000..a7bd8afe1c --- /dev/null +++ b/.azure-pipelines/linux/test-linux.yml @@ -0,0 +1,18 @@ +steps: +- script: | + sudo cp .azure-pipelines/linux/xvfb.init /etc/init.d/xvfb + sudo chmod +x /etc/init.d/xvfb + sudo update-rc.d xvfb defaults + sudo service xvfb start + displayName: 'Start X Virtual Frame Buffer' + +- script: | + export DISPLAY=:10 + gulp test + displayName: 'Test' + env: + SERVICE_PRINCIPAL_CLIENT_ID: $(SERVICE_PRINCIPAL_CLIENT_ID) + SERVICE_PRINCIPAL_SECRET: $(SERVICE_PRINCIPAL_SECRET) + SERVICE_PRINCIPAL_DOMAIN: $(SERVICE_PRINCIPAL_DOMAIN) + +- template: ../common/publish-test-results.yml diff --git a/.azure-pipelines/linux/xvfb.init b/.azure-pipelines/linux/xvfb.init new file mode 100644 index 0000000000..7d3bb8f609 --- /dev/null +++ b/.azure-pipelines/linux/xvfb.init @@ -0,0 +1,56 @@ +#!/bin/bash +# +# COPIED FROM https://github.com/Microsoft/vscode/blob/e29c517386fe6f3a40e2f0ff00effae4919406aa/build/tfs/linux/x64/xvfb.init +# +# +# /etc/rc.d/init.d/xvfbd +# +# chkconfig: 345 95 28 +# description: Starts/Stops X Virtual Framebuffer server +# processname: Xvfb +# +### BEGIN INIT INFO +# Provides: xvfb +# Required-Start: $remote_fs $syslog +# Required-Stop: $remote_fs $syslog +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: Start xvfb at boot time +# Description: Enable xvfb provided by daemon. +### END INIT INFO + +[ "${NETWORKING}" = "no" ] && exit 0 + +PROG="/usr/bin/Xvfb" +PROG_OPTIONS=":10 -ac" +PROG_OUTPUT="/tmp/Xvfb.out" + +case "$1" in + start) + echo "Starting : X Virtual Frame Buffer " + $PROG $PROG_OPTIONS>>$PROG_OUTPUT 2>&1 & + disown -ar + ;; + stop) + echo "Shutting down : X Virtual Frame Buffer" + killproc $PROG + RETVAL=$? + [ $RETVAL -eq 0 ] && /bin/rm -f /var/lock/subsys/Xvfb + /var/run/Xvfb.pid + echo + ;; + restart|reload) + $0 stop + $0 start + RETVAL=$? + ;; + status) + status Xvfb + RETVAL=$? + ;; + *) + echo $"Usage: $0 (start|stop|restart|reload|status)" + exit 1 +esac + +exit $RETVAL diff --git a/.azure-pipelines/main.yml b/.azure-pipelines/main.yml new file mode 100644 index 0000000000..beebf4f7b4 --- /dev/null +++ b/.azure-pipelines/main.yml @@ -0,0 +1,27 @@ +jobs: +- job: Windows + pool: + vmImage: VS2017-Win2016 + steps: + - template: common/build.yml + - template: common/lint.yml + # https://github.com/Microsoft/vscode-docker/issues/606 + # - template: common/test.yml + +- job: Linux + pool: + vmImage: ubuntu-16.04 + steps: + - template: common/build.yml + - template: common/publish-vsix.yml # Only publish vsix from linux build since we use this to release and want to stay consistent + - template: common/lint.yml + - template: linux/test-linux.yml + +- job: macOS + pool: + vmImage: macOS 10.13 + steps: + - template: common/build.yml + - template: common/lint.yml + # https://github.com/Microsoft/vscode-docker/issues/606 + # - template: common/test.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..4df3d45b4a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @Microsoft/vscodeazuretools diff --git a/.gitignore b/.gitignore index d0797fedc3..13475e3938 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ coverage/** .vscode-test testOutput .vs +test-results.xml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 4d512b80f8..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,31 +0,0 @@ -language: node_js - -sudo: required - -services: - - docker - -node_js: - - 'stable' - -before_install: - - if [ $TRAVIS_OS_NAME == "linux" ]; then - export CXX="g++-4.9" CC="gcc-4.9" DISPLAY=:99.0; - sh -e /etc/init.d/xvfb start; - sleep 3; - fi - -install: - - npm install - -script: - - npm run build - - gulp package - - gulp upload-vsix - - npm run lint - - npm test - -notifications: - email: - on_success: never - on_failure: always diff --git a/.vscode/launch.json b/.vscode/launch.json index 11e26728ed..bf12d467fc 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,7 @@ "request": "launch", "runtimeExecutable": "${execPath}", "args": [ - "--extensionDevelopmentPath=${workspaceRoot}" + "--extensionDevelopmentPath=${workspaceFolder}" ], "env": { "DEBUGTELEMETRY": "1" @@ -16,7 +16,7 @@ "stopOnEntry": false, "sourceMaps": true, "outFiles": [ - "${workspaceRoot}/out/**/*.js" + "${workspaceFolder}/out/**/*.js" ], "preLaunchTask": "npm" }, @@ -31,14 +31,14 @@ // "c:/Repos/vscode-docker/test/test.code-workspace", // "--extensionDevelopmentPath=c:/Repos/vscode-docker", // "--extensionTestsPath=c:/Repos/vscode-docker/out/test" - "${workspaceRoot}/test/test.code-workspace", - "--extensionDevelopmentPath=${workspaceRoot}", - "--extensionTestsPath=${workspaceRoot}/out/test" + "${workspaceFolder}/test/test.code-workspace", + "--extensionDevelopmentPath=${workspaceFolder}", + "--extensionTestsPath=${workspaceFolder}/out/test" ], "stopOnEntry": false, "sourceMaps": true, "outFiles": [ - "${workspaceRoot}/out/test" + "${workspaceFolder}/out/test" ], "preLaunchTask": "npm", "env": { diff --git a/.vscode/tasks.json b/.vscode/tasks.json index f2f0e5e0f6..a85061ecce 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,5 +1,5 @@ // Available variables which can be used inside of strings. -// ${workspaceRoot}: the root folder of the team +// ${workspaceFolder}: the root folder of the team // ${file}: the current opened file // ${fileBasename}: the current opened file's basename // ${fileDirname}: the current opened file's dirname @@ -34,10 +34,7 @@ { "type": "npm", "script": "lint", - "problemMatcher": { - "base": "$tslint5", - "fileLocation": "absolute" - } + "problemMatcher": "$tslint5" } ] } diff --git a/.vscodeignore b/.vscodeignore index 46d3fec9e3..3586132196 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -6,3 +6,6 @@ typings/** tsconfig.json test/** testOutput/** +.github/** +test-results.xml +.azure-pipelines/** diff --git a/CHANGELOG.md b/CHANGELOG.md index a97d8faa6f..e1830cb0c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## 0.x.x - Unreleased + +### Added +* Adds preview support for debugging .NET Core web applications running in Linux Docker containers. + +## 0.3.1 - 25 September 2018 + +### Fixed + +* Error while generating Dockerfile for 'other' [#504](https://github.com/Microsoft/vscode-docker/issues/504) + ## 0.3.0 - 21 September 2018 ### Added diff --git a/README.md b/README.md index 555eddead6..0721381ea9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Docker Support for Visual Studio Code -[![Version](https://vsmarketplacebadge.apphb.com/version/PeterJausovec.vscode-docker.svg)](https://marketplace.visualstudio.com/items?itemName=PeterJausovec.vscode-docker) [![Installs](https://vsmarketplacebadge.apphb.com/installs-short/PeterJausovec.vscode-docker.svg)](https://marketplace.visualstudio.com/items?itemName=PeterJausovec.vscode-docker) [![Build Status](https://travis-ci.org/Microsoft/vscode-docker.svg?branch=master)](https://travis-ci.org/Microsoft/vscode-docker) +[![Version](https://vsmarketplacebadge.apphb.com/version/PeterJausovec.vscode-docker.svg)](https://marketplace.visualstudio.com/items?itemName=PeterJausovec.vscode-docker) [![Installs](https://vsmarketplacebadge.apphb.com/installs-short/PeterJausovec.vscode-docker.svg)](https://marketplace.visualstudio.com/items?itemName=PeterJausovec.vscode-docker) [![Build Status](https://dev.azure.com/ms-azuretools/AzCode/_apis/build/status/vscode-docker)](https://dev.azure.com/ms-azuretools/AzCode/_build/latest?definitionId=8) The Docker extension makes it easy to build, manage and deploy containerized applications from Visual Studio Code, for example: @@ -10,12 +10,13 @@ The Docker extension makes it easy to build, manage and deploy containerized app * Command Palette (`F1`) integration for the most common Docker commands (for example `docker build`, `docker push`, etc.) * Explorer integration for managing Images, running Containers, and Docker Hub registries * Deploy images from Docker Hub and Azure Container Registries directly to Azure App Service +* Debug .NET Core applications running in Linux Docker containers * [Working with docker](https://code.visualstudio.com/docs/azure/docker) will walk you through many of the features of this extension ## Generating Docker Files -Press `F1` and search for `Docker: Add Docker files to Workspace` to generate `Dockerfile`, `docker-compose.yml`, `docker-compose.debug.yml`, and `.dockerignore` files for your workspace type: +Press `F1` and search for `Docker: Add Docker Files to Workspace` to generate `Dockerfile`, `docker-compose.yml`, `docker-compose.debug.yml`, and `.dockerignore` files for your workspace type: ![dockerfile](images/generateFiles.gif) @@ -86,6 +87,150 @@ After the container is started, you will be prompted to login to your Azure acco This build includes preview support for connecting to private registries (such as those described in Docker Hub [documentation](https://docs.docker.com/registry/deploying/)). At the moment, OAuth is not supported, only basic authentication. We hope to extend this support in the future. +## Debugging .NET Core (Preview) + +> Note that Windows containers are **not** currently supported, only Linux containers. + +### Prerequisites + + +1. (All users) Install the [.NET Core SDK](https://www.microsoft.com/net/download) which includes support for attaching to the .NET Core debugger. + +1. (All users) Install the [C# VS Code extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode.csharp) which includes support for attaching to the .NET Core debugger in VS Code. + +1. (Mac users) add `/usr/local/share/dotnet/sdk/NuGetFallbackFolder` as a shared folder in your Docker preferences. + +![Docker Shared Folders](images/dockerSharedFolders.png) + +### Starting the Debugger + +To debug a .NET Core application running in a Linux Docker container, add a Docker .NET Core launch configuration: + +1. Switch to the debugging tab. +1. Select `Add configuration...` +1. Select `Docker: Launch .NET Core (Preview)` +1. Set a breakpoint. +1. Start debugging. + +Upon debugging, a Docker image will be built and a container will be run based on that image. The container will have volumes mapped to the locally-built application and the .NET Core debugger. If the Docker container exposes port 80, after the debugger is attached the browser will be launched and navigate to the application's initial page. + +> NOTE: you may see errors in the debug console when debugging ends (e.g. "`Error from pipe program 'docker': ...`"). This appears due to debugger issue [#2439](https://github.com/OmniSharp/omnisharp-vscode/issues/2439) and should not impact debugging. + +Most properties of the configuration are optional and will be inferred from the project. If not, or if there are additional customizations to be made to the Docker image build or container run process, those can be added under the `dockerBuild` and `dockerRun` properties of the configuration, respectively. + +```json +{ + "configurations": [ + { + "name": "Docker: Launch .NET Core (Preview)", + "type": "docker-coreclr", + "request": "launch", + "preLaunchTask": "build", + "dockerBuild": { + // Image customizations + }, + "dockerRun": { + // Container customizations + } + } + ] +} +``` + +### Application Customizations + +When possible, the location and output of the application will be inferred from the workspace folder opened in VS Code. When they cannot be inferred, these properties can be used to make them explicit: + +| Property | Description | Default | +| --- | --- | --- | +| `appFolder` | The root folder of the application | The workspace folder | +| `appProject` | The path to the project file | The first `.csproj` found in the application folder | +| `appOutput` | The application folder relative path to the output assembly | The `TargetPath` MS Build property | + +> You can specify either `appFolder` or `appProject` but should not specify *both*. + +### Docker Build Customizations + +Customize the Docker image build process by adding properties under the `dockerBuild` configuration property. + +| Property | Description | Default | +| --- | --- | --- | +| `args` | Build arguments applied to the image. | None | +| `context` | The Docker context used during the build process. | The workspace folder, if the same as the application folder; otherwise, the application's parent (i.e. solution) folder | +| `dockerfile` | The path to the Dockerfile used to build the image. | The file `Dockerfile` in the application folder | +| `labels` | The set of labels added to the image. | `com.microsoft.created-by` = `visual-studio-code` | +| `tag` | The tag added to the image. | `:dev` | +| `target` | The target (stage) of the Dockerfile from which to build the image. | `base` + +Example build customizations: + +```json +{ + "configurations": [ + { + "name": "Launch .NET Core in Docker", + "type": "docker-coreclr", + "request": "launch", + "preLaunchTask": "build", + "dockerBuild": { + "args": { + "arg1": "value1", + "arg2": "value2" + }, + "context": "${workspaceFolder}/src", + "dockerfile": "${workspaceFolder}/src/Dockerfile", + "labels": { + "label1": "value1", + "label2": "value2" + }, + "tag": "mytag", + "target": "publish" + } + } + ] +} +``` + +### Docker Run Customization + +Customize the Docker container run process by adding properties under the `dockerRun` configuration property. + +| Property | Description | Default | +| --- | --- | --- | +| `containerName` | The name of the container. | `-dev` | +| `env` | Environment variables applied to the container. | None | +| `envFiles` | Files of environment variables read in and applied to the container. Environment variables are specified one per line, in `=` format. | None | +| `labels` | The set of labels added to the container. | `com.microsoft.created-by` = `visual-studio-code` | + +Example run customization: + +```json +{ + "configurations": [ + { + "name": "Launch .NET Core in Docker", + "type": "docker-coreclr", + "request": "launch", + "preLaunchTask": "build", + "dockerRun": { + "containerName": "my-container", + "env": { + "var1": "value1", + "var2": "value2" + }, + "envFiles": [ + "${workspaceFolder}/staging.env" + ], + "labels": { + "label1": "value1", + "label2": "value2" + } + } + } + ] +} +``` + ## Configuration Settings The Docker extension comes with a number of useful configuration settings allowing you to customize your workflow. diff --git a/commands/azureCommands/acr-logs-utils/logFileManager.ts b/commands/azureCommands/acr-logs-utils/logFileManager.ts new file mode 100644 index 0000000000..a44b0d6518 --- /dev/null +++ b/commands/azureCommands/acr-logs-utils/logFileManager.ts @@ -0,0 +1,65 @@ +import { BlobService, createBlobServiceWithSas } from 'azure-storage'; +import * as fse from 'fs-extra'; +import * as vscode from 'vscode'; +import { getBlobInfo, getBlobToText, IBlobInfo } from '../../../utils/Azure/acrTools'; + +export class LogContentProvider implements vscode.TextDocumentContentProvider { + public static scheme: string = 'purejs'; + private onDidChangeEvent: vscode.EventEmitter = new vscode.EventEmitter(); + + constructor() { } + + public provideTextDocumentContent(uri: vscode.Uri): string { + let parse: { log: string } = <{ log: string }>JSON.parse(uri.query); + return decodeBase64(parse.log); + } + + get onDidChange(): vscode.Event { + return this.onDidChangeEvent.event; + } + + public update(uri: vscode.Uri, message: string): void { + this.onDidChangeEvent.fire(uri); + } + +} + +export function decodeBase64(str: string): string { + return Buffer.from(str, 'base64').toString('ascii'); +} + +export function encodeBase64(str: string): string { + return Buffer.from(str, 'ascii').toString('base64'); +} + +/** Loads log text from remote url using azure blobservices */ +export async function accessLog(url: string, title: string, download: boolean): Promise { + let blobInfo: IBlobInfo = getBlobInfo(url); + let blob: BlobService = createBlobServiceWithSas(blobInfo.host, blobInfo.sasToken); + let text1 = await getBlobToText(blobInfo, blob, 0); + if (download) { + await downloadLog(text1, title); + } else { + openLogInNewWindow(text1, title); + } +} + +function openLogInNewWindow(content: string, title: string): void { + const scheme = 'purejs'; + let query = JSON.stringify({ 'log': encodeBase64(content) }); + let uri: vscode.Uri = vscode.Uri.parse(`${scheme}://authority/${title}.log?${query}#idk`); + vscode.workspace.openTextDocument(uri).then((doc) => { + return vscode.window.showTextDocument(doc, vscode.ViewColumn.Active + 1, true); + }); +} + +export async function downloadLog(content: string, title: string): Promise { + let uri = await vscode.window.showSaveDialog({ + filters: { 'Log': ['.log', '.txt'] }, + defaultUri: vscode.Uri.file(`${title}.log`) + }); + fse.writeFile(uri.fsPath, content, + (err) => { + if (err) { throw err; } + }); +} diff --git a/commands/azureCommands/acr-logs-utils/logScripts.js b/commands/azureCommands/acr-logs-utils/logScripts.js new file mode 100644 index 0000000000..a69911fdf6 --- /dev/null +++ b/commands/azureCommands/acr-logs-utils/logScripts.js @@ -0,0 +1,306 @@ +// Global Variables +const status = { + 'Succeeded': 4, + 'Queued': 3, + 'Error': 2, + 'Failed': 1 +} + +var currentItemsCount = 4; +var currentDir = "asc" +var triangles = { + 'down': ' ', + 'up': ' ' +} + +document.addEventListener("scroll", function () { + var translate = "translate(0," + this.lastChild.scrollTop + "px)"; + let fixedItems = this.querySelectorAll(".fixed"); + for (item of fixedItems) { + item.style.transform = translate; + } +}); + +// Main +let content = document.querySelector('#core'); +const vscode = acquireVsCodeApi(); +setLoadMoreListener(); +setInputListeners(); +loading(); + +document.onkeydown = function (event) { + if (event.key === "Enter") { // The Enter/Return key + document.activeElement.onclick(event); + } +}; + +/* Sorting + * PR note, while this does not use a particularly quick algorithm + * it allows a low stuttering experience that allowed rapid testing. + * I will improve it soon.*/ +function sortTable(n, dir = "asc", holdDir = false) { + currentItemsCount = n; + let table, rows, switching, i, x, y, shouldSwitch, switchcount = 0; + let cmpFunc = acquireCompareFunction(n); + table = document.getElementById("core"); + switching = true; + //Set the sorting direction to ascending: + + while (switching) { + switching = false; + rows = table.querySelectorAll(".holder"); + for (i = 0; i < rows.length - 1; i++) { + shouldSwitch = false; + x = rows[i].getElementsByTagName("TD")[n + 1]; + y = rows[i + 1].getElementsByTagName("TD")[n + 1]; + if (dir == "asc") { + if (cmpFunc(x, y)) { + shouldSwitch = true; + break; + } + } else if (dir == "desc") { + if (cmpFunc(y, x)) { + shouldSwitch = true; + break; + } + } + } + if (shouldSwitch) { + rows[i].parentNode.insertBefore(rows[i + 1], rows[i]); + switching = true; + switchcount++; + } else { + /*If no switching has been done AND the direction is "asc", set the direction to "desc" and run the while loop again.*/ + if (switchcount == 0 && dir == "asc" && !holdDir) { + dir = "desc"; + switching = true; + } + } + } + if (!holdDir) { + let sortColumns = document.querySelectorAll(".sort"); + if (sortColumns[n].innerHTML === triangles['down']) { + sortColumns[n].innerHTML = triangles['up']; + } else if (sortColumns[n].innerHTML === triangles['up']) { + sortColumns[n].innerHTML = triangles['down']; + } else { + for (cell of sortColumns) { + cell.innerHTML = ' '; + } + sortColumns[n].innerHTML = triangles['down']; + } + } + currentDir = dir; +} + +function acquireCompareFunction(n) { + switch (n) { + case 0: //Name + case 1: //Task + return (x, y) => { + return x.innerHTML.toLowerCase() > y.innerHTML.toLowerCase() + } + case 2: //Status + return (x, y) => { + return status[x.dataset.status] > status[y.dataset.status];; + } + case 3: //Created time + return (x, y) => { + if (x.dataset.createdtime === '') return true; + if (y.dataset.createdtime === '') return false; + let dateX = new Date(x.dataset.createdtime); + let dateY = new Date(y.dataset.createdtime); + return dateX > dateY; + } + case 4: //Elapsed time + return (x, y) => { + if (x.innerHTML === '') return true; + if (y.innerHTML === '') return false; + return Number(x.innerHTML.substring(0, x.innerHTML.length - 1)) > Number(y.innerHTML.substring(0, y.innerHTML.length - 1)); + } + case 5: //OS Type + return (x, y) => { + return x.innerHTML.toLowerCase() > y.innerHTML.toLowerCase() + } + default: + throw 'Could not acquire Compare function, invalid n'; + } +} + +// Event Listener Setup +window.addEventListener('message', event => { + const message = event.data; // The JSON data our extension sent + if (message.type === 'populate') { + content.insertAdjacentHTML('beforeend', message.logComponent); + + let item = content.querySelector(`#btn${message.id}`); + setSingleAccordion(item); + + let panel = item.nextElementSibling; + + const logButton = panel.querySelector('.openLog'); + setLogBtnListener(logButton, false); + const downloadlogButton = panel.querySelector('.downloadlog'); + setLogBtnListener(downloadlogButton, true); + + const digestClickables = panel.querySelectorAll('.copy'); + setDigestListener(digestClickables); + + } else if (message.type === 'endContinued') { + sortTable(currentItemsCount, currentDir, true); + loading(); + } else if (message.type === 'end') { + window.addEventListener("resize", setAccordionTableWidth); + setAccordionTableWidth(); + setTableSorter(); + loading(); + } + + if (message.canLoadMore) { + const loadBtn = document.querySelector('.loadMoreBtn'); + loadBtn.style.display = 'flex'; + } + +}); + +function setSingleAccordion(item) { + item.onclick = function (event) { + this.classList.toggle('active'); + this.querySelector('.arrow').classList.toggle('activeArrow'); + let panel = this.nextElementSibling; + if (panel.style.maxHeight) { + panel.style.display = 'none'; + panel.style.maxHeight = null; + let index = openAccordions.indexOf(panel); + if (index > -1) { + openAccordions.splice(index, 1); + } + } else { + openAccordions.push(panel); + setAccordionTableWidth(); + panel.style.display = 'table-row'; + let paddingTop = +panel.style.paddingTop.split('px')[0]; + let paddingBottom = +panel.style.paddingBottom.split('px')[0]; + panel.style.maxHeight = (panel.scrollHeight + paddingTop + paddingBottom) + 'px'; + } + }; +} + +function setTableSorter() { + let tableHeader = document.querySelector("#tableHead"); + let items = tableHeader.querySelectorAll(".colTitle"); + for (let i = 0; i < items.length; i++) { + items[i].onclick = () => { + sortTable(i); + }; + } +} + +function setLogBtnListener(item, download) { + item.onclick = (event) => { + vscode.postMessage({ + logRequest: { + 'id': event.target.dataset.id, + 'download': download + } + }); + }; +} + +function setLoadMoreListener() { + let item = document.querySelector("#loadBtn"); + item.onclick = function () { + const loadBtn = document.querySelector('.loadMoreBtn'); + loadBtn.style.display = 'none'; + loading(); + vscode.postMessage({ + loadMore: true + }); + }; +} + +function setDigestListener(digestClickables) { + for (digest of digestClickables) { + digest.onclick = function (event) { + vscode.postMessage({ + copyRequest: { + 'text': event.target.parentNode.dataset.digest, + } + }); + }; + } +} + +let openAccordions = []; + +function setAccordionTableWidth() { + let headerCells = document.querySelectorAll("#core thead tr th"); + let topWidths = []; + for (let cell of headerCells) { + topWidths.push(parseInt(getComputedStyle(cell).width)); + } + for (acc of openAccordions) { + let cells = acc.querySelectorAll(".innerTable th, .innerTable td"); // 4 items + const cols = acc.querySelectorAll(".innerTable th").length + 1; //Account for arrowHolder + const rows = cells.length / cols; + //cells[0].style.width = topWidths[0]; + for (let row = 0; row < rows; row++) { + for (let col = 1; col < cols - 1; col++) { + let cell = cells[row * cols + col]; + cell.style.width = topWidths[col - 1] + "px" + } + } + } +} + +function setInputListeners() { + const inputFields = document.querySelectorAll("input"); + const loadBtn = document.querySelector('.loadMoreBtn'); + for (let inputField of inputFields) { + inputField.addEventListener("keyup", function (event) { + if (event.key === "Enter") { + clearLogs(); + loading(); + loadBtn.style.display = 'none'; + vscode.postMessage({ + loadFiltered: { + filterString: getFilterString(inputFields) + } + }); + } + }); + } +} + +/*interface Filter + image?: string; + runId?: string; + runTask?: string; +*/ +function getFilterString(inputFields) { + let filter = {}; + if (inputFields[0].value.length > 0) { //Run Id + filter.runId = inputFields[0].value; + } else if (inputFields[1].value.length > 0) { //Task id + filter.task = inputFields[1].value; + } + return filter; +} + +function clearLogs() { + let items = document.querySelectorAll("#core tbody"); + for (let item of items) { + item.remove(); + } +} +var shouldLoad = false; + +function loading() { + const loader = document.querySelector('#loadingDiv'); + if (shouldLoad) { + loader.style.display = 'flex'; + } else { + loader.style.display = 'none'; + } + shouldLoad = !shouldLoad; +} diff --git a/commands/azureCommands/acr-logs-utils/style/fabric-components/css/vscmdl2-icons.css b/commands/azureCommands/acr-logs-utils/style/fabric-components/css/vscmdl2-icons.css new file mode 100644 index 0000000000..cd0e84c017 --- /dev/null +++ b/commands/azureCommands/acr-logs-utils/style/fabric-components/css/vscmdl2-icons.css @@ -0,0 +1,71 @@ +/* + Your use of the content in the files referenced here is subject to the terms of the license at https://aka.ms/fabric-assets-license +*/ + +@font-face { + font-family: 'VSC MDL2 Assets'; + src: url('../fonts/vscmdl2-icons-d3699964.woff') format('woff'); +} + +.ms-Icon { + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + display: inline-block; + font-family: 'VSC MDL2 Assets'; + font-style: normal; + font-weight: normal; + speak: none; +} + +.ms-Icon--ChevronDown:before { + cursor: pointer; + content: "\E70D"; +} + +.ms-Icon--ChevronRight:before { + cursor: pointer; + content: "\E76C"; +} + +.ms-Icon--Clear:before { + content: "\E894"; +} + +.ms-Icon--OpenInNewWindow:before { + cursor: pointer; + content: "\E8A7"; +} + +.ms-Icon--Copy:before { + cursor: pointer; + content: "\E8C8"; +} + +.ms-Icon--StatusErrorFull:before { + color: var(--vscode-list-errorForeground); + content: "\EB90"; +} + +.ms-Icon--CompletedSolid:before { + color: var(--vscode-list-warningForeground); + content: "\EC61"; +} + +.ms-Icon--SkypeCircleClock:before { + color: #CCCCCC; + content: "\EF7E"; +} + +.ms-Icon--CaretSolidDown:before { + content: "\F08E"; +} + +.ms-Icon--MSNVideosSolid:before { + color: var(--vscode-activityBarBadge-foreground); + content: "\F2DA"; +} + +.ms-Icon--CriticalErrorSolid:before { + color: var(--vscode-list-invalidItemForeground); + content: "\F5C9"; +} diff --git a/commands/azureCommands/acr-logs-utils/style/fabric-components/fonts/vscmdl2-icons-d3699964.woff b/commands/azureCommands/acr-logs-utils/style/fabric-components/fonts/vscmdl2-icons-d3699964.woff new file mode 100644 index 0000000000..6c579dbdbb Binary files /dev/null and b/commands/azureCommands/acr-logs-utils/style/fabric-components/fonts/vscmdl2-icons-d3699964.woff differ diff --git a/commands/azureCommands/acr-logs-utils/style/fabric-components/fonts/vscmdl2-icons.ttf b/commands/azureCommands/acr-logs-utils/style/fabric-components/fonts/vscmdl2-icons.ttf new file mode 100644 index 0000000000..03770761cc Binary files /dev/null and b/commands/azureCommands/acr-logs-utils/style/fabric-components/fonts/vscmdl2-icons.ttf differ diff --git a/commands/azureCommands/acr-logs-utils/style/fabric-components/microsoft-ui-fabric-assets-license.pdf b/commands/azureCommands/acr-logs-utils/style/fabric-components/microsoft-ui-fabric-assets-license.pdf new file mode 100644 index 0000000000..47153a3b80 Binary files /dev/null and b/commands/azureCommands/acr-logs-utils/style/fabric-components/microsoft-ui-fabric-assets-license.pdf differ diff --git a/commands/azureCommands/acr-logs-utils/style/stylesheet.css b/commands/azureCommands/acr-logs-utils/style/stylesheet.css new file mode 100644 index 0000000000..112a760315 --- /dev/null +++ b/commands/azureCommands/acr-logs-utils/style/stylesheet.css @@ -0,0 +1,387 @@ +.accordion { + background-color: var(--vscode-editor-background); + color: var(--color); + cursor: pointer; + margin: 0px; + height: 30px; + width: 100%; + border: none; + text-align: left; + outline: none; + font-size: var(--vscode-editor-font-size); + font-family: var(--vscode-editor-font-family); + transition: 0.4s; + text-align: left; +} + +.accordion:hover { + cursor: pointer; + background-color: var(--vscode-list-hoverBackground); +} + +.accordion:focus { + background-color: var(--vscode-list-hoverBackground); +} + +.active { + background-color: var(--vscode-list-activeSelectionBackground); +} + +.active.accordion:focus, +.active.accordion:hover { + background-color: var(--vscode-list-activeSelectionBackground); +} + +.panel { + width: 100%; + display: none; + max-height: 0; + overflow: hidden; + transition: max-height 0.2s ease-out; +} + +table { + text-align: left; + border-collapse: collapse; + width: 100%; +} + +.widthControl { + box-sizing: border-box; + text-align: left; +} + +.solidBackground { + background-color: var(--vscode-editor-background); +} + +h2 { + padding-left: 10px; + font-size: var(--vscode-editor-font-size); + font-family: var(--vscode-editor-font-family); +} + +#core { + padding-top: 0.2cm; + table-layout: auto; + box-sizing: border-box; +} + +#core td, +#core th { + box-sizing: border-box; + padding-right: 0.35cm; + padding-left: 0.35cm; +} + +.colTitle { + cursor: pointer; + align-items: center; + display: flex; +} + +body { + padding: 0px; + width: 100%; + color: var(--color); +} + +.logConsole { + height: 100px; + overflow-y: auto; +} + +.innerTable td { + box-sizing: border-box; + border-bottom: 1px solid rgba(196, 196, 196, 0.2); + font-family: var(--vscode-editor-font-family); + font-size: var(--vscode-editor-font-size); +} + +.innerTable td.lastTd { + border-bottom: 0px; +} + +.innerTable td.arrowHolder { + border-bottom: 0px; + padding-right: 0.7cm; +} + +.innerTable td, +.innerTable th { + text-align: left; +} + +.button-holder { + box-sizing: border-box; + display: flex; + justify-content: center; + align-content: center; + align-items: center; + width: 100%; + padding-left: 0.7cm; +} + +.viewLog { + background-color: var(--vscode-button-background); + border: none; + color: var(--vscode-button-foreground); + padding: 5px 13px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: var(--vscode-editor-font-size); + cursor: pointer; + align-items: center; +} + +.loadMoreBtn { + display: none; + justify-content: center; + align-content: center; + align-items: center; + width: 100%; + margin-top: 1cm; + margin-bottom: 1cm; +} + +.viewLog:hover { + background-color: var(--vscode-button-hoverBackground); +} + +.arrow { + -webkit-transition: -webkit-transform .2s ease-in-out; + transition: transform .2s ease-in-out; +} + +.rotate180 { + transform: rotate(180deg); +} + +.activeArrow { + -webkit-transform: rotate(90deg); + transform: rotate(90deg); +} + +.paddingDiv { + display: flex; + width: 100%; + padding-top: 10px; + padding-bottom: 10px; +} + +.copy:hover { + cursor: pointer; +} + +.borderLimit { + padding-left: 40px; +} + +.sort { + display: flex; + align-items: center; +} + +#digestVisualizer { + position: absolute; + width: 1px; + visibility: hidden; +} + +.arrowHolder { + box-sizing: border-box; + width: 4.6%; + padding-right: 0.7cm; + text-align: center; +} + +.overflowX { + overflow-x: auto; +} + +.IconContainer { + font-size: 14px; + float: left; + margin: 0 5px 5px 0; + width: 50px; + height: 50px; + line-height: 51px; +} + +.IconContainer-icon { + text-align: center; +} + +.IconContainer-name, +.IconContainer-unicode { + display: none; +} + +.holder { + border-bottom: 1px solid rgba(196, 196, 196, 0.2); +} + +.textAlignRight { + text-align: right; +} + +p { + margin: 0px; +} + +.innerTable th { + border-bottom: 1px solid rgba(196, 196, 196, 0.5); +} + +.doubleLine { + border-bottom: 2px solid rgba(196, 196, 196, 0.5); +} + +main { + display: grid; + box-sizing: border-box; + margin-left: 10px; + margin-right: 10px; +} + +@media screen and (min-width: 1399px) { + main { + margin-left: 5%; + margin-right: 5% + } +} + +@media screen and (min-width: 1920px) { + main { + width: 1920px; + } +} + +#tableHead { + border-bottom: 1.1px solid var(--vscode-editor-foreground); + box-shadow: 0px 1px var(--vscode-editor-foreground); +} + +#tableHead tr { + height: 0.85cm; +} + +.dragLine { + position: absolute; + height: 80%; + width: 1px; + background-color: white; + background-clip: content-box; + padding-left: 0.35cm; + padding-right: 0.35cm; + left: 100%; + top: 10%; + cursor: w-resize; + z-index: 10; +} + +.dragWrapper { + position: relative; + height: 100%; + width: 100%; + display: flex; + justify-content: first baseline; +} + +.searchBoxes { + padding-top: 8px; + display: flex; + flex-direction: row; +} + +.searchBoxes .middle { + padding-right: 0.5%; + padding-left: 0.5%; + width: 34%; + box-sizing: border-box; +} + +.searchBoxes div { + padding-right: 0px; + width: 33%; + box-sizing: border-box; +} + +.searchBoxes div input { + padding: 5px; + border: 0px; + width: 100%; + box-sizing: border-box; + background-color: var(--vscode-list-hoverBackground); + color: var(--vscode-dropdown-foreground); +} + +.tooltip { + position: relative; +} + +.tooltip .tooltiptext { + box-sizing: border-box; + display: none; + background-color: var(--vscode-editor-foreground); + color: var(--vscode-activityBar-background); + text-align: center; + padding: 5px 16px; + position: absolute; + z-index: 1; + left: 50%; + bottom: 100%; + transform: translateX(-50%); + opacity: 0; + transition: opacity 0.5s; +} + +.tooltip .tooltiptext::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: var(--vscode-editor-foreground) transparent transparent transparent; +} + +.tooltip:hover .tooltiptext { + display: inline; + opacity: 1; +} + +#loading { + display: inline-block; + border: 3px solid var(--vscode-editor-foreground); + border-radius: 50%; + border-top-color: var(--vscode-editor-background); + animation: spin 1s ease-in-out infinite; + -webkit-animation: spin 1s ease-in-out infinite; + height: calc(var(--vscode-editor-font-size)*1.5); + width: calc(var(--vscode-editor-font-size)*1.5); +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@-webkit-keyframes spin { + to { + transform: rotate(360deg); + } +} + +#loadingDiv { + display: flex; + justify-content: center; + align-items: center; + text-align: center; + width: 100%; + margin-top: 1cm; + margin-bottom: 1cm; +} diff --git a/commands/azureCommands/acr-logs-utils/tableDataManager.ts b/commands/azureCommands/acr-logs-utils/tableDataManager.ts new file mode 100644 index 0000000000..354523d7b4 --- /dev/null +++ b/commands/azureCommands/acr-logs-utils/tableDataManager.ts @@ -0,0 +1,156 @@ +import ContainerRegistryManagementClient from "azure-arm-containerregistry"; +import { Registry, Run, RunGetLogResult, RunListResult } from "azure-arm-containerregistry/lib/models"; +import vscode = require('vscode'); +import { parseError } from "vscode-azureextensionui"; +import { ext } from "../../../extensionVariables"; +import { acquireACRAccessTokenFromRegistry } from "../../../utils/Azure/acrTools"; +/** Class to manage data and data acquisition for logs */ +export class LogData { + public registry: Registry; + public resourceGroup: string; + public links: { requesting: boolean, url?: string }[]; + public logs: Run[]; + public client: ContainerRegistryManagementClient; + private nextLink: string; + + constructor(client: ContainerRegistryManagementClient, registry: Registry, resourceGroup: string) { + this.registry = registry; + this.resourceGroup = resourceGroup; + this.client = client; + this.logs = []; + this.links = []; + } + /** Acquires Links from an item number corresponding to the index of the corresponding log, caches + * logs in order to avoid unecessary requests if opened multiple times. + */ + public async getLink(itemNumber: number): Promise { + if (itemNumber >= this.links.length) { + throw new Error('Log for which the link was requested has not been added'); + } + + if (this.links[itemNumber].url) { + return this.links[itemNumber].url; + } + + //If user is simply clicking many times impatiently it makes sense to only have one request at once + if (this.links[itemNumber].requesting) { return 'requesting' } + + this.links[itemNumber].requesting = true; + const temp: RunGetLogResult = await this.client.runs.getLogSasUrl(this.resourceGroup, this.registry.name, this.logs[itemNumber].runId); + this.links[itemNumber].url = temp.logLink; + this.links[itemNumber].requesting = false; + return this.links[itemNumber].url + } + + //contains(TaskName, 'testTask') + //`TaskName eq 'testTask' + // + /** Loads logs from azure + * @param loadNext Determines if the next page of logs should be loaded, will throw an error if there are no more logs to load + * @param removeOld Cleans preexisting information on links and logs imediately before new requests, if loadNext is specified + * the next page of logs will be saved and all preexisting data will be deleted. + * @param filter Specifies a filter for log items, if run Id is specified this will take precedence + */ + public async loadLogs(options: { webViewEvent: boolean, loadNext: boolean, removeOld?: boolean, filter?: Filter }): Promise { + let runListResult: RunListResult; + + if (options.filter && Object.keys(options.filter).length) { + if (!options.filter.runId) { + let runOptions: { + filter?: string, + top?: number, + customHeaders?: { + [headerName: string]: string; + }; + } = {}; + runOptions.filter = await this.parseFilter(options.filter); + if (options.filter.image) { runOptions.top = 1; } + runListResult = await this.client.runs.list(this.resourceGroup, this.registry.name, runOptions); + } else { + runListResult = []; + try { + runListResult.push(await this.client.runs.get(this.resourceGroup, this.registry.name, options.filter.runId)); + } catch (err) { + const error = parseError(err); + if (!options.webViewEvent) { + throw err; + } else if (error.errorType !== "EntityNotFound") { + vscode.window.showErrorMessage(`Error '${error.errorType}': ${error.message}`); + } + } + } + } else { + if (options.loadNext) { + if (this.nextLink) { + runListResult = await this.client.runs.listNext(this.nextLink); + } else if (options.webViewEvent) { + vscode.window.showErrorMessage("No more logs to show."); + } else { + throw new Error('No more logs to show'); + } + } else { + runListResult = await this.client.runs.list(this.resourceGroup, this.registry.name); + } + } + if (options.removeOld) { + //Clear Log Items + this.logs = []; + this.links = []; + this.nextLink = ''; + } + this.nextLink = runListResult.nextLink; + this.logs = this.logs.concat(runListResult); + + const itemCount = runListResult.length; + for (let i = 0; i < itemCount; i++) { + this.links.push({ 'requesting': false }); + } + } + + public hasNextPage(): boolean { + return this.nextLink !== undefined; + } + + public isEmpty(): boolean { + return this.logs.length === 0; + } + + private async parseFilter(filter: Filter): Promise { + let parsedFilter = ""; + if (filter.task) { //Task id + parsedFilter = `TaskName eq '${filter.task}'`; + } else if (filter.image) { //Image + let items: string[] = filter.image.split(':') + const { acrAccessToken } = await acquireACRAccessTokenFromRegistry(this.registry, 'repository:' + items[0] + ':pull'); + let digest = await new Promise((resolve, reject) => ext.request.get('https://' + this.registry.loginServer + `/v2/${items[0]}/manifests/${items[1]}`, { + auth: { + bearer: acrAccessToken + }, + headers: { + accept: 'application/vnd.docker.distribution.manifest.v2+json; 0.5, application/vnd.docker.distribution.manifest.list.v2+json; 0.6' + } + }, (err, httpResponse, body) => { + if (err) { + reject(err); + } else { + const imageDigest = httpResponse.headers['docker-content-digest']; + if (imageDigest instanceof Array) { + reject(new Error('docker-content-digest should be a string not an array.')) + } else { + resolve(imageDigest); + } + } + })); + + if (parsedFilter.length > 0) { parsedFilter += ' and '; } + parsedFilter += `contains(OutputImageManifests, '${items[0]}@${digest}')`; + } + return parsedFilter; + } +} + +export interface Filter { + image?: string; + runId?: string; + task?: string; +} diff --git a/commands/azureCommands/acr-logs-utils/tableViewManager.ts b/commands/azureCommands/acr-logs-utils/tableViewManager.ts new file mode 100644 index 0000000000..6d8e6cec6b --- /dev/null +++ b/commands/azureCommands/acr-logs-utils/tableViewManager.ts @@ -0,0 +1,278 @@ + +import { ImageDescriptor, Run } from "azure-arm-containerregistry/lib/models"; +import * as clipboardy from 'clipboardy' +import * as path from 'path'; +import * as vscode from "vscode"; +import { parseError } from "vscode-azureextensionui"; +import { ext } from "../../../extensionVariables"; +import { accessLog } from './logFileManager'; +import { Filter, LogData } from './tableDataManager' +export class LogTableWebview { + private logData: LogData; + private panel: vscode.WebviewPanel; + + constructor(webviewName: string, logData: LogData) { + this.logData = logData; + this.panel = vscode.window.createWebviewPanel('log Viewer', webviewName, vscode.ViewColumn.One, { enableScripts: true, retainContextWhenHidden: true }); + + //Get path to resource on disk + const extensionPath = ext.context.extensionPath; + const scriptFile = vscode.Uri.file(path.join(extensionPath, 'commands', 'azureCommands', 'acr-logs-utils', 'logScripts.js')).with({ scheme: 'vscode-resource' }); + const styleFile = vscode.Uri.file(path.join(extensionPath, 'commands', 'azureCommands', 'acr-logs-utils', 'style', 'stylesheet.css')).with({ scheme: 'vscode-resource' }); + const iconStyle = vscode.Uri.file(path.join(extensionPath, 'commands', 'azureCommands', 'acr-logs-utils', 'style', 'fabric-components', 'css', 'vscmdl2-icons.css')).with({ scheme: 'vscode-resource' }); + //Populate Webview + this.panel.webview.html = this.getBaseHtml(scriptFile, styleFile, iconStyle); + this.setupIncomingListeners(); + this.addLogsToWebView(); + } + //Post Opening communication from webview + /** Setup communication with the webview sorting out received mesages from its javascript file */ + private setupIncomingListeners(): void { + this.panel.webview.onDidReceiveMessage(async (message: IMessage) => { + if (message.logRequest) { + const itemNumber: number = +message.logRequest.id; + try { + await this.logData.getLink(itemNumber).then(async (url) => { + if (url !== 'requesting') { + await accessLog(url, this.logData.logs[itemNumber].runId, message.logRequest.download); + } + }); + } catch (err) { + const error = parseError(err); + vscode.window.showErrorMessage(`Error '${error.errorType}': ${error.message}`); + } + } else if (message.copyRequest) { + // tslint:disable-next-line:no-unsafe-any + clipboardy.writeSync(message.copyRequest.text); + + } else if (message.loadMore) { + const alreadyLoaded = this.logData.logs.length; + await this.logData.loadLogs({ + webViewEvent: true, + loadNext: true + }); + this.addLogsToWebView(alreadyLoaded); + + } else if (message.loadFiltered) { + await this.logData.loadLogs({ + webViewEvent: true, + loadNext: false, + removeOld: true, + filter: message.loadFiltered.filterString + }); + this.addLogsToWebView(); + } + }); + } + + //Content Management + /** Communicates with the webview javascript file through post requests to populate the log table */ + private addLogsToWebView(startItem?: number): void { + const begin = startItem ? startItem : 0; + for (let i = begin; i < this.logData.logs.length; i++) { + const log = this.logData.logs[i]; + this.panel.webview.postMessage({ + 'type': 'populate', + 'id': i, + 'logComponent': this.getLogTableItem(log, i) + }); + } + if (startItem) { + this.panel.webview.postMessage({ 'type': 'endContinued', 'canLoadMore': this.logData.hasNextPage() }); + } else { + this.panel.webview.postMessage({ 'type': 'end', 'canLoadMore': this.logData.hasNextPage() }); + } + } + + private getImageOutputTable(log: Run): string { + let imageOutput: string = ''; + if (log.outputImages) { + //Adresses strange error where the image list can exist and contain only one null item. + if (!log.outputImages[0]) { + imageOutput += this.getImageItem(true); + } else { + for (let j = 0; j < log.outputImages.length; j++) { + let img = log.outputImages[j] + imageOutput += this.getImageItem(j === log.outputImages.length - 1, img); + } + } + } else { + imageOutput += this.getImageItem(true); + } + return imageOutput; + } + + //HTML Content Loaders + /** Create the table in which to push the logs */ + private getBaseHtml(scriptFile: vscode.Uri, stylesheet: vscode.Uri, iconStyles: vscode.Uri): string { + return ` + + + + + + + + Logs + + + +
+
+
+ Filter by ID:
+ +
+
+ Filter by Task:
+ +
+
+ + + + + + + + + + + + + + + + + + + +
ID Task Status Created Elapsed Time Platform
+
+
+ Loading     +
+
+ +
+ + `; + } + + private getLogTableItem(log: Run, logId: number): string { + const task: string = log.task ? log.task : ''; + const prettyDate: string = log.createTime ? this.getPrettyDate(log.createTime) : ''; + const timeElapsed: string = log.startTime && log.finishTime ? Math.ceil((log.finishTime.valueOf() - log.startTime.valueOf()) / 1000).toString() + 's' : ''; + const osType: string = log.platform.os ? log.platform.os : ''; + const name: string = log.name ? log.name : ''; + const imageOutput: string = this.getImageOutputTable(log); + const statusIcon: string = this.getLogStatusIcon(log.status); + + return ` + + +
+ ${name} + ${task} + ${statusIcon} ${log.status} + ${prettyDate} + ${timeElapsed} + ${osType} + + + +
+ + + + + + + + + ${imageOutput} +
 TagRepositoryDigest +

Log

+
+
+ + + ` + } + + private getImageItem(islastTd: boolean, img?: ImageDescriptor): string { + if (img) { + const tag: string = img.tag ? img.tag : ''; + const repository: string = img.repository ? img.repository : ''; + const digest: string = img.digest ? img.digest : ''; + const truncatedDigest: string = digest ? digest.substr(0, 5) + '...' + digest.substr(digest.length - 5) : ''; + const lastTd: string = islastTd ? 'lastTd' : ''; + return ` +   + ${tag} + ${repository} + + + ${truncatedDigest} + ${digest} + + + + + ` + } else { + return ` +   + NA + NA + NA + + `; + } + + } + + private getLogStatusIcon(status?: string): string { + if (!status) { return ''; } + switch (status) { + case 'Error': + return ''; + case 'Failed': + return ''; + case 'Succeeded': + return ''; + case 'Queued': + return ''; + case 'Running': + return ''; + default: + return ''; + } + } + + private getPrettyDate(date: Date): string { + let currentDate = new Date(); + let secs = Math.floor((currentDate.getTime() - date.getTime()) / 1000); + if (secs === 1) { return "1 second ago"; } + if (secs < 60) { return secs + " seconds ago"; } + if (secs < 120) { return " 1 minute ago"; } + if (secs < 3600) { return Math.floor(secs / 60) + " minutes ago"; } + if (secs < 7200) { return Math.floor(secs / 60) + "1 hour ago"; } + if (secs < 86400) { return Math.floor(secs / 3600) + " hours ago"; } + if (secs < 172800) { return "1 day ago"; } + if (secs < 604800) { return Math.floor(secs / 86400) + " days ago"; } + if (secs < 1209600) { return "1 week ago"; } + if (secs < 2592000) { return Math.floor(secs / 604800) + " weeks ago"; } + if (secs < 5184000) { return "1 month ago"; } + if (secs < 31536000) { return Math.floor(secs / 2592000) + " months ago"; } + if (secs < 63072000) { return "1 year ago"; } + return Math.floor(secs / 31536000) + " years ago"; + } +} + +interface IMessage { + logRequest?: { id: number; download: boolean }; + copyRequest?: { text: string }; + loadMore?: string; + loadFiltered?: { filterString: Filter }; +} diff --git a/commands/azureCommands/acr-logs.ts b/commands/azureCommands/acr-logs.ts new file mode 100644 index 0000000000..ee09302b88 --- /dev/null +++ b/commands/azureCommands/acr-logs.ts @@ -0,0 +1,81 @@ +"use strict"; + +import { Registry } from "azure-arm-containerregistry/lib/models"; +import { Subscription } from "azure-arm-resource/lib/subscription/models"; +import * as vscode from "vscode"; +import { AzureImageTagNode, AzureRegistryNode } from '../../explorer/models/azureRegistryNodes'; +import { TaskNode } from "../../explorer/models/taskNode"; +import { getResourceGroupName, getSubscriptionFromRegistry } from '../../utils/Azure/acrTools'; +import { AzureUtilityManager } from '../../utils/azureUtilityManager'; +import { quickPickACRRegistry } from '../utils/quick-pick-azure' +import { accessLog } from "./acr-logs-utils/logFileManager"; +import { LogData } from "./acr-logs-utils/tableDataManager"; +import { LogTableWebview } from "./acr-logs-utils/tableViewManager"; + +/** This command is used through a right click on an azure registry, repository or image in the Docker Explorer. It is used to view ACR logs for a given item. */ +export async function viewACRLogs(context: AzureRegistryNode | AzureImageTagNode | TaskNode): Promise { + let registry: Registry; + let subscription: Subscription; + if (!context) { + registry = await quickPickACRRegistry(); + subscription = await getSubscriptionFromRegistry(registry); + } else { + registry = context.registry; + subscription = context.subscription; + } + let resourceGroup: string = getResourceGroupName(registry); + const client = await AzureUtilityManager.getInstance().getContainerRegistryManagementClient(subscription); + let logData: LogData = new LogData(client, registry, resourceGroup); + + // Filtering provided + if (context && context instanceof AzureImageTagNode) { + //ACR Image Logs + await logData.loadLogs({ + webViewEvent: false, + loadNext: false, + removeOld: false, + filter: { image: context.label } + }); + if (!hasValidLogContent(context, logData)) { return; } + const url = await logData.getLink(0); + await accessLog(url, logData.logs[0].runId, false); + } else { + if (context && context instanceof TaskNode) { + //ACR Task Logs + await logData.loadLogs({ + webViewEvent: false, + loadNext: false, + removeOld: false, + filter: { task: context.label } + }); + } else { + //ACR Registry Logs + await logData.loadLogs({ + webViewEvent: false, + loadNext: false + }); + } + if (!hasValidLogContent(context, logData)) { return; } + let webViewTitle = registry.name; + if (context instanceof TaskNode) { + webViewTitle += '/' + context.label; + } + const webview = new LogTableWebview(webViewTitle, logData); + } +} + +function hasValidLogContent(context: AzureRegistryNode | AzureImageTagNode | TaskNode, logData: LogData): boolean { + if (logData.logs.length === 0) { + let itemType: string; + if (context && context instanceof TaskNode) { + itemType = 'task'; + } else if (context && context instanceof AzureImageTagNode) { + itemType = 'image'; + } else { + itemType = 'registry'; + } + vscode.window.showInformationMessage(`This ${itemType} has no associated logs`); + return false; + } + return true; +} diff --git a/commands/azureCommands/create-registry.ts b/commands/azureCommands/create-registry.ts index 12bbac334b..d81196d48b 100644 --- a/commands/azureCommands/create-registry.ts +++ b/commands/azureCommands/create-registry.ts @@ -7,6 +7,7 @@ import { Registry, RegistryNameStatus } from "azure-arm-containerregistry/lib/mo import { SubscriptionModels } from 'azure-arm-resource'; import { ResourceGroup } from "azure-arm-resource/lib/resource/models"; import * as vscode from "vscode"; +import { skus } from '../../constants'; import { dockerExplorerProvider } from '../../dockerExtension'; import { ext } from '../../extensionVariables'; import { isValidAzureName } from '../../utils/Azure/common'; @@ -18,7 +19,7 @@ import { quickPickLocation, quickPickResourceGroup, quickPickSKU, quickPickSubsc export async function createRegistry(): Promise { const subscription: SubscriptionModels.Subscription = await quickPickSubscription(); const resourceGroup: ResourceGroup = await quickPickResourceGroup(true, subscription); - const client = AzureUtilityManager.getInstance().getContainerRegistryManagementClient(subscription); + const client = await AzureUtilityManager.getInstance().getContainerRegistryManagementClient(subscription); const registryName: string = await acquireRegistryName(client); const sku: string = await quickPickSKU(); const location = await quickPickLocation(subscription); diff --git a/commands/azureCommands/delete-registry.ts b/commands/azureCommands/delete-registry.ts index 8faf3d63db..3b2918c7a8 100644 --- a/commands/azureCommands/delete-registry.ts +++ b/commands/azureCommands/delete-registry.ts @@ -24,9 +24,9 @@ export async function deleteAzureRegistry(context?: AzureRegistryNode): Promise< } const shouldDelete = await confirmUserIntent(`Are you sure you want to delete ${registry.name} and its associated images?`); if (shouldDelete) { - let subscription: SubscriptionModels.Subscription = acrTools.getSubscriptionFromRegistry(registry); + let subscription: SubscriptionModels.Subscription = await acrTools.getSubscriptionFromRegistry(registry); let resourceGroup: string = acrTools.getResourceGroupName(registry); - const client = AzureUtilityManager.getInstance().getContainerRegistryManagementClient(subscription); + const client = await AzureUtilityManager.getInstance().getContainerRegistryManagementClient(subscription); await client.registries.beginDeleteMethod(resourceGroup, nonNullProp(registry, 'name')); vscode.window.showInformationMessage(`Successfully deleted registry ${registry.name}`); dockerExplorerProvider.refreshRegistries(); diff --git a/commands/azureCommands/pull-from-azure.ts b/commands/azureCommands/pull-from-azure.ts new file mode 100644 index 0000000000..b2c2bd91dd --- /dev/null +++ b/commands/azureCommands/pull-from-azure.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { Registry } from "azure-arm-containerregistry/lib/models"; +import { exec } from 'child_process'; +import * as fse from 'fs-extra'; +import * as path from "path"; +import vscode = require('vscode'); +import { callWithTelemetryAndErrorHandling, IActionContext, parseError } from 'vscode-azureextensionui'; +import { UserCancelledError } from '../../explorer/deploy/wizard'; +import { AzureImageTagNode, AzureRepositoryNode } from '../../explorer/models/azureRegistryNodes'; +import { ext } from '../../extensionVariables'; +import * as acrTools from '../../utils/Azure/acrTools'; +import { AzureImage } from "../../utils/Azure/models/image"; +import { Repository } from "../../utils/Azure/models/repository"; +import * as quickPicks from '../utils/quick-pick-azure'; + +/* Pulls an image from Azure. The context is the image node the user has right clicked on */ +export async function pullFromAzure(context?: AzureImageTagNode | AzureRepositoryNode): Promise { + let registryName: string; + let registry: Registry; + let imageName: string; + + if (context) { // Right Click + registryName = context.registry.loginServer; + registry = context.registry; + + if (context instanceof AzureImageTagNode) { // Right Click on AzureImageNode + imageName = context.label; + } else if (context instanceof AzureRepositoryNode) { // Right Click on AzureRepositoryNode + imageName = `${context.label} -a`; // Pull all images in repository + } else { + assert.fail(`Unexpected node type`); + } + + } else { // Command Palette + registry = await quickPicks.quickPickACRRegistry(); + registryName = registry.loginServer; + const repository: Repository = await quickPicks.quickPickACRRepository(registry, 'Select the repository of the image you want to pull'); + const image: AzureImage = await quickPicks.quickPickACRImage(repository, 'Select the image you want to pull'); + imageName = `${repository.name}:${image.tag}`; + } + + // Using loginCredentials function to get the username and password. This takes care of all users, even if they don't have the Azure CLI + const credentials = await acrTools.getLoginCredentials(registry); + const username = credentials.username; + const password = credentials.password; + await pullImage(registryName, imageName, username, password); +} + +async function pullImage(registryName: string, imageName: string, username: string, password: string): Promise { + // Check if user is logged into Docker and send appropriate commands to terminal + let result = await isLoggedIntoDocker(registryName); + if (!result.loggedIn) { // If not logged in to Docker + let login: vscode.MessageItem = { title: 'Log in to Docker CLI' }; + let msg = `You are not currently logged in to "${registryName}" in the Docker CLI.`; + let response = await vscode.window.showErrorMessage(msg, login) + if (response !== login) { + throw new UserCancelledError(msg); + } + + await new Promise((resolve, reject) => { + let childProcess = exec(`docker login ${registryName} --username ${username} --password-stdin`, (err, stdout, stderr) => { + ext.outputChannel.append(stdout); + ext.outputChannel.append(stderr); + if (err && err.message.match(/error storing credentials.*The stub received bad data/)) { + // Temporary work-around for this error- same as Azure CLI + // See https://github.com/Azure/azure-cli/issues/4843 + reject(new Error(`In order to log in to the Docker CLI using tokens, you currently need to go to \n${result.configPath} and remove "credsStore": "wincred" from the config.json file, then try again. \nDoing this will disable wincred and cause Docker to store credentials directly in the .docker/config.json file. All registries that are currently logged in will be effectly logged out.`)); + } else if (err) { + reject(err); + } else if (stderr) { + reject(stderr); + } + + resolve(); + }); + + childProcess.stdin.write(password); // Prevents insecure password error + childProcess.stdin.end(); + }); + } + + const terminal: vscode.Terminal = ext.terminalProvider.createTerminal("docker pull"); + terminal.show(); + + terminal.sendText(`docker pull ${registryName}/${imageName}`); +} + +async function isLoggedIntoDocker(registryName: string): Promise<{ configPath: string, loggedIn: boolean }> { + let home = process.env.HOMEPATH; + let configPath: string = path.join(home, '.docker', 'config.json'); + let buffer: Buffer; + + await callWithTelemetryAndErrorHandling('findDockerConfig', async function (this: IActionContext): Promise { + this.suppressTelemetry = true; + buffer = fse.readFileSync(configPath); + }); + + let index = buffer.indexOf(registryName); + let loggedIn = index >= 0; // Returns -1 if user is not logged into Docker + return { configPath, loggedIn }; // Returns object with configuration path and boolean indicating if user was logged in or not +} diff --git a/commands/azureCommands/quick-build.ts b/commands/azureCommands/quick-build.ts new file mode 100644 index 0000000000..9e107db2c4 --- /dev/null +++ b/commands/azureCommands/quick-build.ts @@ -0,0 +1,94 @@ +import { ContainerRegistryManagementClient } from 'azure-arm-containerregistry/lib/containerRegistryManagementClient'; +import { Registry, Run, SourceUploadDefinition } from 'azure-arm-containerregistry/lib/models'; +import { DockerBuildRequest } from "azure-arm-containerregistry/lib/models"; +import { Subscription } from 'azure-arm-resource/lib/subscription/models'; +import { BlobService, createBlobServiceWithSas } from "azure-storage"; +import * as fse from 'fs-extra'; +import * as os from 'os'; +import * as process from 'process'; +import * as tar from 'tar'; +import * as url from 'url'; +import * as vscode from "vscode"; +import { IActionContext, IAzureQuickPickItem } from 'vscode-azureextensionui'; +import { ext } from '../../extensionVariables'; +import { getBlobInfo, getResourceGroupName, IBlobInfo, streamLogs } from "../../utils/Azure/acrTools"; +import { AzureUtilityManager } from "../../utils/azureUtilityManager"; +import { Item } from '../build-image'; +import { quickPickACRRegistry, quickPickSubscription } from '../utils/quick-pick-azure'; +import { quickPickDockerFileItem, quickPickImageName } from '../utils/quick-pick-image'; +import { quickPickWorkspaceFolder } from '../utils/quickPickWorkspaceFolder'; + +const idPrecision = 6; +const vcsIgnoreList: Set = new Set(['.git', '.gitignore', '.bzr', 'bzrignore', '.hg', '.hgignore', '.svn']); +const status = vscode.window.createOutputChannel('ACR Build Status'); + +// Prompts user to select a subscription, resource group, then registry from drop down. If there are multiple folders in the workspace, the source folder must also be selected. +// The user is then asked to name & tag the image. A build is queued for the image in the selected registry. +// Selected source code must contain a path to the desired dockerfile. +export async function quickBuild(actionContext: IActionContext, dockerFileUri?: vscode.Uri | undefined): Promise { + //Acquire information from user + let rootFolder: vscode.WorkspaceFolder = await quickPickWorkspaceFolder("To quick build Docker files you must first open a folder or workspace in VS Code."); + const dockerItem: Item = await quickPickDockerFileItem(actionContext, dockerFileUri, rootFolder); + const subscription: Subscription = await quickPickSubscription(); + const registry: Registry = await quickPickACRRegistry(true); + const osPick = ['Linux', 'Windows'].map(item => >{ label: item, data: item }); + const osType: string = (await ext.ui.showQuickPick(osPick, { 'canPickMany': false, 'placeHolder': 'Select image base OS' })).data; + const imageName: string = await quickPickImageName(actionContext, rootFolder, dockerItem); + + const resourceGroupName: string = getResourceGroupName(registry); + const tarFilePath: string = getTempSourceArchivePath(); + const client: ContainerRegistryManagementClient = await AzureUtilityManager.getInstance().getContainerRegistryManagementClient(subscription); + + //Begin readying build + status.show(); + + const uploadedSourceLocation: string = await uploadSourceCode(client, registry.name, resourceGroupName, rootFolder, tarFilePath); + status.appendLine("Uploaded Source Code to " + tarFilePath); + + const runRequest: DockerBuildRequest = { + type: 'DockerBuildRequest', + imageNames: [imageName], + isPushEnabled: true, + sourceLocation: uploadedSourceLocation, + platform: { os: osType }, + dockerFilePath: dockerItem.relativeFilePath + }; + status.appendLine("Set up Run Request"); + + const run: Run = await client.registries.scheduleRun(resourceGroupName, registry.name, runRequest); + status.appendLine("Scheduled Run " + run.runId); + + await streamLogs(registry, run, status, client); + await fse.unlink(tarFilePath); +} + +async function uploadSourceCode(client: ContainerRegistryManagementClient, registryName: string, resourceGroupName: string, rootFolder: vscode.WorkspaceFolder, tarFilePath: string): Promise { + status.appendLine(" Sending source code to temp file"); + let source: string = rootFolder.uri.fsPath; + let items = await fse.readdir(source); + items = items.filter(i => !(i in vcsIgnoreList)); + // tslint:disable-next-line:no-unsafe-any + tar.c({ cwd: source }, items).pipe(fse.createWriteStream(tarFilePath)); + + status.appendLine(" Getting Build Source Upload Url "); + let sourceUploadLocation: SourceUploadDefinition = await client.registries.getBuildSourceUploadUrl(resourceGroupName, registryName); + let upload_url: string = sourceUploadLocation.uploadUrl; + let relative_path: string = sourceUploadLocation.relativePath; + + status.appendLine(" Getting blob info from Upload Url "); + // Right now, accountName and endpointSuffix are unused, but will be used for streaming logs later. + let blobInfo: IBlobInfo = getBlobInfo(upload_url); + status.appendLine(" Creating Blob Service "); + let blob: BlobService = createBlobServiceWithSas(blobInfo.host, blobInfo.sasToken); + status.appendLine(" Creating Block Blob "); + blob.createBlockBlobFromLocalFile(blobInfo.containerName, blobInfo.blobName, tarFilePath, (): void => { }); + return relative_path; +} + +function getTempSourceArchivePath(): string { + /* tslint:disable-next-line:insecure-random */ + let id: number = Math.floor(Math.random() * Math.pow(10, idPrecision)); + status.appendLine("Setting up temp file with 'sourceArchive" + id + ".tar.gz' "); + let tarFilePath: string = url.resolve(os.tmpdir(), `sourceArchive${id}.tar.gz`); + return tarFilePath; +} diff --git a/commands/azureCommands/run-task.ts b/commands/azureCommands/run-task.ts new file mode 100644 index 0000000000..f4f6f0dc37 --- /dev/null +++ b/commands/azureCommands/run-task.ts @@ -0,0 +1,42 @@ +import { TaskRunRequest } from "azure-arm-containerregistry/lib/models"; +import { Registry } from "azure-arm-containerregistry/lib/models"; +import { ResourceGroup } from "azure-arm-resource/lib/resource/models"; +import { Subscription } from "azure-arm-resource/lib/subscription/models"; +import vscode = require('vscode'); +import { parseError } from "vscode-azureextensionui"; +import { TaskNode } from "../../explorer/models/taskNode"; +import * as acrTools from '../../utils/Azure/acrTools'; +import { AzureUtilityManager } from "../../utils/azureUtilityManager"; +import { quickPickACRRegistry, quickPickSubscription, quickPickTask } from '../utils/quick-pick-azure'; + +export async function runTask(context?: TaskNode): Promise { + let taskName: string; + let subscription: Subscription; + let resourceGroup: ResourceGroup; + let registry: Registry; + + if (context) { // Right Click + subscription = context.subscription; + registry = context.registry; + resourceGroup = await acrTools.getResourceGroup(registry, subscription); + taskName = context.task.name; + } else { // Command Palette + subscription = await quickPickSubscription(); + registry = await quickPickACRRegistry(); + resourceGroup = await acrTools.getResourceGroup(registry, subscription); + taskName = (await quickPickTask(registry, subscription, resourceGroup)).name; + } + + const client = await AzureUtilityManager.getInstance().getContainerRegistryManagementClient(subscription); + let runRequest: TaskRunRequest = { + type: 'TaskRunRequest', + taskName: taskName + }; + + try { + let taskRun = await client.registries.scheduleRun(resourceGroup.name, registry.name, runRequest); + vscode.window.showInformationMessage(`Successfully scheduled the Task '${taskName}' with ID '${taskRun.runId}'.`); + } catch (err) { + throw new Error(`Failed to schedule the Task '${taskName}'\nError: '${parseError(err).message}'`); + } +} diff --git a/commands/azureCommands/show-task.ts b/commands/azureCommands/show-task.ts new file mode 100644 index 0000000000..e515eda150 --- /dev/null +++ b/commands/azureCommands/show-task.ts @@ -0,0 +1,32 @@ +import { Registry, Task } from "azure-arm-containerregistry/lib/models"; +import { ResourceGroup } from "azure-arm-resource/lib/resource/models"; +import { Subscription } from "azure-arm-resource/lib/subscription/models"; +import { TaskNode } from "../../explorer/models/taskNode"; +import * as acrTools from '../../utils/Azure/acrTools'; +import { AzureUtilityManager } from "../../utils/azureUtilityManager"; +import { quickPickACRRegistry, quickPickSubscription, quickPickTask } from '../utils/quick-pick-azure'; +import { openTask } from "./task-utils/showTaskManager"; + +export async function showTaskProperties(context?: TaskNode): Promise { + let subscription: Subscription; + let registry: Registry; + let resourceGroup: ResourceGroup; + let task: string; + + if (context) { // Right click + subscription = context.subscription; + registry = context.registry; + resourceGroup = await acrTools.getResourceGroup(registry, subscription); + task = context.task.name; + } else { // Command palette + subscription = await quickPickSubscription(); + registry = await quickPickACRRegistry(); + resourceGroup = await acrTools.getResourceGroup(registry, subscription); + task = (await quickPickTask(registry, subscription, resourceGroup)).name; + } + + const client = await AzureUtilityManager.getInstance().getContainerRegistryManagementClient(subscription); + let item: Task = await client.tasks.get(resourceGroup.name, registry.name, task); + let indentation = 2; + openTask(JSON.stringify(item, undefined, indentation), task); +} diff --git a/commands/azureCommands/task-utils/showTaskManager.ts b/commands/azureCommands/task-utils/showTaskManager.ts new file mode 100644 index 0000000000..19d5b2ff78 --- /dev/null +++ b/commands/azureCommands/task-utils/showTaskManager.ts @@ -0,0 +1,38 @@ +import * as vscode from 'vscode'; + +export class TaskContentProvider implements vscode.TextDocumentContentProvider { + public static scheme: string = 'task'; + private onDidChangeEvent: vscode.EventEmitter = new vscode.EventEmitter(); + + constructor() { } + + public provideTextDocumentContent(uri: vscode.Uri): string { + const parse: { content: string } = <{ content: string }>JSON.parse(uri.query); + return decodeBase64(parse.content); + } + + get onDidChange(): vscode.Event { + return this.onDidChangeEvent.event; + } + + public update(uri: vscode.Uri, message: string): void { + this.onDidChangeEvent.fire(uri); + } +} + +export function decodeBase64(str: string): string { + return Buffer.from(str, 'base64').toString('utf8'); +} + +export function encodeBase64(str: string): string { + return Buffer.from(str, 'ascii').toString('base64'); +} + +export function openTask(content: string, title: string): void { + const scheme = 'task'; + let query = JSON.stringify({ 'content': encodeBase64(content) }); + let uri: vscode.Uri = vscode.Uri.parse(`${scheme}://authority/${title}.json?${query}#idk`); + vscode.workspace.openTextDocument(uri).then((doc) => { + return vscode.window.showTextDocument(doc, vscode.ViewColumn.Active + 1, true); + }); +} diff --git a/commands/build-image.ts b/commands/build-image.ts index 1f3e350deb..2116f6424f 100644 --- a/commands/build-image.ts +++ b/commands/build-image.ts @@ -10,12 +10,13 @@ import { DOCKERFILE_GLOB_PATTERN } from '../dockerExtension'; import { delay } from "../explorer/utils/utils"; import { ext } from "../extensionVariables"; import { addImageTaggingTelemetry, getTagFromUserInput } from "./tag-image"; +import { quickPickWorkspaceFolder } from "./utils/quickPickWorkspaceFolder"; async function getDockerFileUris(folder: vscode.WorkspaceFolder): Promise { return await vscode.workspace.findFiles(new vscode.RelativePattern(folder, DOCKERFILE_GLOB_PATTERN), undefined, 1000, undefined); } -interface Item extends vscode.QuickPickItem { +export interface Item extends vscode.QuickPickItem { relativeFilePath: string; relativeFolderPath: string; } @@ -31,7 +32,7 @@ function createDockerfileItem(rootFolder: vscode.WorkspaceFolder, uri: vscode.Ur }; } -async function resolveDockerFileItem(rootFolder: vscode.WorkspaceFolder, dockerFileUri: vscode.Uri | undefined): Promise { +export async function resolveDockerFileItem(rootFolder: vscode.WorkspaceFolder, dockerFileUri: vscode.Uri | undefined): Promise { if (dockerFileUri) { return createDockerfileItem(rootFolder, dockerFileUri); } @@ -56,25 +57,7 @@ export async function buildImage(actionContext: IActionContext, dockerFileUri: v const defaultContextPath = configOptions.get('imageBuildContextPath', ''); let dockerFileItem: Item | undefined; - let rootFolder: vscode.WorkspaceFolder; - if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length === 1) { - rootFolder = vscode.workspace.workspaceFolders[0]; - } else { - let selected = await vscode.window.showWorkspaceFolderPick(); - if (!selected) { - throw new UserCancelledError(); - } - rootFolder = selected; - } - - if (!rootFolder) { - if (!vscode.workspace.workspaceFolders) { - vscode.window.showErrorMessage('Docker files can only be built if VS Code is opened on a folder.'); - } else { - vscode.window.showErrorMessage('Docker files can only be built if a workspace folder is picked in VS Code.'); - } - return; - } + let rootFolder: vscode.WorkspaceFolder = await quickPickWorkspaceFolder('To build Docker files you must first open a folder or workspace in VS Code.'); while (!dockerFileItem) { let resolvedItem: Item | undefined = await resolveDockerFileItem(rootFolder, dockerFileUri); diff --git a/commands/docker-compose.ts b/commands/docker-compose.ts index ff13ab25ae..4c15f398a9 100644 --- a/commands/docker-compose.ts +++ b/commands/docker-compose.ts @@ -9,6 +9,7 @@ import { UserCancelledError } from 'vscode-azureextensionui'; import { COMPOSE_FILE_GLOB_PATTERN } from '../dockerExtension'; import { ext } from '../extensionVariables'; import { reporter } from '../telemetry/telemetry'; +import { quickPickWorkspaceFolder } from './utils/quickPickWorkspaceFolder'; const teleCmdId: string = 'vscode-docker.compose.'; // we append up or down when reporting telemetry async function getDockerComposeFileUris(folder: vscode.WorkspaceFolder): Promise { @@ -41,22 +42,7 @@ function computeItems(folder: vscode.WorkspaceFolder, uris: vscode.Uri[]): vscod } async function compose(commands: ('up' | 'down')[], message: string, dockerComposeFileUri?: vscode.Uri, selectedComposeFileUris?: vscode.Uri[]): Promise { - let folder: vscode.WorkspaceFolder | undefined; - - if (!vscode.workspace.workspaceFolders) { - vscode.window.showErrorMessage('Docker compose can only run if VS Code is opened on a folder.'); - return; - } - - if (vscode.workspace.workspaceFolders.length === 1) { - folder = vscode.workspace.workspaceFolders[0]; - } else { - folder = await vscode.window.showWorkspaceFolderPick(); - } - - if (!folder) { - throw new UserCancelledError(); - } + let folder: vscode.WorkspaceFolder = await quickPickWorkspaceFolder('To run Docker compose you must first open a folder or workspace in VS Code.'); let commandParameterFileUris: vscode.Uri[]; if (selectedComposeFileUris && selectedComposeFileUris.length) { diff --git a/commands/registrySettings.ts b/commands/registrySettings.ts index dceb316126..e264a46eeb 100644 --- a/commands/registrySettings.ts +++ b/commands/registrySettings.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as assert from 'assert'; import * as vscode from 'vscode'; import { DialogResponses } from 'vscode-azureextensionui'; import { configurationKeys } from '../constants'; diff --git a/commands/start-container.ts b/commands/start-container.ts index 8f78ece3eb..e6d30709eb 100644 --- a/commands/start-container.ts +++ b/commands/start-container.ts @@ -4,14 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import * as cp from 'child_process'; -import * as fs from 'fs'; +import * as fse from 'fs-extra'; import os = require('os'); import vscode = require('vscode'); import { IActionContext, parseError } from 'vscode-azureextensionui'; import { ImageNode } from '../explorer/models/imageNode'; import { RootNode } from '../explorer/models/rootNode'; import { ext } from '../extensionVariables'; -import { reporter } from '../telemetry/telemetry'; import { docker, DockerEngineType } from './utils/docker-endpoint'; import { ImageItem, quickPickImage } from './utils/quick-pick-image'; @@ -93,13 +92,13 @@ export async function startAzureCLI(actionContext: IActionContext): Promise { const placeHolder = prompt ? prompt : 'Select image to use'; @@ -33,6 +35,16 @@ export async function quickPickACRRepository(registry: Registry, prompt?: string return desiredRepo.data; } +export async function quickPickTask(registry: Registry, subscription: Subscription, resourceGroup: ResourceGroup, prompt?: string): Promise { + const placeHolder = prompt ? prompt : 'Choose a Task'; + + const client = await AzureUtilityManager.getInstance().getContainerRegistryManagementClient(subscription); + let tasks: ContainerModels.Task[] = await client.tasks.list(resourceGroup.name, registry.name); + const quickpPickBTList = tasks.map(task => >{ label: task.name, data: task }); + let desiredTask = await ext.ui.showQuickPick(quickpPickBTList, { 'canPickMany': false, 'placeHolder': placeHolder }); + return desiredTask.data; +} + export async function quickPickACRRegistry(canCreateNew: boolean = false, prompt?: string): Promise { const placeHolder = prompt ? prompt : 'Select registry to use'; let registries = await AzureUtilityManager.getInstance().getRegistries(); @@ -47,7 +59,7 @@ export async function quickPickACRRegistry(canCreateNew: boolean = false, prompt }); let registry: Registry; if (desiredReg === createNewItem) { - registry = await vscode.commands.executeCommand("vscode-docker.create-ACR-Registry"); + registry = await createRegistry(); } else { registry = desiredReg.data; } @@ -64,7 +76,7 @@ export async function quickPickSKU(): Promise { } export async function quickPickSubscription(): Promise { - const subscriptions = AzureUtilityManager.getInstance().getFilteredSubscriptionList(); + const subscriptions = await AzureUtilityManager.getInstance().getFilteredSubscriptionList(); if (subscriptions.length === 0) { vscode.window.showErrorMessage("You do not have any subscriptions. You can create one in your Azure portal", "Open Portal").then(val => { if (val === "Open Portal") { @@ -144,7 +156,7 @@ export async function confirmUserIntent(yesOrNoPrompt: string): Promise /*Creates a new resource group within the current subscription */ async function createNewResourceGroup(loc: string, subscription?: Subscription): Promise { - const resourceGroupClient = AzureUtilityManager.getInstance().getResourceManagementClient(subscription); + const resourceGroupClient = await AzureUtilityManager.getInstance().getResourceManagementClient(subscription); let opt: vscode.InputBoxOptions = { validateInput: async (value: string) => { return await checkForValidResourcegroupName(value, resourceGroupClient) }, @@ -153,7 +165,6 @@ async function createNewResourceGroup(loc: string, subscription?: Subscription): }; let resourceGroupName: string = await ext.ui.showInputBox(opt); - let newResourceGroup: ResourceGroup = { name: resourceGroupName, location: loc, diff --git a/commands/utils/quick-pick-container.ts b/commands/utils/quick-pick-container.ts index 09e2b56e28..738ba9093b 100644 --- a/commands/utils/quick-pick-container.ts +++ b/commands/utils/quick-pick-container.ts @@ -9,7 +9,6 @@ import * as os from 'os'; import vscode = require('vscode'); import { IActionContext, parseError, TelemetryProperties } from 'vscode-azureextensionui'; import { ext } from '../../extensionVariables'; -import { openShellContainer } from '../open-shell-container'; import { docker } from './docker-endpoint'; export interface ContainerItem extends vscode.QuickPickItem { diff --git a/commands/utils/quick-pick-image.ts b/commands/utils/quick-pick-image.ts index a9c9454c42..4ffd483bfa 100644 --- a/commands/utils/quick-pick-image.ts +++ b/commands/utils/quick-pick-image.ts @@ -2,11 +2,14 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ - import * as Docker from 'dockerode'; +import * as path from "path"; import vscode = require('vscode'); -import { IActionContext, parseError, TelemetryProperties } from 'vscode-azureextensionui'; +import { DialogResponses, IActionContext, parseError, TelemetryProperties } from 'vscode-azureextensionui'; +import { delay } from '../../explorer/utils/utils'; import { ext } from '../../extensionVariables'; +import { Item, resolveDockerFileItem } from '../build-image'; +import { addImageTaggingTelemetry, getTagFromUserInput } from '../tag-image'; import { docker } from './docker-endpoint'; export interface ImageItem extends vscode.QuickPickItem { @@ -78,3 +81,51 @@ export async function quickPickImage(actionContext: IActionContext, includeAll?: return response; } } + +export async function quickPickImageName(actionContext: IActionContext, rootFolder: vscode.WorkspaceFolder, dockerFileItem: Item | undefined): Promise { + let absFilePath: string = path.join(rootFolder.uri.fsPath, dockerFileItem.relativeFilePath); + let dockerFileKey = `ACR_buildTag_${absFilePath}`; + let prevImageName: string | undefined = ext.context.globalState.get(dockerFileKey); + let suggestedImageName: string; + + if (!prevImageName) { + // Get imageName based on name of subfolder containing the Dockerfile, or else workspacefolder + suggestedImageName = path.basename(dockerFileItem.relativeFolderPath).toLowerCase(); + if (suggestedImageName === '.') { + suggestedImageName = path.basename(rootFolder.uri.fsPath).toLowerCase().replace(/\s/g, ''); + } + + suggestedImageName += ":{{.Run.ID}}" + } else { + suggestedImageName = prevImageName; + } + + // Temporary work-around for vscode bug where valueSelection can be messed up if a quick pick is followed by a showInputBox + await delay(500); + + addImageTaggingTelemetry(actionContext, suggestedImageName, '.before'); + const imageName: string = await getTagFromUserInput(suggestedImageName, false); + addImageTaggingTelemetry(actionContext, imageName, '.after'); + + await ext.context.globalState.update(dockerFileKey, imageName); + return imageName; +} + +export async function quickPickDockerFileItem(actionContext: IActionContext, dockerFileUri: vscode.Uri | undefined, rootFolder: vscode.WorkspaceFolder): Promise { + let dockerFileItem: Item; + + while (!dockerFileItem) { + let resolvedItem: Item | undefined = await resolveDockerFileItem(rootFolder, dockerFileUri); + if (resolvedItem) { + dockerFileItem = resolvedItem; + } else { + let msg = "Couldn't find a Dockerfile in your workspace. Would you like to add Docker files to the workspace?"; + actionContext.properties.cancelStep = msg; + await ext.ui.showWarningMessage(msg, DialogResponses.yes, DialogResponses.cancel); + actionContext.properties.cancelStep = undefined; + await vscode.commands.executeCommand('vscode-docker.configure'); + // Try again + } + } + return dockerFileItem; +} diff --git a/commands/utils/quickPickWorkspaceFolder.ts b/commands/utils/quickPickWorkspaceFolder.ts new file mode 100644 index 0000000000..541ad5d993 --- /dev/null +++ b/commands/utils/quickPickWorkspaceFolder.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { UserCancelledError } from 'vscode-azureextensionui'; + +export async function quickPickWorkspaceFolder(noWorkspacesMessage: string): Promise { + if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length === 1) { + return vscode.workspace.workspaceFolders[0]; + } else if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 1) { + let selected = await vscode.window.showWorkspaceFolderPick(); + if (!selected) { + throw new UserCancelledError(); + } + return selected; + } else { + throw new Error(noWorkspacesMessage); + } +} diff --git a/configureWorkspace/config-utils.ts b/configureWorkspace/config-utils.ts index a4b907c3db..8252492cb8 100644 --- a/configureWorkspace/config-utils.ts +++ b/configureWorkspace/config-utils.ts @@ -3,30 +3,28 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { isNumber } from 'util'; import vscode = require('vscode'); import { IAzureQuickPickItem, IAzureUserInput } from 'vscode-azureextensionui'; import { ext } from "../extensionVariables"; - -export type OS = 'Windows' | 'Linux'; -export type Platform = - 'Go' | - 'Java' | - '.NET Core Console' | - 'ASP.NET Core' | - 'Node.js' | - 'Python' | - 'Ruby' | - 'Other'; +import { Platform, PlatformOS } from '../utils/platform'; /** * Prompts for a port number * @throws `UserCancelledError` if the user cancels. */ -export async function promptForPort(port: number): Promise { +export async function promptForPort(port: string): Promise { let opt: vscode.InputBoxOptions = { placeHolder: `${port}`, - prompt: 'What port does your app listen on?', - value: `${port}` + prompt: 'What port does your app listen on? ENTER for none.', + value: `${port}`, + validateInput: (value: string): string | undefined => { + if (value && (!Number.isInteger(Number(value)) || Number(value) <= 0)) { + return 'Port must be a positive integer or else empty for no exposed port'; + } + + return undefined; + } } return ext.ui.showInputBox(opt); @@ -63,15 +61,15 @@ export async function quickPickPlatform(): Promise { * Prompts for an OS * @throws `UserCancelledError` if the user cancels. */ -export async function quickPickOS(): Promise { +export async function quickPickOS(): Promise { let opt: vscode.QuickPickOptions = { matchOnDescription: true, matchOnDetail: true, placeHolder: 'Select Operating System' } - const OSes: OS[] = ['Windows', 'Linux']; - const items = OSes.map(p => >{ label: p, data: p }); + const OSes: PlatformOS[] = ['Windows', 'Linux']; + const items = OSes.map(p => >{ label: p, data: p }); let response = await ext.ui.showQuickPick(items, opt); return response.data; diff --git a/configureWorkspace/configure.ts b/configureWorkspace/configure.ts index 4958a94f21..4b1c6cf9a7 100644 --- a/configureWorkspace/configure.ts +++ b/configureWorkspace/configure.ts @@ -4,22 +4,23 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import * as fs from 'fs'; import * as fse from 'fs-extra'; import * as gradleParser from "gradle-to-js/lib/parser"; -import { EOL } from 'os'; import * as path from "path"; import * as pomParser from "pom-parser"; import * as vscode from "vscode"; import { IActionContext, TelemetryProperties } from 'vscode-azureextensionui'; +import { quickPickWorkspaceFolder } from '../commands/utils/quickPickWorkspaceFolder'; import { ext } from '../extensionVariables'; import { globAsync } from '../helpers/async'; import { extractRegExGroups } from '../helpers/extractRegExGroups'; -import { OS, Platform, promptForPort, quickPickOS, quickPickPlatform } from './config-utils'; +import { Platform, PlatformOS } from '../utils/platform'; +import { promptForPort, quickPickOS, quickPickPlatform } from './config-utils'; import { configureAspDotNetCore, configureDotNetCoreConsole } from './configure_dotnetcore'; import { configureGo } from './configure_go'; import { configureJava } from './configure_java'; import { configureNode } from './configure_node'; +import { configureOther } from './configure_other'; import { configurePython } from './configure_python'; import { configureRuby } from './configure_ruby'; @@ -48,16 +49,23 @@ interface PomXmlContents { export type ConfigureTelemetryProperties = { configurePlatform?: Platform; - configureOs?: OS; + configureOs?: PlatformOS; packageFileType?: string; // 'build.gradle', 'pom.xml', 'package.json', '.csproj' packageFileSubfolderDepth?: string; // 0 = project/etc file in root folder, 1 = in subfolder, 2 = in subfolder of subfolder, etc. }; -const generatorsByPlatform = new Map(); + genDockerComposeDebug: GeneratorFunction, + defaultPort: string | undefined // '' = defaults to empty but still asks user if they want a port, undefined = don't ask at all +} + +export function getExposeStatements(port: string): string { + return port ? `EXPOSE ${port}` : ''; +} + +const generatorsByPlatform = new Map(); generatorsByPlatform.set('ASP.NET Core', configureAspDotNetCore); generatorsByPlatform.set('Go', configureGo); generatorsByPlatform.set('Java', configureJava); @@ -65,16 +73,24 @@ generatorsByPlatform.set('.NET Core Console', configureDotNetCoreConsole); generatorsByPlatform.set('Node.js', configureNode); generatorsByPlatform.set('Python', configurePython); generatorsByPlatform.set('Ruby', configureRuby); +generatorsByPlatform.set('Other', configureOther); -function genDockerFile(serviceNameAndRelativePath: string, platform: Platform, os: OS | undefined, port: string | undefined, { cmd, author, version, artifactName }: Partial): string { +function genDockerFile(serviceNameAndRelativePath: string, platform: Platform, os: PlatformOS | undefined, port: string | undefined, { cmd, author, version, artifactName }: Partial): string { let generators = generatorsByPlatform.get(platform); assert(generators, `Could not find dockerfile generator functions for "${platform}"`); if (generators.genDockerFile) { - return generators.genDockerFile(serviceNameAndRelativePath, platform, os, port, { cmd, author, version, artifactName }); + let contents = generators.genDockerFile(serviceNameAndRelativePath, platform, os, port, { cmd, author, version, artifactName }); + + // Remove multiple empty lines with single empty lines, as might be produced + // if $expose_statements$ or another template variable is an empty string + contents = contents.replace(/(\r\n){3}/g, "\r\n\r\n") + .replace(/(\n){3}/g, "\n\n"); + + return contents; } } -function genDockerCompose(serviceNameAndRelativePath: string, platform: Platform, os: OS | undefined, port: string): string { +function genDockerCompose(serviceNameAndRelativePath: string, platform: Platform, os: PlatformOS | undefined, port: string): string { let generators = generatorsByPlatform.get(platform); assert(generators, `Could not find docker compose file generator function for "${platform}"`); if (generators.genDockerCompose) { @@ -82,7 +98,7 @@ function genDockerCompose(serviceNameAndRelativePath: string, platform: Platform } } -function genDockerComposeDebug(serviceNameAndRelativePath: string, platform: Platform, os: OS | undefined, port: string, packageInfo: Partial): string { +function genDockerComposeDebug(serviceNameAndRelativePath: string, platform: Platform, os: PlatformOS | undefined, port: string, packageInfo: Partial): string { let generators = generatorsByPlatform.get(platform); assert(generators, `Could not find docker debug compose file generator function for "${platform}"`); if (generators.genDockerComposeDebug) { @@ -129,7 +145,7 @@ async function readPackageJson(folderPath: string): Promise<{ packagePath?: stri if (uris && uris.length > 0) { packagePath = uris[0].fsPath; - const json = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + const json = JSON.parse(fse.readFileSync(packagePath, 'utf8')); if (json.scripts && typeof json.scripts.start === "string") { packageInfo.npmStart = true; @@ -225,7 +241,7 @@ async function findCSProjFile(folderPath: string): Promise { const projectFiles: string[] = await globAsync('**/*.csproj', { cwd: folderPath }); if (!projectFiles || !projectFiles.length) { - throw new Error("No .csproj file could be found."); + throw new Error("No .csproj file could be found. You need a C# project file in the workspace to generate Docker files for the selected platform."); } if (projectFiles.length > 1) { @@ -237,7 +253,7 @@ async function findCSProjFile(folderPath: string): Promise { } } -type GeneratorFunction = (serviceName: string, platform: Platform, os: OS | undefined, port: string, packageJson?: Partial) => string; +type GeneratorFunction = (serviceName: string, platform: Platform, os: PlatformOS | undefined, port: string, packageJson?: Partial) => string; const DOCKER_FILE_TYPES: { [key: string]: GeneratorFunction } = { 'docker-compose.yml': genDockerCompose, @@ -282,63 +298,62 @@ export interface ConfigureApiOptions { /** * The OS for the images. Currently only needed for .NET platforms. */ - os?: OS; + os?: PlatformOS; + + /** + * Open the Dockerfile that was generated + */ + openDockerFile?: boolean; } export async function configure(actionContext: IActionContext, rootFolderPath: string | undefined): Promise { if (!rootFolderPath) { - let folder: vscode.WorkspaceFolder | undefined; - if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length === 1) { - folder = vscode.workspace.workspaceFolders[0]; - } else { - folder = await vscode.window.showWorkspaceFolderPick(); - } - - if (!folder) { - if (!vscode.workspace.workspaceFolders) { - throw new Error('Docker files can only be generated if VS Code is opened on a folder.'); - } else { - throw new Error('Docker files can only be generated if a workspace folder is picked in VS Code.'); - } - } - + let folder: vscode.WorkspaceFolder = await quickPickWorkspaceFolder('To generate Docker files you must first open a folder or workspace in VS Code.'); rootFolderPath = folder.uri.fsPath; } - return configureCore( + let filesWritten = await configureCore( actionContext, { rootPath: rootFolderPath, - outputFolder: rootFolderPath + outputFolder: rootFolderPath, + openDockerFile: true }); + + // Open the dockerfile (if written) + try { + let dockerfile = filesWritten.find(fp => path.basename(fp).toLowerCase() === 'dockerfile'); + if (dockerfile) { + await vscode.window.showTextDocument(vscode.Uri.file(dockerfile)); + } + } catch (err) { + // Ignore + } } export async function configureApi(actionContext: IActionContext, options: ConfigureApiOptions): Promise { - return configureCore(actionContext, options); + await configureCore(actionContext, options); } // tslint:disable-next-line:max-func-body-length // Because of nested functions -async function configureCore(actionContext: IActionContext, options: ConfigureApiOptions): Promise { +async function configureCore(actionContext: IActionContext, options: ConfigureApiOptions): Promise { let properties: TelemetryProperties & ConfigureTelemetryProperties = actionContext.properties; let rootFolderPath: string = options.rootPath; let outputFolder = options.outputFolder; const platformType: Platform = options.platform || await quickPickPlatform(); properties.configurePlatform = platformType; + let generatorInfo = generatorsByPlatform.get(platformType); - let os: OS | undefined = options.os; + let os: PlatformOS | undefined = options.os; if (!os && platformType.toLowerCase().includes('.net')) { os = await quickPickOS(); } properties.configureOs = os; let port: string | undefined = options.port; - if (!port) { - if (platformType.toLowerCase().includes('.net')) { - port = await promptForPort(80); - } else { - port = await promptForPort(3000); - } + if (!port && generatorInfo.defaultPort !== undefined) { + port = await promptForPort(generatorInfo.defaultPort); } let targetFramework: string; @@ -391,18 +406,13 @@ async function configureCore(actionContext: IActionContext, options: ConfigureAp return createWorkspaceFileIfNotExists(fileName, DOCKER_FILE_TYPES[fileName]); })); - // Don't wait - vscode.window.showInformationMessage( - filesWritten.length ? - `The following files were written into the workspace:${EOL}${EOL}${filesWritten.join(', ')}` : - "No files were written" - ); + return filesWritten; async function createWorkspaceFileIfNotExists(fileName: string, generatorFunction: GeneratorFunction): Promise { const filePath = path.join(outputFolder, fileName); let writeFile = false; if (await fse.pathExists(filePath)) { - const response: vscode.MessageItem | undefined = await vscode.window.showErrorMessage(`"${fileName}" already exists.Would you like to overwrite it?`, ...YES_OR_NO_PROMPTS); + const response: vscode.MessageItem | undefined = await vscode.window.showErrorMessage(`"${fileName}" already exists. Would you like to overwrite it?`, ...YES_OR_NO_PROMPTS); if (response === YES_PROMPT) { writeFile = true; } @@ -414,8 +424,8 @@ async function configureCore(actionContext: IActionContext, options: ConfigureAp // Paths in the docker files should be relative to the Dockerfile (which is in the output folder) let fileContents = generatorFunction(serviceNameAndPathRelativeToOutput, platformType, os, port, packageInfo); if (fileContents) { - fs.writeFileSync(filePath, fileContents, { encoding: 'utf8' }); - filesWritten.push(fileName); + fse.writeFileSync(filePath, fileContents, { encoding: 'utf8' }); + filesWritten.push(filePath); } } } diff --git a/configureWorkspace/configure_dotnetcore.ts b/configureWorkspace/configure_dotnetcore.ts index 9f0fa557eb..5f05bdfe9a 100644 --- a/configureWorkspace/configure_dotnetcore.ts +++ b/configureWorkspace/configure_dotnetcore.ts @@ -9,19 +9,24 @@ import * as path from 'path'; import * as semver from 'semver'; import { extractRegExGroups } from '../helpers/extractRegExGroups'; import { isWindows, isWindows10RS3OrNewer, isWindows10RS4OrNewer } from '../helpers/windowsVersion'; -import { OS, Platform } from './config-utils'; -import { PackageInfo } from './configure'; +import { Platform, PlatformOS } from '../utils/platform'; +import { getExposeStatements, IPlatformGeneratorInfo, PackageInfo } from './configure'; // This file handles both ASP.NET core and .NET Core Console -let configureDotNetCore = { +export const configureAspDotNetCore: IPlatformGeneratorInfo = { genDockerFile, genDockerCompose: undefined, // We don't generate compose files for .net core - genDockerComposeDebug: undefined // We don't generate compose files for .net core + genDockerComposeDebug: undefined, // We don't generate compose files for .net core + defaultPort: '80' }; -export let configureAspDotNetCore = configureDotNetCore; -export let configureDotNetCoreConsole = configureDotNetCore; +export const configureDotNetCoreConsole: IPlatformGeneratorInfo = { + genDockerFile, + genDockerCompose: undefined, // We don't generate compose files for .net core + genDockerComposeDebug: undefined, // We don't generate compose files for .net core + defaultPort: undefined +}; const AspNetCoreRuntimeImageFormat = "microsoft/aspnetcore:{0}.{1}{2}"; const AspNetCoreSdkImageFormat = "microsoft/aspnetcore-build:{0}.{1}{2}"; @@ -150,7 +155,7 @@ ENTRYPOINT ["dotnet", "$assembly_name$.dll"] //#endregion -function genDockerFile(serviceNameAndRelativePath: string, platform: Platform, os: OS | undefined, port: string, { version }: Partial): string { +function genDockerFile(serviceNameAndRelativePath: string, platform: Platform, os: PlatformOS | undefined, port: string, { version }: Partial): string { // VS version of this function is in ResolveImageNames (src/Docker/Microsoft.VisualStudio.Docker.DotNetCore/DockerDotNetCoreScaffoldingProvider.cs) if (os !== 'Windows' && os !== 'Linux') { @@ -164,7 +169,7 @@ function genDockerFile(serviceNameAndRelativePath: string, platform: Platform, o let assemblyNameNoExtension = serviceName; // example: COPY Core2.0ConsoleAppWindows/Core2.0ConsoleAppWindows.csproj Core2.0ConsoleAppWindows/ let copyProjectCommands = `COPY ["${serviceNameAndRelativePath}.csproj", "${projectDirectory}/"]` - let exposeStatements = port ? `EXPOSE ${port}` : ''; + let exposeStatements = getExposeStatements(port); // Parse version from TargetFramework // Example: netcoreapp1.0 @@ -226,9 +231,6 @@ function genDockerFile(serviceNameAndRelativePath: string, platform: Platform, o .replace(/\$assembly_name\$/g, assemblyNameNoExtension) .replace(/\$copy_project_commands\$/g, copyProjectCommands); - // Remove multiple empty lines, as might be produced if there's no EXPOSE statement - contents = contents.replace(new RegExp(`${nodeOs.EOL}\{3\}`, 'g'), `${nodeOs.EOL}${nodeOs.EOL}`); - let unreplacedToken = extractRegExGroups(contents, /(\$[a-z_]+\$)/, ['']); if (unreplacedToken[0]) { assert.fail(`Unreplaced template token "${unreplacedToken}"`); diff --git a/configureWorkspace/configure_go.ts b/configureWorkspace/configure_go.ts index 380ff20c52..9a890efd4e 100644 --- a/configureWorkspace/configure_go.ts +++ b/configureWorkspace/configure_go.ts @@ -3,15 +3,18 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { PackageInfo } from './configure'; +import { getExposeStatements, IPlatformGeneratorInfo, PackageInfo } from './configure'; -export let configureGo = { +export let configureGo: IPlatformGeneratorInfo = { genDockerFile, genDockerCompose, - genDockerComposeDebug + genDockerComposeDebug, + defaultPort: '3000' }; function genDockerFile(serviceNameAndRelativePath: string, platform: string, os: string | undefined, port: string, { cmd, author, version, artifactName }: Partial): string { + let exposeStatements = getExposeStatements(port); + return ` #build stage FROM golang:alpine AS builder @@ -27,7 +30,7 @@ RUN apk --no-cache add ca-certificates COPY --from=builder /go/bin/app /app ENTRYPOINT ./app LABEL Name=${serviceNameAndRelativePath} Version=${version} -EXPOSE ${port} +${exposeStatements} `; } diff --git a/configureWorkspace/configure_java.ts b/configureWorkspace/configure_java.ts index d7e3364816..6c1ba6615c 100644 --- a/configureWorkspace/configure_java.ts +++ b/configureWorkspace/configure_java.ts @@ -3,24 +3,26 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { PackageInfo } from './configure'; +import { getExposeStatements, IPlatformGeneratorInfo, PackageInfo } from './configure'; -export let configureJava = { +export let configureJava: IPlatformGeneratorInfo = { genDockerFile, genDockerCompose, - genDockerComposeDebug + genDockerComposeDebug, + defaultPort: '3000' }; function genDockerFile(serviceNameAndRelativePath: string, platform: string, os: string | undefined, port: string, { cmd, author, version, artifactName }: Partial): string { - + let exposeStatements = getExposeStatements(port); const artifact = artifactName ? artifactName : `${serviceNameAndRelativePath}.jar`; + return ` FROM openjdk:8-jdk-alpine VOLUME /tmp ARG JAVA_OPTS ENV JAVA_OPTS=$JAVA_OPTS ADD ${artifact} ${serviceNameAndRelativePath}.jar -EXPOSE ${port} +${exposeStatements} ENTRYPOINT exec java $JAVA_OPTS -jar ${serviceNameAndRelativePath}.jar # For Spring-Boot project, use the entrypoint below to reduce Tomcat startup time. #ENTRYPOINT exec java $JAVA_OPTS -Djava.security.egd=file:/dev/./urandom -jar ${serviceNameAndRelativePath}.jar diff --git a/configureWorkspace/configure_node.ts b/configureWorkspace/configure_node.ts index 43e328c22c..1584350947 100644 --- a/configureWorkspace/configure_node.ts +++ b/configureWorkspace/configure_node.ts @@ -3,22 +3,25 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { PackageInfo } from './configure'; +import { getExposeStatements, IPlatformGeneratorInfo, PackageInfo } from './configure'; -export let configureNode = { +export let configureNode: IPlatformGeneratorInfo = { genDockerFile, genDockerCompose, - genDockerComposeDebug + genDockerComposeDebug, + defaultPort: '3000' }; function genDockerFile(serviceNameAndRelativePath: string, platform: string, os: string | undefined, port: string, { cmd, author, version, artifactName }: Partial): string { + let exposeStatements = getExposeStatements(port); + return `FROM node:8.9-alpine ENV NODE_ENV production WORKDIR /usr/src/app COPY ["package.json", "package-lock.json*", "npm-shrinkwrap.json*", "./"] RUN npm install --production --silent && mv node_modules ../ COPY . . -EXPOSE ${port} +${exposeStatements} CMD ${cmd}`; } diff --git a/configureWorkspace/configure_other.ts b/configureWorkspace/configure_other.ts new file mode 100644 index 0000000000..460c9db7a2 --- /dev/null +++ b/configureWorkspace/configure_other.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { PackageInfo } from './configure'; + +export let configureOther = { + genDockerFile, + genDockerCompose, + genDockerComposeDebug, + defaultPort: '3000' +}; + +function genDockerFile(serviceNameAndRelativePath: string, platform: string, os: string | undefined, port: string, { cmd, author, version, artifactName }: Partial): string { + return `FROM docker/whalesay:latest +LABEL Name=${serviceNameAndRelativePath} Version=${version} +RUN apt-get -y update && apt-get install -y fortunes +CMD /usr/games/fortune -a | cowsay +`; +} + +function genDockerCompose(serviceNameAndRelativePath: string, platform: string, os: string | undefined, port: string): string { + return `version: '2.1' + +services: + ${serviceNameAndRelativePath}: + image: ${serviceNameAndRelativePath} + build: . + ports: + - ${port}:${port} +`; +} + +function genDockerComposeDebug(serviceNameAndRelativePath: string, platform: string, os: string | undefined, port: string, { fullCommand: cmd }: Partial): string { + return `version: '2.1' + +services: + ${serviceNameAndRelativePath}: + image: ${serviceNameAndRelativePath} + build: + context: . + dockerfile: Dockerfile + ports: + - ${port}:${port} +`; +} diff --git a/configureWorkspace/configure_python.ts b/configureWorkspace/configure_python.ts index 6e4df2d5ed..aa8d11b59e 100644 --- a/configureWorkspace/configure_python.ts +++ b/configureWorkspace/configure_python.ts @@ -3,15 +3,18 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { PackageInfo } from './configure'; +import { getExposeStatements, IPlatformGeneratorInfo, PackageInfo } from './configure'; -export let configurePython = { +export let configurePython: IPlatformGeneratorInfo = { genDockerFile, genDockerCompose, - genDockerComposeDebug + genDockerComposeDebug, + defaultPort: '3000' }; function genDockerFile(serviceNameAndRelativePath: string, platform: string, os: string | undefined, port: string, { cmd, author, version, artifactName }: Partial): string { + let exposeStatements = getExposeStatements(port); + return `# Python support can be specified down to the minor or micro version # (e.g. 3.6 or 3.6.3). # OS Support also exists for jessie & stretch (slim and full). @@ -23,7 +26,7 @@ FROM python:alpine #FROM continuumio/miniconda3 LABEL Name=${serviceNameAndRelativePath} Version=${version} -EXPOSE ${port} +${exposeStatements} WORKDIR /app ADD . /app @@ -51,7 +54,8 @@ services: image: ${serviceNameAndRelativePath} build: . ports: - - ${port}:${port}`; + - ${port}:${port} +`; } function genDockerComposeDebug(serviceNameAndRelativePath: string, platform: string, os: string | undefined, port: string, { fullCommand: cmd }: Partial): string { @@ -64,6 +68,6 @@ services: context: . dockerfile: Dockerfile ports: - - ${port}:${port} + - ${port}:${port} `; } diff --git a/configureWorkspace/configure_ruby.ts b/configureWorkspace/configure_ruby.ts index bd4f097282..f1d55d4a8e 100644 --- a/configureWorkspace/configure_ruby.ts +++ b/configureWorkspace/configure_ruby.ts @@ -3,19 +3,22 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { PackageInfo } from './configure'; +import { getExposeStatements, IPlatformGeneratorInfo, PackageInfo } from './configure'; -export let configureRuby = { +export let configureRuby: IPlatformGeneratorInfo = { genDockerFile, genDockerCompose, - genDockerComposeDebug + genDockerComposeDebug, + defaultPort: '3000' }; function genDockerFile(serviceNameAndRelativePath: string, platform: string, os: string | undefined, port: string, { cmd, author, version, artifactName }: Partial): string { + let exposeStatements = getExposeStatements(port); + return `FROM ruby:2.5-slim LABEL Name=${serviceNameAndRelativePath} Version=${version} -EXPOSE ${port} +${exposeStatements} # throw errors if Gemfile has been modified since Gemfile.lock RUN bundle config --global frozen 1 diff --git a/constants.ts b/constants.ts index d4c4114828..01bb59df85 100644 --- a/constants.ts +++ b/constants.ts @@ -23,7 +23,10 @@ export namespace configurationKeys { } //Credentials Constants -export const NULL_GUID = '00000000-0000-0000-0000-000000000000'; +export const NULL_GUID = '00000000-0000-0000-0000-000000000000'; //Empty GUID is a special username to indicate the login credential is based on JWT token. //Azure Container Registries export const skus = ["Standard", "Basic", "Premium"]; + +//Repository + Tag format +export const imageTagRegExp = new RegExp('^[a-zA-Z0-9.-_/]{1,256}:(?![.-])[a-zA-Z0-9.-_]{1,128}$'); diff --git a/debugging/coreclr/appStorage.ts b/debugging/coreclr/appStorage.ts new file mode 100644 index 0000000000..00297d3c1d --- /dev/null +++ b/debugging/coreclr/appStorage.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as path from 'path'; +import { FileSystemProvider } from "./fsProvider"; + +export interface MementoAsync { + get(name: string, defaultValue?: T): Promise; + update(name: string, item: T | undefined): Promise; +} + +export interface AppStorageProvider { + getStorage(appFolder: string): Promise; +} + +export class DefaultAppStorage implements MementoAsync { + constructor( + private readonly appFolder: string, + private readonly fileSystemProvider: FileSystemProvider) { + } + + public async get(name: string, defaultValue?: T): Promise { + const itemPath = this.createItemPath(name); + + if (await this.fileSystemProvider.fileExists(itemPath)) { + const itemData = await this.fileSystemProvider.readFile(itemPath); + + return JSON.parse(itemData); + } + + return defaultValue; + } + + public async update(name: string, item: T | undefined): Promise { + const itemPath = this.createItemPath(name); + + if (item) { + const itemDir = path.dirname(itemPath); + + if (!await this.fileSystemProvider.dirExists(itemDir)) { + await this.fileSystemProvider.makeDir(itemDir); + } + + await this.fileSystemProvider.writeFile(itemPath, JSON.stringify(item)); + } else { + await this.fileSystemProvider.unlinkFile(itemPath); + } + } + + private createItemPath(name: string): string { + return path.join(this.appFolder, 'obj', 'docker', `${name}.json`); + } +} + +export class DefaultAppStorageProvider implements AppStorageProvider { + constructor(private readonly fileSystemProvider: FileSystemProvider) { + } + + public async getStorage(appFolder: string): Promise { + return await Promise.resolve(new DefaultAppStorage(appFolder, this.fileSystemProvider)); + } +} diff --git a/debugging/coreclr/browserClient.ts b/debugging/coreclr/browserClient.ts new file mode 100644 index 0000000000..46a0333b13 --- /dev/null +++ b/debugging/coreclr/browserClient.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as opn from 'opn'; +import { Uri } from "vscode"; + +export interface BrowserClient { + openBrowser(url: string): void; +} + +export class OpnBrowserClient implements BrowserClient { + public openBrowser(url: string): void { + const uri = Uri.parse(url); + + if (uri.scheme === 'http' || uri.scheme === 'https') { + // tslint:disable-next-line:no-unsafe-any + opn(url); + } + } +} + +export default OpnBrowserClient; diff --git a/debugging/coreclr/commandLineBuilder.ts b/debugging/coreclr/commandLineBuilder.ts new file mode 100644 index 0000000000..7e49aac09a --- /dev/null +++ b/debugging/coreclr/commandLineBuilder.ts @@ -0,0 +1,75 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + + export type CommandLineArgFactory = () => (string | undefined); + +export class CommandLineBuilder { + private readonly args: CommandLineArgFactory[] = []; + + public static create(...args: (undefined | string | CommandLineArgFactory)[]): CommandLineBuilder { + let builder = new CommandLineBuilder(); + + for (let arg of args) { + if (arg) { + if (typeof arg === 'string') { + builder = builder.withArg(arg); + } else { + builder = builder.withArgFactory(arg); + } + } + } + + return builder; + } + + public build(): string { + return this.args.map(arg => arg()).filter(arg => arg !== undefined).join(' '); + } + + public withArg(arg: string | undefined): CommandLineBuilder { + return this.withArgFactory(() => arg); + } + + public withArrayArgs(name: string, values: T[] | undefined, formatter?: (value: T) => string): CommandLineBuilder { + formatter = formatter || ((value: T) => value.toString()); + + return this.withArgFactory(() => values ? values.map(value => `${name} "${formatter(value)}"`).join(' ') : undefined); + } + + public withArgFactory(factory: CommandLineArgFactory | undefined): CommandLineBuilder { + if (factory) { + this.args.push(factory); + } + + return this; + } + + public withFlagArg(name: string, value: boolean | undefined): CommandLineBuilder { + return this.withArgFactory(() => value ? name : undefined); + } + + public withKeyValueArgs(name: string, values: { [key: string]: string }): CommandLineBuilder { + return this.withArgFactory(() => { + if (values) { + const keys = Object.keys(values); + + if (keys.length > 0) { + return keys.map(key => `${name} "${key}=${values[key]}"`).join(' '); + } + } + + return undefined; + }); + } + + public withNamedArg(name: string, value: string | undefined): CommandLineBuilder { + return this.withArgFactory(() => value ? `${name} "${value}"` : undefined); + } + + public withQuotedArg(value: string | undefined): CommandLineBuilder { + return this.withArgFactory(() => value ? `"${value}"` : undefined); + } +} + +export default CommandLineBuilder; diff --git a/debugging/coreclr/debugSessionManager.ts b/debugging/coreclr/debugSessionManager.ts new file mode 100644 index 0000000000..1719512574 --- /dev/null +++ b/debugging/coreclr/debugSessionManager.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { DockerManager } from './dockerManager'; + +export interface DebugSessionManager { + startListening(): void; + stopListening(): void; +} + +export class DockerDebugSessionManager implements DebugSessionManager, vscode.Disposable { + private eventSubscription: vscode.Disposable | undefined; + + constructor( + private readonly debugSessionTerminated: vscode.Event, + private readonly dockerManager: DockerManager) { + } + + public dispose(): void { + this.stopListening(); + } + + public startListening(): void { + if (this.eventSubscription === undefined) { + this.eventSubscription = this.debugSessionTerminated( + () => { + this.dockerManager + .cleanupAfterLaunch() + .catch(reason => console.log(`Unable to clean up Docker images after launch: ${reason}`)); + + this.stopListening(); + }); + } + } + + public stopListening(): void { + if (this.eventSubscription) { + this.eventSubscription.dispose(); + this.eventSubscription = undefined; + } + } +} diff --git a/debugging/coreclr/debuggerClient.ts b/debugging/coreclr/debuggerClient.ts new file mode 100644 index 0000000000..9f5a2e0964 --- /dev/null +++ b/debugging/coreclr/debuggerClient.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { PlatformOS } from '../../utils/platform'; +import { VsDbgClient } from './vsdbgClient'; + +export interface DebuggerClient { + getDebugger(os: PlatformOS): Promise; +} + +export class DefaultDebuggerClient { + private static debuggerVersion: string = 'vs2017u5'; + private static debuggerLinuxRuntime: string = 'debian.8-x64'; + private static debuggerWindowsRuntime: string = 'win7-x64'; + + constructor(private readonly vsdbgClient: VsDbgClient) { + } + + public async getDebugger(os: PlatformOS): Promise { + return await this.vsdbgClient.getVsDbgVersion( + DefaultDebuggerClient.debuggerVersion, + os === 'Windows' ? DefaultDebuggerClient.debuggerWindowsRuntime : DefaultDebuggerClient.debuggerLinuxRuntime); + } +} diff --git a/debugging/coreclr/dockerClient.ts b/debugging/coreclr/dockerClient.ts new file mode 100644 index 0000000000..baa750a09b --- /dev/null +++ b/debugging/coreclr/dockerClient.ts @@ -0,0 +1,228 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import CommandLineBuilder from "./commandLineBuilder"; +import { LineSplitter } from "./lineSplitter"; +import { ProcessProvider } from "./processProvider"; + +export type DockerBuildImageOptions = { + args?: { [key: string]: string }; + context?: string; + dockerfile?: string; + labels?: { [key: string]: string }; + tag?: string; + target?: string; +}; + +export type DockerInspectObjectOptions = { + format?: string; +}; + +export type DockerContainersListOptions = { + format?: string; +}; + +export type DockerContainerRemoveOptions = { + force?: boolean; +}; + +export type DockerContainerVolume = { + localPath: string; + containerPath: string; + permissions?: 'ro' | 'rw'; +}; + +export type DockerRunContainerOptions = { + command?: string; + containerName?: string; + entrypoint?: string; + env?: { [key: string]: string }; + envFiles?: string[]; + labels?: { [key: string]: string }; + volumes?: DockerContainerVolume[]; +}; + +export type DockerVersionOptions = { + format?: string; +} + +export interface DockerClient { + buildImage(options: DockerBuildImageOptions, progress?: (content: string) => void): Promise; + getVersion(options?: DockerVersionOptions): Promise; + inspectObject(nameOrId: string, options?: DockerInspectObjectOptions): Promise; + listContainers(options?: DockerContainersListOptions): Promise; + matchId(id1: string, id2: string): boolean; + removeContainer(containerNameOrId: string, options?: DockerContainerRemoveOptions): Promise; + runContainer(imageTagOrId: string, options?: DockerRunContainerOptions): Promise; + trimId(id: string): string; +} + +export class CliDockerClient implements DockerClient { + constructor(private readonly processProvider: ProcessProvider) { + // CONSIDER: Use dockerode client as basis for debugging. + } + + public async buildImage(options?: DockerBuildImageOptions, progress?: (content: string) => void): Promise { + options = options || {}; + + let command = CommandLineBuilder + .create('docker', 'build', '--rm') + .withNamedArg('-f', options.dockerfile) + .withKeyValueArgs('--build-arg', options.args) + .withKeyValueArgs('--label', options.labels) + .withNamedArg('-t', options.tag) + .withNamedArg('--target', options.target) + .withQuotedArg(options.context) + .build(); + + let imageId: string | undefined; + + const lineSplitter = new LineSplitter(); + + lineSplitter.onLine( + line => { + // Expected output is: 'Successfully built 7cc5654ca3b6' + const buildSuccessPrefix = 'Successfully built '; + + if (line.startsWith(buildSuccessPrefix)) { + imageId = line.substr(buildSuccessPrefix.length, 12); + } + }); + + const buildProgress = + (content: string) => { + if (progress) { + progress(content); + } + + lineSplitter.write(content); + }; + + await this.processProvider.exec(command, { progress: buildProgress }); + + lineSplitter.close(); + + if (!imageId) { + throw new Error('The Docker image was built successfully but the image ID could not be retrieved.'); + } + + return imageId; + } + + public async getVersion(options?: DockerVersionOptions): Promise { + options = options || {}; + + const command = CommandLineBuilder + .create('docker', 'version') + .withNamedArg('--format', options.format) + .build(); + + const result = await this.processProvider.exec(command, {}); + + return result.stdout; + } + + public async inspectObject(nameOrId: string, options?: DockerInspectObjectOptions): Promise { + options = options || {}; + + const command = CommandLineBuilder + .create('docker', 'inspect') + .withNamedArg('--format', options.format) + .withQuotedArg(nameOrId) + .build(); + + try { + const output = await this.processProvider.exec(command, {}); + + return output.stdout; + } catch { + // Failure (typically) means the object wasn't found... + return undefined; + } + } + + public async listContainers(options?: DockerContainersListOptions): Promise { + options = options || {}; + + const command = CommandLineBuilder + .create('docker', 'ps', '-a') + .withNamedArg('--format', options.format) + .build(); + + const output = await this.processProvider.exec(command, {}); + + return output.stdout; + } + + public matchId(id1: string, id2: string): boolean { + const validateArgument = + id => { + if (id === undefined || id1.length < 12) { + throw new Error(`'${id}' must be defined and at least 12 characters.`) + } + }; + + validateArgument(id1); + validateArgument(id2); + + return id1.length < id2.length + ? id2.startsWith(id1) + : id1.startsWith(id2); + } + + public async removeContainer(containerNameOrId: string, options?: DockerContainerRemoveOptions): Promise { + options = options || {}; + + const command = CommandLineBuilder + .create('docker', 'rm') + .withFlagArg('--force', options.force) + .withQuotedArg(containerNameOrId) + .build(); + + await this.processProvider.exec(command, {}); + } + + public async runContainer(imageTagOrId: string, options?: DockerRunContainerOptions): Promise { + options = options || {}; + + const command = CommandLineBuilder + .create('docker', 'run', '-dt', '-P') + .withNamedArg('--name', options.containerName) + .withKeyValueArgs('-e', options.env) + .withArrayArgs('--env-file', options.envFiles) + .withKeyValueArgs('--label', options.labels) + .withArrayArgs('-v', options.volumes, volume => `${volume.localPath}:${volume.containerPath}${volume.permissions ? ':' + volume.permissions : ''}`) + .withNamedArg('--entrypoint', options.entrypoint) + .withQuotedArg(imageTagOrId) + .withArg(options.command) + .build(); + + const result = await this.processProvider.exec(command, {}); + + // The '-d' option returns the container ID (with whitespace) upon completion. + const containerId = result.stdout.trim(); + + if (!containerId) { + throw new Error('The Docker container was run successfully but the container ID could not be retrieved.') + } + + return containerId; + } + + public trimId(id: string): string { + if (!id) { + throw new Error('The ID to be trimmed must be non-empty.'); + } + + const trimmedId = id.trim(); + + if (trimmedId.length < 12) { + throw new Error('The ID to be trimmed must be at least 12 characters.'); + } + + return id.substring(0, 12); + } +} + +export default CliDockerClient; diff --git a/debugging/coreclr/dockerDebugConfigurationProvider.ts b/debugging/coreclr/dockerDebugConfigurationProvider.ts new file mode 100644 index 0000000000..a592df2930 --- /dev/null +++ b/debugging/coreclr/dockerDebugConfigurationProvider.ts @@ -0,0 +1,287 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as path from 'path'; +import { CancellationToken, DebugConfiguration, DebugConfigurationProvider, ProviderResult, WorkspaceFolder } from 'vscode'; +import { callWithTelemetryAndErrorHandling } from 'vscode-azureextensionui'; +import { PlatformOS } from '../../utils/platform'; +import { DebugSessionManager } from './debugSessionManager'; +import { DockerManager, LaunchBuildOptions, LaunchResult, LaunchRunOptions } from './dockerManager'; +import { FileSystemProvider } from './fsProvider'; +import { NetCoreProjectProvider } from './netCoreProjectProvider'; +import { OSProvider } from './osProvider'; +import { Prerequisite } from './prereqManager'; + +interface DockerDebugBuildOptions { + args?: { [key: string]: string }; + context?: string; + dockerfile?: string; + labels?: { [key: string]: string }; + tag?: string; + target?: string; +} + +interface DockerDebugRunOptions { + containerName?: string; + env?: { [key: string]: string }; + envFiles?: string[]; + labels?: { [key: string]: string }; + os?: PlatformOS; +} + +interface DebugConfigurationBrowserBaseOptions { + enabled?: boolean; + command?: string; + args?: string; +} + +interface DebugConfigurationBrowserOptions extends DebugConfigurationBrowserBaseOptions { + windows?: DebugConfigurationBrowserBaseOptions; + osx?: DebugConfigurationBrowserBaseOptions; + linux?: DebugConfigurationBrowserBaseOptions; +} + +interface DockerDebugConfiguration extends DebugConfiguration { + appFolder?: string; + appOutput?: string; + appProject?: string; + dockerBuild?: DockerDebugBuildOptions; + dockerRun?: DockerDebugRunOptions; +} + +export class DockerDebugConfigurationProvider implements DebugConfigurationProvider { + private static readonly defaultLabels: { [key: string]: string } = { 'com.microsoft.created-by': 'visual-studio-code' }; + + constructor( + private readonly debugSessionManager: DebugSessionManager, + private readonly dockerManager: DockerManager, + private readonly fsProvider: FileSystemProvider, + private readonly osProvider: OSProvider, + private readonly netCoreProjectProvider: NetCoreProjectProvider, + private readonly prerequisite: Prerequisite) { + } + + public provideDebugConfigurations(folder: WorkspaceFolder | undefined, token?: CancellationToken): ProviderResult { + return [ + { + name: 'Docker: Launch .NET Core (Preview)', + type: 'docker-coreclr', + request: 'launch', + preLaunchTask: 'build', + dockerBuild: { + }, + dockerRun: { + } + } + ]; + } + + public resolveDebugConfiguration(folder: WorkspaceFolder | undefined, debugConfiguration: DockerDebugConfiguration, token?: CancellationToken): ProviderResult { + return callWithTelemetryAndErrorHandling( + 'debugCoreClr', + async () => await this.resolveDockerDebugConfiguration(folder, debugConfiguration)); + } + + private static resolveFolderPath(folderPath: string, folder: WorkspaceFolder): string { + return folderPath.replace(/\$\{workspaceFolder\}/gi, folder.uri.fsPath); + } + + private async resolveDockerDebugConfiguration(folder: WorkspaceFolder | undefined, debugConfiguration: DockerDebugConfiguration): Promise { + if (!folder) { + throw new Error('No workspace folder is associated with debugging.'); + } + + const prerequisiteSatisfied = await this.prerequisite.checkPrerequisite(); + + if (!prerequisiteSatisfied) { + return undefined; + } + + const appFolder = this.inferAppFolder(folder, debugConfiguration); + + const resolvedAppFolder = DockerDebugConfigurationProvider.resolveFolderPath(appFolder, folder); + + const appProject = await this.inferAppProject(debugConfiguration, resolvedAppFolder); + + const resolvedAppProject = DockerDebugConfigurationProvider.resolveFolderPath(appProject, folder); + + const appName = path.basename(resolvedAppProject, '.csproj'); + + const os = debugConfiguration && debugConfiguration.dockerRun && debugConfiguration.dockerRun.os + ? debugConfiguration.dockerRun.os + : 'Linux'; + + const appOutput = await this.inferAppOutput(debugConfiguration, os, resolvedAppProject); + + const buildOptions = DockerDebugConfigurationProvider.inferBuildOptions(folder, debugConfiguration, appFolder, resolvedAppFolder, appName); + const runOptions = DockerDebugConfigurationProvider.inferRunOptions(folder, debugConfiguration, appName, os); + + const result = await this.dockerManager.prepareForLaunch({ + appFolder: resolvedAppFolder, + appOutput, + build: buildOptions, + run: runOptions + }); + + const configuration = this.createConfiguration(debugConfiguration, appFolder, result); + + this.debugSessionManager.startListening(); + + return configuration; + } + + private static inferBuildOptions(folder: WorkspaceFolder, debugConfiguration: DockerDebugConfiguration, appFolder: string, resolvedAppFolder: string, appName: string): LaunchBuildOptions { + const context = DockerDebugConfigurationProvider.inferContext(folder, resolvedAppFolder, debugConfiguration); + const resolvedContext = DockerDebugConfigurationProvider.resolveFolderPath(context, folder); + + let dockerfile = debugConfiguration && debugConfiguration.dockerBuild && debugConfiguration.dockerBuild.dockerfile + ? DockerDebugConfigurationProvider.resolveFolderPath(debugConfiguration.dockerBuild.dockerfile, folder) + : path.join(appFolder, 'Dockerfile'); // CONSIDER: Omit dockerfile argument if not specified or possibly infer from context. + + dockerfile = DockerDebugConfigurationProvider.resolveFolderPath(dockerfile, folder); + + const args = debugConfiguration && debugConfiguration.dockerBuild && debugConfiguration.dockerBuild.args; + + const labels = (debugConfiguration && debugConfiguration.dockerBuild && debugConfiguration.dockerBuild.labels) + || DockerDebugConfigurationProvider.defaultLabels; + + const tag = debugConfiguration && debugConfiguration.dockerBuild && debugConfiguration.dockerBuild.tag + ? debugConfiguration.dockerBuild.tag + : `${appName.toLowerCase()}:dev`; + + const target = debugConfiguration && debugConfiguration.dockerBuild && debugConfiguration.dockerBuild.target + ? debugConfiguration.dockerBuild.target + : 'base'; // CONSIDER: Omit target if not specified, or possibly infer from Dockerfile. + + return { + args, + context: resolvedContext, + dockerfile, + labels, + tag, + target + }; + } + + private static inferRunOptions(folder: WorkspaceFolder, debugConfiguration: DockerDebugConfiguration, appName: string, os: PlatformOS): LaunchRunOptions { + const containerName = debugConfiguration && debugConfiguration.dockerRun && debugConfiguration.dockerRun.containerName + ? debugConfiguration.dockerRun.containerName + : `${appName}-dev`; // CONSIDER: Use unique ID instead? + + const env = debugConfiguration && debugConfiguration.dockerRun && debugConfiguration.dockerRun.env; + const envFiles = debugConfiguration && debugConfiguration.dockerRun && debugConfiguration.dockerRun.envFiles + ? debugConfiguration.dockerRun.envFiles.map(file => DockerDebugConfigurationProvider.resolveFolderPath(file, folder)) + : undefined; + + const labels = (debugConfiguration && debugConfiguration.dockerRun && debugConfiguration.dockerRun.labels) + || DockerDebugConfigurationProvider.defaultLabels; + + return { + containerName, + env, + envFiles, + labels, + os, + }; + } + + private inferAppFolder(folder: WorkspaceFolder, configuration: DockerDebugConfiguration): string { + if (configuration) { + if (configuration.appFolder) { + return configuration.appFolder; + } + + if (configuration.appProject) { + return path.dirname(configuration.appProject); + } + } + + return folder.uri.fsPath; + } + + private async inferAppOutput(configuration: DockerDebugConfiguration, targetOS: PlatformOS, resolvedAppProject: string): Promise { + if (configuration && configuration.appOutput) { + return configuration.appOutput; + } + + const targetPath = await this.netCoreProjectProvider.getTargetPath(resolvedAppProject); + const relativeTargetPath = this.osProvider.pathNormalize(targetOS, path.relative(path.dirname(resolvedAppProject), targetPath)); + + return relativeTargetPath; + } + + private async inferAppProject(configuration: DockerDebugConfiguration, resolvedAppFolder: string): Promise { + if (configuration) { + if (configuration.appProject) { + return configuration.appProject; + } + } + + const files = await this.fsProvider.readDir(resolvedAppFolder); + + const projectFile = files.find(file => path.extname(file) === '.csproj'); + + if (projectFile) { + return path.join(resolvedAppFolder, projectFile); + } + + throw new Error('Unable to infer the application project file. Set either the `appFolder` or `appProject` property in the Docker debug configuration.'); + } + + private static inferContext(folder: WorkspaceFolder, resolvedAppFolder: string, configuration: DockerDebugConfiguration): string { + return configuration && configuration.dockerBuild && configuration.dockerBuild.context + ? configuration.dockerBuild.context + : path.normalize(resolvedAppFolder) === path.normalize(folder.uri.fsPath) + ? resolvedAppFolder // The context defaults to the application folder if it's the same as the workspace folder (i.e. there's no solution folder). + : path.dirname(resolvedAppFolder); // The context defaults to the application's parent (i.e. solution) folder. + } + + private createLaunchBrowserConfiguration(result: LaunchResult): DebugConfigurationBrowserOptions { + return result.browserUrl + ? { + enabled: true, + args: result.browserUrl, + windows: { + command: 'cmd.exe', + args: `/C start ${result.browserUrl}` + }, + osx: { + command: 'open' + }, + linux: { + command: 'xdg-open' + } + } + : { + enabled: false + }; + } + + private createConfiguration(debugConfiguration: DockerDebugConfiguration, appFolder: string, result: LaunchResult): DebugConfiguration { + const launchBrowser = this.createLaunchBrowserConfiguration(result); + + return { + name: debugConfiguration.name, + type: 'coreclr', + request: 'launch', + program: result.program, + args: result.programArgs.join(' '), + cwd: result.programCwd, + launchBrowser, + pipeTransport: { + pipeCwd: result.pipeCwd, + pipeProgram: result.pipeProgram, + pipeArgs: result.pipeArgs, + debuggerPath: result.debuggerPath, + quoteArgs: false + }, + preLaunchTask: debugConfiguration.preLaunchTask, + sourceFileMap: { + '/app/Views': path.join(appFolder, 'Views') + } + }; + } +} + +export default DockerDebugConfigurationProvider; diff --git a/debugging/coreclr/dockerManager.ts b/debugging/coreclr/dockerManager.ts new file mode 100644 index 0000000000..00d7f98904 --- /dev/null +++ b/debugging/coreclr/dockerManager.ts @@ -0,0 +1,364 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import deepEqual = require('deep-equal'); +import * as path from 'path'; +import { Memento } from 'vscode'; +import { PlatformOS } from '../../utils/platform'; +import { AppStorageProvider } from './appStorage'; +import { DebuggerClient } from './debuggerClient'; +import { DockerBuildImageOptions, DockerClient, DockerContainerVolume, DockerRunContainerOptions } from "./dockerClient"; +import { FileSystemProvider } from './fsProvider'; +import Lazy from './lazy'; +import { OSProvider } from './osProvider'; +import { OutputManager } from './outputManager'; +import { ProcessProvider } from './processProvider'; + +export type DockerManagerBuildImageOptions + = DockerBuildImageOptions + & { + appFolder: string; + context: string; + dockerfile: string; + }; + +export type DockerManagerRunContainerOptions + = DockerRunContainerOptions + & { + appFolder: string; + os: PlatformOS; + }; + +type Omit = Pick>; + +export type LaunchBuildOptions = Omit; +export type LaunchRunOptions = Omit; + +export type LaunchOptions = { + appFolder: string; + appOutput: string; + build: LaunchBuildOptions; + run: LaunchRunOptions; +}; + +export type LaunchResult = { + browserUrl: string | undefined; + debuggerPath: string; + pipeArgs: string[]; + pipeCwd: string; + pipeProgram: string; + program: string; + programArgs: string[]; + programCwd: string; +}; + +type LastImageBuildMetadata = { + dockerfileHash: string; + dockerIgnoreHash: string | undefined; + imageId: string; + options: DockerBuildImageOptions; +}; + +type LastContainerRunMetadata = { + containerId: string; + options: DockerRunContainerOptions; +} + +export interface DockerManager { + buildImage(options: DockerManagerBuildImageOptions): Promise; + runContainer(imageTagOrId: string, options: DockerManagerRunContainerOptions): Promise; + prepareForLaunch(options: LaunchOptions): Promise; + cleanupAfterLaunch(): Promise; +} + +export const MacNuGetPackageFallbackFolderPath = '/usr/local/share/dotnet/sdk/NuGetFallbackFolder'; + +function compareProperty(obj1: T | undefined, obj2: T | undefined, getter: (obj: T) => (U | undefined)): boolean { + const prop1 = obj1 ? getter(obj1) : undefined; + const prop2 = obj2 ? getter(obj2) : undefined; + + return prop1 === prop2; +} + +function compareDictionary(obj1: T | undefined, obj2: T | undefined, getter: (obj: T) => ({ [key: string]: string } | undefined)): boolean { + const dict1 = (obj1 ? getter(obj1) : {}) || {}; + const dict2 = (obj2 ? getter(obj2) : {}) || {}; + + return deepEqual(dict1, dict2); +} + +export function compareBuildImageOptions(options1: DockerBuildImageOptions | undefined, options2: DockerBuildImageOptions | undefined): boolean { + // NOTE: We do not compare options.dockerfile as it (i.e. the name itself) has no impact on the built image. + + if (!compareProperty(options1, options2, options => options.context)) { + return false; + } + + if (!compareDictionary(options1, options2, options => options.args)) { + return false; + } + + if (!compareProperty(options1, options2, options => options.tag)) { + return false; + } + + if (!compareProperty(options1, options2, options => options.target)) { + return false; + } + + if (!compareDictionary(options1, options2, options => options.labels)) { + return false; + } + + return true; +} + +export class DefaultDockerManager implements DockerManager { + private static readonly DebugContainersKey: string = 'DefaultDockerManager.debugContainers'; + + constructor( + private readonly appCacheFactory: AppStorageProvider, + private readonly debuggerClient: DebuggerClient, + private readonly dockerClient: DockerClient, + private readonly dockerOutputManager: OutputManager, + private readonly fileSystemProvider: FileSystemProvider, + private readonly osProvider: OSProvider, + private readonly processProvider: ProcessProvider, + private readonly workspaceState: Memento) { + } + + public async buildImage(options: DockerManagerBuildImageOptions): Promise { + const cache = await this.appCacheFactory.getStorage(options.appFolder); + const buildMetadata = await cache.get('build'); + const dockerIgnorePath = path.join(options.context, '.dockerignore'); + + const dockerfileHasher = new Lazy(async () => await this.fileSystemProvider.hashFile(options.dockerfile)); + const dockerIgnoreHasher = new Lazy( + async () => { + if (await this.fileSystemProvider.fileExists(dockerIgnorePath)) { + return await this.fileSystemProvider.hashFile(dockerIgnorePath); + } else { + return undefined; + } + }); + + if (buildMetadata && buildMetadata.imageId) { + const imageObject = await this.dockerClient.inspectObject(buildMetadata.imageId); + + if (imageObject && compareBuildImageOptions(buildMetadata.options, options)) { + const currentDockerfileHash = await dockerfileHasher.value; + const currentDockerIgnoreHash = await dockerIgnoreHasher.value; + + if (buildMetadata.dockerfileHash === currentDockerfileHash + && buildMetadata.dockerIgnoreHash === currentDockerIgnoreHash) { + + // The image is up to date, no build is necessary... + return buildMetadata.imageId; + } + } + } + + const imageId = await this.dockerOutputManager.performOperation( + 'Building Docker image...', + async outputManager => await this.dockerClient.buildImage(options, content => outputManager.append(content)), + id => `Docker image ${this.dockerClient.trimId(id)} built.`, + err => `Failed to build Docker image: ${err}`); + + const dockerfileHash = await dockerfileHasher.value; + const dockerIgnoreHash = await dockerIgnoreHasher.value; + + await cache.update( + 'build', + { + dockerfileHash, + dockerIgnoreHash, + imageId, + options + }); + + return imageId; + } + + public async runContainer(imageTagOrId: string, options: DockerManagerRunContainerOptions): Promise { + if (options.containerName === undefined) { + throw new Error('No container name was provided.'); + } + + const containerName = options.containerName; + + const debuggerFolder = await this.debuggerClient.getDebugger(options.os); + + const command = options.os === 'Windows' + ? '-t localhost' + : '-f /dev/null'; + + const entrypoint = options.os === 'Windows' + ? 'ping' + : 'tail'; + + const volumes = this.getVolumes(debuggerFolder, options); + + const containerId = await this.dockerOutputManager.performOperation( + 'Starting container...', + async () => { + const containers = (await this.dockerClient.listContainers({ format: '{{.Names}}' })).split('\n'); + + if (containers.find(container => container === containerName)) { + await this.dockerClient.removeContainer(containerName, { force: true }); + } + + return await this.dockerClient.runContainer( + imageTagOrId, + { + command, + containerName: options.containerName, + entrypoint, + env: options.env, + envFiles: options.envFiles, + labels: options.labels, + volumes + }); + }, + id => `Container ${this.dockerClient.trimId(id)} started.`, + err => `Unable to start container: ${err}`); + + return containerId; + } + + public async prepareForLaunch(options: LaunchOptions): Promise { + const imageId = await this.buildImage({ appFolder: options.appFolder, ...options.build }); + + const containerId = await this.runContainer(imageId, { appFolder: options.appFolder, ...options.run }); + + await this.addToDebugContainers(containerId); + + const browserUrl = await this.getContainerWebEndpoint(containerId); + + const additionalProbingPaths = options.run.os === 'Windows' + ? [ + 'C:\\.nuget\\packages', + 'C:\\.nuget\\fallbackpackages' + ] + : [ + '/root/.nuget/packages', + '/root/.nuget/fallbackpackages' + ]; + const additionalProbingPathsArgs = additionalProbingPaths.map(probingPath => `--additionalProbingPath ${probingPath}`).join(' '); + + const containerAppOutput = options.run.os === 'Windows' + ? this.osProvider.pathJoin(options.run.os, 'C:\\app', options.appOutput) + : this.osProvider.pathJoin(options.run.os, '/app', options.appOutput); + + return { + browserUrl, + debuggerPath: options.run.os === 'Windows' ? 'C:\\remote_debugger\\vsdbg' : '/remote_debugger/vsdbg', + // tslint:disable-next-line:no-invalid-template-strings + pipeArgs: ['exec', '-i', containerId, '${debuggerCommand}'], + // tslint:disable-next-line:no-invalid-template-strings + pipeCwd: '${workspaceFolder}', + pipeProgram: 'docker', + program: 'dotnet', + programArgs: [additionalProbingPathsArgs, containerAppOutput], + programCwd: options.run.os === 'Windows' ? 'C:\\app' : '/app' + }; + } + + public async cleanupAfterLaunch(): Promise { + const debugContainers = this.workspaceState.get(DefaultDockerManager.DebugContainersKey, []); + + const runningContainers = (await this.dockerClient.listContainers({ format: '{{.ID}}' })).split('\n'); + + let remainingContainers; + + if (runningContainers && runningContainers.length >= 0) { + const removeContainerTasks = + debugContainers + .filter(containerId => runningContainers.find(runningContainerId => this.dockerClient.matchId(containerId, runningContainerId))) + .map( + async containerId => { + try { + await this.dockerClient.removeContainer(containerId, { force: true }); + + return undefined; + } catch { + return containerId; + } + }); + + remainingContainers = (await Promise.all(removeContainerTasks)).filter(containerId => containerId !== undefined); + } else { + remainingContainers = []; + } + + await this.workspaceState.update(DefaultDockerManager.DebugContainersKey, remainingContainers); + } + + private async addToDebugContainers(containerId: string): Promise { + const runningContainers = this.workspaceState.get(DefaultDockerManager.DebugContainersKey, []); + + runningContainers.push(containerId); + + await this.workspaceState.update(DefaultDockerManager.DebugContainersKey, runningContainers); + } + + private async getContainerWebEndpoint(containerNameOrId: string): Promise { + const webPorts = await this.dockerClient.inspectObject(containerNameOrId, { format: '{{(index (index .NetworkSettings.Ports \\\"80/tcp\\\") 0).HostPort}}' }); + + if (webPorts) { + const webPort = webPorts.split('\n')[0]; + + // tslint:disable-next-line:no-http-string + return `http://localhost:${webPort}`; + } + + return undefined; + } + + private static readonly ProgramFilesEnvironmentVariable: string = 'ProgramFiles'; + + private getVolumes(debuggerFolder: string, options: DockerManagerRunContainerOptions): DockerContainerVolume[] { + const appVolume: DockerContainerVolume = { + localPath: options.appFolder, + containerPath: options.os === 'Windows' ? 'C:\\app' : '/app', + permissions: 'rw' + }; + + const debuggerVolume: DockerContainerVolume = { + localPath: debuggerFolder, + containerPath: options.os === 'Windows' ? 'C:\\remote_debugger' : '/remote_debugger', + permissions: 'ro' + }; + + const nugetVolume: DockerContainerVolume = { + localPath: path.join(this.osProvider.homedir, '.nuget', 'packages'), + containerPath: options.os === 'Windows' ? 'C:\\.nuget\\packages' : '/root/.nuget/packages', + permissions: 'ro' + }; + + let programFilesEnvironmentVariable: string | undefined; + + if (this.osProvider.os === 'Windows') { + programFilesEnvironmentVariable = this.processProvider.env[DefaultDockerManager.ProgramFilesEnvironmentVariable]; + + if (programFilesEnvironmentVariable === undefined) { + throw new Error(`The environment variable '${DefaultDockerManager.ProgramFilesEnvironmentVariable}' is not defined. This variable is used to locate the NuGet fallback folder.`); + } + } + + const nugetFallbackVolume: DockerContainerVolume = { + localPath: this.osProvider.os === 'Windows' ? path.join(programFilesEnvironmentVariable, 'dotnet', 'sdk', 'NuGetFallbackFolder') : MacNuGetPackageFallbackFolderPath, + containerPath: options.os === 'Windows' ? 'C:\\.nuget\\fallbackpackages' : '/root/.nuget/fallbackpackages', + permissions: 'ro' + }; + + const volumes: DockerContainerVolume[] = [ + appVolume, + debuggerVolume, + nugetVolume, + nugetFallbackVolume + ]; + + return volumes; + } +} diff --git a/debugging/coreclr/dotNetClient.ts b/debugging/coreclr/dotNetClient.ts new file mode 100644 index 0000000000..913bc54a77 --- /dev/null +++ b/debugging/coreclr/dotNetClient.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { ProcessProvider } from "./processProvider"; + +export type MSBuildExecOptions = { + target?: string; + properties?: { [key: string]: string }; +}; + +export interface DotNetClient { + execTarget(projectFile: string, options?: MSBuildExecOptions): Promise; + getVersion(): Promise; +} + +export class CommandLineDotNetClient implements DotNetClient { + constructor(private readonly processProvider: ProcessProvider) { + } + + public async execTarget(projectFile: string, options?: MSBuildExecOptions): Promise { + let command = `dotnet msbuild "${projectFile}"`; + + if (options) { + if (options.target) { + command += ` "/t:${options.target}"`; + } + + if (options.properties) { + const properties = options.properties; + + command += Object.keys(properties).map(key => ` "/p:${key}=${properties[key]}"`).join(''); + } + } + + await this.processProvider.exec(command, {}); + } + + public async getVersion(): Promise { + try { + + const command = `dotnet --version`; + + const result = await this.processProvider.exec(command, {}); + + return result.stdout.trim(); + } catch { + return undefined; + } + } +} + +export default CommandLineDotNetClient; diff --git a/debugging/coreclr/fsProvider.ts b/debugging/coreclr/fsProvider.ts new file mode 100644 index 0000000000..9d3c2162af --- /dev/null +++ b/debugging/coreclr/fsProvider.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as crypto from 'crypto'; +import * as fse from 'fs-extra'; + +export interface FileSystemProvider { + dirExists(path: string): Promise; + fileExists(path: string): Promise; + hashFile(path: string): Promise; + makeDir(path: string): Promise; + readDir(path: string): Promise; + readFile(filename: string, encoding?: string): Promise; + unlinkFile(filename: string): Promise; + // tslint:disable-next-line:no-any + writeFile(filename: string, data: any): Promise; +} + +export class LocalFileSystemProvider implements FileSystemProvider { + public async dirExists(path: string): Promise { + try { + const stats = await fse.stat(path); + + return stats.isDirectory(); + } catch (err) { + // tslint:disable-next-line:no-unsafe-any + if (err.code === "ENOENT") { + return false; + } + + throw err; + } + } + + public async fileExists(path: string): Promise { + try { + const stats = await fse.stat(path); + + return stats.isFile(); + } catch (err) { + // tslint:disable-next-line:no-unsafe-any + if (err.code === "ENOENT") { + return false; + } + + throw err; + } + } + + public async hashFile(path: string): Promise { + const hash = crypto.createHash('sha256'); + + const contents = await this.readFile(path); + + hash.update(contents); + + return hash.digest('hex'); + } + + public async makeDir(path: string): Promise { + return await fse.mkdir(path); + } + + public async readDir(path: string): Promise { + return await fse.readdir(path); + } + + public async readFile(filename: string, encoding?: string): Promise { + // NOTE: If encoding is specified, output is a string; if omitted, output is a Buffer. + return (await (encoding ? fse.readFile(filename, encoding) : fse.readFile(filename))).toString(); + } + + public async unlinkFile(filename: string): Promise { + return await fse.unlink(filename); + } + + // tslint:disable-next-line:no-any + public async writeFile(filename: string, data: any): Promise { + return await fse.writeFile(filename, data); + } +} diff --git a/debugging/coreclr/lazy.ts b/debugging/coreclr/lazy.ts new file mode 100644 index 0000000000..a04a779277 --- /dev/null +++ b/debugging/coreclr/lazy.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +export class Lazy { + private _isValueCreated: boolean = false; + private _value: T | undefined; + + constructor(private readonly valueFactory: () => T) { + } + + get isValueCreated(): boolean { + return this._isValueCreated; + } + + get value(): T { + if (!this._isValueCreated) { + this._value = this.valueFactory(); + this._isValueCreated = true; + } + + return this._value; + } +} + +export default Lazy; diff --git a/debugging/coreclr/lineSplitter.ts b/debugging/coreclr/lineSplitter.ts new file mode 100644 index 0000000000..eb5f268f30 --- /dev/null +++ b/debugging/coreclr/lineSplitter.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +export class LineSplitter implements vscode.Disposable { + private readonly emitter: vscode.EventEmitter = new vscode.EventEmitter(); + private buffer: string | undefined; + + public get onLine(): vscode.Event { + return this.emitter.event; + } + + public close(): void { + if (this.buffer !== undefined) { + this.emitter.fire(this.buffer); + this.buffer = undefined; + } + } + + public dispose(): void { + this.close(); + } + + public write(data: string): void { + if (data === undefined) { + return; + } + + this.buffer = this.buffer !== undefined ? this.buffer + data : data; + + let index = 0; + let lineStart = 0; + + while (index < this.buffer.length) { + if (this.buffer[index] === '\n') { + const line = index === 0 || this.buffer[index - 1] !== '\r' + ? this.buffer.substring(lineStart, index) + : this.buffer.substring(lineStart, index - 1); + + this.emitter.fire(line); + + lineStart = index + 1; + } + + index++; + } + + this.buffer = lineStart < index ? this.buffer.substring(lineStart) : undefined; + } + + public static splitLines(data: string): string[] { + const splitter = new LineSplitter(); + + const lines: string[] = []; + + splitter.onLine(line => lines.push(line)); + splitter.write(data); + splitter.close(); + + return lines; + } +} + +export default LineSplitter; diff --git a/debugging/coreclr/netCoreProjectProvider.ts b/debugging/coreclr/netCoreProjectProvider.ts new file mode 100644 index 0000000000..54d0547ae2 --- /dev/null +++ b/debugging/coreclr/netCoreProjectProvider.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as path from 'path'; +import { DotNetClient } from "./dotNetClient"; +import { FileSystemProvider } from "./fsProvider"; +import { TempFileProvider } from './tempFileProvider'; + +const getTargetPathProjectFileContent = +` + + + + + + +`; + +export interface NetCoreProjectProvider { + getTargetPath(projectFile: string): Promise; +} + +export class MsBuildNetCoreProjectProvider implements NetCoreProjectProvider { + constructor( + private readonly fsProvider: FileSystemProvider, + private readonly msBuildClient: DotNetClient, + private readonly tempFileProvider: TempFileProvider) { + } + + public async getTargetPath(projectFile: string): Promise { + const getTargetPathProjectFile = this.tempFileProvider.getTempFilename(); + const targetOutputFilename = this.tempFileProvider.getTempFilename(); + await this.fsProvider.writeFile(getTargetPathProjectFile, getTargetPathProjectFileContent); + try { + await this.msBuildClient.execTarget( + getTargetPathProjectFile, + { + target: 'GetTargetPath', + properties: { + 'ProjectFilename': projectFile, + 'TargetOutputFilename': targetOutputFilename + } + }); + + const targetOutputContent = await this.fsProvider.readFile(targetOutputFilename); + + return targetOutputContent.split(/\r?\n/)[0]; + } + finally { + await this.fsProvider.unlinkFile(getTargetPathProjectFile); + await this.fsProvider.unlinkFile(targetOutputFilename); + } + } +} diff --git a/debugging/coreclr/osProvider.ts b/debugging/coreclr/osProvider.ts new file mode 100644 index 0000000000..9b97f15539 --- /dev/null +++ b/debugging/coreclr/osProvider.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as os from 'os'; +import * as path from 'path'; +import { PlatformOS } from '../../utils/platform'; + +export interface OSProvider { + homedir: string; + isMac: boolean; + os: PlatformOS; + tmpdir: string; + pathJoin(os: PlatformOS, ...paths: string[]): string; + pathNormalize(os: PlatformOS, rawPath: string): string; +} + +export class LocalOSProvider implements OSProvider { + get homedir(): string { + return os.homedir(); + } + + get isMac(): boolean { + return os.platform() === 'darwin'; + } + + get os(): PlatformOS { + return os.platform() === 'win32' ? 'Windows' : 'Linux'; + } + + get tmpdir(): string { + return os.tmpdir(); + } + + public pathJoin(pathOS: PlatformOS, ...paths: string[]): string { + return pathOS === 'Windows' ? path.win32.join(...paths) : path.posix.join(...paths); + } + + public pathNormalize(pathOS: PlatformOS, rawPath: string): string { + return rawPath.replace( + pathOS === 'Windows' ? /\//g : /\\/g, + pathOS === 'Windows' ? '\\' : '/'); + } +} + +export default LocalOSProvider; diff --git a/debugging/coreclr/outputManager.ts b/debugging/coreclr/outputManager.ts new file mode 100644 index 0000000000..4ddcae7d89 --- /dev/null +++ b/debugging/coreclr/outputManager.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { LineSplitter } from './lineSplitter'; + +type outputCallback = (result: T) => string; + +export interface OutputManager { + append(content: string): void; + appendLine(content: string): void; + performOperation(startContent: string, operation: (outputManager: OutputManager) => Promise, endContent?: string | outputCallback, errorContent?: string | outputCallback): Promise; +} + +export class DefaultOutputManager implements OutputManager, vscode.Disposable { + private readonly lineSplitter: LineSplitter = new LineSplitter(); + private isShown: boolean = false; + + constructor(private readonly outputChannel: vscode.OutputChannel, private readonly level: number = 0) { + this.lineSplitter.onLine(line => this.outputChannel.appendLine(this.generatePrefix(line))); + } + + public append(content: string): void { + if (this.level) { + this.lineSplitter.write(content); + } else { + this.outputChannel.append(content); + } + } + + public appendLine(content: string): void { + if (this.level) { + this.lineSplitter.write(content + '\n'); + } else { + this.outputChannel.appendLine(content); + } + } + + public dispose(): void { + this.lineSplitter.close(); + } + + public async performOperation(startContent: string, operation: (outputManager: OutputManager) => Promise, endContent?: string | outputCallback, errorContent?: string | outputCallback): Promise { + if (!this.isShown) { + this.outputChannel.show(true); + this.isShown = true; + } + + this.appendLine(startContent); + + try { + const nextLevelOutputManager = new DefaultOutputManager(this.outputChannel, this.level + 1); + + let result: T; + + try { + + result = await operation(nextLevelOutputManager); + } + finally { + nextLevelOutputManager.dispose(); + } + + if (endContent) { + this.appendLine(typeof endContent === 'string' ? endContent : endContent(result)); + } + + return result; + } catch (error) { + if (errorContent) { + this.appendLine(typeof errorContent === 'string' ? errorContent : errorContent(error)); + } + + throw error; + } + } + + private generatePrefix(content?: string): string { + return '>'.repeat(this.level) + ' ' + (content || ''); + } +} diff --git a/debugging/coreclr/prereqManager.ts b/debugging/coreclr/prereqManager.ts new file mode 100644 index 0000000000..377d009147 --- /dev/null +++ b/debugging/coreclr/prereqManager.ts @@ -0,0 +1,174 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as path from 'path'; +import * as vscode from 'vscode'; +import { BrowserClient } from './browserClient'; +import { DockerClient } from './dockerClient'; +import { MacNuGetPackageFallbackFolderPath } from './dockerManager'; +import { DotNetClient } from './dotNetClient'; +import { FileSystemProvider } from './fsProvider'; +import { OSProvider } from './osProvider'; +import { ProcessProvider } from './processProvider'; + +export interface Prerequisite { + checkPrerequisite(): Promise; +} + +export type ShowErrorMessageFunction = (message: string, ...items: vscode.MessageItem[]) => Thenable; + +export class DockerDaemonIsLinuxPrerequisite implements Prerequisite { + constructor( + private readonly dockerClient: DockerClient, + private readonly showErrorMessage: ShowErrorMessageFunction) { + } + + public async checkPrerequisite(): Promise { + const daemonOsJson = await this.dockerClient.getVersion({ format: '{{json .Server.Os}}' }); + const daemonOs = JSON.parse(daemonOsJson.trim()); + + if (daemonOs === 'linux') { + return true; + } + + this.showErrorMessage('The Docker daemon is not configured to run Linux containers. Only Linux containers can be used for .NET Core debugging.') + + return false; + } +} + +export class DotNetExtensionInstalledPrerequisite implements Prerequisite { + constructor( + private readonly browserClient: BrowserClient, + private readonly getExtension: (extensionId: string) => vscode.Extension | undefined, + private readonly showErrorMessage: ShowErrorMessageFunction) { + } + + public async checkPrerequisite(): Promise { + // NOTE: Debugging .NET Core in Docker containers requires the C# (i.e. .NET Core debugging) extension. + // As this extension targets Docker in general and not .NET Core in particular, we don't want the + // extension as a whole to depend on it. Hence, we only check for its existence if/when asked to + // debug .NET Core in Docker containers. + const dependenciesSatisfied = this.getExtension('ms-vscode.csharp') !== undefined; + + if (!dependenciesSatisfied) { + const openExtensionInGallery: vscode.MessageItem = { + title: 'View extension in gallery' + }; + + this + .showErrorMessage( + 'To debug .NET Core in Docker containers, install the C# extension for VS Code.', + openExtensionInGallery) + .then(result => { + if (result === openExtensionInGallery) { + this.browserClient.openBrowser('https://marketplace.visualstudio.com/items?itemName=ms-vscode.csharp'); + } + }); + } + + return await Promise.resolve(dependenciesSatisfied); + } +} + +export class DotNetSdkInstalledPrerequisite implements Prerequisite { + constructor( + private readonly msbuildClient: DotNetClient, + private readonly showErrorMessage: ShowErrorMessageFunction) { + } + + public async checkPrerequisite(): Promise { + const result = await this.msbuildClient.getVersion(); + + if (result) { + return true; + } + + this.showErrorMessage('The .NET Core SDK must be installed to debug .NET Core applications running within Docker containers.'); + + return false; + } +} + +type DockerSettings = { + filesharingDirectories?: string[]; +}; + +export class LinuxUserInDockerGroupPrerequisite implements Prerequisite { + constructor( + private readonly osProvider: OSProvider, + private readonly processProvider: ProcessProvider, + private readonly showErrorMessage: ShowErrorMessageFunction) { + } + + public async checkPrerequisite(): Promise { + if (this.osProvider.os !== 'Linux' || this.osProvider.isMac) { + return true; + } + + const result = await this.processProvider.exec('id -Gn', {}); + const groups = result.stdout.trim().split(' '); + const inDockerGroup = groups.find(group => group === 'docker') !== undefined; + + if (inDockerGroup) { + return true; + } + + this.showErrorMessage('The current user is not a member of the "docker" group. Add it using the command "sudo usermod -a -G docker $USER".') + + return false; + } +} + +export class MacNuGetFallbackFolderSharedPrerequisite implements Prerequisite { + constructor( + private readonly fileSystemProvider: FileSystemProvider, + private readonly osProvider: OSProvider, + private readonly showErrorMessage: ShowErrorMessageFunction) { + } + + public async checkPrerequisite(): Promise { + if (!this.osProvider.isMac) { + // Only Mac requires this folder be specifically shared. + return true; + } + + const settingsPath = path.posix.join(this.osProvider.homedir, 'Library/Group Containers/group.com.docker/settings.json'); + + if (!await this.fileSystemProvider.fileExists(settingsPath)) { + // Docker versions earlier than 17.12.0-ce-mac46 may not have the settings file. + return true; + } + + const settingsContent = await this.fileSystemProvider.readFile(settingsPath); + const settings = JSON.parse(settingsContent); + + if (settings === undefined || settings.filesharingDirectories === undefined) { + // Docker versions earlier than 17.12.0-ce-mac46 may not have the property. + return true; + } + + if (settings.filesharingDirectories.find(directory => directory === MacNuGetPackageFallbackFolderPath) !== undefined) { + return true; + } + + this.showErrorMessage(`To debug .NET Core in Docker containers, add "${MacNuGetPackageFallbackFolderPath}" as a shared folder in your Docker preferences.`); + + return false; + } +} + +export class AggregatePrerequisite implements Prerequisite { + private readonly prerequisites: Prerequisite[]; + + constructor(...prerequisites: Prerequisite[]) { + this.prerequisites = prerequisites; + } + + public async checkPrerequisite(): Promise { + const results = await Promise.all(this.prerequisites.map(async prerequisite => await prerequisite.checkPrerequisite())); + + return results.every(result => result); + } +} diff --git a/debugging/coreclr/processProvider.ts b/debugging/coreclr/processProvider.ts new file mode 100644 index 0000000000..b131c0d942 --- /dev/null +++ b/debugging/coreclr/processProvider.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as cp from 'child_process'; +import * as process from 'process'; + +export type ProcessProviderExecOptions = cp.ExecOptions & { progress?(content: string): void }; + +export interface ProcessProvider { + env: { [key: string]: string | undefined }; + pid: number; + + exec(command: string, options: ProcessProviderExecOptions): Promise<{ stdout: string, stderr: string }>; +} + +export class ChildProcessProvider implements ProcessProvider { + + get env(): { [key: string]: string | undefined } { + return process.env; + } + + get pid(): number { + return process.pid; + } + + public async exec(command: string, options: ProcessProviderExecOptions): Promise<{ stdout: string, stderr: string }> { + return await new Promise<{ stdout: string, stderr: string }>( + (resolve, reject) => { + const p = cp.exec( + command, + options, + (error, stdout, stderr) => { + if (error) { + return reject(error); + } + + resolve({ stdout, stderr }); + }); + + if (options.progress) { + const progress = options.progress; + + p.stderr.on('data', chunk => progress(chunk.toString())); + p.stdout.on('data', chunk => progress(chunk.toString())); + } + }); + } +} + +export default ChildProcessProvider; diff --git a/debugging/coreclr/registerDebugger.ts b/debugging/coreclr/registerDebugger.ts new file mode 100644 index 0000000000..4ce76f9e84 --- /dev/null +++ b/debugging/coreclr/registerDebugger.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { DefaultAppStorageProvider } from './appStorage'; +import OpnBrowserClient from './browserClient'; +import { DefaultDebuggerClient } from './debuggerClient'; +import { DockerDebugSessionManager } from './debugSessionManager'; +import CliDockerClient from './dockerClient'; +import DockerDebugConfigurationProvider from './dockerDebugConfigurationProvider'; +import { DefaultDockerManager } from './dockerManager'; +import CommandLineDotNetClient from './dotNetClient'; +import { LocalFileSystemProvider } from './fsProvider'; +import { MsBuildNetCoreProjectProvider } from './netCoreProjectProvider'; +import LocalOSProvider from './osProvider'; +import { DefaultOutputManager } from './outputManager'; +import { AggregatePrerequisite, DockerDaemonIsLinuxPrerequisite, DotNetExtensionInstalledPrerequisite, DotNetSdkInstalledPrerequisite, LinuxUserInDockerGroupPrerequisite, MacNuGetFallbackFolderSharedPrerequisite } from './prereqManager'; +import ChildProcessProvider from './processProvider'; +import { OSTempFileProvider } from './tempFileProvider'; +import { RemoteVsDbgClient } from './vsdbgClient'; + +export function registerDebugConfigurationProvider(ctx: vscode.ExtensionContext): void { + const fileSystemProvider = new LocalFileSystemProvider(); + + const processProvider = new ChildProcessProvider(); + const dockerClient = new CliDockerClient(processProvider); + const msBuildClient = new CommandLineDotNetClient(processProvider); + const osProvider = new LocalOSProvider(); + + const dockerOutputChannel = vscode.window.createOutputChannel('Docker'); + + ctx.subscriptions.push(dockerOutputChannel); + + const dockerOutputManager = new DefaultOutputManager(dockerOutputChannel); + + const dockerManager = + new DefaultDockerManager( + new DefaultAppStorageProvider(fileSystemProvider), + new DefaultDebuggerClient( + new RemoteVsDbgClient( + dockerOutputManager, + fileSystemProvider, + ctx.globalState, + osProvider, + processProvider)), + dockerClient, + dockerOutputManager, + fileSystemProvider, + osProvider, + processProvider, + ctx.workspaceState); + + const debugSessionManager = new DockerDebugSessionManager( + vscode.debug.onDidTerminateDebugSession, + dockerManager + ); + + ctx.subscriptions.push(debugSessionManager); + + ctx.subscriptions.push( + vscode.debug.registerDebugConfigurationProvider( + 'docker-coreclr', + new DockerDebugConfigurationProvider( + debugSessionManager, + dockerManager, + fileSystemProvider, + osProvider, + new MsBuildNetCoreProjectProvider( + fileSystemProvider, + msBuildClient, + new OSTempFileProvider( + osProvider, + processProvider)), + new AggregatePrerequisite( + new DockerDaemonIsLinuxPrerequisite( + dockerClient, + vscode.window.showErrorMessage), + new DotNetExtensionInstalledPrerequisite( + new OpnBrowserClient(), + vscode.extensions.getExtension, + vscode.window.showErrorMessage), + new DotNetSdkInstalledPrerequisite( + msBuildClient, + vscode.window.showErrorMessage), + new MacNuGetFallbackFolderSharedPrerequisite( + fileSystemProvider, + osProvider, + vscode.window.showErrorMessage), + new LinuxUserInDockerGroupPrerequisite( + osProvider, + processProvider, + vscode.window.showErrorMessage) + )))); +} diff --git a/debugging/coreclr/tempFileProvider.ts b/debugging/coreclr/tempFileProvider.ts new file mode 100644 index 0000000000..eaa5de3a24 --- /dev/null +++ b/debugging/coreclr/tempFileProvider.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as path from 'path'; +import { OSProvider } from "./osProvider"; +import { ProcessProvider } from './processProvider'; + +export interface TempFileProvider { + getTempFilename(prefix?: string): string; +} + +export class OSTempFileProvider implements TempFileProvider { + private count: number = 1; + + constructor( + private readonly osProvider: OSProvider, + private readonly processProvider: ProcessProvider) { + } + + public getTempFilename(prefix: string = 'temp'): string { + return path.join(this.osProvider.tmpdir, `${prefix}_${new Date().valueOf()}_${this.processProvider.pid}_${this.count++}.tmp` ); + } +} diff --git a/debugging/coreclr/vsdbgClient.ts b/debugging/coreclr/vsdbgClient.ts new file mode 100644 index 0000000000..0e2131f0af --- /dev/null +++ b/debugging/coreclr/vsdbgClient.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as path from 'path'; +import * as process from 'process'; +import { Memento } from 'vscode'; +import { ext } from '../../extensionVariables'; +import { FileSystemProvider } from './fsProvider'; +import { OSProvider } from './osProvider'; +import { OutputManager } from './outputManager'; +import { ProcessProvider } from './processProvider'; + +export interface VsDbgClient { + getVsDbgVersion(version: string, runtime: string): Promise; +} + +type VsDbgScriptPlatformOptions = { + name: string; + url: string; + getAcquisitionCommand(vsdbgAcquisitionScriptPath: string, version: string, runtime: string, vsdbgVersionPath: string): Promise; + onScriptAcquired?(path: string): Promise; +}; + +export class RemoteVsDbgClient implements VsDbgClient { + private static readonly stateKey: string = 'RemoteVsDbgClient'; + private static readonly winDir: string = 'WINDIR'; + + private readonly vsdbgPath: string; + private readonly options: VsDbgScriptPlatformOptions; + + constructor( + private readonly dockerOutputManager: OutputManager, + private readonly fileSystemProvider: FileSystemProvider, + private readonly globalState: Memento, + osProvider: OSProvider, + private readonly processProvider: ProcessProvider) { + this.vsdbgPath = path.join(osProvider.homedir, '.vsdbg'); + this.options = osProvider.os === 'Windows' + ? { + name: 'GetVsDbg.ps1', + url: 'https://aka.ms/getvsdbgps1', + getAcquisitionCommand: async (vsdbgAcquisitionScriptPath: string, version: string, runtime: string, vsdbgVersionPath: string) => { + const powershellCommand = `${process.env[RemoteVsDbgClient.winDir]}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; + return await Promise.resolve(`${powershellCommand} -NonInteractive -NoProfile -WindowStyle Hidden -ExecutionPolicy RemoteSigned -File \"${vsdbgAcquisitionScriptPath}\" -Version ${version} -RuntimeID ${runtime} -InstallPath \"${vsdbgVersionPath}\"`); + } + } + : { + name: 'getvsdbg.sh', + url: 'https://aka.ms/getvsdbgsh', + getAcquisitionCommand: async (vsdbgAcquisitionScriptPath: string, version: string, runtime: string, vsdbgVersionPath: string) => { + return await Promise.resolve(`${vsdbgAcquisitionScriptPath} -v ${version} -r ${runtime} -l \"${vsdbgVersionPath}\"`); + }, + onScriptAcquired: async (scriptPath: string) => { + await this.processProvider.exec(`chmod +x \"${scriptPath}\"`, { cwd: this.vsdbgPath }); + } + }; + } + + public async getVsDbgVersion(version: string, runtime: string): Promise { + const vsdbgVersionPath = path.join(this.vsdbgPath, runtime, version); + const vsdbgVersionExists = await this.fileSystemProvider.dirExists(vsdbgVersionPath); + + if (vsdbgVersionExists && await this.isUpToDate(this.lastDebuggerAcquisitionKey(version, runtime))) { + // The debugger is up to date... + return vsdbgVersionPath; + } + + return await this.dockerOutputManager.performOperation( + 'Acquiring the latest .NET Core debugger...', + async () => { + + await this.getVsDbgAcquisitionScript(); + + const vsdbgAcquisitionScriptPath = path.join(this.vsdbgPath, this.options.name); + + const acquisitionCommand = await this.options.getAcquisitionCommand(vsdbgAcquisitionScriptPath, version, runtime, vsdbgVersionPath); + + await this.processProvider.exec(acquisitionCommand, { cwd: this.vsdbgPath }); + + await this.updateDate(this.lastDebuggerAcquisitionKey(version, runtime), new Date()); + + return vsdbgVersionPath; + }, + 'Debugger acquired.', + 'Unable to acquire the .NET Core debugger.'); + } + + private async getVsDbgAcquisitionScript(): Promise { + const vsdbgAcquisitionScriptPath = path.join(this.vsdbgPath, this.options.name); + const acquisitionScriptExists = await this.fileSystemProvider.fileExists(vsdbgAcquisitionScriptPath); + + if (acquisitionScriptExists && await this.isUpToDate(this.lastScriptAcquisitionKey)) { + // The acquisition script is up to date... + return; + } + + const directoryExists = await this.fileSystemProvider.dirExists(this.vsdbgPath); + + if (!directoryExists) { + await this.fileSystemProvider.makeDir(this.vsdbgPath); + } + + const script = await ext.request(this.options.url); + + await this.fileSystemProvider.writeFile(vsdbgAcquisitionScriptPath, script); + + if (this.options.onScriptAcquired) { + await this.options.onScriptAcquired(vsdbgAcquisitionScriptPath); + } + + await this.updateDate(this.lastScriptAcquisitionKey, new Date()); + } + + private async isUpToDate(key: string): Promise { + const lastAcquisitionDate = await this.getDate(key); + + if (lastAcquisitionDate) { + let aquisitionExpirationDate = new Date(lastAcquisitionDate); + + aquisitionExpirationDate.setDate(lastAcquisitionDate.getDate() + 1); + + if (aquisitionExpirationDate.valueOf() > new Date().valueOf()) { + // The acquisition is up to date... + return true; + } + } + + return false; + } + + private get lastScriptAcquisitionKey(): string { + return `${RemoteVsDbgClient.stateKey}.lastScriptAcquisition`; + } + + private lastDebuggerAcquisitionKey(version: string, runtime: string): string { + return `${RemoteVsDbgClient.stateKey}.lastDebuggerAcquisition(${version}, ${runtime})`; + } + + private async getDate(key: string): Promise { + const dateString = this.globalState.get(key); + + return await Promise.resolve(dateString ? new Date(dateString) : undefined); + } + + private async updateDate(key: string, timestamp: Date): Promise { + await this.globalState.update(key, timestamp); + } +} diff --git a/dockerCompose/dockerComposeCompletionItemProvider.ts b/dockerCompose/dockerComposeCompletionItemProvider.ts index 87282447e7..5ef6bcd246 100644 --- a/dockerCompose/dockerComposeCompletionItemProvider.ts +++ b/dockerCompose/dockerComposeCompletionItemProvider.ts @@ -7,7 +7,7 @@ import { CancellationToken, CompletionItem, CompletionItemKind, CompletionItemProvider, Position, TextDocument, Uri } from 'vscode'; import { KeyInfo } from '../dockerExtension'; -import hub = require('../dockerHubApi'); +import hub = require('../dockerHubSearch'); import helper = require('../helpers/suggestSupportHelper'); import composeVersions from './dockerComposeKeyInfo'; diff --git a/dockerCompose/dockerComposeHoverProvider.ts b/dockerCompose/dockerComposeHoverProvider.ts index c8596f705c..65825ab54f 100644 --- a/dockerCompose/dockerComposeHoverProvider.ts +++ b/dockerCompose/dockerComposeHoverProvider.ts @@ -7,7 +7,7 @@ import { CancellationToken, Hover, HoverProvider, MarkedString, Position, Range, TextDocument } from 'vscode'; import { KeyInfo } from "../dockerExtension"; -import hub = require('../dockerHubApi'); +import hub = require('../dockerHubSearch'); import suggestHelper = require('../helpers/suggestSupportHelper'); import parser = require('../parser'); diff --git a/dockerExtension.ts b/dockerExtension.ts index 37b4bfa90c..b4b17ad753 100644 --- a/dockerExtension.ts +++ b/dockerExtension.ts @@ -3,16 +3,25 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as opn from 'opn'; +let loadStartTime = Date.now(); + +import * as assert from 'assert'; import * as path from 'path'; import * as request from 'request-promise-native'; import * as vscode from 'vscode'; -import { AzureUserInput, createTelemetryReporter, IActionContext, parseError, registerCommand as uiRegisterCommand, registerUIExtensionVariables, TelemetryProperties, UserCancelledError } from 'vscode-azureextensionui'; +import { AzureUserInput, callWithTelemetryAndErrorHandling, createTelemetryReporter, IActionContext, registerCommand as uiRegisterCommand, registerUIExtensionVariables, TelemetryProperties, UserCancelledError } from 'vscode-azureextensionui'; import { ConfigurationParams, DidChangeConfigurationNotification, DocumentSelector, LanguageClient, LanguageClientOptions, Middleware, ServerOptions, TransportKind } from 'vscode-languageclient/lib/main'; +import { viewACRLogs } from "./commands/azureCommands/acr-logs"; +import { LogContentProvider } from "./commands/azureCommands/acr-logs-utils/logFileManager"; import { createRegistry } from './commands/azureCommands/create-registry'; import { deleteAzureImage } from './commands/azureCommands/delete-image'; import { deleteAzureRegistry } from './commands/azureCommands/delete-registry'; import { deleteRepository } from './commands/azureCommands/delete-repository'; +import { pullFromAzure } from './commands/azureCommands/pull-from-azure'; +import { quickBuild } from "./commands/azureCommands/quick-build"; +import { runTask } from "./commands/azureCommands/run-task"; +import { showTaskProperties } from "./commands/azureCommands/show-task"; +import { TaskContentProvider } from "./commands/azureCommands/task-utils/showTaskManager"; import { buildImage } from './commands/build-image'; import { composeDown, composeRestart, composeUp } from './commands/docker-compose'; import inspectImage from './commands/inspect-image'; @@ -26,11 +35,12 @@ import { showLogsContainer } from './commands/showlogs-container'; import { startAzureCLI, startContainer, startContainerInteractive } from './commands/start-container'; import { stopContainer } from './commands/stop-container'; import { systemPrune } from './commands/system-prune'; -import { IHasImageDescriptorAndLabel, tagImage } from './commands/tag-image'; +import { tagImage } from './commands/tag-image'; import { docker } from './commands/utils/docker-endpoint'; import { DefaultTerminalProvider } from './commands/utils/TerminalProvider'; import { DockerDebugConfigProvider } from './configureWorkspace/configDebugProvider'; import { configure, configureApi, ConfigureApiOptions } from './configureWorkspace/configure'; +import { registerDebugConfigurationProvider } from './debugging/coreclr/registerDebugger'; import { DockerComposeCompletionItemProvider } from './dockerCompose/dockerComposeCompletionItemProvider'; import { DockerComposeHoverProvider } from './dockerCompose/dockerComposeHoverProvider'; import composeVersionKeys from './dockerCompose/dockerComposeKeyInfo'; @@ -38,7 +48,7 @@ import { DockerComposeParser } from './dockerCompose/dockerComposeParser'; import { DockerfileCompletionItemProvider } from './dockerfile/dockerfileCompletionItemProvider'; import DockerInspectDocumentContentProvider, { SCHEME as DOCKER_INSPECT_SCHEME } from './documentContentProviders/dockerInspect'; import { AzureAccountWrapper } from './explorer/deploy/azureAccountWrapper'; -import * as util from "./explorer/deploy/util"; +import * as util from './explorer/deploy/util'; import { WebAppCreator } from './explorer/deploy/webAppCreator'; import { DockerExplorerProvider } from './explorer/dockerExplorer'; import { AzureImageTagNode, AzureRegistryNode, AzureRepositoryNode } from './explorer/models/azureRegistryNodes'; @@ -50,11 +60,9 @@ import { NodeBase } from './explorer/models/nodeBase'; import { RootNode } from './explorer/models/rootNode'; import { browseAzurePortal } from './explorer/utils/browseAzurePortal'; import { browseDockerHub, dockerHubLogout } from './explorer/utils/dockerHubUtils'; -import { ext } from "./extensionVariables"; +import { ext } from './extensionVariables'; import { initializeTelemetryReporter, reporter } from './telemetry/telemetry'; -import { AzureAccount } from './typings/azure-account.api'; import { addUserAgent } from './utils/addUserAgent'; -import { registerAzureCommand } from './utils/Azure/common'; import { AzureUtilityManager } from './utils/azureUtilityManager'; import { Keytar } from './utils/keytar'; @@ -64,251 +72,322 @@ export const DOCKERFILE_GLOB_PATTERN = '**/{*.dockerfile,[dD]ocker[fF]ile}'; export let dockerExplorerProvider: DockerExplorerProvider; -export type KeyInfo = { [keyName: string]: string; }; +export type KeyInfo = { [keyName: string]: string }; export interface ComposeVersionKeys { - All: KeyInfo, - v1: KeyInfo, - v2: KeyInfo + All: KeyInfo; + v1: KeyInfo; + v2: KeyInfo; } let client: LanguageClient; const DOCUMENT_SELECTOR: DocumentSelector = [ - { language: 'dockerfile', scheme: 'file' } + { language: 'dockerfile', scheme: 'file' } ]; function initializeExtensionVariables(ctx: vscode.ExtensionContext): void { - registerUIExtensionVariables(ext); - - if (!ext.ui) { - // This allows for standard interactions with the end user (as opposed to test input) - ext.ui = new AzureUserInput(ctx.globalState); - } - ext.context = ctx; - ext.outputChannel = util.getOutputChannel(); - if (!ext.terminalProvider) { - ext.terminalProvider = new DefaultTerminalProvider(); - } - initializeTelemetryReporter(createTelemetryReporter(ctx)); - ext.reporter = reporter; - if (!ext.keytar) { - ext.keytar = Keytar.tryCreate(); - } - - // Set up the user agent for all direct 'request' calls in the extension (must use ext.request) - let defaultRequestOptions = {}; - addUserAgent(defaultRequestOptions); - ext.request = request.defaults(defaultRequestOptions); + if (!ext.ui) { + // This allows for standard interactions with the end user (as opposed to test input) + ext.ui = new AzureUserInput(ctx.globalState); + } + ext.context = ctx; + ext.outputChannel = util.getOutputChannel(); + if (!ext.terminalProvider) { + ext.terminalProvider = new DefaultTerminalProvider(); + } + initializeTelemetryReporter(createTelemetryReporter(ctx)); + ext.reporter = reporter; + if (!ext.keytar) { + ext.keytar = Keytar.tryCreate(); + } + + registerUIExtensionVariables(ext); } export async function activate(ctx: vscode.ExtensionContext): Promise { - const installedExtensions = vscode.extensions.all; - let azureAccount: AzureAccount | undefined; - - initializeExtensionVariables(ctx); - - // tslint:disable-next-line:prefer-for-of // Grandfathered in - for (let i = 0; i < installedExtensions.length; i++) { - const extension = installedExtensions[i]; - if (extension.id === 'ms-vscode.azure-account') { - try { - // tslint:disable-next-line:no-unsafe-any - azureAccount = await extension.activate(); - } catch (error) { - console.log('Failed to activate the Azure Account Extension: ' + parseError(error).message); - } - break; - } - } - ctx.subscriptions.push(vscode.languages.registerCompletionItemProvider(DOCUMENT_SELECTOR, new DockerfileCompletionItemProvider(), '.')); - - const YAML_MODE_ID: vscode.DocumentFilter = { language: 'yaml', scheme: 'file', pattern: COMPOSE_FILE_GLOB_PATTERN }; - let yamlHoverProvider = new DockerComposeHoverProvider(new DockerComposeParser(), composeVersionKeys.All); - ctx.subscriptions.push(vscode.languages.registerHoverProvider(YAML_MODE_ID, yamlHoverProvider)); - ctx.subscriptions.push(vscode.languages.registerCompletionItemProvider(YAML_MODE_ID, new DockerComposeCompletionItemProvider(), '.')); - - ctx.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(DOCKER_INSPECT_SCHEME, new DockerInspectDocumentContentProvider())); - - if (azureAccount) { - AzureUtilityManager.getInstance().setAccount(azureAccount); - } - - registerDockerCommands(azureAccount); - - ctx.subscriptions.push(vscode.debug.registerDebugConfigurationProvider('docker', new DockerDebugConfigProvider())); + let activateStartTime = Date.now(); + + initializeExtensionVariables(ctx); + + // Set up the user agent for all direct 'request' calls in the extension (must use ext.request) + let defaultRequestOptions = {}; + addUserAgent(defaultRequestOptions); + ext.request = request.defaults(defaultRequestOptions); + + await callWithTelemetryAndErrorHandling('docker.activate', async function (this: IActionContext): Promise { + this.properties.isActivationEvent = 'true'; + this.measurements.mainFileLoad = (loadEndTime - loadStartTime) / 1000; + this.measurements.mainFileLoadedToActivate = (activateStartTime - loadEndTime) / 1000; + + ctx.subscriptions.push( + vscode.languages.registerCompletionItemProvider( + DOCUMENT_SELECTOR, + new DockerfileCompletionItemProvider(), + '.' + ) + ); + + const YAML_MODE_ID: vscode.DocumentFilter = { + language: 'yaml', + scheme: 'file', + pattern: COMPOSE_FILE_GLOB_PATTERN + }; + let yamlHoverProvider = new DockerComposeHoverProvider( + new DockerComposeParser(), + composeVersionKeys.All + ); + ctx.subscriptions.push( + vscode.languages.registerHoverProvider(YAML_MODE_ID, yamlHoverProvider) + ); + ctx.subscriptions.push( + vscode.languages.registerCompletionItemProvider( + YAML_MODE_ID, + new DockerComposeCompletionItemProvider(), + "." + ) + ); + + ctx.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider( + DOCKER_INSPECT_SCHEME, + new DockerInspectDocumentContentProvider() + ) + ); + ctx.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider( + LogContentProvider.scheme, + new LogContentProvider() + ) + ); + ctx.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider( + TaskContentProvider.scheme, + new TaskContentProvider() + ) + ); + + registerDockerCommands(); + + ctx.subscriptions.push( + vscode.debug.registerDebugConfigurationProvider( + 'docker', + new DockerDebugConfigProvider() + ) + ); + registerDebugConfigurationProvider(ctx); await consolidateDefaultRegistrySettings(); activateLanguageClient(ctx); + + // Start loading the Azure account after we're completely done activating. + setTimeout( + // Do not wait + // tslint:disable-next-line:promise-function-async + () => AzureUtilityManager.getInstance().tryGetAzureAccount(), + 1); + }); } -async function createWebApp(context?: AzureImageTagNode | DockerHubImageTagNode, azureAccount?: AzureAccount): Promise { - if (context) { - if (azureAccount) { - const azureAccountWrapper = new AzureAccountWrapper(ext.context, azureAccount); - const wizard = new WebAppCreator(ext.outputChannel, azureAccountWrapper, context); - const result = await wizard.run(); - if (result.status === 'Faulted') { - throw result.error; - } else if (result.status === 'Cancelled') { - throw new UserCancelledError(); - } - } else { - const open: vscode.MessageItem = { title: "View in Marketplace" }; - const response = await vscode.window.showErrorMessage('Please install the Azure Account extension to deploy to Azure.', open); - if (response === open) { - // tslint:disable-next-line:no-unsafe-any - opn('https://marketplace.visualstudio.com/items?itemName=ms-vscode.azure-account'); - } - } - } +async function createWebApp(context?: AzureImageTagNode | DockerHubImageTagNode): Promise { + assert(!!context, "Should not be available through command palette"); + + let azureAccount = await AzureUtilityManager.getInstance().requireAzureAccount(); + const azureAccountWrapper = new AzureAccountWrapper(ext.context, azureAccount); + const wizard = new WebAppCreator(ext.outputChannel, azureAccountWrapper, context); + const result = await wizard.run(); + if (result.status === 'Faulted') { + throw result.error; + } else if (result.status === 'Cancelled') { + throw new UserCancelledError(); + } } // Remove this when https://github.com/Microsoft/vscode-docker/issues/445 fixed -// tslint:disable-next-line:no-any -function registerCommand(commandId: string, callback: (this: IActionContext, ...args: any[]) => any): void { - return uiRegisterCommand( - commandId, - // tslint:disable-next-line:no-function-expression no-any - async function (this: IActionContext, ...args: any[]): Promise { - if (args.length) { - let properties: { - contextValue?: string; - } & TelemetryProperties = this.properties; - const contextArg = args[0]; - - if (contextArg instanceof NodeBase) { - properties.contextValue = contextArg.contextValue; - } else if (contextArg instanceof vscode.Uri) { - properties.contextValue = 'Uri'; - } - } - - return callback.call(this, ...args); - }); +function registerCommand( + commandId: string, + // tslint:disable-next-line: no-any + callback: (this: IActionContext, ...args: any[]) => any +): void { + return uiRegisterCommand( + commandId, + // tslint:disable-next-line:no-function-expression no-any + async function (this: IActionContext, ...args: any[]): Promise { + if (args.length) { + let properties: { + contextValue?: string; + } & TelemetryProperties = this.properties; + const contextArg = args[0]; + + if (contextArg instanceof NodeBase) { + properties.contextValue = contextArg.contextValue; + } else if (contextArg instanceof vscode.Uri) { + properties.contextValue = "Uri"; + } + } + + return callback.call(this, ...args); + } + ); } -function registerDockerCommands(azureAccount: AzureAccount | undefined): void { - dockerExplorerProvider = new DockerExplorerProvider(azureAccount); - vscode.window.registerTreeDataProvider('dockerExplorer', dockerExplorerProvider); - registerCommand('vscode-docker.explorer.refresh', () => dockerExplorerProvider.refresh()); - - registerCommand('vscode-docker.configure', async function (this: IActionContext): Promise { await configure(this, undefined); }); - registerCommand('vscode-docker.api.configure', async function (this: IActionContext, options: ConfigureApiOptions): Promise { - await configureApi(this, options); - }); - - registerCommand('vscode-docker.container.start', async function (this: IActionContext, node: ImageNode | undefined): Promise { await startContainer(this, node); }); - registerCommand('vscode-docker.container.start.interactive', async function (this: IActionContext, node: ImageNode | undefined): Promise { await startContainerInteractive(this, node); }); - registerCommand('vscode-docker.container.start.azurecli', async function (this: IActionContext): Promise { await startAzureCLI(this); }); - registerCommand('vscode-docker.container.stop', async function (this: IActionContext, node: ContainerNode | RootNode | undefined): Promise { await stopContainer(this, node); }); - registerCommand('vscode-docker.container.restart', async function (this: IActionContext, node: ContainerNode | RootNode | undefined): Promise { await restartContainer(this, node); }); - registerCommand('vscode-docker.container.show-logs', async function (this: IActionContext, node: ContainerNode | RootNode | undefined): Promise { await showLogsContainer(this, node); }); - registerCommand('vscode-docker.container.open-shell', async function (this: IActionContext, node: ContainerNode | RootNode | undefined): Promise { await openShellContainer(this, node); }); - registerCommand('vscode-docker.container.remove', async function (this: IActionContext, node: ContainerNode | RootNode | undefined): Promise { await removeContainer(this, node); }); - registerCommand('vscode-docker.image.build', async function (this: IActionContext, item: vscode.Uri | undefined): Promise { await buildImage(this, item); }); - registerCommand('vscode-docker.image.inspect', async function (this: IActionContext, node: ImageNode | undefined): Promise { await inspectImage(this, node); }); - registerCommand('vscode-docker.image.remove', async function (this: IActionContext, node: ImageNode | RootNode | undefined): Promise { await removeImage(this, node); }); - registerCommand('vscode-docker.image.push', async function (this: IActionContext, node: ImageNode | undefined): Promise { await pushImage(this, node); }); - registerCommand('vscode-docker.image.tag', async function (this: IActionContext, node: ImageNode | undefined): Promise { await tagImage(this, node); }); - registerCommand('vscode-docker.compose.up', composeUp); - registerCommand('vscode-docker.compose.down', composeDown); - registerCommand('vscode-docker.compose.restart', composeRestart); - registerCommand('vscode-docker.system.prune', systemPrune); - registerCommand('vscode-docker.createWebApp', async (context?: AzureImageTagNode | DockerHubImageTagNode) => await createWebApp(context, azureAccount)); - registerCommand('vscode-docker.dockerHubLogout', dockerHubLogout); - registerCommand('vscode-docker.browseDockerHub', (context?: DockerHubImageTagNode | DockerHubRepositoryNode | DockerHubOrgNode) => { - browseDockerHub(context); - }); - registerCommand('vscode-docker.browseAzurePortal', (context?: AzureRegistryNode | AzureRepositoryNode | AzureImageTagNode) => { - browseAzurePortal(context); - }); - registerCommand('vscode-docker.connectCustomRegistry', connectCustomRegistry); - registerCommand('vscode-docker.disconnectCustomRegistry', disconnectCustomRegistry); - registerCommand('vscode-docker.setRegistryAsDefault', setRegistryAsDefault); - registerAzureCommand('vscode-docker.delete-ACR-Registry', deleteAzureRegistry); - registerAzureCommand('vscode-docker.delete-ACR-Image', deleteAzureImage); - registerAzureCommand('vscode-docker.delete-ACR-Repository', deleteRepository); - registerAzureCommand('vscode-docker.create-ACR-Registry', createRegistry); +// tslint:disable-next-line:max-func-body-length +function registerDockerCommands(): void { + dockerExplorerProvider = new DockerExplorerProvider(); + vscode.window.registerTreeDataProvider( + 'dockerExplorer', + dockerExplorerProvider + ); + + registerCommand('vscode-docker.acr.createRegistry', createRegistry); + registerCommand('vscode-docker.acr.deleteImage', deleteAzureImage); + registerCommand('vscode-docker.acr.deleteRegistry', deleteAzureRegistry); + registerCommand('vscode-docker.acr.deleteRepository', deleteRepository); + registerCommand('vscode-docker.acr.pullImage', pullFromAzure); + registerCommand('vscode-docker.acr.quickBuild', async function (this: IActionContext, item: vscode.Uri | undefined): Promise { await quickBuild(this, item); }); + registerCommand('vscode-docker.acr.runTask', runTask); + registerCommand('vscode-docker.acr.showTask', showTaskProperties); + registerCommand('vscode-docker.acr.viewLogs', viewACRLogs); + + registerCommand('vscode-docker.api.configure', async function (this: IActionContext, options: ConfigureApiOptions): Promise { await configureApi(this, options); }); + registerCommand('vscode-docker.browseDockerHub', (context?: DockerHubImageTagNode | DockerHubRepositoryNode | DockerHubOrgNode) => { browseDockerHub(context); }); + registerCommand('vscode-docker.browseAzurePortal', (context?: AzureRegistryNode | AzureRepositoryNode | AzureImageTagNode) => { browseAzurePortal(context); }); + + registerCommand('vscode-docker.compose.down', composeDown); + registerCommand('vscode-docker.compose.restart', composeRestart); + registerCommand('vscode-docker.compose.up', composeUp); + registerCommand('vscode-docker.configure', async function (this: IActionContext): Promise { await configure(this, undefined); }); + registerCommand('vscode-docker.connectCustomRegistry', connectCustomRegistry); + + registerCommand('vscode-docker.container.open-shell', async function (this: IActionContext, node: ContainerNode | RootNode | undefined): Promise { await openShellContainer(this, node); }); + registerCommand('vscode-docker.container.remove', async function (this: IActionContext, node: ContainerNode | RootNode | undefined): Promise { await removeContainer(this, node); }); + registerCommand('vscode-docker.container.restart', async function (this: IActionContext, node: ContainerNode | RootNode | undefined): Promise { await restartContainer(this, node); }); + registerCommand('vscode-docker.container.show-logs', async function (this: IActionContext, node: ContainerNode | RootNode | undefined): Promise { await showLogsContainer(this, node); }); + registerCommand('vscode-docker.container.start', async function (this: IActionContext, node: ImageNode | undefined): Promise { await startContainer(this, node); }); + registerCommand('vscode-docker.container.start.azurecli', async function (this: IActionContext): Promise { await startAzureCLI(this); }); + registerCommand('vscode-docker.container.start.interactive', async function (this: IActionContext, node: ImageNode | undefined): Promise { await startContainerInteractive(this, node); }); + registerCommand('vscode-docker.container.stop', async function (this: IActionContext, node: ContainerNode | RootNode | undefined): Promise { await stopContainer(this, node); }); + + registerCommand('vscode-docker.createWebApp', async (context?: AzureImageTagNode | DockerHubImageTagNode) => await createWebApp(context)); + registerCommand('vscode-docker.disconnectCustomRegistry', disconnectCustomRegistry); + registerCommand('vscode-docker.dockerHubLogout', dockerHubLogout); + registerCommand('vscode-docker.explorer.refresh', () => dockerExplorerProvider.refresh()); + + registerCommand('vscode-docker.image.build', async function (this: IActionContext, item: vscode.Uri | undefined): Promise { await buildImage(this, item); }); + registerCommand('vscode-docker.image.inspect', async function (this: IActionContext, node: ImageNode | undefined): Promise { await inspectImage(this, node); }); + registerCommand('vscode-docker.image.push', async function (this: IActionContext, node: ImageNode | undefined): Promise { await pushImage(this, node); }); + registerCommand('vscode-docker.image.remove', async function (this: IActionContext, node: ImageNode | RootNode | undefined): Promise { await removeImage(this, node); }); + registerCommand('vscode-docker.image.tag', async function (this: IActionContext, node: ImageNode | undefined): Promise { await tagImage(this, node); }); + + registerCommand('vscode-docker.setRegistryAsDefault', setRegistryAsDefault); + registerCommand('vscode-docker.system.prune', systemPrune); } export async function deactivate(): Promise { - if (!client) { - return undefined; - } - // perform cleanup - Configuration.dispose(); - return await client.stop(); + if (!client) { + return undefined; + } + // perform cleanup + Configuration.dispose(); + return await client.stop(); } namespace Configuration { - - let configurationListener: vscode.Disposable; - - export function computeConfiguration(params: ConfigurationParams): vscode.WorkspaceConfiguration[] { - let result: vscode.WorkspaceConfiguration[] = []; - for (let item of params.items) { - let config: vscode.WorkspaceConfiguration; - - if (item.scopeUri) { - config = vscode.workspace.getConfiguration(item.section, client.protocol2CodeConverter.asUri(item.scopeUri)); - } else { - config = vscode.workspace.getConfiguration(item.section); - } - result.push(config); - } - return result; + let configurationListener: vscode.Disposable; + + export function computeConfiguration(params: ConfigurationParams): vscode.WorkspaceConfiguration[] { + let result: vscode.WorkspaceConfiguration[] = []; + for (let item of params.items) { + let config: vscode.WorkspaceConfiguration; + + if (item.scopeUri) { + config = vscode.workspace.getConfiguration( + item.section, + client.protocol2CodeConverter.asUri(item.scopeUri) + ); + } else { + config = vscode.workspace.getConfiguration(item.section); + } + result.push(config); } - - export function initialize(): void { - configurationListener = vscode.workspace.onDidChangeConfiguration((e: vscode.ConfigurationChangeEvent) => { - // notify the language server that settings have change - client.sendNotification(DidChangeConfigurationNotification.type, { settings: null }); - - // Update endpoint and refresh explorer if needed - if (e.affectsConfiguration('docker')) { - docker.refreshEndpoint(); - vscode.commands.executeCommand("vscode-docker.explorer.refresh"); - } + return result; + } + + export function initialize(): void { + configurationListener = vscode.workspace.onDidChangeConfiguration( + (e: vscode.ConfigurationChangeEvent) => { + // notify the language server that settings have change + client.sendNotification(DidChangeConfigurationNotification.type, { + settings: null }); - } - export function dispose(): void { - if (configurationListener) { - // remove this listener when disposed - configurationListener.dispose(); + // Update endpoint and refresh explorer if needed + if (e.affectsConfiguration('docker')) { + docker.refreshEndpoint(); + vscode.commands.executeCommand('vscode-docker.explorer.refresh'); } + } + ); + } + + export function dispose(): void { + if (configurationListener) { + // remove this listener when disposed + configurationListener.dispose(); } + } } function activateLanguageClient(ctx: vscode.ExtensionContext): void { - let serverModule = ctx.asAbsolutePath(path.join("node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")); - let debugOptions = { execArgv: ["--nolazy", "--inspect=6009"] }; - - let serverOptions: ServerOptions = { - run: { module: serverModule, transport: TransportKind.ipc, args: ["--node-ipc"] }, - debug: { module: serverModule, transport: TransportKind.ipc, options: debugOptions } + let serverModule = ctx.asAbsolutePath( + path.join( + "node_modules", + "dockerfile-language-server-nodejs", + "lib", + "server.js" + ) + ); + let debugOptions = { execArgv: ["--nolazy", "--inspect=6009"] }; + + let serverOptions: ServerOptions = { + run: { + module: serverModule, + transport: TransportKind.ipc, + args: ["--node-ipc"] + }, + debug: { + module: serverModule, + transport: TransportKind.ipc, + options: debugOptions } + }; - let middleware: Middleware = { - workspace: { - configuration: Configuration.computeConfiguration - } - }; - - let clientOptions: LanguageClientOptions = { - documentSelector: DOCUMENT_SELECTOR, - synchronize: { - fileEvents: vscode.workspace.createFileSystemWatcher('**/.clientrc') - }, - middleware: middleware + let middleware: Middleware = { + workspace: { + configuration: Configuration.computeConfiguration } - - client = new LanguageClient("dockerfile-langserver", "Dockerfile Language Server", serverOptions, clientOptions); - // tslint:disable-next-line:no-floating-promises - client.onReady().then(() => { - // attach the VS Code settings listener - Configuration.initialize(); - }); - client.start(); + }; + + let clientOptions: LanguageClientOptions = { + documentSelector: DOCUMENT_SELECTOR, + synchronize: { + fileEvents: vscode.workspace.createFileSystemWatcher("**/.clientrc") + }, + middleware: middleware + }; + + client = new LanguageClient( + "dockerfile-langserver", + "Dockerfile Language Server", + serverOptions, + clientOptions + ); + // tslint:disable-next-line:no-floating-promises + client.onReady().then(() => { + // attach the VS Code settings listener + Configuration.initialize(); + }); + client.start(); } + +let loadEndTime = Date.now(); diff --git a/dockerHubApi.ts b/dockerHubSearch.ts similarity index 100% rename from dockerHubApi.ts rename to dockerHubSearch.ts diff --git a/explorer/dockerExplorer.ts b/explorer/dockerExplorer.ts index 73ae253ff5..f08a81ee30 100644 --- a/explorer/dockerExplorer.ts +++ b/explorer/dockerExplorer.ts @@ -4,8 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { docker } from '../commands/utils/docker-endpoint'; -import { AzureAccount } from '../typings/azure-account.api'; import { NodeBase } from './models/nodeBase'; import { RootNode } from './models/rootNode'; @@ -16,11 +14,6 @@ export class DockerExplorerProvider implements vscode.TreeDataProvider private _imagesNode: RootNode | undefined; private _containersNode: RootNode | undefined; private _registriesNode: RootNode | undefined; - private _azureAccount: AzureAccount | undefined; - - constructor(azureAccount: AzureAccount | undefined) { - this._azureAccount = azureAccount; - } public refresh(): void { this.refreshImages(); @@ -67,7 +60,7 @@ export class DockerExplorerProvider implements vscode.TreeDataProvider this._containersNode = node; rootNodes.push(node); - node = new RootNode('Registries', 'registriesRootNode', this._onDidChangeTreeData, this._azureAccount); + node = new RootNode('Registries', 'registriesRootNode', this._onDidChangeTreeData); this._registriesNode = node; rootNodes.push(node); diff --git a/explorer/models/azureRegistryNodes.ts b/explorer/models/azureRegistryNodes.ts index 3f8aef822b..8e41ff2044 100644 --- a/explorer/models/azureRegistryNodes.ts +++ b/explorer/models/azureRegistryNodes.ts @@ -14,11 +14,12 @@ import { Repository } from '../../utils/Azure/models/repository'; import { getLoginServer, } from '../../utils/nonNull'; import { formatTag } from './commonRegistryUtils'; import { IconPath, NodeBase } from './nodeBase'; +import { TaskRootNode } from './taskNode'; export class AzureRegistryNode extends NodeBase { constructor( public readonly label: string, - public readonly azureAccount: AzureAccount | undefined, + public readonly azureAccount: AzureAccount, public readonly registry: ContainerModels.Registry, public readonly subscription: SubscriptionModels.Subscription ) { @@ -40,8 +41,12 @@ export class AzureRegistryNode extends NodeBase { } } - public async getChildren(element: AzureRegistryNode): Promise { - const repoNodes: AzureRepositoryNode[] = []; + public async getChildren(element: AzureRegistryNode): Promise { + const repoNodes: NodeBase[] = []; + + //Pushing single TaskRootNode under the current registry. All the following nodes added to registryNodes are type AzureRepositoryNode + let taskNode = new TaskRootNode("Tasks", element.azureAccount, element.subscription, element.registry); + repoNodes.push(taskNode); if (!this.azureAccount) { return []; @@ -62,7 +67,6 @@ export class AzureRegistryNode extends NodeBase { return repoNodes; } } - export class AzureRepositoryNode extends NodeBase { constructor( public readonly label: string, @@ -94,7 +98,7 @@ export class AzureRepositoryNode extends NodeBase { public async getChildren(element: AzureRepositoryNode): Promise { const imageNodes: AzureImageTagNode[] = []; let node: AzureImageTagNode; - let repo = new Repository(element.registry, element.label); + let repo = await Repository.Create(element.registry, element.label); let images: AzureImage[] = await getImagesByRepository(repo); for (let img of images) { node = new AzureImageTagNode( @@ -124,12 +128,16 @@ export class AzureImageTagNode extends NodeBase { public readonly tag: string, public readonly created: Date, ) { - super(`${repositoryName}:${tag}`); + super(AzureImageTagNode.getImageNameWithTag(repositoryName, tag)); } public static readonly contextValue: string = 'azureImageTagNode'; public readonly contextValue: string = AzureImageTagNode.contextValue; + public static getImageNameWithTag(repositoryName: string, tag: string): string { + return `${repositoryName}:${tag}`; + } + public getTreeItem(): vscode.TreeItem { return { label: formatTag(this.label, this.created), diff --git a/explorer/models/containerNode.ts b/explorer/models/containerNode.ts index 2761126e33..8896b2e13c 100644 --- a/explorer/models/containerNode.ts +++ b/explorer/models/containerNode.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import { trimWithElipsis } from '../utils/utils'; +import { getImageOrContainerDisplayName } from './getImageOrContainerDisplayName'; import { IconPath, NodeBase } from './nodeBase'; export type ContainerNodeContextValue = 'stoppedLocalContainerNode' | 'runningLocalContainerNode'; @@ -21,14 +22,8 @@ export class ContainerNode extends NodeBase { } public getTreeItem(): vscode.TreeItem { - let displayName: string = this.label; - - if (vscode.workspace.getConfiguration('docker').get('truncateLongRegistryPaths', false)) { - if (/\//.test(displayName)) { - let parts: string[] = this.label.split(/\//); - displayName = trimWithElipsis(parts[0], vscode.workspace.getConfiguration('docker').get('truncateMaxLength', 10)) + '/' + parts[1]; - } - } + let config = vscode.workspace.getConfiguration('docker'); + let displayName: string = getImageOrContainerDisplayName(this.label, config.get('truncateLongRegistryPaths'), config.get('truncateMaxLength')); return { label: `${displayName}`, diff --git a/explorer/models/getImageOrContainerDisplayName.ts b/explorer/models/getImageOrContainerDisplayName.ts new file mode 100644 index 0000000000..9ab49e07a4 --- /dev/null +++ b/explorer/models/getImageOrContainerDisplayName.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { extractRegExGroups } from '../../helpers/extractRegExGroups'; +import { trimWithElipsis } from '../utils/utils'; + +export function getImageOrContainerDisplayName(fullName: string, truncateLongRegistryPaths: boolean, truncateMaxLength: number): string { + if (!truncateLongRegistryPaths) { + return fullName; + } + + // Extra registry from the rest of the name + let [registry, restOfName] = extractRegExGroups(fullName, /^([^\/]+)\/(.*)$/, ['', fullName]); + let trimmedRegistry: string | undefined; + + if (registry) { + registry = trimWithElipsis(registry, truncateMaxLength); + return `${registry}/${restOfName}`; + } + + return fullName; +} diff --git a/explorer/models/imageNode.ts b/explorer/models/imageNode.ts index c87211af96..c363408b3e 100644 --- a/explorer/models/imageNode.ts +++ b/explorer/models/imageNode.ts @@ -7,6 +7,7 @@ import * as moment from 'moment'; import * as path from 'path'; import * as vscode from 'vscode'; import { trimWithElipsis } from '../utils/utils'; +import { getImageOrContainerDisplayName } from './getImageOrContainerDisplayName'; import { NodeBase } from './nodeBase'; export class ImageNode extends NodeBase { @@ -23,14 +24,8 @@ export class ImageNode extends NodeBase { public readonly contextValue: string = ImageNode.contextValue; public getTreeItem(): vscode.TreeItem { - let displayName: string = this.label; - - if (vscode.workspace.getConfiguration('docker').get('truncateLongRegistryPaths', false)) { - if (/\//.test(displayName)) { - let parts: string[] = this.label.split(/\//); - displayName = trimWithElipsis(parts[0], vscode.workspace.getConfiguration('docker').get('truncateMaxLength', 10)) + '/' + parts[1]; - } - } + let config = vscode.workspace.getConfiguration('docker'); + let displayName: string = getImageOrContainerDisplayName(this.label, config.get('truncateLongRegistryPaths'), config.get('truncateMaxLength')); displayName = `${displayName} (${moment(new Date(this.imageDesc.Created * 1000)).fromNow()})`; @@ -45,5 +40,5 @@ export class ImageNode extends NodeBase { } } - // no children + // No children } diff --git a/explorer/models/registryRootNode.ts b/explorer/models/registryRootNode.ts index 39ee0c7720..5b0558db62 100644 --- a/explorer/models/registryRootNode.ts +++ b/explorer/models/registryRootNode.ts @@ -5,6 +5,7 @@ import * as assert from 'assert'; import * as ContainerModels from 'azure-arm-containerregistry/lib/models'; +import * as ContainerOps from 'azure-arm-containerregistry/lib/operations'; import { SubscriptionModels } from 'azure-arm-resource'; import * as vscode from 'vscode'; import { parseError } from 'vscode-azureextensionui'; @@ -27,8 +28,8 @@ export class RegistryRootNode extends NodeBase { constructor( public readonly label: string, public readonly contextValue: 'dockerHubRootNode' | 'azureRegistryRootNode' | 'customRootNode', - public readonly eventEmitter?: vscode.EventEmitter, - public readonly azureAccount?: AzureAccount + public readonly eventEmitter: vscode.EventEmitter | undefined, // Needed only for Azure + public readonly azureAccount: AzureAccount | undefined // Needed only for Azure ) { super(label); @@ -133,14 +134,14 @@ export class RegistryRootNode extends NodeBase { } if (loggedIntoAzure) { - const subscriptions: SubscriptionModels.Subscription[] = AzureUtilityManager.getInstance().getFilteredSubscriptionList(); + const subscriptions: SubscriptionModels.Subscription[] = await AzureUtilityManager.getInstance().getFilteredSubscriptionList(); const subPool = new AsyncPool(MAX_CONCURRENT_SUBSCRIPTON_REQUESTS); let subsAndRegistries: { 'subscription': SubscriptionModels.Subscription, 'registries': ContainerModels.RegistryListResult }[] = []; //Acquire each subscription's data simultaneously for (let sub of subscriptions) { subPool.addTask(async () => { - const client = AzureUtilityManager.getInstance().getContainerRegistryManagementClient(sub); + const client = await AzureUtilityManager.getInstance().getContainerRegistryManagementClient(sub); try { let regs: ContainerModels.Registry[] = await client.registries.list(); subsAndRegistries.push({ @@ -150,6 +151,7 @@ export class RegistryRootNode extends NodeBase { } catch (error) { vscode.window.showErrorMessage(parseError(error).message); } + }); } await subPool.runAll(); diff --git a/explorer/models/rootNode.ts b/explorer/models/rootNode.ts index 6312f58104..915d121334 100644 --- a/explorer/models/rootNode.ts +++ b/explorer/models/rootNode.ts @@ -7,6 +7,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { docker } from '../../commands/utils/docker-endpoint'; import { AzureAccount } from '../../typings/azure-account.api'; +import { AzureUtilityManager } from '../../utils/azureUtilityManager'; import { ContainerNode, ContainerNodeContextValue } from './containerNode'; import { ImageNode } from './imageNode'; import { IconPath, NodeBase } from './nodeBase'; @@ -31,13 +32,11 @@ export class RootNode extends NodeBase { private _containerCache: Docker.ContainerDesc[] | undefined; private _containerDebounceTimer: NodeJS.Timer | undefined; private _containersNode: RootNode | undefined; - private _azureAccount: AzureAccount | undefined; constructor( public readonly label: string, public readonly contextValue: 'imagesRootNode' | 'containersRootNode' | 'registriesRootNode', - public eventEmitter: vscode.EventEmitter, - public azureAccount?: AzureAccount + public eventEmitter: vscode.EventEmitter ) { super(label); if (this.contextValue === 'imagesRootNode') { @@ -45,7 +44,6 @@ export class RootNode extends NodeBase { } else if (this.contextValue === 'containersRootNode') { this._containersNode = this; } - this._azureAccount = azureAccount; } public autoRefreshImages(): void { @@ -104,19 +102,21 @@ export class RootNode extends NodeBase { } - public async getChildren(element: NodeBase): Promise { - - if (element.contextValue === 'imagesRootNode') { - return this.getImages(); - } - if (element.contextValue === 'containersRootNode') { - return this.getContainers(); - } - if (element.contextValue === 'registriesRootNode') { - return this.getRegistries() + public async getChildren(element: RootNode): Promise { + switch (element.contextValue) { + case 'imagesRootNode': { + return this.getImages(); + } + case 'containersRootNode': { + return this.getContainers(); + } + case 'registriesRootNode': { + return this.getRegistries(); + } + default: { + throw new Error(`Unexpected contextValue ${element.contextValue}`); + } } - - throw new Error(`Unexpected contextValue ${element.contextValue}`); } private async getImages(): Promise { @@ -129,17 +129,15 @@ export class RootNode extends NodeBase { return []; } - // tslint:disable-next-line:prefer-for-of // Grandfathered in - for (let i = 0; i < images.length; i++) { - // tslint:disable-next-line:prefer-for-of // Grandfathered in - if (!images[i].RepoTags) { - let node = new ImageNode(`:`, images[i], this.eventEmitter); + for (let image of images) { + if (!image.RepoTags) { + let node = new ImageNode(`:`, image, this.eventEmitter); + node.imageDesc = image; imageNodes.push(node); } else { - // tslint:disable-next-line:prefer-for-of // Grandfathered in - for (let j = 0; j < images[i].RepoTags.length; j++) { - // tslint:disable-next-line:prefer-for-of // Grandfathered in - let node = new ImageNode(`${images[i].RepoTags[j]}`, images[i], this.eventEmitter); + for (let repoTag of image.RepoTags) { + let node = new ImageNode(`${repoTag}`, image, this.eventEmitter); + node.imageDesc = image; imageNodes.push(node); } } @@ -183,15 +181,13 @@ export class RootNode extends NodeBase { if (this._containerCache.length !== containers.length) { needToRefresh = true; } else { - // tslint:disable-next-line:prefer-for-of // Grandfathered in - for (let i = 0; i < this._containerCache.length; i++) { - let ctr: Docker.ContainerDesc = this._containerCache[i]; - // tslint:disable-next-line:prefer-for-of // Grandfathered in - for (let j = 0; j < containers.length; j++) { + for (let cachedContainer of this._containerCache) { + let ctr: Docker.ContainerDesc = cachedContainer; + for (let cont of containers) { // can't do a full object compare because "Status" keeps changing for running containers - if (ctr.Id === containers[j].Id && - ctr.Image === containers[j].Image && - ctr.State === containers[j].State) { + if (ctr.Id === cont.Id && + ctr.Image === cont.Image && + ctr.State === cont.State) { found = true; break; } @@ -227,9 +223,8 @@ export class RootNode extends NodeBase { return []; } - // tslint:disable-next-line:prefer-for-of // Grandfathered in - for (let i = 0; i < containers.length; i++) { - if (['exited', 'dead'].includes(containers[i].State)) { + for (let container of containers) { + if (['exited', 'dead'].includes(container.State)) { contextValue = "stoppedLocalContainerNode"; iconPath = { light: path.join(__filename, '..', '..', '..', '..', 'images', 'light', 'stoppedContainer.svg'), @@ -243,7 +238,7 @@ export class RootNode extends NodeBase { }; } - let containerNode: ContainerNode = new ContainerNode(`${containers[i].Image} (${containers[i].Names[0].substring(1)}) (${containers[i].Status})`, containers[i], contextValue, iconPath); + let containerNode: ContainerNode = new ContainerNode(`${container.Image} (${container.Names[0].substring(1)}) (${container.Status})`, container, contextValue, iconPath); containerNodes.push(containerNode); } @@ -260,13 +255,14 @@ export class RootNode extends NodeBase { private async getRegistries(): Promise { const registryRootNodes: RegistryRootNode[] = []; - registryRootNodes.push(new RegistryRootNode('Docker Hub', "dockerHubRootNode")); + registryRootNodes.push(new RegistryRootNode('Docker Hub', "dockerHubRootNode", undefined, undefined)); - if (this._azureAccount) { - registryRootNodes.push(new RegistryRootNode('Azure', "azureRegistryRootNode", this.eventEmitter, this._azureAccount)); + let azureAccount: AzureAccount = await AzureUtilityManager.getInstance().tryGetAzureAccount(); + if (azureAccount) { + registryRootNodes.push(new RegistryRootNode('Azure', "azureRegistryRootNode", this.eventEmitter, azureAccount)); } - registryRootNodes.push(new RegistryRootNode('Private Registries', 'customRootNode')); + registryRootNodes.push(new RegistryRootNode('Private Registries', 'customRootNode', undefined, undefined)); return registryRootNodes; } diff --git a/explorer/models/taskNode.ts b/explorer/models/taskNode.ts new file mode 100644 index 0000000000..f0cb25ef2a --- /dev/null +++ b/explorer/models/taskNode.ts @@ -0,0 +1,88 @@ +import ContainerRegistryManagementClient from 'azure-arm-containerregistry'; +import * as ContainerModels from 'azure-arm-containerregistry/lib/models'; +import { SubscriptionModels } from 'azure-arm-resource'; +import * as opn from 'opn'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { AzureAccount } from '../../typings/azure-account.api'; +import * as acrTools from '../../utils/Azure/acrTools'; +import { AzureUtilityManager } from '../../utils/azureUtilityManager'; +import { NodeBase } from './nodeBase'; +/* Single TaskRootNode under each Repository. Labeled "Tasks" */ +export class TaskRootNode extends NodeBase { + public static readonly contextValue: string = 'taskRootNode'; + private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; + constructor( + public readonly label: string, + public readonly azureAccount: AzureAccount, + public readonly subscription: SubscriptionModels.Subscription, + public readonly registry: ContainerModels.Registry, + //public readonly iconPath: any = null, + ) { + super(label); + } + + public readonly contextValue: string = 'taskRootNode'; + public name: string; + public readonly iconPath: { light: string | vscode.Uri; dark: string | vscode.Uri } = { + light: path.join(__filename, '..', '..', '..', '..', 'images', 'light', 'tasks_light.svg'), + dark: path.join(__filename, '..', '..', '..', '..', 'images', 'dark', 'tasks_dark.svg') + }; + + public getTreeItem(): vscode.TreeItem { + return { + label: this.label, + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + contextValue: TaskRootNode.contextValue, + iconPath: this.iconPath + } + } + + /* Making a list view of TaskNodes, or the Tasks of the current registry */ + public async getChildren(element: TaskRootNode): Promise { + const taskNodes: TaskNode[] = []; + let tasks: ContainerModels.Task[] = []; + const client: ContainerRegistryManagementClient = await AzureUtilityManager.getInstance().getContainerRegistryManagementClient(element.subscription); + const resourceGroup: string = acrTools.getResourceGroupName(element.registry); + tasks = await client.tasks.list(resourceGroup, element.registry.name); + if (tasks.length === 0) { + vscode.window.showInformationMessage(`You do not have any Tasks in the registry '${element.registry.name}'.`, "Learn How to Create Build Tasks").then(val => { + if (val === "Learn More") { + // tslint:disable-next-line:no-unsafe-any + opn('https://aka.ms/acr/task'); + } + }) + } + + for (let task of tasks) { + let node = new TaskNode(task, element.registry, element.subscription, element); + taskNodes.push(node); + } + return taskNodes; + } +} +export class TaskNode extends NodeBase { + constructor( + public task: ContainerModels.Task, + public registry: ContainerModels.Registry, + + public subscription: SubscriptionModels.Subscription, + public parent: NodeBase + + ) { + super(task.name); + } + + public label: string; + public readonly contextValue: string = 'taskNode'; + + public getTreeItem(): vscode.TreeItem { + return { + label: this.label, + collapsibleState: vscode.TreeItemCollapsibleState.None, + contextValue: this.contextValue, + iconPath: null + } + } +} diff --git a/explorer/utils/browseAzurePortal.ts b/explorer/utils/browseAzurePortal.ts index dfb9480b39..063e815ce8 100644 --- a/explorer/utils/browseAzurePortal.ts +++ b/explorer/utils/browseAzurePortal.ts @@ -9,7 +9,6 @@ import { getTenantId, nonNullValue } from '../../utils/nonNull'; import { AzureImageTagNode, AzureRegistryNode, AzureRepositoryNode } from '../models/azureRegistryNodes'; export function browseAzurePortal(node?: AzureRegistryNode | AzureRepositoryNode | AzureImageTagNode): void { - if (node && node.azureAccount) { const tenantId: string = getTenantId(node.subscription); const session: AzureSession = nonNullValue( @@ -22,5 +21,4 @@ export function browseAzurePortal(node?: AzureRegistryNode | AzureRepositoryNode // tslint:disable-next-line:no-unsafe-any opn(url); } - } diff --git a/gulpfile.js b/gulpfile.js index 4a1c7668e3..d050bdb748 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -1,54 +1,48 @@ /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See LICENSE.md in the project root for license information. + * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ const gulp = require('gulp'); +const decompress = require('gulp-decompress'); +const download = require('gulp-download'); const path = require('path'); -const azureStorage = require('azure-storage'); -const vsce = require('vsce'); -const packageJson = require('./package.json'); +const os = require('os'); +const cp = require('child_process'); +const glob = require('glob'); -const brightYellowFormatting = '\x1b[33m\x1b[1m%s\x1b[0m'; -const brightWhiteFormatting = '\x1b[1m%s\x1b[0m'; - -gulp.task('package', async () => { - await vsce.createVSIX(); +gulp.task('test', ['install-azure-account'], (cb) => { + const env = process.env; + env.DEBUGTELEMETRY = 1; + env.CODE_TESTS_WORKSPACE = './test/test.code-workspace'; + env.MOCHA_reporter = 'mocha-junit-reporter'; + env.MOCHA_FILE = path.join(__dirname, 'test-results.xml'); + const cmd = cp.spawn('node', ['./node_modules/vscode/bin/test'], { stdio: 'inherit', env }); + cmd.on('close', (code) => { + cb(code); + }); }); -gulp.task('upload-vsix', (callback) => { - if (process.env.TRAVIS_PULL_REQUEST_BRANCH) { - console.log('Skipping upload-vsix for PR build.'); - } else { - const containerName = packageJson.name; - const vsixName = `${packageJson.name}-${packageJson.version}.vsix`; - const blobPath = path.join(process.env.TRAVIS_BRANCH, process.env.TRAVIS_BUILD_NUMBER, vsixName); - const storageName = process.env.STORAGE_NAME; - const storageKey = process.env.STORAGE_KEY; - if (!storageName || !storageKey) { - console.log(); - console.log(brightYellowFormatting, '======== Skipping upload of VSIX to storage account because STORAGE_NAME and STORAGE_KEY have not been set'); - } else { - const blobService = azureStorage.createBlobService(process.env.STORAGE_NAME, process.env.STORAGE_KEY); - blobService.createContainerIfNotExists(containerName, { publicAccessLevel: "blob" }, (err) => { - if (err) { - callback(err); - } else { - blobService.createBlockBlobFromLocalFile(containerName, blobPath, vsixName, (err) => { - if (err) { - callback(err); - } else { - console.log(); - console.log(brightYellowFormatting, '================================================ vsix url ================================================'); - console.log(); - console.log(brightWhiteFormatting, blobService.getUrl(containerName, blobPath)); - console.log(); - console.log(brightYellowFormatting, '=========================================================================================================='); - console.log(); - } - }); +/** + * Installs the azure account extension before running tests (otherwise our extension would fail to activate) + * NOTE: The version isn't super important since we don't actually use the account extension in tests + */ +gulp.task('install-azure-account', () => { + const version = '0.4.3'; + const extensionPath = path.join(os.homedir(), `.vscode/extensions/ms-vscode.azure-account-${version}`); + const existingExtensions = glob.sync(extensionPath.replace(version, '*')); + if (existingExtensions.length === 0) { + return download(`http://ms-vscode.gallery.vsassets.io/_apis/public/gallery/publisher/ms-vscode/extension/azure-account/${version}/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage`) + .pipe(decompress({ + filter: file => file.path.startsWith('extension/'), + map: file => { + file.path = file.path.slice(10); + return file; } - }); - } + })) + .pipe(gulp.dest(extensionPath)); + } else { + console.log('Azure Account extension already installed.'); } }); + diff --git a/helpers/suggestSupportHelper.ts b/helpers/suggestSupportHelper.ts index 74396c602c..37ed945179 100644 --- a/helpers/suggestSupportHelper.ts +++ b/helpers/suggestSupportHelper.ts @@ -7,7 +7,7 @@ import vscode = require('vscode'); import { FROM_DIRECTIVE_PATTERN } from "../dockerExtension"; -import hub = require('../dockerHubApi'); +import hub = require('../dockerHubSearch'); import parser = require('../parser'); export class SuggestSupportHelper { diff --git a/images/dark/tasks_dark.svg b/images/dark/tasks_dark.svg new file mode 100644 index 0000000000..618310ec7c --- /dev/null +++ b/images/dark/tasks_dark.svg @@ -0,0 +1 @@ +Manufacture_16x \ No newline at end of file diff --git a/images/dockerSharedFolders.png b/images/dockerSharedFolders.png new file mode 100644 index 0000000000..284fa44009 Binary files /dev/null and b/images/dockerSharedFolders.png differ diff --git a/images/light/tasks_light.svg b/images/light/tasks_light.svg new file mode 100644 index 0000000000..27f50db2ce --- /dev/null +++ b/images/light/tasks_light.svg @@ -0,0 +1 @@ +Manufacture_16x \ No newline at end of file diff --git a/package.json b/package.json index 16e36630af..6db69af78a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vscode-docker", - "version": "0.3.0", + "version": "0.3.2-alpha", "publisher": "PeterJausovec", "displayName": "Docker", "description": "Adds syntax highlighting, commands, hover tips, and linting for Dockerfile and docker-compose files.", @@ -23,49 +23,61 @@ "multi-root ready" ], "repository": { - "url": "https://github.com/microsoft/vscode-docker.git" + "type": "git", + "url": "https://github.com/microsoft/vscode-docker" }, "homepage": "https://github.com/Microsoft/vscode-docker/blob/master/README.md", "activationEvents": [ - "onLanguage:dockerfile", - "onLanguage:yaml", + "onCommand:vscode-docker.acr.createRegistry", + "onCommand:vscode-docker.acr.deleteImage", + "onCommand:vscode-docker.acr.deleteRegistry", + "onCommand:vscode-docker.acr.deleteRepository", + "onCommand:vscode-docker.acr.pullImage", + "onCommand:vscode-docker.acr.quickBuild", + "onCommand:vscode-docker.acr.runTask", + "onCommand:vscode-docker.acr.showTask", + "onCommand:vscode-docker.acr.viewLogs", "onCommand:vscode-docker.api.configure", - "onCommand:vscode-docker.image.build", - "onCommand:vscode-docker.image.inspect", - "onCommand:vscode-docker.image.remove", - "onCommand:vscode-docker.image.push", - "onCommand:vscode-docker.image.tag", - "onCommand:vscode-docker.container.start", - "onCommand:vscode-docker.container.start.interactive", - "onCommand:vscode-docker.container.start.azurecli", - "onCommand:vscode-docker.container.stop", - "onCommand:vscode-docker.container.restart", - "onCommand:vscode-docker.container.show-logs", - "onCommand:vscode-docker.container.open-shell", - "onCommand:vscode-docker.compose.up", + "onCommand:vscode-docker.browseAzurePortal", + "onCommand:vscode-docker.browseDockerHub", "onCommand:vscode-docker.compose.down", "onCommand:vscode-docker.compose.restart", + "onCommand:vscode-docker.compose.up", "onCommand:vscode-docker.configure", + "onCommand:vscode-docker.connectCustomRegistry", + "onCommand:vscode-docker.container.open-shell", + "onCommand:vscode-docker.container.remove", + "onCommand:vscode-docker.container.restart", + "onCommand:vscode-docker.container.show-logs", + "onCommand:vscode-docker.container.start", + "onCommand:vscode-docker.container.start.azurecli", + "onCommand:vscode-docker.container.start.interactive", + "onCommand:vscode-docker.container.stop", "onCommand:vscode-docker.createWebApp", - "onCommand:vscode-docker.create-ACR-Registry", - "onCommand:vscode-docker.system.prune", + "onCommand:vscode-docker.disconnectCustomRegistry", "onCommand:vscode-docker.dockerHubLogout", - "onCommand:vscode-docker.browseDockerHub", - "onCommand:vscode-docker.browseAzurePortal", "onCommand:vscode-docker.explorer.refresh", - "onCommand:vscode-docker.delete-ACR-Registry", - "onCommand:vscode-docker.delete-ACR-Repository", - "onCommand:vscode-docker.delete-ACR-Image", - "onCommand:vscode-docker.connectCustomRegistry", + "onCommand:vscode-docker.image.build", + "onCommand:vscode-docker.image.inspect", + "onCommand:vscode-docker.image.push", + "onCommand:vscode-docker.image.remove", + "onCommand:vscode-docker.image.tag", "onCommand:vscode-docker.setRegistryAsDefault", - "onCommand:vscode-docker.disconnectCustomRegistry", - "onView:dockerExplorer", - "onDebugInitialConfigurations" + "onCommand:vscode-docker.system.prune", + "onDebugInitialConfigurations", + "onDebugResolve:docker-coreclr", + "onLanguage:dockerfile", + "onLanguage:yaml", + "onView:dockerExplorer" ], "main": "./out/dockerExtension", "contributes": { "menus": { "commandPalette": [ + { + "command": "vscode-docker.api.configure", + "when": "never" + }, { "command": "vscode-docker.browseDockerHub", "when": "false" @@ -73,21 +85,12 @@ { "command": "vscode-docker.createWebApp", "when": "false" - }, - { - "command": "vscode-docker.api.configure", - "when": "never" } ], "editor/context": [ { - "when": "editorLangId == dockerfile", - "command": "vscode-docker.image.build", - "group": "docker" - }, - { - "when": "resourceFilename == docker-compose.yml", - "command": "vscode-docker.compose.up", + "when": "editorLangId == dockerfile && isAzureAccountInstalled", + "command": "vscode-docker.acr.quickBuild", "group": "docker" }, { @@ -101,7 +104,7 @@ "group": "docker" }, { - "when": "resourceFilename == docker-compose.debug.yml", + "when": "resourceFilename == docker-compose.yml", "command": "vscode-docker.compose.up", "group": "docker" }, @@ -114,27 +117,42 @@ "when": "resourceFilename == docker-compose.debug.yml", "command": "vscode-docker.compose.restart", "group": "docker" + }, + { + "when": "resourceFilename == docker-compose.debug.yml", + "command": "vscode-docker.compose.up", + "group": "docker" + }, + { + "when": "editorLangId == dockerfile", + "command": "vscode-docker.image.build", + "group": "docker" } ], "explorer/context": [ { "when": "resourceFilename =~ /[dD]ocker[fF]ile/", - "command": "vscode-docker.image.build", + "command": "vscode-docker.acr.quickBuild", "group": "docker" }, { "when": "resourceFilename =~ /[dD]ocker-[cC]ompose/", - "command": "vscode-docker.compose.up", + "command": "vscode-docker.compose.down", "group": "docker" }, { "when": "resourceFilename =~ /[dD]ocker-[cC]ompose/", - "command": "vscode-docker.compose.down", + "command": "vscode-docker.compose.restart", "group": "docker" }, { "when": "resourceFilename =~ /[dD]ocker-[cC]ompose/", - "command": "vscode-docker.compose.restart", + "command": "vscode-docker.compose.up", + "group": "docker" + }, + { + "when": "resourceFilename =~ /[dD]ocker[fF]ile/", + "command": "vscode-docker.image.build", "group": "docker" } ], @@ -152,33 +170,57 @@ ], "view/item/context": [ { - "command": "vscode-docker.container.start", - "when": "view == dockerExplorer && viewItem =~ /^(localImageNode|imagesRootNode)$/" + "command": "vscode-docker.acr.createRegistry", + "when": "view == dockerExplorer && viewItem == azureRegistryRootNode" }, { - "command": "vscode-docker.container.start.interactive", - "when": "view == dockerExplorer && viewItem =~ /^(localImageNode|imagesRootNode)$/" + "command": "vscode-docker.acr.deleteImage", + "when": "view == dockerExplorer && viewItem == azureImageTagNode" }, { - "command": "vscode-docker.image.push", - "when": "view == dockerExplorer && viewItem =~ /^(localImageNode|imagesRootNode)$/" + "command": "vscode-docker.acr.deleteRegistry", + "when": "view == dockerExplorer && viewItem == azureRegistryNode" }, { - "command": "vscode-docker.image.remove", - "when": "view == dockerExplorer && viewItem =~ /^(localImageNode|imagesRootNode)$/" + "command": "vscode-docker.acr.deleteRepository", + "when": "view == dockerExplorer && viewItem == azureRepositoryNode" }, { - "command": "vscode-docker.image.inspect", - "when": "view == dockerExplorer && viewItem =~ /^(localImageNode|imagesRootNode)$/" + "command": "vscode-docker.acr.pullImage", + "when": "view == dockerExplorer && viewItem == azureImageNode" }, { - "command": "vscode-docker.image.tag", - "when": "view == dockerExplorer && viewItem =~ /^(localImageNode|imagesRootNode)$/" + "command": "vscode-docker.acr.runTask", + "when": "view == dockerExplorer && viewItem == taskNode" }, { - "command": "vscode-docker.container.stop", + "command": "vscode-docker.acr.showTask", + "when": "view == dockerExplorer && viewItem == taskNode" + }, + { + "command": "vscode-docker.acr.viewLogs", + "when": "view == dockerExplorer && viewItem =~ /^(azureRegistryNode|azureImageTagNode|taskNode)$/" + }, + { + "command": "vscode-docker.browseDockerHub", + "when": "view == dockerExplorer && viewItem =~ /^(dockerHubImageTagNode|dockerHubRepositoryNode|dockerHubOrgNode)$/" + }, + { + "command": "vscode-docker.browseAzurePortal", + "when": "view == dockerExplorer && viewItem =~ /^(azureRegistryNode|azureRepositoryNode|azureImageNode)$/" + }, + { + "command": "vscode-docker.connectCustomRegistry", + "when": "view == dockerExplorer && viewItem == customRootNode" + }, + { + "command": "vscode-docker.container.open-shell", "when": "view == dockerExplorer && viewItem =~ /^(runningLocalContainerNode|containersRootNode)$/" }, + { + "command": "vscode-docker.container.remove", + "when": "view == dockerExplorer && viewItem =~ /^(stoppedLocalContainerNode|runningLocalContainerNode|containersRootNode)$/" + }, { "command": "vscode-docker.container.restart", "when": "view == dockerExplorer && viewItem =~ /^(runningLocalContainerNode|stoppedLocalContainerNode|containersRootNode)$/" @@ -188,63 +230,55 @@ "when": "view == dockerExplorer && viewItem =~ /^(runningLocalContainerNode|stoppedLocalContainerNode|containersRootNode)$/" }, { - "command": "vscode-docker.container.open-shell", - "when": "view == dockerExplorer && viewItem =~ /^(runningLocalContainerNode|containersRootNode)$/" + "command": "vscode-docker.container.start", + "when": "view == dockerExplorer && viewItem =~ /^(localImageNode|imagesRootNode)$/" }, { - "command": "vscode-docker.container.remove", - "when": "view == dockerExplorer && viewItem =~ /^(stoppedLocalContainerNode|runningLocalContainerNode|containersRootNode)$/" + "command": "vscode-docker.container.start.interactive", + "when": "view == dockerExplorer && viewItem =~ /^(localImageNode|imagesRootNode)$/" + }, + { + "command": "vscode-docker.container.stop", + "when": "view == dockerExplorer && viewItem =~ /^(runningLocalContainerNode|containersRootNode)$/" }, { "command": "vscode-docker.createWebApp", "when": "view == dockerExplorer && viewItem =~ /^(azureImageTagNode|dockerHubImageTagNode|customImageTagNode)$/" }, { - "command": "vscode-docker.create-ACR-Registry", - "when": "view == dockerExplorer && viewItem == azureRegistryRootNode" + "command": "vscode-docker.disconnectCustomRegistry", + "when": "view == dockerExplorer && viewItem =~ /^(customRegistryNode)$/" }, { "command": "vscode-docker.dockerHubLogout", "when": "view == dockerExplorer && viewItem == dockerHubRootNode" }, { - "command": "vscode-docker.delete-ACR-Repository", - "when": "view == dockerExplorer && viewItem == azureRepositoryNode" - }, - { - "command": "vscode-docker.delete-ACR-Image", - "when": "view == dockerExplorer && viewItem == azureImageTagNode" - }, - { - "command": "vscode-docker.delete-ACR-Registry", - "when": "view == dockerExplorer && viewItem == azureRegistryNode" + "command": "vscode-docker.image.inspect", + "when": "view == dockerExplorer && viewItem =~ /^(localImageNode|imagesRootNode)$/" }, { - "command": "vscode-docker.browseDockerHub", - "when": "view == dockerExplorer && viewItem =~ /^(dockerHubImageTagNode|dockerHubRepositoryNode|dockerHubOrgNode)$/" + "command": "vscode-docker.image.push", + "when": "view == dockerExplorer && viewItem =~ /^(localImageNode|imagesRootNode)$/" }, { - "command": "vscode-docker.browseAzurePortal", - "when": "view == dockerExplorer && viewItem =~ /^(azureRegistryNode|azureRepositoryNode|azureImageTagNode)$/" + "command": "vscode-docker.image.remove", + "when": "view == dockerExplorer && viewItem =~ /^(localImageNode|imagesRootNode)$/" }, { - "command": "vscode-docker.connectCustomRegistry", - "when": "view == dockerExplorer && viewItem == customRootNode" + "command": "vscode-docker.image.tag", + "when": "view == dockerExplorer && viewItem =~ /^(localImageNode|imagesRootNode)$/" }, { "command": "vscode-docker.setRegistryAsDefault", "when": "view == dockerExplorer && viewItem =~ /^(customRegistryNode|azureRegistryNode|dockerHubOrgNode)$/" - }, - { - "command": "vscode-docker.disconnectCustomRegistry", - "when": "view == dockerExplorer && viewItem =~ /^(customRegistryNode)$/" } ] }, "debuggers": [ { "type": "docker", - "label": "Docker", + "label": "Docker: Node.js", "configurationSnippets": [ { "label": "Docker: Attach to Node", @@ -261,6 +295,107 @@ } } ] + }, + { + "type": "docker-coreclr", + "label": "Docker: Launch .NET Core (Preview)", + "configurationSnippets": [ + { + "label": "Docker: Launch .NET Core (Preview)", + "description": "Docker: Launch .NET Core (Preview)", + "body": { + "name": "Docker: Launch .NET Core (Preview)", + "type": "docker-coreclr", + "request": "launch", + "preLaunchTask": "build", + "dockerBuild": {}, + "dockerRun": {} + } + } + ], + "configurationAttributes": { + "launch": { + "properties": { + "appFolder": { + "type": "string", + "description": "Path to the folder for the application." + }, + "appOutput": { + "type": "string", + "description": "Path to the output assembly for the application." + }, + "appProject": { + "type": "string", + "description": "Path to the application project file." + }, + "dockerBuild": { + "description": "Options for building the Docker image used for debugging.", + "properties": { + "args": { + "type": "object", + "description": "Build arguments applied to the Docker image used for debugging.", + "additionalProperties": { + "type": "string" + } + }, + "context": { + "type": "string", + "description": "Path to the Docker build context." + }, + "dockerfile": { + "type": "string", + "description": "Path to the Dockerfile used for the build." + }, + "labels": { + "type": "object", + "description": "Labels applied to the Docker image used for debugging.", + "additionalProperties": { + "type": "string" + } + }, + "tag": { + "type": "string", + "description": "Tag applied to the Docker image used for debugging." + }, + "target": { + "type": "string", + "description": "Docker build target (stage) used for debugging." + } + } + }, + "dockerRun": { + "description": "Options for running the Docker container used for debugging.", + "properties": { + "containerName": { + "type": "string", + "description": "Name of the container used for debugging." + }, + "env": { + "type": "object", + "description": "Environment variables applied to the Docker container used for debugging.", + "additionalProperties": { + "type": "string" + } + }, + "envFiles": { + "type": "array", + "description": "Files of environment variables read in and applied to the Docker container used for debugging.", + "items": { + "type": "string" + } + }, + "labels": { + "type": "object", + "description": "Labels applied to the Docker container used for debugging.", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + } } ], "languages": [ @@ -273,6 +408,12 @@ "*.dockerfile", "Dockerfile" ] + }, + { + "id": "ignore", + "filenames": [ + ".dockerignore" + ] } ], "configuration": { @@ -415,7 +556,7 @@ }, "docker.attachShellCommand.linuxContainer": { "type": "string", - "default": "/bin/sh", + "default": "/bin/bash", "description": "Attach command to use for Linux containers" }, "docker.attachShellCommand.windowsContainer": { @@ -442,85 +583,75 @@ }, "commands": [ { - "command": "vscode-docker.configure", - "title": "Add Docker files to workspace", - "description": "Add Dockerfile, docker-compose.yml files", + "command": "vscode-docker.acr.createRegistry", + "title": "Create Azure Registry", "category": "Docker" }, { - "command": "vscode-docker.api.configure", - "title": "Add Docker files to Workspace (API)" + "command": "vscode-docker.acr.deleteImage", + "title": "Delete Azure Image", + "category": "Docker" }, { - "command": "vscode-docker.image.build", - "title": "Build Image", - "description": "Build a Docker image from a Dockerfile", + "command": "vscode-docker.acr.deleteRegistry", + "title": "Delete Azure Registry", "category": "Docker" }, { - "command": "vscode-docker.image.inspect", - "title": "Inspect Image", - "description": "Inspect the metadata of a Docker image", + "command": "vscode-docker.acr.deleteRepository", + "title": "Delete Azure Repository", "category": "Docker" }, { - "command": "vscode-docker.image.remove", - "title": "Remove Image", - "description": "Remove a Docker image", + "command": "vscode-docker.acr.pullImage", + "title": "Pull Image from Azure", "category": "Docker" }, { - "command": "vscode-docker.image.tag", - "title": "Tag Image", - "description": "Tag a Docker image", + "command": "vscode-docker.acr.quickBuild", + "title": "ACR Tasks: Build Image", + "description": "Queue an Azure build from a Dockerfile", "category": "Docker" }, { - "command": "vscode-docker.container.start", - "title": "Run", - "description": "Starts a container from an image", + "command": "vscode-docker.acr.runTask", + "title": "Run Task", "category": "Docker" }, { - "command": "vscode-docker.container.start.interactive", - "title": "Run Interactive", - "description": "Starts a container from an image and runs it interactively", + "command": "vscode-docker.acr.showTask", + "title": "Show Task Properties", "category": "Docker" }, { - "command": "vscode-docker.container.start.azurecli", - "title": "Azure CLI", - "description": "Starts a container from the Azure CLI image and runs it interactively", + "command": "vscode-docker.acr.viewLogs", + "title": "View Azure Logs", "category": "Docker" }, { - "command": "vscode-docker.container.stop", - "title": "Stop Container", - "description": "Stop a running container", - "category": "Docker" + "command": "vscode-docker.api.configure", + "title": "Add Docker Files to Workspace (API)" }, { - "command": "vscode-docker.container.restart", - "title": "Restart Container", - "description": "Restart one or more containers", + "command": "vscode-docker.browseDockerHub", + "title": "Browse in Docker Hub", "category": "Docker" }, { - "command": "vscode-docker.container.remove", - "title": "Remove Container", - "description": "Remove a stopped container", + "command": "vscode-docker.browseAzurePortal", + "title": "Browse in the Azure Portal", "category": "Docker" }, { - "command": "vscode-docker.container.show-logs", - "title": "Show Logs", - "description": "Show the logs of a running container", + "command": "vscode-docker.compose.down", + "title": "Compose Down", + "description": "Stops a composition of containers", "category": "Docker" }, { - "command": "vscode-docker.container.open-shell", - "title": "Attach Shell", - "description": "Open a terminal with an interactive shell for a running container", + "command": "vscode-docker.compose.restart", + "title": "Compose Restart", + "description": "Restarts a composition of containers", "category": "Docker" }, { @@ -530,84 +661,116 @@ "category": "Docker" }, { - "command": "vscode-docker.compose.down", - "title": "Compose Down", - "description": "Stops a composition of containers", + "command": "vscode-docker.configure", + "title": "Add Docker Files to Workspace", + "description": "Add Dockerfile, docker-compose.yml files", "category": "Docker" }, { - "command": "vscode-docker.compose.restart", - "title": "Compose Restart", - "description": "Restarts a composition of containers", + "command": "vscode-docker.connectCustomRegistry", + "title": "Connect to a Private Registry... (Preview)", "category": "Docker" }, { - "command": "vscode-docker.create-ACR-Registry", - "title": "Create Azure Registry", + "command": "vscode-docker.container.open-shell", + "title": "Attach Shell", + "description": "Open a terminal with an interactive shell for a running container", "category": "Docker" }, { - "command": "vscode-docker.delete-ACR-Repository", - "title": "Delete Azure Repository", + "command": "vscode-docker.container.remove", + "title": "Remove Container", + "description": "Remove a stopped container", "category": "Docker" }, { - "command": "vscode-docker.image.push", - "title": "Push", - "description": "Push an image to a registry", + "command": "vscode-docker.container.restart", + "title": "Restart Container", + "description": "Restart one or more containers", "category": "Docker" }, { - "command": "vscode-docker.system.prune", - "title": "System Prune", - "category": "Docker", - "icon": { - "light": "images/light/prune.svg", - "dark": "images/dark/prune.svg" - } + "command": "vscode-docker.container.show-logs", + "title": "Show Logs", + "description": "Show the logs of a running container", + "category": "Docker" }, { - "command": "vscode-docker.explorer.refresh", - "title": "Refresh Explorer", - "category": "Docker", - "icon": { - "light": "images/light/refresh.svg", - "dark": "images/dark/refresh.svg" - } + "command": "vscode-docker.container.start", + "title": "Run", + "description": "Starts a container from an image", + "category": "Docker" + }, + { + "command": "vscode-docker.container.start.azurecli", + "title": "Azure CLI", + "description": "Starts a container from the Azure CLI image and runs it interactively", + "category": "Docker" + }, + { + "command": "vscode-docker.container.start.interactive", + "title": "Run Interactive", + "description": "Starts a container from an image and runs it interactively", + "category": "Docker" + }, + { + "command": "vscode-docker.container.stop", + "title": "Stop Container", + "description": "Stop a running container", + "category": "Docker" }, { "command": "vscode-docker.createWebApp", "title": "Deploy Image to Azure App Service", "category": "Docker" }, + { + "command": "vscode-docker.disconnectCustomRegistry", + "title": "Disconnect from Private Registry", + "category": "Docker" + }, { "command": "vscode-docker.dockerHubLogout", "title": "Docker Hub Logout", "category": "Docker" }, { - "command": "vscode-docker.browseDockerHub", - "title": "Browse in Docker Hub", + "command": "vscode-docker.explorer.refresh", + "title": "Refresh Explorer", + "category": "Docker", + "icon": { + "light": "images/light/refresh.svg", + "dark": "images/dark/refresh.svg" + } + }, + { + "command": "vscode-docker.image.build", + "title": "Build Image", + "description": "Build a Docker image from a Dockerfile", "category": "Docker" }, { - "command": "vscode-docker.browseAzurePortal", - "title": "Browse in the Azure Portal", + "command": "vscode-docker.image.inspect", + "title": "Inspect Image", + "description": "Inspect the metadata of a Docker image", "category": "Docker" }, { - "command": "vscode-docker.delete-ACR-Registry", - "title": "Delete Azure Registry", + "command": "vscode-docker.image.push", + "title": "Push", + "description": "Push an image to a registry", "category": "Docker" }, { - "command": "vscode-docker.delete-ACR-Image", - "title": "Delete Azure Image", + "command": "vscode-docker.image.remove", + "title": "Remove Image", + "description": "Remove a Docker image", "category": "Docker" }, { - "command": "vscode-docker.connectCustomRegistry", - "title": "Connect to a Private Registry... (Preview)", + "command": "vscode-docker.image.tag", + "title": "Tag Image", + "description": "Tag a Docker image", "category": "Docker" }, { @@ -616,9 +779,13 @@ "category": "Docker" }, { - "command": "vscode-docker.disconnectCustomRegistry", - "title": "Disconnect from Private Registry", - "category": "Docker" + "command": "vscode-docker.system.prune", + "title": "System Prune", + "category": "Docker", + "icon": { + "light": "images/light/prune.svg", + "dark": "images/dark/prune.svg" + } } ], "views": { @@ -644,13 +811,13 @@ "vscode": "^1.25.0" }, "scripts": { - "vscode:prepublish": "tsc -p ./", "build": "tsc -p ./", "compile": "tsc -watch -p ./", + "package": "vsce package", "lint": "tslint --project tsconfig.json -t verbose", "lint-fix": "tslint --project tsconfig.json -t verbose --fix", "postinstall": "node ./node_modules/vscode/bin/install", - "test": "npm run build && cross-env CODE_TESTS_WORKSPACE=./test/test.code-workspace DEBUGTELEMETRY=1 node ./node_modules/vscode/bin/test", + "test": "gulp test", "all": "npm i && npm run lint && npm test" }, "extensionDependencies": [ @@ -659,30 +826,35 @@ ], "devDependencies": { "@types/adm-zip": "^0.4.31", + "@types/deep-equal": "^1.0.1", "@types/dockerode": "^2.5.5", "@types/fs-extra": "^5.0.4", "@types/glob": "5.0.35", "@types/keytar": "^4.0.1", "@types/mocha": "^5.2.5", - "@types/node": "^8.0.34", + "@types/node": "^8.10.34", "@types/request-promise-native": "^1.0.15", "@types/semver": "^5.5.0", "adm-zip": "^0.4.11", - "azure-storage": "^2.8.1", - "cross-env": "^5.2.0", "gulp": "^3.9.1", - "mocha": "5.2.0", + "gulp-decompress": "^2.0.2", + "gulp-download": "^0.0.1", + "mocha": "^2.3.3", + "mocha-junit-reporter": "^1.18.0", "tslint": "^5.11.0", - "tslint-microsoft-contrib": "5.0.1", - "typescript": "^2.1.5", - "vsce": "^1.37.5", + "tslint-microsoft-contrib": "^5.2.1", + "typescript": "^3.1.1", + "vsce": "^1.51.1", "vscode": "^1.1.18" }, "dependencies": { - "azure-arm-containerregistry": "^2.3.0", + "azure-arm-containerregistry": "^3.0.0", "azure-arm-resource": "^2.0.0-preview", "azure-arm-website": "^1.0.0-preview", + "deep-equal": "^1.0.1", "dockerfile-language-server-nodejs": "^0.0.19", + "azure-storage": "^2.8.1", + "clipboardy": "^1.2.3", "dockerode": "^2.5.1", "fs-extra": "^6.0.1", "glob": "7.1.2", @@ -692,8 +864,8 @@ "pom-parser": "^1.1.1", "request-promise-native": "^1.0.5", "semver": "^5.5.1", - "vscode-azureextensionui": "^0.17.0", - "vscode-extension-telemetry": "0.0.18", + "tar": "^4.4.6", + "vscode-azureextensionui": "^0.19.0", "vscode-languageclient": "^4.4.0" } } diff --git a/test/buildAndRun.test.ts b/test/buildAndRun.test.ts index ffec8bf81f..ceb19003e4 100644 --- a/test/buildAndRun.test.ts +++ b/test/buildAndRun.test.ts @@ -12,7 +12,7 @@ import { Uri } from 'vscode'; import * as fse from 'fs-extra'; import * as AdmZip from 'adm-zip'; import * as path from 'path'; -import { Platform } from "../configureWorkspace/config-utils"; +import { Platform } from '../utils/platform'; import { ext } from '../extensionVariables'; import { Suite } from 'mocha'; import { configure } from '../configureWorkspace/configure'; diff --git a/test/configure.test.ts b/test/configure.test.ts index 3f9ab977a3..4385cc08b0 100644 --- a/test/configure.test.ts +++ b/test/configure.test.ts @@ -8,10 +8,10 @@ import * as assertEx from './assertEx'; import * as vscode from 'vscode'; import * as fse from 'fs-extra'; import * as path from 'path'; -import { Platform, OS } from "../configureWorkspace/config-utils"; import { ext } from '../extensionVariables'; +import { PlatformOS, Platform } from '../utils/platform'; import { Suite } from 'mocha'; -import { configure, ConfigureTelemetryProperties, configureApi, ConfigureApiOptions } from '../configureWorkspace/configure'; +import { configure, ConfigureTelemetryProperties, ConfigureApiOptions } from '../configureWorkspace/configure'; import { TestUserInput, IActionContext, TelemetryProperties } from 'vscode-azureextensionui'; import { globAsync } from '../helpers/async'; import { getTestRootFolder, constants, testInEmptyFolder } from './global.test'; @@ -41,7 +41,6 @@ as the easier to read: sub-indented text `; */ - function removeIndentation(text: string): string { while (text[0] === '\r' || text[0] === '\n') { text = text.substr(1); @@ -381,7 +380,7 @@ suite("Configure (Add Docker files to Workspace)", function (this: Suite): void const outputChannel: vscode.OutputChannel = vscode.window.createOutputChannel('Docker extension tests'); ext.outputChannel = outputChannel; - async function testDotNetCoreConsole(os: OS, projectFolder: string, projectFileName: string, projectFileContents: string, expectedDockerFileContents?: string): Promise { + async function testDotNetCoreConsole(os: PlatformOS, projectFolder: string, projectFileName: string, projectFileContents: string, expectedDockerFileContents?: string): Promise { await writeFile(projectFolder, projectFileName, projectFileContents); await writeFile(projectFolder, 'Program.cs', dotnetCoreConsole_ProgramCsContents); @@ -393,7 +392,7 @@ suite("Configure (Add Docker files to Workspace)", function (this: Suite): void packageFileType: '.csproj', packageFileSubfolderDepth: '1' }, - [os, '' /* no port */], + [os /* it doesn't ask for a port, so we don't specify one here */], ['Dockerfile', '.dockerignore', `${projectFolder}/Program.cs`, `${projectFolder}/${projectFileName}`] ); @@ -403,7 +402,7 @@ suite("Configure (Add Docker files to Workspace)", function (this: Suite): void } } - async function testAspNetCore(os: OS, hostOs: OS, hostOsRelease: string, projectFolder: string, projectFileName: string, projectFileContents: string, expectedDockerFileContents?: string): Promise { + async function testAspNetCore(os: PlatformOS, hostOs: PlatformOS, hostOsRelease: string, projectFolder: string, projectFileName: string, projectFileContents: string, expectedDockerFileContents?: string): Promise { let previousOs = ext.os; ext.os = { platform: hostOs === 'Windows' ? 'win32' : 'linux', @@ -564,15 +563,15 @@ suite("Configure (Add Docker files to Workspace)", function (this: Suite): void packageFileType: undefined, packageFileSubfolderDepth: undefined }, - ['Windows', '1234'] + ['Windows'] ), - { message: "No .csproj file could be found." } + { message: "No .csproj file could be found. You need a C# project file in the workspace to generate Docker files for the selected platform." } ); }); testInEmptyFolder("ASP.NET Core no project file", async () => { await assertEx.throwsOrRejectsAsync(async () => testConfigureDocker('ASP.NET Core', {}, ['Windows', '1234']), - { message: "No .csproj file could be found." } + { message: "No .csproj file could be found. You need a C# project file in the workspace to generate Docker files for the selected platform." } ); }); @@ -587,7 +586,7 @@ suite("Configure (Add Docker files to Workspace)", function (this: Suite): void packageFileType: '.csproj', packageFileSubfolderDepth: '1' }, - ['Windows', '1234', 'projectFolder2/aspnetapp.csproj'], + ['Windows', 'projectFolder2/aspnetapp.csproj'], ['Dockerfile', '.dockerignore', 'projectFolder1/aspnetapp.csproj', 'projectFolder2/aspnetapp.csproj'] ); @@ -783,6 +782,28 @@ suite("Configure (Add Docker files to Workspace)", function (this: Suite): void // ASP.NET Core suite("ASP.NET Core 2.2", async () => { + testInEmptyFolder("Default port (80)", async () => { + await writeFile('projectFolder1', 'aspnetapp.csproj', dotNetCoreConsole_21_ProjectFileContents); + await testConfigureDocker( + 'ASP.NET Core', + undefined, + ['Windows', undefined] + ); + + assertFileContains('Dockerfile', 'EXPOSE 80'); + }); + + testInEmptyFolder("No port", async () => { + await writeFile('projectFolder1', 'aspnetapp.csproj', dotNetCoreConsole_21_ProjectFileContents); + await testConfigureDocker( + 'ASP.NET Core', + undefined, + ['Windows', ''] + ); + + assertNotFileContains('Dockerfile', 'EXPOSE'); + }); + testInEmptyFolder("Windows 10 RS4", async () => { await testAspNetCore( 'Windows', @@ -922,6 +943,28 @@ suite("Configure (Add Docker files to Workspace)", function (this: Suite): void // Java suite("Java", () => { + testInEmptyFolder("No port", async () => { + await testConfigureDocker( + 'Java', + undefined, + [''], + ['Dockerfile', 'docker-compose.debug.yml', 'docker-compose.yml', '.dockerignore'] + ); + + assertNotFileContains('Dockerfile', 'EXPOSE'); + }); + + testInEmptyFolder("Default port", async () => { + await testConfigureDocker( + 'Java', + undefined, + [undefined], + ['Dockerfile', 'docker-compose.debug.yml', 'docker-compose.yml', '.dockerignore'] + ); + + assertFileContains('Dockerfile', 'EXPOSE 3000'); + }); + testInEmptyFolder("No pom file", async () => { await testConfigureDocker( 'Java', @@ -1070,7 +1113,7 @@ suite("Configure (Add Docker files to Workspace)", function (this: Suite): void // Ruby suite("Ruby", () => { - testInEmptyFolder("Ruby", async () => { + testInEmptyFolder("Ruby, empty folder", async () => { await testConfigureDocker( 'Ruby', { @@ -1090,6 +1133,70 @@ suite("Configure (Add Docker files to Workspace)", function (this: Suite): void }); }); + suite("'Other'", () => { + testInEmptyFolder("with package.json", async () => { + await writeFile('', 'package.json', JSON.stringify({ + "name": "myexpressapp", + "version": "1.2.3", + "private": true, + "scripts": { + "start": "node ./bin/www" + }, + "dependencies": { + "cookie-parser": "~1.4.3", + "debug": "~2.6.9", + "express": "~4.16.0", + "http-errors": "~1.6.2", + "jade": "~1.11.0", + "morgan": "~1.9.0" + } + })) + await testConfigureDocker( + 'Other', + { + configurePlatform: 'Other', + configureOs: undefined, + packageFileType: undefined, + packageFileSubfolderDepth: undefined + }, + [undefined /*port*/], + ['Dockerfile', 'docker-compose.debug.yml', 'docker-compose.yml', '.dockerignore', 'package.json']); + + let dockerfileContents = await readFile('Dockerfile'); + let composeContents = await readFile('docker-compose.yml'); + let debugComposeContents = await readFile('docker-compose.debug.yml'); + + assert.strictEqual(dockerfileContents, removeIndentation(` + FROM docker/whalesay:latest + LABEL Name=testoutput Version=1.2.3 + RUN apt-get -y update && apt-get install -y fortunes + CMD /usr/games/fortune -a | cowsay + `)); + assert.strictEqual(composeContents, removeIndentation(` + version: '2.1' + + services: + testoutput: + image: testoutput + build: . + ports: + - 3000:3000 + `)); + assert.strictEqual(debugComposeContents, removeIndentation(` + version: '2.1' + + services: + testoutput: + image: testoutput + build: + context: . + dockerfile: Dockerfile + ports: + - 3000:3000 + `)); + }); + }); + // API (vscode-docker.api.configure) suite("API", () => { @@ -1130,13 +1237,13 @@ suite("Configure (Add Docker files to Workspace)", function (this: Suite): void os: "Linux" }, [ - "555", // port 'projectFolder2/aspnetapp.csproj' ], ['Dockerfile', '.dockerignore', 'projectFolder1/aspnetapp.csproj', 'projectFolder2/aspnetapp.csproj'] ); assertFileContains('Dockerfile', 'ENTRYPOINT ["dotnet", "aspnetapp.dll"]'); assertNotFileContains('Dockerfile', 'projectFolder1'); + assertNotFileContains('Dockerfile', 'EXPOSE'); }); testInEmptyFolder("Only port specified, others come from user", async () => { diff --git a/test/debugging/coreclr/commandLineBuilder.test.ts b/test/debugging/coreclr/commandLineBuilder.test.ts new file mode 100644 index 0000000000..f7f197cf5d --- /dev/null +++ b/test/debugging/coreclr/commandLineBuilder.test.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as assert from 'assert'; +import CommandLineBuilder from '../../../debugging/coreclr/commandLineBuilder'; + +suite('debugging/coreclr/CommandLineBuilder', () => { + function testBuilder(name: string, builderInitializer: (CommandLineBuilder) => CommandLineBuilder, expected: string, message: string) { + test(name, () => { + let builder = CommandLineBuilder.create(); + + builder = builderInitializer(builder); + + assert.equal(builder.build(), expected, message); + }); + } + + suite('create', () => { + test('No args', () => assert.equal(CommandLineBuilder.create().build(), '', 'No arguments should return an empty command line.')); + test('With args', () => assert.equal(CommandLineBuilder.create('--arg1', '--arg2').build(), '--arg1 --arg2', 'The command line should contain the arguments.')); + test('With factory', () => assert.equal(CommandLineBuilder.create(() => '--arg1').build(), '--arg1', 'The command line should contain the argument.')); + test('With undefined', () => assert.equal(CommandLineBuilder.create(undefined).build(), '', 'No arguments should return an empty command line.')); + }); + + suite('withArg', () => { + testBuilder('With value', builder => builder.withArg('value'), 'value', 'The command line should contain the value.'); + testBuilder('With undefined', builder => builder.withArg(undefined), '', 'The command line should not contain the value.'); + }); + + suite('withArgFactory', () => { + testBuilder('With factory', builder => builder.withArgFactory(() => 'value'), 'value', 'The command line should contain the value.'); + testBuilder('With undefined', builder => builder.withArgFactory(undefined), '', 'The command line should not contain the value.'); + }); + + suite('withArrayArgs', () => { + testBuilder('With values', builder => builder.withArrayArgs('--arg', ['value1', 'value2']), '--arg "value1" --arg "value2"', 'The command line should contain the values.'); + testBuilder('With value', builder => builder.withArrayArgs('--arg', ['value1']), '--arg "value1"', 'The command line should contain the value.'); + testBuilder('With empty', builder => builder.withArrayArgs('--arg', []), '', 'The command line should not contain the value.'); + testBuilder('With undefined', builder => builder.withArrayArgs('--arg', undefined), '', 'The command line should not contain the value.'); + }); + + suite('withFlagArg', () => { + testBuilder('With true', builder => builder.withFlagArg('--arg', true), '--arg', 'The command line should contain the value.'); + testBuilder('With false', builder => builder.withFlagArg('--arg', false), '', 'The command line should not contain the value.'); + testBuilder('With undefined', builder => builder.withFlagArg('--arg', undefined), '', 'The command line should not contain the value.'); + }); + + suite('withKeyValueArgs', () => { + testBuilder('With values', builder => builder.withKeyValueArgs('--arg', { key1: 'value1', key2: 'value2' }), '--arg "key1=value1" --arg "key2=value2"', 'The command line should contain the values.'); + testBuilder('With value', builder => builder.withKeyValueArgs('--arg', { key1: 'value1' }), '--arg "key1=value1"', 'The command line should contain the value.'); + testBuilder('With empty', builder => builder.withKeyValueArgs('--arg', {}), '', 'The command line should not contain the value.'); + testBuilder('With undefined', builder => builder.withKeyValueArgs('--arg', undefined), '', 'The command line should not contain the value.'); + }); + + suite('withNamedArg', () => { + testBuilder('With value', builder => builder.withNamedArg('--arg', 'value'), '--arg "value"', 'The command line should contain the value.'); + testBuilder('With undefined', builder => builder.withNamedArg('--arg', undefined), '', 'The command line should not contain the value.'); + }); + + suite('withQuotedArg', () => { + testBuilder('With value', builder => builder.withQuotedArg('value'), '"value"', 'The command line should contain the value.'); + testBuilder('With undefined', builder => builder.withQuotedArg(undefined), '', 'The command line should not contain the value.'); + }); +}); diff --git a/test/debugging/coreclr/dockerManager.test.ts b/test/debugging/coreclr/dockerManager.test.ts new file mode 100644 index 0000000000..7ab1660e62 --- /dev/null +++ b/test/debugging/coreclr/dockerManager.test.ts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import assert = require("assert"); +import { DockerBuildImageOptions } from "../../../debugging/coreclr/dockerClient"; +import { compareBuildImageOptions } from "../../../debugging/coreclr/dockerManager"; + +suite('debugging/coreclr/dockerManager', () => { + suite('compareBuildImageOptions', () => { + function testComparison(name: string, options1: DockerBuildImageOptions | undefined, options2: DockerBuildImageOptions | undefined, expected: boolean, message: string) { + test(name, () => assert.equal(compareBuildImageOptions(options1, options2), expected, message)); + } + + function testComparisonOfProperty(property: U, included: boolean = true) { + suite(property, () => { + test('Options undefined', () => { + const options1: DockerBuildImageOptions = {}; + + options1[property] = undefined; + + const options2: DockerBuildImageOptions = undefined; + + assert.equal(compareBuildImageOptions(options1, options2), true, 'Property undefined equates to options undefined.'); + }); + + test('Property unspecified', () => { + const options1: DockerBuildImageOptions = {}; + + options1[property] = undefined; + + const options2: DockerBuildImageOptions = {}; + + assert.equal(compareBuildImageOptions(options1, options2), true, 'Property undefined equates to property unspecified.'); + }); + + test('Properties equal', () => { + const options1: DockerBuildImageOptions = {}; + + options1[property] = 'value'; + + const options2: DockerBuildImageOptions = {}; + + options2[property] = 'value'; + + assert.equal(compareBuildImageOptions(options1, options2), true, 'Equal properties should be equal.'); + }); + + test('Properties different', () => { + const options1: DockerBuildImageOptions = {}; + + options1[property] = 'value1'; + + const options2: DockerBuildImageOptions = {}; + + options2[property] = 'value2'; + + assert.equal(compareBuildImageOptions(options1, options2), !included, 'Different properties should be unequal.'); + }); + }); + } + + function testComparisonOfDictionary(property: U, included: boolean = true) { + suite(property, () => { + test('Options undefined', () => { + const options1: DockerBuildImageOptions = {}; + + options1[property] = undefined; + + const options2: DockerBuildImageOptions = undefined; + + assert.equal(compareBuildImageOptions(options1, options2), true, 'Dictionary undefined equates to options undefined.'); + }); + + test('Dictionary unspecified', () => { + const options1: DockerBuildImageOptions = {}; + + options1[property] = undefined; + + const options2: DockerBuildImageOptions = {}; + + assert.equal(compareBuildImageOptions(options1, options2), true, 'Dictionary undefined equates to dictionary unspecified.'); + }); + + test('Dictionary empty', () => { + const options1: DockerBuildImageOptions = {}; + + options1[property] = {}; + + const options2: DockerBuildImageOptions = {}; + + options2[property] = {}; + + assert.equal(compareBuildImageOptions(options1, options2), true, 'Empty dictionaries should be equal.'); + }); + + test('Dictionary equal', () => { + const options1: DockerBuildImageOptions = {}; + + options1[property] = { arg1: 'value1', arg2: 'value2' }; + + const options2: DockerBuildImageOptions = {}; + + options2[property] = { arg1: 'value1', arg2: 'value2' }; + + assert.equal(compareBuildImageOptions(options1, options2), true, 'Equal dictionaries should be equal.'); + }); + + test('Dictionary different keys', () => { + const options1: DockerBuildImageOptions = {}; + + options1[property] = { arg1: 'value1', arg2: 'value2' }; + + const options2: DockerBuildImageOptions = {}; + + options2[property] = { arg2: 'value2', arg3: 'value3' }; + + assert.equal(compareBuildImageOptions(options1, options2), !included, 'Different properties should be unequal.'); + }); + + test('Dictionary different values', () => { + const options1: DockerBuildImageOptions = {}; + + options1[property] = { arg1: 'value1', arg2: 'value2' }; + + const options2: DockerBuildImageOptions = {}; + + options2[property] = { arg1: 'value1', arg2: 'value3' }; + + assert.equal(compareBuildImageOptions(options1, options2), !included, 'Different properties should be unequal.'); + }); + }); + } + + testComparison('Both undefined', undefined, undefined, true, 'Both being undefined are considered equal.'); + testComparison('One undefined, one empty', undefined, {}, true, 'Undefined and empty are considered equal.'); + testComparison('Both empty', {}, {}, true, 'Both empty are considered equal.'); + + testComparisonOfProperty('context'); + testComparisonOfProperty('dockerfile', false); + testComparisonOfProperty('tag'); + + testComparisonOfDictionary('args'); + testComparisonOfDictionary('labels'); + }); +}); diff --git a/test/debugging/coreclr/lineSplitter.test.ts b/test/debugging/coreclr/lineSplitter.test.ts new file mode 100644 index 0000000000..15443396f6 --- /dev/null +++ b/test/debugging/coreclr/lineSplitter.test.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as assert from 'assert'; +import LineSplitter from '../../../debugging/coreclr/lineSplitter'; + +suite('debugging/coreclr/LineSplitter', () => { + const testCase = (name: string, input: string | string[], output: string[]) => { + test(name, () => { + const splitter = new LineSplitter(); + + const lines: string[] = []; + + splitter.onLine(line => lines.push(line)); + + if (typeof input === 'string') { + splitter.write(input); + } else { + for (let i = 0; i < input.length; i++) { + splitter.write(input[i]); + } + } + + splitter.close(); + + assert.deepEqual(lines, output, 'The number or contents of the lines are not the same.'); + }); + }; + + testCase('Empty string', '', []); + testCase('Only LF', '\n', ['']); + testCase('CR & LF', '\r\n', ['']); + testCase('Multiple LFs', '\n\n', ['', '']); + testCase('Multiple CR & LFs', '\r\n\r\n', ['', '']); + testCase('Single line', 'line one', ['line one']); + testCase('Leading LF', '\nline two', ['', 'line two']); + testCase('Leading CR & LF', '\r\nline two', ['', 'line two']); + testCase('Trailing LF', 'line one\n', ['line one']); + testCase('Trailing CR & LF', 'line one\r\n', ['line one']); + testCase('Multiple lines with LF', 'line one\nline two', ['line one', 'line two']); + testCase('Multiple lines with CR & LF', 'line one\r\nline two', ['line one', 'line two']); + testCase('CR & LF spanning writes', ['line one\r', '\nline two'], ['line one', 'line two']); +}); + diff --git a/test/debugging/coreclr/prereqManager.test.ts b/test/debugging/coreclr/prereqManager.test.ts new file mode 100644 index 0000000000..8fba7789d7 --- /dev/null +++ b/test/debugging/coreclr/prereqManager.test.ts @@ -0,0 +1,211 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { FileSystemProvider } from '../../../debugging/coreclr/fsProvider'; +import { OSProvider } from '../../../debugging/coreclr/osProvider'; +import { ProcessProvider } from '../../../debugging/coreclr/processProvider'; +import { MacNuGetFallbackFolderSharedPrerequisite, LinuxUserInDockerGroupPrerequisite, ShowErrorMessageFunction, DockerDaemonIsLinuxPrerequisite, DotNetSdkInstalledPrerequisite } from '../../../debugging/coreclr/prereqManager'; +import { PlatformOS } from '../../../utils/platform'; +import { DockerClient } from '../../../debugging/coreclr/dockerClient'; +import { DotNetClient } from '../../../debugging/coreclr/dotNetClient'; + +suite('debugging/coreclr/prereqManager', () => { + suite('DockerDaemonIsLinuxPrerequisite', () => { + const generateTest = (name: string, result: boolean, os: PlatformOS) => { + test(name, async () => { + let gotVersion = false; + + const dockerClient = { + getVersion: (options) => { + gotVersion = true; + + assert.deepEqual(options, { format: '{{json .Server.Os}}' }, 'The server OS should be requested, in JSON format.'); + + return Promise.resolve(`"${os.toLowerCase()}"`); + } + }; + + let shown = false; + + const showErrorMessage = (message: string, ...items: vscode.MessageItem[]): Thenable => { + shown = true; + return Promise.resolve(undefined); + }; + + const prerequisite = new DockerDaemonIsLinuxPrerequisite(dockerClient, showErrorMessage); + + const prereqResult = await prerequisite.checkPrerequisite(); + + assert.equal(gotVersion, true, 'The Docker version should have been requested.'); + + assert.equal(prereqResult, result, 'The prerequisite should return `false`.'); + assert.equal(shown, !result, `An error message should ${result ? 'not ' : ''} have been shown.`); + }); + } + + generateTest('Linux daemon', true, 'Linux'); + generateTest('Windows daemon', false, 'Windows'); + }); + + suite('DotNetSdkInstalledPrerequisite', () => { + test('Installed', async () => { + const msBuildClient = { + getVersion: () => Promise.resolve('2.1.402') + }; + + let shown = false; + + const showErrorMessage = (message: string, ...items: vscode.MessageItem[]): Thenable => { + shown = true; + return Promise.resolve(undefined); + }; + + const prerequisite = new DotNetSdkInstalledPrerequisite(msBuildClient, showErrorMessage); + + const prereqResult = await prerequisite.checkPrerequisite(); + + assert.equal(prereqResult, true, 'The prerequisite should pass if the SDK is installed.'); + assert.equal(shown, false, 'No error should be shown.'); + }); + + test('Not installed', async () => { + const msBuildClient = { + getVersion: () => Promise.resolve(undefined) + }; + + let shown = false; + + const showErrorMessage = (message: string, ...items: vscode.MessageItem[]): Thenable => { + shown = true; + return Promise.resolve(undefined); + }; + + const prerequisite = new DotNetSdkInstalledPrerequisite(msBuildClient, showErrorMessage); + + const prereqResult = await prerequisite.checkPrerequisite(); + + assert.equal(prereqResult, false, 'The prerequisite should fail if no SDK is installed.'); + assert.equal(shown, true, 'An error should be shown.'); + }); + }); + + suite('LinuxUserInDockerGroupPrerequisite', () => { + const generateTest = (name: string, result: boolean, os: PlatformOS, isMac?: boolean, inGroup?: boolean) => { + test(name, async () => { + const osProvider = { + os, + isMac + } + + let processProvider = {}; + let listed = false; + + if (os === 'Linux' && !isMac) { + processProvider = { + exec: (command: string, _) => { + listed = true; + + assert.equal(command, 'id -Gn', 'The prerequisite should list the user\'s groups.') + + const groups = inGroup ? 'groupA docker groupB' : 'groupA groupB'; + + return Promise.resolve({ stdout: groups, stderr: ''}); + } + }; + } + + let shown = false; + + const showErrorMessage = (message: string, ...items: vscode.MessageItem[]): Thenable => { + shown = true; + return Promise.resolve(undefined); + }; + + const prerequisite = new LinuxUserInDockerGroupPrerequisite(osProvider, processProvider, showErrorMessage); + + const prereqResult = await prerequisite.checkPrerequisite(); + + if (os === 'Linux' && !isMac) { + assert.equal(listed, true, 'The user\'s groups should have been listed.'); + } + + assert.equal(prereqResult, result, 'The prerequisite should return `false`.'); + assert.equal(shown, !result, `An error message should ${result ? 'not ' : ''} have been shown.`); + }); + }; + + generateTest('Windows: No-op', true, 'Windows'); + generateTest('Mac: No-op', true, 'Linux', true); + generateTest('Linux: In group', true, 'Linux', false, true); + generateTest('Linux: Not in group', false, 'Linux', false, false); + }); + + suite('MacNuGetFallbackFolderSharedPrerequisite', () => { + const generateTest = (name: string, fileContents: string | undefined, result: boolean) => { + const settingsPath = '/Users/User/Library/Group Containers/group.com.docker/settings.json'; + + test(name, async () => { + const fsProvider = { + fileExists: (path: string) => { + assert.equal(settingsPath, path, 'The prerequisite should check for the settings file in the user\'s home directory.'); + + return Promise.resolve(fileContents !== undefined); + }, + readFile: (path: string) => { + if (fileContents === undefined) { + assert.fail('The prerequisite should not attempt to read a file that does not exist.'); + } + + assert.equal(settingsPath, path, 'The prerequisite should read the settings file in the user\'s home directory.'); + + return Promise.resolve(fileContents); + } + }; + + const osProvider = { + homedir: '/Users/User', + isMac: true + }; + + let shown = false; + + const showErrorMessage = (message: string, ...items: vscode.MessageItem[]): Thenable => { + shown = true; + return Promise.resolve(undefined); + }; + + const prereq = new MacNuGetFallbackFolderSharedPrerequisite(fsProvider, osProvider, showErrorMessage); + + const prereqResult = await prereq.checkPrerequisite(); + + assert.equal(prereqResult, result, 'The prerequisite should return `false`.'); + assert.equal(shown, !result, `An error message should ${result ? 'not ' : ''} have been shown.`); + }); + } + + generateTest('Mac: no Docker settings file', undefined, true); + generateTest('Mac: no shared folders in Docker settings file', '{}', true); + generateTest('Mac: no NuGetFallbackFolder in Docker settings file', '{ "filesharingDirectories": [] }', false); + generateTest('Mac: NuGetFallbackFolder in Docker settings file', '{ "filesharingDirectories": [ "/usr/local/share/dotnet/sdk/NuGetFallbackFolder" ] }', true); + + test('Non-Mac: No-op', async () => { + const osProvider = { + isMac: false + }; + + const showErrorMessage = (message: string, ...items: vscode.MessageItem[]): Thenable => { + assert.fail('Should not be called on non-Mac.'); + return Promise.resolve(undefined); + }; + + const prereq = new MacNuGetFallbackFolderSharedPrerequisite({}, osProvider, showErrorMessage); + + const result = await prereq.checkPrerequisite(); + + assert.equal(true, result, 'The prerequisite should return `true` on non-Mac.'); + }); + }); +}); diff --git a/test/getImageOrContainerDisplayName.test.ts b/test/getImageOrContainerDisplayName.test.ts new file mode 100644 index 0000000000..8a10d636a5 --- /dev/null +++ b/test/getImageOrContainerDisplayName.test.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { getImageOrContainerDisplayName } from '../explorer/models/getImageOrContainerDisplayName'; + +suite('getImageOrContainerDisplayName', () => { + function genTest(fullName: string, trim: boolean, max: number, expected: string): void { + test(`${String(fullName)}: ${trim}/${max}`, () => { + let s2 = getImageOrContainerDisplayName(fullName, trim, max); + assert.equal(s2, expected); + }); + } + + genTest('', false, 0, ''); + genTest('', false, 1, ''); + genTest('', true, 0, ''); + genTest('', true, 1, ''); + + genTest('a', false, 1, 'a'); + genTest('abcdefghijklmnopqrstuvwxyz', false, 0, 'abcdefghijklmnopqrstuvwxyz'); + genTest('abcdefghijklmnopqrstuvwxyz', false, 1, 'abcdefghijklmnopqrstuvwxyz'); + genTest('abcdefghijklmnopqrstuvwxyz', false, 25, 'abcdefghijklmnopqrstuvwxyz'); + genTest('abcdefghijklmnopqrstuvwxyz', false, 90, 'abcdefghijklmnopqrstuvwxyz'); + + // No registry - use full image name + genTest('abcdefghijklmnopqrstuvwxyz', true, 0, 'abcdefghijklmnopqrstuvwxyz'); + genTest('abcdefghijklmnopqrstuvwxyz', true, 1, 'abcdefghijklmnopqrstuvwxyz'); + genTest('abcdefghijklmnopqrstuvwxyz', true, 2, 'abcdefghijklmnopqrstuvwxyz'); + genTest('abcdefghijklmnopqrstuvwxyz', true, 10, 'abcdefghijklmnopqrstuvwxyz'); + genTest('abcdefghijklmnopqrstuvwxyz', true, 99, 'abcdefghijklmnopqrstuvwxyz'); + + genTest('abcdefghijklmnopqrstuvwxyz:latest', true, 10, 'abcdefghijklmnopqrstuvwxyz:latest'); + + // Registry + one level + genTest('a/abcdefghijklmnopqrstuvwxyz:latest', true, 10, 'a/abcdefghijklmnopqrstuvwxyz:latest'); + genTest('abcdefghijklmnopqrstuvwxyz/abcdefghijklmnopqrstuvwxyz:latest', true, 10, 'abc...wxyz/abcdefghijklmnopqrstuvwxyz:latest'); + + // Registry + two or more levels + genTest('abcdefghijklmnopqrstuvwxyz/abcdefghijklmnopqrstuvwxyz/abcdefghijklmnopqrstuvwxyz:latest', true, 10, 'abc...wxyz/abcdefghijklmnopqrstuvwxyz/abcdefghijklmnopqrstuvwxyz:latest'); + genTest('abcdefghijklmnopqrstuvwxyz/abcdefghijklmnopqrstuvwxyz/abcdefghijklmnopqrstuvwxyz/abcdefghijklmnopqrstuvwxyz:latest', true, 10, 'abc...wxyz/abcdefghijklmnopqrstuvwxyz/abcdefghijklmnopqrstuvwxyz/abcdefghijklmnopqrstuvwxyz:latest'); + + // Real examples + genTest('registry.gitlab.com/sweatherford/hello-world/sub:latest', true, 7, 're...om/sweatherford/hello-world/sub:latest'); +}); diff --git a/test/index.ts b/test/index.ts index 5099681e89..baf60a0e01 100644 --- a/test/index.ts +++ b/test/index.ts @@ -1,6 +1,3 @@ -import { endianness } from "os"; -import { isNumber } from "util"; - /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See LICENSE.md in the project root for license information. @@ -18,7 +15,8 @@ import { isNumber } from "util"; // to report the results back to the caller. When the tests are finished, return // a possible error to the callback or null if none. -var testRunner = require('vscode/lib/testrunner'); +// tslint:disable-next-line:no-require-imports no-var-requires +let testRunner = require('vscode/lib/testrunner'); let options: { [key: string]: string | boolean | number } = { ui: 'tdd', // the TDD UI is being used in extension.test.ts (suite, test, etc.) @@ -39,19 +37,21 @@ let options: { [key: string]: string | boolean | number } = { // // See https://github.com/mochajs/mocha/wiki/Using-mocha-programmatically#set-options for all available options -for (let envVar of Object.keys(process.env)) { +let environmentVariables = <{ [key: string]: string }>process.env; +for (let envVar of Object.keys(environmentVariables)) { let match = envVar.match(/^mocha_(.+)/i); if (match) { let [, option] = match; - let value: string | number = process.env[envVar] || ''; - if (!isNaN(parseInt(value))) { - value = parseInt(value); + let value: string | number = environmentVariables[envVar]; + if (typeof value === 'string' && !isNaN(parseInt(value, undefined))) { + value = parseInt(value, undefined); } options[option] = value; } } console.warn(`Mocha options: ${JSON.stringify(options, null, 2)}`); +// tslint:disable-next-line: no-unsafe-any testRunner.configure(options); module.exports = testRunner; diff --git a/test/trimWithElipsis.test.ts b/test/trimWithElipsis.test.ts new file mode 100644 index 0000000000..cee544f261 --- /dev/null +++ b/test/trimWithElipsis.test.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { trimWithElipsis } from "../explorer/utils/utils"; +import * as assert from 'assert'; + +suite('trimWithElipsis', () => { + function genTest(s: string, max: number, expected: string): void { + test(`${String(s)}: ${max}`, () => { + let s2 = trimWithElipsis(s, max); + assert.equal(s2, expected); + }); + } + + genTest('', 0, ''); + genTest('', 100, ''); + + genTest('a', 0, 'a'); + genTest('a', 1, 'a'); + genTest('a', 2, 'a'); + + genTest('ab', 0, 'ab'); + genTest('ab', 1, 'a'); + genTest('ab', 2, 'ab'); + genTest('ab', 3, 'ab'); + + genTest('abc', 0, 'abc'); + genTest('abc', 1, 'a'); + genTest('abc', 2, 'ab'); + genTest('abc', 3, 'abc'); + genTest('abc', 4, 'abc'); + + genTest('abcd', 0, 'abcd'); + genTest('abcd', 1, 'a'); + genTest('abcd', 2, 'ab'); + genTest('abcd', 3, '...'); + genTest('abcd', 4, 'abcd'); + genTest('abcd', 5, 'abcd'); + + genTest('abcdefghijklmnopqrstuvwxyz', 1, 'a'); + genTest('abcdefghijklmnopqrstuvwxyz', 2, 'ab'); + genTest('abcdefghijklmnopqrstuvwxyz', 3, '...'); + genTest('abcdefghijklmnopqrstuvwxyz', 4, '...z'); + genTest('abcdefghijklmnopqrstuvwxyz', 5, 'a...z'); + genTest('abcdefghijklmnopqrstuvwxyz', 6, 'a...yz'); + genTest('abcdefghijklmnopqrstuvwxyz', 7, 'ab...yz'); + genTest('abcdefghijklmnopqrstuvwxyz', 8, 'ab...xyz'); + genTest('abcdefghijklmnopqrstuvwxyz', 9, 'abc...xyz'); + genTest('abcdefghijklmnopqrstuvwxyz', 10, 'abc...wxyz'); + genTest('abcdefghijklmnopqrstuvwxyz', 25, 'abcdefghijk...pqrstuvwxyz'); + genTest('abcdefghijklmnopqrstuvwxyz', 25, 'abcdefghijk...pqrstuvwxyz'); + genTest('abcdefghijklmnopqrstuvwxyz', 26, 'abcdefghijklmnopqrstuvwxyz'); +}); diff --git a/thirdpartynotices.txt b/thirdpartynotices.txt index 8a3820cda9..23e175f98b 100644 --- a/thirdpartynotices.txt +++ b/thirdpartynotices.txt @@ -397,3 +397,53 @@ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +12. deep-equal (https://github.com/substack/node-deep-equal) + +This software is released under the MIT license: + +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: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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. + +13. clipboardy (https://github.com/sindresorhus/clipboardy) + +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +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: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +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. + +14. tar (https://github.com/npm/node-tar) + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/tslint.json b/tslint.json index 8195b90218..98a48f8279 100644 --- a/tslint.json +++ b/tslint.json @@ -53,7 +53,7 @@ "no-constant-condition": true, "no-control-regex": true, "no-debugger": true, - "no-duplicate-case": true, + "no-duplicate-switch-case": true, "no-duplicate-super": true, "no-duplicate-variable": true, "no-empty": false, // changed @@ -70,7 +70,7 @@ "no-reference-import": true, "no-regex-spaces": true, "no-sparse-arrays": true, - "no-stateless-class": true, + "no-unnecessary-class": true, "no-string-literal": true, "no-string-throw": true, "no-unnecessary-bind": true, @@ -101,7 +101,7 @@ ], "use-isnan": true, "use-named-parameter": true, - "valid-typeof": true, + "typeof-compare": true, "adjacent-overload-signatures": true, "array-type": [ true, @@ -169,7 +169,7 @@ "no-useless-files": true, "no-var-keyword": true, "no-var-requires": true, - "no-var-self": true, + "no-this-assignment": true, "no-void-expression": [ false, // changed "ignore-arrow-function-shorthand" // changed @@ -303,8 +303,7 @@ "no-empty-interfaces": false, "no-missing-visibility-modifiers": false, "no-multiple-var-decl": false, - "no-switch-case-fall-through": false, - "typeof-compare": false + "no-switch-case-fall-through": false }, "rulesDirectory": "node_modules/tslint-microsoft-contrib/", "linterOptions": { diff --git a/utils/Azure/acrTools.ts b/utils/Azure/acrTools.ts index 8ec082c743..b0b2e79707 100644 --- a/utils/Azure/acrTools.ts +++ b/utils/Azure/acrTools.ts @@ -4,12 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import { AuthenticationContext } from 'adal-node'; -import * as assert from 'assert'; -import { Registry } from "azure-arm-containerregistry/lib/models"; +import ContainerRegistryManagementClient from 'azure-arm-containerregistry'; +import { Registry, Run, RunGetLogResult } from "azure-arm-containerregistry/lib/models"; import { SubscriptionModels } from 'azure-arm-resource'; +import { ResourceGroup } from "azure-arm-resource/lib/resource/models"; import { Subscription } from "azure-arm-resource/lib/subscription/models"; +import { BlobService, createBlobServiceWithSas } from "azure-storage"; import { ServiceClientCredentials } from 'ms-rest'; import { TokenResponse } from 'ms-rest-azure'; +import * as vscode from "vscode"; +import { parseError } from 'vscode-azureextensionui'; import { NULL_GUID } from "../../constants"; import { getCatalog, getTags, TagInfo } from "../../explorer/models/commonRegistryUtils"; import { ext } from '../../extensionVariables'; @@ -21,12 +25,13 @@ import { Repository } from "./models/repository"; //General helpers /** Gets the subscription for a given registry + * @param registry gets the subscription for a given regsitry * @returns a subscription object */ -export function getSubscriptionFromRegistry(registry: Registry): SubscriptionModels.Subscription { +export async function getSubscriptionFromRegistry(registry: Registry): Promise { let id = getId(registry); let subscriptionId = id.slice('/subscriptions/'.length, id.search('/resourceGroups/')); - const subs = AzureUtilityManager.getInstance().getFilteredSubscriptionList(); + const subs = await AzureUtilityManager.getInstance().getFilteredSubscriptionList(); let subscription = subs.find((sub): boolean => { return sub.subscriptionId === subscriptionId; }); @@ -43,12 +48,20 @@ export function getResourceGroupName(registry: Registry): string { return id.slice(id.search('resourceGroups/') + 'resourceGroups/'.length, id.search('/providers/')); } +//Gets resource group object from registry and subscription +export async function getResourceGroup(registry: Registry, subscription: Subscription): Promise { + let resourceGroups: ResourceGroup[] = await AzureUtilityManager.getInstance().getResourceGroups(subscription); + const resourceGroupName = getResourceGroupName(registry); + return resourceGroups.find((res) => { return res.name === resourceGroupName }); +} + //Registry item management /** List images under a specific Repository */ export async function getImagesByRepository(element: Repository): Promise { let allImages: AzureImage[] = []; let image: AzureImage; const { acrAccessToken } = await acquireACRAccessTokenFromRegistry(element.registry, 'repository:' + element.name + ':pull'); + const tags: TagInfo[] = await getTags('https://' + element.registry.loginServer, element.name, { bearer: acrAccessToken }); for (let tag of tags) { image = new AzureImage(element, tag.tag, tag.created); @@ -62,9 +75,10 @@ export async function getRepositoriesByRegistry(registry: Registry): Promise { - const subscription: Subscription = getSubscriptionFromRegistry(registry); - const session: AzureSession = AzureUtilityManager.getInstance().getSession(subscription) +export async function getLoginCredentials(registry: Registry): Promise<{ password: string, username: string }> { + const subscription: Subscription = await getSubscriptionFromRegistry(registry); + const session: AzureSession = await AzureUtilityManager.getInstance().getSession(subscription) const { aadAccessToken, aadRefreshToken } = await acquireAADTokens(session); const acrRefreshToken = await acquireACRRefreshToken(getLoginServer(registry), session.tenantId, aadRefreshToken, aadAccessToken); return { 'password': acrRefreshToken, 'username': NULL_GUID }; @@ -111,8 +125,8 @@ export async function loginCredentials(registry: Registry): Promise<{ password: * @returns acrRefreshToken: For use as a Password for docker registry access , acrAccessToken: For use with docker API */ export async function acquireACRAccessTokenFromRegistry(registry: Registry, scope: string): Promise<{ acrRefreshToken: string, acrAccessToken: string }> { - const subscription: Subscription = getSubscriptionFromRegistry(registry); - const session: AzureSession = AzureUtilityManager.getInstance().getSession(subscription); + const subscription: Subscription = await getSubscriptionFromRegistry(registry); + const session: AzureSession = await AzureUtilityManager.getInstance().getSession(subscription); const { aadAccessToken, aadRefreshToken } = await acquireAADTokens(session); let loginServer = getLoginServer(registry); const acrRefreshToken = await acquireACRRefreshToken(loginServer, session.tenantId, aadRefreshToken, aadAccessToken); @@ -172,3 +186,92 @@ export async function acquireACRAccessToken(registryUrl: string, scope: string, }); return acrAccessTokenResponse.access_token; } + +export interface IBlobInfo { + accountName: string; + endpointSuffix: string; + containerName: string; + blobName: string; + sasToken: string; + host: string; +} + +/** Parses information into a readable format from a blob url */ +export function getBlobInfo(blobUrl: string): IBlobInfo { + let items: string[] = blobUrl.slice(blobUrl.search('https://') + 'https://'.length).split('/'); + const accountName = blobUrl.slice(blobUrl.search('https://') + 'https://'.length, blobUrl.search('.blob')); + const endpointSuffix = items[0].slice(items[0].search('.blob.') + '.blob.'.length); + const containerName = items[1]; + const blobName = items[2] + '/' + items[3] + '/' + items[4].slice(0, items[4].search('[?]')); + const sasToken = items[4].slice(items[4].search('[?]') + 1); + const host = accountName + '.blob.' + endpointSuffix; + return { + accountName: accountName, + endpointSuffix: endpointSuffix, + containerName: containerName, + blobName: blobName, + sasToken: sasToken, + host: host + }; +} + +/** Stream logs from a blob into output channel. + * Note, since output streams don't actually deal with streams directly, text is not actually + * streamed in which prevents updating of already appended lines. Usure if this can be fixed. Nonetheless + * logs do load in chunks every 1 second. + */ +export async function streamLogs(registry: Registry, run: Run, outputChannel: vscode.OutputChannel, providedClient?: ContainerRegistryManagementClient): Promise { + //Prefer passed in client to avoid initialization but if not added obtains own + const subscription = await getSubscriptionFromRegistry(registry); + let client = providedClient ? providedClient : await AzureUtilityManager.getInstance().getContainerRegistryManagementClient(subscription); + let temp: RunGetLogResult = await client.runs.getLogSasUrl(getResourceGroupName(registry), registry.name, run.runId); + const link = temp.logLink; + let blobInfo: IBlobInfo = getBlobInfo(link); + let blob: BlobService = createBlobServiceWithSas(blobInfo.host, blobInfo.sasToken); + let available = 0; + let start = 0; + + let obtainLogs = setInterval(async () => { + let props: BlobService.BlobResult; + let metadata: { [key: string]: string; }; + try { + props = await getBlobProperties(blobInfo, blob); + metadata = props.metadata; + } catch (err) { + const error = parseError(err); + //Not found happens when the properties havent yet been set, blob is not ready. Wait 1 second and try again + if (error.errorType === "NotFound") { return; } else { throw error; } + } + available = +props.contentLength; + let text: string; + //Makes sure that if item fails it does so due to network/azure errors not lack of new content + if (available > start) { + text = await getBlobToText(blobInfo, blob, start); + let utf8encoded = (new Buffer(text, 'ascii')).toString('utf8'); + start += text.length; + outputChannel.append(utf8encoded); + } + if (metadata.Complete) { + clearInterval(obtainLogs); + } + }, 1000); +} + +// Promisify getBlobToText for readability and error handling purposes +export async function getBlobToText(blobInfo: IBlobInfo, blob: BlobService, rangeStart: number): Promise { + return new Promise((resolve, reject) => { + blob.getBlobToText(blobInfo.containerName, blobInfo.blobName, { rangeStart: rangeStart }, + (error, result) => { + if (error) { reject(error) } else { resolve(result); } + }); + }); +} + +// Promisify getBlobProperties for readability and error handling purposes +async function getBlobProperties(blobInfo: IBlobInfo, blob: BlobService): Promise { + return new Promise((resolve, reject) => { + blob.getBlobProperties(blobInfo.containerName, blobInfo.blobName, (error, result) => { + if (error) { reject(error) } else { resolve(result); } + }); + }); +} diff --git a/utils/Azure/common.ts b/utils/Azure/common.ts index 1514d5f206..7e69a06f3e 100644 --- a/utils/Azure/common.ts +++ b/utils/Azure/common.ts @@ -1,7 +1,3 @@ -import * as opn from 'opn'; -import * as vscode from "vscode"; -import { IActionContext, registerCommand } from "vscode-azureextensionui"; -import { AzureUtilityManager } from "../azureUtilityManager"; let alphaNum = new RegExp('^[a-zA-Z0-9]*$'); @@ -14,28 +10,3 @@ export function isValidAzureName(value: string): { isValid: boolean, message?: s return { isValid: true }; } } - -/** Uses consistent error handling from register command to replace callbacks for commands that have a dependency on azure account. - * If the dependency is not found notifies users providing them with information to go download the extension. - */ -// tslint:disable-next-line:no-any -export function registerAzureCommand(commandId: string, callback: (...args: any[]) => any): void { - let commandItem: (actionContext: IActionContext) => void; - - if (!AzureUtilityManager.hasLoadedUtilityManager()) { - commandItem = () => { - const open: vscode.MessageItem = { title: "View in Marketplace" }; - vscode.window.showErrorMessage('Please install the Azure Account extension to use Azure features.', open).then((response) => { - if (response === open) { - // tslint:disable-next-line:no-unsafe-any - opn('https://marketplace.visualstudio.com/items?itemName=ms-vscode.azure-account'); - } - }); - } - - } else { - commandItem = callback; - } - - registerCommand(commandId, commandItem); -} diff --git a/utils/Azure/models/repository.ts b/utils/Azure/models/repository.ts index be87acd391..de3b3ae483 100644 --- a/utils/Azure/models/repository.ts +++ b/utils/Azure/models/repository.ts @@ -15,12 +15,19 @@ export class Repository { public password?: string; public username?: string; - constructor(registry: Registry, repository: string, password?: string, username?: string) { - this.registry = registry; - this.resourceGroupName = acrTools.getResourceGroupName(registry); - this.subscription = acrTools.getSubscriptionFromRegistry(registry); - this.name = repository; - if (password) { this.password = password; } - if (username) { this.username = username; } + private constructor() { + } + + public static async Create(registry: Registry, repositoryName: string, password?: string, username?: string): Promise { + let repository = new Repository(); + + repository.registry = registry; + repository.resourceGroupName = acrTools.getResourceGroupName(registry); + repository.subscription = await acrTools.getSubscriptionFromRegistry(registry); + repository.name = repositoryName; + repository.password = password; + repository.username = username; + + return repository; } } diff --git a/utils/addUserAgent.ts b/utils/addUserAgent.ts index 39a5eb6b0b..fa82a77b53 100644 --- a/utils/addUserAgent.ts +++ b/utils/addUserAgent.ts @@ -9,7 +9,6 @@ import { appendExtensionUserAgent } from 'vscode-azureextensionui'; const userAgentKey = 'User-Agent'; export function addUserAgent(options: { headers?: OutgoingHttpHeaders }): void { - // tslint:disable-next-line:no-any if (!options.headers) { options.headers = {}; } diff --git a/utils/azureUtilityManager.ts b/utils/azureUtilityManager.ts index 4d5efe2e68..9c2cc02157 100644 --- a/utils/azureUtilityManager.ts +++ b/utils/azureUtilityManager.ts @@ -3,14 +3,15 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as assert from 'assert'; import { ContainerRegistryManagementClient } from 'azure-arm-containerregistry'; import * as ContainerModels from 'azure-arm-containerregistry/lib/models'; import { ResourceManagementClient, SubscriptionClient, SubscriptionModels } from 'azure-arm-resource'; import { ResourceGroup } from "azure-arm-resource/lib/resource/models"; import { Subscription } from 'azure-arm-resource/lib/subscription/models'; import { ServiceClientCredentials } from 'ms-rest'; -import { addExtensionUserAgent } from 'vscode-azureextensionui'; +import * as opn from 'opn'; +import * as vscode from 'vscode'; +import { addExtensionUserAgent, callWithTelemetryAndErrorHandling, IActionContext, parseError, UserCancelledError } from 'vscode-azureextensionui'; import { MAX_CONCURRENT_SUBSCRIPTON_REQUESTS } from '../constants'; import { AzureAccount, AzureSession } from '../typings/azure-account.api'; import { AsyncPool } from './asyncpool'; @@ -18,19 +19,36 @@ import { getSubscriptionId, getTenantId } from './nonNull'; /* Singleton for facilitating communication with Azure account services by providing extended shared functionality and extension wide access to azureAccount. Tool for internal use. - Authors: Esteban Rey L, Jackson Stokes + Authors: Esteban Rey L, Jackson Stokes, Julia Lieberman */ export class AzureUtilityManager { - - //SETUP private static _instance: AzureUtilityManager; - private azureAccount: AzureAccount | undefined; + private _azureAccountPromise: Promise; private constructor() { } - public static hasLoadedUtilityManager(): boolean { - if (AzureUtilityManager._instance) { return true; } else { return false; } + private async loadAzureAccountExtension(): Promise { + let azureAccount: AzureAccount | undefined; + + // tslint:disable-next-line:no-function-expression + await callWithTelemetryAndErrorHandling('docker.loadAzureAccountExt', async function (this: IActionContext): Promise { + this.properties.isActivationEvent = 'true'; + + try { + let azureAccountExtension = vscode.extensions.getExtension('ms-vscode.azure-account'); + this.properties.found = azureAccountExtension ? 'true' : 'false'; + if (azureAccountExtension) { + azureAccount = await azureAccountExtension.activate(); + } + + vscode.commands.executeCommand('setContext', 'isAzureAccountInstalled', !!azureAccount); + } catch (error) { + throw new Error('Failed to activate the Azure Account Extension: ' + parseError(error).message); + } + }); + + return azureAccount; } public static getInstance(): AzureUtilityManager { @@ -40,22 +58,34 @@ export class AzureUtilityManager { return AzureUtilityManager._instance; } - //This function has to be called explicitly before using the singleton. - public setAccount(azureAccount: AzureAccount): void { - this.azureAccount = azureAccount; + public async tryGetAzureAccount(): Promise { + if (!this._azureAccountPromise) { + this._azureAccountPromise = this.loadAzureAccountExtension(); + } + + return await this._azureAccountPromise; } - //GETTERS - public getAccount(): AzureAccount { - if (this.azureAccount) { - return this.azureAccount; + public async requireAzureAccount(): Promise { + let azureAccount = await this.tryGetAzureAccount(); + if (azureAccount) { + return azureAccount; + } else { + const open: vscode.MessageItem = { title: "View in Marketplace" }; + const msg = 'This functionality requires installing the Azure Account extension.'; + let response = await vscode.window.showErrorMessage(msg, open); + if (response === open) { + // tslint:disable-next-line:no-unsafe-any + opn('https://marketplace.visualstudio.com/items?itemName=ms-vscode.azure-account'); + } + + throw new UserCancelledError(msg); } - throw new Error('Azure account is not present, you may have forgotten to call setAccount'); } - public getSession(subscription: SubscriptionModels.Subscription): AzureSession { + public async getSession(subscription: SubscriptionModels.Subscription): Promise { const tenantId: string = getTenantId(subscription); - const azureAccount: AzureAccount = this.getAccount(); + const azureAccount: AzureAccount = await this.requireAzureAccount(); let foundSession = azureAccount.sessions.find((s) => s.tenantId.toLowerCase() === tenantId.toLowerCase()); if (!foundSession) { throw new Error(`Could not find a session with tenantId "${tenantId}"`); @@ -64,8 +94,8 @@ export class AzureUtilityManager { return foundSession; } - public getFilteredSubscriptionList(): SubscriptionModels.Subscription[] { - return this.getAccount().filters.map(filter => { + public async getFilteredSubscriptionList(): Promise { + return (await this.requireAzureAccount()).filters.map(filter => { return { id: filter.subscription.id, subscriptionId: filter.subscription.subscriptionId, @@ -78,39 +108,40 @@ export class AzureUtilityManager { }); } - public getContainerRegistryManagementClient(subscription: SubscriptionModels.Subscription): ContainerRegistryManagementClient { - let client = new ContainerRegistryManagementClient(this.getCredentialByTenantId(subscription), getSubscriptionId(subscription)); + public async getContainerRegistryManagementClient(subscription: SubscriptionModels.Subscription): Promise { + let client = new ContainerRegistryManagementClient(await this.getCredentialByTenantId(subscription), getSubscriptionId(subscription)); addExtensionUserAgent(client); return client; } - public getResourceManagementClient(subscription: SubscriptionModels.Subscription): ResourceManagementClient { - return new ResourceManagementClient(this.getCredentialByTenantId(getTenantId(subscription)), getSubscriptionId(subscription)); + public async getResourceManagementClient(subscription: SubscriptionModels.Subscription): Promise { + return new ResourceManagementClient(await this.getCredentialByTenantId(getTenantId(subscription)), getSubscriptionId(subscription)); } public async getRegistries(subscription?: Subscription, resourceGroup?: string, - compareFn: (a: ContainerModels.Registry, b: ContainerModels.Registry) => number = this.sortRegistriesAlphabetically): Promise { + compareFn: (a: ContainerModels.Registry, b: ContainerModels.Registry) => number = this.sortRegistriesAlphabetically + ): Promise { let registries: ContainerModels.Registry[] = []; if (subscription && resourceGroup) { //Get all registries under one resourcegroup - const client = this.getContainerRegistryManagementClient(subscription); + const client = await this.getContainerRegistryManagementClient(subscription); registries = await client.registries.listByResourceGroup(resourceGroup); } else if (subscription) { //Get all registries under one subscription - const client = this.getContainerRegistryManagementClient(subscription); + const client = await this.getContainerRegistryManagementClient(subscription); registries = await client.registries.list(); } else { //Get all registries for all subscriptions - const subs: SubscriptionModels.Subscription[] = this.getFilteredSubscriptionList(); + const subs: SubscriptionModels.Subscription[] = await this.getFilteredSubscriptionList(); const subPool = new AsyncPool(MAX_CONCURRENT_SUBSCRIPTON_REQUESTS); for (let sub of subs) { subPool.addTask(async () => { - const client = this.getContainerRegistryManagementClient(sub); + const client = await this.getContainerRegistryManagementClient(sub); let subscriptionRegistries: ContainerModels.Registry[] = await client.registries.list(); registries = registries.concat(subscriptionRegistries); }); @@ -130,16 +161,17 @@ export class AzureUtilityManager { public async getResourceGroups(subscription?: SubscriptionModels.Subscription): Promise { if (subscription) { - const resourceClient = this.getResourceManagementClient(subscription); + const resourceClient = await this.getResourceManagementClient(subscription); return await resourceClient.resourceGroups.list(); } - const subs = this.getFilteredSubscriptionList(); + const subs = await this.getFilteredSubscriptionList(); const subPool = new AsyncPool(MAX_CONCURRENT_SUBSCRIPTON_REQUESTS); let resourceGroups: ResourceGroup[] = []; //Acquire each subscription's data simultaneously + for (let sub of subs) { subPool.addTask(async () => { - const resourceClient = this.getResourceManagementClient(sub); + const resourceClient = await this.getResourceManagementClient(sub); const internalGroups = await resourceClient.resourceGroups.list(); resourceGroups = resourceGroups.concat(internalGroups); }); @@ -148,19 +180,18 @@ export class AzureUtilityManager { return resourceGroups; } - public getCredentialByTenantId(tenantIdOrSubscription: string | Subscription): ServiceClientCredentials { + public async getCredentialByTenantId(tenantIdOrSubscription: string | Subscription): Promise { let tenantId = typeof tenantIdOrSubscription === 'string' ? tenantIdOrSubscription : getTenantId(tenantIdOrSubscription); - const session = this.getAccount().sessions.find((azureSession) => azureSession.tenantId.toLowerCase() === tenantId.toLowerCase()); + const session = (await this.requireAzureAccount()).sessions.find((azureSession) => azureSession.tenantId.toLowerCase() === tenantId.toLowerCase()); if (session) { return session.credentials; } - throw new Error(`Failed to get credentials, tenant ${tenantId} not found.`); } public async getLocationsBySubscription(subscription: SubscriptionModels.Subscription): Promise { - const credential = this.getCredentialByTenantId(getTenantId(subscription)); + const credential = await this.getCredentialByTenantId(getTenantId(subscription)); const client = new SubscriptionClient(credential); const locations = (await client.subscriptions.listLocations(getSubscriptionId(subscription))); return locations; @@ -169,9 +200,10 @@ export class AzureUtilityManager { //CHECKS //Provides a unified check for login that should be called once before using the rest of the singletons capabilities public async waitForLogin(): Promise { - if (!this.azureAccount) { + let account = await this.tryGetAzureAccount(); + if (!account) { return false; } - return await this.azureAccount.waitForLogin(); + return await account.waitForLogin(); } } diff --git a/utils/nonNull.ts b/utils/nonNull.ts index 82aeb1aeaa..c824517db7 100644 --- a/utils/nonNull.ts +++ b/utils/nonNull.ts @@ -12,7 +12,6 @@ import { isNullOrUndefined } from 'util'; * for the property and will give a compile error if the given name is not a property of the source. */ export function nonNullProp(source: TSource, name: TKey): NonNullable { - // tslint:disable-next-line:no-any let value = >source[name]; return nonNullValue(value, name); } @@ -20,7 +19,6 @@ export function nonNullProp(source: TSource /** * Validates that a given value is not null and not undefined. */ -// tslint:disable-next-line:no-any export function nonNullValue(value: T | undefined, propertyNameOrMessage?: string): T { if (isNullOrUndefined(value)) { throw new Error( diff --git a/utils/platform.ts b/utils/platform.ts new file mode 100644 index 0000000000..9f874db3cb --- /dev/null +++ b/utils/platform.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type PlatformOS = 'Windows' | 'Linux'; +export type Platform = + 'Go' | + 'Java' | + '.NET Core Console' | + 'ASP.NET Core' | + 'Node.js' | + 'Python' | + 'Ruby' | + 'Other';