Skip to content

Commit 9a12259

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 99a6d46 commit 9a12259

16 files changed

Lines changed: 238 additions & 13 deletions

File tree

src/spec-configuration/containerFeaturesConfiguration.ts

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

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

295305
const builtinsEnvFile = `${path.posix.join(FEATURES_CONTAINER_TEMP_DEST_FOLDER, 'devcontainer-features.builtin.env')}`;
296306
let result = `RUN \\
@@ -313,8 +323,10 @@ RUN chmod -R 0755 ${dest} \\
313323
314324
`;
315325
} else {
316-
result += `RUN --mount=type=bind,from=dev_containers_feature_content_source,source=${source},target=/tmp/build-features-src/${folder} \\
317-
cp -ar /tmp/build-features-src/${folder} ${FEATURES_CONTAINER_TEMP_DEST_FOLDER} \\
326+
const secretMounts = getSecretMounts(buildSecrets);
327+
const runPrefix = secretMounts ? `RUN ${secretMounts} ` : 'RUN ';
328+
result += `${runPrefix}--mount=type=bind,from=dev_containers_feature_content_source,source=${source},target=/tmp/build-features-src/${folder} \\
329+
cp -ar /tmp/build-features-src/${folder} ${FEATURES_CONTAINER_TEMP_DEST_FOLDER} \\
318330
&& chmod -R 0755 ${dest} \\
319331
&& cd ${dest} \\
320332
&& chmod +x ./install.sh \\
@@ -340,9 +352,11 @@ RUN chmod -R 0755 ${dest} \\
340352
341353
`;
342354
} else {
355+
const secretMounts = getSecretMounts(buildSecrets);
356+
const runPrefix = secretMounts ? `RUN ${secretMounts} ` : 'RUN ';
343357
result += `
344-
RUN --mount=type=bind,from=dev_containers_feature_content_source,source=${source},target=/tmp/build-features-src/${feature.consecutiveId} \\
345-
cp -ar /tmp/build-features-src/${feature.consecutiveId} ${FEATURES_CONTAINER_TEMP_DEST_FOLDER} \\
358+
${runPrefix}--mount=type=bind,from=dev_containers_feature_content_source,source=${source},target=/tmp/build-features-src/${feature.consecutiveId} \\
359+
cp -ar /tmp/build-features-src/${feature.consecutiveId} ${FEATURES_CONTAINER_TEMP_DEST_FOLDER} \\
346360
&& chmod -R 0755 ${dest} \\
347361
&& cd ${dest} \\
348362
&& 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
@@ -99,6 +99,14 @@ export async function extendImage(params: DockerResolverParameters, config: Subs
9999
for (const securityOpt of featureBuildInfo.securityOpts) {
100100
args.push('--security-opt', securityOpt);
101101
}
102+
103+
for (const secret of params.buildSecrets) {
104+
if (secret.file) {
105+
args.push('--secret', `id=${secret.id},src=${secret.file}`);
106+
} else if (secret.env) {
107+
args.push('--secret', `id=${secret.id},env=${secret.env}`);
108+
}
109+
}
102110
} else {
103111
// Not using buildx
104112
args.push(
@@ -263,7 +271,7 @@ async function getFeaturesBuildOptions(params: DockerResolverParameters, devCont
263271
const contentSourceRootPath = useBuildKitBuildContexts ? '.' : '/tmp/build-features/';
264272
const dockerfile = getContainerFeaturesBaseDockerFile(contentSourceRootPath)
265273
.replace('#{nonBuildKitFeatureContentFallback}', useBuildKitBuildContexts ? '' : `FROM ${buildContentImageName} as dev_containers_feature_content_source`)
266-
.replace('#{featureLayer}', getFeatureLayers(featuresConfig, containerUser, remoteUser, useBuildKitBuildContexts, contentSourceRootPath))
274+
.replace('#{featureLayer}', getFeatureLayers(featuresConfig, containerUser, remoteUser, useBuildKitBuildContexts, contentSourceRootPath, params.buildSecrets))
267275
.replace('#{containerEnv}', generateContainerEnvsV1(featuresConfig))
268276
.replace('#{devcontainerMetadata}', getDevcontainerMetadataLabel(imageMetadata))
269277
.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';
@@ -74,6 +74,7 @@ export interface ProvisionOptions {
7474
omitSyntaxDirective?: boolean;
7575
includeConfig?: boolean;
7676
includeMergedConfig?: boolean;
77+
buildSecrets: BuildSecret[];
7778
}
7879

7980
export async function launch(options: ProvisionOptions, providedIdLabels: string[] | undefined, disposables: (() => Promise<unknown> | undefined)[]) {
@@ -246,7 +247,8 @@ export async function createDockerParams(options: ProvisionOptions, disposables:
246247
additionalLabels: options.additionalLabels,
247248
buildxOutput: common.buildxOutput,
248249
buildxCacheTo: common.buildxCacheTo,
249-
platformInfo
250+
platformInfo,
251+
buildSecrets: options.buildSecrets
250252
};
251253
}
252254

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, '..', '..');
@@ -138,6 +175,7 @@ function provisionOptions(y: Argv) {
138175
'container-session-data-folder': { type: 'string', description: 'Folder to cache CLI data, for example userEnvProbe results' },
139176
'omit-config-remote-env-from-metadata': { type: 'boolean', default: false, hidden: true, description: 'Omit remoteEnv from devcontainer.json for container metadata label' },
140177
'secrets-file': { type: 'string', description: 'Path to a json file containing secret environment variables as key-value pairs.' },
178+
'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.' },
141179
'experimental-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Write lockfile' },
142180
'experimental-frozen-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Ensure lockfile remains unchanged' },
143181
'omit-syntax-directive': { type: 'boolean', default: false, hidden: true, description: 'Omit Dockerfile syntax directives' },
@@ -161,6 +199,10 @@ function provisionOptions(y: Argv) {
161199
if (remoteEnvs?.some(remoteEnv => !/.+=.*/.test(remoteEnv))) {
162200
throw new Error('Unmatched argument format: remote-env must match <name>=<value>');
163201
}
202+
const buildSecrets = (argv['build-secret'] && (Array.isArray(argv['build-secret']) ? argv['build-secret'] : [argv['build-secret']])) as string[] | undefined;
203+
if (buildSecrets?.some(buildSecret => !/^id=[^,]+(,src=.+|,env=.+)?$/.test(buildSecret))) {
204+
throw new Error('Unmatched argument format: build-secret must match id=<id>,src=<path>, id=<id>,env=<var>, or id=<id>');
205+
}
164206
return true;
165207
});
166208
}
@@ -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');
@@ -287,6 +331,7 @@ async function provision({
287331
omitSyntaxDirective,
288332
includeConfig,
289333
includeMergedConfig,
334+
buildSecrets,
290335
};
291336

292337
const result = await doProvision(options, providedIdLabels);
@@ -454,6 +499,7 @@ async function doSetUp({
454499
installCommand: dotfilesInstallCommand,
455500
targetPath: dotfilesTargetPath,
456501
},
502+
buildSecrets: [],
457503
}, disposables);
458504

