Skip to content

Commit 5833785

Browse files
committed
feat: support build secrets in features
This adds support for using Docker build secrets in devcontainer features, allowing sensitive information to be used during the build process without being included in the final image.
1 parent 721c21b commit 5833785

16 files changed

Lines changed: 240 additions & 13 deletions

File tree

src/spec-configuration/containerFeaturesConfiguration.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,17 @@ function escapeQuotesForShell(input: string) {
289289
return input.replace(new RegExp(`'`, 'g'), `'\\''`);
290290
}
291291

292-
export function getFeatureLayers(featuresConfig: FeaturesConfig, containerUser: string, remoteUser: string, useBuildKitBuildContexts = false, contentSourceRootPath = '/tmp/build-features') {
292+
export interface BuildSecret {
293+
id: string;
294+
file?: string;
295+
env?: string;
296+
}
297+
298+
function getSecretMounts(buildSecrets: BuildSecret[]): string {
299+
return buildSecrets.map(secret => `--mount=type=secret,id=${secret.id}`).join(' ');
300+
}
301+
302+
export function getFeatureLayers(featuresConfig: FeaturesConfig, containerUser: string, remoteUser: string, useBuildKitBuildContexts = false, contentSourceRootPath = '/tmp/build-features', buildSecrets: BuildSecret[] = []) {
293303

294304
const builtinsEnvFile = `${path.posix.join(FEATURES_CONTAINER_TEMP_DEST_FOLDER, 'devcontainer-features.builtin.env')}`;
295305
let result = `RUN \\
@@ -312,8 +322,10 @@ RUN chmod -R 0755 ${dest} \\
312322
313323
`;
314324
} else {
315-
result += `RUN --mount=type=bind,from=dev_containers_feature_content_source,source=${source},target=/tmp/build-features-src/${folder} \\
316-
cp -ar /tmp/build-features-src/${folder} ${FEATURES_CONTAINER_TEMP_DEST_FOLDER} \\
325+
const secretMounts = getSecretMounts(buildSecrets);
326+
const runPrefix = secretMounts ? `RUN ${secretMounts} ` : 'RUN ';
327+
result += `${runPrefix}--mount=type=bind,from=dev_containers_feature_content_source,source=${source},target=/tmp/build-features-src/${folder} \\
328+
cp -ar /tmp/build-features-src/${folder} ${FEATURES_CONTAINER_TEMP_DEST_FOLDER} \\
317329
&& chmod -R 0755 ${dest} \\
318330
&& cd ${dest} \\
319331
&& chmod +x ./install.sh \\
@@ -339,9 +351,11 @@ RUN chmod -R 0755 ${dest} \\
339351
340352
`;
341353
} else {
354+
const secretMounts = getSecretMounts(buildSecrets);
355+
const runPrefix = secretMounts ? `RUN ${secretMounts} ` : 'RUN ';
342356
result += `
343-
RUN --mount=type=bind,from=dev_containers_feature_content_source,source=${source},target=/tmp/build-features-src/${feature.consecutiveId} \\
344-
cp -ar /tmp/build-features-src/${feature.consecutiveId} ${FEATURES_CONTAINER_TEMP_DEST_FOLDER} \\
357+
${runPrefix}--mount=type=bind,from=dev_containers_feature_content_source,source=${source},target=/tmp/build-features-src/${feature.consecutiveId} \\
358+
cp -ar /tmp/build-features-src/${feature.consecutiveId} ${FEATURES_CONTAINER_TEMP_DEST_FOLDER} \\
345359
&& chmod -R 0755 ${dest} \\
346360
&& cd ${dest} \\
347361
&& chmod +x ./devcontainer-features-install.sh \\

src/spec-node/containerFeatures.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ export async function extendImage(params: DockerResolverParameters, config: Subs
9696
for (const securityOpt of featureBuildInfo.securityOpts) {
9797
args.push('--security-opt', securityOpt);
9898
}
99+
100+
for (const secret of params.buildSecrets) {
101+
if (secret.file) {
102+
args.push('--secret', `id=${secret.id},src=${secret.file}`);
103+
} else if (secret.env) {
104+
args.push('--secret', `id=${secret.id},env=${secret.env}`);
105+
}
106+
}
99107
} else {
100108
// Not using buildx
101109
args.push(
@@ -257,7 +265,7 @@ async function getFeaturesBuildOptions(params: DockerResolverParameters, devCont
257265
const contentSourceRootPath = useBuildKitBuildContexts ? '.' : '/tmp/build-features/';
258266
const dockerfile = getContainerFeaturesBaseDockerFile(contentSourceRootPath)
259267
.replace('#{nonBuildKitFeatureContentFallback}', useBuildKitBuildContexts ? '' : `FROM ${buildContentImageName} as dev_containers_feature_content_source`)
260-
.replace('#{featureLayer}', getFeatureLayers(featuresConfig, containerUser, remoteUser, useBuildKitBuildContexts, contentSourceRootPath))
268+
.replace('#{featureLayer}', getFeatureLayers(featuresConfig, containerUser, remoteUser, useBuildKitBuildContexts, contentSourceRootPath, params.buildSecrets))
261269
.replace('#{containerEnv}', generateContainerEnvsV1(featuresConfig))
262270
.replace('#{devcontainerMetadata}', getDevcontainerMetadataLabel(imageMetadata))
263271
.replace('#{containerEnvMetadata}', generateContainerEnvs(devContainerConfig.config.containerEnv, true))

src/spec-node/devContainers.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import * as crypto from 'crypto';
88
import * as os from 'os';
99

1010
import { mapNodeOSToGOOS, mapNodeArchitectureToGOARCH } from '../spec-configuration/containerCollectionsOCI';
11-
import { DockerResolverParameters, DevContainerAuthority, UpdateRemoteUserUIDDefault, BindMountConsistency, getCacheFolder, GPUAvailability } from './utils';
11+
import { DockerResolverParameters, DevContainerAuthority, UpdateRemoteUserUIDDefault, BindMountConsistency, getCacheFolder, GPUAvailability, BuildSecret } from './utils';
1212
import { createNullLifecycleHook, finishBackgroundTasks, ResolverParameters, UserEnvProbe } from '../spec-common/injectHeadless';
1313
import { GoARCH, GoOS, getCLIHost, loadNativeModule } from '../spec-common/commonUtils';
1414
import { resolve } from './configContainer';
@@ -73,6 +73,7 @@ export interface ProvisionOptions {
7373
omitSyntaxDirective?: boolean;
7474
includeConfig?: boolean;
7575
includeMergedConfig?: boolean;
76+
buildSecrets: BuildSecret[];
7677
}
7778

7879
export async function launch(options: ProvisionOptions, providedIdLabels: string[] | undefined, disposables: (() => Promise<unknown> | undefined)[]) {
@@ -233,7 +234,8 @@ export async function createDockerParams(options: ProvisionOptions, disposables:
233234
additionalLabels: options.additionalLabels,
234235
buildxOutput: common.buildxOutput,
235236
buildxCacheTo: common.buildxCacheTo,
236-
platformInfo
237+
platformInfo,
238+
buildSecrets: options.buildSecrets
237239
};
238240
}
239241

src/spec-node/devContainersSpecCLI.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import textTable from 'text-table';
1010
import * as jsonc from 'jsonc-parser';
1111

1212
import { createDockerParams, createLog, launch, ProvisionOptions } from './devContainers';
13-
import { SubstitutedConfig, createContainerProperties, envListToObj, inspectDockerImage, isDockerFileConfig, SubstituteConfig, addSubstitution, findContainerAndIdLabels, getCacheFolder, runAsyncHandler } from './utils';
13+
import { SubstitutedConfig, createContainerProperties, envListToObj, inspectDockerImage, isDockerFileConfig, SubstituteConfig, addSubstitution, findContainerAndIdLabels, getCacheFolder, runAsyncHandler, BuildSecret } from './utils';
1414
import { URI } from 'vscode-uri';
1515
import { ContainerError } from '../spec-common/errors';
1616
import { Log, LogDimensions, LogLevel, makeLog, mapLogLevel } from '../spec-utils/log';
@@ -50,6 +50,43 @@ const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell';
5050

5151
const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,external=(true|false))?$/;
5252

53+
function parseBuildSecrets(buildSecretsArg: string[] | undefined): BuildSecret[] {
54+
if (!buildSecretsArg) {
55+
return [];
56+
}
57+
const secrets = Array.isArray(buildSecretsArg) ? buildSecretsArg : [buildSecretsArg];
58+
return secrets.map(secret => {
59+
// Support shorthand: id=name (assumes env=name)
60+
const shorthandMatch = secret.match(/^id=([^,]+)$/);
61+
if (shorthandMatch) {
62+
return {
63+
id: shorthandMatch[1],
64+
env: shorthandMatch[1].toUpperCase()
65+
};
66+
}
67+
68+
// Support file format: id=name,src=path
69+
const fileMatch = secret.match(/^id=([^,]+),src=(.+)$/);
70+
if (fileMatch) {
71+
return {
72+
id: fileMatch[1],
73+
file: path.resolve(process.cwd(), fileMatch[2])
74+
};
75+
}
76+
77+
// Support env format: id=name,env=VAR
78+
const envMatch = secret.match(/^id=([^,]+),env=(.+)$/);
79+
if (envMatch) {
80+
return {
81+
id: envMatch[1],
82+
env: envMatch[2]
83+
};
84+
}
85+
86+
throw new Error(`Invalid build-secret format: ${secret}. Supported formats are: "id=<id>,src=<path>", "id=<id>,env=<var>", or "id=<id>" (which assumes env=<ID>).`);
87+
});
88+
}
89+
5390
(async () => {
5491

5592
const packageFolder = path.join(__dirname, '..', '..');
@@ -137,6 +174,7 @@ function provisionOptions(y: Argv) {
137174
'container-session-data-folder': { type: 'string', description: 'Folder to cache CLI data, for example userEnvProbe results' },
138175
'omit-config-remote-env-from-metadata': { type: 'boolean', default: false, hidden: true, description: 'Omit remoteEnv from devcontainer.json for container metadata label' },
139176
'secrets-file': { type: 'string', description: 'Path to a json file containing secret environment variables as key-value pairs.' },
177+
'build-secret': { type: 'string', description: 'Build secrets in the format id=<id>,src=<path>, id=<id>,env=<var>, or id=<id> (assumes env=<ID>). These will be passed as Docker build secrets to feature installation steps.' },
140178
'experimental-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Write lockfile' },
141179
'experimental-frozen-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Ensure lockfile remains unchanged' },
142180
'omit-syntax-directive': { type: 'boolean', default: false, hidden: true, description: 'Omit Dockerfile syntax directives' },
@@ -162,6 +200,10 @@ function provisionOptions(y: Argv) {
162200
if (remoteEnvs?.some(remoteEnv => !/.+=.*/.test(remoteEnv))) {
163201
throw new Error('Unmatched argument format: remote-env must match <name>=<value>');
164202
}
203+
const buildSecrets = (argv['build-secret'] && (Array.isArray(argv['build-secret']) ? argv['build-secret'] : [argv['build-secret']])) as string[] | undefined;
204+
if (buildSecrets?.some(buildSecret => !/^id=[^,]+(,src=.+|,env=.+)?$/.test(buildSecret))) {
205+
throw new Error('Unmatched argument format: build-secret must match id=<id>,src=<path>, id=<id>,env=<var>, or id=<id>');
206+
}
165207
return true;
166208
});
167209
}
@@ -211,6 +253,7 @@ async function provision({
211253
'container-session-data-folder': containerSessionDataFolder,
212254
'omit-config-remote-env-from-metadata': omitConfigRemotEnvFromMetadata,
213255
'secrets-file': secretsFile,
256+
'build-secret': buildSecret,
214257
'experimental-lockfile': experimentalLockfile,
215258
'experimental-frozen-lockfile': experimentalFrozenLockfile,
216259
'omit-syntax-directive': omitSyntaxDirective,
@@ -223,6 +266,7 @@ async function provision({
223266
const addCacheFroms = addCacheFrom ? (Array.isArray(addCacheFrom) ? addCacheFrom as string[] : [addCacheFrom]) : [];
224267
const additionalFeatures = additionalFeaturesJson ? jsonc.parse(additionalFeaturesJson) as Record<string, string | boolean | Record<string, string | boolean>> : {};
225268
const providedIdLabels = idLabel ? Array.isArray(idLabel) ? idLabel as string[] : [idLabel] : undefined;
269+
const buildSecrets = parseBuildSecrets(buildSecret as string[] | undefined);
226270

227271
const cwd = workspaceFolder || process.cwd();
228272
const cliHost = await getCLIHost(cwd, loadNativeModule, logFormat === 'text');
@@ -286,6 +330,7 @@ async function provision({
286330
omitSyntaxDirective,
287331
includeConfig,
288332
includeMergedConfig,
333+
buildSecrets,
289334
};
290335

291336
const result = await doProvision(options, providedIdLabels);
@@ -452,6 +497,7 @@ async function doSetUp({
452497
installCommand: dotfilesInstallCommand,
453498
targetPath: dotfilesTargetPath,
454499
},
500+
buildSecrets: [],
455501
}, disposables);
456502

457503
const { common } = params;
@@ -523,6 +569,7 @@ function buildOptions(y: Argv) {
523569
'additional-features': { type: 'string', description: 'Additional features to apply to the dev container (JSON as per "features" section in devcontainer.json)' },
524570
'skip-feature-auto-mapping': { type: 'boolean', default: false, hidden: true, description: 'Temporary option for testing.' },
525571
'skip-persisting-customizations-from-features': { type: 'boolean', default: false, hidden: true, description: 'Do not save customizations from referenced Features as image metadata' },
572+
'build-secret': { type: 'string', description: 'Build secrets in the format id=<id>,src=<path>, id=<id>,env=<var>, or id=<id> (assumes env=<ID>). These will be passed as Docker build secrets to feature installation steps.' },
526573
'experimental-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Write lockfile' },
527574
'experimental-frozen-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Ensure lockfile remains unchanged' },
528575
'omit-syntax-directive': { type: 'boolean', default: false, hidden: true, description: 'Omit Dockerfile syntax directives' },
@@ -568,6 +615,7 @@ async function doBuild({
568615
'experimental-lockfile': experimentalLockfile,
569616
'experimental-frozen-lockfile': experimentalFrozenLockfile,
570617
'omit-syntax-directive': omitSyntaxDirective,
618+
'build-secret': buildSecret,
571619
}: BuildArgs) {
572620
const disposables: (() => Promise<unknown> | undefined)[] = [];
573621
const dispose = async () => {
@@ -579,6 +627,7 @@ async function doBuild({
579627
const overrideConfigFile: URI | undefined = /* overrideConfig ? URI.file(path.resolve(process.cwd(), overrideConfig)) : */ undefined;
580628
const addCacheFroms = addCacheFrom ? (Array.isArray(addCacheFrom) ? addCacheFrom as string[] : [addCacheFrom]) : [];
581629
const additionalFeatures = additionalFeaturesJson ? jsonc.parse(additionalFeaturesJson) as Record<string, string | boolean | Record<string, string | boolean>> : {};
630+
const buildSecrets = parseBuildSecrets(buildSecret as string[] | undefined);
582631
const params = await createDockerParams({
583632
dockerPath,
584633
dockerComposePath,
@@ -617,6 +666,7 @@ async function doBuild({
617666
experimentalLockfile,
618667
experimentalFrozenLockfile,
619668
omitSyntaxDirective,
669+
buildSecrets,
620670
}, disposables);
621671

622672
const { common, dockerComposeCLI } = params;
@@ -849,6 +899,7 @@ async function doRunUserCommands({
849899
const cwd = workspaceFolder || process.cwd();
850900
const cliHost = await getCLIHost(cwd, loadNativeModule, logFormat === 'text');
851901
const secretsP = readSecretsFromFile({ secretsFile, cliHost });
902+
const buildSecrets: BuildSecret[] = [];
852903

853904
const params = await createDockerParams({
854905
dockerPath,
@@ -891,6 +942,7 @@ async function doRunUserCommands({
891942
},
892943
containerSessionDataFolder,
893944
secretsP,
945+
buildSecrets,
894946
}, disposables);
895947

896948
const { common } = params;
@@ -1333,7 +1385,8 @@ export async function doExec({
13331385
buildxOutput: undefined,
13341386
skipPostAttach: false,
13351387
skipPersistingCustomizationsFromFeatures: false,
1336-
dotfiles: {}
1388+
dotfiles: {},
1389+
buildSecrets: []
13371390
}, disposables);
13381391

13391392
const { common } = params;

src/spec-node/dockerCompose.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import * as yaml from 'js-yaml';
77
import * as shellQuote from 'shell-quote';
88

9-
import { createContainerProperties, startEventSeen, ResolverResult, getTunnelInformation, DockerResolverParameters, inspectDockerImage, getEmptyContextFolder, getFolderImageName, SubstitutedConfig, checkDockerSupportForGPU, isBuildKitImagePolicyError } from './utils';
9+
import { createContainerProperties, startEventSeen, ResolverResult, getTunnelInformation, DockerResolverParameters, inspectDockerImage, getEmptyContextFolder, getFolderImageName, SubstitutedConfig, checkDockerSupportForGPU, isBuildKitImagePolicyError, BuildSecret } from './utils';
1010
import { ContainerProperties, setupInContainer, ResolverProgress } from '../spec-common/injectHeadless';
1111
import { ContainerError } from '../spec-common/errors';
1212
import { Workspace } from '../spec-utils/workspaces';
@@ -243,6 +243,13 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf
243243
buildOverrideContent += ` - ${buildKitContext}=${featureBuildInfo.buildKitContexts[buildKitContext]}\n`;
244244
}
245245
}
246+
247+
if (params.buildSecrets.length > 0) {
248+
buildOverrideContent += ' secrets:\n';
249+
for (const secret of params.buildSecrets) {
250+
buildOverrideContent += ` - ${secret.id}\n`;
251+
}
252+
}
246253
}
247254

248255
// Generate the docker-compose override and build
@@ -253,10 +260,27 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf
253260
await cliHost.mkdirp(composeFolder);
254261
const composeOverrideFile = cliHost.path.join(composeFolder, `${overrideFilePrefix}-${Date.now()}.yml`);
255262
const cacheFromOverrideContent = (additionalCacheFroms && additionalCacheFroms.length > 0) ? ` cache_from:\n${additionalCacheFroms.map(cacheFrom => ` - ${cacheFrom}\n`).join('\n')}` : '';
263+
const secretsOverrideContent = generateSecretsOverrideContent(params.buildSecrets);
264+
265+
function generateSecretsOverrideContent(buildSecrets: BuildSecret[]): string {
266+
if (!buildSecrets || buildSecrets.length === 0) {
267+
return '';
268+
}
269+
let content = 'secrets:\n';
270+
for (const secret of buildSecrets) {
271+
if (secret.file) {
272+
content += ` ${secret.id}:\n file: ${secret.file}\n`;
273+
} else if (secret.env) {
274+
content += ` ${secret.id}:\n environment: ${secret.env}\n`;
275+
}
276+
}
277+
return content;
278+
}
256279
const composeOverrideContent = `${versionPrefix}services:
257280
${config.service}:
258281
${buildOverrideContent?.trimEnd()}
259282
${cacheFromOverrideContent}
283+
${secretsOverrideContent}
260284
`;
261285
output.write(`Docker Compose override file for building image:\n${composeOverrideContent}`);
262286
await cliHost.writeFile(composeOverrideFile, Buffer.from(composeOverrideContent));

src/spec-node/singleContainer.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ async function buildAndExtendImage(buildParams: DockerResolverParameters, config
200200
if (buildParams.buildxPush) {
201201
args.push('--push');
202202
} else {
203-
if (buildParams.buildxOutput) {
203+
if (buildParams.buildxOutput) {
204204
args.push('--output', buildParams.buildxOutput);
205205
} else {
206206
args.push('--load'); // (short for --output=docker, i.e. load into normal 'docker images' collection)
@@ -210,6 +210,14 @@ async function buildAndExtendImage(buildParams: DockerResolverParameters, config
210210
args.push('--cache-to', buildParams.buildxCacheTo);
211211
}
212212
args.push('--build-arg', 'BUILDKIT_INLINE_CACHE=1');
213+
214+
for (const secret of buildParams.buildSecrets) {
215+
if (secret.file) {
216+
args.push('--secret', `id=${secret.id},src=${secret.file}`);
217+
} else if (secret.env) {
218+
args.push('--secret', `id=${secret.id},env=${secret.env}`);
219+
}
220+
}
213221
} else {
214222
args.push('build');
215223
}

src/spec-node/utils.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ export type BindMountConsistency = 'consistent' | 'cached' | 'delegated' | undef
3737

3838
export type GPUAvailability = 'all' | 'detect' | 'none';
3939

40+
export interface BuildSecret {
41+
id: string;
42+
file?: string;
43+
env?: string;
44+
}
45+
4046
// Generic retry function
4147
export async function retry<T>(fn: () => Promise<T>, options: { retryIntervalMilliseconds: number; maxRetries: number; output: Log }): Promise<T> {
4248
const { retryIntervalMilliseconds, maxRetries, output } = options;
@@ -123,6 +129,7 @@ export interface DockerResolverParameters {
123129
buildxOutput: string | undefined;
124130
buildxCacheTo: string | undefined;
125131
platformInfo: PlatformInfo;
132+
buildSecrets: BuildSecret[];
126133
}
127134

128135
export interface ResolverResult {

0 commit comments

Comments
 (0)