diff --git a/src/spec-configuration/containerFeaturesConfiguration.ts b/src/spec-configuration/containerFeaturesConfiguration.ts index e0094202a..4e1c2f05c 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -71,10 +71,16 @@ export interface Mount { external?: boolean; } +const normalizedMountKeys: Record = { + src: 'source', + destination: 'target', + dst: 'target', +}; + export function parseMount(str: string): Mount { return str.split(',') .map(s => s.split('=')) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}) as Mount; + .reduce((acc, [key, value]) => ({ ...acc, [(normalizedMountKeys[key] || key)]: value }), {}) as Mount; } export type SourceInformation = LocalCacheSourceInformation | GithubSourceInformation | DirectTarballSourceInformation | FilePathSourceInformation | OCISourceInformation; diff --git a/src/spec-node/imageMetadata.ts b/src/spec-node/imageMetadata.ts index ba7a42908..37de895fd 100644 --- a/src/spec-node/imageMetadata.ts +++ b/src/spec-node/imageMetadata.ts @@ -5,7 +5,7 @@ import { ContainerError } from '../spec-common/errors'; import { DevContainerConfig, DevContainerConfigCommand, DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, DevContainerFromImageConfig, getDockerComposeFilePaths, getDockerfilePath, HostRequirements, isDockerFileConfig, PortAttributes, UserEnvProbe } from '../spec-configuration/configuration'; -import { Feature, FeaturesConfig, Mount } from '../spec-configuration/containerFeaturesConfiguration'; +import { Feature, FeaturesConfig, Mount, parseMount } from '../spec-configuration/containerFeaturesConfiguration'; import { ContainerDetails, DockerCLIParameters, ImageDetails } from '../spec-shutdown/dockerUtils'; import { Log } from '../spec-utils/log'; import { getBuildInfoForService, readDockerComposeConfig } from './dockerCompose'; @@ -125,7 +125,7 @@ export function mergeConfiguration(config: DevContainerConfig, imageMetadata: Im capAdd: unionOrUndefined(imageMetadata.map(entry => entry.capAdd)), securityOpt: unionOrUndefined(imageMetadata.map(entry => entry.securityOpt)), entrypoints: collectOrUndefined(imageMetadata, 'entrypoint'), - mounts: concatOrUndefined(imageMetadata.map(entry => entry.mounts)), + mounts: mergeMounts(imageMetadata), customizations: Object.keys(customizations).length ? customizations : undefined, onCreateCommands: collectOrUndefined(imageMetadata, 'onCreateCommand'), updateContentCommands: collectOrUndefined(imageMetadata, 'updateContentCommand'), @@ -181,9 +181,20 @@ function parseBytes(str: string) { return 0; } -function concatOrUndefined(entries: (T[] | undefined)[]): T[] | undefined { - const values = ([] as T[]).concat(...entries.filter(entry => !!entry) as T[][]); - return values.length ? values : undefined; +function mergeMounts(imageMetadata: ImageMetadataEntry[]): (Mount | string)[] | undefined { + const seen = new Set(); + const mounts = imageMetadata.map(entry => entry.mounts) + .filter(Boolean) + .flat() + .map(mount => ({ + obj: typeof mount === 'string' ? parseMount(mount) : mount!, + orig: mount!, + })) + .reverse() + .filter(mount => !seen.has(mount.obj.target) && seen.add(mount.obj.target)) + .reverse() + .map(mount => mount.orig); + return mounts.length ? mounts : undefined; } function unionOrUndefined(entries: (T[] | undefined)[]): T[] | undefined { diff --git a/src/test/imageMetadata.test.ts b/src/test/imageMetadata.test.ts index 544478330..4dc8899c2 100644 --- a/src/test/imageMetadata.test.ts +++ b/src/test/imageMetadata.test.ts @@ -6,7 +6,7 @@ import * as assert from 'assert'; import * as path from 'path'; import { URI } from 'vscode-uri'; -import { Feature, FeaturesConfig, FeatureSet } from '../spec-configuration/containerFeaturesConfiguration'; +import { Feature, FeaturesConfig, FeatureSet, Mount } from '../spec-configuration/containerFeaturesConfiguration'; import { experimentalImageMetadataDefault } from '../spec-node/devContainers'; import { getDevcontainerMetadata, getDevcontainerMetadataLabel, getImageMetadata, getImageMetadataFromContainer, imageMetadataLabel, mergeConfiguration } from '../spec-node/imageMetadata'; import { SubstitutedConfig } from '../spec-node/utils'; @@ -244,6 +244,46 @@ describe('Image Metadata', function () { assert.strictEqual(merged.remoteEnv?.ENV3, 'devcontainer.json'); assert.strictEqual(merged.remoteEnv?.ENV4, 'feature1'); }); + + it('should deduplicate mounts', () => { + const merged = mergeConfiguration({ + configFilePath: URI.parse('file:///devcontainer.json'), + image: 'image', + }, [ + { + mounts: [ + 'source=source1,dst=target1,type=volume', + 'source=source2,target=target2,type=volume', + 'source=source3,destination=target3,type=volume', + ], + }, + { + mounts: [ + { + source: 'source4', + target: 'target1', + type: 'volume' + }, + ], + }, + { + mounts: [ + { + source: 'source5', + target: 'target3', + type: 'volume' + }, + ], + }, + ]); + assert.strictEqual(merged.mounts?.length, 3); + assert.strictEqual(typeof merged.mounts?.[0], 'string'); + assert.strictEqual(merged.mounts?.[0], 'source=source2,target=target2,type=volume'); + assert.strictEqual(typeof merged.mounts?.[1], 'object'); + assert.strictEqual((merged.mounts?.[1] as Mount).source, 'source4'); + assert.strictEqual(typeof merged.mounts?.[2], 'object'); + assert.strictEqual((merged.mounts?.[2] as Mount).source, 'source5'); + }); }); });