diff --git a/commands/azureCommands/delete-azure-image.ts b/commands/azureCommands/delete-azure-image.ts new file mode 100644 index 0000000000..30afe43810 --- /dev/null +++ b/commands/azureCommands/delete-azure-image.ts @@ -0,0 +1,60 @@ +import { Registry } from "azure-arm-containerregistry/lib/models"; +import { SubscriptionModels } from 'azure-arm-resource'; +import * as vscode from "vscode"; +import { AzureImageNode } from '../../explorer/models/AzureRegistryNodes'; +import { Repository } from "../../utils/Azure/models/repository"; +import { AzureCredentialsManager } from '../../utils/azureCredentialsManager'; +const teleCmdId: string = 'vscode-docker.deleteAzureImage'; +import * as quickPicks from '../../commands/utils/quick-pick-azure'; +import * as acrTools from '../../utils/Azure/acrTools'; + +/** + * function to delete an Azure repository and its associated images + * @param context : if called through right click on AzureRepositoryNode, the node object will be passed in. See azureRegistryNodes.ts for more info + */ +export async function deleteAzureImage(context?: AzureImageNode): Promise { + if (!AzureCredentialsManager.getInstance().isLoggedIn()) { + vscode.window.showErrorMessage('You are not logged into Azure'); + return; + } + let registry: Registry; + let subscription: SubscriptionModels.Subscription; + let repoName: string; + let username: string; + let password: string; + let tag: string; + if (!context) { + registry = await quickPicks.quickPickACRRegistry(); + subscription = acrTools.getRegistrySubscription(registry); + let repository: Repository = await quickPicks.quickPickACRRepository(registry); + repoName = repository.name; + const image = await quickPicks.quickPickACRImage(repository); + tag = image.tag; + } + + //ensure user truly wants to delete image + let opt: vscode.InputBoxOptions = { + ignoreFocusOut: true, + placeHolder: 'No', + value: 'No', + prompt: 'Are you sure you want to delete this image? Enter Yes to continue: ' + }; + let answer = await vscode.window.showInputBox(opt); + answer = answer.toLowerCase(); + if (answer !== 'yes') { return; } + + if (context) { + repoName = context.label; + subscription = context.subscription; + registry = context.registry; + let wholeName = repoName.split(':'); + repoName = wholeName[0]; + tag = wholeName[1]; + } + + let creds = await acrTools.loginCredentials(subscription, registry); + username = creds.username; + password = creds.password; + let path = `/v2/_acr/${repoName}/tags/${tag}`; + await acrTools.requestDataFromRegistry('delete', registry.loginServer, path, username, password); //official call to delete the image +} diff --git a/commands/utils/quick-pick-azure.ts b/commands/utils/quick-pick-azure.ts new file mode 100644 index 0000000000..e88496036e --- /dev/null +++ b/commands/utils/quick-pick-azure.ts @@ -0,0 +1,58 @@ +import { ContainerRegistryManagementClient } from 'azure-arm-containerregistry'; +import { Registry } from 'azure-arm-containerregistry/lib/models'; +import * as vscode from "vscode"; +import * as acrTools from '../../utils/Azure/acrTools'; +import { AzureImage } from "../../utils/Azure/models/image"; +import { Repository } from "../../utils/Azure/models/Repository"; +import { AzureCredentialsManager } from '../../utils/azureCredentialsManager'; + +/** + * function to allow user to pick a desired image for use + * @param repository the repository to look in + * @returns an AzureImage object (see azureUtils.ts) + */ +export async function quickPickACRImage(repository: Repository): Promise { + const repoImages: AzureImage[] = await acrTools.getAzureImages(repository); + let imageListNames: string[] = []; + for (let tempImage of repoImages) { + imageListNames.push(tempImage.tag); + } + let desiredImage = await vscode.window.showQuickPick(imageListNames, { 'canPickMany': false, 'placeHolder': 'Choose the image you want to delete' }); + if (!desiredImage) { return; } + const image = repoImages.find((myImage): boolean => { return desiredImage === myImage.tag }); + return image; +} + +/** + * function to allow user to pick a desired repository for use + * @param registry the registry to choose a repository from + * @returns a Repository object (see azureUtils.ts) + */ +export async function quickPickACRRepository(registry: Registry): Promise { + const myRepos: Repository[] = await acrTools.getAzureRepositories(registry); + let rep: string[] = []; + for (let repo of myRepos) { + rep.push(repo.name); + } + let desiredRepo = await vscode.window.showQuickPick(rep, { 'canPickMany': false, 'placeHolder': 'Choose the repository from which your desired image exists' }); + if (!desiredRepo) { return; } + const repository = myRepos.find((currentRepo): boolean => { return desiredRepo === currentRepo.name }); + return repository; +} + +/** + * function to let user choose a registry for use + * @returns a Registry object + */ +export async function quickPickACRRegistry(): Promise { + //first get desired registry + let registries = await AzureCredentialsManager.getInstance().getRegistries(); + let reg: string[] = []; + for (let registryName of registries) { + reg.push(registryName.name); + } + let desired = await vscode.window.showQuickPick(reg, { 'canPickMany': false, 'placeHolder': 'Choose the Registry from which your desired image exists' }); + if (!desired) { return; } + const registry = registries.find((currentReg): boolean => { return desired === currentReg.name }); + return registry; +} diff --git a/dockerExtension.ts b/dockerExtension.ts index 2cac6f0be5..c38ba577e5 100644 --- a/dockerExtension.ts +++ b/dockerExtension.ts @@ -7,6 +7,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { AzureUserInput } from 'vscode-azureextensionui'; import { ConfigurationParams, DidChangeConfigurationNotification, DocumentSelector, LanguageClient, LanguageClientOptions, Middleware, ServerOptions, TransportKind } from 'vscode-languageclient'; +import { deleteAzureImage } from './commands/azureCommands/delete-azure-image'; import { buildImage } from './commands/build-image'; import { composeDown, composeRestart, composeUp } from './commands/docker-compose'; import inspectImage from './commands/inspect-image'; @@ -117,7 +118,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ctx.subscriptions.push(vscode.commands.registerCommand('vscode-docker.compose.down', composeDown)); ctx.subscriptions.push(vscode.commands.registerCommand('vscode-docker.compose.restart', composeRestart)); ctx.subscriptions.push(vscode.commands.registerCommand('vscode-docker.system.prune', systemPrune)); + ctx.subscriptions.push(vscode.commands.registerCommand('vscode-docker.deleteAzureImage', deleteAzureImage)); ctx.subscriptions.push(vscode.commands.registerCommand('vscode-docker.createRegistry', createRegistry)); + ctx.subscriptions.push(vscode.commands.registerCommand('vscode-docker.createWebApp', async (context?: AzureImageNode | DockerHubImageNode) => { if (context) { if (azureAccount) { diff --git a/package.json b/package.json index afb138d79f..fbd5ea054b 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "onCommand:vscode-docker.browseDockerHub", "onCommand:vscode-docker.browseAzurePortal", "onCommand:vscode-docker.explorer.refresh", + "onCommand:vscode-docker.deleteAzureImage", "onView:dockerExplorer", "onDebugInitialConfigurations" ], @@ -256,6 +257,10 @@ "command": "vscode-docker.dockerHubLogout", "when": "view == dockerExplorer && viewItem == dockerHubRootNode" }, + { + "command": "vscode-docker.deleteAzureImage", + "when": "view == dockerExplorer && viewItem == azureImageNode" + }, { "command": "vscode-docker.browseDockerHub", "when": "view == dockerExplorer && viewItem == dockerHubImageTag" @@ -620,6 +625,11 @@ "command": "vscode-docker.browseAzurePortal", "title": "Browse in the Azure Portal", "category": "Docker" + }, + { + "command": "vscode-docker.deleteAzureImage", + "title": "Delete Azure Image", + "category": "Docker" } ], "views": { diff --git a/utils/Azure/acrTools.ts b/utils/Azure/acrTools.ts new file mode 100644 index 0000000000..ca98da0f5a --- /dev/null +++ b/utils/Azure/acrTools.ts @@ -0,0 +1,275 @@ +import { Registry } from "azure-arm-containerregistry/lib/models"; +import { SubscriptionModels } from 'azure-arm-resource'; +import request = require('request-promise'); +import * as vscode from "vscode"; +import { AzureImageNode, AzureRepositoryNode } from '../../explorer/models/AzureRegistryNodes'; +import { AzureAccount, AzureSession } from "../../typings/azure-account.api"; +import { AzureImage } from "../Azure/models/image"; +import { Repository } from "../Azure/models/Repository"; +import { AzureCredentialsManager } from '../azureCredentialsManager'; +const teleCmdId: string = 'vscode-docker.deleteAzureImage'; + +/** + * Developers can use this to visualize and list repositories on a given Registry. This is not a command, just a developer tool. + * @param registry : the registry whose repositories you want to see + * @returns allRepos : an array of Repository objects that exist within the given registry + */ +export async function getAzureRepositories(registry: Registry): Promise { + const allRepos: Repository[] = []; + let repo: Repository; + let azureAccount: AzureAccount = AzureCredentialsManager.getInstance().getAccount(); + if (!azureAccount) { + return []; + } + const { accessToken, refreshToken } = await getRegistryTokens(registry); + if (accessToken && refreshToken) { + + await request.get('https://' + registry.loginServer + '/v2/_catalog', { + auth: { + bearer: accessToken + } + }, (err, httpResponse, body) => { + if (body.length > 0) { + const repositories = JSON.parse(body).repositories; + for (let tempRepo of repositories) { + repo = new Repository(registry, tempRepo, accessToken, refreshToken); + allRepos.push(repo); + } + } + }); + } + //Note these are ordered by default in alphabetical order + return allRepos; +} + +/** + * @param registry : the registry to get credentials for + * @returns : the updated refresh and access tokens which can be used to generate a header for an API call + */ +export async function getRegistryTokens(registry: Registry): Promise<{ refreshToken: any, accessToken: any }> { + const subscription = getRegistrySubscription(registry); + const tenantId: string = subscription.tenantId; + let azureAccount: AzureAccount = AzureCredentialsManager.getInstance().getAccount(); + + const session: AzureSession = azureAccount.sessions.find((s, i, array) => s.tenantId.toLowerCase() === tenantId.toLowerCase()); + const { accessToken } = await acquireARMToken(session); + + //regenerates in case they have expired + if (accessToken) { + let refreshTokenACR; + let accessTokenACR; + + await request.post('https://' + registry.loginServer + '/oauth2/exchange', { + form: { + grant_type: 'access_token', + service: registry.loginServer, + tenant: tenantId, + access_token: accessToken + } + }, (err, httpResponse, body) => { + if (body.length > 0) { + refreshTokenACR = JSON.parse(body).refresh_token; + } else { + return; + } + }); + + await request.post('https://' + registry.loginServer + '/oauth2/token', { + form: { + grant_type: 'refresh_token', + service: registry.loginServer, + scope: 'registry:catalog:*', + refresh_token: refreshTokenACR + } + }, (err, httpResponse, body) => { + if (body.length > 0) { + accessTokenACR = JSON.parse(body).access_token; + } else { + return; + } + }); + if (refreshTokenACR && accessTokenACR) { + return { 'refreshToken': refreshTokenACR, 'accessToken': accessTokenACR }; + } + } + vscode.window.showErrorMessage('Could not generate tokens'); +} + +export async function acquireARMToken(localSession: AzureSession): Promise<{ accessToken: string; }> { + return new Promise<{ accessToken: string; }>((resolve, reject) => { + const credentials: any = localSession.credentials; + const environment: any = localSession.environment; + // tslint:disable-next-line:no-function-expression // Grandfathered in + credentials.context.acquireToken(environment.activeDirectoryResourceId, credentials.username, credentials.clientId, function (err: any, result: { accessToken: string; }): void { + if (err) { + reject(err); + } else { + resolve({ + accessToken: result.accessToken + }); + } + }); + }); +} + +/** + * + * function used to create header for http request to acr + */ +export function getAuthorizationHeader(username: string, password: string): string { + let auth; + if (username === '00000000-0000-0000-0000-000000000000') { + auth = 'Bearer ' + password; + } else { + auth = ('Basic ' + (encode(username + ':' + password).trim())); + } + return auth; +} + +/** + * first encodes to base 64, and then to latin1. See online documentation to see typescript encoding capabilities + * see https://nodejs.org/api/buffer.html#buffer_buf_tostring_encoding_start_end for details {Buffers and Character Encodings} + * current character encodings include: ascii, utf8, utf16le, ucs2, base64, latin1, binary, hex. Version v6.4.0 + * @param str : the string to encode for api URL purposes + */ +export function encode(str: string): string { + let bufferB64 = new Buffer(str); + let bufferLat1 = new Buffer(bufferB64.toString('base64')); + return bufferLat1.toString('latin1'); +} + +/** + * Lots of https requests but they must be separate from getTokens because the forms are different + * @param element the repository where the desired images are + * @returns a list of AzureImage objects from the given repository (see azureUtils.ts) + */ +export async function getAzureImages(element: Repository): Promise { + let allImages: AzureImage[] = []; + let image: AzureImage; + let tags; + let azureAccount: AzureAccount = AzureCredentialsManager.getInstance().getAccount(); + let tenantId: string = element.subscription.tenantId; + let refreshTokenACR; + let accessTokenACR; + const session: AzureSession = azureAccount.sessions.find((s, i, array) => s.tenantId.toLowerCase() === tenantId.toLowerCase()); + const { accessToken } = await acquireARMToken(session); + if (accessToken) { + await request.post('https://' + element.registry.loginServer + '/oauth2/exchange', { + form: { + grant_type: 'access_token', + service: element.registry.loginServer, + tenant: tenantId, + access_token: accessToken + } + }, (err, httpResponse, body) => { + if (body.length > 0) { + refreshTokenACR = JSON.parse(body).refresh_token; + } else { + return []; + } + }); + + await request.post('https://' + element.registry.loginServer + '/oauth2/token', { + form: { + grant_type: 'refresh_token', + service: element.registry.loginServer, + scope: 'repository:' + element.name + ':pull', + refresh_token: refreshTokenACR + } + }, (err, httpResponse, body) => { + if (body.length > 0) { + accessTokenACR = JSON.parse(body).access_token; + } else { + return []; + } + }); + + await request.get('https://' + element.registry.loginServer + '/v2/' + element.name + '/tags/list', { + auth: { + bearer: accessTokenACR + } + }, (err, httpResponse, body) => { + if (err) { return []; } + + if (body.length > 0) { + tags = JSON.parse(body).tags; + } + }); + + for (let tag of tags) { + image = new AzureImage(element, tag); + allImages.push(image); + } + } + return allImages; +} + +//Implements new Service principal model for ACR container registries while maintaining old admin enabled use +/** + * this function implements a new Service principal model for ACR and gets the valid login credentials to make an API call + * @param subscription : the subscription the registry is on + * @param registry : the registry to get login credentials for + * @param context : if command is invoked through a right click on an AzureRepositoryNode. This context has a password and username + */ +export async function loginCredentials(subscription: SubscriptionModels.Subscription, registry: Registry, context?: AzureImageNode | AzureRepositoryNode): Promise<{ password: string, username: string }> { + let node: AzureImageNode | AzureRepositoryNode; + if (context) { + node = context; + } + let username: string; + let password: string; + const client = AzureCredentialsManager.getInstance().getContainerRegistryManagementClient(subscription); + const resourceGroup: string = registry.id.slice(registry.id.search('resourceGroups/') + 'resourceGroups/'.length, registry.id.search('/providers/')); + if (context) { + username = node.userName; + password = node.password; + } else if (registry.adminUserEnabled) { + let creds = await client.registries.listCredentials(resourceGroup, registry.name); + password = creds.passwords[0].value; + username = creds.username; + } else { + //grab the access token to be used as a password, and a generic username + let creds = await getRegistryTokens(registry); + password = creds.accessToken; + username = '00000000-0000-0000-0000-000000000000'; + } + return { password, username }; +} + +/** + * + * @param http_method : the http method, this function currently only uses delete + * @param login_server: the login server of the registry + * @param path : the URL path + * @param username : registry username, can be in generic form of 0's, used to generate authorization header + * @param password : registry password, can be in form of accessToken, used to generate authorization header + */ +export async function sendRequestToRegistry(http_method: string, login_server: string, path: string, username: string, password: string): Promise { + let url: string = `https://${login_server}${path}`; + let header = getAuthorizationHeader(username, password); + let opt = { + headers: { 'Authorization': header }, + http_method: http_method, + url: url + } + try { + await request.delete(opt); + } catch (error) { + throw error; + } + vscode.window.showInformationMessage('Successfully deleted image'); +} + +/** + * + * @param registry gets the subscription for a given registry + * @returns a subscription object + */ +export function getRegistrySubscription(registry: Registry): SubscriptionModels.Subscription { + let subscriptionId = registry.id.slice('/subscriptions/'.length, registry.id.search('/resourceGroups/')); + const subs = AzureCredentialsManager.getInstance().getFilteredSubscriptionList(); + let subscription = subs.find((sub): boolean => { + return sub.subscriptionId === subscriptionId; + }); + return subscription; +} diff --git a/utils/Azure/models/image.ts b/utils/Azure/models/image.ts new file mode 100644 index 0000000000..0f047e3453 --- /dev/null +++ b/utils/Azure/models/image.ts @@ -0,0 +1,32 @@ +import { Registry } from 'azure-arm-containerregistry/lib/models'; +import { SubscriptionModels } from 'azure-arm-resource'; +import { AzureAccount, AzureSession } from '../../../typings/azure-account.api'; +import { Repository } from '../models/repository'; + +/** + * class Repository: used locally as of August 2018, primarily for functions within azureUtils.ts and new commands such as delete Repository + * accessToken can be used like a password, and the username can be '00000000-0000-0000-0000-000000000000' + */ +export class AzureImage { + public registry: Registry; + public repository: Repository; + public tag: string; + public subscription: SubscriptionModels.Subscription; + public resourceGroupName: string; + public accessToken?: string; + public refreshToken?: string; + public password?: string; + public username?: string; + + constructor(repository: Repository, tag: string) { + this.registry = repository.registry; + this.repository = repository; + this.tag = tag; + this.subscription = repository.subscription; + this.resourceGroupName = repository.resourceGroupName; + if (repository.accessToken) { this.accessToken = repository.accessToken; } + if (repository.refreshToken) { this.refreshToken = repository.refreshToken; } + if (repository.password) { this.password = repository.password; } + if (repository.username) { this.username = repository.username; } + } +} diff --git a/utils/Azure/models/repository.ts b/utils/Azure/models/repository.ts new file mode 100644 index 0000000000..5491269b39 --- /dev/null +++ b/utils/Azure/models/repository.ts @@ -0,0 +1,36 @@ +import { Registry } from 'azure-arm-containerregistry/lib/models'; +import { SubscriptionModels } from 'azure-arm-resource'; +import { AzureAccount, AzureSession } from '../../../typings/azure-account.api'; +import * as acrTools from '../../../utils/Azure/acrTools'; +import { AzureCredentialsManager } from '../../AzureCredentialsManager'; +/** + * class Repository: used locally as of August 2018, primarily for functions within azureUtils.ts and new commands such as delete Repository + * accessToken can be used like a password, and the username can be '00000000-0000-0000-0000-000000000000' + */ +export class Repository { + public registry: Registry; + public name: string; + public subscription: SubscriptionModels.Subscription; + public resourceGroupName: string; + public accessToken?: string; + public refreshToken?: string; + public password?: string; + public username?: string; + + constructor(registry: Registry, repository: string, accessToken?: string, refreshToken?: string, password?: string, username?: string) { + this.registry = registry; + this.resourceGroupName = registry.id.slice(registry.id.search('resourceGroups/') + 'resourceGroups/'.length, registry.id.search('/providers/')); + this.subscription = acrTools.getRegistrySubscription(registry); + this.name = repository; + if (accessToken) { this.accessToken = accessToken; } + if (refreshToken) { this.refreshToken = refreshToken; } + if (password) { this.password = password; } + if (username) { this.username = username; } + } + + public async setTokens(registry: Registry): Promise { + let tokens = await acrTools.getRegistryTokens(registry); + this.accessToken = tokens.accessToken; + this.refreshToken = tokens.refreshToken; + } +} diff --git a/utils/azureCredentialsManager.ts b/utils/azureCredentialsManager.ts index 3a491e6a2c..a7839e3f78 100644 --- a/utils/azureCredentialsManager.ts +++ b/utils/azureCredentialsManager.ts @@ -1,15 +1,15 @@ import { ContainerRegistryManagementClient } from 'azure-arm-containerregistry'; +import { Registry } from 'azure-arm-containerregistry/lib/models'; import { ResourceManagementClient, SubscriptionClient, SubscriptionModels } from 'azure-arm-resource'; import { ResourceGroup, ResourceGroupListResult } from "azure-arm-resource/lib/resource/models"; import { ServiceClientCredentials } from 'ms-rest'; import * as ContainerModels from '../node_modules/azure-arm-containerregistry/lib/models'; -import { AzureAccount } from '../typings/azure-account.api'; +import { AzureAccount, AzureSession } from '../typings/azure-account.api'; import { AsyncPool } from '../utils/asyncpool'; import { MAX_CONCURRENT_SUBSCRIPTON_REQUESTS } from './constants'; - /* 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 AzureCredentialsManager { @@ -82,17 +82,16 @@ export class AzureCredentialsManager { for (let sub of subs) { subPool.addTask(async () => { const client = this.getContainerRegistryManagementClient(sub); + let subscriptionRegistries: ContainerModels.Registry[] = await client.registries.list(); registries = registries.concat(subscriptionRegistries); }); } await subPool.runAll(); } - if (sortFunction && registries.length > 1) { registries.sort(sortFunction); } - return registries; } @@ -101,13 +100,14 @@ export class AzureCredentialsManager { const resourceClient = this.getResourceManagementClient(subscription); return await resourceClient.resourceGroups.list(); } - const subs = this.getFilteredSubscriptionList(); + const subs: SubscriptionModels.Subscription[] = this.getFilteredSubscriptionList(); const subPool = new AsyncPool(MAX_CONCURRENT_SUBSCRIPTON_REQUESTS); let resourceGroups: ResourceGroup[] = []; //Acquire each subscription's data simultaneously - for (let sub of subs) { + + for (let tempSub of subs) { subPool.addTask(async () => { - const resourceClient = this.getResourceManagementClient(sub); + const resourceClient = this.getResourceManagementClient(tempSub); const internalGroups = await resourceClient.resourceGroups.list(); resourceGroups = resourceGroups.concat(internalGroups); }); @@ -117,13 +117,10 @@ export class AzureCredentialsManager { } public getCredentialByTenantId(tenantId: string): ServiceClientCredentials { - const session = this.getAccount().sessions.find((azureSession) => azureSession.tenantId.toLowerCase() === tenantId.toLowerCase()); - if (session) { return session.credentials; } - throw new Error(`Failed to get credentials, tenant ${tenantId} not found.`); }