diff --git a/src/spec-configuration/containerFeaturesConfiguration.ts b/src/spec-configuration/containerFeaturesConfiguration.ts index 38d93a1a8..10b79235a 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -119,6 +119,22 @@ export function parseMount(str: string): Mount { .reduce((acc, [key, value]) => ({ ...acc, [(normalizedMountKeys[key] || key)]: value }), {}) as Mount; } +const mountArgRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,external=(true|false))?$/; + +export function parseMountArg(str: string): Mount | string { + const match = mountArgRegex.exec(str); + if (!match) { + return str; + } + const [, type, source, target, external] = match; + return { + type: type as Mount['type'], + source, + target, + external: external === 'true', + }; +} + export type SourceInformation = GithubSourceInformation | DirectTarballSourceInformation | FilePathSourceInformation | OCISourceInformation; interface BaseSourceInformation { diff --git a/src/spec-node/devContainers.ts b/src/spec-node/devContainers.ts index 11ab285c3..b30797aac 100644 --- a/src/spec-node/devContainers.ts +++ b/src/spec-node/devContainers.ts @@ -48,7 +48,7 @@ export interface ProvisionOptions { skipNonBlocking: boolean; prebuild: boolean; persistedFolder: string | undefined; - additionalMounts: Mount[]; + additionalMounts: (Mount | string)[]; updateRemoteUserUIDDefault: UpdateRemoteUserUIDDefault; remoteEnv: Record; additionalCacheFroms: string[]; diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 221f9e3fa..8897c980d 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -24,7 +24,7 @@ import { readDevContainerConfigFile } from './configContainer'; import { getDefaultDevContainerConfigPath, getDevContainerConfigPathIn, uriToFsPath } from '../spec-configuration/configurationCommonUtils'; import { CLIHost, getCLIHost } from '../spec-common/cliHost'; import { loadNativeModule, processSignals } from '../spec-common/commonUtils'; -import { loadVersionInfo } from '../spec-configuration/containerFeaturesConfiguration'; +import { loadVersionInfo, parseMountArg } from '../spec-configuration/containerFeaturesConfiguration'; import { featuresTestOptions, featuresTestHandler } from './featuresCLI/test'; import { featuresPackageHandler, featuresPackageOptions } from './featuresCLI/package'; import { featuresPublishHandler, featuresPublishOptions } from './featuresCLI/publish'; @@ -50,8 +50,6 @@ import { parseCrossOriginAuthHosts } from '../spec-configuration/httpOCIRegistry const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell'; -const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,external=(true|false))?$/; - (async () => { const packageFolder = path.join(__dirname, '..', '..'); @@ -157,7 +155,7 @@ function provisionOptions(y: Argv) { 'skip-non-blocking-commands': { type: 'boolean', default: false, description: 'Stop running user commands after running the command configured with waitFor or the updateContentCommand by default.' }, prebuild: { type: 'boolean', default: false, description: 'Stop after onCreateCommand and updateContentCommand, rerunning updateContentCommand if it has run before.' }, 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, - 'mount': { type: 'string', description: 'Additional mount point(s). Format: type=,source=,target=[,external=]' }, + 'mount': { type: 'string', description: 'Additional mount point(s). Format: type=,source=,target=[,external=]. Any other value is passed as is to the --mount option of the container runtime (not supported with Docker Compose configurations).' }, 'remote-env': { type: 'string', description: 'Remote environment variables of the format name=value. These will be added when executing the user commands.' }, 'cache-from': { type: 'string', description: 'Additional image to use as potential layer cache during image building' }, 'cache-to': { type: 'string', description: 'Additional image to use as potential layer cache during image building' }, @@ -188,10 +186,6 @@ function provisionOptions(y: Argv) { if (!argv['workspace-folder'] && !argv['id-label'] && !argv['override-config']) { argv['workspace-folder'] = process.cwd(); } - const mounts = (argv.mount && (Array.isArray(argv.mount) ? argv.mount : [argv.mount])) as string[] | undefined; - if (mounts?.some(mount => !mountRegex.test(mount))) { - throw new Error('Unmatched argument format: mount must match type=,source=,target=[,external=]'); - } const remoteEnvs = (argv['remote-env'] && (Array.isArray(argv['remote-env']) ? argv['remote-env'] : [argv['remote-env']])) as string[] | undefined; if (remoteEnvs?.some(remoteEnv => !/.+=.*/.test(remoteEnv))) { throw new Error('Unmatched argument format: remote-env must match ='); @@ -305,15 +299,7 @@ async function provision({ skipNonBlocking, prebuild, persistedFolder, - additionalMounts: mount ? (Array.isArray(mount) ? mount : [mount]).map(mount => { - const [, type, source, target, external] = mountRegex.exec(mount)!; - return { - type: type as 'bind' | 'volume', - source, - target, - external: external === 'true' - }; - }) : [], + additionalMounts: mount ? (Array.isArray(mount) ? mount : [mount]).map(parseMountArg) : [], dotfiles: { repository: dotfilesRepository, installCommand: dotfilesInstallCommand, diff --git a/src/spec-node/dockerCompose.ts b/src/spec-node/dockerCompose.ts index 410f7636d..fb24ade6b 100644 --- a/src/spec-node/dockerCompose.ts +++ b/src/spec-node/dockerCompose.ts @@ -475,7 +475,7 @@ async function writeFeaturesComposeOverrideFile( imageDetails: () => Promise, service: any, additionalLabels: string[], - additionalMounts: Mount[], + additionalMounts: (Mount | string)[], overrideFilePath: string, overrideFilePrefix: string, buildCLIHost: CLIHost, @@ -510,7 +510,7 @@ async function generateFeaturesComposeOverrideContent( imageDetails: () => Promise, service: any, additionalLabels: string[], - additionalMounts: Mount[], + additionalMounts: (Mount | string)[], params: DockerResolverParameters, ) { const overrideImage = updatedImageName !== originalImageName; @@ -519,6 +519,10 @@ async function generateFeaturesComposeOverrideContent( const env = mergedConfig.containerEnv || {}; const capAdd = mergedConfig.capAdd || []; const securityOpts = mergedConfig.securityOpt || []; + const unsupportedMount = additionalMounts.find(m => typeof m === 'string'); + if (unsupportedMount) { + throw new ContainerError({ description: `--mount "${unsupportedMount}" is not supported with Docker Compose configurations. Use type=,source=,target=[,external=].` }); + } const mounts = [ ...mergedConfig.mounts || [], ...additionalMounts, diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index 88e2dccc8..18ca31179 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -132,7 +132,7 @@ export interface DockerResolverParameters { buildNoCache?: boolean; expectExistingContainer?: boolean; userRepositoryConfigurationPaths: string[]; - additionalMounts: Mount[]; + additionalMounts: (Mount | string)[]; updateRemoteUserUIDDefault: UpdateRemoteUserUIDDefault; additionalCacheFroms: string[]; buildKitVersion: { versionString: string; versionMatch?: string } | undefined; diff --git a/src/test/mountArg.test.ts b/src/test/mountArg.test.ts new file mode 100644 index 000000000..0d53b5ba8 --- /dev/null +++ b/src/test/mountArg.test.ts @@ -0,0 +1,19 @@ +import { assert } from 'chai'; +import { parseMountArg } from '../spec-configuration/containerFeaturesConfiguration'; + +describe('parseMountArg', () => { + + it('parses the bind/volume form into a mount object', () => { + assert.deepEqual(parseMountArg('type=volume,source=cache,target=/cache,external=true'), { + type: 'volume', + source: 'cache', + target: '/cache', + external: true, + }); + }); + + it('passes any other mount through to the container runtime unchanged', () => { + const mount = 'type=image,source=localhost/devtools:latest,target=/nix,subpath=/nix'; + assert.strictEqual(parseMountArg(mount), mount); + }); +});