From 4d4c5cae25398e4542c03137334fd842b5974112 Mon Sep 17 00:00:00 2001 From: Samruddhi Khandale Date: Fri, 1 Mar 2024 01:00:11 +0000 Subject: [PATCH 01/20] Outdated image for devcontainer, dockerfile and dockercompose --- .../containerFeaturesConfiguration.ts | 160 +++++++++++++++++- src/spec-node/devContainersSpecCLI.ts | 82 +++++++-- 2 files changed, 228 insertions(+), 14 deletions(-) diff --git a/src/spec-configuration/containerFeaturesConfiguration.ts b/src/spec-configuration/containerFeaturesConfiguration.ts index d5cf48efb..504b5015f 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -10,8 +10,9 @@ import * as tar from 'tar'; import * as crypto from 'crypto'; import * as semver from 'semver'; import * as os from 'os'; +import * as fs from 'fs'; -import { DevContainerConfig, DevContainerFeature, VSCodeCustomizations } from './configuration'; +import { DevContainerConfig, DevContainerFeature, DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, VSCodeCustomizations, getDockerComposeFilePaths, getDockerfilePath } from './configuration'; import { mkdirpLocal, readLocalFile, rmLocal, writeLocalFile, cpDirectoryLocal, isLocalFile } from '../spec-utils/pfs'; import { Log, LogLevel, nullLog } from '../spec-utils/log'; import { request } from '../spec-utils/httpRequest'; @@ -21,8 +22,12 @@ import { CommonParams, ManifestContainer, OCIManifest, OCIRef, getRef, getVersio import { Lockfile, generateLockfile, readLockfile, writeLockfile } from './lockfile'; import { computeDependsOnInstallationOrder } from './containerFeaturesOrder'; import { logFeatureAdvisories } from './featureAdvisories'; -import { getEntPasswdShellCommand } from '../spec-common/commonUtils'; +import { CLIHost, getEntPasswdShellCommand } from '../spec-common/commonUtils'; import { ContainerError } from '../spec-common/errors'; +import { extractDockerfile, findBaseImage } from '../spec-node/dockerfileUtils'; +import { DockerResolverParameters } from '../spec-node/utils'; +import { DockerCLIParameters } from '../spec-shutdown/dockerUtils'; +import { getBuildInfoForService, readDockerComposeConfig } from '../spec-node/dockerCompose'; // v1 const V1_ASSET_NAME = 'devcontainer-features.tgz'; @@ -511,6 +516,155 @@ export async function generateFeaturesConfig(params: ContainerFeatureInternalPar return featuresConfig; } +/* + image: mcr.microsoft.com/devcontainers/python:0.204-3.11-buster + imageName: mcr.microsoft.com/devcontainers/python + tag: 0.204-3.11-buster + version: 0.204 + tagSuffix: 3.11-buster +*/ +async function findImageVersionInfo(params: CommonParams, image: string) { + const { output } = params; + const [imageName, tag] = image.split(':'); + + if (tag === undefined) { + output.write(`Skipping image '${imageName}' as it does not have a tag`, LogLevel.Trace); + return { image: {} }; + } + + const [version, ...tagSuffixParts] = tag.split('-'); + const tagSuffix = tagSuffixParts.join('-'); + + if (!imageName.startsWith('mcr.microsoft.com/devcontainers/') && !imageName.startsWith('mcr.microsoft.com/vscode/devcontainers/')) { + output.write(`Skipping image '${imageName}' as it is not an image hosted from devcontainer/images`, LogLevel.Trace); + return { image: {} }; + } + + const specialImages = ['base', 'cpp', 'universal']; + const imageType = imageName.split('/').pop() || ''; + if (tag.startsWith('dev-') || (!specialImages.includes(imageType) && (!tag.includes('-') || !/\d/.test(tagSuffix))) || (specialImages.includes(imageType) && !/^\d/.test(tag))) { + output.write(`Skipping image '${imageName}' as it does not pin to a semantic version`, LogLevel.Trace); + return { image: {} }; + } + + try { + const subName = imageName.replace('mcr.microsoft.com/', ''); + const url = `https://mcr.microsoft.com/v2/${subName}/tags/list`; + const options = { type: 'GET', url, headers: {} }; + const data = JSON.parse((await request(options, output)).toString()); + + const latestVersion: string = data.tags + .filter((v: string) => v.endsWith(tagSuffix) && semver.valid(v.split('-')[0])) + .map((v: string) => v.split('-')[0]) + .sort(semver.compare) + .pop(); + + if (latestVersion) { + const wantedVersion = latestVersion.split('.').slice(0, version.split('.').length).join('.'); + const wantedTag = tagSuffix ? `${wantedVersion}-${tagSuffix}` : wantedVersion; + return { image: { name: imageName, current: tag, wanted: wantedTag } }; + } else { + output.write(`Failed to find maximum satisfying latest version for image '${image}'`, LogLevel.Error); + } + } catch (e) { + output.write(`Failed to parse published versions: ${e}`, LogLevel.Error); + } + + return { image: {} }; +} + +// const image = config.image; +// const image = "mcr.microsoft.com/devcontainers/python:0-3.9"; +// const image = "mcr.microsoft.com/devcontainers/python:0-3.9-buster"; +// const image = "mcr.microsoft.com/devcontainers/python:0.203-3.9"; +// const image = "mcr.microsoft.com/devcontainers/python:0.203.10-3.9"; +// const image = "mcr.microsoft.com/devcontainers/python:0.204-3.11-buster"; +// const image = "mcr.microsoft.com/devcontainers/python:3"; +// const image = "mcr.microsoft.com/devcontainers/python:3.9"; +// const image = "mcr.microsoft.com/devcontainers/python:3.9-buster"; +// const image = "mcr.microsoft.com/devcontainers/python:dev"; +// const image = "mcr.microsoft.com/devcontainers/python:latest"; + +// const image = "mcr.microsoft.com/devcontainers/base:0"; +// const image = "mcr.microsoft.com/devcontainers/base:0-buster"; +// const image = "mcr.microsoft.com/devcontainers/base:0.202-debian-10"; +// const image = "mcr.microsoft.com/devcontainers/base:0.202.10-debian-10"; +// const image = "mcr.microsoft.com/devcontainers/base:0.203.0-ubuntu-20.04"; +// const image = "mcr.microsoft.com/devcontainers/base:ubuntu"; +// const image = "mcr.microsoft.com/devcontainers/base:ubuntu-20.04"; + +// const image = "mcr.microsoft.com/devcontainers/cpp:0.206.6"; +// const image = "mcr.microsoft.com/devcontainers/cpp:0.205"; +// const image = "mcr.microsoft.com/devcontainers/cpp:0"; +// const image = "mcr.microsoft.com/devcontainers/cpp:latest"; + +// const image = "mcr.microsoft.com/devcontainers/javascript-node:1.0.0-16"; +// const image = "mcr.microsoft.com/devcontainers/javascript-node:14"; + +// const image = "mcr.microsoft.com/devcontainers/jekyll:3.3-bookworm"; + +// const image = "mcr.microsoft.com/vscode/devcontainers/universal:0"; +// const image = "mcr.microsoft.com/vscode/devcontainers/universal:0.18.0-linux"; +export async function loadImageVersionInfo(params: CommonParams, config: DevContainerConfig, cliHost: CLIHost, dockerParams: DockerResolverParameters) { + const { output } = params; + if ('image' in config && config.image !== undefined) { + return findImageVersionInfo(params, config.image); + + } else if ('build' in config && config.build !== undefined && 'dockerfile' in config.build) { + const dockerfileUri = getDockerfilePath(cliHost, config as DevContainerFromDockerfileConfig); + const dockerfilePath = await uriToFsPath(dockerfileUri, cliHost.platform); + const dockerfileText = (await cliHost.readFile(dockerfilePath)).toString(); + const dockerfile = extractDockerfile(dockerfileText); + + if ('build' in config && config.build?.args !== undefined) { + const image = findBaseImage(dockerfile, config.build.args, undefined); + if (image === undefined) { + return { image: {} }; + } + + return findImageVersionInfo(params, image); + } + } else if ('dockerComposeFile' in config) { + const { dockerCLI, dockerComposeCLI } = dockerParams; + const { output } = params; + const composeFiles = await getDockerComposeFilePaths(cliHost, config, cliHost.env, cliHost.cwd); + const buildParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env: cliHost.env, output, platformInfo: dockerParams.platformInfo }; + const cwdEnvFile = cliHost.path.join(cliHost.cwd, '.env'); + const envFile = Array.isArray(config.dockerComposeFile) && config.dockerComposeFile.length === 0 && await cliHost.isFile(cwdEnvFile) ? cwdEnvFile : undefined; + const composeConfig = await readDockerComposeConfig(buildParams, composeFiles, envFile); + + const services = Object.keys(composeConfig.services || {}); + if (services.indexOf(config.service) === -1) { + output.write('Service not found in Docker Compose configuration'); + return { image: {} }; + } + + const composeService = composeConfig.services[config.service]; + if (composeService.image) { + return findImageVersionInfo(params, composeService.image); + } else { + const serviceInfo = getBuildInfoForService(composeService, cliHost.path, composeFiles); + if (serviceInfo.build) { + const { context, dockerfilePath } = serviceInfo.build; + const resolvedDockerfilePath = cliHost.path.isAbsolute(dockerfilePath) ? dockerfilePath : cliHost.path.resolve(context, dockerfilePath); + const dockerfileText = (await cliHost.readFile(resolvedDockerfilePath)).toString(); + const dockerfile = extractDockerfile(dockerfileText); + + if (composeService.build?.args !== undefined) { + const image = findBaseImage(dockerfile, composeService.build?.args, undefined); + if (image === undefined) { + return { image: {} }; + } + + return findImageVersionInfo(params, image); + } + } + } + } + + return { image: {} }; +} + export async function loadVersionInfo(params: ContainerFeatureInternalParams, config: DevContainerConfig) { const userFeatures = userFeaturesToArray(config); if (!userFeatures) { @@ -1115,7 +1269,7 @@ export async function fetchContentsAtTarballUri(params: { output: Log; env: Node // No 'metadataFile' to look for. if (!metadataFile) { - await cleanupIterationFetchAndMerge(tempTarballPath, output); + await cleanupIterationFetchAndMerge(tempTarballPath, output); return { computedDigest, metadata: undefined }; } diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 0b04f4fb0..165e11cf4 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -24,7 +24,7 @@ import { readDevContainerConfigFile } from './configContainer'; import { getDefaultDevContainerConfigPath, getDevContainerConfigPathIn, uriToFsPath } from '../spec-configuration/configurationCommonUtils'; import { CLIHost, getCLIHost } from '../spec-common/cliHost'; import { loadNativeModule, processSignals } from '../spec-common/commonUtils'; -import { loadVersionInfo } from '../spec-configuration/containerFeaturesConfiguration'; +import { loadImageVersionInfo, loadVersionInfo } from '../spec-configuration/containerFeaturesConfiguration'; import { featuresTestOptions, featuresTestHandler } from './featuresCLI/test'; import { featuresPackageHandler, featuresPackageOptions } from './featuresCLI/package'; import { featuresPublishHandler, featuresPublishOptions } from './featuresCLI/publish'; @@ -1127,22 +1127,82 @@ async function outdated({ platform: cliHost.platform, }; - const outdated = await loadVersionInfo(params, configs.config.config); + const outdatedFeatures = await loadVersionInfo(params, configs.config.config); + + const outputParams = { output, env: process.env }; + const dockerParams = await createDockerParams({ + containerDataFolder: undefined, + containerSystemDataFolder: undefined, + workspaceFolder, + mountWorkspaceGitRoot: false, + configFile, + logLevel: mapLogLevel(logLevel), + logFormat, + log: text => process.stderr.write(text), + terminalDimensions: /* terminalColumns && terminalRows ? { columns: terminalColumns, rows: terminalRows } : */ undefined, // TODO + defaultUserEnvProbe: 'loginInteractiveShell', + removeExistingContainer: false, + buildNoCache: false, + expectExistingContainer: false, + postCreateEnabled: false, + skipNonBlocking: false, + prebuild: false, + persistedFolder: undefined, + additionalMounts: [], + updateRemoteUserUIDDefault: 'never', + additionalCacheFroms: [], + useBuildKit: 'never', + buildxPlatform: undefined, + buildxPush: false, + buildxOutput: undefined, + buildxCacheTo: undefined, + skipFeatureAutoMapping: false, + skipPostAttach: false, + skipPersistingCustomizationsFromFeatures: false, + dotfiles: { + repository: undefined, + installCommand: undefined, + targetPath: undefined + }, + dockerPath: undefined, + dockerComposePath: undefined, + overrideConfigFile: undefined, + remoteEnv: {} + }, disposables); + + const outdatedImages = await loadImageVersionInfo(outputParams, configs.config.config, cliHost, dockerParams); + await new Promise((resolve, reject) => { - let text; + let text = ''; if (outputFormat === 'text') { - const rows = Object.keys(outdated.features).map(key => { - const value = outdated.features[key]; + const rows = Object.keys(outdatedFeatures.features).map(key => { + const value = outdatedFeatures.features[key]; return [ getFeatureIdWithoutVersion(key), value.current, value.wanted, value.latest ] .map(v => v === undefined ? '-' : v); }); - const header = ['Feature', 'Current', 'Wanted', 'Latest']; - text = textTable([ - header, - ...rows, - ]); + + if (rows.length !== 0) { + const featureHeader = ['Feature', 'Current', 'Wanted', 'Latest']; + text = textTable([ + featureHeader, + ...rows, + ]); + } + + if (outdatedImages !== undefined && outdatedImages.image !== undefined) { + const imageHeader = ['Image', 'Current', 'Latest']; + const image = outdatedImages.image; + + if (image.current !== undefined && image.wanted !== undefined && image.current !== image.wanted) { + text += '\n\n'; + text += textTable([ + imageHeader, + [image.name, image.current, image.wanted], + ]); + } + } } else { - text = JSON.stringify(outdated, undefined, process.stdout.isTTY ? ' ' : undefined); + text = JSON.stringify({ ...outdatedFeatures, ...outdatedImages }, undefined, process.stdout.isTTY ? ' ' : undefined); } process.stdout.write(text + '\n', err => err ? reject(err) : resolve()); }); From 85d547b497eac65f99c9f2086dacc5097fddf9c0 Mon Sep 17 00:00:00 2001 From: Samruddhi Khandale Date: Fri, 1 Mar 2024 17:52:15 +0000 Subject: [PATCH 02/20] update json with "path" property --- .../containerFeaturesConfiguration.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/spec-configuration/containerFeaturesConfiguration.ts b/src/spec-configuration/containerFeaturesConfiguration.ts index 504b5015f..4e096632f 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -523,7 +523,7 @@ export async function generateFeaturesConfig(params: ContainerFeatureInternalPar version: 0.204 tagSuffix: 3.11-buster */ -async function findImageVersionInfo(params: CommonParams, image: string) { +async function findImageVersionInfo(params: CommonParams, image: string, path: string) { const { output } = params; const [imageName, tag] = image.split(':'); @@ -562,7 +562,7 @@ async function findImageVersionInfo(params: CommonParams, image: string) { if (latestVersion) { const wantedVersion = latestVersion.split('.').slice(0, version.split('.').length).join('.'); const wantedTag = tagSuffix ? `${wantedVersion}-${tagSuffix}` : wantedVersion; - return { image: { name: imageName, current: tag, wanted: wantedTag } }; + return { image: { name: imageName, current: tag, wanted: wantedTag, path } }; } else { output.write(`Failed to find maximum satisfying latest version for image '${image}'`, LogLevel.Error); } @@ -606,9 +606,8 @@ async function findImageVersionInfo(params: CommonParams, image: string) { // const image = "mcr.microsoft.com/vscode/devcontainers/universal:0"; // const image = "mcr.microsoft.com/vscode/devcontainers/universal:0.18.0-linux"; export async function loadImageVersionInfo(params: CommonParams, config: DevContainerConfig, cliHost: CLIHost, dockerParams: DockerResolverParameters) { - const { output } = params; if ('image' in config && config.image !== undefined) { - return findImageVersionInfo(params, config.image); + return findImageVersionInfo(params, config.image, config.configFilePath?.path || ''); } else if ('build' in config && config.build !== undefined && 'dockerfile' in config.build) { const dockerfileUri = getDockerfilePath(cliHost, config as DevContainerFromDockerfileConfig); @@ -622,7 +621,7 @@ export async function loadImageVersionInfo(params: CommonParams, config: DevCont return { image: {} }; } - return findImageVersionInfo(params, image); + return findImageVersionInfo(params, image, dockerfilePath); } } else if ('dockerComposeFile' in config) { const { dockerCLI, dockerComposeCLI } = dockerParams; @@ -641,7 +640,7 @@ export async function loadImageVersionInfo(params: CommonParams, config: DevCont const composeService = composeConfig.services[config.service]; if (composeService.image) { - return findImageVersionInfo(params, composeService.image); + return findImageVersionInfo(params, composeService.image, composeFiles[0]); } else { const serviceInfo = getBuildInfoForService(composeService, cliHost.path, composeFiles); if (serviceInfo.build) { @@ -656,7 +655,7 @@ export async function loadImageVersionInfo(params: CommonParams, config: DevCont return { image: {} }; } - return findImageVersionInfo(params, image); + return findImageVersionInfo(params, image, resolvedDockerfilePath); } } } From 78e7b62f4657df8b97a5d0bc4b3be2df7683bb5f Mon Sep 17 00:00:00 2001 From: Samruddhi Khandale Date: Fri, 1 Mar 2024 19:54:14 +0000 Subject: [PATCH 03/20] "currentImageValue" and "newImageValue" --- .../containerFeaturesConfiguration.ts | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/spec-configuration/containerFeaturesConfiguration.ts b/src/spec-configuration/containerFeaturesConfiguration.ts index 4e096632f..9777d15ab 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -523,7 +523,7 @@ export async function generateFeaturesConfig(params: ContainerFeatureInternalPar version: 0.204 tagSuffix: 3.11-buster */ -async function findImageVersionInfo(params: CommonParams, image: string, path: string) { +async function findImageVersionInfo(params: CommonParams, image: string, path: string, currentImageValue: string) { const { output } = params; const [imageName, tag] = image.split(':'); @@ -562,7 +562,22 @@ async function findImageVersionInfo(params: CommonParams, image: string, path: s if (latestVersion) { const wantedVersion = latestVersion.split('.').slice(0, version.split('.').length).join('.'); const wantedTag = tagSuffix ? `${wantedVersion}-${tagSuffix}` : wantedVersion; - return { image: { name: imageName, current: tag, wanted: wantedTag, path } }; + + if (wantedTag === tag) { + output.write(`Image '${imageName}' is already at the latest version '${tag}'`, LogLevel.Trace); + return { image: {} }; + } + + let newImageValue = `${imageName}:${wantedTag}`; + + // Useful when image tag is set with build args (eg. VARIANT) + const currentImageTag = currentImageValue.split(':')[1]; + if (currentImageTag !== tag) { + const currentTagSuffix = currentImageTag.split('-').slice(1).join('-'); + newImageValue = `${imageName}:${wantedVersion}-${currentTagSuffix}`; + } + + return { image: { name: imageName, current: tag, wanted: wantedTag, currentImageValue, newImageValue, path } }; } else { output.write(`Failed to find maximum satisfying latest version for image '${image}'`, LogLevel.Error); } @@ -607,7 +622,7 @@ async function findImageVersionInfo(params: CommonParams, image: string, path: s // const image = "mcr.microsoft.com/vscode/devcontainers/universal:0.18.0-linux"; export async function loadImageVersionInfo(params: CommonParams, config: DevContainerConfig, cliHost: CLIHost, dockerParams: DockerResolverParameters) { if ('image' in config && config.image !== undefined) { - return findImageVersionInfo(params, config.image, config.configFilePath?.path || ''); + return findImageVersionInfo(params, config.image, config.configFilePath?.path || '', config.image); } else if ('build' in config && config.build !== undefined && 'dockerfile' in config.build) { const dockerfileUri = getDockerfilePath(cliHost, config as DevContainerFromDockerfileConfig); @@ -621,7 +636,7 @@ export async function loadImageVersionInfo(params: CommonParams, config: DevCont return { image: {} }; } - return findImageVersionInfo(params, image, dockerfilePath); + return findImageVersionInfo(params, image, dockerfilePath, dockerfile.stages[0].from.image); } } else if ('dockerComposeFile' in config) { const { dockerCLI, dockerComposeCLI } = dockerParams; @@ -640,7 +655,7 @@ export async function loadImageVersionInfo(params: CommonParams, config: DevCont const composeService = composeConfig.services[config.service]; if (composeService.image) { - return findImageVersionInfo(params, composeService.image, composeFiles[0]); + return findImageVersionInfo(params, composeService.image, composeFiles[0], composeService.image); } else { const serviceInfo = getBuildInfoForService(composeService, cliHost.path, composeFiles); if (serviceInfo.build) { @@ -655,7 +670,7 @@ export async function loadImageVersionInfo(params: CommonParams, config: DevCont return { image: {} }; } - return findImageVersionInfo(params, image, resolvedDockerfilePath); + return findImageVersionInfo(params, image, resolvedDockerfilePath, dockerfile.stages[0].from.image); } } } From 2ab7094fae77ca1d4d759bd2f34feffb5970860e Mon Sep 17 00:00:00 2001 From: Samruddhi Khandale Date: Fri, 1 Mar 2024 20:10:53 +0000 Subject: [PATCH 04/20] Refactor outdated to isolated class --- .../containerFeaturesConfiguration.ts | 251 +---------- .../collectionCommonUtils/outdated.ts | 22 + .../outdatedCommandImpl.ts | 402 ++++++++++++++++++ src/spec-node/devContainersSpecCLI.ts | 163 +------ 4 files changed, 429 insertions(+), 409 deletions(-) create mode 100644 src/spec-node/collectionCommonUtils/outdated.ts create mode 100644 src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts diff --git a/src/spec-configuration/containerFeaturesConfiguration.ts b/src/spec-configuration/containerFeaturesConfiguration.ts index 9777d15ab..eb473b0ad 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -8,26 +8,19 @@ import * as path from 'path'; import * as URL from 'url'; import * as tar from 'tar'; import * as crypto from 'crypto'; -import * as semver from 'semver'; -import * as os from 'os'; -import * as fs from 'fs'; -import { DevContainerConfig, DevContainerFeature, DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, VSCodeCustomizations, getDockerComposeFilePaths, getDockerfilePath } from './configuration'; +import { DevContainerConfig, DevContainerFeature, VSCodeCustomizations } from './configuration'; import { mkdirpLocal, readLocalFile, rmLocal, writeLocalFile, cpDirectoryLocal, isLocalFile } from '../spec-utils/pfs'; -import { Log, LogLevel, nullLog } from '../spec-utils/log'; +import { Log, LogLevel } from '../spec-utils/log'; import { request } from '../spec-utils/httpRequest'; import { fetchOCIFeature, tryGetOCIFeatureSet, fetchOCIFeatureManifestIfExistsFromUserIdentifier } from './containerFeaturesOCI'; import { uriToFsPath } from './configurationCommonUtils'; -import { CommonParams, ManifestContainer, OCIManifest, OCIRef, getRef, getVersionsStrictSorted } from './containerCollectionsOCI'; +import { CommonParams, OCIManifest, OCIRef } from './containerCollectionsOCI'; import { Lockfile, generateLockfile, readLockfile, writeLockfile } from './lockfile'; import { computeDependsOnInstallationOrder } from './containerFeaturesOrder'; import { logFeatureAdvisories } from './featureAdvisories'; -import { CLIHost, getEntPasswdShellCommand } from '../spec-common/commonUtils'; +import { getEntPasswdShellCommand } from '../spec-common/commonUtils'; import { ContainerError } from '../spec-common/errors'; -import { extractDockerfile, findBaseImage } from '../spec-node/dockerfileUtils'; -import { DockerResolverParameters } from '../spec-node/utils'; -import { DockerCLIParameters } from '../spec-shutdown/dockerUtils'; -import { getBuildInfoForService, readDockerComposeConfig } from '../spec-node/dockerCompose'; // v1 const V1_ASSET_NAME = 'devcontainer-features.tgz'; @@ -516,242 +509,6 @@ export async function generateFeaturesConfig(params: ContainerFeatureInternalPar return featuresConfig; } -/* - image: mcr.microsoft.com/devcontainers/python:0.204-3.11-buster - imageName: mcr.microsoft.com/devcontainers/python - tag: 0.204-3.11-buster - version: 0.204 - tagSuffix: 3.11-buster -*/ -async function findImageVersionInfo(params: CommonParams, image: string, path: string, currentImageValue: string) { - const { output } = params; - const [imageName, tag] = image.split(':'); - - if (tag === undefined) { - output.write(`Skipping image '${imageName}' as it does not have a tag`, LogLevel.Trace); - return { image: {} }; - } - - const [version, ...tagSuffixParts] = tag.split('-'); - const tagSuffix = tagSuffixParts.join('-'); - - if (!imageName.startsWith('mcr.microsoft.com/devcontainers/') && !imageName.startsWith('mcr.microsoft.com/vscode/devcontainers/')) { - output.write(`Skipping image '${imageName}' as it is not an image hosted from devcontainer/images`, LogLevel.Trace); - return { image: {} }; - } - - const specialImages = ['base', 'cpp', 'universal']; - const imageType = imageName.split('/').pop() || ''; - if (tag.startsWith('dev-') || (!specialImages.includes(imageType) && (!tag.includes('-') || !/\d/.test(tagSuffix))) || (specialImages.includes(imageType) && !/^\d/.test(tag))) { - output.write(`Skipping image '${imageName}' as it does not pin to a semantic version`, LogLevel.Trace); - return { image: {} }; - } - - try { - const subName = imageName.replace('mcr.microsoft.com/', ''); - const url = `https://mcr.microsoft.com/v2/${subName}/tags/list`; - const options = { type: 'GET', url, headers: {} }; - const data = JSON.parse((await request(options, output)).toString()); - - const latestVersion: string = data.tags - .filter((v: string) => v.endsWith(tagSuffix) && semver.valid(v.split('-')[0])) - .map((v: string) => v.split('-')[0]) - .sort(semver.compare) - .pop(); - - if (latestVersion) { - const wantedVersion = latestVersion.split('.').slice(0, version.split('.').length).join('.'); - const wantedTag = tagSuffix ? `${wantedVersion}-${tagSuffix}` : wantedVersion; - - if (wantedTag === tag) { - output.write(`Image '${imageName}' is already at the latest version '${tag}'`, LogLevel.Trace); - return { image: {} }; - } - - let newImageValue = `${imageName}:${wantedTag}`; - - // Useful when image tag is set with build args (eg. VARIANT) - const currentImageTag = currentImageValue.split(':')[1]; - if (currentImageTag !== tag) { - const currentTagSuffix = currentImageTag.split('-').slice(1).join('-'); - newImageValue = `${imageName}:${wantedVersion}-${currentTagSuffix}`; - } - - return { image: { name: imageName, current: tag, wanted: wantedTag, currentImageValue, newImageValue, path } }; - } else { - output.write(`Failed to find maximum satisfying latest version for image '${image}'`, LogLevel.Error); - } - } catch (e) { - output.write(`Failed to parse published versions: ${e}`, LogLevel.Error); - } - - return { image: {} }; -} - -// const image = config.image; -// const image = "mcr.microsoft.com/devcontainers/python:0-3.9"; -// const image = "mcr.microsoft.com/devcontainers/python:0-3.9-buster"; -// const image = "mcr.microsoft.com/devcontainers/python:0.203-3.9"; -// const image = "mcr.microsoft.com/devcontainers/python:0.203.10-3.9"; -// const image = "mcr.microsoft.com/devcontainers/python:0.204-3.11-buster"; -// const image = "mcr.microsoft.com/devcontainers/python:3"; -// const image = "mcr.microsoft.com/devcontainers/python:3.9"; -// const image = "mcr.microsoft.com/devcontainers/python:3.9-buster"; -// const image = "mcr.microsoft.com/devcontainers/python:dev"; -// const image = "mcr.microsoft.com/devcontainers/python:latest"; - -// const image = "mcr.microsoft.com/devcontainers/base:0"; -// const image = "mcr.microsoft.com/devcontainers/base:0-buster"; -// const image = "mcr.microsoft.com/devcontainers/base:0.202-debian-10"; -// const image = "mcr.microsoft.com/devcontainers/base:0.202.10-debian-10"; -// const image = "mcr.microsoft.com/devcontainers/base:0.203.0-ubuntu-20.04"; -// const image = "mcr.microsoft.com/devcontainers/base:ubuntu"; -// const image = "mcr.microsoft.com/devcontainers/base:ubuntu-20.04"; - -// const image = "mcr.microsoft.com/devcontainers/cpp:0.206.6"; -// const image = "mcr.microsoft.com/devcontainers/cpp:0.205"; -// const image = "mcr.microsoft.com/devcontainers/cpp:0"; -// const image = "mcr.microsoft.com/devcontainers/cpp:latest"; - -// const image = "mcr.microsoft.com/devcontainers/javascript-node:1.0.0-16"; -// const image = "mcr.microsoft.com/devcontainers/javascript-node:14"; - -// const image = "mcr.microsoft.com/devcontainers/jekyll:3.3-bookworm"; - -// const image = "mcr.microsoft.com/vscode/devcontainers/universal:0"; -// const image = "mcr.microsoft.com/vscode/devcontainers/universal:0.18.0-linux"; -export async function loadImageVersionInfo(params: CommonParams, config: DevContainerConfig, cliHost: CLIHost, dockerParams: DockerResolverParameters) { - if ('image' in config && config.image !== undefined) { - return findImageVersionInfo(params, config.image, config.configFilePath?.path || '', config.image); - - } else if ('build' in config && config.build !== undefined && 'dockerfile' in config.build) { - const dockerfileUri = getDockerfilePath(cliHost, config as DevContainerFromDockerfileConfig); - const dockerfilePath = await uriToFsPath(dockerfileUri, cliHost.platform); - const dockerfileText = (await cliHost.readFile(dockerfilePath)).toString(); - const dockerfile = extractDockerfile(dockerfileText); - - if ('build' in config && config.build?.args !== undefined) { - const image = findBaseImage(dockerfile, config.build.args, undefined); - if (image === undefined) { - return { image: {} }; - } - - return findImageVersionInfo(params, image, dockerfilePath, dockerfile.stages[0].from.image); - } - } else if ('dockerComposeFile' in config) { - const { dockerCLI, dockerComposeCLI } = dockerParams; - const { output } = params; - const composeFiles = await getDockerComposeFilePaths(cliHost, config, cliHost.env, cliHost.cwd); - const buildParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env: cliHost.env, output, platformInfo: dockerParams.platformInfo }; - const cwdEnvFile = cliHost.path.join(cliHost.cwd, '.env'); - const envFile = Array.isArray(config.dockerComposeFile) && config.dockerComposeFile.length === 0 && await cliHost.isFile(cwdEnvFile) ? cwdEnvFile : undefined; - const composeConfig = await readDockerComposeConfig(buildParams, composeFiles, envFile); - - const services = Object.keys(composeConfig.services || {}); - if (services.indexOf(config.service) === -1) { - output.write('Service not found in Docker Compose configuration'); - return { image: {} }; - } - - const composeService = composeConfig.services[config.service]; - if (composeService.image) { - return findImageVersionInfo(params, composeService.image, composeFiles[0], composeService.image); - } else { - const serviceInfo = getBuildInfoForService(composeService, cliHost.path, composeFiles); - if (serviceInfo.build) { - const { context, dockerfilePath } = serviceInfo.build; - const resolvedDockerfilePath = cliHost.path.isAbsolute(dockerfilePath) ? dockerfilePath : cliHost.path.resolve(context, dockerfilePath); - const dockerfileText = (await cliHost.readFile(resolvedDockerfilePath)).toString(); - const dockerfile = extractDockerfile(dockerfileText); - - if (composeService.build?.args !== undefined) { - const image = findBaseImage(dockerfile, composeService.build?.args, undefined); - if (image === undefined) { - return { image: {} }; - } - - return findImageVersionInfo(params, image, resolvedDockerfilePath, dockerfile.stages[0].from.image); - } - } - } - } - - return { image: {} }; -} - -export async function loadVersionInfo(params: ContainerFeatureInternalParams, config: DevContainerConfig) { - const userFeatures = userFeaturesToArray(config); - if (!userFeatures) { - return { features: {} }; - } - - const { lockfile } = await readLockfile(config); - - const resolved: Record = {}; - - await Promise.all(userFeatures.map(async userFeature => { - const userFeatureId = userFeature.userFeatureId; - const featureRef = getRef(nullLog, userFeatureId); // Filters out Feature identifiers that cannot be versioned (e.g. local paths, deprecated, etc..) - if (featureRef) { - const versions = (await getVersionsStrictSorted(params, featureRef)) - ?.reverse(); - if (versions) { - const lockfileVersion = lockfile?.features[userFeatureId]?.version; - let wanted = lockfileVersion; - const tag = featureRef.tag; - if (tag) { - if (tag === 'latest') { - wanted = versions[0]; - } else { - wanted = versions.find(version => semver.satisfies(version, tag)); - } - } else if (featureRef.digest && !wanted) { - const { type, manifest } = await getFeatureIdType(params, userFeatureId, undefined); - if (type === 'oci' && manifest) { - const wantedFeature = await findOCIFeatureMetadata(params, manifest); - wanted = wantedFeature?.version; - } - } - resolved[userFeatureId] = { - current: lockfileVersion || wanted, - wanted, - wantedMajor: wanted && semver.major(wanted)?.toString(), - latest: versions[0], - latestMajor: semver.major(versions[0])?.toString(), - }; - } - } - })); - - // Reorder Features to match the order in which they were specified in config - return { - features: userFeatures.reduce((acc, userFeature) => { - const r = resolved[userFeature.userFeatureId]; - if (r) { - acc[userFeature.userFeatureId] = r; - } - return acc; - }, {} as Record) - }; -} - -async function findOCIFeatureMetadata(params: ContainerFeatureInternalParams, manifest: ManifestContainer) { - const annotation = manifest.manifestObj.annotations?.['dev.containers.metadata']; - if (annotation) { - return jsonc.parse(annotation) as Feature; - } - - // Backwards compatibility. - const featureSet = tryGetOCIFeatureSet(params.output, manifest.canonicalId, {}, manifest, manifest.canonicalId); - if (!featureSet) { - return undefined; - } - - const tmp = path.join(os.tmpdir(), crypto.randomUUID()); - const f = await fetchOCIFeature(params, featureSet, tmp, tmp, DEVCONTAINER_FEATURE_FILE_NAME); - return f.metadata as Feature | undefined; -} - async function prepareOCICache(dstFolder: string) { const ociCacheDir = path.join(dstFolder, 'ociCache'); await mkdirpLocal(ociCacheDir); diff --git a/src/spec-node/collectionCommonUtils/outdated.ts b/src/spec-node/collectionCommonUtils/outdated.ts new file mode 100644 index 000000000..188163f0b --- /dev/null +++ b/src/spec-node/collectionCommonUtils/outdated.ts @@ -0,0 +1,22 @@ +import { Argv } from 'yargs'; +import { UnpackArgv } from '../devContainersSpecCLI'; +import { outdated } from './outdatedCommandImpl'; + +export function outdatedOptions(y: Argv) { + return y.options({ + 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, + 'workspace-folder': { type: 'string', required: true, description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, + 'output-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text', description: 'Output format.' }, + 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level for the --terminal-log-file. When set to trace, the log level for --log-file will also be set to trace.' }, + 'log-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text' as 'text', description: 'Log format.' }, + 'terminal-columns': { type: 'number', implies: ['terminal-rows'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + }); +} + +export type OutdatedArgs = UnpackArgv>; + +export function outdatedHandler(args: OutdatedArgs) { + (async () => outdated(args))().catch(console.error); +} diff --git a/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts b/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts new file mode 100644 index 000000000..80970d124 --- /dev/null +++ b/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts @@ -0,0 +1,402 @@ +import * as jsonc from 'jsonc-parser'; +import * as os from 'os'; +import * as path from 'path'; +import * as semver from 'semver'; + +import { Log, LogLevel, mapLogLevel, nullLog } from '../../spec-utils/log'; +import { OutdatedArgs } from './outdated'; +import { CLIHost, getCLIHost } from '../../spec-common/cliHost'; +import { URI } from 'vscode-uri'; +import { loadNativeModule } from '../../spec-common/commonUtils'; +import textTable from 'text-table'; +import { ContainerError } from '../../spec-common/errors'; +import { getDevContainerConfigPathIn, getDefaultDevContainerConfigPath } from '../../spec-configuration/configurationCommonUtils'; +import { fetchOCIFeature, getFeatureIdWithoutVersion, tryGetOCIFeatureSet } from '../../spec-configuration/containerFeaturesOCI'; +import { getPackageConfig } from '../../spec-utils/product'; +import { workspaceFromPath } from '../../spec-utils/workspaces'; +import { readDevContainerConfigFile } from '../configContainer'; +import { createLog, createDockerParams } from '../devContainers'; +import { uriToFsPath, getCacheFolder, DockerResolverParameters, getDockerfilePath } from '../utils'; +import { DevContainerConfig, DevContainerFromDockerfileConfig, getDockerComposeFilePaths } from '../../spec-configuration/configuration'; +import { CommonParams, ManifestContainer, getRef, getVersionsStrictSorted } from '../../spec-configuration/containerCollectionsOCI'; +import { DockerCLIParameters } from '../../spec-shutdown/dockerUtils'; +import { request } from '../../spec-utils/httpRequest'; +import { readDockerComposeConfig, getBuildInfoForService } from '../dockerCompose'; +import { extractDockerfile, findBaseImage } from '../dockerfileUtils'; +import { ContainerFeatureInternalParams, userFeaturesToArray, getFeatureIdType, DEVCONTAINER_FEATURE_FILE_NAME, Feature } from '../../spec-configuration/containerFeaturesConfiguration'; +import { readLockfile } from '../../spec-configuration/lockfile'; + +export async function outdated({ + // 'user-data-folder': persistedFolder, + 'workspace-folder': workspaceFolderArg, + config: configParam, + 'output-format': outputFormat, + 'log-level': logLevel, + 'log-format': logFormat, + 'terminal-rows': terminalRows, + 'terminal-columns': terminalColumns, +}: OutdatedArgs) { + const disposables: (() => Promise | undefined)[] = []; + const dispose = async () => { + await Promise.all(disposables.map(d => d())); + }; + let output: Log | undefined; + try { + const workspaceFolder = path.resolve(process.cwd(), workspaceFolderArg); + const configFile = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; + const cliHost = await getCLIHost(workspaceFolder, loadNativeModule, logFormat === 'text'); + const extensionPath = path.join(__dirname, '..', '..'); + const sessionStart = new Date(); + const pkg = getPackageConfig(); + output = createLog({ + logLevel: mapLogLevel(logLevel), + logFormat, + log: text => process.stderr.write(text), + terminalDimensions: terminalColumns && terminalRows ? { columns: terminalColumns, rows: terminalRows } : undefined, + }, pkg, sessionStart, disposables); + + const workspace = workspaceFromPath(cliHost.path, workspaceFolder); + const configPath = configFile ? configFile : await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath); + const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, true, output) || undefined; + if (!configs) { + throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); + } + + const cacheFolder = await getCacheFolder(cliHost); + const params = { + extensionPath, + cacheFolder, + cwd: cliHost.cwd, + output, + env: cliHost.env, + skipFeatureAutoMapping: false, + platform: cliHost.platform, + }; + + const outdatedFeatures = await loadFeatureVersionInfo(params, configs.config.config); + + const outputParams = { output, env: process.env }; + const dockerParams = await createDockerParams({ + containerDataFolder: undefined, + containerSystemDataFolder: undefined, + workspaceFolder, + mountWorkspaceGitRoot: false, + configFile, + logLevel: mapLogLevel(logLevel), + logFormat, + log: text => process.stderr.write(text), + terminalDimensions: /* terminalColumns && terminalRows ? { columns: terminalColumns, rows: terminalRows } : */ undefined, // TODO + defaultUserEnvProbe: 'loginInteractiveShell', + removeExistingContainer: false, + buildNoCache: false, + expectExistingContainer: false, + postCreateEnabled: false, + skipNonBlocking: false, + prebuild: false, + persistedFolder: undefined, + additionalMounts: [], + updateRemoteUserUIDDefault: 'never', + additionalCacheFroms: [], + useBuildKit: 'never', + buildxPlatform: undefined, + buildxPush: false, + buildxOutput: undefined, + buildxCacheTo: undefined, + skipFeatureAutoMapping: false, + skipPostAttach: false, + skipPersistingCustomizationsFromFeatures: false, + dotfiles: { + repository: undefined, + installCommand: undefined, + targetPath: undefined + }, + dockerPath: undefined, + dockerComposePath: undefined, + overrideConfigFile: undefined, + remoteEnv: {} + }, disposables); + + const outdatedImages = await loadImageVersionInfo(outputParams, configs.config.config, cliHost, dockerParams); + + await new Promise((resolve, reject) => { + let text = ''; + if (outputFormat === 'text') { + const rows = Object.keys(outdatedFeatures.features).map(key => { + const value = outdatedFeatures.features[key]; + return [getFeatureIdWithoutVersion(key), value.current, value.wanted, value.latest] + .map(v => v === undefined ? '-' : v); + }); + + if (rows.length !== 0) { + const featureHeader = ['Feature', 'Current', 'Wanted', 'Latest']; + text = textTable([ + featureHeader, + ...rows, + ]); + } + + if (outdatedImages !== undefined && outdatedImages.image !== undefined) { + const imageHeader = ['Image', 'Current', 'Latest']; + const image = outdatedImages.image; + + if (image.current !== undefined && image.wanted !== undefined && image.current !== image.wanted) { + text += '\n\n'; + text += textTable([ + imageHeader, + [image.name, image.current, image.wanted], + ]); + } + } + } else { + text = JSON.stringify({ ...outdatedFeatures, ...outdatedImages }, undefined, process.stdout.isTTY ? ' ' : undefined); + } + process.stdout.write(text + '\n', err => err ? reject(err) : resolve()); + }); + } catch (err) { + if (output) { + output.write(err && (err.stack || err.message) || String(err)); + } else { + console.error(err); + } + await dispose(); + process.exit(1); + } + await dispose(); + process.exit(0); +} + +/* + image: mcr.microsoft.com/devcontainers/python:0.204-3.11-buster + imageName: mcr.microsoft.com/devcontainers/python + tag: 0.204-3.11-buster + version: 0.204 + tagSuffix: 3.11-buster +*/ +async function findImageVersionInfo(params: CommonParams, image: string, path: string, currentImageValue: string) { + const { output } = params; + const [imageName, tag] = image.split(':'); + + if (tag === undefined) { + output.write(`Skipping image '${imageName}' as it does not have a tag`, LogLevel.Trace); + return { image: {} }; + } + + const [version, ...tagSuffixParts] = tag.split('-'); + const tagSuffix = tagSuffixParts.join('-'); + + if (!imageName.startsWith('mcr.microsoft.com/devcontainers/') && !imageName.startsWith('mcr.microsoft.com/vscode/devcontainers/')) { + output.write(`Skipping image '${imageName}' as it is not an image hosted from devcontainer/images`, LogLevel.Trace); + return { image: {} }; + } + + const specialImages = ['base', 'cpp', 'universal']; + const imageType = imageName.split('/').pop() || ''; + if (tag.startsWith('dev-') || (!specialImages.includes(imageType) && (!tag.includes('-') || !/\d/.test(tagSuffix))) || (specialImages.includes(imageType) && !/^\d/.test(tag))) { + output.write(`Skipping image '${imageName}' as it does not pin to a semantic version`, LogLevel.Trace); + return { image: {} }; + } + + try { + const subName = imageName.replace('mcr.microsoft.com/', ''); + const url = `https://mcr.microsoft.com/v2/${subName}/tags/list`; + const options = { type: 'GET', url, headers: {} }; + const data = JSON.parse((await request(options, output)).toString()); + + const latestVersion: string = data.tags + .filter((v: string) => v.endsWith(tagSuffix) && semver.valid(v.split('-')[0])) + .map((v: string) => v.split('-')[0]) + .sort(semver.compare) + .pop(); + + if (latestVersion) { + const wantedVersion = latestVersion.split('.').slice(0, version.split('.').length).join('.'); + const wantedTag = tagSuffix ? `${wantedVersion}-${tagSuffix}` : wantedVersion; + + if (wantedTag === tag) { + output.write(`Image '${imageName}' is already at the latest version '${tag}'`, LogLevel.Trace); + return { image: {} }; + } + + let newImageValue = `${imageName}:${wantedTag}`; + + // Useful when image tag is set with build args (eg. VARIANT) + const currentImageTag = currentImageValue.split(':')[1]; + if (currentImageTag !== tag) { + const currentTagSuffix = currentImageTag.split('-').slice(1).join('-'); + newImageValue = `${imageName}:${wantedVersion}-${currentTagSuffix}`; + } + + return { image: { name: imageName, current: tag, wanted: wantedTag, currentImageValue, newImageValue, path } }; + } else { + output.write(`Failed to find maximum satisfying latest version for image '${image}'`, LogLevel.Error); + } + } catch (e) { + output.write(`Failed to parse published versions: ${e}`, LogLevel.Error); + } + + return { image: {} }; +} + +// const image = config.image; +// const image = "mcr.microsoft.com/devcontainers/python:0-3.9"; +// const image = "mcr.microsoft.com/devcontainers/python:0-3.9-buster"; +// const image = "mcr.microsoft.com/devcontainers/python:0.203-3.9"; +// const image = "mcr.microsoft.com/devcontainers/python:0.203.10-3.9"; +// const image = "mcr.microsoft.com/devcontainers/python:0.204-3.11-buster"; +// const image = "mcr.microsoft.com/devcontainers/python:3"; +// const image = "mcr.microsoft.com/devcontainers/python:3.9"; +// const image = "mcr.microsoft.com/devcontainers/python:3.9-buster"; +// const image = "mcr.microsoft.com/devcontainers/python:dev"; +// const image = "mcr.microsoft.com/devcontainers/python:latest"; + +// const image = "mcr.microsoft.com/devcontainers/base:0"; +// const image = "mcr.microsoft.com/devcontainers/base:0-buster"; +// const image = "mcr.microsoft.com/devcontainers/base:0.202-debian-10"; +// const image = "mcr.microsoft.com/devcontainers/base:0.202.10-debian-10"; +// const image = "mcr.microsoft.com/devcontainers/base:0.203.0-ubuntu-20.04"; +// const image = "mcr.microsoft.com/devcontainers/base:ubuntu"; +// const image = "mcr.microsoft.com/devcontainers/base:ubuntu-20.04"; + +// const image = "mcr.microsoft.com/devcontainers/cpp:0.206.6"; +// const image = "mcr.microsoft.com/devcontainers/cpp:0.205"; +// const image = "mcr.microsoft.com/devcontainers/cpp:0"; +// const image = "mcr.microsoft.com/devcontainers/cpp:latest"; + +// const image = "mcr.microsoft.com/devcontainers/javascript-node:1.0.0-16"; +// const image = "mcr.microsoft.com/devcontainers/javascript-node:14"; + +// const image = "mcr.microsoft.com/devcontainers/jekyll:3.3-bookworm"; + +// const image = "mcr.microsoft.com/vscode/devcontainers/universal:0"; +// const image = "mcr.microsoft.com/vscode/devcontainers/universal:0.18.0-linux"; +async function loadImageVersionInfo(params: CommonParams, config: DevContainerConfig, cliHost: CLIHost, dockerParams: DockerResolverParameters) { + if ('image' in config && config.image !== undefined) { + return findImageVersionInfo(params, config.image, config.configFilePath?.path || '', config.image); + + } else if ('build' in config && config.build !== undefined && 'dockerfile' in config.build) { + const dockerfileUri = getDockerfilePath(cliHost, config as DevContainerFromDockerfileConfig); + const dockerfilePath = await uriToFsPath(dockerfileUri, cliHost.platform); + const dockerfileText = (await cliHost.readFile(dockerfilePath)).toString(); + const dockerfile = extractDockerfile(dockerfileText); + + if ('build' in config && config.build?.args !== undefined) { + const image = findBaseImage(dockerfile, config.build.args, undefined); + if (image === undefined) { + return { image: {} }; + } + + return findImageVersionInfo(params, image, dockerfilePath, dockerfile.stages[0].from.image); + } + } else if ('dockerComposeFile' in config) { + const { dockerCLI, dockerComposeCLI } = dockerParams; + const { output } = params; + const composeFiles = await getDockerComposeFilePaths(cliHost, config, cliHost.env, cliHost.cwd); + const buildParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env: cliHost.env, output, platformInfo: dockerParams.platformInfo }; + const cwdEnvFile = cliHost.path.join(cliHost.cwd, '.env'); + const envFile = Array.isArray(config.dockerComposeFile) && config.dockerComposeFile.length === 0 && await cliHost.isFile(cwdEnvFile) ? cwdEnvFile : undefined; + const composeConfig = await readDockerComposeConfig(buildParams, composeFiles, envFile); + + const services = Object.keys(composeConfig.services || {}); + if (services.indexOf(config.service) === -1) { + output.write('Service not found in Docker Compose configuration'); + return { image: {} }; + } + + const composeService = composeConfig.services[config.service]; + if (composeService.image) { + return findImageVersionInfo(params, composeService.image, composeFiles[0], composeService.image); + } else { + const serviceInfo = getBuildInfoForService(composeService, cliHost.path, composeFiles); + if (serviceInfo.build) { + const { context, dockerfilePath } = serviceInfo.build; + const resolvedDockerfilePath = cliHost.path.isAbsolute(dockerfilePath) ? dockerfilePath : cliHost.path.resolve(context, dockerfilePath); + const dockerfileText = (await cliHost.readFile(resolvedDockerfilePath)).toString(); + const dockerfile = extractDockerfile(dockerfileText); + + if (composeService.build?.args !== undefined) { + const image = findBaseImage(dockerfile, composeService.build?.args, undefined); + if (image === undefined) { + return { image: {} }; + } + + return findImageVersionInfo(params, image, resolvedDockerfilePath, dockerfile.stages[0].from.image); + } + } + } + } + + return { image: {} }; +} + +export async function loadFeatureVersionInfo(params: ContainerFeatureInternalParams, config: DevContainerConfig) { + const userFeatures = userFeaturesToArray(config); + if (!userFeatures) { + return { features: {} }; + } + + const { lockfile } = await readLockfile(config); + + const resolved: Record = {}; + + await Promise.all(userFeatures.map(async userFeature => { + const userFeatureId = userFeature.userFeatureId; + const featureRef = getRef(nullLog, userFeatureId); // Filters out Feature identifiers that cannot be versioned (e.g. local paths, deprecated, etc..) + if (featureRef) { + const versions = (await getVersionsStrictSorted(params, featureRef)) + ?.reverse(); + if (versions) { + const lockfileVersion = lockfile?.features[userFeatureId]?.version; + let wanted = lockfileVersion; + const tag = featureRef.tag; + if (tag) { + if (tag === 'latest') { + wanted = versions[0]; + } else { + wanted = versions.find(version => semver.satisfies(version, tag)); + } + } else if (featureRef.digest && !wanted) { + const { type, manifest } = await getFeatureIdType(params, userFeatureId, undefined); + if (type === 'oci' && manifest) { + const wantedFeature = await findOCIFeatureMetadata(params, manifest); + wanted = wantedFeature?.version; + } + } + resolved[userFeatureId] = { + current: lockfileVersion || wanted, + wanted, + wantedMajor: wanted && semver.major(wanted)?.toString(), + latest: versions[0], + latestMajor: semver.major(versions[0])?.toString(), + }; + } + } + })); + + // Reorder Features to match the order in which they were specified in config + return { + features: userFeatures.reduce((acc, userFeature) => { + const r = resolved[userFeature.userFeatureId]; + if (r) { + acc[userFeature.userFeatureId] = r; + } + return acc; + }, {} as Record) + }; +} + +async function findOCIFeatureMetadata(params: ContainerFeatureInternalParams, manifest: ManifestContainer) { + const annotation = manifest.manifestObj.annotations?.['dev.containers.metadata']; + if (annotation) { + return jsonc.parse(annotation) as Feature; + } + + // Backwards compatibility. + const featureSet = tryGetOCIFeatureSet(params.output, manifest.canonicalId, {}, manifest, manifest.canonicalId); + if (!featureSet) { + return undefined; + } + + const tmp = path.join(os.tmpdir(), crypto.randomUUID()); + const f = await fetchOCIFeature(params, featureSet, tmp, tmp, DEVCONTAINER_FEATURE_FILE_NAME); + return f.metadata as Feature | undefined; +} \ No newline at end of file diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 165e11cf4..4824ef749 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -5,8 +5,6 @@ import * as path from 'path'; import yargs, { Argv } from 'yargs'; -import textTable from 'text-table'; - import * as jsonc from 'jsonc-parser'; import { createDockerParams, createLog, launch, ProvisionOptions } from './devContainers'; @@ -24,7 +22,6 @@ import { readDevContainerConfigFile } from './configContainer'; import { getDefaultDevContainerConfigPath, getDevContainerConfigPathIn, uriToFsPath } from '../spec-configuration/configurationCommonUtils'; import { CLIHost, getCLIHost } from '../spec-common/cliHost'; import { loadNativeModule, processSignals } from '../spec-common/commonUtils'; -import { loadImageVersionInfo, loadVersionInfo } from '../spec-configuration/containerFeaturesConfiguration'; import { featuresTestOptions, featuresTestHandler } from './featuresCLI/test'; import { featuresPackageHandler, featuresPackageOptions } from './featuresCLI/package'; import { featuresPublishHandler, featuresPublishOptions } from './featuresCLI/publish'; @@ -38,10 +35,10 @@ import { bailOut, buildNamedImageAndExtend } from './singleContainer'; import { Event, NodeEventEmitter } from '../spec-utils/event'; import { ensureNoDisallowedFeatures } from './disallowedFeatures'; import { featuresResolveDependenciesHandler, featuresResolveDependenciesOptions } from './featuresCLI/resolveDependencies'; -import { getFeatureIdWithoutVersion } from '../spec-configuration/containerFeaturesOCI'; import { featuresUpgradeHandler, featuresUpgradeOptions } from './upgradeCommand'; import { readFeaturesConfig } from './featureUtils'; import { mapNodeOSToGOOS, mapNodeArchitectureToGOARCH } from '../spec-configuration/containerCollectionsOCI'; +import { outdatedHandler, outdatedOptions } from './collectionCommonUtils/outdated'; const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell'; @@ -1061,164 +1058,6 @@ async function readConfiguration({ process.exit(0); } -function outdatedOptions(y: Argv) { - return y.options({ - 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, - 'workspace-folder': { type: 'string', required: true, description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, - 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, - 'output-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text', description: 'Output format.' }, - 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level for the --terminal-log-file. When set to trace, the log level for --log-file will also be set to trace.' }, - 'log-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text' as 'text', description: 'Log format.' }, - 'terminal-columns': { type: 'number', implies: ['terminal-rows'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' }, - 'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output.' }, - }); -} - -type OutdatedArgs = UnpackArgv>; - -function outdatedHandler(args: OutdatedArgs) { - (async () => outdated(args))().catch(console.error); -} - -async function outdated({ - // 'user-data-folder': persistedFolder, - 'workspace-folder': workspaceFolderArg, - config: configParam, - 'output-format': outputFormat, - 'log-level': logLevel, - 'log-format': logFormat, - 'terminal-rows': terminalRows, - 'terminal-columns': terminalColumns, -}: OutdatedArgs) { - const disposables: (() => Promise | undefined)[] = []; - const dispose = async () => { - await Promise.all(disposables.map(d => d())); - }; - let output: Log | undefined; - try { - const workspaceFolder = path.resolve(process.cwd(), workspaceFolderArg); - const configFile = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; - const cliHost = await getCLIHost(workspaceFolder, loadNativeModule, logFormat === 'text'); - const extensionPath = path.join(__dirname, '..', '..'); - const sessionStart = new Date(); - const pkg = getPackageConfig(); - output = createLog({ - logLevel: mapLogLevel(logLevel), - logFormat, - log: text => process.stderr.write(text), - terminalDimensions: terminalColumns && terminalRows ? { columns: terminalColumns, rows: terminalRows } : undefined, - }, pkg, sessionStart, disposables); - - const workspace = workspaceFromPath(cliHost.path, workspaceFolder); - const configPath = configFile ? configFile : await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath); - const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, true, output) || undefined; - if (!configs) { - throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); - } - - const cacheFolder = await getCacheFolder(cliHost); - const params = { - extensionPath, - cacheFolder, - cwd: cliHost.cwd, - output, - env: cliHost.env, - skipFeatureAutoMapping: false, - platform: cliHost.platform, - }; - - const outdatedFeatures = await loadVersionInfo(params, configs.config.config); - - const outputParams = { output, env: process.env }; - const dockerParams = await createDockerParams({ - containerDataFolder: undefined, - containerSystemDataFolder: undefined, - workspaceFolder, - mountWorkspaceGitRoot: false, - configFile, - logLevel: mapLogLevel(logLevel), - logFormat, - log: text => process.stderr.write(text), - terminalDimensions: /* terminalColumns && terminalRows ? { columns: terminalColumns, rows: terminalRows } : */ undefined, // TODO - defaultUserEnvProbe: 'loginInteractiveShell', - removeExistingContainer: false, - buildNoCache: false, - expectExistingContainer: false, - postCreateEnabled: false, - skipNonBlocking: false, - prebuild: false, - persistedFolder: undefined, - additionalMounts: [], - updateRemoteUserUIDDefault: 'never', - additionalCacheFroms: [], - useBuildKit: 'never', - buildxPlatform: undefined, - buildxPush: false, - buildxOutput: undefined, - buildxCacheTo: undefined, - skipFeatureAutoMapping: false, - skipPostAttach: false, - skipPersistingCustomizationsFromFeatures: false, - dotfiles: { - repository: undefined, - installCommand: undefined, - targetPath: undefined - }, - dockerPath: undefined, - dockerComposePath: undefined, - overrideConfigFile: undefined, - remoteEnv: {} - }, disposables); - - const outdatedImages = await loadImageVersionInfo(outputParams, configs.config.config, cliHost, dockerParams); - - await new Promise((resolve, reject) => { - let text = ''; - if (outputFormat === 'text') { - const rows = Object.keys(outdatedFeatures.features).map(key => { - const value = outdatedFeatures.features[key]; - return [ getFeatureIdWithoutVersion(key), value.current, value.wanted, value.latest ] - .map(v => v === undefined ? '-' : v); - }); - - if (rows.length !== 0) { - const featureHeader = ['Feature', 'Current', 'Wanted', 'Latest']; - text = textTable([ - featureHeader, - ...rows, - ]); - } - - if (outdatedImages !== undefined && outdatedImages.image !== undefined) { - const imageHeader = ['Image', 'Current', 'Latest']; - const image = outdatedImages.image; - - if (image.current !== undefined && image.wanted !== undefined && image.current !== image.wanted) { - text += '\n\n'; - text += textTable([ - imageHeader, - [image.name, image.current, image.wanted], - ]); - } - } - } else { - text = JSON.stringify({ ...outdatedFeatures, ...outdatedImages }, undefined, process.stdout.isTTY ? ' ' : undefined); - } - process.stdout.write(text + '\n', err => err ? reject(err) : resolve()); - }); - } catch (err) { - if (output) { - output.write(err && (err.stack || err.message) || String(err)); - } else { - console.error(err); - } - await dispose(); - process.exit(1); - } - await dispose(); - process.exit(0); -} - function execOptions(y: Argv) { return y.options({ 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, From 0ed835ef5deffbb5520c533c6fc5b759df4b24ac Mon Sep 17 00:00:00 2001 From: Samruddhi Khandale Date: Fri, 1 Mar 2024 20:14:50 +0000 Subject: [PATCH 05/20] fix type check --- src/spec-configuration/tsconfig.json | 3 +++ src/spec-node/devContainersSpecCLI.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/spec-configuration/tsconfig.json b/src/spec-configuration/tsconfig.json index f33845140..c94573cb7 100644 --- a/src/spec-configuration/tsconfig.json +++ b/src/spec-configuration/tsconfig.json @@ -6,6 +6,9 @@ }, { "path": "../spec-utils" + }, + { + "path": "../spec-shutdown" } ] } \ No newline at end of file diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 4824ef749..76d537c59 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -8,7 +8,7 @@ import yargs, { Argv } from 'yargs'; import * as jsonc from 'jsonc-parser'; import { createDockerParams, createLog, launch, ProvisionOptions } from './devContainers'; -import { SubstitutedConfig, createContainerProperties, envListToObj, inspectDockerImage, isDockerFileConfig, SubstituteConfig, addSubstitution, findContainerAndIdLabels, getCacheFolder } from './utils'; +import { SubstitutedConfig, createContainerProperties, envListToObj, inspectDockerImage, isDockerFileConfig, SubstituteConfig, addSubstitution, findContainerAndIdLabels } from './utils'; import { URI } from 'vscode-uri'; import { ContainerError } from '../spec-common/errors'; import { Log, LogDimensions, LogLevel, makeLog, mapLogLevel } from '../spec-utils/log'; From c705a1447a87e093f9668fd489569a7920da6f09 Mon Sep 17 00:00:00 2001 From: Samruddhi Khandale Date: Fri, 1 Mar 2024 20:42:09 +0000 Subject: [PATCH 06/20] fix test --- src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts b/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts index 80970d124..2b08b2e1b 100644 --- a/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts +++ b/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts @@ -2,6 +2,7 @@ import * as jsonc from 'jsonc-parser'; import * as os from 'os'; import * as path from 'path'; import * as semver from 'semver'; +import * as crypto from 'crypto'; import { Log, LogLevel, mapLogLevel, nullLog } from '../../spec-utils/log'; import { OutdatedArgs } from './outdated'; From ddf0ae56a61d48d262afa2e31699fa69e40ddacc Mon Sep 17 00:00:00 2001 From: Samruddhi Khandale Date: Fri, 1 Mar 2024 20:52:49 +0000 Subject: [PATCH 07/20] isolate outdated tests to cli.outdated.test.ts --- .github/workflows/dev-containers.yml | 1 + src/test/cli.outdated.test.ts | 89 +++++++++++++++++++ .../.devcontainer-lock.json | 0 .../.devcontainer.json | 0 src/test/container-features/lockfile.test.ts | 66 -------------- 5 files changed, 90 insertions(+), 66 deletions(-) create mode 100644 src/test/cli.outdated.test.ts rename src/test/{container-features => }/configs/lockfile-outdated-command/.devcontainer-lock.json (100%) rename src/test/{container-features => }/configs/lockfile-outdated-command/.devcontainer.json (100%) diff --git a/.github/workflows/dev-containers.yml b/.github/workflows/dev-containers.yml index 3e5c392ff..c87edf482 100644 --- a/.github/workflows/dev-containers.yml +++ b/.github/workflows/dev-containers.yml @@ -55,6 +55,7 @@ jobs: "src/test/cli.test.ts", "src/test/cli.up.test.ts", "src/test/imageMetadata.test.ts", + "src/test/cli.outdated.test.ts", "src/test/container-features/containerFeaturesOCIPush.test.ts", # Run all except the above: "--exclude src/test/container-features/containerFeaturesOrder.test.ts --exclude src/test/container-features/registryCompatibilityOCI.test.ts --exclude src/test/container-features/containerFeaturesOCIPush.test.ts --exclude src/test/container-features/e2e.test.ts --exclude src/test/container-features/featuresCLICommands.test.ts --exclude src/test/cli.build.test.ts --exclude src/test/cli.exec.buildKit.1.test.ts --exclude src/test/cli.exec.buildKit.2.test.ts --exclude src/test/cli.exec.nonBuildKit.1.test.ts --exclude src/test/cli.exec.nonBuildKit.2.test.ts --exclude src/test/cli.test.ts --exclude src/test/cli.up.test.ts --exclude src/test/imageMetadata.test.ts 'src/test/**/*.test.ts'", diff --git a/src/test/cli.outdated.test.ts b/src/test/cli.outdated.test.ts new file mode 100644 index 000000000..0bab47869 --- /dev/null +++ b/src/test/cli.outdated.test.ts @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as path from 'path'; +import * as semver from 'semver'; +import { shellExec } from './testUtils'; + +const pkg = require('../../package.json'); + +describe('Outdated', function () { + this.timeout('240s'); + + const tmp = path.relative(process.cwd(), path.join(__dirname, 'tmp')); + const cli = `npx --prefix ${tmp} devcontainer`; + + before('Install', async () => { + await shellExec(`rm -rf ${tmp}/node_modules`); + await shellExec(`mkdir -p ${tmp}`); + await shellExec(`npm --prefix ${tmp} install devcontainers-cli-${pkg.version}.tgz`); + }); + + it('outdated command with json output', async () => { + const workspaceFolder = path.join(__dirname, 'configs/lockfile-outdated-command'); + + const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format json`); + const response = JSON.parse(res.stdout); + + const git = response.features['ghcr.io/devcontainers/features/git:1.0']; + assert.ok(git); + assert.strictEqual(git.current, '1.0.4'); + assert.ok(semver.gt(git.wanted, git.current), `semver.gt(${git.wanted}, ${git.current}) is false`); + assert.ok(semver.gt(git.latest, git.wanted), `semver.gt(${git.latest}, ${git.wanted}) is false`); + + const lfs = response.features['ghcr.io/devcontainers/features/git-lfs@sha256:24d5802c837b2519b666a8403a9514c7296d769c9607048e9f1e040e7d7e331c']; + assert.ok(lfs); + assert.strictEqual(lfs.current, '1.0.6'); + assert.strictEqual(lfs.current, lfs.wanted); + assert.ok(semver.gt(lfs.latest, lfs.wanted), `semver.gt(${lfs.latest}, ${lfs.wanted}) is false`); + + const github = response.features['ghcr.io/devcontainers/features/github-cli']; + assert.ok(github); + assert.strictEqual(github.current, github.latest); + assert.strictEqual(github.wanted, github.latest); + + const azure = response.features['ghcr.io/devcontainers/features/azure-cli:0']; + assert.ok(azure); + assert.strictEqual(azure.current, undefined); + assert.strictEqual(azure.wanted, undefined); + assert.ok(azure.latest); + + const foo = response.features['ghcr.io/codspace/versioning/foo:0.3.1']; + assert.ok(foo); + assert.strictEqual(foo.current, '0.3.1'); + assert.strictEqual(foo.wanted, '0.3.1'); + assert.strictEqual(foo.wantedMajor, '0'); + assert.strictEqual(foo.latest, '2.11.1'); + assert.strictEqual(foo.latestMajor, '2'); + }); + + it('outdated command with text output', async () => { + const workspaceFolder = path.join(__dirname, 'configs/lockfile-outdated-command'); + + const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format text`); + const response = res.stdout; + // Count number of lines of output + assert.strictEqual(response.split('\n').length, 7); // 5 valid Features + header + empty line + + // Check that the header is present + assert.ok(response.includes('Current'), 'Current column is missing'); + assert.ok(response.includes('Wanted'), 'Wanted column is missing'); + assert.ok(response.includes('Latest'), 'Latest column is missing'); + + // Check that the features are present + // The version values are checked for correctness in the json variant of this test + assert.ok(response.includes('ghcr.io/devcontainers/features/git'), 'git Feature is missing'); + assert.ok(response.includes('ghcr.io/devcontainers/features/git-lfs'), 'git-lfs Feature is missing'); + assert.ok(response.includes('ghcr.io/devcontainers/features/github-cli'), 'github-cli Feature is missing'); + assert.ok(response.includes('ghcr.io/devcontainers/features/azure-cli'), 'azure-cli Feature is missing'); + assert.ok(response.includes('ghcr.io/codspace/versioning/foo'), 'foo Feature is missing'); + + // Check that filtered Features are not present + assert.ok(!response.includes('mylocalfeature')); + assert.ok(!response.includes('terraform')); + assert.ok(!response.includes('myfeatures')); + }); +}); \ No newline at end of file diff --git a/src/test/container-features/configs/lockfile-outdated-command/.devcontainer-lock.json b/src/test/configs/lockfile-outdated-command/.devcontainer-lock.json similarity index 100% rename from src/test/container-features/configs/lockfile-outdated-command/.devcontainer-lock.json rename to src/test/configs/lockfile-outdated-command/.devcontainer-lock.json diff --git a/src/test/container-features/configs/lockfile-outdated-command/.devcontainer.json b/src/test/configs/lockfile-outdated-command/.devcontainer.json similarity index 100% rename from src/test/container-features/configs/lockfile-outdated-command/.devcontainer.json rename to src/test/configs/lockfile-outdated-command/.devcontainer.json diff --git a/src/test/container-features/lockfile.test.ts b/src/test/container-features/lockfile.test.ts index d0889fed8..d3a384bb9 100644 --- a/src/test/container-features/lockfile.test.ts +++ b/src/test/container-features/lockfile.test.ts @@ -5,7 +5,6 @@ import * as assert from 'assert'; import * as path from 'path'; -import * as semver from 'semver'; import { shellExec } from '../testUtils'; import { cpLocal, readLocalFile, rmLocal } from '../../spec-utils/pfs'; @@ -87,71 +86,6 @@ describe('Lockfile', function () { } }); - it('outdated command with json output', async () => { - const workspaceFolder = path.join(__dirname, 'configs/lockfile-outdated-command'); - - const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format json`); - const response = JSON.parse(res.stdout); - - const git = response.features['ghcr.io/devcontainers/features/git:1.0']; - assert.ok(git); - assert.strictEqual(git.current, '1.0.4'); - assert.ok(semver.gt(git.wanted, git.current), `semver.gt(${git.wanted}, ${git.current}) is false`); - assert.ok(semver.gt(git.latest, git.wanted), `semver.gt(${git.latest}, ${git.wanted}) is false`); - - const lfs = response.features['ghcr.io/devcontainers/features/git-lfs@sha256:24d5802c837b2519b666a8403a9514c7296d769c9607048e9f1e040e7d7e331c']; - assert.ok(lfs); - assert.strictEqual(lfs.current, '1.0.6'); - assert.strictEqual(lfs.current, lfs.wanted); - assert.ok(semver.gt(lfs.latest, lfs.wanted), `semver.gt(${lfs.latest}, ${lfs.wanted}) is false`); - - const github = response.features['ghcr.io/devcontainers/features/github-cli']; - assert.ok(github); - assert.strictEqual(github.current, github.latest); - assert.strictEqual(github.wanted, github.latest); - - const azure = response.features['ghcr.io/devcontainers/features/azure-cli:0']; - assert.ok(azure); - assert.strictEqual(azure.current, undefined); - assert.strictEqual(azure.wanted, undefined); - assert.ok(azure.latest); - - const foo = response.features['ghcr.io/codspace/versioning/foo:0.3.1']; - assert.ok(foo); - assert.strictEqual(foo.current, '0.3.1'); - assert.strictEqual(foo.wanted, '0.3.1'); - assert.strictEqual(foo.wantedMajor, '0'); - assert.strictEqual(foo.latest, '2.11.1'); - assert.strictEqual(foo.latestMajor, '2'); - }); - - it('outdated command with text output', async () => { - const workspaceFolder = path.join(__dirname, 'configs/lockfile-outdated-command'); - - const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format text`); - const response = res.stdout; - // Count number of lines of output - assert.strictEqual(response.split('\n').length, 7); // 5 valid Features + header + empty line - - // Check that the header is present - assert.ok(response.includes('Current'), 'Current column is missing'); - assert.ok(response.includes('Wanted'), 'Wanted column is missing'); - assert.ok(response.includes('Latest'), 'Latest column is missing'); - - // Check that the features are present - // The version values are checked for correctness in the json variant of this test - assert.ok(response.includes('ghcr.io/devcontainers/features/git'), 'git Feature is missing'); - assert.ok(response.includes('ghcr.io/devcontainers/features/git-lfs'), 'git-lfs Feature is missing'); - assert.ok(response.includes('ghcr.io/devcontainers/features/github-cli'), 'github-cli Feature is missing'); - assert.ok(response.includes('ghcr.io/devcontainers/features/azure-cli'), 'azure-cli Feature is missing'); - assert.ok(response.includes('ghcr.io/codspace/versioning/foo'), 'foo Feature is missing'); - - // Check that filtered Features are not present - assert.ok(!response.includes('mylocalfeature')); - assert.ok(!response.includes('terraform')); - assert.ok(!response.includes('myfeatures')); - }); - it('upgrade command', async () => { const workspaceFolder = path.join(__dirname, 'configs/lockfile-upgrade-command'); From 320f987e79856801d87a3923120529137338e03c Mon Sep 17 00:00:00 2001 From: Samruddhi Khandale Date: Sat, 2 Mar 2024 00:55:25 +0000 Subject: [PATCH 08/20] multiple stage + multiple services + add tests --- .../outdatedCommandImpl.ts | 154 ++++++++++++------ src/spec-node/dockerfileUtils.ts | 49 ++++++ src/test/cli.outdated.test.ts | 96 ++++++++++- .../.devcontainer/Dockerfile | 2 +- .../.devcontainer/docker-compose.yml | 5 - .../.devcontainer/docker-compose.yml | 2 +- .../dockerfile-with-features/Dockerfile | 2 +- .../configs/dockerfile-with-target/Dockerfile | 4 +- .../.devcontainer.json | 2 +- 9 files changed, 256 insertions(+), 60 deletions(-) diff --git a/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts b/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts index 2b08b2e1b..4adab40d3 100644 --- a/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts +++ b/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts @@ -23,7 +23,7 @@ import { CommonParams, ManifestContainer, getRef, getVersionsStrictSorted } from import { DockerCLIParameters } from '../../spec-shutdown/dockerUtils'; import { request } from '../../spec-utils/httpRequest'; import { readDockerComposeConfig, getBuildInfoForService } from '../dockerCompose'; -import { extractDockerfile, findBaseImage } from '../dockerfileUtils'; +import { extractDockerfile, findImage } from '../dockerfileUtils'; import { ContainerFeatureInternalParams, userFeaturesToArray, getFeatureIdType, DEVCONTAINER_FEATURE_FILE_NAME, Feature } from '../../spec-configuration/containerFeaturesConfiguration'; import { readLockfile } from '../../spec-configuration/lockfile'; @@ -122,31 +122,33 @@ export async function outdated({ await new Promise((resolve, reject) => { let text = ''; if (outputFormat === 'text') { - const rows = Object.keys(outdatedFeatures.features).map(key => { + const featureRows = Object.keys(outdatedFeatures.features).map(key => { const value = outdatedFeatures.features[key]; return [getFeatureIdWithoutVersion(key), value.current, value.wanted, value.latest] .map(v => v === undefined ? '-' : v); }); - if (rows.length !== 0) { + if (featureRows.length !== 0) { const featureHeader = ['Feature', 'Current', 'Wanted', 'Latest']; text = textTable([ featureHeader, - ...rows, + ...featureRows, ]); } - if (outdatedImages !== undefined && outdatedImages.image !== undefined) { + const imageRows = Object.keys(outdatedImages.images).map(key => { + const value = outdatedImages.images[key]; + return [value.name, value.current, value.wanted] + .map(v => v === undefined ? '-' : v); + }); + + if (imageRows.length !== 0) { const imageHeader = ['Image', 'Current', 'Latest']; - const image = outdatedImages.image; - - if (image.current !== undefined && image.wanted !== undefined && image.current !== image.wanted) { - text += '\n\n'; - text += textTable([ - imageHeader, - [image.name, image.current, image.wanted], - ]); - } + text += '\n\n'; + text += textTable([ + imageHeader, + ...imageRows, + ]); } } else { text = JSON.stringify({ ...outdatedFeatures, ...outdatedImages }, undefined, process.stdout.isTTY ? ' ' : undefined); @@ -179,7 +181,7 @@ async function findImageVersionInfo(params: CommonParams, image: string, path: s if (tag === undefined) { output.write(`Skipping image '${imageName}' as it does not have a tag`, LogLevel.Trace); - return { image: {} }; + return undefined; } const [version, ...tagSuffixParts] = tag.split('-'); @@ -187,14 +189,14 @@ async function findImageVersionInfo(params: CommonParams, image: string, path: s if (!imageName.startsWith('mcr.microsoft.com/devcontainers/') && !imageName.startsWith('mcr.microsoft.com/vscode/devcontainers/')) { output.write(`Skipping image '${imageName}' as it is not an image hosted from devcontainer/images`, LogLevel.Trace); - return { image: {} }; + return undefined; } const specialImages = ['base', 'cpp', 'universal']; const imageType = imageName.split('/').pop() || ''; if (tag.startsWith('dev-') || (!specialImages.includes(imageType) && (!tag.includes('-') || !/\d/.test(tagSuffix))) || (specialImages.includes(imageType) && !/^\d/.test(tag))) { output.write(`Skipping image '${imageName}' as it does not pin to a semantic version`, LogLevel.Trace); - return { image: {} }; + return undefined; } try { @@ -215,7 +217,7 @@ async function findImageVersionInfo(params: CommonParams, image: string, path: s if (wantedTag === tag) { output.write(`Image '${imageName}' is already at the latest version '${tag}'`, LogLevel.Trace); - return { image: {} }; + return undefined; } let newImageValue = `${imageName}:${wantedTag}`; @@ -227,7 +229,16 @@ async function findImageVersionInfo(params: CommonParams, image: string, path: s newImageValue = `${imageName}:${wantedVersion}-${currentTagSuffix}`; } - return { image: { name: imageName, current: tag, wanted: wantedTag, currentImageValue, newImageValue, path } }; + return { + name: imageName, + version, + wantedVersion, + current: tag, + wanted: wantedTag, + currentImageValue, + newImageValue, + path, + }; } else { output.write(`Failed to find maximum satisfying latest version for image '${image}'`, LogLevel.Error); } @@ -235,7 +246,7 @@ async function findImageVersionInfo(params: CommonParams, image: string, path: s output.write(`Failed to parse published versions: ${e}`, LogLevel.Error); } - return { image: {} }; + return undefined; } // const image = config.image; @@ -272,21 +283,50 @@ async function findImageVersionInfo(params: CommonParams, image: string, path: s // const image = "mcr.microsoft.com/vscode/devcontainers/universal:0.18.0-linux"; async function loadImageVersionInfo(params: CommonParams, config: DevContainerConfig, cliHost: CLIHost, dockerParams: DockerResolverParameters) { if ('image' in config && config.image !== undefined) { - return findImageVersionInfo(params, config.image, config.configFilePath?.path || '', config.image); - + const imageInfo = await findImageVersionInfo(params, config.image, config.configFilePath?.path || '', config.image); + if (imageInfo !== undefined && imageInfo.name !== undefined) { + return { + images: { + [imageInfo.currentImageValue]: { + ...imageInfo + } + } + }; + } else { + return { images: {} }; + } } else if ('build' in config && config.build !== undefined && 'dockerfile' in config.build) { const dockerfileUri = getDockerfilePath(cliHost, config as DevContainerFromDockerfileConfig); const dockerfilePath = await uriToFsPath(dockerfileUri, cliHost.platform); const dockerfileText = (await cliHost.readFile(dockerfilePath)).toString(); const dockerfile = extractDockerfile(dockerfileText); - if ('build' in config && config.build?.args !== undefined) { - const image = findBaseImage(dockerfile, config.build.args, undefined); - if (image === undefined) { - return { image: {} }; + const resolvedImageInfo: Record = {}; + for (let i = 0; i < dockerfile.stages.length; i++) { + const stage = dockerfile.stages[i]; + if ('build' in config && config.build?.args !== undefined) { + const currentImage = stage.from.image; + const image = findImage(currentImage, dockerfile, config.build.args); + if (image === undefined) { + continue; + } + + let imageInfo = await findImageVersionInfo(params, image, dockerfilePath, currentImage); + + if (imageInfo !== undefined) { + resolvedImageInfo[currentImage] = imageInfo; + } } + } - return findImageVersionInfo(params, image, dockerfilePath, dockerfile.stages[0].from.image); + if (resolvedImageInfo && Object.keys(resolvedImageInfo).length > 0) { + return { + images: { + ...resolvedImageInfo + } + }; + } else { + return { images: {} }; } } else if ('dockerComposeFile' in config) { const { dockerCLI, dockerComposeCLI } = dockerParams; @@ -300,33 +340,55 @@ async function loadImageVersionInfo(params: CommonParams, config: DevContainerCo const services = Object.keys(composeConfig.services || {}); if (services.indexOf(config.service) === -1) { output.write('Service not found in Docker Compose configuration'); - return { image: {} }; + return { images: {} }; } - const composeService = composeConfig.services[config.service]; - if (composeService.image) { - return findImageVersionInfo(params, composeService.image, composeFiles[0], composeService.image); - } else { - const serviceInfo = getBuildInfoForService(composeService, cliHost.path, composeFiles); - if (serviceInfo.build) { - const { context, dockerfilePath } = serviceInfo.build; - const resolvedDockerfilePath = cliHost.path.isAbsolute(dockerfilePath) ? dockerfilePath : cliHost.path.resolve(context, dockerfilePath); - const dockerfileText = (await cliHost.readFile(resolvedDockerfilePath)).toString(); - const dockerfile = extractDockerfile(dockerfileText); - - if (composeService.build?.args !== undefined) { - const image = findBaseImage(dockerfile, composeService.build?.args, undefined); - if (image === undefined) { - return { image: {} }; + const resolvedImageInfo: Record = {}; + for (let s = 0; s < services.length; s++) { + const composeService = composeConfig.services[services[s]]; + if (composeService.image) { + const imageInfo = await findImageVersionInfo(params, composeService.image, composeFiles[0], composeService.image); + if (imageInfo !== undefined) { + resolvedImageInfo[composeService.image] = imageInfo; + } + } else { + const serviceInfo = getBuildInfoForService(composeService, cliHost.path, composeFiles); + if (serviceInfo.build) { + const { context, dockerfilePath } = serviceInfo.build; + const resolvedDockerfilePath = cliHost.path.isAbsolute(dockerfilePath) ? dockerfilePath : cliHost.path.resolve(context, dockerfilePath); + const dockerfileText = (await cliHost.readFile(resolvedDockerfilePath)).toString(); + const dockerfile = extractDockerfile(dockerfileText); + + for (let i = 0; i < dockerfile.stages.length; i++) { + const stage = dockerfile.stages[i]; + const currentImage = stage.from.image; + const image = findImage(currentImage, dockerfile, composeService.build?.args); + if (image === undefined) { + continue; + } + + let imageInfo = await findImageVersionInfo(params, image, dockerfilePath, currentImage); + + if (imageInfo !== undefined) { + resolvedImageInfo[currentImage] = imageInfo; + } } - - return findImageVersionInfo(params, image, resolvedDockerfilePath, dockerfile.stages[0].from.image); } } + + if (resolvedImageInfo && Object.keys(resolvedImageInfo).length > 0) { + return { + images: { + ...resolvedImageInfo + } + }; + } else { + return { images: {} }; + } } } - return { image: {} }; + return { images: {} }; } export async function loadFeatureVersionInfo(params: ContainerFeatureInternalParams, config: DevContainerConfig) { diff --git a/src/spec-node/dockerfileUtils.ts b/src/spec-node/dockerfileUtils.ts index 0f34476c7..e13ed74bb 100644 --- a/src/spec-node/dockerfileUtils.ts +++ b/src/spec-node/dockerfileUtils.ts @@ -119,6 +119,55 @@ export function findBaseImage(dockerfile: Dockerfile, buildArgs: Record) { + const resolvedImage = replaceVariablesInImage(dockerfile, buildArgs, image); + return resolvedImage; +} + +function replaceVariablesInImage(dockerfile: Dockerfile, buildArgs: Record, str: string) { + return [...str.matchAll(argumentExpression)] + .map(match => { + const variable = match.groups!.variable; + const isVarExp = match.groups!.isVarExp ? true : false; + let value = findValueInImage(dockerfile, buildArgs, variable) || ''; + if (isVarExp) { + // Handle replacing variable expressions (${var:+word}) if they exist + const option = match.groups!.option; + const word = match.groups!.word; + const isSet = value !== ''; + value = getExpressionValue(option, isSet, word, value); + } + + return { + begin: match.index!, + end: match.index! + match[0].length, + value, + }; + }).reverse() + .reduce((str, { begin, end, value }) => str.substring(0, begin) + value + str.substring(end), str); +} + +function findValueInImage(dockerfile: Dockerfile, buildArgs: Record, variable: string) { + if (buildArgs !== undefined && variable in buildArgs) { + return buildArgs[variable]; + } + + for (let s = 0; s < dockerfile.stages.length; s++) { + const stage = dockerfile.stages[s]; + const i = findLastIndex(stage.instructions, i => i.name === variable && (i.instruction === 'ENV' || i.instruction === 'ARG')); + if (i !== -1) { + return stage.instructions[i].value; + } + } + + const index = findLastIndex(dockerfile.preamble.instructions, i => i.name === variable && (i.instruction === 'ENV' || i.instruction === 'ARG')); + if (index !== -1) { + return dockerfile.preamble.instructions[index].value; + } + + return undefined; +} + function extractDirectives(preambleStr: string) { const map: Record = {}; for (const line of preambleStr.split(/\r?\n/)) { diff --git a/src/test/cli.outdated.test.ts b/src/test/cli.outdated.test.ts index 0bab47869..356f415cc 100644 --- a/src/test/cli.outdated.test.ts +++ b/src/test/cli.outdated.test.ts @@ -22,7 +22,7 @@ describe('Outdated', function () { await shellExec(`npm --prefix ${tmp} install devcontainers-cli-${pkg.version}.tgz`); }); - it('outdated command with json output', async () => { + it('json output', async () => { const workspaceFolder = path.join(__dirname, 'configs/lockfile-outdated-command'); const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format json`); @@ -58,15 +58,26 @@ describe('Outdated', function () { assert.strictEqual(foo.wantedMajor, '0'); assert.strictEqual(foo.latest, '2.11.1'); assert.strictEqual(foo.latestMajor, '2'); + + assert.equal(Object.keys(response.images).length, 1); + const baseImage = response.images['mcr.microsoft.com/devcontainers/base:0-ubuntu-20.04']; + assert.ok(baseImage); + assert.strictEqual(baseImage.name, 'mcr.microsoft.com/devcontainers/base'); + assert.strictEqual(baseImage.current, '0-ubuntu-20.04'); + assert.notStrictEqual(baseImage.wanted, baseImage.version); + assert.ok((parseInt(baseImage.wantedVersion) > parseInt(baseImage.version)), `semver.gt(${baseImage.wantedVersion}, ${baseImage.version}) is false`); + assert.strictEqual(baseImage.currentImageValue, 'mcr.microsoft.com/devcontainers/base:0-ubuntu-20.04'); + assert.notStrictEqual(baseImage.newImageValue, baseImage.currentImageValue); + assert.strictEqual(baseImage.newImageValue, `mcr.microsoft.com/devcontainers/base:${baseImage.wantedVersion}-ubuntu-20.04`); }); - it('outdated command with text output', async () => { + it('text output', async () => { const workspaceFolder = path.join(__dirname, 'configs/lockfile-outdated-command'); const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format text`); const response = res.stdout; // Count number of lines of output - assert.strictEqual(response.split('\n').length, 7); // 5 valid Features + header + empty line + assert.strictEqual(response.split('\n').length, 10); // 5 valid Features + header + empty line + image // Check that the header is present assert.ok(response.includes('Current'), 'Current column is missing'); @@ -85,5 +96,84 @@ describe('Outdated', function () { assert.ok(!response.includes('mylocalfeature')); assert.ok(!response.includes('terraform')); assert.ok(!response.includes('myfeatures')); + + // Check that the image is present + assert.ok(response.includes('mcr.microsoft.com/devcontainers/base'), 'Image is missing'); + assert.ok(response.includes('0-ubuntu-20.04'), 'Image version is missing'); + }); + + it('dockerfile', async () => { + const workspaceFolder = path.join(__dirname, 'configs/dockerfile-with-features'); + + const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format json`); + const response = JSON.parse(res.stdout); + assert.equal(Object.keys(response.images).length, 0); + }); + + it('dockerfile-with-variant-multi-stage', async () => { + const workspaceFolder = path.join(__dirname, 'configs/dockerfile-with-target'); + + const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format json`); + const response = JSON.parse(res.stdout); + + assert.equal(Object.keys(response.images).length, 2); + + const typeScript = response.images['mcr.microsoft.com/devcontainers/typescript-node:0.204.10-${VARIANT}']; + assert.ok(typeScript); + assert.strictEqual(typeScript.name, 'mcr.microsoft.com/devcontainers/typescript-node'); + assert.strictEqual(typeScript.current, '0.204.10-18-bookworm'); + assert.notStrictEqual(typeScript.wanted, typeScript.version); + assert.ok(semver.gt(typeScript.wantedVersion, typeScript.version), `semver.gt(${typeScript.wantedVersion}, ${typeScript.version}) is false`); + assert.strictEqual(typeScript.currentImageValue, 'mcr.microsoft.com/devcontainers/typescript-node:0.204.10-${VARIANT}'); + assert.notStrictEqual(typeScript.newImageValue, typeScript.currentImageValue); + assert.strictEqual(typeScript.newImageValue, `mcr.microsoft.com/devcontainers/typescript-node:${typeScript.wantedVersion}-\${VARIANT}`); + + const alpine = response.images['mcr.microsoft.com/devcontainers/base:0.207.2-alpine3.18']; + assert.ok(alpine); + assert.strictEqual(alpine.name, 'mcr.microsoft.com/devcontainers/base'); + assert.strictEqual(alpine.current, '0.207.2-alpine3.18'); + assert.notStrictEqual(alpine.wanted, alpine.version); + assert.ok(semver.gt(alpine.wantedVersion, alpine.version), `semver.gt(${alpine.wantedVersion}, ${alpine.version}) is false`); + assert.strictEqual(alpine.currentImageValue, 'mcr.microsoft.com/devcontainers/base:0.207.2-alpine3.18'); + assert.notStrictEqual(alpine.newImageValue, alpine.currentImageValue); + assert.strictEqual(alpine.newImageValue, `mcr.microsoft.com/devcontainers/base:${alpine.wantedVersion}-alpine3.18`); + }); + + it('dockercompose-image', async () => { + const workspaceFolder = path.join(__dirname, 'configs/compose-image-with-features'); + + const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format json`); + const response = JSON.parse(res.stdout); + + assert.equal(Object.keys(response.images).length, 1); + + const javascript = response.images['mcr.microsoft.com/devcontainers/javascript-node:0.204-18-buster']; + assert.ok(javascript); + assert.strictEqual(javascript.name, 'mcr.microsoft.com/devcontainers/javascript-node'); + assert.strictEqual(javascript.current, '0.204-18-buster'); + assert.notStrictEqual(javascript.wanted, javascript.version); + assert.ok((parseFloat(javascript.wantedVersion) > parseFloat(javascript.version)), `semver.gt(${javascript.wantedVersion}, ${javascript.version}) is false`); + assert.strictEqual(javascript.currentImageValue, 'mcr.microsoft.com/devcontainers/javascript-node:0.204-18-buster'); + assert.notStrictEqual(javascript.newImageValue, javascript.currentImageValue); + assert.strictEqual(javascript.newImageValue, `mcr.microsoft.com/devcontainers/javascript-node:${javascript.wantedVersion}-18-buster`); + }); + + it('dockercompose-dockerfile', async () => { + const workspaceFolder = path.join(__dirname, 'configs/compose-Dockerfile-with-features'); + + const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format json`); + const response = JSON.parse(res.stdout); + + assert.equal(Object.keys(response.images).length, 1); + + const javascript = response.images['mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT}']; + assert.ok(javascript); + assert.strictEqual(javascript.name, 'mcr.microsoft.com/devcontainers/javascript-node'); + assert.strictEqual(javascript.current, '0-16-bullseye'); + assert.notStrictEqual(javascript.wanted, javascript.version); + assert.ok((parseFloat(javascript.wantedVersion) > parseFloat(javascript.version)), `semver.gt(${javascript.wantedVersion}, ${javascript.version}) is false`); + assert.strictEqual(javascript.currentImageValue, 'mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT}'); + assert.notStrictEqual(javascript.newImageValue, javascript.currentImageValue); + assert.strictEqual(javascript.newImageValue, `mcr.microsoft.com/devcontainers/javascript-node:${javascript.wantedVersion}-\${VARIANT}`); }); }); \ No newline at end of file diff --git a/src/test/configs/compose-Dockerfile-with-features/.devcontainer/Dockerfile b/src/test/configs/compose-Dockerfile-with-features/.devcontainer/Dockerfile index 4d3a9aaba..533270dc3 100644 --- a/src/test/configs/compose-Dockerfile-with-features/.devcontainer/Dockerfile +++ b/src/test/configs/compose-Dockerfile-with-features/.devcontainer/Dockerfile @@ -1,6 +1,6 @@ # [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster ARG VARIANT=16-bullseye -FROM mcr.microsoft.com/devcontainers/javascript-node:1-${VARIANT} +FROM mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT} # Install MongoDB command line tools if on buster and x86_64 (arm64 not supported) ARG MONGO_TOOLS_VERSION=5.0 diff --git a/src/test/configs/compose-Dockerfile-with-features/.devcontainer/docker-compose.yml b/src/test/configs/compose-Dockerfile-with-features/.devcontainer/docker-compose.yml index 3833580b9..3bbdd99fa 100644 --- a/src/test/configs/compose-Dockerfile-with-features/.devcontainer/docker-compose.yml +++ b/src/test/configs/compose-Dockerfile-with-features/.devcontainer/docker-compose.yml @@ -5,11 +5,6 @@ services: build: context: . dockerfile: Dockerfile - args: - # Update 'VARIANT' to pick an LTS version of Node.js: 18, 16, 14. - # Append -bullseye or -buster to pin to an OS version. - # Use -bullseye variants on local arm64/Apple Silicon. - VARIANT: 18-bookworm volumes: - ..:/workspace:cached diff --git a/src/test/configs/compose-image-with-features/.devcontainer/docker-compose.yml b/src/test/configs/compose-image-with-features/.devcontainer/docker-compose.yml index 07185b299..f63bf2431 100644 --- a/src/test/configs/compose-image-with-features/.devcontainer/docker-compose.yml +++ b/src/test/configs/compose-image-with-features/.devcontainer/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.8' services: app: - image: mcr.microsoft.com/devcontainers/javascript-node:1-18-bookworm + image: mcr.microsoft.com/devcontainers/javascript-node:0.204-18-buster volumes: - ..:/workspace:cached diff --git a/src/test/configs/dockerfile-with-features/Dockerfile b/src/test/configs/dockerfile-with-features/Dockerfile index 07dc79d04..029e494e7 100644 --- a/src/test/configs/dockerfile-with-features/Dockerfile +++ b/src/test/configs/dockerfile-with-features/Dockerfile @@ -1,4 +1,4 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. ARG VARIANT="16-bullseye" -FROM mcr.microsoft.com/devcontainers/typescript-node:1-${VARIANT} +FROM mcr.microsoft.com/devcontainers/typescript-node:latest diff --git a/src/test/configs/dockerfile-with-target/Dockerfile b/src/test/configs/dockerfile-with-target/Dockerfile index 25c88c27c..df426fad6 100644 --- a/src/test/configs/dockerfile-with-target/Dockerfile +++ b/src/test/configs/dockerfile-with-target/Dockerfile @@ -5,9 +5,9 @@ ARG VARIANT="16-bullseye" # Target should skip this layer FROM alpine as false-start -FROM mcr.microsoft.com/devcontainers/typescript-node:1-${VARIANT} as desired-image +FROM mcr.microsoft.com/devcontainers/typescript-node:0.204.10-${VARIANT} as desired-image RUN echo "||test-content||" | sudo tee /var/test-marker # Target should skip this layer -FROM alpine as false-finish +FROM mcr.microsoft.com/devcontainers/base:0.207.2-alpine3.18 as false-finish diff --git a/src/test/configs/lockfile-outdated-command/.devcontainer.json b/src/test/configs/lockfile-outdated-command/.devcontainer.json index 7aba15391..6154466b8 100644 --- a/src/test/configs/lockfile-outdated-command/.devcontainer.json +++ b/src/test/configs/lockfile-outdated-command/.devcontainer.json @@ -1,5 +1,5 @@ { - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "image": "mcr.microsoft.com/devcontainers/base:0-ubuntu-20.04", "features": { "ghcr.io/devcontainers/features/git:1.0": "latest", "ghcr.io/devcontainers/features/git-lfs@sha256:24d5802c837b2519b666a8403a9514c7296d769c9607048e9f1e040e7d7e331c": "latest", From cdf9fe4d7e4447d5d1ff6d378c3c013974744e6d Mon Sep 17 00:00:00 2001 From: Samruddhi Khandale Date: Tue, 5 Mar 2024 19:20:03 +0000 Subject: [PATCH 09/20] fix tests --- src/test/cli.outdated.test.ts | 6 +++--- src/test/configs/dockerfile-with-target/Dockerfile | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/test/cli.outdated.test.ts b/src/test/cli.outdated.test.ts index 356f415cc..d52d0b882 100644 --- a/src/test/cli.outdated.test.ts +++ b/src/test/cli.outdated.test.ts @@ -118,13 +118,13 @@ describe('Outdated', function () { assert.equal(Object.keys(response.images).length, 2); - const typeScript = response.images['mcr.microsoft.com/devcontainers/typescript-node:0.204.10-${VARIANT}']; + const typeScript = response.images['mcr.microsoft.com/devcontainers/typescript-node:1.0.5-${VARIANT}']; assert.ok(typeScript); assert.strictEqual(typeScript.name, 'mcr.microsoft.com/devcontainers/typescript-node'); - assert.strictEqual(typeScript.current, '0.204.10-18-bookworm'); + assert.strictEqual(typeScript.current, '1.0.5-18-bookworm'); assert.notStrictEqual(typeScript.wanted, typeScript.version); assert.ok(semver.gt(typeScript.wantedVersion, typeScript.version), `semver.gt(${typeScript.wantedVersion}, ${typeScript.version}) is false`); - assert.strictEqual(typeScript.currentImageValue, 'mcr.microsoft.com/devcontainers/typescript-node:0.204.10-${VARIANT}'); + assert.strictEqual(typeScript.currentImageValue, 'mcr.microsoft.com/devcontainers/typescript-node:1.0.5-${VARIANT}'); assert.notStrictEqual(typeScript.newImageValue, typeScript.currentImageValue); assert.strictEqual(typeScript.newImageValue, `mcr.microsoft.com/devcontainers/typescript-node:${typeScript.wantedVersion}-\${VARIANT}`); diff --git a/src/test/configs/dockerfile-with-target/Dockerfile b/src/test/configs/dockerfile-with-target/Dockerfile index df426fad6..9f3ce3757 100644 --- a/src/test/configs/dockerfile-with-target/Dockerfile +++ b/src/test/configs/dockerfile-with-target/Dockerfile @@ -5,7 +5,7 @@ ARG VARIANT="16-bullseye" # Target should skip this layer FROM alpine as false-start -FROM mcr.microsoft.com/devcontainers/typescript-node:0.204.10-${VARIANT} as desired-image +FROM mcr.microsoft.com/devcontainers/typescript-node:1.0.5-${VARIANT} as desired-image RUN echo "||test-content||" | sudo tee /var/test-marker From cf1f50a1fde7e581569af35c2e3d3ec3ee563495 Mon Sep 17 00:00:00 2001 From: Samruddhi Khandale Date: Tue, 5 Mar 2024 22:06:32 +0000 Subject: [PATCH 10/20] fix muliti-stage ; same arg defined twice --- .../outdatedCommandImpl.ts | 63 ++++++------------- src/spec-node/dockerfileUtils.ts | 17 +++-- src/test/cli.outdated.test.ts | 34 +++++++--- .../dockerfile-with-syntax/.devcontainer.json | 5 +- .../configs/dockerfile-with-syntax/Dockerfile | 5 +- 5 files changed, 60 insertions(+), 64 deletions(-) diff --git a/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts b/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts index 4adab40d3..bcc8470ca 100644 --- a/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts +++ b/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts @@ -212,6 +212,15 @@ async function findImageVersionInfo(params: CommonParams, image: string, path: s .pop(); if (latestVersion) { + + if (semver.valid(version) && semver.valid(latestVersion) && semver.gt(version, latestVersion)) { + output.write(`Image '${imageName}' is at a higher version than the latest version '${latestVersion}'`, LogLevel.Trace); + return undefined; + } else if (parseFloat(version) > parseFloat(latestVersion)) { + output.write(`Image '${imageName}' is at a higher version than the latest version '${latestVersion}'`, LogLevel.Trace); + return undefined; + } + const wantedVersion = latestVersion.split('.').slice(0, version.split('.').length).join('.'); const wantedTag = tagSuffix ? `${wantedVersion}-${tagSuffix}` : wantedVersion; @@ -249,38 +258,6 @@ async function findImageVersionInfo(params: CommonParams, image: string, path: s return undefined; } -// const image = config.image; -// const image = "mcr.microsoft.com/devcontainers/python:0-3.9"; -// const image = "mcr.microsoft.com/devcontainers/python:0-3.9-buster"; -// const image = "mcr.microsoft.com/devcontainers/python:0.203-3.9"; -// const image = "mcr.microsoft.com/devcontainers/python:0.203.10-3.9"; -// const image = "mcr.microsoft.com/devcontainers/python:0.204-3.11-buster"; -// const image = "mcr.microsoft.com/devcontainers/python:3"; -// const image = "mcr.microsoft.com/devcontainers/python:3.9"; -// const image = "mcr.microsoft.com/devcontainers/python:3.9-buster"; -// const image = "mcr.microsoft.com/devcontainers/python:dev"; -// const image = "mcr.microsoft.com/devcontainers/python:latest"; - -// const image = "mcr.microsoft.com/devcontainers/base:0"; -// const image = "mcr.microsoft.com/devcontainers/base:0-buster"; -// const image = "mcr.microsoft.com/devcontainers/base:0.202-debian-10"; -// const image = "mcr.microsoft.com/devcontainers/base:0.202.10-debian-10"; -// const image = "mcr.microsoft.com/devcontainers/base:0.203.0-ubuntu-20.04"; -// const image = "mcr.microsoft.com/devcontainers/base:ubuntu"; -// const image = "mcr.microsoft.com/devcontainers/base:ubuntu-20.04"; - -// const image = "mcr.microsoft.com/devcontainers/cpp:0.206.6"; -// const image = "mcr.microsoft.com/devcontainers/cpp:0.205"; -// const image = "mcr.microsoft.com/devcontainers/cpp:0"; -// const image = "mcr.microsoft.com/devcontainers/cpp:latest"; - -// const image = "mcr.microsoft.com/devcontainers/javascript-node:1.0.0-16"; -// const image = "mcr.microsoft.com/devcontainers/javascript-node:14"; - -// const image = "mcr.microsoft.com/devcontainers/jekyll:3.3-bookworm"; - -// const image = "mcr.microsoft.com/vscode/devcontainers/universal:0"; -// const image = "mcr.microsoft.com/vscode/devcontainers/universal:0.18.0-linux"; async function loadImageVersionInfo(params: CommonParams, config: DevContainerConfig, cliHost: CLIHost, dockerParams: DockerResolverParameters) { if ('image' in config && config.image !== undefined) { const imageInfo = await findImageVersionInfo(params, config.image, config.configFilePath?.path || '', config.image); @@ -304,18 +281,17 @@ async function loadImageVersionInfo(params: CommonParams, config: DevContainerCo const resolvedImageInfo: Record = {}; for (let i = 0; i < dockerfile.stages.length; i++) { const stage = dockerfile.stages[i]; - if ('build' in config && config.build?.args !== undefined) { - const currentImage = stage.from.image; - const image = findImage(currentImage, dockerfile, config.build.args); - if (image === undefined) { - continue; - } + const currentImage = stage.from.image; + const previousStage = (i !== 0) ? dockerfile.stages[i - 1] : undefined; + const image = findImage(currentImage, dockerfile, config.build.args || {}, previousStage); + if (image === undefined) { + continue; + } - let imageInfo = await findImageVersionInfo(params, image, dockerfilePath, currentImage); + let imageInfo = await findImageVersionInfo(params, image, dockerfilePath, currentImage); - if (imageInfo !== undefined) { - resolvedImageInfo[currentImage] = imageInfo; - } + if (imageInfo !== undefined) { + resolvedImageInfo[currentImage] = imageInfo; } } @@ -362,7 +338,8 @@ async function loadImageVersionInfo(params: CommonParams, config: DevContainerCo for (let i = 0; i < dockerfile.stages.length; i++) { const stage = dockerfile.stages[i]; const currentImage = stage.from.image; - const image = findImage(currentImage, dockerfile, composeService.build?.args); + const previousStage = (i !== 0) ? dockerfile.stages[i - 1] : undefined; + const image = findImage(currentImage, dockerfile, composeService.build?.args, previousStage); if (image === undefined) { continue; } diff --git a/src/spec-node/dockerfileUtils.ts b/src/spec-node/dockerfileUtils.ts index e13ed74bb..144d89eaa 100644 --- a/src/spec-node/dockerfileUtils.ts +++ b/src/spec-node/dockerfileUtils.ts @@ -119,17 +119,17 @@ export function findBaseImage(dockerfile: Dockerfile, buildArgs: Record) { - const resolvedImage = replaceVariablesInImage(dockerfile, buildArgs, image); +export function findImage(image: string, dockerfile: Dockerfile, buildArgs: Record, previousStage: Stage | undefined) { + const resolvedImage = replaceVariablesInImage(dockerfile, buildArgs, image, previousStage); return resolvedImage; } -function replaceVariablesInImage(dockerfile: Dockerfile, buildArgs: Record, str: string) { +function replaceVariablesInImage(dockerfile: Dockerfile, buildArgs: Record, str: string, previousStage: Stage | undefined) { return [...str.matchAll(argumentExpression)] .map(match => { const variable = match.groups!.variable; const isVarExp = match.groups!.isVarExp ? true : false; - let value = findValueInImage(dockerfile, buildArgs, variable) || ''; + let value = findValueInImage(dockerfile, buildArgs, variable, previousStage) || ''; if (isVarExp) { // Handle replacing variable expressions (${var:+word}) if they exist const option = match.groups!.option; @@ -147,16 +147,15 @@ function replaceVariablesInImage(dockerfile: Dockerfile, buildArgs: Record str.substring(0, begin) + value + str.substring(end), str); } -function findValueInImage(dockerfile: Dockerfile, buildArgs: Record, variable: string) { +function findValueInImage(dockerfile: Dockerfile, buildArgs: Record, variable: string, previousStage: Stage | undefined) { if (buildArgs !== undefined && variable in buildArgs) { return buildArgs[variable]; } - for (let s = 0; s < dockerfile.stages.length; s++) { - const stage = dockerfile.stages[s]; - const i = findLastIndex(stage.instructions, i => i.name === variable && (i.instruction === 'ENV' || i.instruction === 'ARG')); + if (previousStage !== undefined) { + const i = findLastIndex(previousStage.instructions, i => i.name === variable && (i.instruction === 'ENV' || i.instruction === 'ARG')); if (i !== -1) { - return stage.instructions[i].value; + return previousStage.instructions[i].value; } } diff --git a/src/test/cli.outdated.test.ts b/src/test/cli.outdated.test.ts index d52d0b882..4c954eca8 100644 --- a/src/test/cli.outdated.test.ts +++ b/src/test/cli.outdated.test.ts @@ -59,7 +59,6 @@ describe('Outdated', function () { assert.strictEqual(foo.latest, '2.11.1'); assert.strictEqual(foo.latestMajor, '2'); - assert.equal(Object.keys(response.images).length, 1); const baseImage = response.images['mcr.microsoft.com/devcontainers/base:0-ubuntu-20.04']; assert.ok(baseImage); assert.strictEqual(baseImage.name, 'mcr.microsoft.com/devcontainers/base'); @@ -116,8 +115,6 @@ describe('Outdated', function () { const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format json`); const response = JSON.parse(res.stdout); - assert.equal(Object.keys(response.images).length, 2); - const typeScript = response.images['mcr.microsoft.com/devcontainers/typescript-node:1.0.5-${VARIANT}']; assert.ok(typeScript); assert.strictEqual(typeScript.name, 'mcr.microsoft.com/devcontainers/typescript-node'); @@ -145,8 +142,6 @@ describe('Outdated', function () { const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format json`); const response = JSON.parse(res.stdout); - assert.equal(Object.keys(response.images).length, 1); - const javascript = response.images['mcr.microsoft.com/devcontainers/javascript-node:0.204-18-buster']; assert.ok(javascript); assert.strictEqual(javascript.name, 'mcr.microsoft.com/devcontainers/javascript-node'); @@ -164,8 +159,6 @@ describe('Outdated', function () { const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format json`); const response = JSON.parse(res.stdout); - assert.equal(Object.keys(response.images).length, 1); - const javascript = response.images['mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT}']; assert.ok(javascript); assert.strictEqual(javascript.name, 'mcr.microsoft.com/devcontainers/javascript-node'); @@ -176,4 +169,31 @@ describe('Outdated', function () { assert.notStrictEqual(javascript.newImageValue, javascript.currentImageValue); assert.strictEqual(javascript.newImageValue, `mcr.microsoft.com/devcontainers/javascript-node:${javascript.wantedVersion}-\${VARIANT}`); }); + + it('dockerfile-multi-arg', async () => { + const workspaceFolder = path.join(__dirname, 'configs/dockerfile-with-syntax'); + + const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format json`); + const response = JSON.parse(res.stdout); + + const typeScript = response.images['mcr.microsoft.com/devcontainers/typescript-node:0-${VARIANT}']; + assert.ok(typeScript); + assert.strictEqual(typeScript.name, 'mcr.microsoft.com/devcontainers/typescript-node'); + assert.strictEqual(typeScript.current, '0-16-bullseye'); + assert.notStrictEqual(typeScript.wanted, typeScript.version); + assert.ok((parseFloat(typeScript.wantedVersion) > parseFloat(typeScript.version)), `semver.gt(${typeScript.wantedVersion}, ${typeScript.version}) is false`); + assert.strictEqual(typeScript.currentImageValue, 'mcr.microsoft.com/devcontainers/typescript-node:0-${VARIANT}'); + assert.notStrictEqual(typeScript.newImageValue, typeScript.currentImageValue); + assert.strictEqual(typeScript.newImageValue, `mcr.microsoft.com/devcontainers/typescript-node:${typeScript.wantedVersion}-\${VARIANT}`); + + const ubuntu = response.images['mcr.microsoft.com/devcontainers/base:0.203-${VARIANT}']; + assert.ok(ubuntu); + assert.strictEqual(ubuntu.name, 'mcr.microsoft.com/devcontainers/base'); + assert.strictEqual(ubuntu.current, '0.203-ubuntu-20.04'); + assert.notStrictEqual(ubuntu.wanted, ubuntu.version); + assert.ok((parseFloat(ubuntu.wantedVersion) > parseFloat(ubuntu.version)), `semver.gt(${ubuntu.wantedVersion}, ${ubuntu.version}) is false`); + assert.strictEqual(ubuntu.currentImageValue, 'mcr.microsoft.com/devcontainers/base:0.203-${VARIANT}'); + assert.notStrictEqual(ubuntu.newImageValue, ubuntu.currentImageValue); + assert.strictEqual(ubuntu.newImageValue, `mcr.microsoft.com/devcontainers/base:${ubuntu.wantedVersion}-\${VARIANT}`); + }); }); \ No newline at end of file diff --git a/src/test/configs/dockerfile-with-syntax/.devcontainer.json b/src/test/configs/dockerfile-with-syntax/.devcontainer.json index 8aaa22fda..30ea9e099 100644 --- a/src/test/configs/dockerfile-with-syntax/.devcontainer.json +++ b/src/test/configs/dockerfile-with-syntax/.devcontainer.json @@ -1,9 +1,6 @@ { "build": { - "dockerfile": "Dockerfile", - "args": { - "VARIANT": "18-bookworm" - } + "dockerfile": "Dockerfile" }, "features": { "ghcr.io/devcontainers/features/docker-in-docker:2": {}, diff --git a/src/test/configs/dockerfile-with-syntax/Dockerfile b/src/test/configs/dockerfile-with-syntax/Dockerfile index 868e47ef5..a907958f0 100644 --- a/src/test/configs/dockerfile-with-syntax/Dockerfile +++ b/src/test/configs/dockerfile-with-syntax/Dockerfile @@ -2,4 +2,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. ARG VARIANT="16-bullseye" -FROM mcr.microsoft.com/devcontainers/typescript-node:1-${VARIANT} +FROM mcr.microsoft.com/devcontainers/typescript-node:0-${VARIANT} + +ARG VARIANT="ubuntu-20.04" +FROM mcr.microsoft.com/devcontainers/base:0.203-${VARIANT} \ No newline at end of file From 33fe005b42465171081aea50a3a96defa0c97f05 Mon Sep 17 00:00:00 2001 From: Samruddhi Khandale Date: Tue, 5 Mar 2024 22:31:50 +0000 Subject: [PATCH 11/20] fix tests --- .../outdatedCommandImpl.ts | 1 - src/test/cli.outdated.test.ts | 27 ------------------- .../dockerfile-with-syntax/.devcontainer.json | 5 +++- .../configs/dockerfile-with-syntax/Dockerfile | 5 +--- 4 files changed, 5 insertions(+), 33 deletions(-) diff --git a/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts b/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts index bcc8470ca..fd9ded9f5 100644 --- a/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts +++ b/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts @@ -212,7 +212,6 @@ async function findImageVersionInfo(params: CommonParams, image: string, path: s .pop(); if (latestVersion) { - if (semver.valid(version) && semver.valid(latestVersion) && semver.gt(version, latestVersion)) { output.write(`Image '${imageName}' is at a higher version than the latest version '${latestVersion}'`, LogLevel.Trace); return undefined; diff --git a/src/test/cli.outdated.test.ts b/src/test/cli.outdated.test.ts index 4c954eca8..196816245 100644 --- a/src/test/cli.outdated.test.ts +++ b/src/test/cli.outdated.test.ts @@ -169,31 +169,4 @@ describe('Outdated', function () { assert.notStrictEqual(javascript.newImageValue, javascript.currentImageValue); assert.strictEqual(javascript.newImageValue, `mcr.microsoft.com/devcontainers/javascript-node:${javascript.wantedVersion}-\${VARIANT}`); }); - - it('dockerfile-multi-arg', async () => { - const workspaceFolder = path.join(__dirname, 'configs/dockerfile-with-syntax'); - - const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format json`); - const response = JSON.parse(res.stdout); - - const typeScript = response.images['mcr.microsoft.com/devcontainers/typescript-node:0-${VARIANT}']; - assert.ok(typeScript); - assert.strictEqual(typeScript.name, 'mcr.microsoft.com/devcontainers/typescript-node'); - assert.strictEqual(typeScript.current, '0-16-bullseye'); - assert.notStrictEqual(typeScript.wanted, typeScript.version); - assert.ok((parseFloat(typeScript.wantedVersion) > parseFloat(typeScript.version)), `semver.gt(${typeScript.wantedVersion}, ${typeScript.version}) is false`); - assert.strictEqual(typeScript.currentImageValue, 'mcr.microsoft.com/devcontainers/typescript-node:0-${VARIANT}'); - assert.notStrictEqual(typeScript.newImageValue, typeScript.currentImageValue); - assert.strictEqual(typeScript.newImageValue, `mcr.microsoft.com/devcontainers/typescript-node:${typeScript.wantedVersion}-\${VARIANT}`); - - const ubuntu = response.images['mcr.microsoft.com/devcontainers/base:0.203-${VARIANT}']; - assert.ok(ubuntu); - assert.strictEqual(ubuntu.name, 'mcr.microsoft.com/devcontainers/base'); - assert.strictEqual(ubuntu.current, '0.203-ubuntu-20.04'); - assert.notStrictEqual(ubuntu.wanted, ubuntu.version); - assert.ok((parseFloat(ubuntu.wantedVersion) > parseFloat(ubuntu.version)), `semver.gt(${ubuntu.wantedVersion}, ${ubuntu.version}) is false`); - assert.strictEqual(ubuntu.currentImageValue, 'mcr.microsoft.com/devcontainers/base:0.203-${VARIANT}'); - assert.notStrictEqual(ubuntu.newImageValue, ubuntu.currentImageValue); - assert.strictEqual(ubuntu.newImageValue, `mcr.microsoft.com/devcontainers/base:${ubuntu.wantedVersion}-\${VARIANT}`); - }); }); \ No newline at end of file diff --git a/src/test/configs/dockerfile-with-syntax/.devcontainer.json b/src/test/configs/dockerfile-with-syntax/.devcontainer.json index 30ea9e099..d044bd433 100644 --- a/src/test/configs/dockerfile-with-syntax/.devcontainer.json +++ b/src/test/configs/dockerfile-with-syntax/.devcontainer.json @@ -1,6 +1,9 @@ { "build": { - "dockerfile": "Dockerfile" + "dockerfile": "Dockerfile", + "args": { + "VARIANT": "18-bookworm" + } }, "features": { "ghcr.io/devcontainers/features/docker-in-docker:2": {}, diff --git a/src/test/configs/dockerfile-with-syntax/Dockerfile b/src/test/configs/dockerfile-with-syntax/Dockerfile index a907958f0..5c6ec58c7 100644 --- a/src/test/configs/dockerfile-with-syntax/Dockerfile +++ b/src/test/configs/dockerfile-with-syntax/Dockerfile @@ -2,7 +2,4 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. ARG VARIANT="16-bullseye" -FROM mcr.microsoft.com/devcontainers/typescript-node:0-${VARIANT} - -ARG VARIANT="ubuntu-20.04" -FROM mcr.microsoft.com/devcontainers/base:0.203-${VARIANT} \ No newline at end of file +FROM mcr.microsoft.com/devcontainers/typescript-node:1-${VARIANT} \ No newline at end of file From b47ad7e9d1791afadccf986137f0368188b9df5c Mon Sep 17 00:00:00 2001 From: Samruddhi Khandale Date: Tue, 5 Mar 2024 22:41:54 +0000 Subject: [PATCH 12/20] add more tests --- src/test/cli.outdated.test.ts | 17 +++++++++++++++++ .../image-with-git-feature/.devcontainer.json | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/test/cli.outdated.test.ts b/src/test/cli.outdated.test.ts index 196816245..cf738a8ed 100644 --- a/src/test/cli.outdated.test.ts +++ b/src/test/cli.outdated.test.ts @@ -169,4 +169,21 @@ describe('Outdated', function () { assert.notStrictEqual(javascript.newImageValue, javascript.currentImageValue); assert.strictEqual(javascript.newImageValue, `mcr.microsoft.com/devcontainers/javascript-node:${javascript.wantedVersion}-\${VARIANT}`); }); + + it('major-version-no-variant', async () => { + const workspaceFolder = path.join(__dirname, 'configs/image-with-git-feature'); + + const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format json`); + const response = JSON.parse(res.stdout); + + const base = response.images['mcr.microsoft.com/vscode/devcontainers/base:0']; + assert.ok(base); + assert.strictEqual(base.name, 'mcr.microsoft.com/vscode/devcontainers/base'); + assert.strictEqual(base.current, '0'); + assert.notStrictEqual(base.wanted, base.version); + assert.ok((parseFloat(base.wantedVersion) > parseFloat(base.version)), `semver.gt(${base.wantedVersion}, ${base.version}) is false`); + assert.strictEqual(base.currentImageValue, 'mcr.microsoft.com/vscode/devcontainers/base:0'); + assert.notStrictEqual(base.newImageValue, base.currentImageValue); + assert.strictEqual(base.newImageValue, `mcr.microsoft.com/vscode/devcontainers/base:${base.wantedVersion}`); + }); }); \ No newline at end of file diff --git a/src/test/configs/image-with-git-feature/.devcontainer.json b/src/test/configs/image-with-git-feature/.devcontainer.json index 5ce74bfc4..b03fbe6aa 100644 --- a/src/test/configs/image-with-git-feature/.devcontainer.json +++ b/src/test/configs/image-with-git-feature/.devcontainer.json @@ -1,5 +1,5 @@ { - "image": "mcr.microsoft.com/vscode/devcontainers/typescript-node:0-16-bullseye", + "image": "mcr.microsoft.com/vscode/devcontainers/base:0", "features": { "ghcr.io/devcontainers/features/git": {} }, From 2c7c14fc3b7bd056c3cdf397fc5afa1ebc9b6fa7 Mon Sep 17 00:00:00 2001 From: Samruddhi Khandale Date: Tue, 5 Mar 2024 23:08:31 +0000 Subject: [PATCH 13/20] more tests --- src/test/cli.outdated.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/test/cli.outdated.test.ts b/src/test/cli.outdated.test.ts index cf738a8ed..23ae2eed4 100644 --- a/src/test/cli.outdated.test.ts +++ b/src/test/cli.outdated.test.ts @@ -61,6 +61,7 @@ describe('Outdated', function () { const baseImage = response.images['mcr.microsoft.com/devcontainers/base:0-ubuntu-20.04']; assert.ok(baseImage); + assert.ok(baseImage.path.includes('.devcontainer.json')); assert.strictEqual(baseImage.name, 'mcr.microsoft.com/devcontainers/base'); assert.strictEqual(baseImage.current, '0-ubuntu-20.04'); assert.notStrictEqual(baseImage.wanted, baseImage.version); @@ -116,6 +117,7 @@ describe('Outdated', function () { const response = JSON.parse(res.stdout); const typeScript = response.images['mcr.microsoft.com/devcontainers/typescript-node:1.0.5-${VARIANT}']; + assert.ok(typeScript.path.includes('Dockerfile')); assert.ok(typeScript); assert.strictEqual(typeScript.name, 'mcr.microsoft.com/devcontainers/typescript-node'); assert.strictEqual(typeScript.current, '1.0.5-18-bookworm'); @@ -127,6 +129,7 @@ describe('Outdated', function () { const alpine = response.images['mcr.microsoft.com/devcontainers/base:0.207.2-alpine3.18']; assert.ok(alpine); + assert.ok(alpine.path.includes('Dockerfile')); assert.strictEqual(alpine.name, 'mcr.microsoft.com/devcontainers/base'); assert.strictEqual(alpine.current, '0.207.2-alpine3.18'); assert.notStrictEqual(alpine.wanted, alpine.version); @@ -144,6 +147,7 @@ describe('Outdated', function () { const javascript = response.images['mcr.microsoft.com/devcontainers/javascript-node:0.204-18-buster']; assert.ok(javascript); + assert.ok(javascript.path.includes('docker-compose.yml')); assert.strictEqual(javascript.name, 'mcr.microsoft.com/devcontainers/javascript-node'); assert.strictEqual(javascript.current, '0.204-18-buster'); assert.notStrictEqual(javascript.wanted, javascript.version); @@ -161,6 +165,7 @@ describe('Outdated', function () { const javascript = response.images['mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT}']; assert.ok(javascript); + assert.ok(javascript.path.includes('Dockerfile')); assert.strictEqual(javascript.name, 'mcr.microsoft.com/devcontainers/javascript-node'); assert.strictEqual(javascript.current, '0-16-bullseye'); assert.notStrictEqual(javascript.wanted, javascript.version); @@ -178,6 +183,7 @@ describe('Outdated', function () { const base = response.images['mcr.microsoft.com/vscode/devcontainers/base:0']; assert.ok(base); + assert.ok(base.path.includes('.devcontainer.json')); assert.strictEqual(base.name, 'mcr.microsoft.com/vscode/devcontainers/base'); assert.strictEqual(base.current, '0'); assert.notStrictEqual(base.wanted, base.version); From 4df54695ec209a4e3ff95578e79d75d94eb06e3c Mon Sep 17 00:00:00 2001 From: Samruddhi Khandale Date: Wed, 6 Mar 2024 18:17:36 +0000 Subject: [PATCH 14/20] fix tests --- src/test/cli.outdated.test.ts | 2 +- src/test/configs/image-with-features/.devcontainer.json | 2 +- src/test/configs/image-with-git-feature/.devcontainer.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/cli.outdated.test.ts b/src/test/cli.outdated.test.ts index 23ae2eed4..0e5119989 100644 --- a/src/test/cli.outdated.test.ts +++ b/src/test/cli.outdated.test.ts @@ -176,7 +176,7 @@ describe('Outdated', function () { }); it('major-version-no-variant', async () => { - const workspaceFolder = path.join(__dirname, 'configs/image-with-git-feature'); + const workspaceFolder = path.join(__dirname, 'configs/image-with-features'); const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format json`); const response = JSON.parse(res.stdout); diff --git a/src/test/configs/image-with-features/.devcontainer.json b/src/test/configs/image-with-features/.devcontainer.json index a8ca0df12..8cea79029 100644 --- a/src/test/configs/image-with-features/.devcontainer.json +++ b/src/test/configs/image-with-features/.devcontainer.json @@ -1,5 +1,5 @@ { - "image": "mcr.microsoft.com/devcontainers/typescript-node:1-18-bookworm", + "image": "mcr.microsoft.com/vscode/devcontainers/base:0", "features": { "ghcr.io/devcontainers/features/docker-in-docker:2": {}, "ghcr.io/devcontainers/feature-starter/hello:1": { diff --git a/src/test/configs/image-with-git-feature/.devcontainer.json b/src/test/configs/image-with-git-feature/.devcontainer.json index b03fbe6aa..5ce74bfc4 100644 --- a/src/test/configs/image-with-git-feature/.devcontainer.json +++ b/src/test/configs/image-with-git-feature/.devcontainer.json @@ -1,5 +1,5 @@ { - "image": "mcr.microsoft.com/vscode/devcontainers/base:0", + "image": "mcr.microsoft.com/vscode/devcontainers/typescript-node:0-16-bullseye", "features": { "ghcr.io/devcontainers/features/git": {} }, From 42eacc0b2c0d3725754cd1b3c72dcb4815746303 Mon Sep 17 00:00:00 2001 From: Samruddhi Khandale Date: Wed, 6 Mar 2024 19:06:46 +0000 Subject: [PATCH 15/20] fix test --- src/test/cli.exec.base.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/cli.exec.base.ts b/src/test/cli.exec.base.ts index 10e876595..1eae42324 100644 --- a/src/test/cli.exec.base.ts +++ b/src/test/cli.exec.base.ts @@ -96,7 +96,7 @@ export function describeTests1({ text, options }: BuildKitOption) { it('should have access to installed features (hello)', async () => { const res = await shellExec(`${cli} exec --workspace-folder ${testFolder} hello`); assert.strictEqual(res.error, null); - assert.match(res.stdout, /howdy, node/); + assert.match(res.stdout, /howdy, vscode/); }); }); describe(`with valid (Dockerfile) config containing features [${text}]`, () => { From c02beb2cafa3629b800778ba85e4445d2a057567 Mon Sep 17 00:00:00 2001 From: Samruddhi Khandale Date: Thu, 7 Mar 2024 19:49:19 +0000 Subject: [PATCH 16/20] add --only-features --only-images --- .github/workflows/dev-containers.yml | 2 +- .../collectionCommonUtils/outdated.ts | 2 + .../outdatedCommandImpl.ts | 180 ++++++++++-------- src/spec-node/devContainersSpecCLI.ts | 2 +- src/test/cli.outdated.test.ts | 36 +++- 5 files changed, 131 insertions(+), 91 deletions(-) diff --git a/.github/workflows/dev-containers.yml b/.github/workflows/dev-containers.yml index c87edf482..ee75bbad5 100644 --- a/.github/workflows/dev-containers.yml +++ b/.github/workflows/dev-containers.yml @@ -58,7 +58,7 @@ jobs: "src/test/cli.outdated.test.ts", "src/test/container-features/containerFeaturesOCIPush.test.ts", # Run all except the above: - "--exclude src/test/container-features/containerFeaturesOrder.test.ts --exclude src/test/container-features/registryCompatibilityOCI.test.ts --exclude src/test/container-features/containerFeaturesOCIPush.test.ts --exclude src/test/container-features/e2e.test.ts --exclude src/test/container-features/featuresCLICommands.test.ts --exclude src/test/cli.build.test.ts --exclude src/test/cli.exec.buildKit.1.test.ts --exclude src/test/cli.exec.buildKit.2.test.ts --exclude src/test/cli.exec.nonBuildKit.1.test.ts --exclude src/test/cli.exec.nonBuildKit.2.test.ts --exclude src/test/cli.test.ts --exclude src/test/cli.up.test.ts --exclude src/test/imageMetadata.test.ts 'src/test/**/*.test.ts'", + "--exclude src/test/container-features/containerFeaturesOrder.test.ts --exclude src/test/container-features/registryCompatibilityOCI.test.ts --exclude src/test/container-features/containerFeaturesOCIPush.test.ts --exclude src/test/container-features/e2e.test.ts --exclude src/test/container-features/featuresCLICommands.test.ts --exclude src/test/cli.build.test.ts --exclude src/test/cli.exec.buildKit.1.test.ts --exclude src/test/cli.exec.buildKit.2.test.ts --exclude src/test/cli.exec.nonBuildKit.1.test.ts --exclude src/test/cli.exec.nonBuildKit.2.test.ts --exclude src/test/cli.test.ts --exclude src/test/cli.up.test.ts --exclude src/test/imageMetadata.test.ts --exclude src/test/cli.outdated.test.ts 'src/test/**/*.test.ts'", ] steps: - name: Checkout diff --git a/src/spec-node/collectionCommonUtils/outdated.ts b/src/spec-node/collectionCommonUtils/outdated.ts index 188163f0b..073821a6e 100644 --- a/src/spec-node/collectionCommonUtils/outdated.ts +++ b/src/spec-node/collectionCommonUtils/outdated.ts @@ -6,6 +6,8 @@ export function outdatedOptions(y: Argv) { return y.options({ 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, 'workspace-folder': { type: 'string', required: true, description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'only-features': { type: 'boolean', default: false, description: 'Only check for outdated features. Cannot be combined with \'--only-images\'' }, + 'only-images': { type: 'boolean', default: false, description: 'Only check for outdated images. Cannot be combined with \'--only-features\'' }, 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, 'output-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text', description: 'Output format.' }, 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level for the --terminal-log-file. When set to trace, the log level for --log-file will also be set to trace.' }, diff --git a/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts b/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts index fd9ded9f5..e71e0751b 100644 --- a/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts +++ b/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts @@ -31,12 +31,18 @@ export async function outdated({ // 'user-data-folder': persistedFolder, 'workspace-folder': workspaceFolderArg, config: configParam, + 'only-features': onlyFeatures, + 'only-images': onlyImages, 'output-format': outputFormat, 'log-level': logLevel, 'log-format': logFormat, 'terminal-rows': terminalRows, 'terminal-columns': terminalColumns, -}: OutdatedArgs) { +}: OutdatedArgs) { + if (onlyImages && onlyFeatures) { + throw new ContainerError({ description: `Cannot specify both --only-features and --only-images.` }); + } + const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { await Promise.all(disposables.map(d => d())); @@ -63,92 +69,102 @@ export async function outdated({ throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); } - const cacheFolder = await getCacheFolder(cliHost); - const params = { - extensionPath, - cacheFolder, - cwd: cliHost.cwd, - output, - env: cliHost.env, - skipFeatureAutoMapping: false, - platform: cliHost.platform, - }; - - const outdatedFeatures = await loadFeatureVersionInfo(params, configs.config.config); - - const outputParams = { output, env: process.env }; - const dockerParams = await createDockerParams({ - containerDataFolder: undefined, - containerSystemDataFolder: undefined, - workspaceFolder, - mountWorkspaceGitRoot: false, - configFile, - logLevel: mapLogLevel(logLevel), - logFormat, - log: text => process.stderr.write(text), - terminalDimensions: /* terminalColumns && terminalRows ? { columns: terminalColumns, rows: terminalRows } : */ undefined, // TODO - defaultUserEnvProbe: 'loginInteractiveShell', - removeExistingContainer: false, - buildNoCache: false, - expectExistingContainer: false, - postCreateEnabled: false, - skipNonBlocking: false, - prebuild: false, - persistedFolder: undefined, - additionalMounts: [], - updateRemoteUserUIDDefault: 'never', - additionalCacheFroms: [], - useBuildKit: 'never', - buildxPlatform: undefined, - buildxPush: false, - buildxOutput: undefined, - buildxCacheTo: undefined, - skipFeatureAutoMapping: false, - skipPostAttach: false, - skipPersistingCustomizationsFromFeatures: false, - dotfiles: { - repository: undefined, - installCommand: undefined, - targetPath: undefined - }, - dockerPath: undefined, - dockerComposePath: undefined, - overrideConfigFile: undefined, - remoteEnv: {} - }, disposables); - - const outdatedImages = await loadImageVersionInfo(outputParams, configs.config.config, cliHost, dockerParams); + let outdatedFeatures: { features: { [key: string]: any } }; + let outdatedImages: { images: { [key: string]: any } }; + + if (onlyFeatures || !(onlyImages || onlyFeatures)) { + const cacheFolder = await getCacheFolder(cliHost); + const params = { + extensionPath, + cacheFolder, + cwd: cliHost.cwd, + output, + env: cliHost.env, + skipFeatureAutoMapping: false, + platform: cliHost.platform, + }; + outdatedFeatures = await loadFeatureVersionInfo(params, configs.config.config); + } + + if (onlyImages || !(onlyImages || onlyFeatures)) { + const outputParams = { output, env: process.env }; + const dockerParams = await createDockerParams({ + containerDataFolder: undefined, + containerSystemDataFolder: undefined, + workspaceFolder, + mountWorkspaceGitRoot: false, + configFile, + logLevel: mapLogLevel(logLevel), + logFormat, + log: text => process.stderr.write(text), + terminalDimensions: /* terminalColumns && terminalRows ? { columns: terminalColumns, rows: terminalRows } : */ undefined, // TODO + defaultUserEnvProbe: 'loginInteractiveShell', + removeExistingContainer: false, + buildNoCache: false, + expectExistingContainer: false, + postCreateEnabled: false, + skipNonBlocking: false, + prebuild: false, + persistedFolder: undefined, + additionalMounts: [], + updateRemoteUserUIDDefault: 'never', + additionalCacheFroms: [], + useBuildKit: 'never', + buildxPlatform: undefined, + buildxPush: false, + buildxOutput: undefined, + buildxCacheTo: undefined, + skipFeatureAutoMapping: false, + skipPostAttach: false, + skipPersistingCustomizationsFromFeatures: false, + dotfiles: { + repository: undefined, + installCommand: undefined, + targetPath: undefined + }, + dockerPath: undefined, + dockerComposePath: undefined, + overrideConfigFile: undefined, + remoteEnv: {} + }, disposables); + + outdatedImages = await loadImageVersionInfo(outputParams, configs.config.config, cliHost, dockerParams); + } await new Promise((resolve, reject) => { let text = ''; if (outputFormat === 'text') { - const featureRows = Object.keys(outdatedFeatures.features).map(key => { - const value = outdatedFeatures.features[key]; - return [getFeatureIdWithoutVersion(key), value.current, value.wanted, value.latest] - .map(v => v === undefined ? '-' : v); - }); - - if (featureRows.length !== 0) { - const featureHeader = ['Feature', 'Current', 'Wanted', 'Latest']; - text = textTable([ - featureHeader, - ...featureRows, - ]); + if (onlyFeatures || !(onlyImages || onlyFeatures)) { + const featureRows = Object.keys(outdatedFeatures.features).map(key => { + const value = outdatedFeatures.features[key]; + return [getFeatureIdWithoutVersion(key), value.current, value.wanted, value.latest] + .map(v => v === undefined ? '-' : v); + }); + + if (featureRows.length !== 0) { + const featureHeader = ['Feature', 'Current', 'Wanted', 'Latest']; + text = textTable([ + featureHeader, + ...featureRows, + ]); + } } - const imageRows = Object.keys(outdatedImages.images).map(key => { - const value = outdatedImages.images[key]; - return [value.name, value.current, value.wanted] - .map(v => v === undefined ? '-' : v); - }); - - if (imageRows.length !== 0) { - const imageHeader = ['Image', 'Current', 'Latest']; - text += '\n\n'; - text += textTable([ - imageHeader, - ...imageRows, - ]); + if (onlyImages || !(onlyImages || onlyFeatures)) { + const imageRows = Object.keys(outdatedImages.images).map(key => { + const value = outdatedImages.images[key]; + return [value.name, value.current, value.wanted] + .map(v => v === undefined ? '-' : v); + }); + + if (imageRows.length !== 0) { + const imageHeader = ['Image', 'Current', 'Latest']; + text += '\n\n'; + text += textTable([ + imageHeader, + ...imageRows, + ]); + } } } else { text = JSON.stringify({ ...outdatedFeatures, ...outdatedImages }, undefined, process.stdout.isTTY ? ' ' : undefined); @@ -343,7 +359,7 @@ async function loadImageVersionInfo(params: CommonParams, config: DevContainerCo continue; } - let imageInfo = await findImageVersionInfo(params, image, dockerfilePath, currentImage); + let imageInfo = await findImageVersionInfo(params, image, resolvedDockerfilePath, currentImage); if (imageInfo !== undefined) { resolvedImageInfo[currentImage] = imageInfo; diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 8f5eb3dec..87f1561f1 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -67,7 +67,7 @@ const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,externa y.command('build [path]', 'Build a dev container image', buildOptions, buildHandler); y.command('run-user-commands', 'Run user commands', runUserCommandsOptions, runUserCommandsHandler); y.command('read-configuration', 'Read configuration', readConfigurationOptions, readConfigurationHandler); - y.command('outdated', 'Show current and available versions', outdatedOptions, outdatedHandler); + y.command('outdated', 'Show current and available versions for Features and images', outdatedOptions, outdatedHandler); y.command('upgrade', 'Upgrade lockfile', featuresUpgradeOptions, featuresUpgradeHandler); y.command('features', 'Features commands', (y: Argv) => { y.command('test [target]', 'Test Features', featuresTestOptions, featuresTestHandler); diff --git a/src/test/cli.outdated.test.ts b/src/test/cli.outdated.test.ts index 0e5119989..906bbe124 100644 --- a/src/test/cli.outdated.test.ts +++ b/src/test/cli.outdated.test.ts @@ -22,12 +22,13 @@ describe('Outdated', function () { await shellExec(`npm --prefix ${tmp} install devcontainers-cli-${pkg.version}.tgz`); }); - it('json output', async () => { + it('json output: only-features', async () => { const workspaceFolder = path.join(__dirname, 'configs/lockfile-outdated-command'); - const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format json`); + const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --only-features --output-format json`); const response = JSON.parse(res.stdout); + assert.equal(response['images'], undefined); const git = response.features['ghcr.io/devcontainers/features/git:1.0']; assert.ok(git); assert.strictEqual(git.current, '1.0.4'); @@ -58,7 +59,15 @@ describe('Outdated', function () { assert.strictEqual(foo.wantedMajor, '0'); assert.strictEqual(foo.latest, '2.11.1'); assert.strictEqual(foo.latestMajor, '2'); + }); + + it('json output: only-images', async () => { + const workspaceFolder = path.join(__dirname, 'configs/lockfile-outdated-command'); + const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --only-images --output-format json`); + const response = JSON.parse(res.stdout); + + assert.equal(response['features'], undefined); const baseImage = response.images['mcr.microsoft.com/devcontainers/base:0-ubuntu-20.04']; assert.ok(baseImage); assert.ok(baseImage.path.includes('.devcontainer.json')); @@ -102,10 +111,19 @@ describe('Outdated', function () { assert.ok(response.includes('0-ubuntu-20.04'), 'Image version is missing'); }); + it('both --only-images --only-features', async () => { + try { + const workspaceFolder = path.join(__dirname, 'configs/dockerfile-with-features'); + await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --only-features --only-images --output-format json`); + } catch (error) { + assert.ok(error.stdout.includes('Only one of --only-features or --only-images can be specified')); + } + }); + it('dockerfile', async () => { const workspaceFolder = path.join(__dirname, 'configs/dockerfile-with-features'); - const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format json`); + const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --only-images --output-format json`); const response = JSON.parse(res.stdout); assert.equal(Object.keys(response.images).length, 0); }); @@ -113,9 +131,10 @@ describe('Outdated', function () { it('dockerfile-with-variant-multi-stage', async () => { const workspaceFolder = path.join(__dirname, 'configs/dockerfile-with-target'); - const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format json`); + const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --only-images --output-format json`); const response = JSON.parse(res.stdout); + assert.equal(response['features'], undefined); const typeScript = response.images['mcr.microsoft.com/devcontainers/typescript-node:1.0.5-${VARIANT}']; assert.ok(typeScript.path.includes('Dockerfile')); assert.ok(typeScript); @@ -142,9 +161,10 @@ describe('Outdated', function () { it('dockercompose-image', async () => { const workspaceFolder = path.join(__dirname, 'configs/compose-image-with-features'); - const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format json`); + const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --only-images --output-format json`); const response = JSON.parse(res.stdout); + assert.equal(response['features'], undefined); const javascript = response.images['mcr.microsoft.com/devcontainers/javascript-node:0.204-18-buster']; assert.ok(javascript); assert.ok(javascript.path.includes('docker-compose.yml')); @@ -160,9 +180,10 @@ describe('Outdated', function () { it('dockercompose-dockerfile', async () => { const workspaceFolder = path.join(__dirname, 'configs/compose-Dockerfile-with-features'); - const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format json`); + const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --only-images --output-format json`); const response = JSON.parse(res.stdout); + assert.equal(response['features'], undefined); const javascript = response.images['mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT}']; assert.ok(javascript); assert.ok(javascript.path.includes('Dockerfile')); @@ -178,9 +199,10 @@ describe('Outdated', function () { it('major-version-no-variant', async () => { const workspaceFolder = path.join(__dirname, 'configs/image-with-features'); - const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --output-format json`); + const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --only-images --output-format json`); const response = JSON.parse(res.stdout); + assert.equal(response['features'], undefined); const base = response.images['mcr.microsoft.com/vscode/devcontainers/base:0']; assert.ok(base); assert.ok(base.path.includes('.devcontainer.json')); From 28e91b29c9b82d769e1eebc15f08931f289ec453 Mon Sep 17 00:00:00 2001 From: Samruddhi Khandale Date: Thu, 7 Mar 2024 20:05:04 +0000 Subject: [PATCH 17/20] use interfaces --- .../outdatedCommandImpl.ts | 36 +++++++++++++++++-- src/test/cli.outdated.test.ts | 2 +- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts b/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts index e71e0751b..c84e57f49 100644 --- a/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts +++ b/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts @@ -27,6 +27,36 @@ import { extractDockerfile, findImage } from '../dockerfileUtils'; import { ContainerFeatureInternalParams, userFeaturesToArray, getFeatureIdType, DEVCONTAINER_FEATURE_FILE_NAME, Feature } from '../../spec-configuration/containerFeaturesConfiguration'; import { readLockfile } from '../../spec-configuration/lockfile'; +export interface OutdatedFeatures { + 'features': { + [key: string]: { + 'current': string; + 'wanted': string; + 'latest': string; + }; + }; +} + +export interface OutdatedImages { + 'images': { + [key: string]: { + 'name': string; + 'version': string; + 'wantedVersion': string; + 'current': string; + 'wanted': string; + 'currentImageValue': string; + 'newImageValue': string; + 'path': string; + }; + }; +} + +export interface OutdatedResult { + 'features': OutdatedFeatures; + 'images': OutdatedImages; +} + export async function outdated({ // 'user-data-folder': persistedFolder, 'workspace-folder': workspaceFolderArg, @@ -40,7 +70,7 @@ export async function outdated({ 'terminal-columns': terminalColumns, }: OutdatedArgs) { if (onlyImages && onlyFeatures) { - throw new ContainerError({ description: `Cannot specify both --only-features and --only-images.` }); + throw new ContainerError({ description: `Cannot specify both --only-features and --only-images. new` }); } const disposables: (() => Promise | undefined)[] = []; @@ -69,8 +99,8 @@ export async function outdated({ throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); } - let outdatedFeatures: { features: { [key: string]: any } }; - let outdatedImages: { images: { [key: string]: any } }; + let outdatedFeatures: OutdatedFeatures; + let outdatedImages: OutdatedImages; if (onlyFeatures || !(onlyImages || onlyFeatures)) { const cacheFolder = await getCacheFolder(cliHost); diff --git a/src/test/cli.outdated.test.ts b/src/test/cli.outdated.test.ts index 906bbe124..9cf07ccd6 100644 --- a/src/test/cli.outdated.test.ts +++ b/src/test/cli.outdated.test.ts @@ -161,7 +161,7 @@ describe('Outdated', function () { it('dockercompose-image', async () => { const workspaceFolder = path.join(__dirname, 'configs/compose-image-with-features'); - const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --only-images --output-format json`); + const res = await shellExec(`${cli} outdated --workspace-folder ${workspaceFolder} --only-images --output-format json`); const response = JSON.parse(res.stdout); assert.equal(response['features'], undefined); From e7124fb9b19dde0bc3cdd58f3832ed4e6c8ed510 Mon Sep 17 00:00:00 2001 From: Samruddhi Khandale Date: Thu, 7 Mar 2024 21:34:31 +0000 Subject: [PATCH 18/20] use findBaseImages() --- .../outdatedCommandImpl.ts | 42 ++++++-------- src/spec-node/dockerfileUtils.ts | 55 ++++--------------- 2 files changed, 26 insertions(+), 71 deletions(-) diff --git a/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts b/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts index c84e57f49..e9544fa8a 100644 --- a/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts +++ b/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts @@ -23,7 +23,7 @@ import { CommonParams, ManifestContainer, getRef, getVersionsStrictSorted } from import { DockerCLIParameters } from '../../spec-shutdown/dockerUtils'; import { request } from '../../spec-utils/httpRequest'; import { readDockerComposeConfig, getBuildInfoForService } from '../dockerCompose'; -import { extractDockerfile, findImage } from '../dockerfileUtils'; +import { extractDockerfile, findBaseImages } from '../dockerfileUtils'; import { ContainerFeatureInternalParams, userFeaturesToArray, getFeatureIdType, DEVCONTAINER_FEATURE_FILE_NAME, Feature } from '../../spec-configuration/containerFeaturesConfiguration'; import { readLockfile } from '../../spec-configuration/lockfile'; @@ -70,7 +70,7 @@ export async function outdated({ 'terminal-columns': terminalColumns, }: OutdatedArgs) { if (onlyImages && onlyFeatures) { - throw new ContainerError({ description: `Cannot specify both --only-features and --only-images. new` }); + throw new ContainerError({ description: `Cannot specify both --only-features and --only-images.` }); } const disposables: (() => Promise | undefined)[] = []; @@ -324,19 +324,14 @@ async function loadImageVersionInfo(params: CommonParams, config: DevContainerCo const dockerfile = extractDockerfile(dockerfileText); const resolvedImageInfo: Record = {}; - for (let i = 0; i < dockerfile.stages.length; i++) { - const stage = dockerfile.stages[i]; - const currentImage = stage.from.image; - const previousStage = (i !== 0) ? dockerfile.stages[i - 1] : undefined; - const image = findImage(currentImage, dockerfile, config.build.args || {}, previousStage); - if (image === undefined) { - continue; - } - - let imageInfo = await findImageVersionInfo(params, image, dockerfilePath, currentImage); + const images = findBaseImages(dockerfile, config.build.args || {}); + for (const currentImage in images) { + if (images.hasOwnProperty(currentImage)) { + let imageInfo = await findImageVersionInfo(params, images[currentImage], dockerfilePath, currentImage); - if (imageInfo !== undefined) { - resolvedImageInfo[currentImage] = imageInfo; + if (imageInfo !== undefined) { + resolvedImageInfo[currentImage] = imageInfo; + } } } @@ -380,19 +375,14 @@ async function loadImageVersionInfo(params: CommonParams, config: DevContainerCo const dockerfileText = (await cliHost.readFile(resolvedDockerfilePath)).toString(); const dockerfile = extractDockerfile(dockerfileText); - for (let i = 0; i < dockerfile.stages.length; i++) { - const stage = dockerfile.stages[i]; - const currentImage = stage.from.image; - const previousStage = (i !== 0) ? dockerfile.stages[i - 1] : undefined; - const image = findImage(currentImage, dockerfile, composeService.build?.args, previousStage); - if (image === undefined) { - continue; - } - - let imageInfo = await findImageVersionInfo(params, image, resolvedDockerfilePath, currentImage); + const images = findBaseImages(dockerfile, composeService.build?.args || {}); + for (const currentImage in images) { + if (images.hasOwnProperty(currentImage)) { + let imageInfo = await findImageVersionInfo(params, images[currentImage], resolvedDockerfilePath, currentImage); - if (imageInfo !== undefined) { - resolvedImageInfo[currentImage] = imageInfo; + if (imageInfo !== undefined) { + resolvedImageInfo[currentImage] = imageInfo; + } } } } diff --git a/src/spec-node/dockerfileUtils.ts b/src/spec-node/dockerfileUtils.ts index 144d89eaa..c7f73e9b0 100644 --- a/src/spec-node/dockerfileUtils.ts +++ b/src/spec-node/dockerfileUtils.ts @@ -119,53 +119,18 @@ export function findBaseImage(dockerfile: Dockerfile, buildArgs: Record, previousStage: Stage | undefined) { - const resolvedImage = replaceVariablesInImage(dockerfile, buildArgs, image, previousStage); - return resolvedImage; -} - -function replaceVariablesInImage(dockerfile: Dockerfile, buildArgs: Record, str: string, previousStage: Stage | undefined) { - return [...str.matchAll(argumentExpression)] - .map(match => { - const variable = match.groups!.variable; - const isVarExp = match.groups!.isVarExp ? true : false; - let value = findValueInImage(dockerfile, buildArgs, variable, previousStage) || ''; - if (isVarExp) { - // Handle replacing variable expressions (${var:+word}) if they exist - const option = match.groups!.option; - const word = match.groups!.word; - const isSet = value !== ''; - value = getExpressionValue(option, isSet, word, value); - } - - return { - begin: match.index!, - end: match.index! + match[0].length, - value, - }; - }).reverse() - .reduce((str, { begin, end, value }) => str.substring(0, begin) + value + str.substring(end), str); -} - -function findValueInImage(dockerfile: Dockerfile, buildArgs: Record, variable: string, previousStage: Stage | undefined) { - if (buildArgs !== undefined && variable in buildArgs) { - return buildArgs[variable]; - } - - if (previousStage !== undefined) { - const i = findLastIndex(previousStage.instructions, i => i.name === variable && (i.instruction === 'ENV' || i.instruction === 'ARG')); - if (i !== -1) { - return previousStage.instructions[i].value; +export function findBaseImages(dockerfile: Dockerfile, buildArgs: Record) { + const resolvedBaseImages = {} as Record; + for (let i = 0; i < dockerfile.stages.length; i++) { + const stage = dockerfile.stages[i]; + const image = replaceVariables(dockerfile, buildArgs, /* not available in FROM instruction */ {}, stage.from.image, dockerfile.preamble, dockerfile.preamble.instructions.length); + const nextStage = dockerfile.stagesByLabel[image]; + if (!nextStage) { + resolvedBaseImages[stage.from.image] = image; } } - - const index = findLastIndex(dockerfile.preamble.instructions, i => i.name === variable && (i.instruction === 'ENV' || i.instruction === 'ARG')); - if (index !== -1) { - return dockerfile.preamble.instructions[index].value; - } - - return undefined; -} + return resolvedBaseImages; +} function extractDirectives(preambleStr: string) { const map: Record = {}; From 216f7e589fca59d8a5b0e7db0a16a80b691f908e Mon Sep 17 00:00:00 2001 From: Samruddhi Khandale Date: Fri, 8 Mar 2024 18:56:17 +0000 Subject: [PATCH 19/20] address comments --- .../outdatedCommandImpl.ts | 111 +++++++----------- src/test/cli.outdated.test.ts | 36 +++--- 2 files changed, 60 insertions(+), 87 deletions(-) diff --git a/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts b/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts index e9544fa8a..926e60ea0 100644 --- a/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts +++ b/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts @@ -16,13 +16,13 @@ import { fetchOCIFeature, getFeatureIdWithoutVersion, tryGetOCIFeatureSet } from import { getPackageConfig } from '../../spec-utils/product'; import { workspaceFromPath } from '../../spec-utils/workspaces'; import { readDevContainerConfigFile } from '../configContainer'; -import { createLog, createDockerParams } from '../devContainers'; -import { uriToFsPath, getCacheFolder, DockerResolverParameters, getDockerfilePath } from '../utils'; +import { createLog } from '../devContainers'; +import { uriToFsPath, getCacheFolder, getDockerfilePath } from '../utils'; import { DevContainerConfig, DevContainerFromDockerfileConfig, getDockerComposeFilePaths } from '../../spec-configuration/configuration'; -import { CommonParams, ManifestContainer, getRef, getVersionsStrictSorted } from '../../spec-configuration/containerCollectionsOCI'; +import { CommonParams, ManifestContainer, getRef, getVersionsStrictSorted, mapNodeArchitectureToGOARCH, mapNodeOSToGOOS } from '../../spec-configuration/containerCollectionsOCI'; import { DockerCLIParameters } from '../../spec-shutdown/dockerUtils'; import { request } from '../../spec-utils/httpRequest'; -import { readDockerComposeConfig, getBuildInfoForService } from '../dockerCompose'; +import { readDockerComposeConfig, getBuildInfoForService, dockerComposeCLIConfig } from '../dockerCompose'; import { extractDockerfile, findBaseImages } from '../dockerfileUtils'; import { ContainerFeatureInternalParams, userFeaturesToArray, getFeatureIdType, DEVCONTAINER_FEATURE_FILE_NAME, Feature } from '../../spec-configuration/containerFeaturesConfiguration'; import { readLockfile } from '../../spec-configuration/lockfile'; @@ -33,6 +33,8 @@ export interface OutdatedFeatures { 'current': string; 'wanted': string; 'latest': string; + 'wantedMajor': string; + 'latestMajor': string; }; }; } @@ -42,9 +44,9 @@ export interface OutdatedImages { [key: string]: { 'name': string; 'version': string; - 'wantedVersion': string; + 'latestVersion': string; 'current': string; - 'wanted': string; + 'latest': string; 'currentImageValue': string; 'newImageValue': string; 'path': string; @@ -118,45 +120,23 @@ export async function outdated({ if (onlyImages || !(onlyImages || onlyFeatures)) { const outputParams = { output, env: process.env }; - const dockerParams = await createDockerParams({ - containerDataFolder: undefined, - containerSystemDataFolder: undefined, - workspaceFolder, - mountWorkspaceGitRoot: false, - configFile, - logLevel: mapLogLevel(logLevel), - logFormat, - log: text => process.stderr.write(text), - terminalDimensions: /* terminalColumns && terminalRows ? { columns: terminalColumns, rows: terminalRows } : */ undefined, // TODO - defaultUserEnvProbe: 'loginInteractiveShell', - removeExistingContainer: false, - buildNoCache: false, - expectExistingContainer: false, - postCreateEnabled: false, - skipNonBlocking: false, - prebuild: false, - persistedFolder: undefined, - additionalMounts: [], - updateRemoteUserUIDDefault: 'never', - additionalCacheFroms: [], - useBuildKit: 'never', - buildxPlatform: undefined, - buildxPush: false, - buildxOutput: undefined, - buildxCacheTo: undefined, - skipFeatureAutoMapping: false, - skipPostAttach: false, - skipPersistingCustomizationsFromFeatures: false, - dotfiles: { - repository: undefined, - installCommand: undefined, - targetPath: undefined - }, - dockerPath: undefined, - dockerComposePath: undefined, - overrideConfigFile: undefined, - remoteEnv: {} - }, disposables); + const dockerCLI = 'docker'; + const dockerComposeCLI = dockerComposeCLIConfig({ + exec: cliHost.exec, + env: cliHost.env, + output, + }, dockerCLI, 'docker-compose'); + const dockerParams: DockerCLIParameters = { + cliHost, + dockerCLI, + dockerComposeCLI, + env: cliHost.env, + output, + platformInfo: { + os: mapNodeOSToGOOS(cliHost.platform), + arch: mapNodeArchitectureToGOARCH(cliHost.arch), + } + }; outdatedImages = await loadImageVersionInfo(outputParams, configs.config.config, cliHost, dockerParams); } @@ -183,7 +163,7 @@ export async function outdated({ if (onlyImages || !(onlyImages || onlyFeatures)) { const imageRows = Object.keys(outdatedImages.images).map(key => { const value = outdatedImages.images[key]; - return [value.name, value.current, value.wanted] + return [value.name, value.current, value.latest] .map(v => v === undefined ? '-' : v); }); @@ -251,23 +231,20 @@ async function findImageVersionInfo(params: CommonParams, image: string, path: s const options = { type: 'GET', url, headers: {} }; const data = JSON.parse((await request(options, output)).toString()); - const latestVersion: string = data.tags + const latestSemVersion: string = data.tags .filter((v: string) => v.endsWith(tagSuffix) && semver.valid(v.split('-')[0])) .map((v: string) => v.split('-')[0]) .sort(semver.compare) .pop(); - if (latestVersion) { - if (semver.valid(version) && semver.valid(latestVersion) && semver.gt(version, latestVersion)) { - output.write(`Image '${imageName}' is at a higher version than the latest version '${latestVersion}'`, LogLevel.Trace); - return undefined; - } else if (parseFloat(version) > parseFloat(latestVersion)) { - output.write(`Image '${imageName}' is at a higher version than the latest version '${latestVersion}'`, LogLevel.Trace); + if (latestSemVersion) { + if ((semver.valid(version) && semver.valid(latestSemVersion) && semver.gt(version, latestSemVersion)) || (parseFloat(version) > parseFloat(latestSemVersion))) { + output.write(`Image '${imageName}' is at a higher version than the latest version '${latestSemVersion}'`, LogLevel.Trace); return undefined; } - const wantedVersion = latestVersion.split('.').slice(0, version.split('.').length).join('.'); - const wantedTag = tagSuffix ? `${wantedVersion}-${tagSuffix}` : wantedVersion; + const latestVersion = latestSemVersion.split('.').slice(0, version.split('.').length).join('.'); + const wantedTag = tagSuffix ? `${latestVersion}-${tagSuffix}` : latestVersion; if (wantedTag === tag) { output.write(`Image '${imageName}' is already at the latest version '${tag}'`, LogLevel.Trace); @@ -280,15 +257,15 @@ async function findImageVersionInfo(params: CommonParams, image: string, path: s const currentImageTag = currentImageValue.split(':')[1]; if (currentImageTag !== tag) { const currentTagSuffix = currentImageTag.split('-').slice(1).join('-'); - newImageValue = `${imageName}:${wantedVersion}-${currentTagSuffix}`; + newImageValue = `${imageName}:${latestVersion}-${currentTagSuffix}`; } return { name: imageName, version, - wantedVersion, + latestVersion, current: tag, - wanted: wantedTag, + latest: wantedTag, currentImageValue, newImageValue, path, @@ -303,7 +280,7 @@ async function findImageVersionInfo(params: CommonParams, image: string, path: s return undefined; } -async function loadImageVersionInfo(params: CommonParams, config: DevContainerConfig, cliHost: CLIHost, dockerParams: DockerResolverParameters) { +async function loadImageVersionInfo(params: CommonParams, config: DevContainerConfig, cliHost: CLIHost, dockerParams: DockerCLIParameters) { if ('image' in config && config.image !== undefined) { const imageInfo = await findImageVersionInfo(params, config.image, config.configFilePath?.path || '', config.image); if (imageInfo !== undefined && imageInfo.name !== undefined) { @@ -326,12 +303,10 @@ async function loadImageVersionInfo(params: CommonParams, config: DevContainerCo const resolvedImageInfo: Record = {}; const images = findBaseImages(dockerfile, config.build.args || {}); for (const currentImage in images) { - if (images.hasOwnProperty(currentImage)) { - let imageInfo = await findImageVersionInfo(params, images[currentImage], dockerfilePath, currentImage); + let imageInfo = await findImageVersionInfo(params, images[currentImage], dockerfilePath, currentImage); - if (imageInfo !== undefined) { - resolvedImageInfo[currentImage] = imageInfo; - } + if (imageInfo !== undefined) { + resolvedImageInfo[currentImage] = imageInfo; } } @@ -377,12 +352,10 @@ async function loadImageVersionInfo(params: CommonParams, config: DevContainerCo const images = findBaseImages(dockerfile, composeService.build?.args || {}); for (const currentImage in images) { - if (images.hasOwnProperty(currentImage)) { - let imageInfo = await findImageVersionInfo(params, images[currentImage], resolvedDockerfilePath, currentImage); + let imageInfo = await findImageVersionInfo(params, images[currentImage], resolvedDockerfilePath, currentImage); - if (imageInfo !== undefined) { - resolvedImageInfo[currentImage] = imageInfo; - } + if (imageInfo !== undefined) { + resolvedImageInfo[currentImage] = imageInfo; } } } diff --git a/src/test/cli.outdated.test.ts b/src/test/cli.outdated.test.ts index 9cf07ccd6..497cd9691 100644 --- a/src/test/cli.outdated.test.ts +++ b/src/test/cli.outdated.test.ts @@ -73,11 +73,11 @@ describe('Outdated', function () { assert.ok(baseImage.path.includes('.devcontainer.json')); assert.strictEqual(baseImage.name, 'mcr.microsoft.com/devcontainers/base'); assert.strictEqual(baseImage.current, '0-ubuntu-20.04'); - assert.notStrictEqual(baseImage.wanted, baseImage.version); - assert.ok((parseInt(baseImage.wantedVersion) > parseInt(baseImage.version)), `semver.gt(${baseImage.wantedVersion}, ${baseImage.version}) is false`); + assert.notStrictEqual(baseImage.latest, baseImage.version); + assert.ok((parseInt(baseImage.latestVersion) > parseInt(baseImage.version)), `semver.gt(${baseImage.latestVersion}, ${baseImage.version}) is false`); assert.strictEqual(baseImage.currentImageValue, 'mcr.microsoft.com/devcontainers/base:0-ubuntu-20.04'); assert.notStrictEqual(baseImage.newImageValue, baseImage.currentImageValue); - assert.strictEqual(baseImage.newImageValue, `mcr.microsoft.com/devcontainers/base:${baseImage.wantedVersion}-ubuntu-20.04`); + assert.strictEqual(baseImage.newImageValue, `mcr.microsoft.com/devcontainers/base:${baseImage.latestVersion}-ubuntu-20.04`); }); it('text output', async () => { @@ -140,22 +140,22 @@ describe('Outdated', function () { assert.ok(typeScript); assert.strictEqual(typeScript.name, 'mcr.microsoft.com/devcontainers/typescript-node'); assert.strictEqual(typeScript.current, '1.0.5-18-bookworm'); - assert.notStrictEqual(typeScript.wanted, typeScript.version); - assert.ok(semver.gt(typeScript.wantedVersion, typeScript.version), `semver.gt(${typeScript.wantedVersion}, ${typeScript.version}) is false`); + assert.notStrictEqual(typeScript.latest, typeScript.version); + assert.ok(semver.gt(typeScript.latestVersion, typeScript.version), `semver.gt(${typeScript.latestVersion}, ${typeScript.version}) is false`); assert.strictEqual(typeScript.currentImageValue, 'mcr.microsoft.com/devcontainers/typescript-node:1.0.5-${VARIANT}'); assert.notStrictEqual(typeScript.newImageValue, typeScript.currentImageValue); - assert.strictEqual(typeScript.newImageValue, `mcr.microsoft.com/devcontainers/typescript-node:${typeScript.wantedVersion}-\${VARIANT}`); + assert.strictEqual(typeScript.newImageValue, `mcr.microsoft.com/devcontainers/typescript-node:${typeScript.latestVersion}-\${VARIANT}`); const alpine = response.images['mcr.microsoft.com/devcontainers/base:0.207.2-alpine3.18']; assert.ok(alpine); assert.ok(alpine.path.includes('Dockerfile')); assert.strictEqual(alpine.name, 'mcr.microsoft.com/devcontainers/base'); assert.strictEqual(alpine.current, '0.207.2-alpine3.18'); - assert.notStrictEqual(alpine.wanted, alpine.version); - assert.ok(semver.gt(alpine.wantedVersion, alpine.version), `semver.gt(${alpine.wantedVersion}, ${alpine.version}) is false`); + assert.notStrictEqual(alpine.latest, alpine.version); + assert.ok(semver.gt(alpine.latestVersion, alpine.version), `semver.gt(${alpine.latestVersion}, ${alpine.version}) is false`); assert.strictEqual(alpine.currentImageValue, 'mcr.microsoft.com/devcontainers/base:0.207.2-alpine3.18'); assert.notStrictEqual(alpine.newImageValue, alpine.currentImageValue); - assert.strictEqual(alpine.newImageValue, `mcr.microsoft.com/devcontainers/base:${alpine.wantedVersion}-alpine3.18`); + assert.strictEqual(alpine.newImageValue, `mcr.microsoft.com/devcontainers/base:${alpine.latestVersion}-alpine3.18`); }); it('dockercompose-image', async () => { @@ -170,11 +170,11 @@ describe('Outdated', function () { assert.ok(javascript.path.includes('docker-compose.yml')); assert.strictEqual(javascript.name, 'mcr.microsoft.com/devcontainers/javascript-node'); assert.strictEqual(javascript.current, '0.204-18-buster'); - assert.notStrictEqual(javascript.wanted, javascript.version); - assert.ok((parseFloat(javascript.wantedVersion) > parseFloat(javascript.version)), `semver.gt(${javascript.wantedVersion}, ${javascript.version}) is false`); + assert.notStrictEqual(javascript.latest, javascript.version); + assert.ok((parseFloat(javascript.latestVersion) > parseFloat(javascript.version)), `semver.gt(${javascript.latestVersion}, ${javascript.version}) is false`); assert.strictEqual(javascript.currentImageValue, 'mcr.microsoft.com/devcontainers/javascript-node:0.204-18-buster'); assert.notStrictEqual(javascript.newImageValue, javascript.currentImageValue); - assert.strictEqual(javascript.newImageValue, `mcr.microsoft.com/devcontainers/javascript-node:${javascript.wantedVersion}-18-buster`); + assert.strictEqual(javascript.newImageValue, `mcr.microsoft.com/devcontainers/javascript-node:${javascript.latestVersion}-18-buster`); }); it('dockercompose-dockerfile', async () => { @@ -189,11 +189,11 @@ describe('Outdated', function () { assert.ok(javascript.path.includes('Dockerfile')); assert.strictEqual(javascript.name, 'mcr.microsoft.com/devcontainers/javascript-node'); assert.strictEqual(javascript.current, '0-16-bullseye'); - assert.notStrictEqual(javascript.wanted, javascript.version); - assert.ok((parseFloat(javascript.wantedVersion) > parseFloat(javascript.version)), `semver.gt(${javascript.wantedVersion}, ${javascript.version}) is false`); + assert.notStrictEqual(javascript.latest, javascript.version); + assert.ok((parseFloat(javascript.latestVersion) > parseFloat(javascript.version)), `semver.gt(${javascript.latestVersion}, ${javascript.version}) is false`); assert.strictEqual(javascript.currentImageValue, 'mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT}'); assert.notStrictEqual(javascript.newImageValue, javascript.currentImageValue); - assert.strictEqual(javascript.newImageValue, `mcr.microsoft.com/devcontainers/javascript-node:${javascript.wantedVersion}-\${VARIANT}`); + assert.strictEqual(javascript.newImageValue, `mcr.microsoft.com/devcontainers/javascript-node:${javascript.latestVersion}-\${VARIANT}`); }); it('major-version-no-variant', async () => { @@ -208,10 +208,10 @@ describe('Outdated', function () { assert.ok(base.path.includes('.devcontainer.json')); assert.strictEqual(base.name, 'mcr.microsoft.com/vscode/devcontainers/base'); assert.strictEqual(base.current, '0'); - assert.notStrictEqual(base.wanted, base.version); - assert.ok((parseFloat(base.wantedVersion) > parseFloat(base.version)), `semver.gt(${base.wantedVersion}, ${base.version}) is false`); + assert.notStrictEqual(base.latest, base.version); + assert.ok((parseFloat(base.latestVersion) > parseFloat(base.version)), `semver.gt(${base.latestVersion}, ${base.version}) is false`); assert.strictEqual(base.currentImageValue, 'mcr.microsoft.com/vscode/devcontainers/base:0'); assert.notStrictEqual(base.newImageValue, base.currentImageValue); - assert.strictEqual(base.newImageValue, `mcr.microsoft.com/vscode/devcontainers/base:${base.wantedVersion}`); + assert.strictEqual(base.newImageValue, `mcr.microsoft.com/vscode/devcontainers/base:${base.latestVersion}`); }); }); \ No newline at end of file From bada9f960497845ed4e01d3902bc76ebade01fa0 Mon Sep 17 00:00:00 2001 From: Samruddhi Khandale Date: Sat, 9 Mar 2024 01:18:21 +0000 Subject: [PATCH 20/20] update latest --- .../collectionCommonUtils/outdatedCommandImpl.ts | 8 +++++--- src/test/cli.outdated.test.ts | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts b/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts index 926e60ea0..812b3a0b6 100644 --- a/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts +++ b/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts @@ -255,17 +255,19 @@ async function findImageVersionInfo(params: CommonParams, image: string, path: s // Useful when image tag is set with build args (eg. VARIANT) const currentImageTag = currentImageValue.split(':')[1]; + let latestTag = wantedTag; if (currentImageTag !== tag) { const currentTagSuffix = currentImageTag.split('-').slice(1).join('-'); - newImageValue = `${imageName}:${latestVersion}-${currentTagSuffix}`; + latestTag = `${latestVersion}-${currentTagSuffix}`; + newImageValue = `${imageName}:${latestTag}`; } return { name: imageName, version, latestVersion, - current: tag, - latest: wantedTag, + current: currentImageTag, + latest: latestTag, currentImageValue, newImageValue, path, diff --git a/src/test/cli.outdated.test.ts b/src/test/cli.outdated.test.ts index 497cd9691..14d61b7ac 100644 --- a/src/test/cli.outdated.test.ts +++ b/src/test/cli.outdated.test.ts @@ -139,7 +139,7 @@ describe('Outdated', function () { assert.ok(typeScript.path.includes('Dockerfile')); assert.ok(typeScript); assert.strictEqual(typeScript.name, 'mcr.microsoft.com/devcontainers/typescript-node'); - assert.strictEqual(typeScript.current, '1.0.5-18-bookworm'); + assert.strictEqual(typeScript.current, '1.0.5-${VARIANT}'); assert.notStrictEqual(typeScript.latest, typeScript.version); assert.ok(semver.gt(typeScript.latestVersion, typeScript.version), `semver.gt(${typeScript.latestVersion}, ${typeScript.version}) is false`); assert.strictEqual(typeScript.currentImageValue, 'mcr.microsoft.com/devcontainers/typescript-node:1.0.5-${VARIANT}'); @@ -188,7 +188,7 @@ describe('Outdated', function () { assert.ok(javascript); assert.ok(javascript.path.includes('Dockerfile')); assert.strictEqual(javascript.name, 'mcr.microsoft.com/devcontainers/javascript-node'); - assert.strictEqual(javascript.current, '0-16-bullseye'); + assert.strictEqual(javascript.current, '0-${VARIANT}'); assert.notStrictEqual(javascript.latest, javascript.version); assert.ok((parseFloat(javascript.latestVersion) > parseFloat(javascript.version)), `semver.gt(${javascript.latestVersion}, ${javascript.version}) is false`); assert.strictEqual(javascript.currentImageValue, 'mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT}');