diff --git a/.gitignore b/.gitignore index b954d43f1..2c69dd1c6 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ build-tmp .DS_Store .env output +*.testMarker +src/test/container-features/configs/temp_lifecycle-hooks-alternative-order diff --git a/src/spec-common/injectHeadless.ts b/src/spec-common/injectHeadless.ts index e5b786236..e9198c470 100644 --- a/src/spec-common/injectHeadless.ts +++ b/src/spec-common/injectHeadless.ts @@ -49,7 +49,7 @@ export interface ResolverParameters { output: Log; allowSystemConfigChange: boolean; defaultUserEnvProbe: UserEnvProbe; - postCreate: PostCreate; + lifecycleHook: LifecycleHook; getLogLevel: () => LogLevel; onDidChangeLogLevel: Event; loadNativeModule: (moduleName: string) => Promise; @@ -67,7 +67,7 @@ export interface ResolverParameters { skipPersistingCustomizationsFromFeatures: boolean; } -export interface PostCreate { +export interface LifecycleHook { enabled: boolean; skipNonBlocking: boolean; output: Log; @@ -75,7 +75,14 @@ export interface PostCreate { done: () => void; } -export function createNullPostCreate(enabled: boolean, skipNonBlocking: boolean, output: Log): PostCreate { +export type LifecycleHooksInstallMap = { + [lifecycleHook in DevContainerLifecycleHook]: { + command: LifecycleCommand; + origin: string; + }[]; // In installation order. +}; + +export function createNullLifecycleHook(enabled: boolean, skipNonBlocking: boolean, output: Log): LifecycleHook { function listener(data: Buffer) { emitter.fire(data.toString()); } @@ -110,11 +117,11 @@ export interface PortAttributes { export type UserEnvProbe = 'none' | 'loginInteractiveShell' | 'interactiveShell' | 'loginShell'; -export type DevContainerConfigCommand = 'initializeCommand' | 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand' | 'postStartCommand' | 'postAttachCommand'; +export type DevContainerLifecycleHook = 'initializeCommand' | 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand' | 'postStartCommand' | 'postAttachCommand'; -const defaultWaitFor: DevContainerConfigCommand = 'updateContentCommand'; +const defaultWaitFor: DevContainerLifecycleHook = 'updateContentCommand'; -type LifecycleCommand = string | string[]; +export type LifecycleCommand = string | string[] | { [key: string]: string | string[] }; export interface CommonDevContainerConfig { configFilePath?: URI; @@ -128,7 +135,7 @@ export interface CommonDevContainerConfig { postCreateCommand?: LifecycleCommand | Record; postStartCommand?: LifecycleCommand | Record; postAttachCommand?: LifecycleCommand | Record; - waitFor?: DevContainerConfigCommand; + waitFor?: DevContainerLifecycleHook; userEnvProbe?: UserEnvProbe; } @@ -138,7 +145,7 @@ export interface CommonContainerMetadata { postCreateCommand?: string | string[]; postStartCommand?: string | string[]; postAttachCommand?: string | string[]; - waitFor?: DevContainerConfigCommand; + waitFor?: DevContainerLifecycleHook; remoteEnv?: Record; userEnvProbe?: UserEnvProbe; } @@ -156,11 +163,11 @@ const replaceProperties = [ ] as const; interface UpdatedConfigProperties { - onCreateCommands?: (string | string[])[]; - updateContentCommands?: (string | string[])[]; - postCreateCommands?: (string | string[])[]; - postStartCommands?: (string | string[])[]; - postAttachCommands?: (string | string[])[]; + onCreateCommands?: LifecycleCommand[]; + updateContentCommands?: LifecycleCommand[]; + postCreateCommands?: LifecycleCommand[]; + postStartCommands?: LifecycleCommand[]; + postAttachCommands?: LifecycleCommand[]; } export interface OSRelease { @@ -306,14 +313,14 @@ export function getSystemVarFolder(params: ResolverParameters): string { return params.containerSystemDataFolder || '/var/devcontainer'; } -export async function setupInContainer(params: ResolverParameters, containerProperties: ContainerProperties, config: CommonMergedDevContainerConfig) { +export async function setupInContainer(params: ResolverParameters, containerProperties: ContainerProperties, config: CommonMergedDevContainerConfig, lifecycleCommandOriginMap: LifecycleHooksInstallMap) { await patchEtcEnvironment(params, containerProperties); await patchEtcProfile(params, containerProperties); - const computeRemoteEnv = params.computeExtensionHostEnv || params.postCreate.enabled; + const computeRemoteEnv = params.computeExtensionHostEnv || params.lifecycleHook.enabled; const updatedConfig = containerSubstitute(params.cliHost.platform, config.configFilePath, containerProperties.env, config); const remoteEnv = computeRemoteEnv ? probeRemoteEnv(params, containerProperties, updatedConfig) : Promise.resolve({}); - if (params.postCreate.enabled) { - await runPostCreateCommands(params, containerProperties, updatedConfig, remoteEnv, false); + if (params.lifecycleHook.enabled) { + await runLifecycleHooks(params, lifecycleCommandOriginMap, containerProperties, updatedConfig, remoteEnv, false); } return { remoteEnv: params.computeExtensionHostEnv ? await remoteEnv : {}, @@ -329,19 +336,21 @@ export function probeRemoteEnv(params: ResolverParameters, containerProperties: } as Record)); } -export async function runPostCreateCommands(params: ResolverParameters, containerProperties: ContainerProperties, config: CommonMergedDevContainerConfig, remoteEnv: Promise>, stopForPersonalization: boolean): Promise<'skipNonBlocking' | 'prebuild' | 'stopForPersonalization' | 'done'> { - const skipNonBlocking = params.postCreate.skipNonBlocking; +export async function runLifecycleHooks(params: ResolverParameters, lifecycleHooksInstallMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, config: CommonMergedDevContainerConfig, remoteEnv: Promise>, stopForPersonalization: boolean): Promise<'skipNonBlocking' | 'prebuild' | 'stopForPersonalization' | 'done'> { + const skipNonBlocking = params.lifecycleHook.skipNonBlocking; const waitFor = config.waitFor || defaultWaitFor; if (skipNonBlocking && waitFor === 'initializeCommand') { return 'skipNonBlocking'; } - await runPostCreateCommand(params, containerProperties, config, 'onCreateCommand', remoteEnv, false); + params.output.write('LifecycleCommandExecutionMap: ' + JSON.stringify(lifecycleHooksInstallMap, undefined, 4), LogLevel.Trace); + + await runPostCreateCommand(params, lifecycleHooksInstallMap, containerProperties, 'onCreateCommand', remoteEnv, false); if (skipNonBlocking && waitFor === 'onCreateCommand') { return 'skipNonBlocking'; } - await runPostCreateCommand(params, containerProperties, config, 'updateContentCommand', remoteEnv, !!params.prebuild); + await runPostCreateCommand(params, lifecycleHooksInstallMap, containerProperties, 'updateContentCommand', remoteEnv, !!params.prebuild); if (skipNonBlocking && waitFor === 'updateContentCommand') { return 'skipNonBlocking'; } @@ -350,7 +359,7 @@ export async function runPostCreateCommands(params: ResolverParameters, containe return 'prebuild'; } - await runPostCreateCommand(params, containerProperties, config, 'postCreateCommand', remoteEnv, false); + await runPostCreateCommand(params, lifecycleHooksInstallMap, containerProperties, 'postCreateCommand', remoteEnv, false); if (skipNonBlocking && waitFor === 'postCreateCommand') { return 'skipNonBlocking'; } @@ -363,13 +372,13 @@ export async function runPostCreateCommands(params: ResolverParameters, containe return 'stopForPersonalization'; } - await runPostStartCommand(params, containerProperties, config, remoteEnv); + await runPostStartCommand(params, lifecycleHooksInstallMap, containerProperties, remoteEnv); if (skipNonBlocking && waitFor === 'postStartCommand') { return 'skipNonBlocking'; } if (!params.skipPostAttach) { - await runPostAttachCommand(params, containerProperties, config, remoteEnv); + await runPostAttachCommand(params, lifecycleHooksInstallMap, containerProperties, remoteEnv); } return 'done'; } @@ -390,16 +399,16 @@ export async function getOSRelease(shellServer: ShellServer) { return { hardware, id, version }; } -async function runPostCreateCommand(params: ResolverParameters, containerProperties: ContainerProperties, config: CommonMergedDevContainerConfig, postCommandName: 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand', remoteEnv: Promise>, rerun: boolean) { +async function runPostCreateCommand(params: ResolverParameters, lifecycleCommandOriginMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, postCommandName: 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand', remoteEnv: Promise>, rerun: boolean) { const markerFile = path.posix.join(containerProperties.userDataFolder, `.${postCommandName}Marker`); const doRun = !!containerProperties.createdAt && await updateMarkerFile(containerProperties.shellServer, markerFile, containerProperties.createdAt) || rerun; - await runPostCommands(params, containerProperties, config, postCommandName, remoteEnv, doRun); + await runLifecycleCommands(params, lifecycleCommandOriginMap, containerProperties, postCommandName, remoteEnv, doRun); } -async function runPostStartCommand(params: ResolverParameters, containerProperties: ContainerProperties, config: CommonMergedDevContainerConfig, remoteEnv: Promise>) { +async function runPostStartCommand(params: ResolverParameters, lifecycleCommandOriginMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, remoteEnv: Promise>) { const markerFile = path.posix.join(containerProperties.userDataFolder, '.postStartCommandMarker'); const doRun = !!containerProperties.startedAt && await updateMarkerFile(containerProperties.shellServer, markerFile, containerProperties.startedAt); - await runPostCommands(params, containerProperties, config, 'postStartCommand', remoteEnv, doRun); + await runLifecycleCommands(params, lifecycleCommandOriginMap, containerProperties, 'postStartCommand', remoteEnv, doRun); } async function updateMarkerFile(shellServer: ShellServer, location: string, content: string) { @@ -411,25 +420,34 @@ async function updateMarkerFile(shellServer: ShellServer, location: string, cont } } -async function runPostAttachCommand(params: ResolverParameters, containerProperties: ContainerProperties, config: CommonMergedDevContainerConfig, remoteEnv: Promise>) { - await runPostCommands(params, containerProperties, config, 'postAttachCommand', remoteEnv, true); +async function runPostAttachCommand(params: ResolverParameters, lifecycleCommandOriginMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, remoteEnv: Promise>) { + await runLifecycleCommands(params, lifecycleCommandOriginMap, containerProperties, 'postAttachCommand', remoteEnv, true); } -async function runPostCommands(params: ResolverParameters, containerProperties: ContainerProperties, config: CommonMergedDevContainerConfig, postCommandName: 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand' | 'postStartCommand' | 'postAttachCommand', remoteEnv: Promise>, doRun: boolean) { - return Promise.all((config[`${postCommandName}s`] || []).map(config => runPostCommand(params, containerProperties, config, postCommandName, remoteEnv, doRun))); + +async function runLifecycleCommands(params: ResolverParameters, lifecycleCommandOriginMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, lifecycleHookName: 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand' | 'postStartCommand' | 'postAttachCommand', remoteEnv: Promise>, doRun: boolean) { + const commandsForHook = lifecycleCommandOriginMap[lifecycleHookName]; + if (commandsForHook.length === 0) { + return; + } + + for (const { command, origin } of commandsForHook) { + const displayOrigin = origin ? (origin === 'devcontainer.json' ? origin : `Feature '${origin}'`) : '???'; /// '???' should never happen. + await runLifecycleCommand(params, containerProperties, command, displayOrigin, lifecycleHookName, remoteEnv, doRun); + } } -async function runPostCommand({ postCreate }: ResolverParameters, containerProperties: ContainerProperties, postCommand: string | string[], postCommandName: 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand' | 'postStartCommand' | 'postAttachCommand', remoteEnv: Promise>, doRun: boolean) { +async function runLifecycleCommand({ lifecycleHook: postCreate }: ResolverParameters, containerProperties: ContainerProperties, userCommand: LifecycleCommand, userCommandOrigin: string, lifecycleHookName: 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand' | 'postStartCommand' | 'postAttachCommand', remoteEnv: Promise>, doRun: boolean) { let hasCommand = false; - if (typeof postCommand === 'string') { - hasCommand = postCommand.trim().length > 0; - } else if (Array.isArray(postCommand)) { - hasCommand = postCommand.length > 0; - } else if (typeof postCommand === 'object') { - hasCommand = Object.keys(postCommand).length > 0; - } - if (doRun && postCommand && hasCommand) { - const progressName = `Running ${postCommandName}...`; + if (typeof userCommand === 'string') { + hasCommand = userCommand.trim().length > 0; + } else if (Array.isArray(userCommand)) { + hasCommand = userCommand.length > 0; + } else if (typeof userCommand === 'object') { + hasCommand = Object.keys(userCommand).length > 0; + } + if (doRun && userCommand && hasCommand) { + const progressName = `Running ${lifecycleHookName}...`; const infoOutput = makeLog({ event(e: LogEvent) { postCreate.output.event(e); @@ -472,8 +490,9 @@ async function runPostCommand({ postCreate }: ResolverParameters, containerPrope const printMode = name ? 'off' : 'continuous'; const { cmdOutput } = await runRemoteCommand({ ...postCreate, output: infoOutput }, containerProperties, typeof postCommand === 'string' ? ['/bin/sh', '-c', postCommand] : postCommand, remoteCwd, { remoteEnv: await remoteEnv, print: printMode }); + // 'name' is set when parallel execution syntax is used. if (name) { - infoOutput.raw(`\x1b[1mRunning ${name} from devcontainer.json...\x1b[0m\r\n${cmdOutput}\r\n`); + infoOutput.raw(`\x1b[1mRunning ${name} from ${userCommandOrigin}...\x1b[0m\r\n${cmdOutput}\r\n`); } infoOutput.event({ @@ -483,14 +502,14 @@ async function runPostCommand({ postCreate }: ResolverParameters, containerPrope }); } - infoOutput.raw(`\x1b[1mRunning the ${postCommandName} from devcontainer.json...\x1b[0m\r\n\r\n`); + infoOutput.raw(`\x1b[1mRunning the ${lifecycleHookName} from ${userCommandOrigin}...\x1b[0m\r\n\r\n`); let commands; - if (typeof postCommand === 'string' || Array.isArray(postCommand)) { - commands = [runSingleCommand(postCommand)]; + if (typeof userCommand === 'string' || Array.isArray(userCommand)) { + commands = [runSingleCommand(userCommand)]; } else { - commands = Object.keys(postCommand).map(name => { - const command = postCommand[name]; + commands = Object.keys(userCommand).map(name => { + const command = userCommand[name]; return runSingleCommand(command, name); }); } @@ -502,13 +521,13 @@ async function runPostCommand({ postCreate }: ResolverParameters, containerPrope status: 'failed', }); if (err && (err.code === 130 || err.signal === 2)) { // SIGINT seen on darwin as code === 130, would also make sense as signal === 2. - infoOutput.raw(`\r\n\x1b[1m${postCommandName} interrupted.\x1b[0m\r\n\r\n`); + infoOutput.raw(`\r\n\x1b[1m${lifecycleHookName} interrupted.\x1b[0m\r\n\r\n`); } else { if (err?.code) { - infoOutput.write(toErrorText(`${postCommandName} failed with exit code ${err.code}. Skipping any further user-provided commands.`)); + infoOutput.write(toErrorText(`${lifecycleHookName} failed with exit code ${err.code}. Skipping any further user-provided commands.`)); } throw new ContainerError({ - description: `The ${postCommandName} in the devcontainer.json failed.`, + description: `The ${lifecycleHookName} in the ${userCommandOrigin} failed.`, originalError: err, }); } diff --git a/src/spec-common/variableSubstitution.ts b/src/spec-common/variableSubstitution.ts index 531f47546..d973f0cd6 100644 --- a/src/spec-common/variableSubstitution.ts +++ b/src/spec-common/variableSubstitution.ts @@ -168,4 +168,4 @@ function devcontainerIdForLabels(idLabels: Record): string { .toString(32) .padStart(52, '0'); return uniqueId; -} +} \ No newline at end of file diff --git a/src/spec-configuration/containerFeaturesConfiguration.ts b/src/spec-configuration/containerFeaturesConfiguration.ts index 2e673dc66..162495462 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -23,18 +23,27 @@ export const V1_DEVCONTAINER_FEATURES_FILE_NAME = 'devcontainer-features.json'; // v2 export const DEVCONTAINER_FEATURE_FILE_NAME = 'devcontainer-feature.json'; -export interface Feature { +export type Feature = SchemaFeatureBaseProperties & SchemaFeatureLifecycleHooks & DeprecatedSchemaFeatureProperties & InternalFeatureProperties; + +export const FEATURES_CONTAINER_TEMP_DEST_FOLDER = '/tmp/dev-container-features'; + +export interface SchemaFeatureLifecycleHooks { + onCreateCommand?: string | string[]; + updateContentCommand?: string | string[]; + postCreateCommand?: string | string[]; + postStartCommand?: string | string[]; + postAttachCommand?: string | string[]; +} + +// Properties who are members of the schema +export interface SchemaFeatureBaseProperties { id: string; version?: string; name?: string; description?: string; - cachePath?: string; - internalVersion?: string; // set programmatically - consecutiveId?: string; documentationURL?: string; licenseURL?: string; options?: Record; - buildArg?: string; // old properties for temporary compatibility containerEnv?: Record; mounts?: Mount[]; init?: boolean; @@ -42,15 +51,27 @@ export interface Feature { capAdd?: string[]; securityOpt?: string[]; entrypoint?: string; - include?: string[]; - exclude?: string[]; - value: boolean | string | Record; // set programmatically - included: boolean; // set programmatically customizations?: VSCodeCustomizations; installsAfter?: string[]; deprecated?: boolean; legacyIds?: string[]; - currentId?: string; // set programmatically +} + +// Properties that are set programmatically for book-keeping purposes +export interface InternalFeatureProperties { + cachePath?: string; + internalVersion?: string; + consecutiveId?: string; + value: boolean | string | Record; + currentId?: string; + included: boolean; +} + +// Old or deprecated properties maintained for backwards compatibility +export interface DeprecatedSchemaFeatureProperties { + buildArg?: string; + include?: string[]; + exclude?: string[]; } export type FeatureOption = { @@ -218,27 +239,25 @@ export function getSourceInfoString(srcInfo: SourceInformation): string { } // TODO: Move to node layer. -export function getContainerFeaturesBaseDockerFile() { +export function getContainerFeaturesBaseDockerFile(contentSourceRootPath: string) { return ` -#{featureBuildStages} #{nonBuildKitFeatureContentFallback} FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize USER root -COPY --from=dev_containers_feature_content_source {contentSourceRootPath}/devcontainer-features.builtin.env /tmp/build-features/ -RUN chmod -R 0700 /tmp/build-features +COPY --from=dev_containers_feature_content_source ${path.posix.join(contentSourceRootPath, 'devcontainer-features.builtin.env')} /tmp/build-features/ +RUN chmod -R 0755 /tmp/build-features/ FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage USER root -COPY --from=dev_containers_feature_content_normalize /tmp/build-features /tmp/build-features +RUN mkdir -p ${FEATURES_CONTAINER_TEMP_DEST_FOLDER} +COPY --from=dev_containers_feature_content_normalize /tmp/build-features/ ${FEATURES_CONTAINER_TEMP_DEST_FOLDER} #{featureLayer} -#{copyFeatureBuildStages} - #{containerEnv} ARG _DEV_CONTAINERS_IMAGE_USER=root @@ -312,10 +331,12 @@ function escapeQuotesForShell(input: string) { return input.replace(new RegExp(`'`, 'g'), `'\\''`); } -export function getFeatureLayers(featuresConfig: FeaturesConfig, containerUser: string, remoteUser: string, useBuildKitBuildContexts = false, contentSourceRootPath = '/tmp/build-features/') { +export function getFeatureLayers(featuresConfig: FeaturesConfig, containerUser: string, remoteUser: string, useBuildKitBuildContexts = false, contentSourceRootPath = '/tmp/build-features') { + + const builtinsEnvFile = `${path.posix.join(FEATURES_CONTAINER_TEMP_DEST_FOLDER, 'devcontainer-features.builtin.env')}`; let result = `RUN \\ -echo "_CONTAINER_USER_HOME=$(getent passwd ${containerUser} | cut -d: -f6)" >> /tmp/build-features/devcontainer-features.builtin.env && \\ -echo "_REMOTE_USER_HOME=$(getent passwd ${remoteUser} | cut -d: -f6)" >> /tmp/build-features/devcontainer-features.builtin.env +echo "_CONTAINER_USER_HOME=$(getent passwd ${containerUser} | cut -d: -f6)" >> ${builtinsEnvFile} && \\ +echo "_REMOTE_USER_HOME=$(getent passwd ${remoteUser} | cut -d: -f6)" >> ${builtinsEnvFile} `; @@ -323,22 +344,23 @@ echo "_REMOTE_USER_HOME=$(getent passwd ${remoteUser} | cut -d: -f6)" >> /tmp/bu const folders = (featuresConfig.featureSets || []).filter(y => y.internalVersion !== '2').map(x => x.features[0].consecutiveId); folders.forEach(folder => { const source = path.posix.join(contentSourceRootPath, folder!); + const dest = path.posix.join(FEATURES_CONTAINER_TEMP_DEST_FOLDER, folder!); if (!useBuildKitBuildContexts) { - result += `COPY --chown=root:root --from=dev_containers_feature_content_source ${source} /tmp/build-features/${folder} -RUN chmod -R 0700 /tmp/build-features/${folder} \\ -&& cd /tmp/build-features/${folder} \\ + result += `COPY --chown=root:root --from=dev_containers_feature_content_source ${source} ${dest} +RUN chmod -R 0755 ${dest} \\ +&& cd ${dest} \\ && chmod +x ./install.sh \\ && ./install.sh `; } else { result += `RUN --mount=type=bind,from=dev_containers_feature_content_source,source=${source},target=/tmp/build-features-src/${folder} \\ - cp -ar /tmp/build-features-src/${folder} /tmp/build-features/ \\ - && chmod -R 0700 /tmp/build-features/${folder} \\ - && cd /tmp/build-features/${folder} \\ + cp -ar /tmp/build-features-src/${folder} ${FEATURES_CONTAINER_TEMP_DEST_FOLDER} \\ + && chmod -R 0755 ${dest} \\ + && cd ${dest} \\ && chmod +x ./install.sh \\ && ./install.sh \\ - && rm -rf /tmp/build-features/${folder} + && rm -rf ${dest} `; } @@ -348,11 +370,12 @@ RUN chmod -R 0700 /tmp/build-features/${folder} \\ featureSet.features.forEach(feature => { result += generateContainerEnvs(feature); const source = path.posix.join(contentSourceRootPath, feature.consecutiveId!); + const dest = path.posix.join(FEATURES_CONTAINER_TEMP_DEST_FOLDER, feature.consecutiveId!); if (!useBuildKitBuildContexts) { result += ` -COPY --chown=root:root --from=dev_containers_feature_content_source ${source} /tmp/build-features/${feature.consecutiveId} -RUN chmod -R 0700 /tmp/build-features/${feature.consecutiveId} \\ -&& cd /tmp/build-features/${feature.consecutiveId} \\ +COPY --chown=root:root --from=dev_containers_feature_content_source ${source} ${dest} +RUN chmod -R 0755 ${dest} \\ +&& cd ${dest} \\ && chmod +x ./devcontainer-features-install.sh \\ && ./devcontainer-features-install.sh @@ -360,12 +383,12 @@ RUN chmod -R 0700 /tmp/build-features/${feature.consecutiveId} \\ } else { result += ` RUN --mount=type=bind,from=dev_containers_feature_content_source,source=${source},target=/tmp/build-features-src/${feature.consecutiveId} \\ - cp -ar /tmp/build-features-src/${feature.consecutiveId} /tmp/build-features/ \\ - && chmod -R 0700 /tmp/build-features/${feature.consecutiveId} \\ - && cd /tmp/build-features/${feature.consecutiveId} \\ + cp -ar /tmp/build-features-src/${feature.consecutiveId} ${FEATURES_CONTAINER_TEMP_DEST_FOLDER} \\ + && chmod -R 0755 ${dest} \\ + && cd ${dest} \\ && chmod +x ./devcontainer-features-install.sh \\ && ./devcontainer-features-install.sh \\ - && rm -rf /tmp/build-features/${feature.consecutiveId} + && rm -rf ${dest} `; } @@ -938,6 +961,11 @@ async function fetchFeatures(params: { extensionPath: string; cwd: string; outpu feature.cachePath = featCachePath; feature.consecutiveId = consecutiveId; + if (!feature.consecutiveId || !feature.id || !featureSet?.sourceInformation || !featureSet.sourceInformation.userFeatureId) { + const err = 'Internal Features error. Missing required attribute(s).'; + throw new Error(err); + } + const featureDebugId = `${feature.consecutiveId}_${sourceInfoType}`; output.write(`* Fetching feature: ${featureDebugId}`); diff --git a/src/spec-node/configContainer.ts b/src/spec-node/configContainer.ts index 068aa10a5..775ddc479 100644 --- a/src/spec-node/configContainer.ts +++ b/src/spec-node/configContainer.ts @@ -56,7 +56,7 @@ async function resolveWithLocalFolder(params: DockerResolverParameters, parsedAu const configWithRaw = addSubstitution(configs.config, config => beforeContainerSubstitute(envListToObj(idLabels), config)); const { config } = configWithRaw; - await runUserCommand({ ...params, common: { ...common, output: common.postCreate.output } }, config.initializeCommand, common.postCreate.onDidInput); + await runUserCommand({ ...params, common: { ...common, output: common.lifecycleHook.output } }, config.initializeCommand, common.lifecycleHook.onDidInput); let result: ResolverResult; if (isDockerFileConfig(config) || 'image' in config) { diff --git a/src/spec-node/containerFeatures.ts b/src/spec-node/containerFeatures.ts index 6640284d5..0d352751b 100644 --- a/src/spec-node/containerFeatures.ts +++ b/src/spec-node/containerFeatures.ts @@ -285,13 +285,10 @@ async function getFeaturesBuildOptions(params: DockerResolverParameters, devCont // When copying via buildkit, the content is accessed via '.' (i.e. in the context root) // When copying via temp image, the content is in '/tmp/build-features' const contentSourceRootPath = useBuildKitBuildContexts ? '.' : '/tmp/build-features/'; - const dockerfile = getContainerFeaturesBaseDockerFile() + const dockerfile = getContainerFeaturesBaseDockerFile(contentSourceRootPath) .replace('#{nonBuildKitFeatureContentFallback}', useBuildKitBuildContexts ? '' : `FROM ${buildContentImageName} as dev_containers_feature_content_source`) - .replace('{contentSourceRootPath}', contentSourceRootPath) - .replace('#{featureBuildStages}', getFeatureBuildStages(featuresConfig, buildStageScripts, contentSourceRootPath)) .replace('#{featureLayer}', getFeatureLayers(featuresConfig, containerUser, remoteUser, useBuildKitBuildContexts, contentSourceRootPath)) .replace('#{containerEnv}', generateContainerEnvs(featuresConfig)) - .replace('#{copyFeatureBuildStages}', getCopyFeatureBuildStages(featuresConfig, buildStageScripts)) .replace('#{devcontainerMetadata}', getDevcontainerMetadataLabel(imageMetadata, common.experimentalImageMetadata)) .replace('#{containerEnvMetadata}', getContainerEnvMetadata(devContainerConfig.config.containerEnv)) ; @@ -389,31 +386,6 @@ export function findContainerUsers(imageMetadata: SubstitutedConfig[], contentSourceRootPath: string) { - return ([] as string[]).concat(...featuresConfig.featureSets - .map((featureSet, i) => featureSet.features - .filter(f => (includeAllConfiguredFeatures || f.included) && f.value && buildStageScripts[i][f.id]?.hasAcquire) - .map(f => `FROM mcr.microsoft.com/vscode/devcontainers/base:0-focal as ${getSourceInfoString(featureSet.sourceInformation)}_${f.id} -COPY --from=dev_containers_feature_content_normalize ${path.posix.join(contentSourceRootPath, getSourceInfoString(featureSet.sourceInformation), 'features', f.id)} ${path.posix.join('/tmp/build-features', getSourceInfoString(featureSet.sourceInformation), 'features', f.id)} -COPY --from=dev_containers_feature_content_normalize ${path.posix.join(contentSourceRootPath, getSourceInfoString(featureSet.sourceInformation), 'common')} ${path.posix.join('/tmp/build-features', getSourceInfoString(featureSet.sourceInformation), 'common')} -RUN cd ${path.posix.join('/tmp/build-features', getSourceInfoString(featureSet.sourceInformation), 'features', f.id)} && set -a && . ./devcontainer-features.env && set +a && ./bin/acquire` - ) - ) - ).join('\n\n'); -} - -function getCopyFeatureBuildStages(featuresConfig: FeaturesConfig, buildStageScripts: Record[]) { - return ([] as string[]).concat(...featuresConfig.featureSets - .map((featureSet, i) => featureSet.features - .filter(f => (includeAllConfiguredFeatures || f.included) && f.value && buildStageScripts[i][f.id]?.hasAcquire) - .map(f => { - const featurePath = path.posix.join('/usr/local/devcontainer-features', getSourceInfoString(featureSet.sourceInformation), f.id); - return `COPY --from=${getSourceInfoString(featureSet.sourceInformation)}_${f.id} ${featurePath} ${featurePath}${buildStageScripts[i][f.id]?.hasConfigure ? ` -RUN cd ${path.posix.join('/tmp/build-features', getSourceInfoString(featureSet.sourceInformation), 'features', f.id)} && set -a && . ./devcontainer-features.env && set +a && ./bin/configure` : ''}`; - }) - ) - ).join('\n\n'); -} function getFeatureEnvVariables(f: Feature) { const values = getFeatureValueObject(f); diff --git a/src/spec-node/devContainers.ts b/src/spec-node/devContainers.ts index d8a5f51b2..e34cf6efb 100644 --- a/src/spec-node/devContainers.ts +++ b/src/spec-node/devContainers.ts @@ -8,7 +8,7 @@ import * as crypto from 'crypto'; import * as os from 'os'; import { DockerResolverParameters, DevContainerAuthority, UpdateRemoteUserUIDDefault, BindMountConsistency, getCacheFolder } from './utils'; -import { createNullPostCreate, finishBackgroundTasks, ResolverParameters, UserEnvProbe } from '../spec-common/injectHeadless'; +import { createNullLifecycleHook, finishBackgroundTasks, ResolverParameters, UserEnvProbe } from '../spec-common/injectHeadless'; import { getCLIHost, loadNativeModule } from '../spec-common/commonUtils'; import { resolve } from './configContainer'; import { URI } from 'vscode-uri'; @@ -122,7 +122,7 @@ export async function createDockerParams(options: ProvisionOptions, disposables: output, allowSystemConfigChange: true, defaultUserEnvProbe: options.defaultUserEnvProbe, - postCreate: createNullPostCreate(options.postCreateEnabled, options.skipNonBlocking, output), + lifecycleHook: createNullLifecycleHook(options.postCreateEnabled, options.skipNonBlocking, output), getLogLevel: () => options.logLevel, onDidChangeLogLevel: () => ({ dispose() { } }), loadNativeModule, diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 16e316cb4..e1df59ee6 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -13,8 +13,7 @@ import { SubstitutedConfig, createContainerProperties, createFeaturesTempFolder, import { URI } from 'vscode-uri'; import { ContainerError } from '../spec-common/errors'; import { Log, LogLevel, makeLog, mapLogLevel } from '../spec-utils/log'; -import { probeRemoteEnv, runPostCreateCommands, runRemoteCommand, UserEnvProbe, setupInContainer } from '../spec-common/injectHeadless'; -import { bailOut, buildNamedImageAndExtend } from './singleContainer'; +import { probeRemoteEnv, runLifecycleHooks, runRemoteCommand, UserEnvProbe, setupInContainer } from '../spec-common/injectHeadless'; import { extendImage } from './containerFeatures'; import { DockerCLIParameters, dockerPtyCLI, inspectContainer } from '../spec-shutdown/dockerUtils'; import { buildAndExtendDockerCompose, dockerComposeCLIConfig, getDefaultImageName, getProjectName, readDockerComposeConfig, readVersionPrefix } from './dockerCompose'; @@ -31,10 +30,11 @@ import { featuresPublishHandler, featuresPublishOptions } from './featuresCLI/pu import { featureInfoTagsHandler, featuresInfoTagsOptions } from './featuresCLI/infoTags'; import { beforeContainerSubstitute, containerSubstitute, substitute } from '../spec-common/variableSubstitution'; import { getPackageConfig, PackageConfiguration } from '../spec-utils/product'; -import { getDevcontainerMetadata, getImageBuildInfo, getImageMetadataFromContainer, ImageMetadataEntry, mergeConfiguration, MergedDevContainerConfig } from './imageMetadata'; +import { getDevcontainerMetadata, getImageBuildInfo, getImageMetadataFromContainer, ImageMetadataEntry, lifecycleCommandOriginMapFromMetadata, mergeConfiguration, MergedDevContainerConfig } from './imageMetadata'; import { templatesPublishHandler, templatesPublishOptions } from './templatesCLI/publish'; import { templateApplyHandler, templateApplyOptions } from './templatesCLI/apply'; import { featuresInfoManifestHandler, featuresInfoManifestOptions } from './featuresCLI/infoManifest'; +import { bailOut, buildNamedImageAndExtend } from './singleContainer'; const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell'; @@ -423,7 +423,7 @@ async function doSetUp({ const imageMetadata = getImageMetadataFromContainer(container, config, undefined, undefined, true, output).config; const mergedConfig = mergeConfiguration(config.config, imageMetadata); const containerProperties = await createContainerProperties(params, container.Id, configs?.workspaceConfig.workspaceFolder, mergedConfig.remoteUser); - await setupInContainer(common, containerProperties, mergedConfig); + await setupInContainer(common, containerProperties, mergedConfig, lifecycleCommandOriginMapFromMetadata(imageMetadata)); return { outcome: 'success' as 'success', dispose, @@ -834,7 +834,7 @@ async function doRunUserCommands({ const containerProperties = await createContainerProperties(params, container.Id, configs?.workspaceConfig.workspaceFolder, mergedConfig.remoteUser); const updatedConfig = containerSubstitute(cliHost.platform, config.config.configFilePath, containerProperties.env, mergedConfig); const remoteEnv = probeRemoteEnv(common, containerProperties, updatedConfig); - const result = await runPostCreateCommands(common, containerProperties, updatedConfig, remoteEnv, stopForPersonalization); + const result = await runLifecycleHooks(common, lifecycleCommandOriginMapFromMetadata(imageMetadata), containerProperties, updatedConfig, remoteEnv, stopForPersonalization); return { outcome: 'success' as 'success', result, diff --git a/src/spec-node/dockerCompose.ts b/src/spec-node/dockerCompose.ts index da24c1981..48c796d91 100644 --- a/src/spec-node/dockerCompose.ts +++ b/src/spec-node/dockerCompose.ts @@ -17,7 +17,7 @@ import { Log, LogLevel, makeLog, terminalEscapeSequences } from '../spec-utils/l import { getExtendImageBuildInfo, updateRemoteUserUID } from './containerFeatures'; import { Mount, parseMount } from '../spec-configuration/containerFeaturesConfiguration'; import path from 'path'; -import { getDevcontainerMetadata, getImageBuildInfoFromDockerfile, getImageBuildInfoFromImage, getImageMetadataFromContainer, ImageBuildInfo, mergeConfiguration, MergedDevContainerConfig } from './imageMetadata'; +import { getDevcontainerMetadata, getImageBuildInfoFromDockerfile, getImageBuildInfoFromImage, getImageMetadataFromContainer, ImageBuildInfo, lifecycleCommandOriginMapFromMetadata, mergeConfiguration, MergedDevContainerConfig } from './imageMetadata'; import { ensureDockerfileHasFinalStageName } from './dockerfileUtils'; const projectLabel = 'com.docker.compose.project'; @@ -68,13 +68,13 @@ async function _openDockerComposeDevContainer(params: DockerResolverParameters, // collapsedFeaturesConfig = collapseFeaturesConfig(featuresConfig); } - const configs = getImageMetadataFromContainer(container, configWithRaw, undefined, idLabels, common.experimentalImageMetadata, common.output).config; - const mergedConfig = mergeConfiguration(configWithRaw.config, configs); + const imageMetadata = getImageMetadataFromContainer(container, configWithRaw, undefined, idLabels, common.experimentalImageMetadata, common.output).config; + const mergedConfig = mergeConfiguration(configWithRaw.config, imageMetadata); containerProperties = await createContainerProperties(params, container.Id, remoteWorkspaceFolder, mergedConfig.remoteUser); const { remoteEnv: extensionHostEnv, - } = await setupInContainer(common, containerProperties, mergedConfig); + } = await setupInContainer(common, containerProperties, mergedConfig, lifecycleCommandOriginMapFromMetadata(imageMetadata)); return { params: common, diff --git a/src/spec-node/imageMetadata.ts b/src/spec-node/imageMetadata.ts index e3adc2af4..9dadeee4e 100644 --- a/src/spec-node/imageMetadata.ts +++ b/src/spec-node/imageMetadata.ts @@ -4,8 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { ContainerError } from '../spec-common/errors'; +import { LifecycleCommand, LifecycleHooksInstallMap } from '../spec-common/injectHeadless'; import { DevContainerConfig, DevContainerConfigCommand, DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, DevContainerFromImageConfig, getDockerComposeFilePaths, getDockerfilePath, HostGPURequirements, HostRequirements, isDockerFileConfig, PortAttributes, UserEnvProbe } from '../spec-configuration/configuration'; -import { Feature, FeaturesConfig, Mount, parseMount } from '../spec-configuration/containerFeaturesConfiguration'; +import { Feature, FeaturesConfig, Mount, parseMount, SchemaFeatureLifecycleHooks } from '../spec-configuration/containerFeaturesConfiguration'; import { ContainerDetails, DockerCLIParameters, ImageDetails } from '../spec-shutdown/dockerUtils'; import { Log } from '../spec-utils/log'; import { getBuildInfoForService, readDockerComposeConfig } from './dockerCompose'; @@ -45,7 +46,16 @@ const pickUpdateableConfigProperties: (keyof DevContainerConfig & keyof ImageMet 'remoteEnv', ]; +const pickFeatureLifecycleHookProperties: Exclude[] = [ + 'onCreateCommand', + 'updateContentCommand', + 'postCreateCommand', + 'postStartCommand', + 'postAttachCommand', +]; + const pickFeatureProperties: Exclude[] = [ + ...pickFeatureLifecycleHookProperties, 'init', 'privileged', 'capAdd', @@ -64,11 +74,11 @@ export interface ImageMetadataEntry { entrypoint?: string; mounts?: (Mount | string)[]; customizations?: Record; - onCreateCommand?: string | string[]; - updateContentCommand?: string | string[]; - postCreateCommand?: string | string[]; - postStartCommand?: string | string[]; - postAttachCommand?: string | string[]; + onCreateCommand?: LifecycleCommand; + updateContentCommand?: LifecycleCommand; + postCreateCommand?: LifecycleCommand; + postStartCommand?: LifecycleCommand; + postAttachCommand?: LifecycleCommand; waitFor?: DevContainerConfigCommand; remoteUser?: string; containerUser?: string; @@ -102,14 +112,47 @@ const replaceProperties = [ interface UpdatedConfigProperties { customizations?: Record; entrypoints?: string[]; - onCreateCommands?: (string | string[])[]; - updateContentCommands?: (string | string[])[]; - postCreateCommands?: (string | string[])[]; - postStartCommands?: (string | string[])[]; - postAttachCommands?: (string | string[])[]; + onCreateCommands?: LifecycleCommand[]; + updateContentCommands?: LifecycleCommand[]; + postCreateCommands?: LifecycleCommand[]; + postStartCommands?: LifecycleCommand[]; + postAttachCommands?: LifecycleCommand[]; shutdownAction?: 'none' | 'stopContainer' | 'stopCompose'; } +export function lifecycleCommandOriginMapFromMetadata(metadata: ImageMetadataEntry[]): LifecycleHooksInstallMap { + const map: LifecycleHooksInstallMap = { + onCreateCommand: [], + updateContentCommand: [], + postCreateCommand: [], + postStartCommand: [], + postAttachCommand: [], + initializeCommand: [] + }; + for (const entry of metadata) { + const id = entry.id; // Only Features have IDs encoded in the metadata. + const origin = id ?? 'devcontainer.json'; + for (const hook of pickFeatureLifecycleHookProperties) { + const command = entry[hook]; + if (command) { + map[hook].push({ origin, command }); + } + } + } + return map; +} + +function mergeLifecycleHooks(metadata: ImageMetadataEntry[], hook: (keyof SchemaFeatureLifecycleHooks)): LifecycleCommand[] | undefined { + const collected: LifecycleCommand[] = []; + for (const entry of metadata) { + const command = entry[hook]; + if (command) { + collected.push(command); + } + } + return collected; +} + export function mergeConfiguration(config: DevContainerConfig, imageMetadata: ImageMetadataEntry[]): MergedDevContainerConfig { const customizations = imageMetadata.reduce((obj, entry) => { for (const key in entry.customizations) { @@ -133,11 +176,11 @@ export function mergeConfiguration(config: DevContainerConfig, imageMetadata: Im entrypoints: collectOrUndefined(imageMetadata, 'entrypoint'), mounts: mergeMounts(imageMetadata), customizations: Object.keys(customizations).length ? customizations : undefined, - onCreateCommands: collectOrUndefined(imageMetadata, 'onCreateCommand'), - updateContentCommands: collectOrUndefined(imageMetadata, 'updateContentCommand'), - postCreateCommands: collectOrUndefined(imageMetadata, 'postCreateCommand'), - postStartCommands: collectOrUndefined(imageMetadata, 'postStartCommand'), - postAttachCommands: collectOrUndefined(imageMetadata, 'postAttachCommand'), + onCreateCommands: mergeLifecycleHooks(imageMetadata, 'onCreateCommand'), + updateContentCommands: mergeLifecycleHooks(imageMetadata, 'updateContentCommand'), + postCreateCommands: mergeLifecycleHooks(imageMetadata, 'postCreateCommand'), + postStartCommands: mergeLifecycleHooks(imageMetadata, 'postStartCommand'), + postAttachCommands: mergeLifecycleHooks(imageMetadata, 'postAttachCommand'), waitFor: reversed.find(entry => entry.waitFor)?.waitFor, remoteUser: reversed.find(entry => entry.remoteUser)?.remoteUser, containerUser: reversed.find(entry => entry.containerUser)?.containerUser, @@ -247,22 +290,25 @@ function collectOrUndefined(entries: T[], property: K): No export function getDevcontainerMetadata(baseImageMetadata: SubstitutedConfig, devContainerConfig: SubstitutedConfig, featuresConfig: FeaturesConfig | undefined, omitPropertyOverride: string[] = []): SubstitutedConfig { const effectivePickFeatureProperties = pickFeatureProperties.filter(property => !omitPropertyOverride.includes(property)); - const raw = featuresConfig?.featureSets.map(featureSet => featureSet.features.map(feature => ({ - id: featureSet.sourceInformation.userFeatureId, - ...pick(feature, effectivePickFeatureProperties), - }))).flat() || []; + const featureRaw = featuresConfig?.featureSets.map(featureSet => + featureSet.features.map(feature => ({ + id: featureSet.sourceInformation.userFeatureId, + ...pick(feature, effectivePickFeatureProperties), + }))).flat() || []; + + const raw = [ + ...baseImageMetadata.raw, + ...featureRaw, + pick(devContainerConfig.raw, pickConfigProperties), + ].filter(config => Object.keys(config).length); return { config: [ ...baseImageMetadata.config, - ...raw.map(devContainerConfig.substitute), + ...featureRaw.map(devContainerConfig.substitute), pick(devContainerConfig.config, pickConfigProperties), ].filter(config => Object.keys(config).length), - raw: [ - ...baseImageMetadata.raw, - ...raw, - pick(devContainerConfig.raw, pickConfigProperties), - ].filter(config => Object.keys(config).length), + raw, substitute: devContainerConfig.substitute, }; } diff --git a/src/spec-node/singleContainer.ts b/src/spec-node/singleContainer.ts index 82196c943..58f518ae8 100644 --- a/src/spec-node/singleContainer.ts +++ b/src/spec-node/singleContainer.ts @@ -11,7 +11,7 @@ import { ContainerDetails, listContainers, DockerCLIParameters, inspectContainer import { DevContainerConfig, DevContainerFromDockerfileConfig, DevContainerFromImageConfig } from '../spec-configuration/configuration'; import { LogLevel, Log, makeLog } from '../spec-utils/log'; import { extendImage, getExtendImageBuildInfo, updateRemoteUserUID } from './containerFeatures'; -import { getDevcontainerMetadata, getImageBuildInfoFromDockerfile, getImageMetadataFromContainer, ImageMetadataEntry, mergeConfiguration, MergedDevContainerConfig } from './imageMetadata'; +import { getDevcontainerMetadata, getImageBuildInfoFromDockerfile, getImageMetadataFromContainer, ImageMetadataEntry, lifecycleCommandOriginMapFromMetadata, mergeConfiguration, MergedDevContainerConfig } from './imageMetadata'; import { ensureDockerfileHasFinalStageName } from './dockerfileUtils'; export const hostFolderLabel = 'devcontainer.local_folder'; // used to label containers created from a workspace/folder @@ -27,6 +27,7 @@ export async function openDockerfileDevContainer(params: DockerResolverParameter try { container = await findExistingContainer(params, idLabels); + let imageMetadata: ImageMetadataEntry[]; let mergedConfig: MergedDevContainerConfig; if (container) { // let _collapsedFeatureConfig: Promise; @@ -38,11 +39,11 @@ export async function openDockerfileDevContainer(params: DockerResolverParameter // })()); // }; await startExistingContainer(params, idLabels, container); - const imageMetadata = getImageMetadataFromContainer(container, configWithRaw, undefined, idLabels, common.experimentalImageMetadata, common.output).config; + imageMetadata = getImageMetadataFromContainer(container, configWithRaw, undefined, idLabels, common.experimentalImageMetadata, common.output).config; mergedConfig = mergeConfiguration(config, imageMetadata); } else { const res = await buildNamedImageAndExtend(params, configWithRaw, additionalFeatures, true); - const imageMetadata = res.imageMetadata.config; + imageMetadata = res.imageMetadata.config; mergedConfig = mergeConfiguration(config, imageMetadata); const { containerUser } = mergedConfig; const updatedImageName = await updateRemoteUserUID(params, mergedConfig, res.updatedImageName[0], res.imageDetails, findUserArg(config.runArgs) || containerUser); @@ -62,7 +63,7 @@ export async function openDockerfileDevContainer(params: DockerResolverParameter } containerProperties = await createContainerProperties(params, container.Id, workspaceConfig.workspaceFolder, mergedConfig.remoteUser); - return await setupContainer(container, params, containerProperties, config, mergedConfig); + return await setupContainer(container, params, containerProperties, config, mergedConfig, imageMetadata); } catch (e) { throw createSetupError(e, container, params, containerProperties, config); @@ -89,11 +90,11 @@ function createSetupError(originalError: any, container: ContainerDetails | unde return err; } -async function setupContainer(container: ContainerDetails, params: DockerResolverParameters, containerProperties: ContainerProperties, config: DevContainerFromDockerfileConfig | DevContainerFromImageConfig, mergedConfig: MergedDevContainerConfig): Promise { +async function setupContainer(container: ContainerDetails, params: DockerResolverParameters, containerProperties: ContainerProperties, config: DevContainerFromDockerfileConfig | DevContainerFromImageConfig, mergedConfig: MergedDevContainerConfig, imageMetadata: ImageMetadataEntry[]): Promise { const { common } = params; const { remoteEnv: extensionHostEnv, - } = await setupInContainer(common, containerProperties, mergedConfig); + } = await setupInContainer(common, containerProperties, mergedConfig, lifecycleCommandOriginMapFromMetadata(imageMetadata)); return { params: common, diff --git a/src/test/cli.exec.base.ts b/src/test/cli.exec.base.ts index 76419e68c..142d3b437 100644 --- a/src/test/cli.exec.base.ts +++ b/src/test/cli.exec.base.ts @@ -12,7 +12,7 @@ const pkg = require('../../package.json'); export function describeTests1({ text, options }: BuildKitOption) { describe('Dev Containers CLI', function () { - this.timeout('240s'); + this.timeout('360s'); const tmp = path.relative(process.cwd(), path.join(__dirname, 'tmp')); const cli = `npx --prefix ${tmp} devcontainer`; diff --git a/src/test/cli.up.test.ts b/src/test/cli.up.test.ts index b55f38ea9..b2cca1ca7 100644 --- a/src/test/cli.up.test.ts +++ b/src/test/cli.up.test.ts @@ -12,7 +12,7 @@ import { devContainerDown, devContainerUp, shellExec, UpResult, pathExists } fro const pkg = require('../../package.json'); describe('Dev Containers CLI', function () { - this.timeout('120s'); + this.timeout('240s'); const tmp = path.relative(process.cwd(), path.join(__dirname, 'tmp')); const cli = `npx --prefix ${tmp} devcontainer`; diff --git a/src/test/configs/image-metadata/.devcontainer/localFeatureA/devcontainer-feature.json b/src/test/configs/image-metadata/.devcontainer/localFeatureA/devcontainer-feature.json index 3b8be3b51..77f064bdf 100644 --- a/src/test/configs/image-metadata/.devcontainer/localFeatureA/devcontainer-feature.json +++ b/src/test/configs/image-metadata/.devcontainer/localFeatureA/devcontainer-feature.json @@ -9,5 +9,20 @@ "extensionB" ] } - } + }, + "updateContentCommand": [ + "one", + "two" + ], + "onCreateCommand": { + "command": "three", + "commandWithArgs": [ + "four", + "arg1", + "arg2" + ] + }, + "postCreateCommand": "five", + "postStartCommand": "six", + "postAttachCommand": "seven" } diff --git a/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/Dockerfile b/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/Dockerfile new file mode 100644 index 000000000..c286a4279 --- /dev/null +++ b/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/Dockerfile @@ -0,0 +1 @@ +FROM mcr.microsoft.com/devcontainers/base:ubuntu \ No newline at end of file diff --git a/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/devcontainer.json b/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/devcontainer.json new file mode 100644 index 000000000..c2d027c14 --- /dev/null +++ b/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/devcontainer.json @@ -0,0 +1,18 @@ +{ + "build": { + "dockerfile": "Dockerfile" + }, + "features": { + "./otter": {}, + "./rabbit": {} + }, + "postCreateCommand": { + "parallel1": ".devcontainer/helper_script.sh parallel_postCreateCommand_1", + "parallel2": [ + ".devcontainer/helper_script.sh", + "parallel_postCreateCommand_2" + ] + }, + "postStartCommand": "touch `rabbit`.postStartCommand.testMarker", // The 'rabbit' command is installed and added to the path by .devcontainer/rabbit/install.sh + "postAttachCommand": "touch `otter`.postAttachCommand.testMarker" // The 'otter' command is installed and added to the path by .devcontainer/otter/install.sh +} \ No newline at end of file diff --git a/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/helper_script.sh b/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/helper_script.sh new file mode 100755 index 000000000..1c8c5c658 --- /dev/null +++ b/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/helper_script.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +MARKER_FILE_NAME="$1" +echo "Hello from the .devcontainer helper_script.sh invoked by ${MARKER_FILE_NAME}" +touch "helperScript.devContainer.${MARKER_FILE_NAME}.testMarker" \ No newline at end of file diff --git a/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/otter/devcontainer-feature.json b/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/otter/devcontainer-feature.json new file mode 100644 index 000000000..a53c1fd76 --- /dev/null +++ b/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/otter/devcontainer-feature.json @@ -0,0 +1,22 @@ +{ + "id": "otter", + "version": "1.2.3", + "options": {}, + "updateContentCommand": "/usr/features/otter/helper_script.sh updateContentCommand", + "onCreateCommand": "/usr/features/otter/helper_script.sh onCreateCommand", + "postCreateCommand": { + "parallel1": "/usr/features/otter/helper_script.sh parallel_postCreateCommand_1", + "parallel2": [ + "/usr/features/otter/helper_script.sh", + "parallel_postCreateCommand_2" + ] + }, + "postStartCommand": [ + "/usr/features/otter/helper_script.sh", + "postStartCommand" + ], + "postAttachCommand": [ + "/usr/features/otter/helper_script.sh", + "postAttachCommand" + ] +} \ No newline at end of file diff --git a/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/otter/helper_script.sh b/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/otter/helper_script.sh new file mode 100755 index 000000000..f52d7dcd8 --- /dev/null +++ b/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/otter/helper_script.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +MARKER_FILE_NAME="$1" +echo "Hello from otter helper_script.sh invoked by ${MARKER_FILE_NAME}" +touch "helperScript.otter.${MARKER_FILE_NAME}.testMarker" \ No newline at end of file diff --git a/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/otter/install.sh b/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/otter/install.sh new file mode 100644 index 000000000..e1799fdae --- /dev/null +++ b/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/otter/install.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -e + +echo "Activating feature 'otter'" + +cat > /usr/local/bin/otter \ +<< EOF +#!/bin/sh +echo "i-am-an-otter" +EOF + +# Copy helper script into somewhere that will persist +mkdir -p /usr/features/otter +chmod -R 0755 /usr/features/otter +cp ./helper_script.sh /usr/features/otter/helper_script.sh + +chmod +x /usr/local/bin/otter \ No newline at end of file diff --git a/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/rabbit/devcontainer-feature.json b/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/rabbit/devcontainer-feature.json new file mode 100644 index 000000000..3dbad71bd --- /dev/null +++ b/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/rabbit/devcontainer-feature.json @@ -0,0 +1,22 @@ +{ + "id": "rabbit", + "version": "100.200.300", + "options": {}, + "updateContentCommand": "/usr/features/rabbit/helper_script.sh updateContentCommand", + "onCreateCommand": "/usr/features/rabbit/helper_script.sh onCreateCommand", + "postCreateCommand": { + "parallel1": "/usr/features/rabbit/helper_script.sh parallel_postCreateCommand_1", + "parallel2": [ + "/usr/features/rabbit/helper_script.sh", + "parallel_postCreateCommand_2" + ] + }, + "postStartCommand": [ + "/usr/features/rabbit/helper_script.sh", + "postStartCommand" + ], + "postAttachCommand": [ + "/usr/features/rabbit/helper_script.sh", + "postAttachCommand" + ] +} \ No newline at end of file diff --git a/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/rabbit/helper_script.sh b/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/rabbit/helper_script.sh new file mode 100755 index 000000000..133963fc3 --- /dev/null +++ b/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/rabbit/helper_script.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +MARKER_FILE_NAME="$1" +echo "Hello from rabbit helper_script.sh invoked by ${MARKER_FILE_NAME}" +touch "helperScript.rabbit.${MARKER_FILE_NAME}.testMarker" \ No newline at end of file diff --git a/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/rabbit/install.sh b/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/rabbit/install.sh new file mode 100644 index 000000000..7fe0dd5a2 --- /dev/null +++ b/src/test/container-features/configs/lifecycle-hooks-advanced/.devcontainer/rabbit/install.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -e + +echo "Activating feature 'rabbit'" + +cat > /usr/local/bin/rabbit \ +<< EOF +#!/bin/sh +echo "i-am-a-rabbit" +EOF + +# Copy helper script into somewhere that will persist +mkdir -p /usr/features/rabbit +chmod -R 0755 /usr/features/rabbit +cp ./helper_script.sh /usr/features/rabbit/helper_script.sh + + +chmod +x /usr/local/bin/rabbit \ No newline at end of file diff --git a/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/Dockerfile b/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/Dockerfile new file mode 100644 index 000000000..c286a4279 --- /dev/null +++ b/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/Dockerfile @@ -0,0 +1 @@ +FROM mcr.microsoft.com/devcontainers/base:ubuntu \ No newline at end of file diff --git a/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/createMarker.sh b/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/createMarker.sh new file mode 100755 index 000000000..d6533e732 --- /dev/null +++ b/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/createMarker.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +MARKER_FILE_NAME="$1" + +echo "Starting '${MARKER_FILE_NAME}'...." +sleep 1s + +[[ -f saved_value.testMarker ]] || echo 0 > saved_value.testMarker +n=$(< saved_value.testMarker) +echo "${n}.`date +%s%3N`" > "${n}.${MARKER_FILE_NAME}" +echo $(( n + 1 )) > saved_value.testMarker + +echo "Ending '${MARKER_FILE_NAME}'...." \ No newline at end of file diff --git a/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/devcontainer.json b/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/devcontainer.json new file mode 100644 index 000000000..e946a2563 --- /dev/null +++ b/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/devcontainer.json @@ -0,0 +1,17 @@ +{ + "build": { + "dockerfile": "Dockerfile" + }, + "features": { + "./tiger": {}, + "./panda": {} + }, + "onCreateCommand": ".devcontainer/createMarker.sh devContainer.onCreateCommand.testMarker", + "updateContentCommand": ".devcontainer/createMarker.sh devContainer.updateContentCommand.testMarker", + "postCreateCommand": [ + ".devcontainer/createMarker.sh", + "devContainer.postCreateCommand.testMarker" + ], + "postStartCommand": ".devcontainer/createMarker.sh devContainer.postStartCommand.testMarker", + "postAttachCommand": ".devcontainer/createMarker.sh devContainer.postAttachCommand.testMarker" +} \ No newline at end of file diff --git a/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/panda/devcontainer-feature.json b/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/panda/devcontainer-feature.json new file mode 100644 index 000000000..4a7c12c20 --- /dev/null +++ b/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/panda/devcontainer-feature.json @@ -0,0 +1,14 @@ +{ + "id": "panda", + "version": "4.5.6", + "name": "Echos the panda emoji. That is it!", + "options": {}, + "updateContentCommand": [ + ".devcontainer/createMarker.sh", + "panda.updateContentCommand.testMarker" + ], + "onCreateCommand": "./.devcontainer/createMarker.sh panda.onCreateCommand.testMarker", + "postCreateCommand": "./.devcontainer/createMarker.sh panda.postCreateCommand.testMarker", + "postStartCommand": "./.devcontainer/createMarker.sh panda.postStartCommand.testMarker", + "postAttachCommand": "./.devcontainer/createMarker.sh panda.postAttachCommand.testMarker" +} \ No newline at end of file diff --git a/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/panda/install.sh b/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/panda/install.sh new file mode 100644 index 000000000..be7e04401 --- /dev/null +++ b/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/panda/install.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +echo "Activating feature 'panda'" + +cat > /usr/local/bin/panda \ +<< EOF +#!/bin/sh +echo 🐼 +EOF + +chmod +x /usr/local/bin/panda \ No newline at end of file diff --git a/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/tiger/devcontainer-feature.json b/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/tiger/devcontainer-feature.json new file mode 100644 index 000000000..29ef857c3 --- /dev/null +++ b/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/tiger/devcontainer-feature.json @@ -0,0 +1,17 @@ +{ + "id": "tiger", + "version": "8.9.10", + "name": "Echos the tiger emoji. That is it!", + "options": {}, + "updateContentCommand": "./.devcontainer/createMarker.sh tiger.updateContentCommand.testMarker", + "onCreateCommand": "./.devcontainer/createMarker.sh tiger.onCreateCommand.testMarker", + "postCreateCommand": "./.devcontainer/createMarker.sh tiger.postCreateCommand.testMarker", + "postStartCommand": "./.devcontainer/createMarker.sh tiger.postStartCommand.testMarker", + "postAttachCommand": [ + ".devcontainer/createMarker.sh", + "tiger.postAttachCommand.testMarker" + ], + "installsAfter": [ + "./panda" + ] +} \ No newline at end of file diff --git a/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/tiger/install.sh b/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/tiger/install.sh new file mode 100644 index 000000000..bf6d6dfd9 --- /dev/null +++ b/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/tiger/install.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +echo "Activating feature 'tiger'" + +cat > /usr/local/bin/tiger \ +<< EOF +#!/bin/sh +echo 🐯 +EOF + +chmod +x /usr/local/bin/tiger \ No newline at end of file diff --git a/src/test/container-features/configs/lifecycle-hooks-resume-existing-container/.devcontainer/Dockerfile b/src/test/container-features/configs/lifecycle-hooks-resume-existing-container/.devcontainer/Dockerfile new file mode 100644 index 000000000..c286a4279 --- /dev/null +++ b/src/test/container-features/configs/lifecycle-hooks-resume-existing-container/.devcontainer/Dockerfile @@ -0,0 +1 @@ +FROM mcr.microsoft.com/devcontainers/base:ubuntu \ No newline at end of file diff --git a/src/test/container-features/configs/lifecycle-hooks-resume-existing-container/.devcontainer/devcontainer.json b/src/test/container-features/configs/lifecycle-hooks-resume-existing-container/.devcontainer/devcontainer.json new file mode 100644 index 000000000..ab00a985a --- /dev/null +++ b/src/test/container-features/configs/lifecycle-hooks-resume-existing-container/.devcontainer/devcontainer.json @@ -0,0 +1,8 @@ +{ + "build": { + "dockerfile": "Dockerfile" + }, + "features": { + "./hippo": {} + } +} \ No newline at end of file diff --git a/src/test/container-features/configs/lifecycle-hooks-resume-existing-container/.devcontainer/hippo/createMarker.sh b/src/test/container-features/configs/lifecycle-hooks-resume-existing-container/.devcontainer/hippo/createMarker.sh new file mode 100755 index 000000000..d6533e732 --- /dev/null +++ b/src/test/container-features/configs/lifecycle-hooks-resume-existing-container/.devcontainer/hippo/createMarker.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +MARKER_FILE_NAME="$1" + +echo "Starting '${MARKER_FILE_NAME}'...." +sleep 1s + +[[ -f saved_value.testMarker ]] || echo 0 > saved_value.testMarker +n=$(< saved_value.testMarker) +echo "${n}.`date +%s%3N`" > "${n}.${MARKER_FILE_NAME}" +echo $(( n + 1 )) > saved_value.testMarker + +echo "Ending '${MARKER_FILE_NAME}'...." \ No newline at end of file diff --git a/src/test/container-features/configs/lifecycle-hooks-resume-existing-container/.devcontainer/hippo/devcontainer-feature.json b/src/test/container-features/configs/lifecycle-hooks-resume-existing-container/.devcontainer/hippo/devcontainer-feature.json new file mode 100644 index 000000000..aaa08461d --- /dev/null +++ b/src/test/container-features/configs/lifecycle-hooks-resume-existing-container/.devcontainer/hippo/devcontainer-feature.json @@ -0,0 +1,8 @@ +{ + "id": "hippo", + "version": "1.0.1", + "name": "Hippo", + "options": {}, + "postStartCommand": "/usr/features/hippo/createMarker.sh hippo.postStartCommand.testMarker", + "postAttachCommand": "/usr/features/hippo/createMarker.sh hippo.postAttachCommand.testMarker" +} \ No newline at end of file diff --git a/src/test/container-features/configs/lifecycle-hooks-resume-existing-container/.devcontainer/hippo/install.sh b/src/test/container-features/configs/lifecycle-hooks-resume-existing-container/.devcontainer/hippo/install.sh new file mode 100644 index 000000000..291325142 --- /dev/null +++ b/src/test/container-features/configs/lifecycle-hooks-resume-existing-container/.devcontainer/hippo/install.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -e + +echo "Activating feature 'hippo'" + +cat > /usr/local/bin/hippo \ +<< EOF +#!/bin/sh +echo 🦛 +EOF + +# Copy helper script into somewhere that will persist +mkdir -p /usr/features/hippo +cp ./createMarker.sh /usr/features/hippo +chmod -R 0755 /usr/features/hippo + +chmod +x /usr/local/bin/hippo \ No newline at end of file diff --git a/src/test/container-features/e2e.test.ts b/src/test/container-features/e2e.test.ts index 8ff2aef78..e896e82f7 100644 --- a/src/test/container-features/e2e.test.ts +++ b/src/test/container-features/e2e.test.ts @@ -11,7 +11,7 @@ import { devContainerDown, devContainerUp, shellExec } from '../testUtils'; const pkg = require('../../../package.json'); describe('Dev Container Features E2E (remote)', function () { - this.timeout('120s'); + this.timeout('240s'); const tmp = path.relative(process.cwd(), path.join(__dirname, 'tmp')); const cli = `npx --prefix ${tmp} devcontainer`; diff --git a/src/test/container-features/example-v2-features-sets/lifecycle-hooks/src/a/devcontainer-feature.json b/src/test/container-features/example-v2-features-sets/lifecycle-hooks/src/a/devcontainer-feature.json new file mode 100644 index 000000000..c23c4cff2 --- /dev/null +++ b/src/test/container-features/example-v2-features-sets/lifecycle-hooks/src/a/devcontainer-feature.json @@ -0,0 +1,8 @@ +{ + "id": "a", + "version": "1.0.0", + "installsAfter": [ + "./b" + ], + "onCreateCommand": "touch a.onCreateCommand.testMarker && echo 'A-ON-CREATE-COMMAND'" +} \ No newline at end of file diff --git a/src/test/container-features/example-v2-features-sets/lifecycle-hooks/src/a/install.sh b/src/test/container-features/example-v2-features-sets/lifecycle-hooks/src/a/install.sh new file mode 100644 index 000000000..21e05d112 --- /dev/null +++ b/src/test/container-features/example-v2-features-sets/lifecycle-hooks/src/a/install.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -e + +echo "Activating feature 'a'" + +touch /usr/local/bin/a + +# InstallsAfter Feature B + +if [ ! -f /usr/local/bin/b ]; then + echo "Feature B not available!" + exit 1 +fi \ No newline at end of file diff --git a/src/test/container-features/example-v2-features-sets/lifecycle-hooks/src/b/devcontainer-feature.json b/src/test/container-features/example-v2-features-sets/lifecycle-hooks/src/b/devcontainer-feature.json new file mode 100644 index 000000000..c44240502 --- /dev/null +++ b/src/test/container-features/example-v2-features-sets/lifecycle-hooks/src/b/devcontainer-feature.json @@ -0,0 +1,5 @@ +{ + "id": "b", + "version": "1.0.0", + "onCreateCommand": "touch b.onCreateCommand.testMarker && echo 'B-ON-CREATE-COMMAND'" +} \ No newline at end of file diff --git a/src/test/container-features/example-v2-features-sets/lifecycle-hooks/src/b/install.sh b/src/test/container-features/example-v2-features-sets/lifecycle-hooks/src/b/install.sh new file mode 100644 index 000000000..224934dda --- /dev/null +++ b/src/test/container-features/example-v2-features-sets/lifecycle-hooks/src/b/install.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -e + +echo "Activating feature 'b'" + +touch /usr/local/bin/b + diff --git a/src/test/container-features/example-v2-features-sets/lifecycle-hooks/test/a/test.sh b/src/test/container-features/example-v2-features-sets/lifecycle-hooks/test/a/test.sh new file mode 100644 index 000000000..036849417 --- /dev/null +++ b/src/test/container-features/example-v2-features-sets/lifecycle-hooks/test/a/test.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +test -f /usr/local/bin/a \ No newline at end of file diff --git a/src/test/container-features/example-v2-features-sets/lifecycle-hooks/test/b/test.sh b/src/test/container-features/example-v2-features-sets/lifecycle-hooks/test/b/test.sh new file mode 100644 index 000000000..fc39d54b3 --- /dev/null +++ b/src/test/container-features/example-v2-features-sets/lifecycle-hooks/test/b/test.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +test -f /usr/local/bin/b \ No newline at end of file diff --git a/src/test/container-features/featuresCLICommands.test.ts b/src/test/container-features/featuresCLICommands.test.ts index 419b0aaf1..3829b85c8 100644 --- a/src/test/container-features/featuresCLICommands.test.ts +++ b/src/test/container-features/featuresCLICommands.test.ts @@ -329,6 +329,33 @@ describe('CLI features subcommands', async function () { assert.isTrue(hasExpectedTestReport); }); + it('lifecycle-hooks', async function () { + const collectionFolder = `${__dirname}/example-v2-features-sets/lifecycle-hooks`; + let success = false; + let result: ExecResult | undefined = undefined; + try { + result = await shellExec(`${cli} features test --log-level trace ${collectionFolder}`); + success = true; + + } catch (error) { + assert.fail('features test sub-command should not throw'); + } + + assert.isTrue(success); + assert.isDefined(result); + + const onCreateFiredFeatureA = result.stderr.includes('A-ON-CREATE-COMMAND'); + assert.isTrue(onCreateFiredFeatureA); + const onCreateFiredFeatureB = result.stderr.includes('B-ON-CREATE-COMMAND'); + assert.isTrue(onCreateFiredFeatureB); + + const expectedTestReport = ` ================== TEST REPORT ================== +✅ Passed: 'a' +✅ Passed: 'b'`; + const hasExpectedTestReport = result.stdout.includes(expectedTestReport); + assert.isTrue(hasExpectedTestReport); + }); + it('installsAfter fruit -> hello', async function () { const collectionFolder = `${__dirname}/configs/example-installsAfter`; let result: ExecResult | undefined = undefined; diff --git a/src/test/container-features/generateFeaturesConfig.test.ts b/src/test/container-features/generateFeaturesConfig.test.ts index e36af4bf3..24fdcd875 100644 --- a/src/test/container-features/generateFeaturesConfig.test.ts +++ b/src/test/container-features/generateFeaturesConfig.test.ts @@ -72,18 +72,18 @@ describe('validate generateFeaturesConfig()', function () { // getFeatureLayers const actualLayers = getFeatureLayers(featuresConfig, 'testContainerUser', 'testRemoteUser'); const expectedLayers = `RUN \\ -echo "_CONTAINER_USER_HOME=$(getent passwd testContainerUser | cut -d: -f6)" >> /tmp/build-features/devcontainer-features.builtin.env && \\ -echo "_REMOTE_USER_HOME=$(getent passwd testRemoteUser | cut -d: -f6)" >> /tmp/build-features/devcontainer-features.builtin.env +echo "_CONTAINER_USER_HOME=$(getent passwd testContainerUser | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env && \\ +echo "_REMOTE_USER_HOME=$(getent passwd testRemoteUser | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env -COPY --chown=root:root --from=dev_containers_feature_content_source /tmp/build-features/first_1 /tmp/build-features/first_1 -RUN chmod -R 0700 /tmp/build-features/first_1 \\ -&& cd /tmp/build-features/first_1 \\ +COPY --chown=root:root --from=dev_containers_feature_content_source /tmp/build-features/first_1 /tmp/dev-container-features/first_1 +RUN chmod -R 0755 /tmp/dev-container-features/first_1 \\ +&& cd /tmp/dev-container-features/first_1 \\ && chmod +x ./install.sh \\ && ./install.sh -COPY --chown=root:root --from=dev_containers_feature_content_source /tmp/build-features/second_2 /tmp/build-features/second_2 -RUN chmod -R 0700 /tmp/build-features/second_2 \\ -&& cd /tmp/build-features/second_2 \\ +COPY --chown=root:root --from=dev_containers_feature_content_source /tmp/build-features/second_2 /tmp/dev-container-features/second_2 +RUN chmod -R 0755 /tmp/dev-container-features/second_2 \\ +&& cd /tmp/dev-container-features/second_2 \\ && chmod +x ./install.sh \\ && ./install.sh @@ -134,20 +134,20 @@ RUN chmod -R 0700 /tmp/build-features/second_2 \\ // getFeatureLayers const actualLayers = getFeatureLayers(featuresConfig, 'testContainerUser', 'testRemoteUser'); const expectedLayers = `RUN \\ -echo "_CONTAINER_USER_HOME=$(getent passwd testContainerUser | cut -d: -f6)" >> /tmp/build-features/devcontainer-features.builtin.env && \\ -echo "_REMOTE_USER_HOME=$(getent passwd testRemoteUser | cut -d: -f6)" >> /tmp/build-features/devcontainer-features.builtin.env +echo "_CONTAINER_USER_HOME=$(getent passwd testContainerUser | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env && \\ +echo "_REMOTE_USER_HOME=$(getent passwd testRemoteUser | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env -COPY --chown=root:root --from=dev_containers_feature_content_source /tmp/build-features/color_3 /tmp/build-features/color_3 -RUN chmod -R 0700 /tmp/build-features/color_3 \\ -&& cd /tmp/build-features/color_3 \\ +COPY --chown=root:root --from=dev_containers_feature_content_source /tmp/build-features/color_3 /tmp/dev-container-features/color_3 +RUN chmod -R 0755 /tmp/dev-container-features/color_3 \\ +&& cd /tmp/dev-container-features/color_3 \\ && chmod +x ./devcontainer-features-install.sh \\ && ./devcontainer-features-install.sh -COPY --chown=root:root --from=dev_containers_feature_content_source /tmp/build-features/hello_4 /tmp/build-features/hello_4 -RUN chmod -R 0700 /tmp/build-features/hello_4 \\ -&& cd /tmp/build-features/hello_4 \\ +COPY --chown=root:root --from=dev_containers_feature_content_source /tmp/build-features/hello_4 /tmp/dev-container-features/hello_4 +RUN chmod -R 0755 /tmp/dev-container-features/hello_4 \\ +&& cd /tmp/dev-container-features/hello_4 \\ && chmod +x ./devcontainer-features-install.sh \\ && ./devcontainer-features-install.sh diff --git a/src/test/container-features/lifecycleHooks.test.ts b/src/test/container-features/lifecycleHooks.test.ts new file mode 100644 index 000000000..14b6d3470 --- /dev/null +++ b/src/test/container-features/lifecycleHooks.test.ts @@ -0,0 +1,290 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { assert } from 'chai'; +import * as path from 'path'; +import { Feature } from '../../spec-configuration/containerFeaturesConfiguration'; +import { devContainerDown, devContainerStop, devContainerUp, shellExec } from '../testUtils'; + +const pkg = require('../../../package.json'); + +describe('Feature lifecycle hooks', function () { + this.timeout('120s'); + + const tmp = path.relative(process.cwd(), path.join(__dirname, 'tmp5')); + 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`); + }); + + describe('lifecycle-hooks-inline-commands', () => { + const testFolder = `${__dirname}/configs/lifecycle-hooks-inline-commands`; + + describe('devcontainer up', () => { + let containerId: string | null = null; + + before(async () => { + await shellExec(`rm -f ${testFolder}/*.testMarker`, undefined, undefined, true); + containerId = (await devContainerUp(cli, testFolder, { 'logLevel': 'trace' })).containerId; + }); + + after(async () => { + await devContainerDown({ containerId }); + await shellExec(`rm -f ${testFolder}/*.testMarker`, undefined, undefined, true); + }); + + it('marker files should exist, be executed in stable order, and hooks should postStart/attach should trigger on a resume', async () => { + + { + const res = await shellExec(`${cli} exec --workspace-folder ${testFolder} ls -altr`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + + const outputOfExecCommand = res.stderr; + console.log(outputOfExecCommand); + + assert.match(outputOfExecCommand, /0.panda.onCreateCommand.testMarker/); + assert.match(outputOfExecCommand, /3.panda.updateContentCommand.testMarker/); + assert.match(outputOfExecCommand, /6.panda.postCreateCommand.testMarker/); + assert.match(outputOfExecCommand, /9.panda.postStartCommand.testMarker/); + assert.match(outputOfExecCommand, /12.panda.postAttachCommand.testMarker/); + + assert.match(outputOfExecCommand, /1.tiger.onCreateCommand.testMarker/); + assert.match(outputOfExecCommand, /4.tiger.updateContentCommand.testMarker/); + assert.match(outputOfExecCommand, /7.tiger.postCreateCommand.testMarker/); + assert.match(outputOfExecCommand, /10.tiger.postStartCommand.testMarker/); + assert.match(outputOfExecCommand, /13.tiger.postAttachCommand.testMarker/); + + assert.match(outputOfExecCommand, /2.devContainer.onCreateCommand.testMarker/); + assert.match(outputOfExecCommand, /5.devContainer.updateContentCommand.testMarker/); + assert.match(outputOfExecCommand, /8.devContainer.postCreateCommand.testMarker/); + assert.match(outputOfExecCommand, /11.devContainer.postStartCommand.testMarker/); + assert.match(outputOfExecCommand, /14.devContainer.postAttachCommand.testMarker/); + + // This shouldn't have happened _yet_. + assert.notMatch(outputOfExecCommand, /15.panda.postStartCommand.testMarker/); + } + + // Stop the container. + await devContainerStop({ containerId }); + + { + // Attempt to bring the same container up, which should just re-run the postStart and postAttach hooks + const resume = await devContainerUp(cli, testFolder, { logLevel: 'trace' }); + assert.equal(resume.containerId, containerId); // Restarting the same container. + assert.equal(resume.outcome, 'success'); + + const res = await shellExec(`${cli} exec --workspace-folder ${testFolder} ls -altr`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + + const outputOfExecCommand = res.stderr; + console.log(outputOfExecCommand); + + assert.match(outputOfExecCommand, /15.panda.postStartCommand.testMarker/); + assert.match(outputOfExecCommand, /16.tiger.postStartCommand.testMarker/); + assert.match(outputOfExecCommand, /17.devContainer.postStartCommand.testMarker/); + assert.match(outputOfExecCommand, /18.panda.postAttachCommand.testMarker/); + assert.match(outputOfExecCommand, /19.tiger.postAttachCommand.testMarker/); + assert.match(outputOfExecCommand, /20.devContainer.postAttachCommand.testMarker/); + } + + + }); + }); + }); + + describe('lifecycle-hooks-alternative-order', () => { + // This is the same test as 'lifecycle-hooks-inline-commands' + // but with the the 'installsAfter' order changed (tiger -> panda -> devContainer). + + let containerId: string | null = null; + const testFolder = `${__dirname}/configs/temp_lifecycle-hooks-alternative-order`; + + before(async () => { + await shellExec(`rm -rf ${testFolder}`, undefined, undefined, true); + await shellExec(`mkdir -p ${testFolder}`); + await shellExec(`bash -c 'cp -r . ${testFolder}'`, { cwd: `${__dirname}/configs/lifecycle-hooks-inline-commands` }); + + // Read in the JSON from the two Feature's devcontainer-feature.json + const pandaFeatureJson: Feature = JSON.parse((await shellExec(`cat ${testFolder}/.devcontainer/panda/devcontainer-feature.json`)).stdout); + const tigerFeatureJson: Feature = JSON.parse((await shellExec(`cat ${testFolder}/.devcontainer/tiger/devcontainer-feature.json`)).stdout); + + // Remove the installsAfter from the tiger's devcontainer-feature.json and add it to the panda's devcontainer-feature.json + delete tigerFeatureJson.installsAfter; + pandaFeatureJson.installsAfter = ['./tiger']; + + // Write the JSON back to the two Feature's devcontainer-feature.json + await shellExec(`echo '${JSON.stringify(pandaFeatureJson)}' > ${testFolder}/.devcontainer/panda/devcontainer-feature.json`); + await shellExec(`echo '${JSON.stringify(tigerFeatureJson)}' > ${testFolder}/.devcontainer/tiger/devcontainer-feature.json`); + + containerId = (await devContainerUp(cli, testFolder, { 'logLevel': 'trace' })).containerId; + }); + + after(async () => { + await devContainerDown({ containerId }); + await shellExec(`rm -rf ${testFolder}`, undefined, undefined, true); + }); + + it('marker files should exist and executed in stable order', async () => { + const res = await shellExec(`${cli} exec --workspace-folder ${testFolder} ls -altr`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + + const outputOfExecCommand = res.stderr; + console.log(outputOfExecCommand); + + assert.match(outputOfExecCommand, /0.tiger.onCreateCommand.testMarker/); + assert.match(outputOfExecCommand, /3.tiger.updateContentCommand.testMarker/); + assert.match(outputOfExecCommand, /6.tiger.postCreateCommand.testMarker/); + assert.match(outputOfExecCommand, /9.tiger.postStartCommand.testMarker/); + assert.match(outputOfExecCommand, /12.tiger.postAttachCommand.testMarker/); + + assert.match(outputOfExecCommand, /1.panda.onCreateCommand.testMarker/); + assert.match(outputOfExecCommand, /4.panda.updateContentCommand.testMarker/); + assert.match(outputOfExecCommand, /7.panda.postCreateCommand.testMarker/); + assert.match(outputOfExecCommand, /10.panda.postStartCommand.testMarker/); + assert.match(outputOfExecCommand, /13.panda.postAttachCommand.testMarker/); + + assert.match(outputOfExecCommand, /2.devContainer.onCreateCommand.testMarker/); + assert.match(outputOfExecCommand, /5.devContainer.updateContentCommand.testMarker/); + assert.match(outputOfExecCommand, /8.devContainer.postCreateCommand.testMarker/); + assert.match(outputOfExecCommand, /11.devContainer.postStartCommand.testMarker/); + assert.match(outputOfExecCommand, /14.devContainer.postAttachCommand.testMarker/); + }); + + }); + + describe('lifecycle-hooks-resume-existing-container', () => { + let containerId: string | null = null; + const testFolder = `${__dirname}/configs/lifecycle-hooks-resume-existing-container`; + + // Clean up + before(async () => { + await shellExec(`rm -f ${testFolder}/*.testMarker`, undefined, undefined, true); + containerId = (await devContainerUp(cli, testFolder, { 'logLevel': 'trace' })).containerId; + }); + + // Ensure clean after running. + after(async () => { + await devContainerDown({ containerId, doNotThrow: true }); + await shellExec(`rm -f ${testFolder}/*.testMarker`, undefined, undefined, true); + }); + + it('the appropriate lifecycle hooks are executed when resuming an existing container', async () => { + + await devContainerStop({ containerId }); + // Attempt to bring the same container up, which should just re-run the postStart and postAttach hooks + const resume = await devContainerUp(cli, testFolder, { logLevel: 'trace' }); + assert.equal(resume.containerId, containerId); // Restarting the same container. + assert.equal(resume.outcome, 'success'); + + const res = await shellExec(`${cli} exec --workspace-folder ${testFolder} ls -altr`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + + const outputOfExecCommand = res.stderr; + console.log(outputOfExecCommand); + + assert.match(outputOfExecCommand, /0.hippo.postStartCommand.testMarker/); + assert.match(outputOfExecCommand, /1.hippo.postAttachCommand.testMarker/); + assert.match(outputOfExecCommand, /2.hippo.postStartCommand.testMarker/); + assert.match(outputOfExecCommand, /3.hippo.postAttachCommand.testMarker/); + }); + }); + + describe('lifecycle-hooks-advanced', () => { + + describe(`devcontainer up`, () => { + let containerId: string | null = null; + let containerUpStandardError: string; + const testFolder = `${__dirname}/configs/lifecycle-hooks-advanced`; + + before(async () => { + await shellExec(`rm -f ${testFolder}/*.testMarker`, undefined, undefined, true); + const res = await devContainerUp(cli, testFolder, { 'logLevel': 'trace' }); + containerId = res.containerId; + containerUpStandardError = res.stderr; + }); + + after(async () => { + await devContainerDown({ containerId }); + await shellExec(`rm -f ${testFolder}/*.testMarker`, undefined, undefined, true); + }); + + it('executes lifecycle hooks in advanced cases', async () => { + const res = await shellExec(`${cli} exec --workspace-folder ${testFolder} ls -altr`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + + const outputOfExecCommand = res.stderr; + console.log(outputOfExecCommand); + + // Executes the command that was installed by each Feature's 'install.sh'. + // The command is installed to a directory on the $PATH so it can be executed from the lifecycle script. + assert.match(outputOfExecCommand, /i-am-a-rabbit.postStartCommand.testMarker/); + assert.match(containerUpStandardError, /Running the postCreateCommand from devcontainer.json/); + + assert.match(outputOfExecCommand, /i-am-an-otter.postAttachCommand.testMarker/); + assert.match(containerUpStandardError, /Running the postAttachCommand from devcontainer.json/); + + assert.match(outputOfExecCommand, /helperScript.devContainer.parallel_postCreateCommand_1.testMarker/); + assert.match(containerUpStandardError, /Running parallel1 from devcontainer.json.../); + + assert.match(outputOfExecCommand, /helperScript.devContainer.parallel_postCreateCommand_2.testMarker/); + assert.match(containerUpStandardError, /Running parallel2 from devcontainer.json.../); + + // Since lifecycle scripts are executed relative to the workspace folder, + // to run a script bundled with the Feature, the Feature author needs to copy that script to a persistent directory. + // These Features' install scripts do that. + + // -- 'Rabbit' Feature + assert.match(outputOfExecCommand, /helperScript.rabbit.onCreateCommand.testMarker/); + assert.match(containerUpStandardError, /Running the onCreateCommand from Feature '\.\/rabbit'/); + + assert.match(outputOfExecCommand, /helperScript.rabbit.updateContentCommand.testMarker/); + assert.match(containerUpStandardError, /Running the updateContentCommand from Feature '\.\/rabbit'/); + + assert.match(outputOfExecCommand, /helperScript.rabbit.postStartCommand.testMarker/); + assert.match(containerUpStandardError, /Running the postStartCommand from Feature '\.\/rabbit'/); + + assert.match(outputOfExecCommand, /helperScript.rabbit.postAttachCommand.testMarker/); + assert.match(containerUpStandardError, /Running the postAttachCommand from Feature '\.\/rabbit'/); + + assert.match(outputOfExecCommand, /helperScript.rabbit.parallel_postCreateCommand_1.testMarker/); + assert.match(containerUpStandardError, /Running parallel1 from Feature '\.\/rabbit'/); + + assert.match(outputOfExecCommand, /helperScript.rabbit.parallel_postCreateCommand_2.testMarker/); + assert.match(containerUpStandardError, /Running parallel2 from Feature '\.\/rabbit'/); + + + // -- 'Otter' Feature + assert.match(outputOfExecCommand, /helperScript.otter.onCreateCommand.testMarker/); + assert.match(containerUpStandardError, /Running the onCreateCommand from Feature '\.\/otter'/); + + assert.match(outputOfExecCommand, /helperScript.otter.updateContentCommand.testMarker/); + assert.match(containerUpStandardError, /Running the updateContentCommand from Feature '\.\/otter'/); + + assert.match(outputOfExecCommand, /helperScript.otter.postStartCommand.testMarker/); + assert.match(containerUpStandardError, /Running the postStartCommand from Feature '\.\/otter'/); + + assert.match(outputOfExecCommand, /helperScript.otter.postAttachCommand.testMarker/); + assert.match(containerUpStandardError, /Running the postAttachCommand from Feature '\.\/otter'/); + + assert.match(outputOfExecCommand, /helperScript.otter.parallel_postCreateCommand_1.testMarker/); + assert.match(containerUpStandardError, /Running parallel1 from Feature '\.\/otter'/); + + assert.match(outputOfExecCommand, /helperScript.otter.parallel_postCreateCommand_2.testMarker/); + assert.match(containerUpStandardError, /Running parallel2 from Feature '\.\/otter'/); + + // -- Assert that at no point did logging the lifecycle hook fail. + assert.notMatch(containerUpStandardError, /Running the (.*) from \?\?\?/); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/test/imageMetadata.test.ts b/src/test/imageMetadata.test.ts index c94ee3eb2..666b02d6c 100644 --- a/src/test/imageMetadata.test.ts +++ b/src/test/imageMetadata.test.ts @@ -56,6 +56,20 @@ describe('Image Metadata', function () { assert.strictEqual(metadata[0].id, 'baseFeature-substituted'); assert.strictEqual(metadata[1].id, './localFeatureA-substituted'); assert.strictEqual(metadata[1].init, true); + + assert.deepStrictEqual(metadata[1].updateContentCommand, ['one', 'two']); + assert.deepStrictEqual(metadata[1].onCreateCommand, { + 'command': 'three', + 'commandWithArgs': [ + 'four', + 'arg1', + 'arg2' + ] + }); + assert.deepStrictEqual(metadata[1].postCreateCommand, 'five'); + assert.deepStrictEqual(metadata[1].postStartCommand, 'six'); + assert.deepStrictEqual(metadata[1].postAttachCommand, 'seven'); + assert.strictEqual(metadata[2].id, './localFeatureB-substituted'); assert.strictEqual(metadata[2].privileged, true); assert.strictEqual(raw.length, 3); @@ -176,6 +190,7 @@ describe('Image Metadata', function () { id: 'someFeature', value: 'someValue', included: true, + consecutiveId: 'someFeature_1', } ])); assert.strictEqual(metadata.length, 2); @@ -334,6 +349,7 @@ describe('Image Metadata', function () { id: 'someFeature', value: 'someValue', included: true, + consecutiveId: 'someFeature_1', } ])), true); const expected = [ diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts index de7288a20..5c116822e 100644 --- a/src/test/testUtils.ts +++ b/src/test/testUtils.ts @@ -21,6 +21,7 @@ export interface UpResult { outcome: string; containerId: string; composeProjectName: string | undefined; + stderr: string; } export interface ExecResult { @@ -52,14 +53,14 @@ export async function devContainerUp(cli: string, workspaceFolder: string, optio assert.equal(response.outcome, 'success'); const { outcome, containerId, composeProjectName } = response as UpResult; assert.ok(containerId, 'Container id not found.'); - return { outcome, containerId, composeProjectName }; + return { outcome, containerId, composeProjectName, stderr: res.stderr }; } -export async function devContainerDown(options: { containerId?: string | null; composeProjectName?: string | null }) { +export async function devContainerDown(options: { containerId?: string | null; composeProjectName?: string | null; doNotThrow?: boolean }) { if (options.containerId) { - await shellExec(`docker rm -f ${options.containerId}`); + await shellExec(`docker rm -f ${options.containerId}`, undefined, undefined, options.doNotThrow); } if (options.composeProjectName) { - await shellExec(`docker compose --project-name ${options.composeProjectName} down`); + await shellExec(`docker compose --project-name ${options.composeProjectName} down`, undefined, undefined, options.doNotThrow); } } export async function devContainerStop(options: { containerId?: string | null; composeProjectName?: string | null }) {