diff --git a/src/spec-node/dockerCompose.ts b/src/spec-node/dockerCompose.ts index 8093464cc..a4e582d32 100644 --- a/src/spec-node/dockerCompose.ts +++ b/src/spec-node/dockerCompose.ts @@ -19,6 +19,7 @@ import { Mount, parseMount } from '../spec-configuration/containerFeaturesConfig import path from 'path'; import { getDevcontainerMetadata, getImageBuildInfoFromDockerfile, getImageBuildInfoFromImage, getImageMetadataFromContainer, ImageBuildInfo, lifecycleCommandOriginMapFromMetadata, mergeConfiguration, MergedDevContainerConfig } from './imageMetadata'; import { ensureDockerfileHasFinalStageName } from './dockerfileUtils'; +import { copyDockerIgnoreFileIfExists } from './dockerignoreUtils'; import { randomUUID } from 'crypto'; const projectLabel = 'com.docker.compose.project'; @@ -162,12 +163,13 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf // determine base imageName for generated features build stage(s) let baseName = 'dev_container_auto_added_stage_label'; let dockerfile: string | undefined; + let sourceDockerfilePath: string | undefined; let imageBuildInfo: ImageBuildInfo; const serviceInfo = getBuildInfoForService(composeService, cliHost.path, localComposeFiles); if (serviceInfo.build) { const { context, dockerfilePath, target } = serviceInfo.build; - const resolvedDockerfilePath = cliHost.path.isAbsolute(dockerfilePath) ? dockerfilePath : path.resolve(context, dockerfilePath); - const originalDockerfile = (await cliHost.readFile(resolvedDockerfilePath)).toString(); + sourceDockerfilePath = cliHost.path.isAbsolute(dockerfilePath) ? dockerfilePath : path.resolve(context, dockerfilePath); + const originalDockerfile = (await cliHost.readFile(sourceDockerfilePath)).toString(); dockerfile = originalDockerfile; if (target) { // Explictly set build target for the dev container build features on that @@ -214,6 +216,9 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf let finalDockerfileContent = `${featureBuildInfo.dockerfilePrefixContent}${dockerfile}\n${featureBuildInfo.dockerfileContent}`; const finalDockerfilePath = cliHost.path.join(featureBuildInfo?.dstFolder, 'Dockerfile-with-features'); await cliHost.writeFile(finalDockerfilePath, Buffer.from(finalDockerfileContent)); + if (sourceDockerfilePath) { + await copyDockerIgnoreFileIfExists(cliHost, sourceDockerfilePath, finalDockerfilePath); + } buildOverrideContent += ` dockerfile: ${finalDockerfilePath}\n`; if (serviceInfo.build?.target) { // Replace target. (Only when set because it is only supported with Docker Compose file version 3.4 and later.) diff --git a/src/spec-node/dockerignoreUtils.ts b/src/spec-node/dockerignoreUtils.ts new file mode 100644 index 000000000..566e084f6 --- /dev/null +++ b/src/spec-node/dockerignoreUtils.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CLIHost } from '../spec-common/cliHost'; + +export async function copyDockerIgnoreFileIfExists(cliHost: CLIHost, sourceDockerfilePath: string, targetDockerfilePath: string) { + const sourceDockerIgnorePath = `${sourceDockerfilePath}.dockerignore`; + if (!(await cliHost.isFile(sourceDockerIgnorePath))) { + return; + } + + const targetDockerIgnorePath = `${targetDockerfilePath}.dockerignore`; + await cliHost.writeFile(targetDockerIgnorePath, await cliHost.readFile(sourceDockerIgnorePath)); +} diff --git a/src/spec-node/singleContainer.ts b/src/spec-node/singleContainer.ts index 1c3669f74..041f4d494 100644 --- a/src/spec-node/singleContainer.ts +++ b/src/spec-node/singleContainer.ts @@ -13,6 +13,7 @@ import { LogLevel, Log, makeLog } from '../spec-utils/log'; import { extendImage, getExtendImageBuildInfo, updateRemoteUserUID } from './containerFeatures'; import { getDevcontainerMetadata, getImageBuildInfoFromDockerfile, getImageMetadataFromContainer, ImageMetadataEntry, lifecycleCommandOriginMapFromMetadata, mergeConfiguration, MergedDevContainerConfig } from './imageMetadata'; import { ensureDockerfileHasFinalStageName, generateMountCommand } from './dockerfileUtils'; +import { copyDockerIgnoreFileIfExists } from './dockerignoreUtils'; export const hostFolderLabel = 'devcontainer.local_folder'; // used to label containers created from a workspace/folder export const configFileLabel = 'devcontainer.config_file'; @@ -161,6 +162,7 @@ async function buildAndExtendImage(buildParams: DockerResolverParameters, config let finalDockerfileContent = `${featureBuildInfo.dockerfilePrefixContent}${dockerfile}\n${featureBuildInfo.dockerfileContent}`; finalDockerfilePath = cliHost.path.join(featureBuildInfo?.dstFolder, 'Dockerfile-with-features'); await cliHost.writeFile(finalDockerfilePath, Buffer.from(finalDockerfileContent)); + await copyDockerIgnoreFileIfExists(cliHost, dockerfilePath, finalDockerfilePath); // track additional build args to include below for (const buildContext in featureBuildInfo.buildKitContexts) { diff --git a/src/test/dockerignore.test.ts b/src/test/dockerignore.test.ts new file mode 100644 index 000000000..34626d368 --- /dev/null +++ b/src/test/dockerignore.test.ts @@ -0,0 +1,124 @@ +import { assert } from 'chai'; +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { getCLIHost } from '../spec-common/cliHost'; +import { buildAndExtendDockerCompose } from '../spec-node/dockerCompose'; +import { nullLog } from '../spec-utils/log'; +import { testSubstitute } from './testUtils'; + +describe('dockerignore handling', () => { + it('copies Dockerfile-specific dockerignore files next to generated compose Dockerfiles', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dockerignore-compose-')); + const workspace = path.join(root, 'workspace'); + const devcontainerDir = path.join(workspace, '.devcontainer', 'app'); + const composeFile = path.join(devcontainerDir, 'docker-compose.yaml'); + const dockerfile = path.join(devcontainerDir, 'dev.Dockerfile'); + const sourceDockerIgnore = `${dockerfile}.dockerignore`; + const dockerIgnoreContent = '*\n!/app/requirements.txt\n'; + let generatedFolder: string | undefined; + + try { + await fs.mkdir(devcontainerDir, { recursive: true }); + await fs.writeFile(dockerfile, 'FROM ubuntu:24.04\nRUN echo hello\n'); + await fs.writeFile(sourceDockerIgnore, dockerIgnoreContent); + await fs.writeFile(composeFile, [ + 'services:', + ' app:', + ' build:', + ' context: ../..', + ' dockerfile: .devcontainer/app/dev.Dockerfile', + '', + ].join('\n')); + + const fakeDocker = path.join(root, 'fake-docker'); + await fs.writeFile(fakeDocker, `#!/bin/sh +set -eu +mode="" +for arg in "$@"; do + case "$arg" in + config) mode="config" ;; + build) mode="build" ;; + esac +done +if [ "$1" = "inspect" ]; then + printf '%s' '[{"Id":"img","Architecture":"amd64","Os":"linux","Config":{"User":"","Env":[],"Labels":{}}}]' + exit 0 +fi +if [ "$1" = "compose" ] && [ "$mode" = "config" ]; then + cat <<'EOF' +services: + app: + build: + context: ${workspace} + dockerfile: .devcontainer/app/dev.Dockerfile +EOF + exit 0 +fi +if [ "$1" = "compose" ] && [ "$mode" = "build" ]; then + exit 0 +fi +printf 'unexpected %s\n' "$*" >&2 +exit 1 +`); + await fs.chmod(fakeDocker, 0o755); + + const cliHost = await getCLIHost(workspace, async () => undefined, false); + const common = { + cliHost, + env: process.env, + output: nullLog, + package: { name: 'test', version: '0.0.0' }, + persistedFolder: path.join(root, 'persisted'), + skipPersistingCustomizationsFromFeatures: false, + omitSyntaxDirective: false, + } as any; + const params = { + common, + dockerCLI: fakeDocker, + dockerComposeCLI: async () => ({ version: '2.20.0', cmd: fakeDocker, args: ['compose'] }), + dockerEnv: process.env, + isPodman: false, + buildKitVersion: undefined, + dockerEngineVersion: undefined, + isTTY: false, + buildPlatformInfo: { os: 'linux', arch: 'amd64' }, + targetPlatformInfo: { os: 'linux', arch: 'amd64' }, + } as any; + const config = { service: 'app' }; + + const result = await buildAndExtendDockerCompose( + { config, raw: config, substitute: testSubstitute } as any, + 'proj', + params, + [composeFile], + undefined, + [], + [], + false, + common.persistedFolder, + 'docker-compose.devcontainer.build', + '', + {}, + true, + undefined, + true + ); + + assert.lengthOf(result.additionalComposeOverrideFiles, 1); + const override = await fs.readFile(result.additionalComposeOverrideFiles[0], 'utf8'); + const match = override.match(/dockerfile: (.+)/); + assert.isNotNull(match); + const generatedDockerfile = match![1].trim(); + const generatedDockerIgnore = `${generatedDockerfile}.dockerignore`; + + generatedFolder = path.dirname(generatedDockerfile); + assert.strictEqual(await fs.readFile(generatedDockerIgnore, 'utf8'), dockerIgnoreContent); + } finally { + await fs.rm(root, { recursive: true, force: true }); + if (generatedFolder) { + await fs.rm(generatedFolder, { recursive: true, force: true }); + } + } + }); +});