459505
const { common } = params;
@@ -525,6 +571,7 @@ function buildOptions(y: Argv) {
525571
'additional-features': { type: 'string', description: 'Additional features to apply to the dev container (JSON as per "features" section in devcontainer.json)' },
526572
'skip-feature-auto-mapping': { type: 'boolean', default: false, hidden: true, description: 'Temporary option for testing.' },
527573
'skip-persisting-customizations-from-features': { type: 'boolean', default: false, hidden: true, description: 'Do not save customizations from referenced Features as image metadata' },
574+
'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.' },
528575
'experimental-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Write lockfile' },
529576
'experimental-frozen-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Ensure lockfile remains unchanged' },
530577
'omit-syntax-directive': { type: 'boolean', default: false, hidden: true, description: 'Omit Dockerfile syntax directives' },
@@ -570,6 +617,7 @@ async function doBuild({
570617
'experimental-lockfile': experimentalLockfile,
571618
'experimental-frozen-lockfile': experimentalFrozenLockfile,
572619
'omit-syntax-directive': omitSyntaxDirective,
620+
'build-secret': buildSecret,
573621
}: BuildArgs) {
574622
const disposables: (() => Promise<unknown> | undefined)[] = [];
575623
const dispose = async () => {
@@ -581,6 +629,7 @@ async function doBuild({
581629
const overrideConfigFile: URI | undefined = /* overrideConfig ? URI.file(path.resolve(process.cwd(), overrideConfig)) : */ undefined;
582630
const addCacheFroms = addCacheFrom ? (Array.isArray(addCacheFrom) ? addCacheFrom as string[] : [addCacheFrom]) : [];
583631
const additionalFeatures = additionalFeaturesJson ? jsonc.parse(additionalFeaturesJson) as Record<string, string | boolean | Record<string, string | boolean>> : {};
632+
const buildSecrets = parseBuildSecrets(buildSecret as string[] | undefined);
584633
const params = await createDockerParams({
585634
dockerPath,
586635
dockerComposePath,
@@ -620,6 +669,7 @@ async function doBuild({
620669
experimentalLockfile,
621670
experimentalFrozenLockfile,
622671
omitSyntaxDirective,
672+
buildSecrets,
623673
}, disposables);
624674

625675
const { common, dockerComposeCLI } = params;
@@ -854,6 +904,7 @@ async function doRunUserCommands({
854904
const cwd = workspaceFolder || process.cwd();
855905
const cliHost = await getCLIHost(cwd, loadNativeModule, logFormat === 'text');
856906
const secretsP = readSecretsFromFile({ secretsFile, cliHost });
907+
const buildSecrets: BuildSecret[] = [];
857908

858909
const params = await createDockerParams({
859910
dockerPath,
@@ -897,6 +948,7 @@ async function doRunUserCommands({
897948
},
898949
containerSessionDataFolder,
899950
secretsP,
951+
buildSecrets,
900952
}, disposables);
901953

902954
const { common } = params;
@@ -1344,7 +1396,8 @@ export async function doExec({
13441396
buildxOutput: undefined,
13451397
skipPostAttach: false,
13461398
skipPersistingCustomizationsFromFeatures: false,
1347-
dotfiles: {}
1399+
dotfiles: {},
1400+
buildSecrets: []
13481401
}, disposables);
13491402

13501403
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: 8 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)
@@ -212,6 +212,13 @@ async function buildAndExtendImage(buildParams: DockerResolverParameters, config
212212
if (!isBuildxCacheToInline(buildParams.buildxCacheTo)) {
213213
args.push('--build-arg', 'BUILDKIT_INLINE_CACHE=1');
214214
}
215+
for (const secret of buildParams.buildSecrets) {
216+
if (secret.file) {
217+
args.push('--secret', `id=${secret.id},src=${secret.file}`);
218+
} else if (secret.env) {
219+
args.push('--secret', `id=${secret.id},env=${secret.env}`);
220+
}
221+
}
215222
} else {
216223
args.push('build');
217224
}

src/spec-node/utils.ts

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

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

39+
export interface BuildSecret {
40+
id: string;
41+
file?: string;
42+
env?: string;
43+
}
44+
3945
// Generic retry function
4046
export async function retry<T>(fn: () => Promise<T>, options: { retryIntervalMilliseconds: number; maxRetries: number; output: Log }): Promise<T> {
4147
const { retryIntervalMilliseconds, maxRetries, output } = options;
@@ -135,6 +141,7 @@ export interface DockerResolverParameters {
135141
buildxOutput: string | undefined;
136142
buildxCacheTo: string | undefined;
137143
platformInfo: PlatformInfo;
144+
buildSecrets: BuildSecret[];
138145
}
139146

140147
export interface ResolverResult {

0 commit comments

Comments
 (0)