Skip to content

Commit d52e282

Browse files
committed
User env variables for features (devcontainers/spec#91)
1 parent 848d348 commit d52e282

8 files changed

+118
-25
lines changed

src/spec-configuration/containerFeaturesConfiguration.ts

+8-4
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ echo '${optionsIndented}'
258258
echo ===========================================================================
259259
260260
set -a
261+
. ../devcontainer-features.builtin.env
261262
. ./devcontainer-features.env
262263
set +a
263264
@@ -274,8 +275,12 @@ function escapeQuotesForShell(input: string) {
274275
return input.replace(new RegExp(`'`, 'g'), `'\\''`);
275276
}
276277

277-
export function getFeatureLayers(featuresConfig: FeaturesConfig) {
278-
let result = '';
278+
export function getFeatureLayers(featuresConfig: FeaturesConfig, containerUser: string, remoteUser: string) {
279+
let result = `RUN \\
280+
echo "_CONTAINER_USER_HOME=$(getent passwd ${containerUser} | cut -d: -f6)" >> /tmp/build-features/devcontainer-features.builtin.env && \\
281+
echo "_REMOTE_USER_HOME=$(getent passwd ${remoteUser} | cut -d: -f6)" >> /tmp/build-features/devcontainer-features.builtin.env
282+
283+
`;
279284

280285
// Features version 1
281286
const folders = (featuresConfig.featureSets || []).filter(y => y.internalVersion !== '2').map(x => x.features[0].consecutiveId);
@@ -290,8 +295,7 @@ export function getFeatureLayers(featuresConfig: FeaturesConfig) {
290295
featuresConfig.featureSets.filter(y => y.internalVersion === '2').forEach(featureSet => {
291296
featureSet.features.forEach(feature => {
292297
result += generateContainerEnvs(feature);
293-
result += `
294-
RUN cd /tmp/build-features/${feature.consecutiveId} \\
298+
result += `RUN cd /tmp/build-features/${feature.consecutiveId} \\
295299
&& chmod +x ./devcontainer-features-install.sh \\
296300
&& ./devcontainer-features-install.sh
297301

src/spec-node/containerFeatures.ts

+24-8
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { readLocalFile } from '../spec-utils/pfs';
1515
import { includeAllConfiguredFeatures } from '../spec-utils/product';
1616
import { createFeaturesTempFolder, DockerResolverParameters, getCacheFolder, getFolderImageName, getEmptyContextFolder, SubstitutedConfig } from './utils';
1717
import { isEarlierVersion, parseVersion } from '../spec-common/commonUtils';
18-
import { getDevcontainerMetadata, getDevcontainerMetadataLabel, getImageBuildInfoFromImage, ImageBuildInfo, MergedDevContainerConfig } from './imageMetadata';
18+
import { getDevcontainerMetadata, getDevcontainerMetadataLabel, getImageBuildInfoFromImage, ImageBuildInfo, ImageMetadataEntry, MergedDevContainerConfig } from './imageMetadata';
1919
import { supportsBuildContexts } from './dockerfileUtils';
2020

2121
// Escapes environment variable keys.
@@ -34,7 +34,7 @@ export async function extendImage(params: DockerResolverParameters, config: Subs
3434
const { cliHost, output } = common;
3535

3636
const imageBuildInfo = await getImageBuildInfoFromImage(params, imageName, config.substitute, common.experimentalImageMetadata);
37-
const extendImageDetails = await getExtendImageBuildInfo(params, config, imageName, imageBuildInfo, additionalFeatures);
37+
const extendImageDetails = await getExtendImageBuildInfo(params, config, imageName, imageBuildInfo, undefined, additionalFeatures);
3838
if (!extendImageDetails || !extendImageDetails.featureBuildInfo) {
3939
// no feature extensions - return
4040
return {
@@ -94,7 +94,7 @@ export async function extendImage(params: DockerResolverParameters, config: Subs
9494
};
9595
}
9696

97-
export async function getExtendImageBuildInfo(params: DockerResolverParameters, config: SubstitutedConfig<DevContainerConfig>, baseName: string, imageBuildInfo: ImageBuildInfo, additionalFeatures: Record<string, string | boolean | Record<string, string | boolean>>) {
97+
export async function getExtendImageBuildInfo(params: DockerResolverParameters, config: SubstitutedConfig<DevContainerConfig>, baseName: string, imageBuildInfo: ImageBuildInfo, composeServiceUser: string | undefined, additionalFeatures: Record<string, string | boolean | Record<string, string | boolean>>) {
9898

9999
// Creates the folder where the working files will be setup.
100100
const dstFolder = await createFeaturesTempFolder(params.common);
@@ -109,7 +109,7 @@ export async function getExtendImageBuildInfo(params: DockerResolverParameters,
109109
}
110110

111111
// Generates the end configuration.
112-
const featureBuildInfo = await getFeaturesBuildOptions(params, config, featuresConfig, baseName, imageBuildInfo);
112+
const featureBuildInfo = await getFeaturesBuildOptions(params, config, featuresConfig, baseName, imageBuildInfo, composeServiceUser);
113113
if (!featureBuildInfo) {
114114
return undefined;
115115
}
@@ -191,7 +191,7 @@ function getImageBuildOptions(params: DockerResolverParameters, config: Substitu
191191
dstFolder,
192192
dockerfileContent: `
193193
FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage
194-
${getDevcontainerMetadataLabel(imageBuildInfo.metadata, config, { featureSets: [] }, params.common.experimentalImageMetadata)}
194+
${getDevcontainerMetadataLabel(getDevcontainerMetadata(imageBuildInfo.metadata, config, { featureSets: [] }), params.common.experimentalImageMetadata)}
195195
`,
196196
overrideTarget: 'dev_containers_target_stage',
197197
dockerfilePrefixContent: `
@@ -204,7 +204,7 @@ ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder
204204
};
205205
}
206206

207-
async function getFeaturesBuildOptions(params: DockerResolverParameters, devContainerConfig: SubstitutedConfig<DevContainerConfig>, featuresConfig: FeaturesConfig, baseName: string, imageBuildInfo: ImageBuildInfo): Promise<ImageBuildOptions | undefined> {
207+
async function getFeaturesBuildOptions(params: DockerResolverParameters, devContainerConfig: SubstitutedConfig<DevContainerConfig>, featuresConfig: FeaturesConfig, baseName: string, imageBuildInfo: ImageBuildInfo, composeServiceUser: string | undefined): Promise<ImageBuildOptions | undefined> {
208208
const { common } = params;
209209
const { cliHost, output } = common;
210210
const { dstFolder } = featuresConfig;
@@ -239,17 +239,26 @@ async function getFeaturesBuildOptions(params: DockerResolverParameters, devCont
239239
const useBuildKitBuildContexts = buildKitVersionParsed ? !isEarlierVersion(buildKitVersionParsed, minRequiredVersion) : false;
240240
const buildContentImageName = 'dev_container_feature_content_temp';
241241

242+
const imageMetadata = getDevcontainerMetadata(imageBuildInfo.metadata, devContainerConfig, featuresConfig);
243+
const { containerUser, remoteUser } = findContainerUsers(imageMetadata, composeServiceUser, imageBuildInfo.user);
244+
const builtinVariables = [
245+
`_CONTAINER_USER=${containerUser}`,
246+
`_REMOTE_USER=${remoteUser}`,
247+
];
248+
const envPath = cliHost.path.join(dstFolder, 'devcontainer-features.builtin.env');
249+
await cliHost.writeFile(envPath, Buffer.from(builtinVariables.join('\n') + '\n'));
250+
242251
// When copying via buildkit, the content is accessed via '.' (i.e. in the context root)
243252
// When copying via temp image, the content is in '/tmp/build-features'
244253
const contentSourceRootPath = useBuildKitBuildContexts ? '.' : '/tmp/build-features/';
245254
const dockerfile = getContainerFeaturesBaseDockerFile()
246255
.replace('#{nonBuildKitFeatureContentFallback}', useBuildKitBuildContexts ? '' : `FROM ${buildContentImageName} as dev_containers_feature_content_source`)
247256
.replace('{contentSourceRootPath}', contentSourceRootPath)
248257
.replace('#{featureBuildStages}', getFeatureBuildStages(featuresConfig, buildStageScripts, contentSourceRootPath))
249-
.replace('#{featureLayer}', getFeatureLayers(featuresConfig))
258+
.replace('#{featureLayer}', getFeatureLayers(featuresConfig, containerUser, remoteUser))
250259
.replace('#{containerEnv}', generateContainerEnvs(featuresConfig))
251260
.replace('#{copyFeatureBuildStages}', getCopyFeatureBuildStages(featuresConfig, buildStageScripts))
252-
.replace('#{devcontainerMetadata}', getDevcontainerMetadataLabel(imageBuildInfo.metadata, devContainerConfig, featuresConfig, common.experimentalImageMetadata))
261+
.replace('#{devcontainerMetadata}', getDevcontainerMetadataLabel(imageMetadata, common.experimentalImageMetadata))
253262
;
254263
const syntax = imageBuildInfo.dockerfile?.preamble.directives.syntax;
255264
const dockerfilePrefixContent = `${useBuildKitBuildContexts && !(imageBuildInfo.dockerfile && supportsBuildContexts(imageBuildInfo.dockerfile)) ?
@@ -338,6 +347,13 @@ ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder
338347
};
339348
}
340349

350+
export function findContainerUsers(imageMetadata: SubstitutedConfig<ImageMetadataEntry[]>, composeServiceUser: string | undefined, imageUser: string) {
351+
const reversed = imageMetadata.config.slice().reverse();
352+
const containerUser = reversed.find(entry => entry.containerUser)?.containerUser || composeServiceUser || imageUser;
353+
const remoteUser = reversed.find(entry => entry.remoteUser)?.remoteUser || containerUser;
354+
return { containerUser, remoteUser };
355+
}
356+
341357
function getFeatureBuildStages(featuresConfig: FeaturesConfig, buildStageScripts: Record<string, { hasAcquire: boolean; hasConfigure: boolean } | undefined>[], contentSourceRootPath: string) {
342358
return ([] as string[]).concat(...featuresConfig.featureSets
343359
.map((featureSet, i) => featureSet.features

src/spec-node/dockerCompose.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf
182182
// determine whether we need to extend with features
183183
const noBuildKitParams = { ...params, buildKitVersion: null }; // skip BuildKit -> can't set additional build contexts with compose
184184
const imageBuildInfo = await getImageBuildInfoFromDockerfile(params, originalDockerfile, serviceInfo.build?.args || {}, serviceInfo.build?.target, configWithRaw.substitute, common.experimentalImageMetadata);
185-
const extendImageBuildInfo = await getExtendImageBuildInfo(noBuildKitParams, configWithRaw, baseName, imageBuildInfo, additionalFeatures);
185+
const extendImageBuildInfo = await getExtendImageBuildInfo(noBuildKitParams, configWithRaw, baseName, imageBuildInfo, composeService.user, additionalFeatures);
186186

187187
let overrideImageName: string | undefined;
188188
let buildOverrideContent = '';

src/spec-node/imageMetadata.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -359,11 +359,11 @@ function internalGetImageMetadata0(imageDetails: ImageDetails | ContainerDetails
359359
return [];
360360
}
361361

362-
export function getDevcontainerMetadataLabel(baseImageMetadata: SubstitutedConfig<ImageMetadataEntry[]>, devContainerConfig: SubstitutedConfig<DevContainerConfig>, featuresConfig: FeaturesConfig, experimentalImageMetadata: boolean) {
362+
export function getDevcontainerMetadataLabel(devContainerMetadata: SubstitutedConfig<ImageMetadataEntry[]>, experimentalImageMetadata: boolean) {
363363
if (!experimentalImageMetadata) {
364364
return '';
365365
}
366-
const metadata = getDevcontainerMetadata(baseImageMetadata, devContainerConfig, featuresConfig).raw;
366+
const metadata = devContainerMetadata.raw;
367367
if (!metadata.length) {
368368
return '';
369369
}

src/spec-node/singleContainer.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ async function buildAndExtendImage(buildParams: DockerResolverParameters, config
147147
}
148148

149149
const imageBuildInfo = await getImageBuildInfoFromDockerfile(buildParams, originalDockerfile, config.build?.args || {}, config.build?.target, configWithRaw.substitute, buildParams.common.experimentalImageMetadata);
150-
const extendImageBuildInfo = await getExtendImageBuildInfo(buildParams, configWithRaw, baseName, imageBuildInfo, additionalFeatures);
150+
const extendImageBuildInfo = await getExtendImageBuildInfo(buildParams, configWithRaw, baseName, imageBuildInfo, undefined, additionalFeatures);
151151

152152
let finalDockerfilePath = dockerfilePath;
153153
const additionalBuildArgs: string[] = [];

src/test/container-features/featureHelpers.test.ts

+69-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import * as path from 'path';
33
import { DevContainerFeature } from '../../spec-configuration/configuration';
44
import { OCIRef } from '../../spec-configuration/containerCollectionsOCI';
55
import { Feature, FeatureSet, getBackwardCompatibleFeatureId, getFeatureInstallWrapperScript, processFeatureIdentifier } from '../../spec-configuration/containerFeaturesConfiguration';
6-
import { getSafeId } from '../../spec-node/containerFeatures';
6+
import { getSafeId, findContainerUsers } from '../../spec-node/containerFeatures';
7+
import { ImageMetadataEntry } from '../../spec-node/imageMetadata';
8+
import { SubstitutedConfig } from '../../spec-node/utils';
79
import { createPlainLog, LogLevel, makeLog } from '../../spec-utils/log';
810

911
export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace));
@@ -487,6 +489,7 @@ echo ''
487489
echo ===========================================================================
488490
489491
set -a
492+
. ../devcontainer-features.builtin.env
490493
. ./devcontainer-features.env
491494
set +a
492495
@@ -566,6 +569,7 @@ echo ' VERSION=latest
566569
echo ===========================================================================
567570
568571
set -a
572+
. ../devcontainer-features.builtin.env
569573
. ./devcontainer-features.env
570574
set +a
571575
@@ -576,4 +580,67 @@ chmod +x ./install.sh
576580
const actual = getFeatureInstallWrapperScript(feature, set, options);
577581
assert.equal(actual, expected);
578582
});
579-
});
583+
});
584+
585+
describe('findContainerUsers', () => {
586+
it('returns last metadata containerUser as containerUser and remoteUser', () => {
587+
assert.deepEqual(findContainerUsers(configWithRaw([
588+
{
589+
containerUser: 'metadataTestUser1',
590+
},
591+
{
592+
containerUser: 'metadataTestUser2',
593+
},
594+
]), 'composeTestUser', 'imageTestUser'), {
595+
containerUser: 'metadataTestUser2',
596+
remoteUser: 'metadataTestUser2',
597+
});
598+
});
599+
it('returns compose service user as containerUser and remoteUser', () => {
600+
assert.deepEqual(findContainerUsers(configWithRaw<ImageMetadataEntry[]>([
601+
{
602+
remoteEnv: { foo: 'bar' },
603+
},
604+
{
605+
remoteEnv: { bar: 'baz' },
606+
},
607+
]), 'composeTestUser', 'imageTestUser'), {
608+
containerUser: 'composeTestUser',
609+
remoteUser: 'composeTestUser',
610+
});
611+
});
612+
it('returns image user as containerUser and remoteUser', () => {
613+
assert.deepEqual(findContainerUsers(configWithRaw<ImageMetadataEntry[]>([
614+
{
615+
remoteEnv: { foo: 'bar' },
616+
},
617+
{
618+
remoteEnv: { bar: 'baz' },
619+
},
620+
]), undefined, 'imageTestUser'), {
621+
containerUser: 'imageTestUser',
622+
remoteUser: 'imageTestUser',
623+
});
624+
});
625+
it('returns last metadata remoteUser', () => {
626+
assert.deepEqual(findContainerUsers(configWithRaw([
627+
{
628+
remoteUser: 'metadataTestUser1',
629+
},
630+
{
631+
remoteUser: 'metadataTestUser2',
632+
},
633+
]), 'composeTestUser', 'imageTestUser'), {
634+
containerUser: 'composeTestUser',
635+
remoteUser: 'metadataTestUser2',
636+
});
637+
});
638+
});
639+
640+
function configWithRaw<T>(config: T): SubstitutedConfig<T> {
641+
return {
642+
config,
643+
raw: config,
644+
substitute: config => config,
645+
};
646+
}

src/test/container-features/generateFeaturesConfig.test.ts

+11-5
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,12 @@ describe('validate generateFeaturesConfig()', function () {
6868
// assert.strictEqual(actualEnvs, expectedEnvs);
6969

7070
// getFeatureLayers
71-
const actualLayers = getFeatureLayers(featuresConfig);
72-
const expectedLayers = `RUN cd /tmp/build-features/first_1 \\
71+
const actualLayers = getFeatureLayers(featuresConfig, 'testContainerUser', 'testRemoteUser');
72+
const expectedLayers = `RUN \\
73+
echo "_CONTAINER_USER_HOME=$(getent passwd testContainerUser | cut -d: -f6)" >> /tmp/build-features/devcontainer-features.builtin.env && \\
74+
echo "_REMOTE_USER_HOME=$(getent passwd testRemoteUser | cut -d: -f6)" >> /tmp/build-features/devcontainer-features.builtin.env
75+
76+
RUN cd /tmp/build-features/first_1 \\
7377
&& chmod +x ./install.sh \\
7478
&& ./install.sh
7579
@@ -122,13 +126,15 @@ RUN cd /tmp/build-features/second_2 \\
122126
// -- Test containerFeatures.ts helper functions
123127

124128
// getFeatureLayers
125-
const actualLayers = getFeatureLayers(featuresConfig);
126-
const expectedLayers = `
129+
const actualLayers = getFeatureLayers(featuresConfig, 'testContainerUser', 'testRemoteUser');
130+
const expectedLayers = `RUN \\
131+
echo "_CONTAINER_USER_HOME=$(getent passwd testContainerUser | cut -d: -f6)" >> /tmp/build-features/devcontainer-features.builtin.env && \\
132+
echo "_REMOTE_USER_HOME=$(getent passwd testRemoteUser | cut -d: -f6)" >> /tmp/build-features/devcontainer-features.builtin.env
133+
127134
RUN cd /tmp/build-features/color_3 \\
128135
&& chmod +x ./devcontainer-features-install.sh \\
129136
&& ./devcontainer-features-install.sh
130137
131-
132138
RUN cd /tmp/build-features/hello_4 \\
133139
&& chmod +x ./devcontainer-features-install.sh \\
134140
&& ./devcontainer-features-install.sh

src/test/imageMetadata.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ describe('Image Metadata', function () {
180180
});
181181

182182
it('should create label for Dockerfile', () => {
183-
const label = getDevcontainerMetadataLabel(configWithRaw([
183+
const label = getDevcontainerMetadataLabel(getDevcontainerMetadata(configWithRaw([
184184
{
185185
id: 'baseFeature',
186186
}
@@ -194,7 +194,7 @@ describe('Image Metadata', function () {
194194
value: 'someValue',
195195
included: true,
196196
}
197-
]), true);
197+
])), true);
198198
const expected = [
199199
{
200200
id: 'baseFeature',

0 commit comments

Comments
 (0)