diff --git a/.github/workflows/dev-containers.yml b/.github/workflows/dev-containers.yml index 3e5c392ff..ee75bbad5 100644 --- a/.github/workflows/dev-containers.yml +++ b/.github/workflows/dev-containers.yml @@ -55,9 +55,10 @@ 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'", + "--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-configuration/containerFeaturesConfiguration.ts b/src/spec-configuration/containerFeaturesConfiguration.ts index d5cf48efb..eb473b0ad 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -8,16 +8,14 @@ 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 { 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'; @@ -511,79 +509,6 @@ export async function generateFeaturesConfig(params: ContainerFeatureInternalPar return featuresConfig; } -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); @@ -1115,7 +1040,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-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/collectionCommonUtils/outdated.ts b/src/spec-node/collectionCommonUtils/outdated.ts new file mode 100644 index 000000000..073821a6e --- /dev/null +++ b/src/spec-node/collectionCommonUtils/outdated.ts @@ -0,0 +1,24 @@ +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.' }, + '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.' }, + '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..812b3a0b6 --- /dev/null +++ b/src/spec-node/collectionCommonUtils/outdatedCommandImpl.ts @@ -0,0 +1,452 @@ +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'; +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 } from '../devContainers'; +import { uriToFsPath, getCacheFolder, getDockerfilePath } from '../utils'; +import { DevContainerConfig, DevContainerFromDockerfileConfig, getDockerComposeFilePaths } from '../../spec-configuration/configuration'; +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, 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'; + +export interface OutdatedFeatures { + 'features': { + [key: string]: { + 'current': string; + 'wanted': string; + 'latest': string; + 'wantedMajor': string; + 'latestMajor': string; + }; + }; +} + +export interface OutdatedImages { + 'images': { + [key: string]: { + 'name': string; + 'version': string; + 'latestVersion': string; + 'current': string; + 'latest': 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, + config: configParam, + 'only-features': onlyFeatures, + 'only-images': onlyImages, + 'output-format': outputFormat, + 'log-level': logLevel, + 'log-format': logFormat, + 'terminal-rows': terminalRows, + 'terminal-columns': terminalColumns, +}: 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())); + }; + 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.` }); + } + + let outdatedFeatures: OutdatedFeatures; + let outdatedImages: OutdatedImages; + + 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 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); + } + + await new Promise((resolve, reject) => { + let text = ''; + if (outputFormat === 'text') { + 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, + ]); + } + } + + if (onlyImages || !(onlyImages || onlyFeatures)) { + const imageRows = Object.keys(outdatedImages.images).map(key => { + const value = outdatedImages.images[key]; + return [value.name, value.current, value.latest] + .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); + } + 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 undefined; + } + + 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 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 undefined; + } + + 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 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 (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 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); + return undefined; + } + + let newImageValue = `${imageName}:${wantedTag}`; + + // 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('-'); + latestTag = `${latestVersion}-${currentTagSuffix}`; + newImageValue = `${imageName}:${latestTag}`; + } + + return { + name: imageName, + version, + latestVersion, + current: currentImageTag, + latest: latestTag, + 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 undefined; +} + +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) { + 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); + + const resolvedImageInfo: Record = {}; + const images = findBaseImages(dockerfile, config.build.args || {}); + for (const currentImage in images) { + let imageInfo = await findImageVersionInfo(params, images[currentImage], dockerfilePath, currentImage); + + if (imageInfo !== undefined) { + resolvedImageInfo[currentImage] = imageInfo; + } + } + + if (resolvedImageInfo && Object.keys(resolvedImageInfo).length > 0) { + return { + images: { + ...resolvedImageInfo + } + }; + } else { + return { images: {} }; + } + } 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 { images: {} }; + } + + 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); + + const images = findBaseImages(dockerfile, composeService.build?.args || {}); + for (const currentImage in images) { + let imageInfo = await findImageVersionInfo(params, images[currentImage], resolvedDockerfilePath, currentImage); + + if (imageInfo !== undefined) { + resolvedImageInfo[currentImage] = imageInfo; + } + } + } + } + + if (resolvedImageInfo && Object.keys(resolvedImageInfo).length > 0) { + return { + images: { + ...resolvedImageInfo + } + }; + } else { + return { images: {} }; + } + } + } + + return { images: {} }; +} + +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 1c5bb7dff..87f1561f1 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -5,12 +5,10 @@ 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'; -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'; @@ -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 { 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'; @@ -70,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); @@ -1062,104 +1059,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 outdated = await loadVersionInfo(params, configs.config.config); - await new Promise((resolve, reject) => { - let text; - if (outputFormat === 'text') { - const rows = Object.keys(outdated.features).map(key => { - const value = outdated.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, - ]); - } else { - text = JSON.stringify(outdated, 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.' }, diff --git a/src/spec-node/dockerfileUtils.ts b/src/spec-node/dockerfileUtils.ts index 0f34476c7..c7f73e9b0 100644 --- a/src/spec-node/dockerfileUtils.ts +++ b/src/spec-node/dockerfileUtils.ts @@ -119,6 +119,19 @@ export function findBaseImage(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; + } + } + return resolvedBaseImages; +} + function extractDirectives(preambleStr: string) { const map: Record = {}; for (const line of preambleStr.split(/\r?\n/)) { 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}]`, () => { diff --git a/src/test/cli.outdated.test.ts b/src/test/cli.outdated.test.ts new file mode 100644 index 000000000..14d61b7ac --- /dev/null +++ b/src/test/cli.outdated.test.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * 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('json output: only-features', async () => { + const workspaceFolder = path.join(__dirname, 'configs/lockfile-outdated-command'); + + 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'); + 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('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')); + assert.strictEqual(baseImage.name, 'mcr.microsoft.com/devcontainers/base'); + assert.strictEqual(baseImage.current, '0-ubuntu-20.04'); + 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.latestVersion}-ubuntu-20.04`); + }); + + 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, 10); // 5 valid Features + header + empty line + image + + // 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')); + + // 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('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} --only-images --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} --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); + assert.strictEqual(typeScript.name, 'mcr.microsoft.com/devcontainers/typescript-node'); + 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}'); + assert.notStrictEqual(typeScript.newImageValue, typeScript.currentImageValue); + 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.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.latestVersion}-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} --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')); + assert.strictEqual(javascript.name, 'mcr.microsoft.com/devcontainers/javascript-node'); + assert.strictEqual(javascript.current, '0.204-18-buster'); + 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.latestVersion}-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} --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')); + assert.strictEqual(javascript.name, 'mcr.microsoft.com/devcontainers/javascript-node'); + 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}'); + assert.notStrictEqual(javascript.newImageValue, javascript.currentImageValue); + assert.strictEqual(javascript.newImageValue, `mcr.microsoft.com/devcontainers/javascript-node:${javascript.latestVersion}-\${VARIANT}`); + }); + + it('major-version-no-variant', async () => { + const workspaceFolder = path.join(__dirname, 'configs/image-with-features'); + + 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')); + assert.strictEqual(base.name, 'mcr.microsoft.com/vscode/devcontainers/base'); + assert.strictEqual(base.current, '0'); + 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.latestVersion}`); + }); +}); \ 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-syntax/.devcontainer.json b/src/test/configs/dockerfile-with-syntax/.devcontainer.json index 8aaa22fda..d044bd433 100644 --- a/src/test/configs/dockerfile-with-syntax/.devcontainer.json +++ b/src/test/configs/dockerfile-with-syntax/.devcontainer.json @@ -1,7 +1,7 @@ { "build": { "dockerfile": "Dockerfile", - "args": { + "args": { "VARIANT": "18-bookworm" } }, diff --git a/src/test/configs/dockerfile-with-syntax/Dockerfile b/src/test/configs/dockerfile-with-syntax/Dockerfile index 868e47ef5..5c6ec58c7 100644 --- a/src/test/configs/dockerfile-with-syntax/Dockerfile +++ b/src/test/configs/dockerfile-with-syntax/Dockerfile @@ -2,4 +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:1-${VARIANT} +FROM mcr.microsoft.com/devcontainers/typescript-node:1-${VARIANT} \ No newline at end of file diff --git a/src/test/configs/dockerfile-with-target/Dockerfile b/src/test/configs/dockerfile-with-target/Dockerfile index 25c88c27c..9f3ce3757 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:1.0.5-${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/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/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 88% rename from src/test/container-features/configs/lockfile-outdated-command/.devcontainer.json rename to src/test/configs/lockfile-outdated-command/.devcontainer.json index 7aba15391..6154466b8 100644 --- a/src/test/container-features/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", 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');