diff --git a/.vscode/launch.json b/.vscode/launch.json index 6860027536..174d34be4b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,107 +1,107 @@ // A launch configuration that compiles the extension and then opens it inside a new window { - "version": "0.1.0", - "configurations": [ - { - "name": "Launch Extension", - "type": "extensionHost", - "request": "launch", - "runtimeExecutable": "${execPath}", - "args": [ - "--extensionDevelopmentPath=${workspaceFolder}" - ], - "env": { - "AZCODE_DOCKER_IGNORE_BUNDLE": "1", - "DEBUGTELEMETRY": "1" - }, - "stopOnEntry": false, - "sourceMaps": true, - "outFiles": [ - "${workspaceFolder}/out/**/*.js" - ], - "preLaunchTask": "${defaultBuildTask}" - }, - { - "name": "Launch Extension (webpack)", - "type": "extensionHost", - "request": "launch", - "runtimeExecutable": "${execPath}", - "args": [ - "--extensionDevelopmentPath=${workspaceFolder}" - ], - "env": { - "AZCODE_DOCKER_IGNORE_BUNDLE": "", - "DEBUGTELEMETRY": "1" - }, - "stopOnEntry": false, - "sourceMaps": true, - "outFiles": [ - "${workspaceFolder}/out/**/*.js" - ], - "preLaunchTask": "npm: webpack-prod" - }, - { - "name": "Launch Tests", - "type": "extensionHost", - "request": "launch", - "runtimeExecutable": "${execPath}", - "args": [ - "${workspaceFolder}/test/test.code-workspace", - "--extensionDevelopmentPath=${workspaceFolder}", - "--extensionTestsPath=${workspaceFolder}/out/test/index" - ], - "stopOnEntry": false, - "sourceMaps": true, - "outFiles": [ - "${workspaceFolder}/out/**/*.js" - ], - "preLaunchTask": "${defaultBuildTask}", - "env": { - "AZCODE_DOCKER_IGNORE_BUNDLE": "1", - "MOCHA_grep": "", // RegExp of tests to run (empty means all) - } - }, - { - "name": "Launch Tests (unit)", - "type": "extensionHost", - "request": "launch", - "runtimeExecutable": "${execPath}", - "args": [ - "${workspaceFolder}/test/test.code-workspace", - "--extensionDevelopmentPath=${workspaceFolder}", - "--extensionTestsPath=${workspaceFolder}/out/test/index" - ], - "stopOnEntry": false, - "sourceMaps": true, - "outFiles": [ - "${workspaceFolder}/out/**/*.js" - ], - "preLaunchTask": "${defaultBuildTask}", - "env": { - "AZCODE_DOCKER_IGNORE_BUNDLE": "1", - "MOCHA_grep": "\\(unit\\)", // RegExp of tests to run (empty means all) - } - }, - { - "name": "Launch Tests (webpack)", - "type": "extensionHost", - "request": "launch", - "runtimeExecutable": "${execPath}", - "args": [ - "${workspaceFolder}/test/test.code-workspace", - "--extensionDevelopmentPath=${workspaceFolder}", - "--extensionTestsPath=${workspaceFolder}/dist/test/index" - ], - "stopOnEntry": false, - "sourceMaps": true, - "outFiles": [ - "${workspaceFolder}/dist/**/*.js" - ], - "preLaunchTask": "npm: webpack-prod", - "env": { - "AZCODE_DOCKER_IGNORE_BUNDLE": "", - "MOCHA_grep": "", // RegExp of tests to run (empty means all) - } - } - ] + "version": "0.1.0", + "configurations": [ + { + "name": "Launch Extension", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}" + ], + "env": { + "AZCODE_DOCKER_IGNORE_BUNDLE": "1", + "DEBUGTELEMETRY": "1" + }, + "stopOnEntry": false, + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/out/**/*.js" + ], + "preLaunchTask": "${defaultBuildTask}" + }, + { + "name": "Launch Extension (webpack)", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}" + ], + "env": { + "AZCODE_DOCKER_IGNORE_BUNDLE": "", + "DEBUGTELEMETRY": "1" + }, + "stopOnEntry": false, + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/out/**/*.js" + ], + "preLaunchTask": "npm: webpack-prod" + }, + { + "name": "Launch Tests", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "${workspaceFolder}/test/test.code-workspace", + "--extensionDevelopmentPath=${workspaceFolder}", + "--extensionTestsPath=${workspaceFolder}/out/test/index" + ], + "stopOnEntry": false, + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/out/**/*.js" + ], + "preLaunchTask": "${defaultBuildTask}", + "env": { + "AZCODE_DOCKER_IGNORE_BUNDLE": "1", + "MOCHA_grep": "", // RegExp of tests to run (empty means all) + } + }, + { + "name": "Launch Tests (unit)", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "${workspaceFolder}/test/test.code-workspace", + "--extensionDevelopmentPath=${workspaceFolder}", + "--extensionTestsPath=${workspaceFolder}/out/test/index" + ], + "stopOnEntry": false, + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/out/**/*.js" + ], + "preLaunchTask": "${defaultBuildTask}", + "env": { + "AZCODE_DOCKER_IGNORE_BUNDLE": "1", + "MOCHA_grep": "\\(unit\\)", // RegExp of tests to run (empty means all) + } + }, + { + "name": "Launch Tests (webpack)", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "${workspaceFolder}/test/test.code-workspace", + "--extensionDevelopmentPath=${workspaceFolder}", + "--extensionTestsPath=${workspaceFolder}/dist/test/index" + ], + "stopOnEntry": false, + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/dist/**/*.js" + ], + "preLaunchTask": "npm: webpack-prod", + "env": { + "AZCODE_DOCKER_IGNORE_BUNDLE": "", + "MOCHA_grep": "", // RegExp of tests to run (empty means all) + } + } + ] } diff --git a/package.json b/package.json index fd32190a1e..de2c4674c6 100644 --- a/package.json +++ b/package.json @@ -1782,6 +1782,11 @@ "Label" ] }, + "docker.images.checkForOutdatedImages": { + "type": "boolean", + "default": true, + "description": "%vscode-docker.config.docker.images.checkForOutdatedImages%" + }, "docker.networks.groupBy": { "type": "string", "default": "None", diff --git a/package.nls.json b/package.nls.json index f8c73837cd..69024c37fc 100644 --- a/package.nls.json +++ b/package.nls.json @@ -136,6 +136,7 @@ "vscode-docker.config.docker.images.description": "Any secondary properties to display for a image (an array). Possible elements include: CreatedTime, FullTag, ImageId, Registry, Repository, RepositoryName, RepositoryNameAndTag, and Tag", "vscode-docker.config.docker.images.label": "The primary property to display for a image: CreatedTime, FullTag, ImageId, Registry, Repository, RepositoryName, RepositoryNameAndTag, or Tag", "vscode-docker.config.docker.images.sortBy": "The property to use to sort images in Docker view: CreatedTime or Label", + "vscode-docker.config.docker.images.checkForOutdatedImages": "Check for outdated base images once per Visual Studio Code session", "vscode-docker.config.docker.networks.groupBy": "The property to use to group networks in Docker view: CreatedTime, NetworkDriver, NetworkId, NetworkName, or None", "vscode-docker.config.docker.networks.description": "Any secondary properties to display for a Docker network (an array). Possible elements include CreatedTime, NetworkDriver, NetworkId, and NetworkName", "vscode-docker.config.docker.networks.label": "The primary property to display for a Docker network: CreatedTime, NetworkDriver, NetworkId, or NetworkName", diff --git a/src/constants.ts b/src/constants.ts index b93d1c173d..be32276c60 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -11,14 +11,6 @@ export const configPrefix: string = 'docker'; // Consider downloading multiple pages (images, tags, etc) export const PAGE_SIZE = 100; -export namespace keytarConstants { - export const serviceId: string = 'vscode-docker'; - - export const dockerHubTokenKey: string = 'dockerhub.token'; - export const dockerHubUserNameKey: string = 'dockerhub.username'; - export const dockerHubPasswordKey: string = 'dockerhub.password'; -} - export namespace configurationKeys { export const groupImagesBy = 'groupImagesBy'; } diff --git a/src/docker/Images.ts b/src/docker/Images.ts index a80b128d32..53680b9c45 100644 --- a/src/docker/Images.ts +++ b/src/docker/Images.ts @@ -12,6 +12,7 @@ export interface DockerImage extends DockerObject { export interface DockerImageInspection extends DockerObject { readonly Config?: { readonly ExposedPorts?: { readonly [portAndProtocol: string]: unknown; }; + readonly Image?: string; }; readonly Name: undefined; // Not defined for inspection diff --git a/src/tree/images/ImageGroupTreeItem.ts b/src/tree/images/ImageGroupTreeItem.ts index 97a3c78a23..421974edc8 100644 --- a/src/tree/images/ImageGroupTreeItem.ts +++ b/src/tree/images/ImageGroupTreeItem.ts @@ -3,12 +3,12 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { DockerImage } from "../../docker/Images"; import { IconPath } from "../IconPath"; import { LocalGroupTreeItemBase } from "../LocalGroupTreeItemBase"; import { getImageGroupIcon, ImageProperty } from "./ImageProperties"; +import { DatedDockerImage } from "./ImagesTreeItem"; -export class ImageGroupTreeItem extends LocalGroupTreeItemBase { +export class ImageGroupTreeItem extends LocalGroupTreeItemBase { public static readonly contextValue: string = 'imageGroup'; public readonly contextValue: string = ImageGroupTreeItem.contextValue; public childTypeLabel: string = 'image'; diff --git a/src/tree/images/ImageTreeItem.ts b/src/tree/images/ImageTreeItem.ts index 88ff5fd6be..b477e2dfad 100644 --- a/src/tree/images/ImageTreeItem.ts +++ b/src/tree/images/ImageTreeItem.ts @@ -4,17 +4,18 @@ *--------------------------------------------------------------------------------------------*/ import { AzExtParentTreeItem, AzExtTreeItem, IActionContext } from "vscode-azureextensionui"; -import { DockerImage } from '../../docker/Images'; import { ext } from '../../extensionVariables'; +import { localize } from "../../localize"; import { getThemedIconPath, IconPath } from '../IconPath'; import { getTreeId } from "../LocalRootTreeItemBase"; +import { DatedDockerImage } from "./ImagesTreeItem"; export class ImageTreeItem extends AzExtTreeItem { public static contextValue: string = 'image'; public contextValue: string = ImageTreeItem.contextValue; - private readonly _item: DockerImage; + private readonly _item: DatedDockerImage; - public constructor(parent: AzExtParentTreeItem, itemInfo: DockerImage) { + public constructor(parent: AzExtParentTreeItem, itemInfo: DatedDockerImage) { super(parent); this._item = itemInfo; } @@ -40,10 +41,14 @@ export class ImageTreeItem extends AzExtTreeItem { } public get description(): string | undefined { - return ext.imagesRoot.getTreeItemDescription(this._item); + return `${ext.imagesRoot.getTreeItemDescription(this._item)}${this._item.Outdated ? localize('vscode-docker.tree.images.outdated', ' (Out of date)') : ''}`; } public get iconPath(): IconPath { + if (this._item.Outdated) { + return getThemedIconPath('statusWarning'); + } + let icon: string; switch (ext.imagesRoot.labelSetting) { case 'Tag': diff --git a/src/tree/images/ImagesTreeItem.ts b/src/tree/images/ImagesTreeItem.ts index b2866e23d5..c41aad87dc 100644 --- a/src/tree/images/ImagesTreeItem.ts +++ b/src/tree/images/ImagesTreeItem.ts @@ -13,14 +13,21 @@ import { ITreeArraySettingInfo, ITreeSettingInfo } from "../settings/ITreeSettin import { ImageGroupTreeItem } from './ImageGroupTreeItem'; import { getImagePropertyValue, imageProperties, ImageProperty } from "./ImageProperties"; import { ImageTreeItem } from "./ImageTreeItem"; +import { OutdatedImageChecker } from "./OutdatedImageChecker"; + +export interface DatedDockerImage extends DockerImage { + Outdated?: boolean; +} + +export class ImagesTreeItem extends LocalRootTreeItemBase { + private readonly outdatedImageChecker: OutdatedImageChecker = new OutdatedImageChecker(); -export class ImagesTreeItem extends LocalRootTreeItemBase { public treePrefix: string = 'images'; public label: string = localize('vscode-docker.tree.images.label', 'Images'); public configureExplorerTitle: string = localize('vscode-docker.tree.images.configure', 'Configure images explorer'); - public childType: LocalChildType = ImageTreeItem; - public childGroupType: LocalChildGroupType = ImageGroupTreeItem; + public childType: LocalChildType = ImageTreeItem; + public childGroupType: LocalChildGroupType = ImageGroupTreeItem; public labelSettingInfo: ITreeSettingInfo = { properties: imageProperties, @@ -41,8 +48,10 @@ export class ImagesTreeItem extends LocalRootTreeItemBase { - return ext.dockerClient.getImages(context); + public async getItems(context: IActionContext): Promise { + const result = await ext.dockerClient.getImages(context); + this.outdatedImageChecker.markOutdatedImages(result); + return result; } public getPropertyValue(item: DockerImage, property: ImageProperty): string { diff --git a/src/tree/images/OutdatedImageChecker.ts b/src/tree/images/OutdatedImageChecker.ts new file mode 100644 index 0000000000..4a926ad205 --- /dev/null +++ b/src/tree/images/OutdatedImageChecker.ts @@ -0,0 +1,170 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Response } from 'request'; +import * as request from 'request-promise-native'; +import * as vscode from 'vscode'; +import { callWithTelemetryAndErrorHandling, IActionContext } from 'vscode-azureextensionui'; +import { DockerImage } from '../../docker/Images'; +import { ext } from '../../extensionVariables'; +import { localize } from '../../localize'; +import { AsyncLazy, Lazy } from '../../utils/lazy'; +import { IOAuthContext } from '../registries/auth/IAuthProvider'; +import { getWwwAuthenticateContext } from '../registries/auth/oAuthUtils'; +import { getImagePropertyValue } from './ImageProperties'; +import { DatedDockerImage } from './ImagesTreeItem'; + +const noneRegex = //i; + +export class OutdatedImageChecker { + private shouldLoad: boolean; + private readonly outdatedImageIds: string[] = []; + private readonly authContext: AsyncLazy; + private readonly defaultRequestOptions: Lazy; + + public constructor() { + const dockerConfig = vscode.workspace.getConfiguration('docker'); + this.shouldLoad = dockerConfig.get('images.checkForOutdatedImages'); + + this.defaultRequestOptions = new Lazy(() => this.getRequestOptions()); + this.authContext = new AsyncLazy(async () => this.getAuthContext()); + } + + public markOutdatedImages(images: DatedDockerImage[]): void { + if (this.shouldLoad) { + this.shouldLoad = false; + + // Don't wait + void callWithTelemetryAndErrorHandling('outdatedImageCheck', async (context: IActionContext) => { + context.errorHandling.suppressReportIssue = true; + context.errorHandling.suppressDisplay = true; + + const imageCheckPromises = images + .filter(image => { + // Only include images that are potentially in docker.io/library (no private or other public registries are supported) + return /docker[.]io\/library/i.test(getImagePropertyValue(image, 'Registry')); + }) + .map(async (image) => { + if (await this.checkImage(context, image) === 'outdated') { + this.outdatedImageIds.push(image.Id); + } + }); + + context.telemetry.measurements.imagesChecked = imageCheckPromises.length; + + // Load the data for all images then force the tree to refresh + await Promise.all(imageCheckPromises); + + context.telemetry.measurements.outdatedImages = this.outdatedImageIds.length; + + // Don't wait + void ext.imagesRoot.refresh(); + }); + } + + for (const image of images) { + image.Outdated = this.outdatedImageIds.some(i => i.toLowerCase() === image.Id.toLowerCase()); + } + } + + private async checkImage(context: IActionContext, image: DockerImage): Promise<'latest' | 'outdated' | 'unknown'> { + try { + const [repo, tag] = image.Name.split(':'); + + if (noneRegex.test(repo) || noneRegex.test(tag)) { + return 'outdated'; + } + + // 1. Get an OAuth token to access the resource. No Authorization header is required for public scopes. + const token = await this.getToken(`repository:library/${repo}:pull`); + + // 2. Get the latest image ID from the manifest + const latestConfigImageId = await this.getLatestConfigImageId(repo, tag, token); + + // 3. Compare it with the current image's value + const imageInspectInfo = await ext.dockerClient.inspectImage(context, image.Id); + + if (latestConfigImageId.toLowerCase() !== imageInspectInfo?.Config?.Image?.toLowerCase()) { + return 'outdated'; + } + + return 'latest'; + } catch { // Errors are expected, e.g. all untagged local images are treated as if they are in docker.io/library, but will 404 when queried + return 'unknown'; + } + } + + private async getToken(scope: string): Promise { + const authContext = { + ...await this.authContext.getValue(), + scope: scope, + }; + + const authOptions: request.RequestPromiseOptions = { + ...this.defaultRequestOptions.value, + qs: { + service: authContext.service, + scope: authContext.scope, + }, + }; + + const tokenResponse = await request(authContext.realm.toString(), authOptions) as Response; + // eslint-disable-next-line @typescript-eslint/tslint/config + const token: string = tokenResponse?.body?.token; + + if (!token) { + throw new Error(localize('vscode-docker.outdatedImageChecker.noToken', 'Failed to acquire OAuth token for scope: \'{0}\'', scope)); + } + + return token; + } + + private async getLatestConfigImageId(repo: string, tag: string, oAuthToken: string): Promise { + const manifestOptions: request.RequestPromiseOptions = { + ...this.defaultRequestOptions.value, + auth: { + bearer: oAuthToken, + }, + }; + + const manifestResponse = await request(`https://registry-1.docker.io/v2/library/${repo}/manifests/${tag}`, manifestOptions) as Response; + /* eslint-disable @typescript-eslint/tslint/config */ + const firstHistory = JSON.parse(manifestResponse?.body?.history?.[0]?.v1Compatibility); + const latestConfigImageId: string = firstHistory?.config?.Image; + /* eslint-enable @typescript-eslint/tslint/config */ + + if (!latestConfigImageId) { + throw new Error(localize('vscode-docker.outdatedImageChecker.noManifest', 'Failed to acquire manifest token for image: \'{0}:{1}\'', repo, tag)); + } + + return latestConfigImageId; + } + + private async getAuthContext(): Promise { + try { + const options = this.defaultRequestOptions.value; + await request('https://registry-1.docker.io/v2/', options); + } catch (err) { + const result = getWwwAuthenticateContext(err); + + if (!result) { + throw err; + } + + return result; + } + } + + private getRequestOptions(): request.RequestPromiseOptions { + const httpSettings = vscode.workspace.getConfiguration('http'); + const strictSSL = httpSettings.get('proxyStrictSSL', true); + return { + method: 'GET', + json: true, + resolveWithFullResponse: true, + strictSSL: strictSSL + }; + } +}