From 5833785f2e9987b801649d3defef84ac72fbb95c Mon Sep 17 00:00:00 2001 From: Karsten Becker Date: Wed, 24 Sep 2025 21:26:09 +0000 Subject: [PATCH] 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. --- .../containerFeaturesConfiguration.ts | 24 ++++++-- src/spec-node/containerFeatures.ts | 10 +++- src/spec-node/devContainers.ts | 6 +- src/spec-node/devContainersSpecCLI.ts | 57 ++++++++++++++++++- src/spec-node/dockerCompose.ts | 26 ++++++++- src/spec-node/singleContainer.ts | 10 +++- src/spec-node/utils.ts | 7 +++ src/test/cli.build.test.ts | 52 ++++++++++++++++- .../.devcontainer/devcontainer.json | 7 +++ .../secret_test/devcontainer-feature.json | 6 ++ .../.devcontainer/secret_test/install.sh | 11 ++++ .../.devcontainer/Dockerfile | 2 + .../.devcontainer/devcontainer.json | 9 +++ .../.devcontainer/docker-compose.yml | 9 +++ .../secret_test/devcontainer-feature.json | 6 ++ .../.devcontainer/secret_test/install.sh | 11 ++++ 16 files changed, 240 insertions(+), 13 deletions(-) create mode 100644 src/test/configs/build-secret-feature/.devcontainer/devcontainer.json create mode 100644 src/test/configs/build-secret-feature/.devcontainer/secret_test/devcontainer-feature.json create mode 100755 src/test/configs/build-secret-feature/.devcontainer/secret_test/install.sh create mode 100644 src/test/configs/compose-build-secret-feature/.devcontainer/Dockerfile create mode 100644 src/test/configs/compose-build-secret-feature/.devcontainer/devcontainer.json create mode 100644 src/test/configs/compose-build-secret-feature/.devcontainer/docker-compose.yml create mode 100644 src/test/configs/compose-build-secret-feature/.devcontainer/secret_test/devcontainer-feature.json create mode 100755 src/test/configs/compose-build-secret-feature/.devcontainer/secret_test/install.sh diff --git a/src/spec-configuration/containerFeaturesConfiguration.ts b/src/spec-configuration/containerFeaturesConfiguration.ts index 662ed3b65..132727861 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -289,7 +289,17 @@ 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 interface BuildSecret { + id: string; + file?: string; + env?: string; +} + +function getSecretMounts(buildSecrets: BuildSecret[]): string { + return buildSecrets.map(secret => `--mount=type=secret,id=${secret.id}`).join(' '); +} + +export function getFeatureLayers(featuresConfig: FeaturesConfig, containerUser: string, remoteUser: string, useBuildKitBuildContexts = false, contentSourceRootPath = '/tmp/build-features', buildSecrets: BuildSecret[] = []) { const builtinsEnvFile = `${path.posix.join(FEATURES_CONTAINER_TEMP_DEST_FOLDER, 'devcontainer-features.builtin.env')}`; let result = `RUN \\ @@ -312,8 +322,10 @@ RUN chmod -R 0755 ${dest} \\ `; } 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} ${FEATURES_CONTAINER_TEMP_DEST_FOLDER} \\ + const secretMounts = getSecretMounts(buildSecrets); + const runPrefix = secretMounts ? `RUN ${secretMounts} ` : 'RUN '; + result += `${runPrefix}--mount=type=bind,from=dev_containers_feature_content_source,source=${source},target=/tmp/build-features-src/${folder} \\ + cp -ar /tmp/build-features-src/${folder} ${FEATURES_CONTAINER_TEMP_DEST_FOLDER} \\ && chmod -R 0755 ${dest} \\ && cd ${dest} \\ && chmod +x ./install.sh \\ @@ -339,9 +351,11 @@ RUN chmod -R 0755 ${dest} \\ `; } else { + const secretMounts = getSecretMounts(buildSecrets); + const runPrefix = secretMounts ? `RUN ${secretMounts} ` : 'RUN '; 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} ${FEATURES_CONTAINER_TEMP_DEST_FOLDER} \\ +${runPrefix}--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} ${FEATURES_CONTAINER_TEMP_DEST_FOLDER} \\ && chmod -R 0755 ${dest} \\ && cd ${dest} \\ && chmod +x ./devcontainer-features-install.sh \\ diff --git a/src/spec-node/containerFeatures.ts b/src/spec-node/containerFeatures.ts index c7e42a56a..579885e69 100644 --- a/src/spec-node/containerFeatures.ts +++ b/src/spec-node/containerFeatures.ts @@ -96,6 +96,14 @@ export async function extendImage(params: DockerResolverParameters, config: Subs for (const securityOpt of featureBuildInfo.securityOpts) { args.push('--security-opt', securityOpt); } + + for (const secret of params.buildSecrets) { + if (secret.file) { + args.push('--secret', `id=${secret.id},src=${secret.file}`); + } else if (secret.env) { + args.push('--secret', `id=${secret.id},env=${secret.env}`); + } + } } else { // Not using buildx args.push( @@ -257,7 +265,7 @@ async function getFeaturesBuildOptions(params: DockerResolverParameters, devCont const contentSourceRootPath = useBuildKitBuildContexts ? '.' : '/tmp/build-features/'; const dockerfile = getContainerFeaturesBaseDockerFile(contentSourceRootPath) .replace('#{nonBuildKitFeatureContentFallback}', useBuildKitBuildContexts ? '' : `FROM ${buildContentImageName} as dev_containers_feature_content_source`) - .replace('#{featureLayer}', getFeatureLayers(featuresConfig, containerUser, remoteUser, useBuildKitBuildContexts, contentSourceRootPath)) + .replace('#{featureLayer}', getFeatureLayers(featuresConfig, containerUser, remoteUser, useBuildKitBuildContexts, contentSourceRootPath, params.buildSecrets)) .replace('#{containerEnv}', generateContainerEnvsV1(featuresConfig)) .replace('#{devcontainerMetadata}', getDevcontainerMetadataLabel(imageMetadata)) .replace('#{containerEnvMetadata}', generateContainerEnvs(devContainerConfig.config.containerEnv, true)) diff --git a/src/spec-node/devContainers.ts b/src/spec-node/devContainers.ts index 493d0dc22..32ccb40d9 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 { mapNodeOSToGOOS, mapNodeArchitectureToGOARCH } from '../spec-configuration/containerCollectionsOCI'; -import { DockerResolverParameters, DevContainerAuthority, UpdateRemoteUserUIDDefault, BindMountConsistency, getCacheFolder, GPUAvailability } from './utils'; +import { DockerResolverParameters, DevContainerAuthority, UpdateRemoteUserUIDDefault, BindMountConsistency, getCacheFolder, GPUAvailability, BuildSecret } from './utils'; import { createNullLifecycleHook, finishBackgroundTasks, ResolverParameters, UserEnvProbe } from '../spec-common/injectHeadless'; import { GoARCH, GoOS, getCLIHost, loadNativeModule } from '../spec-common/commonUtils'; import { resolve } from './configContainer'; @@ -73,6 +73,7 @@ export interface ProvisionOptions { omitSyntaxDirective?: boolean; includeConfig?: boolean; includeMergedConfig?: boolean; + buildSecrets: BuildSecret[]; } export async function launch(options: ProvisionOptions, providedIdLabels: string[] | undefined, disposables: (() => Promise | undefined)[]) { @@ -233,7 +234,8 @@ export async function createDockerParams(options: ProvisionOptions, disposables: additionalLabels: options.additionalLabels, buildxOutput: common.buildxOutput, buildxCacheTo: common.buildxCacheTo, - platformInfo + platformInfo, + buildSecrets: options.buildSecrets }; } diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 59136695d..c22554ae5 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -10,7 +10,7 @@ import textTable from 'text-table'; import * as jsonc from 'jsonc-parser'; import { createDockerParams, createLog, launch, ProvisionOptions } from './devContainers'; -import { SubstitutedConfig, createContainerProperties, envListToObj, inspectDockerImage, isDockerFileConfig, SubstituteConfig, addSubstitution, findContainerAndIdLabels, getCacheFolder, runAsyncHandler } from './utils'; +import { SubstitutedConfig, createContainerProperties, envListToObj, inspectDockerImage, isDockerFileConfig, SubstituteConfig, addSubstitution, findContainerAndIdLabels, getCacheFolder, runAsyncHandler, BuildSecret } from './utils'; import { URI } from 'vscode-uri'; import { ContainerError } from '../spec-common/errors'; import { Log, LogDimensions, LogLevel, makeLog, mapLogLevel } from '../spec-utils/log'; @@ -50,6 +50,43 @@ const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell'; const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,external=(true|false))?$/; +function parseBuildSecrets(buildSecretsArg: string[] | undefined): BuildSecret[] { + if (!buildSecretsArg) { + return []; + } + const secrets = Array.isArray(buildSecretsArg) ? buildSecretsArg : [buildSecretsArg]; + return secrets.map(secret => { + // Support shorthand: id=name (assumes env=name) + const shorthandMatch = secret.match(/^id=([^,]+)$/); + if (shorthandMatch) { + return { + id: shorthandMatch[1], + env: shorthandMatch[1].toUpperCase() + }; + } + + // Support file format: id=name,src=path + const fileMatch = secret.match(/^id=([^,]+),src=(.+)$/); + if (fileMatch) { + return { + id: fileMatch[1], + file: path.resolve(process.cwd(), fileMatch[2]) + }; + } + + // Support env format: id=name,env=VAR + const envMatch = secret.match(/^id=([^,]+),env=(.+)$/); + if (envMatch) { + return { + id: envMatch[1], + env: envMatch[2] + }; + } + + throw new Error(`Invalid build-secret format: ${secret}. Supported formats are: "id=,src=", "id=,env=", or "id=" (which assumes env=).`); + }); +} + (async () => { const packageFolder = path.join(__dirname, '..', '..'); @@ -137,6 +174,7 @@ function provisionOptions(y: Argv) { 'container-session-data-folder': { type: 'string', description: 'Folder to cache CLI data, for example userEnvProbe results' }, 'omit-config-remote-env-from-metadata': { type: 'boolean', default: false, hidden: true, description: 'Omit remoteEnv from devcontainer.json for container metadata label' }, 'secrets-file': { type: 'string', description: 'Path to a json file containing secret environment variables as key-value pairs.' }, + 'build-secret': { type: 'string', description: 'Build secrets in the format id=,src=, id=,env=, or id= (assumes env=). These will be passed as Docker build secrets to feature installation steps.' }, 'experimental-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Write lockfile' }, 'experimental-frozen-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Ensure lockfile remains unchanged' }, 'omit-syntax-directive': { type: 'boolean', default: false, hidden: true, description: 'Omit Dockerfile syntax directives' }, @@ -162,6 +200,10 @@ function provisionOptions(y: Argv) { if (remoteEnvs?.some(remoteEnv => !/.+=.*/.test(remoteEnv))) { throw new Error('Unmatched argument format: remote-env must match ='); } + const buildSecrets = (argv['build-secret'] && (Array.isArray(argv['build-secret']) ? argv['build-secret'] : [argv['build-secret']])) as string[] | undefined; + if (buildSecrets?.some(buildSecret => !/^id=[^,]+(,src=.+|,env=.+)?$/.test(buildSecret))) { + throw new Error('Unmatched argument format: build-secret must match id=,src=, id=,env=, or id='); + } return true; }); } @@ -211,6 +253,7 @@ async function provision({ 'container-session-data-folder': containerSessionDataFolder, 'omit-config-remote-env-from-metadata': omitConfigRemotEnvFromMetadata, 'secrets-file': secretsFile, + 'build-secret': buildSecret, 'experimental-lockfile': experimentalLockfile, 'experimental-frozen-lockfile': experimentalFrozenLockfile, 'omit-syntax-directive': omitSyntaxDirective, @@ -223,6 +266,7 @@ async function provision({ const addCacheFroms = addCacheFrom ? (Array.isArray(addCacheFrom) ? addCacheFrom as string[] : [addCacheFrom]) : []; const additionalFeatures = additionalFeaturesJson ? jsonc.parse(additionalFeaturesJson) as Record> : {}; const providedIdLabels = idLabel ? Array.isArray(idLabel) ? idLabel as string[] : [idLabel] : undefined; + const buildSecrets = parseBuildSecrets(buildSecret as string[] | undefined); const cwd = workspaceFolder || process.cwd(); const cliHost = await getCLIHost(cwd, loadNativeModule, logFormat === 'text'); @@ -286,6 +330,7 @@ async function provision({ omitSyntaxDirective, includeConfig, includeMergedConfig, + buildSecrets, }; const result = await doProvision(options, providedIdLabels); @@ -452,6 +497,7 @@ async function doSetUp({ installCommand: dotfilesInstallCommand, targetPath: dotfilesTargetPath, }, + buildSecrets: [], }, disposables); const { common } = params; @@ -523,6 +569,7 @@ function buildOptions(y: Argv) { 'additional-features': { type: 'string', description: 'Additional features to apply to the dev container (JSON as per "features" section in devcontainer.json)' }, 'skip-feature-auto-mapping': { type: 'boolean', default: false, hidden: true, description: 'Temporary option for testing.' }, 'skip-persisting-customizations-from-features': { type: 'boolean', default: false, hidden: true, description: 'Do not save customizations from referenced Features as image metadata' }, + 'build-secret': { type: 'string', description: 'Build secrets in the format id=,src=, id=,env=, or id= (assumes env=). These will be passed as Docker build secrets to feature installation steps.' }, 'experimental-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Write lockfile' }, 'experimental-frozen-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Ensure lockfile remains unchanged' }, 'omit-syntax-directive': { type: 'boolean', default: false, hidden: true, description: 'Omit Dockerfile syntax directives' }, @@ -568,6 +615,7 @@ async function doBuild({ 'experimental-lockfile': experimentalLockfile, 'experimental-frozen-lockfile': experimentalFrozenLockfile, 'omit-syntax-directive': omitSyntaxDirective, + 'build-secret': buildSecret, }: BuildArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -579,6 +627,7 @@ async function doBuild({ const overrideConfigFile: URI | undefined = /* overrideConfig ? URI.file(path.resolve(process.cwd(), overrideConfig)) : */ undefined; const addCacheFroms = addCacheFrom ? (Array.isArray(addCacheFrom) ? addCacheFrom as string[] : [addCacheFrom]) : []; const additionalFeatures = additionalFeaturesJson ? jsonc.parse(additionalFeaturesJson) as Record> : {}; + const buildSecrets = parseBuildSecrets(buildSecret as string[] | undefined); const params = await createDockerParams({ dockerPath, dockerComposePath, @@ -617,6 +666,7 @@ async function doBuild({ experimentalLockfile, experimentalFrozenLockfile, omitSyntaxDirective, + buildSecrets, }, disposables); const { common, dockerComposeCLI } = params; @@ -849,6 +899,7 @@ async function doRunUserCommands({ const cwd = workspaceFolder || process.cwd(); const cliHost = await getCLIHost(cwd, loadNativeModule, logFormat === 'text'); const secretsP = readSecretsFromFile({ secretsFile, cliHost }); + const buildSecrets: BuildSecret[] = []; const params = await createDockerParams({ dockerPath, @@ -891,6 +942,7 @@ async function doRunUserCommands({ }, containerSessionDataFolder, secretsP, + buildSecrets, }, disposables); const { common } = params; @@ -1333,7 +1385,8 @@ export async function doExec({ buildxOutput: undefined, skipPostAttach: false, skipPersistingCustomizationsFromFeatures: false, - dotfiles: {} + dotfiles: {}, + buildSecrets: [] }, disposables); const { common } = params; diff --git a/src/spec-node/dockerCompose.ts b/src/spec-node/dockerCompose.ts index 4c20ebd68..0a6467b07 100644 --- a/src/spec-node/dockerCompose.ts +++ b/src/spec-node/dockerCompose.ts @@ -6,7 +6,7 @@ import * as yaml from 'js-yaml'; import * as shellQuote from 'shell-quote'; -import { createContainerProperties, startEventSeen, ResolverResult, getTunnelInformation, DockerResolverParameters, inspectDockerImage, getEmptyContextFolder, getFolderImageName, SubstitutedConfig, checkDockerSupportForGPU, isBuildKitImagePolicyError } from './utils'; +import { createContainerProperties, startEventSeen, ResolverResult, getTunnelInformation, DockerResolverParameters, inspectDockerImage, getEmptyContextFolder, getFolderImageName, SubstitutedConfig, checkDockerSupportForGPU, isBuildKitImagePolicyError, BuildSecret } from './utils'; import { ContainerProperties, setupInContainer, ResolverProgress } from '../spec-common/injectHeadless'; import { ContainerError } from '../spec-common/errors'; import { Workspace } from '../spec-utils/workspaces'; @@ -243,6 +243,13 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf buildOverrideContent += ` - ${buildKitContext}=${featureBuildInfo.buildKitContexts[buildKitContext]}\n`; } } + + if (params.buildSecrets.length > 0) { + buildOverrideContent += ' secrets:\n'; + for (const secret of params.buildSecrets) { + buildOverrideContent += ` - ${secret.id}\n`; + } + } } // Generate the docker-compose override and build @@ -253,10 +260,27 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf await cliHost.mkdirp(composeFolder); const composeOverrideFile = cliHost.path.join(composeFolder, `${overrideFilePrefix}-${Date.now()}.yml`); const cacheFromOverrideContent = (additionalCacheFroms && additionalCacheFroms.length > 0) ? ` cache_from:\n${additionalCacheFroms.map(cacheFrom => ` - ${cacheFrom}\n`).join('\n')}` : ''; + const secretsOverrideContent = generateSecretsOverrideContent(params.buildSecrets); + + function generateSecretsOverrideContent(buildSecrets: BuildSecret[]): string { + if (!buildSecrets || buildSecrets.length === 0) { + return ''; + } + let content = 'secrets:\n'; + for (const secret of buildSecrets) { + if (secret.file) { + content += ` ${secret.id}:\n file: ${secret.file}\n`; + } else if (secret.env) { + content += ` ${secret.id}:\n environment: ${secret.env}\n`; + } + } + return content; + } const composeOverrideContent = `${versionPrefix}services: ${config.service}: ${buildOverrideContent?.trimEnd()} ${cacheFromOverrideContent} +${secretsOverrideContent} `; output.write(`Docker Compose override file for building image:\n${composeOverrideContent}`); await cliHost.writeFile(composeOverrideFile, Buffer.from(composeOverrideContent)); diff --git a/src/spec-node/singleContainer.ts b/src/spec-node/singleContainer.ts index 07d111cc2..d1c047999 100644 --- a/src/spec-node/singleContainer.ts +++ b/src/spec-node/singleContainer.ts @@ -200,7 +200,7 @@ async function buildAndExtendImage(buildParams: DockerResolverParameters, config if (buildParams.buildxPush) { args.push('--push'); } else { - if (buildParams.buildxOutput) { + if (buildParams.buildxOutput) { args.push('--output', buildParams.buildxOutput); } else { 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 args.push('--cache-to', buildParams.buildxCacheTo); } args.push('--build-arg', 'BUILDKIT_INLINE_CACHE=1'); + + for (const secret of buildParams.buildSecrets) { + if (secret.file) { + args.push('--secret', `id=${secret.id},src=${secret.file}`); + } else if (secret.env) { + args.push('--secret', `id=${secret.id},env=${secret.env}`); + } + } } else { args.push('build'); } diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index 2fe141320..78fb5a1bd 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -37,6 +37,12 @@ export type BindMountConsistency = 'consistent' | 'cached' | 'delegated' | undef export type GPUAvailability = 'all' | 'detect' | 'none'; +export interface BuildSecret { + id: string; + file?: string; + env?: string; +} + // Generic retry function export async function retry(fn: () => Promise, options: { retryIntervalMilliseconds: number; maxRetries: number; output: Log }): Promise { const { retryIntervalMilliseconds, maxRetries, output } = options; @@ -123,6 +129,7 @@ export interface DockerResolverParameters { buildxOutput: string | undefined; buildxCacheTo: string | undefined; platformInfo: PlatformInfo; + buildSecrets: BuildSecret[]; } export interface ResolverResult { diff --git a/src/test/cli.build.test.ts b/src/test/cli.build.test.ts index 0dfae0427..a0357dec4 100644 --- a/src/test/cli.build.test.ts +++ b/src/test/cli.build.test.ts @@ -53,7 +53,7 @@ describe('Dev Containers CLI', function () { await shellExec(`${cli} build --workspace-folder ${testFolder} --image-name demo:v1`); const tags = await shellExec(`docker images --format "{{.Tag}}" demo`); const imageTags = tags.stdout.trim().split('\n').filter(tag => tag !== ''); - assert.equal(imageTags.length, 1, 'There should be only one tag for demo:v1'); + assert.equal(imageTags.length, 1, 'There should be only one tag for demo:v1'); } catch (error) { assert.equal(error.code, 'ERR_ASSERTION', 'Should fail with ERR_ASSERTION'); } @@ -433,5 +433,55 @@ describe('Dev Containers CLI', function () { const details = JSON.parse((await shellExec(`docker inspect ${response.imageName}`)).stdout)[0] as ImageDetails; assert.strictEqual(details.Config.Labels?.test_build_options, 'success'); }); + + it('should build with Docker Compose and build secrets', async () => { + // Create a temporary secret file + const secretContent = 'My super important secret'; + const secretFile = path.join(os.tmpdir(), 'test_secret.txt'); + fs.writeFileSync(secretFile, secretContent); + + const testFolder = `${__dirname}/configs/compose-build-secret-feature`; + const imageName = 'test-compose-image'; + + try { + const res = await shellExec(`${cli} build --workspace-folder ${testFolder} --build-secret id=compose_file,src=${secretFile} --image-name ${imageName} --no-cache`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + + // Verify the secret content was correctly written to the image + const secretCheck = await shellExec(`docker run --rm --entrypoint="cat" ${imageName} /secret_file.txt`); + assert.equal(secretCheck.stdout.trim(), secretContent); + const secretCheckDockerfile = await shellExec(`docker run --rm --entrypoint="cat" ${imageName} /secret_test_output_dockerfile.txt`); + assert.equal(secretCheckDockerfile.stdout.trim(), secretContent); + } finally { + // Cleanup + fs.unlinkSync(secretFile); + await shellExec(`docker rmi -f ${imageName}`).catch(() => { }); + } + }); + + it('should build with image and build secrets', async () => { + // Create a temporary secret file + const secretContent = 'My super important secret'; + const secretFile = path.join(os.tmpdir(), 'test_secret.txt'); + fs.writeFileSync(secretFile, secretContent); + + const testFolder = `${__dirname}/configs/build-secret-feature`; + const imageName = 'test-build-secrets-image'; + + try { + const res = await shellExec(`${cli} build --workspace-folder ${testFolder} --build-secret id=compose_file,src=${secretFile} --image-name ${imageName} --no-cache`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + + // Verify the secret content was correctly written to the image + const secretCheck = await shellExec(`docker run --rm --entrypoint="cat" ${imageName} /secret_file.txt`); + assert.equal(secretCheck.stdout.trim(), secretContent); + } finally { + // Cleanup + fs.unlinkSync(secretFile); + await shellExec(`docker rmi -f ${imageName}`).catch(() => { }); + } + }); }); }); diff --git a/src/test/configs/build-secret-feature/.devcontainer/devcontainer.json b/src/test/configs/build-secret-feature/.devcontainer/devcontainer.json new file mode 100644 index 000000000..c8039894d --- /dev/null +++ b/src/test/configs/build-secret-feature/.devcontainer/devcontainer.json @@ -0,0 +1,7 @@ +{ + "name": "Test Feature Secrets", + "image": "debian:trixie-slim", + "features": { + "./secret_test": {} + } +} \ No newline at end of file diff --git a/src/test/configs/build-secret-feature/.devcontainer/secret_test/devcontainer-feature.json b/src/test/configs/build-secret-feature/.devcontainer/secret_test/devcontainer-feature.json new file mode 100644 index 000000000..51d49e8c5 --- /dev/null +++ b/src/test/configs/build-secret-feature/.devcontainer/secret_test/devcontainer-feature.json @@ -0,0 +1,6 @@ +{ + "id": "secret_test", + "name": "Secret Test Feature", + "description": "A test feature to demonstrate secrets in Docker Buildx", + "version": "1.0.0" +} \ No newline at end of file diff --git a/src/test/configs/build-secret-feature/.devcontainer/secret_test/install.sh b/src/test/configs/build-secret-feature/.devcontainer/secret_test/install.sh new file mode 100755 index 000000000..b62e73c66 --- /dev/null +++ b/src/test/configs/build-secret-feature/.devcontainer/secret_test/install.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -e +if [ -f /run/secrets/compose_file ]; then + echo "Contents of secret file:" + cat /run/secrets/compose_file + echo "Writing secret file contents to /secret_file.txt" + cat /run/secrets/compose_file > /secret_file.txt +else + echo "Secret file not found!" + exit 1 +fi diff --git a/src/test/configs/compose-build-secret-feature/.devcontainer/Dockerfile b/src/test/configs/compose-build-secret-feature/.devcontainer/Dockerfile new file mode 100644 index 000000000..0347efcab --- /dev/null +++ b/src/test/configs/compose-build-secret-feature/.devcontainer/Dockerfile @@ -0,0 +1,2 @@ +FROM debian:trixie-slim +RUN --mount=type=secret,id=compose_file cat /run/secrets/compose_file > /secret_test_output_dockerfile.txt diff --git a/src/test/configs/compose-build-secret-feature/.devcontainer/devcontainer.json b/src/test/configs/compose-build-secret-feature/.devcontainer/devcontainer.json new file mode 100644 index 000000000..eabd819bb --- /dev/null +++ b/src/test/configs/compose-build-secret-feature/.devcontainer/devcontainer.json @@ -0,0 +1,9 @@ +{ + "name": "Test Docker Compose Feature Secrets", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces", + "features": { + "./secret_test": {} + } +} \ No newline at end of file diff --git a/src/test/configs/compose-build-secret-feature/.devcontainer/docker-compose.yml b/src/test/configs/compose-build-secret-feature/.devcontainer/docker-compose.yml new file mode 100644 index 000000000..14b9ba6a5 --- /dev/null +++ b/src/test/configs/compose-build-secret-feature/.devcontainer/docker-compose.yml @@ -0,0 +1,9 @@ +version: '3.8' +services: + app: + build: + context: . + dockerfile: Dockerfile + volumes: + - ..:/workspaces:cached + command: sleep infinity \ No newline at end of file diff --git a/src/test/configs/compose-build-secret-feature/.devcontainer/secret_test/devcontainer-feature.json b/src/test/configs/compose-build-secret-feature/.devcontainer/secret_test/devcontainer-feature.json new file mode 100644 index 000000000..51d49e8c5 --- /dev/null +++ b/src/test/configs/compose-build-secret-feature/.devcontainer/secret_test/devcontainer-feature.json @@ -0,0 +1,6 @@ +{ + "id": "secret_test", + "name": "Secret Test Feature", + "description": "A test feature to demonstrate secrets in Docker Buildx", + "version": "1.0.0" +} \ No newline at end of file diff --git a/src/test/configs/compose-build-secret-feature/.devcontainer/secret_test/install.sh b/src/test/configs/compose-build-secret-feature/.devcontainer/secret_test/install.sh new file mode 100755 index 000000000..b62e73c66 --- /dev/null +++ b/src/test/configs/compose-build-secret-feature/.devcontainer/secret_test/install.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -e +if [ -f /run/secrets/compose_file ]; then + echo "Contents of secret file:" + cat /run/secrets/compose_file + echo "Writing secret file contents to /secret_file.txt" + cat /run/secrets/compose_file > /secret_file.txt +else + echo "Secret file not found!" + exit 1 +fi