diff --git a/src/spec-common/injectHeadless.ts b/src/spec-common/injectHeadless.ts index e7eb550a8..9e2c4f2ad 100644 --- a/src/spec-common/injectHeadless.ts +++ b/src/spec-common/injectHeadless.ts @@ -61,7 +61,6 @@ export interface ResolverParameters { buildxPlatform: string | undefined; buildxPush: boolean; buildxOutput: string | undefined; - skipFeatureAutoMapping: boolean; skipPostAttach: boolean; containerSessionDataFolder?: string; skipPersistingCustomizationsFromFeatures: boolean; diff --git a/src/spec-configuration/configuration.ts b/src/spec-configuration/configuration.ts index ae0bdabff..d3fcdf499 100644 --- a/src/spec-configuration/configuration.ts +++ b/src/spec-configuration/configuration.ts @@ -34,7 +34,8 @@ export interface HostRequirements { } export interface DevContainerFeature { - userFeatureId: string; + rawUserFeatureId: string; + normalizedUserFeatureId: string; options: boolean | string | Record; } diff --git a/src/spec-configuration/containerFeaturesConfiguration.ts b/src/spec-configuration/containerFeaturesConfiguration.ts index 2bc937f51..2e5fa1545 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -208,7 +208,6 @@ export interface ContainerFeatureInternalParams { cwd: string; output: Log; env: NodeJS.ProcessEnv; - skipFeatureAutoMapping: boolean; platform: NodeJS.Platform; experimentalLockfile?: boolean; experimentalFrozenLockfile?: boolean; @@ -527,7 +526,7 @@ export async function generateFeaturesConfig(params: ContainerFeatureInternalPar const workspaceRoot = params.cwd; output.write(`workspace root: ${workspaceRoot}`, LogLevel.Trace); - const userFeatures = updateDeprecatedFeaturesIntoOptions(userFeaturesToArray(config, additionalFeatures), output); + const userFeatures = updateDeprecatedFeaturesIntoOptions(userFeaturesToArray(output, config, additionalFeatures), output); if (!userFeatures) { return undefined; } @@ -548,8 +547,8 @@ export async function generateFeaturesConfig(params: ContainerFeatureInternalPar const lockfile = await readLockfile(config); - const processFeature = async (_userFeature: DevContainerFeature) => { - return await processFeatureIdentifier(params, configPath, workspaceRoot, _userFeature, lockfile); + const processFeature = async (f: { userFeature: DevContainerFeature }) => { + return await processFeatureIdentifier(params, configPath, workspaceRoot, f.userFeature, lockfile); }; output.write('--- Processing User Features ----', LogLevel.Trace); @@ -576,7 +575,7 @@ export async function generateFeaturesConfig(params: ContainerFeatureInternalPar export async function loadVersionInfo(params: ContainerFeatureInternalParams, config: DevContainerConfig) { const { output } = params; - const userFeatures = updateDeprecatedFeaturesIntoOptions(userFeaturesToArray(config), output); + const userFeatures = updateDeprecatedFeaturesIntoOptions(userFeaturesToArray(output, config), output); if (!userFeatures) { return { features: {} }; } @@ -586,7 +585,7 @@ export async function loadVersionInfo(params: ContainerFeatureInternalParams, co const features: Record = {}; await Promise.all(userFeatures.map(async userFeature => { - const userFeatureId = userFeature.userFeatureId; + const userFeatureId = userFeature.rawUserFeatureId; const updatedFeatureId = getBackwardCompatibleFeatureId(output, userFeatureId); const featureRef = getRef(output, updatedFeatureId); if (featureRef) { @@ -645,33 +644,42 @@ async function prepareOCICache(dstFolder: string) { return ociCacheDir; } -export function userFeaturesToArray(config: DevContainerConfig, additionalFeatures?: Record>): DevContainerFeature[] | undefined { +export function normalizeUserFeatureIdentifier(output: Log, userFeatureId: string): string { + return getBackwardCompatibleFeatureId(output, userFeatureId).toLowerCase(); +} + + +export function userFeaturesToArray(output: Log, config: DevContainerConfig, additionalFeatures?: Record>): DevContainerFeature[] | undefined { if (!Object.keys(config.features || {}).length && !Object.keys(additionalFeatures || {}).length) { return undefined; } const userFeatures: DevContainerFeature[] = []; - const userFeatureKeys = new Set(); + const keys = new Set(); if (config.features) { - for (const userFeatureKey of Object.keys(config.features)) { - const userFeatureValue = config.features[userFeatureKey]; + for (const rawUserFeatureId of Object.keys(config.features)) { + const normalizedUserFeatureId = normalizeUserFeatureIdentifier(output, rawUserFeatureId); + const userFeatureValue = config.features[rawUserFeatureId]; const feature: DevContainerFeature = { - userFeatureId: userFeatureKey, + rawUserFeatureId, + normalizedUserFeatureId, options: userFeatureValue }; userFeatures.push(feature); - userFeatureKeys.add(userFeatureKey); + keys.add(normalizedUserFeatureId); } } if (additionalFeatures) { - for (const userFeatureKey of Object.keys(additionalFeatures)) { + for (const rawUserFeatureId of Object.keys(additionalFeatures)) { + const normalizedUserFeatureId = normalizeUserFeatureIdentifier(output, rawUserFeatureId); // add the additional feature if it hasn't already been added from the config features - if (!userFeatureKeys.has(userFeatureKey)) { - const userFeatureValue = additionalFeatures[userFeatureKey]; + if (!keys.has(normalizedUserFeatureId)) { + const userFeatureValue = additionalFeatures[rawUserFeatureId]; const feature: DevContainerFeature = { - userFeatureId: userFeatureKey, + rawUserFeatureId, + normalizedUserFeatureId, options: userFeatureValue }; userFeatures.push(feature); @@ -711,11 +719,11 @@ export function updateDeprecatedFeaturesIntoOptions(userFeatures: DevContainerFe const newFeaturePath = 'ghcr.io/devcontainers/features'; const versionBackwardComp = '1'; - for (const update of userFeatures.filter(feature => deprecatedFeaturesIntoOptions[feature.userFeatureId])) { - const { mapTo, withOptions } = deprecatedFeaturesIntoOptions[update.userFeatureId]; - output.write(`(!) WARNING: Using the deprecated '${update.userFeatureId}' Feature. It is now part of the '${mapTo}' Feature. See https://github.com/devcontainers/features/tree/main/src/${mapTo}#options for the updated Feature.`, LogLevel.Warning); + for (const update of userFeatures.filter(feature => deprecatedFeaturesIntoOptions[feature.rawUserFeatureId])) { + const { mapTo, withOptions } = deprecatedFeaturesIntoOptions[update.rawUserFeatureId]; + output.write(`(!) WARNING: Using the deprecated '${update.rawUserFeatureId}' Feature. It is now part of the '${mapTo}' Feature. See https://github.com/devcontainers/features/tree/main/src/${mapTo}#options for the updated Feature.`, LogLevel.Warning); const qualifiedMapToId = `${newFeaturePath}/${mapTo}`; - let userFeature = userFeatures.find(feature => feature.userFeatureId === mapTo || feature.userFeatureId === qualifiedMapToId || feature.userFeatureId.startsWith(`${qualifiedMapToId}:`)); + let userFeature = userFeatures.find(feature => feature.rawUserFeatureId === mapTo || feature.rawUserFeatureId === qualifiedMapToId || feature.rawUserFeatureId.startsWith(`${qualifiedMapToId}:`)); if (userFeature) { userFeature.options = { ...( @@ -726,14 +734,16 @@ export function updateDeprecatedFeaturesIntoOptions(userFeatures: DevContainerFe ...withOptions, }; } else { + const rawUserFeatureId = `${qualifiedMapToId}:${versionBackwardComp}`; userFeature = { - userFeatureId: `${qualifiedMapToId}:${versionBackwardComp}`, + rawUserFeatureId, + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, rawUserFeatureId), options: withOptions }; userFeatures.push(userFeature); } } - const updatedUserFeatures = userFeatures.filter(feature => !deprecatedFeaturesIntoOptions[feature.userFeatureId]); + const updatedUserFeatures = userFeatures.filter(feature => !deprecatedFeaturesIntoOptions[feature.rawUserFeatureId]); return updatedUserFeatures; } @@ -817,19 +827,12 @@ export function getBackwardCompatibleFeatureId(output: Log, id: string) { // Strictly processes the user provided feature identifier to determine sourceInformation type. // Returns a featureSet per feature. -export async function processFeatureIdentifier(params: CommonParams, configPath: string | undefined, _workspaceRoot: string, userFeature: DevContainerFeature, lockfile?: Lockfile, skipFeatureAutoMapping?: boolean): Promise { +export async function processFeatureIdentifier(params: CommonParams, configPath: string | undefined, _workspaceRoot: string, userFeature: DevContainerFeature, lockfile?: Lockfile): Promise { const { output } = params; - output.write(`* Processing feature: ${userFeature.userFeatureId}`); - - // id referenced by the user before the automapping from old shorthand syntax to "ghcr.io/devcontainers/features" - const originalUserFeatureId = userFeature.userFeatureId; - // Adding backward compatibility - if (!skipFeatureAutoMapping) { - userFeature.userFeatureId = getBackwardCompatibleFeatureId(output, userFeature.userFeatureId); - } + output.write(`* Processing feature: ${userFeature.rawUserFeatureId}`); - const { type, manifest } = await getFeatureIdType(params, userFeature.userFeatureId, lockfile); + const { type, manifest } = await getFeatureIdType(params, userFeature.normalizedUserFeatureId, lockfile); // cached feature // Resolves deprecated features (fish, maven, gradle, homebrew, jupyterlab) @@ -837,8 +840,8 @@ export async function processFeatureIdentifier(params: CommonParams, configPath: output.write(`Cached feature found.`); let feat: Feature = { - id: userFeature.userFeatureId, - name: userFeature.userFeatureId, + id: userFeature.normalizedUserFeatureId, + name: userFeature.normalizedUserFeatureId, value: userFeature.options, included: true, }; @@ -846,7 +849,7 @@ export async function processFeatureIdentifier(params: CommonParams, configPath: let newFeaturesSet: FeatureSet = { sourceInformation: { type: 'local-cache', - userFeatureId: originalUserFeatureId + userFeatureId: userFeature.rawUserFeatureId // Without backwards-compatible normalization }, features: [feat], }; @@ -857,7 +860,7 @@ export async function processFeatureIdentifier(params: CommonParams, configPath: // remote tar file if (type === 'direct-tarball') { output.write(`Remote tar file found.`); - const tarballUri = new URL.URL(userFeature.userFeatureId); + const tarballUri = new URL.URL(userFeature.rawUserFeatureId); const fullPath = tarballUri.pathname; const tarballName = fullPath.substring(fullPath.lastIndexOf('/') + 1); @@ -878,8 +881,8 @@ export async function processFeatureIdentifier(params: CommonParams, configPath: } let feat: Feature = { - id: id, - name: userFeature.userFeatureId, + id, + name: userFeature.normalizedUserFeatureId, value: userFeature.options, included: true, }; @@ -888,7 +891,7 @@ export async function processFeatureIdentifier(params: CommonParams, configPath: sourceInformation: { type: 'direct-tarball', tarballUri: tarballUri.toString(), - userFeatureId: originalUserFeatureId + userFeatureId: userFeature.normalizedUserFeatureId, }, features: [feat], }; @@ -900,10 +903,8 @@ export async function processFeatureIdentifier(params: CommonParams, configPath: if (type === 'file-path') { output.write(`Local disk feature.`); - const id = path.basename(userFeature.userFeatureId); - // Fail on Absolute paths. - if (path.isAbsolute(userFeature.userFeatureId)) { + if (path.isAbsolute(userFeature.normalizedUserFeatureId)) { output.write('An Absolute path to a local feature is not allowed.', LogLevel.Error); return undefined; } @@ -913,7 +914,7 @@ export async function processFeatureIdentifier(params: CommonParams, configPath: output.write('A local feature requires a configuration path.', LogLevel.Error); return undefined; } - const featureFolderPath = path.join(path.dirname(configPath), userFeature.userFeatureId); + const featureFolderPath = path.join(path.dirname(configPath), userFeature.rawUserFeatureId); // OS Path may be case-sensitive // Ensure we aren't escaping .devcontainer folder const parent = path.join(_workspaceRoot, '.devcontainer'); @@ -925,14 +926,13 @@ export async function processFeatureIdentifier(params: CommonParams, configPath: return undefined; } - output.write(`Resolved: ${userFeature.userFeatureId} -> ${featureFolderPath}`, LogLevel.Trace); + output.write(`Resolved: ${userFeature.rawUserFeatureId} -> ${featureFolderPath}`, LogLevel.Trace); // -- All parsing and validation steps complete at this point. - output.write(`Parsed feature id: ${id}`, LogLevel.Trace); let feat: Feature = { - id, - name: userFeature.userFeatureId, + id: path.basename(userFeature.normalizedUserFeatureId), + name: userFeature.normalizedUserFeatureId, value: userFeature.options, included: true, }; @@ -941,7 +941,7 @@ export async function processFeatureIdentifier(params: CommonParams, configPath: sourceInformation: { type: 'file-path', resolvedFilePath: featureFolderPath, - userFeatureId: originalUserFeatureId + userFeatureId: userFeature.normalizedUserFeatureId }, features: [feat], }; @@ -951,19 +951,19 @@ export async function processFeatureIdentifier(params: CommonParams, configPath: // (6) Oci Identifier if (type === 'oci' && manifest) { - return tryGetOCIFeatureSet(output, userFeature.userFeatureId, userFeature.options, manifest, originalUserFeatureId); + return tryGetOCIFeatureSet(output, userFeature.normalizedUserFeatureId, userFeature.options, manifest, userFeature.rawUserFeatureId); } output.write(`Github feature.`); // Github repository source. let version = 'latest'; - let splitOnAt = userFeature.userFeatureId.split('@'); + let splitOnAt = userFeature.normalizedUserFeatureId.split('@'); if (splitOnAt.length > 2) { output.write(`Parse error. Use the '@' symbol only to designate a version tag.`, LogLevel.Error); return undefined; } if (splitOnAt.length === 2) { - output.write(`[${userFeature.userFeatureId}] has version ${splitOnAt[1]}`, LogLevel.Trace); + output.write(`[${userFeature.normalizedUserFeatureId}] has version ${splitOnAt[1]}`, LogLevel.Trace); version = splitOnAt[1]; } @@ -974,7 +974,7 @@ export async function processFeatureIdentifier(params: CommonParams, configPath: // eg: // if (splitOnSlash.length !== 3 || splitOnSlash.some(x => x === '') || !allowedFeatureIdRegex.test(splitOnSlash[2])) { // This is the final fallback. If we end up here, we weren't able to resolve the Feature - output.write(`Could not resolve Feature '${userFeature.userFeatureId}'. Ensure the Feature is published and accessible from your current environment.`, LogLevel.Error); + output.write(`Could not resolve Feature '${userFeature.normalizedUserFeatureId}'. Ensure the Feature is published and accessible from your current environment.`, LogLevel.Error); return undefined; } const owner = splitOnSlash[0]; @@ -983,12 +983,12 @@ export async function processFeatureIdentifier(params: CommonParams, configPath: let feat: Feature = { id: id, - name: userFeature.userFeatureId, + name: userFeature.normalizedUserFeatureId, value: userFeature.options, included: true, }; - const userFeatureIdWithoutVersion = originalUserFeatureId.split('@')[0]; + const userFeatureIdWithoutVersion = userFeature.normalizedUserFeatureId.split('@')[0]; if (version === 'latest') { let newFeaturesSet: FeatureSet = { sourceInformation: { @@ -998,7 +998,7 @@ export async function processFeatureIdentifier(params: CommonParams, configPath: owner, repo, isLatest: true, - userFeatureId: originalUserFeatureId, + userFeatureId: userFeature.normalizedUserFeatureId, userFeatureIdWithoutVersion }, features: [feat], @@ -1015,7 +1015,7 @@ export async function processFeatureIdentifier(params: CommonParams, configPath: repo, tag: version, isLatest: false, - userFeatureId: originalUserFeatureId, + userFeatureId: userFeature.normalizedUserFeatureId, userFeatureIdWithoutVersion }, features: [feat], diff --git a/src/spec-configuration/containerFeaturesOrder.ts b/src/spec-configuration/containerFeaturesOrder.ts index 18f449a0a..c511240d9 100644 --- a/src/spec-configuration/containerFeaturesOrder.ts +++ b/src/spec-configuration/containerFeaturesOrder.ts @@ -8,7 +8,7 @@ import * as jsonc from 'jsonc-parser'; import * as os from 'os'; import * as crypto from 'crypto'; -import { DEVCONTAINER_FEATURE_FILE_NAME, DirectTarballSourceInformation, Feature, FeatureSet, FilePathSourceInformation, OCISourceInformation, fetchContentsAtTarballUri } from '../spec-configuration/containerFeaturesConfiguration'; +import { DEVCONTAINER_FEATURE_FILE_NAME, DirectTarballSourceInformation, Feature, FeatureSet, FilePathSourceInformation, OCISourceInformation, fetchContentsAtTarballUri, normalizeUserFeatureIdentifier } from '../spec-configuration/containerFeaturesConfiguration'; import { LogLevel } from '../spec-utils/log'; import { DevContainerFeature } from './configuration'; import { CommonParams, OCIRef } from './containerCollectionsOCI'; @@ -18,8 +18,8 @@ import { Lockfile } from './lockfile'; interface FNode { type: 'user-provided' | 'override' | 'resolved'; - userFeatureId: string; - options: string | boolean | Record; + // Object from reading Features referenced in the devcontainer.json or devcontainer-feature.json + userFeature: DevContainerFeature; // FeatureSet contains 'sourceInformation', useful for: // Providing information on if Feature is an OCI Feature, Direct HTTPS Feature, or Local Feature. @@ -205,6 +205,8 @@ function ociResourceCompareTo(a: { featureRef: OCIRef; aliases?: string[] }, b: // If the sorting algorithm should place A _after_ B, return positive number. function compareTo(params: CommonParams, a: FNode, b: FNode): number { const { output } = params; + const aOptions = a.userFeature.options; + const bOptions = b.userFeature.options; const aSourceInfo = a.featureSet?.sourceInformation; let bSourceInfo = b.featureSet?.sourceInformation; // Mutable only for type-casting. @@ -226,7 +228,7 @@ function compareTo(params: CommonParams, a: FNode, b: FNode): number { const bDigest = bSourceInfo.manifestDigest; // Short circuit if the digests and options are equal - if (aDigest === bDigest && optionsCompareTo(a.options, b.options) === 0) { + if (aDigest === bDigest && optionsCompareTo(aOptions, bOptions) === 0) { return 0; } @@ -250,7 +252,7 @@ function compareTo(params: CommonParams, a: FNode, b: FNode): number { } // Sort by options - const optionsVal = optionsCompareTo(a.options, b.options); + const optionsVal = optionsCompareTo(aOptions, bOptions); if (optionsVal !== 0) { return optionsVal; } @@ -269,7 +271,7 @@ function compareTo(params: CommonParams, a: FNode, b: FNode): number { if (pathCompare !== 0) { return pathCompare; } - return optionsCompareTo(a.options, b.options); + return optionsCompareTo(aOptions, bOptions); case 'direct-tarball': bSourceInfo = bSourceInfo as DirectTarballSourceInformation; @@ -277,7 +279,7 @@ function compareTo(params: CommonParams, a: FNode, b: FNode): number { if (urlCompare !== 0) { return urlCompare; } - return optionsCompareTo(a.options, b.options); + return optionsCompareTo(aOptions, bOptions); default: // Legacy @@ -287,13 +289,13 @@ function compareTo(params: CommonParams, a: FNode, b: FNode): number { if (userIdCompare !== 0) { return userIdCompare; } - return optionsCompareTo(a.options, b.options); + return optionsCompareTo(aOptions, bOptions); } } async function applyOverrideFeatureInstallOrder( params: CommonParams, - processFeature: (userFeature: DevContainerFeature) => Promise, + processFeature: (f: { userFeature: DevContainerFeature }) => Promise, worklist: FNode[], config: { overrideFeatureInstallOrder?: string[] }, ) { @@ -311,10 +313,15 @@ async function applyOverrideFeatureInstallOrder( // First element == N, last element == 1 const roundPriority = originalLength - i; + const userFeature = { + rawUserFeatureId: overrideFeatureId, + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, overrideFeatureId), + options: {}, + } as DevContainerFeature; + const tmpOverrideNode: FNode = { type: 'override', - userFeatureId: overrideFeatureId, - options: {}, + userFeature, roundPriority, installsAfter: [], dependsOn: [], @@ -323,7 +330,7 @@ async function applyOverrideFeatureInstallOrder( const processed = await processFeature(tmpOverrideNode); if (!processed) { - throw new Error(`Feature '${tmpOverrideNode.userFeatureId}' in 'overrideFeatureInstallOrder' could not be processed.`); + throw new Error(`Feature '${tmpOverrideNode.userFeature.rawUserFeatureId}' in 'overrideFeatureInstallOrder' could not be processed.`); } tmpOverrideNode.featureSet = processed; @@ -332,7 +339,7 @@ async function applyOverrideFeatureInstallOrder( for (const node of worklist) { if (satisfiesSoftDependency(params, node, tmpOverrideNode)) { // Increase the priority of this node to install it sooner. - output.write(`[override]: '${node.userFeatureId}' has override priority of ${roundPriority}`, LogLevel.Trace); + output.write(`[override]: '${node.userFeature.rawUserFeatureId}' has override priority of ${roundPriority}`, LogLevel.Trace); node.roundPriority = Math.max(node.roundPriority, roundPriority); } } @@ -344,7 +351,7 @@ async function applyOverrideFeatureInstallOrder( async function _buildDependencyGraph( params: CommonParams, - processFeature: (userFeature: DevContainerFeature) => Promise, + processFeature: (f: { userFeature: DevContainerFeature }) => Promise, worklist: FNode[], acc: FNode[], lockfile: Lockfile | undefined): Promise { @@ -353,12 +360,12 @@ async function _buildDependencyGraph( while (worklist.length > 0) { const current = worklist.shift()!; - output.write(`Resolving Feature dependencies for '${current.userFeatureId}'...`, LogLevel.Info); + output.write(`Resolving Feature dependencies for '${current.userFeature.rawUserFeatureId}'...`, LogLevel.Info); const processedFeature = await processFeature(current); if (!processedFeature) { - throw new Error(`ERR: Feature '${current.userFeatureId}' could not be processed. You may not have permission to access this Feature, or may not be logged in. If the issue persists, report this to the Feature author.`); - } + throw new Error(`ERR: Feature '${current.userFeature.rawUserFeatureId}' could not be processed. You may not have permission to access this Feature, or may not be logged in. If the issue persists, report this to the Feature author.`); + } // Set the processed FeatureSet object onto Node. current.featureSet = processedFeature; @@ -383,7 +390,7 @@ async function _buildDependencyGraph( const filePath = (current.featureSet.sourceInformation as FilePathSourceInformation).resolvedFilePath; const metadataFilePath = path.join(filePath, DEVCONTAINER_FEATURE_FILE_NAME); if (!isLocalFile(filePath)) { - throw new Error(`Metadata file '${metadataFilePath}' cannot be read for Feature '${current.userFeatureId}'.`); + throw new Error(`Metadata file '${metadataFilePath}' cannot be read for Feature '${current.userFeature.rawUserFeatureId}'.`); } const serialized = (await readLocalFile(metadataFilePath)).toString(); if (serialized) { @@ -424,8 +431,11 @@ async function _buildDependencyGraph( for (const [userFeatureId, options] of Object.entries(dependsOn)) { const dependency: FNode = { type: 'resolved', - userFeatureId, - options, + userFeature: { + rawUserFeatureId: userFeatureId, + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, userFeatureId), + options, + }, featureSet: undefined, dependsOn: [], installsAfter: [], @@ -437,11 +447,16 @@ async function _buildDependencyGraph( // Add a new node for each 'installsAfter' soft-dependency onto the 'current' node. // Soft-dependencies are NOT recursively processed - do *not* add to worklist. - for (const userFeatureId of installsAfter) { + for (const rawUserFeatureId of installsAfter) { + const userFeature = { + rawUserFeatureId, + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, rawUserFeatureId), + options: {}, + } as DevContainerFeature; + const dependency: FNode = { type: 'resolved', - userFeatureId, - options: {}, + userFeature, featureSet: undefined, dependsOn: [], installsAfter: [], @@ -449,7 +464,7 @@ async function _buildDependencyGraph( }; const processedFeatureSet = await processFeature(dependency); if (!processedFeatureSet) { - throw new Error(`installsAfter dependency '${userFeatureId}' of Feature '${current.userFeatureId}' could not be processed.`); + throw new Error(`installsAfter dependency '${userFeature.rawUserFeatureId}' of Feature '${current.userFeature.rawUserFeatureId}' could not be processed.`); } dependency.featureSet = processedFeatureSet; @@ -522,7 +537,7 @@ async function getTgzFeatureMetadata(params: CommonParams, node: FNode, expected const tmp = path.join(os.tmpdir(), crypto.randomUUID()); const result = await fetchContentsAtTarballUri(params, srcInfo.tarballUri, expectedDigest, tmp, undefined, tmp, DEVCONTAINER_FEATURE_FILE_NAME); if (!result || !result.metadata) { - output.write(`No metadata for Feature '${node.userFeatureId}' from '${srcInfo.tarballUri}'`, LogLevel.Trace); + output.write(`No metadata for Feature '${node.userFeature.rawUserFeatureId}' from '${srcInfo.tarballUri}'`, LogLevel.Trace); return; } @@ -534,7 +549,7 @@ async function getTgzFeatureMetadata(params: CommonParams, node: FNode, expected // Creates the directed acyclic graph (DAG) of Features and their dependencies. export async function buildDependencyGraph( params: CommonParams, - processFeature: (userFeature: DevContainerFeature) => Promise, + processFeature: (f: { userFeature: DevContainerFeature }) => Promise, userFeatures: DevContainerFeature[], config: { overrideFeatureInstallOrder?: string[] }, lockfile: Lockfile | undefined): Promise { @@ -542,22 +557,21 @@ export async function buildDependencyGraph( const { output } = params; const rootNodes = - userFeatures.map(f => { + userFeatures.map(userFeature => { return { type: 'user-provided', // This Feature was provided by the user in the 'features' object of devcontainer.json. - userFeatureId: f.userFeatureId, - options: f.options, + userFeature, dependsOn: [], installsAfter: [], roundPriority: 0, }; }); - output.write(`[* user-provided] ${rootNodes.map(n => n.userFeatureId).join(', ')}`, LogLevel.Trace); + output.write(`[* user-provided] ${rootNodes.map(n => n.userFeature.rawUserFeatureId).join(', ')}`, LogLevel.Trace); const { worklist } = await _buildDependencyGraph(params, processFeature, rootNodes, [], lockfile); - output.write(`[* resolved worklist] ${worklist.map(n => n.userFeatureId).join(', ')}`, LogLevel.Trace); + output.write(`[* resolved worklist] ${worklist.map(n => n.userFeature.rawUserFeatureId).join(', ')}`, LogLevel.Trace); // Apply the 'overrideFeatureInstallOrder' to the worklist. if (config?.overrideFeatureInstallOrder) { @@ -570,7 +584,7 @@ export async function buildDependencyGraph( // Returns the ordered list of FeatureSets to fetch and install, or undefined on error. export async function computeDependsOnInstallationOrder( params: CommonParams, - processFeature: (userFeature: DevContainerFeature) => Promise, + processFeature: (f: { userFeature: DevContainerFeature }) => Promise, userFeatures: DevContainerFeature[], config: { overrideFeatureInstallOrder?: string[] }, lockfile?: Lockfile, @@ -599,7 +613,7 @@ export async function computeDependsOnInstallationOrder( throw new Error(`ERR: Failure resolving Features.`); } - output.write(`[raw worklist]: ${worklist.map(n => n.userFeatureId).join(', ')}`, LogLevel.Trace); + output.write(`[raw worklist]: ${worklist.map(n => n.userFeature.rawUserFeatureId).join(', ')}`, LogLevel.Trace); // For each node in the worklist, remove all 'soft-dependency' graph edges that are irrelevant // i.e. the node is not a 'soft match' for any node in the worklist itself @@ -609,14 +623,14 @@ export async function computeDependsOnInstallationOrder( for (let j = node.installsAfter.length - 1; j >= 0; j--) { const softDep = node.installsAfter[j]; if (!worklist.some(n => satisfiesSoftDependency(params, n, softDep))) { - output.write(`Soft-dependency '${softDep.userFeatureId}' is not required. Removing from installation order...`, LogLevel.Info); + output.write(`Soft-dependency '${softDep.userFeature.rawUserFeatureId}' is not required. Removing from installation order...`, LogLevel.Info); // Delete that soft-dependency node.installsAfter.splice(j, 1); } } } - output.write(`[worklist-without-dangling-soft-deps]: ${worklist.map(n => n.userFeatureId).join(', ')}`, LogLevel.Trace); + output.write(`[worklist-without-dangling-soft-deps]: ${worklist.map(n => n.userFeature.rawUserFeatureId).join(', ')}`, LogLevel.Trace); output.write('Starting round-based Feature install order calculation from worklist...', LogLevel.Trace); const installationOrder: FNode[] = []; @@ -630,14 +644,14 @@ export async function computeDependsOnInstallationOrder( && node.installsAfter.every(dep => installationOrder.some(installed => satisfiesSoftDependency(params, installed, dep)))); - output.write(`\n[round] ${round.map(r => r.userFeatureId).join(', ')}`, LogLevel.Trace); + output.write(`\n[round] ${round.map(r => r.userFeature.rawUserFeatureId).join(', ')}`, LogLevel.Trace); if (round.length === 0) { output.write('Circular dependency detected!', LogLevel.Error); - output.write(`Nodes remaining: ${worklist.map(n => n.userFeatureId!).join(', ')}`, LogLevel.Error); + output.write(`Nodes remaining: ${worklist.map(n => n.userFeature.rawUserFeatureId!).join(', ')}`, LogLevel.Error); return; } - output.write(`[round-candidates] ${round.map(r => `${r.userFeatureId} (${r.roundPriority})`).join(', ')}`, LogLevel.Trace); + output.write(`[round-candidates] ${round.map(r => `${r.userFeature.rawUserFeatureId} (${r.roundPriority})`).join(', ')}`, LogLevel.Trace); // Given the set of eligible nodes to install this round, // determine the highest 'roundPriority' present of the nodes in this @@ -647,14 +661,14 @@ export async function computeDependsOnInstallationOrder( // - The overrideFeatureInstallOrder property (more generically, 'roundPriority') is honored const maxRoundPriority = Math.max(...round.map(r => r.roundPriority)); round.splice(0, round.length, ...round.filter(node => node.roundPriority === maxRoundPriority)); - output.write(`[round-after-filter-priority] (maxPriority=${maxRoundPriority}) ${round.map(r => `${r.userFeatureId} (${r.roundPriority})`).join(', ')}`, LogLevel.Trace); + output.write(`[round-after-filter-priority] (maxPriority=${maxRoundPriority}) ${round.map(r => `${r.userFeature.rawUserFeatureId} (${r.roundPriority})`).join(', ')}`, LogLevel.Trace); // Delete all nodes present in this round from the worklist. worklist.splice(0, worklist.length, ...worklist.filter(node => !round.some(r => equals(params, r, node)))); // Sort rounds lexicographically by id. round.sort((a, b) => compareTo(params, a, b)); - output.write(`[round-after-comparesTo] ${round.map(r => r.userFeatureId).join(', ')}`, LogLevel.Trace); + output.write(`[round-after-comparesTo] ${round.map(r => r.userFeature.rawUserFeatureId).join(', ')}`, LogLevel.Trace); // Commit round installationOrder.push(...round); @@ -702,5 +716,5 @@ function generateMermaidNode(node: FNode) { const hasher = crypto.createHash('sha256', { encoding: 'hex' }); const hash = hasher.update(JSON.stringify(node)).digest('hex').slice(0, 6); const aliases = node.featureIdAliases && node.featureIdAliases.length > 0 ? `
aliases: ${node.featureIdAliases.join(', ')}` : ''; - return `${hash}[${node.userFeatureId}
<${node.roundPriority}>${aliases}]`; + return `${hash}[${node.userFeature.normalizedUserFeatureId}
<${node.roundPriority}>${aliases}]`; } \ No newline at end of file diff --git a/src/spec-node/devContainers.ts b/src/spec-node/devContainers.ts index f9cc18340..41caa6a39 100644 --- a/src/spec-node/devContainers.ts +++ b/src/spec-node/devContainers.ts @@ -53,7 +53,6 @@ export interface ProvisionOptions { buildxPush: boolean; buildxOutput: string | undefined; additionalFeatures?: Record>; - skipFeatureAutoMapping: boolean; skipPostAttach: boolean; containerSessionDataFolder?: string; skipPersistingCustomizationsFromFeatures: boolean; @@ -140,7 +139,6 @@ export async function createDockerParams(options: ProvisionOptions, disposables: buildxPlatform: options.buildxPlatform, buildxPush: options.buildxPush, buildxOutput: options.buildxOutput, - skipFeatureAutoMapping: options.skipFeatureAutoMapping, skipPostAttach: options.skipPostAttach, containerSessionDataFolder: options.containerSessionDataFolder, skipPersistingCustomizationsFromFeatures: options.skipPersistingCustomizationsFromFeatures, diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 25afd9774..734199687 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -186,7 +186,6 @@ async function provision({ 'cache-from': addCacheFrom, 'buildkit': buildkit, 'additional-features': additionalFeaturesJson, - 'skip-feature-auto-mapping': skipFeatureAutoMapping, 'skip-post-attach': skipPostAttach, 'dotfiles-repository': dotfilesRepository, 'dotfiles-install-command': dotfilesInstallCommand, @@ -253,7 +252,6 @@ async function provision({ buildxPush: false, buildxOutput: undefined, additionalFeatures, - skipFeatureAutoMapping, skipPostAttach, containerSessionDataFolder, skipPersistingCustomizationsFromFeatures: false, @@ -405,7 +403,6 @@ async function doSetUp({ buildxPlatform: undefined, buildxPush: false, buildxOutput: undefined, - skipFeatureAutoMapping: false, skipPostAttach: false, skipPersistingCustomizationsFromFeatures: false, dotfiles: { @@ -516,7 +513,6 @@ async function doBuild({ 'push': buildxPush, 'output': buildxOutput, 'additional-features': additionalFeaturesJson, - 'skip-feature-auto-mapping': skipFeatureAutoMapping, 'skip-persisting-customizations-from-features': skipPersistingCustomizationsFromFeatures, 'experimental-lockfile': experimentalLockfile, 'experimental-frozen-lockfile': experimentalFrozenLockfile @@ -560,7 +556,6 @@ async function doBuild({ buildxPlatform, buildxPush, buildxOutput, - skipFeatureAutoMapping, skipPostAttach: true, skipPersistingCustomizationsFromFeatures: skipPersistingCustomizationsFromFeatures, dotfiles: {}, @@ -766,7 +761,6 @@ async function doRunUserCommands({ prebuild, 'stop-for-personalization': stopForPersonalization, 'remote-env': addRemoteEnv, - 'skip-feature-auto-mapping': skipFeatureAutoMapping, 'skip-post-attach': skipPostAttach, 'dotfiles-repository': dotfilesRepository, 'dotfiles-install-command': dotfilesInstallCommand, @@ -818,7 +812,6 @@ async function doRunUserCommands({ buildxPlatform: undefined, buildxPush: false, buildxOutput: undefined, - skipFeatureAutoMapping, skipPostAttach, skipPersistingCustomizationsFromFeatures: false, dotfiles: { @@ -941,7 +934,6 @@ async function readConfiguration({ 'include-features-configuration': includeFeaturesConfig, 'include-merged-configuration': includeMergedConfig, 'additional-features': additionalFeaturesJson, - 'skip-feature-auto-mapping': skipFeatureAutoMapping, }: ReadConfigurationArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -1002,7 +994,7 @@ async function readConfiguration({ const additionalFeatures = additionalFeaturesJson ? jsonc.parse(additionalFeaturesJson) as Record> : {}; const needsFeaturesConfig = includeFeaturesConfig || (includeMergedConfig && !container); - const featuresConfiguration = needsFeaturesConfig ? await readFeaturesConfig(params, pkg, configuration.config, extensionPath, skipFeatureAutoMapping, additionalFeatures) : undefined; + const featuresConfiguration = needsFeaturesConfig ? await readFeaturesConfig(params, pkg, configuration.config, extensionPath, additionalFeatures) : undefined; let mergedConfig: MergedDevContainerConfig | undefined; if (includeMergedConfig) { let imageMetadata: ImageMetadataEntry[]; @@ -1037,12 +1029,12 @@ async function readConfiguration({ process.exit(0); } -async function readFeaturesConfig(params: DockerCLIParameters, pkg: PackageConfiguration, config: DevContainerConfig, extensionPath: string, skipFeatureAutoMapping: boolean, additionalFeatures: Record>): Promise { +async function readFeaturesConfig(params: DockerCLIParameters, pkg: PackageConfiguration, config: DevContainerConfig, extensionPath: string, additionalFeatures: Record>): Promise { const { cliHost, output } = params; const { cwd, env, platform } = cliHost; const featuresTmpFolder = await createFeaturesTempFolder({ cliHost, package: pkg }); const cacheFolder = await getCacheFolder(cliHost); - return generateFeaturesConfig({ extensionPath, cacheFolder, cwd, output, env, skipFeatureAutoMapping, platform }, featuresTmpFolder, config, getContainerFeaturesFolder, additionalFeatures); + return generateFeaturesConfig({ extensionPath, cacheFolder, cwd, output, env, platform }, featuresTmpFolder, config, getContainerFeaturesFolder, additionalFeatures); } function outdatedOptions(y: Argv) { @@ -1107,7 +1099,6 @@ async function outdated({ cwd: cliHost.cwd, output, env: cliHost.env, - skipFeatureAutoMapping: false, platform: cliHost.platform, }; @@ -1223,7 +1214,6 @@ export async function doExec({ 'terminal-columns': terminalColumns, 'default-user-env-probe': defaultUserEnvProbe, 'remote-env': addRemoteEnv, - 'skip-feature-auto-mapping': skipFeatureAutoMapping, _: restArgs, }: ExecArgs & { _?: string[] }) { const disposables: (() => Promise | undefined)[] = []; @@ -1268,7 +1258,6 @@ export async function doExec({ omitLoggerHeader: true, buildxPlatform: undefined, buildxPush: false, - skipFeatureAutoMapping, buildxOutput: undefined, skipPostAttach: false, skipPersistingCustomizationsFromFeatures: false, diff --git a/src/spec-node/featuresCLI/info.ts b/src/spec-node/featuresCLI/info.ts index 3cfb71619..756ac74fb 100644 --- a/src/spec-node/featuresCLI/info.ts +++ b/src/spec-node/featuresCLI/info.ts @@ -6,7 +6,7 @@ import { createLog } from '../devContainers'; import { UnpackArgv } from '../devContainersSpecCLI'; import { buildDependencyGraph, generateMermaidDiagram } from '../../spec-configuration/containerFeaturesOrder'; import { DevContainerFeature } from '../../spec-configuration/configuration'; -import { processFeatureIdentifier } from '../../spec-configuration/containerFeaturesConfiguration'; +import { normalizeUserFeatureIdentifier, processFeatureIdentifier } from '../../spec-configuration/containerFeaturesConfiguration'; export function featuresInfoOptions(y: Argv) { return y @@ -102,10 +102,15 @@ async function featuresInfo({ process.exit(1); } - const processFeature = async (_userFeature: DevContainerFeature) => { - return await processFeatureIdentifier(params, undefined, '', _userFeature); + const processFeature = async (f: { userFeature: DevContainerFeature }) => { + return await processFeatureIdentifier(params, undefined, '', f.userFeature); }; - const graph = await buildDependencyGraph(params, processFeature, [{ userFeatureId: featureId, options: {} }], { overrideFeatureInstallOrder: undefined }, undefined); + const userFeature = { + rawUserFeatureId: featureId, + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, featureId), + options: {} + }; + const graph = await buildDependencyGraph(params, processFeature, [userFeature], { overrideFeatureInstallOrder: undefined }, undefined); output.write(JSON.stringify(graph, undefined, 4), LogLevel.Trace); if (!graph) { output.write(`Could not build dependency graph.`, LogLevel.Error); diff --git a/src/spec-node/featuresCLI/resolveDependencies.ts b/src/spec-node/featuresCLI/resolveDependencies.ts index 096f75528..303571d9d 100644 --- a/src/spec-node/featuresCLI/resolveDependencies.ts +++ b/src/spec-node/featuresCLI/resolveDependencies.ts @@ -73,7 +73,7 @@ async function featuresResolveDependencies({ output.write(`No Features object in configuration '${configPath}'`, LogLevel.Error); process.exit(1); } - const userFeaturesConfig = userFeaturesToArray(config); + const userFeaturesConfig = userFeaturesToArray(output, config); if (!userFeaturesConfig) { output.write(`Could not parse features object in configuration '${configPath}'`, LogLevel.Error); process.exit(1); @@ -84,8 +84,8 @@ async function featuresResolveDependencies({ }; const lockfile = await readLockfile(config); - const processFeature = async (_userFeature: DevContainerFeature) => { - return await processFeatureIdentifier(params, configPath, workspaceFolder, _userFeature, lockfile); + const processFeature = async (f: { userFeature: DevContainerFeature }) => { + return await processFeatureIdentifier(params, configPath, workspaceFolder, f.userFeature, lockfile); }; const graph = await buildDependencyGraph(params, processFeature, userFeaturesConfig, config, lockfile); diff --git a/src/spec-node/featuresCLI/testCommandImpl.ts b/src/spec-node/featuresCLI/testCommandImpl.ts index 12b4ee417..89fb1c79d 100644 --- a/src/spec-node/featuresCLI/testCommandImpl.ts +++ b/src/spec-node/featuresCLI/testCommandImpl.ts @@ -553,7 +553,6 @@ async function launchProject(params: DockerResolverParameters, workspaceFolder: logLevel: common.getLogLevel(), mountWorkspaceGitRoot: true, remoteEnv: common.remoteEnv, - skipFeatureAutoMapping: common.skipFeatureAutoMapping, skipPersistingCustomizationsFromFeatures: common.skipPersistingCustomizationsFromFeatures, omitConfigRemotEnvFromMetadata: common.omitConfigRemotEnvFromMetadata, log: text => quiet ? null : process.stderr.write(text), @@ -653,7 +652,6 @@ async function generateDockerParams(workspaceFolder: string, args: FeaturesTestC buildxPlatform: undefined, buildxPush: false, buildxOutput: undefined, - skipFeatureAutoMapping: false, skipPostAttach: false, skipPersistingCustomizationsFromFeatures: false, dotfiles: {} diff --git a/src/test/container-features/containerFeaturesOrder.test.ts b/src/test/container-features/containerFeaturesOrder.test.ts index e8b2cfdd9..8dc54478a 100644 --- a/src/test/container-features/containerFeaturesOrder.test.ts +++ b/src/test/container-features/containerFeaturesOrder.test.ts @@ -28,14 +28,14 @@ async function setupInstallOrderTest(testWorkspaceFolder: string) { const buffer = await readLocalFile(configPath); const config = JSON.parse(buffer.toString()) as DevContainerConfig; - const userFeatures = userFeaturesToArray(config); + const userFeatures = userFeaturesToArray(output, config); if (!userFeatures) { assert.fail(`Test: Could not extract userFeatures from config: ${configPath}`); } - const processFeature = async (_userFeature: DevContainerFeature) => { - return await processFeatureIdentifier(params, configPath, testWorkspaceFolder, _userFeature); + const processFeature = async (f: { userFeature: DevContainerFeature }) => { + return await processFeatureIdentifier(params, configPath, testWorkspaceFolder, f.userFeature); }; return { diff --git a/src/test/container-features/e2e.test.ts b/src/test/container-features/e2e.test.ts index 74d65d4c7..1261c0c6a 100644 --- a/src/test/container-features/e2e.test.ts +++ b/src/test/container-features/e2e.test.ts @@ -116,19 +116,6 @@ describe('Dev Container Features E2E - local cache/short-hand notation', functio await shellExec(`mkdir -p ${tmp}`); await shellExec(`npm --prefix ${tmp} install devcontainers-cli-${pkg.version}.tgz`); }); - - describe(`image-with-v1-features-node-python-local-cache with --skipFeatureAutoMapping`, () => { - let containerId: string | null = null; - const testFolder = `${__dirname}/configs/image-with-v1-features-node-python-local-cache`; - beforeEach(async () => containerId = (await devContainerUp(cli, testFolder, { 'logLevel': 'trace', 'extraArgs': '--skipFeatureAutoMapping' })).containerId); - afterEach(async () => await devContainerDown({ containerId })); - - it('should exec a PATH without the string \'ENV\'', async () => { - const res = await shellExec(`${cli} exec --workspace-folder ${testFolder} echo \${PATH}`); - assert.isNull(res.error); - assert.notMatch(res.stdout, /ENV/); - }); - }); }); diff --git a/src/test/container-features/featureHelpers.test.ts b/src/test/container-features/featureHelpers.test.ts index 86f3543f0..e49eba152 100644 --- a/src/test/container-features/featureHelpers.test.ts +++ b/src/test/container-features/featureHelpers.test.ts @@ -2,7 +2,7 @@ import { assert } from 'chai'; import * as path from 'path'; import { DevContainerConfig, DevContainerFeature } from '../../spec-configuration/configuration'; import { OCIRef } from '../../spec-configuration/containerCollectionsOCI'; -import { Feature, FeatureSet, getBackwardCompatibleFeatureId, getFeatureInstallWrapperScript, processFeatureIdentifier, updateDeprecatedFeaturesIntoOptions } from '../../spec-configuration/containerFeaturesConfiguration'; +import { Feature, FeatureSet, getBackwardCompatibleFeatureId, getFeatureInstallWrapperScript, normalizeUserFeatureIdentifier, processFeatureIdentifier, updateDeprecatedFeaturesIntoOptions } from '../../spec-configuration/containerFeaturesConfiguration'; import { getSafeId, findContainerUsers } from '../../spec-node/containerFeatures'; import { ImageMetadataEntry } from '../../spec-node/imageMetadata'; import { SubstitutedConfig } from '../../spec-node/utils'; @@ -61,7 +61,8 @@ describe('validate processFeatureIdentifier', async function () { it('should process v1 local-cache', async function () { // Parsed out of a user's devcontainer.json let userFeature: DevContainerFeature = { - userFeatureId: 'docker-in-docker', + rawUserFeatureId: 'docker-in-docker', + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, 'docker-in-docker'), options: {} }; const featureSet = await processFeatureIdentifier(params, defaultConfigPath, workspaceRoot, userFeature); @@ -82,7 +83,8 @@ describe('validate processFeatureIdentifier', async function () { it('should process github-repo (without version)', async function () { const userFeature: DevContainerFeature = { - userFeatureId: 'octocat/myfeatures/helloworld', + rawUserFeatureId: 'octocat/myfeatures/helloworld', + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, 'octocat/myfeatures/helloworld'), options: {}, }; const featureSet = await processFeatureIdentifier(params, defaultConfigPath, workspaceRoot, userFeature); @@ -110,7 +112,8 @@ describe('validate processFeatureIdentifier', async function () { it('should process github-repo (with version)', async function () { const userFeature: DevContainerFeature = { - userFeatureId: 'octocat/myfeatures/helloworld@v0.0.4', + rawUserFeatureId: 'octocat/myfeatures/helloworld@v0.0.4', + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, 'octocat/myfeatures/helloworld@v0.0.4'), options: {}, }; const featureSet = await processFeatureIdentifier(params, defaultConfigPath, workspaceRoot, userFeature); @@ -139,7 +142,8 @@ describe('validate processFeatureIdentifier', async function () { it('should process direct-tarball (v2 with direct tar download)', async function () { const userFeature: DevContainerFeature = { - userFeatureId: 'https://example.com/some/long/path/devcontainer-feature-ruby.tgz', + rawUserFeatureId: 'https://example.com/some/long/path/devcontainer-feature-ruby.tgz', + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, 'https://example.com/some/long/path/devcontainer-feature-ruby.tgz'), options: {}, }; @@ -157,7 +161,8 @@ describe('validate processFeatureIdentifier', async function () { it('local-path should parse when provided a relative path with Config file in $WORKSPACE_ROOT/.devcontainer', async function () { const userFeature: DevContainerFeature = { - userFeatureId: './featureA', + rawUserFeatureId: './featureA', + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, './featureA'), options: {}, }; @@ -172,7 +177,8 @@ describe('validate processFeatureIdentifier', async function () { it('local-path should parse when provided relative path with config file in $WORKSPACE_ROOT', async function () { const userFeature: DevContainerFeature = { - userFeatureId: './.devcontainer/featureB', + rawUserFeatureId: './.devcontainer/featureB', + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, './.devcontainer/featureB'), options: {}, }; @@ -187,7 +193,8 @@ describe('validate processFeatureIdentifier', async function () { it('should process oci registry (without tag)', async function () { const userFeature: DevContainerFeature = { - userFeatureId: 'ghcr.io/codspace/features/ruby', + rawUserFeatureId: 'ghcr.io/codspace/features/ruby', + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, 'ghcr.io/codspace/features/ruby'), options: {}, }; @@ -223,7 +230,8 @@ describe('validate processFeatureIdentifier', async function () { it('should process oci registry (with a digest)', async function () { const userFeature: DevContainerFeature = { - userFeatureId: 'ghcr.io/devcontainers/features/ruby@sha256:4ef08c9c3b708f3c2faecc5a898b39736423dd639f09f2a9f8bf9b0b9252ef0a', + rawUserFeatureId: 'ghcr.io/devcontainers/features/ruby@sha256:4ef08c9c3b708f3c2faecc5a898b39736423dd639f09f2a9f8bf9b0b9252ef0a', + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, 'ghcr.io/devcontainers/features/ruby@sha256:4ef08c9c3b708f3c2faecc5a898b39736423dd639f09f2a9f8bf9b0b9252ef0a'), options: {}, }; @@ -259,7 +267,8 @@ describe('validate processFeatureIdentifier', async function () { it('should process oci registry (with a tag)', async function () { const userFeature: DevContainerFeature = { - userFeatureId: 'ghcr.io/codspace/features/ruby:1.0.13', + rawUserFeatureId: 'ghcr.io/codspace/features/ruby:1.0.13', + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, 'ghcr.io/codspace/features/ruby:1.0.13'), options: {}, }; @@ -297,7 +306,8 @@ describe('validate processFeatureIdentifier', async function () { describe('INVALID processFeatureIdentifier examples', async function () { it('local-path should fail to parse when provided absolute path and defaultConfigPath with a .devcontainer', async function () { const userFeature: DevContainerFeature = { - userFeatureId: '/some/long/path/to/helloworld', + rawUserFeatureId: '/some/long/path/to/helloworld', + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, '/some/long/path/to/helloworld'), options: {}, }; @@ -309,7 +319,8 @@ describe('validate processFeatureIdentifier', async function () { it('local-path should fail to parse when provided an absolute path and defaultConfigPath without a .devcontainer', async function () { const userFeature: DevContainerFeature = { - userFeatureId: '/some/long/path/to/helloworld', + rawUserFeatureId: '/some/long/path/to/helloworld', + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, '/some/long/path/to/helloworld'), options: {}, }; @@ -321,7 +332,8 @@ describe('validate processFeatureIdentifier', async function () { it('local-path should fail to parse when provided an a relative path breaking out of the .devcontainer folder', async function () { const userFeature: DevContainerFeature = { - userFeatureId: '../featureC', + rawUserFeatureId: '../featureC', + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, '../featureC'), options: {}, }; @@ -333,7 +345,8 @@ describe('validate processFeatureIdentifier', async function () { it('should fail parsing a generic tar with no feature and trailing slash', async function () { const userFeature: DevContainerFeature = { - userFeatureId: 'https://example.com/some/long/path/devcontainer-features.tgz/', + rawUserFeatureId: 'https://example.com/some/long/path/devcontainer-features.tgz/', + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, 'https://example.com/some/long/path/devcontainer-features.tgz/'), options: {}, }; @@ -343,7 +356,8 @@ describe('validate processFeatureIdentifier', async function () { it('should not parse gitHub without triple slash', async function () { const userFeature: DevContainerFeature = { - userFeatureId: 'octocat/myfeatures#helloworld', + rawUserFeatureId: 'octocat/myfeatures#helloworld', + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, 'octocat/myfeatures#helloworld'), options: {}, }; @@ -353,7 +367,8 @@ describe('validate processFeatureIdentifier', async function () { it('should fail parsing a generic tar with no feature and no trailing slash', async function () { const userFeature: DevContainerFeature = { - userFeatureId: 'https://example.com/some/long/path/devcontainer-features.tgz', + rawUserFeatureId: 'https://example.com/some/long/path/devcontainer-features.tgz', + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, 'https://example.com/some/long/path/devcontainer-features.tgz'), options: {}, }; @@ -363,7 +378,8 @@ describe('validate processFeatureIdentifier', async function () { it('should fail parsing a generic tar with a hash but no feature', async function () { const userFeature: DevContainerFeature = { - userFeatureId: 'https://example.com/some/long/path/devcontainer-features.tgz#', + rawUserFeatureId: 'https://example.com/some/long/path/devcontainer-features.tgz#', + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, 'https://example.com/some/long/path/devcontainer-features.tgz#'), options: {}, }; @@ -373,7 +389,8 @@ describe('validate processFeatureIdentifier', async function () { it('should fail parsing a marketplace shorthand with only two segments and a hash with no feature', async function () { const userFeature: DevContainerFeature = { - userFeatureId: 'octocat/myfeatures#', + rawUserFeatureId: 'octocat/myfeatures#', + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, 'octocat/myfeatures#'), options: {}, }; @@ -383,7 +400,8 @@ describe('validate processFeatureIdentifier', async function () { it('should fail parsing a marketplace shorthand with only two segments (no feature)', async function () { const userFeature: DevContainerFeature = { - userFeatureId: 'octocat/myfeatures', + rawUserFeatureId: 'octocat/myfeatures', + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, 'octocat/myfeatures'), options: {}, }; @@ -393,7 +411,8 @@ describe('validate processFeatureIdentifier', async function () { it('should fail parsing a marketplace shorthand with an invalid feature name (1)', async function () { const userFeature: DevContainerFeature = { - userFeatureId: 'octocat/myfeatures/@mycoolfeature', + rawUserFeatureId: 'octocat/myfeatures/@mycoolfeature', + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, 'octocat/myfeatures/@mycoolfeature'), options: {}, }; @@ -403,7 +422,8 @@ describe('validate processFeatureIdentifier', async function () { it('should fail parsing a marketplace shorthand with an invalid feature name (2)', async function () { const userFeature: DevContainerFeature = { - userFeatureId: 'octocat/myfeatures/MY_$UPER_COOL_FEATURE', + rawUserFeatureId: 'octocat/myfeatures/MY_$UPER_COOL_FEATURE', + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, 'octocat/myfeatures/MY_$UPER_COOL_FEATURE'), options: {}, }; @@ -413,7 +433,8 @@ describe('validate processFeatureIdentifier', async function () { it('should fail parsing a marketplace shorthand with only two segments, no hash, and with a version', async function () { const userFeature: DevContainerFeature = { - userFeatureId: 'octocat/myfeatures@v0.0.1', + rawUserFeatureId: 'octocat/myfeatures@v0.0.1', + normalizedUserFeatureId: normalizeUserFeatureIdentifier(output, 'octocat/myfeatures@v0.0.1'), options: {}, }; @@ -491,7 +512,7 @@ describe('validate function updateDeprecatedFeaturesIntoOptions', () => { it('should add feature with option', () => { const updated = updateDeprecatedFeaturesIntoOptions([ { - userFeatureId: 'jupyterlab', + rawUserFeatureId: 'jupyterlab', options: {} } ], nullLog); @@ -500,7 +521,7 @@ describe('validate function updateDeprecatedFeaturesIntoOptions', () => { } assert.strictEqual(updated.length, 1); - assert.strictEqual(updated[0].userFeatureId, 'ghcr.io/devcontainers/features/python:1'); + assert.strictEqual(updated[0].rawUserFeatureId, 'ghcr.io/devcontainers/features/python:1'); assert.ok(updated[0].options); assert.strictEqual(typeof updated[0].options, 'object'); assert.strictEqual((updated[0].options as Record)['installJupyterlab'], true); @@ -509,11 +530,11 @@ describe('validate function updateDeprecatedFeaturesIntoOptions', () => { it('should update feature with option', () => { const updated = updateDeprecatedFeaturesIntoOptions([ { - userFeatureId: 'ghcr.io/devcontainers/features/python:1', + rawUserFeatureId: 'ghcr.io/devcontainers/features/python:1', options: {} }, { - userFeatureId: 'jupyterlab', + rawUserFeatureId: 'jupyterlab', options: {} } ], nullLog); @@ -522,7 +543,7 @@ describe('validate function updateDeprecatedFeaturesIntoOptions', () => { } assert.strictEqual(updated.length, 1); - assert.strictEqual(updated[0].userFeatureId, 'ghcr.io/devcontainers/features/python:1'); + assert.strictEqual(updated[0].rawUserFeatureId, 'ghcr.io/devcontainers/features/python:1'); assert.ok(updated[0].options); assert.strictEqual(typeof updated[0].options, 'object'); assert.strictEqual((updated[0].options as Record)['installJupyterlab'], true); @@ -531,11 +552,11 @@ describe('validate function updateDeprecatedFeaturesIntoOptions', () => { it('should update legacy feature with option', () => { const updated = updateDeprecatedFeaturesIntoOptions([ { - userFeatureId: 'python', + rawUserFeatureId: 'python', options: {} }, { - userFeatureId: 'jupyterlab', + rawUserFeatureId: 'jupyterlab', options: {} } ], nullLog); @@ -543,7 +564,7 @@ describe('validate function updateDeprecatedFeaturesIntoOptions', () => { assert.fail('updated is null'); } assert.strictEqual(updated.length, 1); - assert.strictEqual(updated[0].userFeatureId, 'python'); + assert.strictEqual(updated[0].rawUserFeatureId, 'python'); assert.ok(updated[0].options); assert.strictEqual(typeof updated[0].options, 'object'); assert.strictEqual((updated[0].options as Record)['installJupyterlab'], true); diff --git a/src/test/container-features/generateFeaturesConfig.test.ts b/src/test/container-features/generateFeaturesConfig.test.ts index 480a17cb4..4c5c2aa56 100644 --- a/src/test/container-features/generateFeaturesConfig.test.ts +++ b/src/test/container-features/generateFeaturesConfig.test.ts @@ -20,7 +20,7 @@ describe('validate generateFeaturesConfig()', function () { const env = { 'SOME_KEY': 'SOME_VAL' }; const platform = process.platform; const cacheFolder = path.join(os.tmpdir(), `devcontainercli-test-${crypto.randomUUID()}`); - const params = { extensionPath: '', cwd: '', output, env, cacheFolder, persistedFolder: '', skipFeatureAutoMapping: false, platform }; + const params = { extensionPath: '', cwd: '', output, env, cacheFolder, persistedFolder: '', platform }; // Mocha executes with the root of the project as the cwd. const localFeaturesFolder = (_: string) => { @@ -183,8 +183,6 @@ RUN chmod -R 0755 /tmp/dev-container-features/hello_1 \\ }, }; - params.skipFeatureAutoMapping = true; - const featuresConfig = await generateFeaturesConfig(params, tmpFolder, config, getContainerFeaturesFolder, {}); if (!featuresConfig) { assert.fail();