Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/spec-node/dockerCompose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sourceDockerfilePath is resolved using Node's path.resolve(...) even though the rest of the compose path handling uses cliHost.path (which can be posix/win32 depending on the host). This can produce incorrect paths when the CLI host path semantics differ from the local Node process (e.g. WSL/remote scenarios), and would prevent reading/copying the Dockerfile-specific .dockerignore. Use cliHost.path.resolve(context, dockerfilePath) (and avoid mixing path and cliHost.path) to keep resolution consistent.

Suggested change
sourceDockerfilePath = cliHost.path.isAbsolute(dockerfilePath) ? dockerfilePath : path.resolve(context, dockerfilePath);
sourceDockerfilePath = cliHost.path.isAbsolute(dockerfilePath) ? dockerfilePath : cliHost.path.resolve(context, dockerfilePath);

Copilot uses AI. Check for mistakes.
const originalDockerfile = (await cliHost.readFile(sourceDockerfilePath)).toString();
dockerfile = originalDockerfile;
if (target) {
// Explictly set build target for the dev container build features on that
Expand Down Expand Up @@ -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.)
Expand Down
16 changes: 16 additions & 0 deletions src/spec-node/dockerignoreUtils.ts
Original file line number Diff line number Diff line change
@@ -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));
}
2 changes: 2 additions & 0 deletions src/spec-node/singleContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
124 changes: 124 additions & 0 deletions src/test/dockerignore.test.ts
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +34 to +36
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test creates a fake docker executable as a #!/bin/sh script and relies on chmod + shebang execution. That approach will fail on Windows when running tests via Node’s child_process.spawn (even under Git Bash), because Windows cannot directly execute shebang scripts without an explicit interpreter. Consider generating a cross-platform fake (e.g. a small Node.js .js script invoked via node, or a .cmd wrapper on win32), or skipping this test on process.platform === 'win32'.

Copilot uses AI. Check for mistakes.
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 });
}
}
});
});
Loading