Skip to content

Commit f3d6762

Browse files
committed
fix: preserve Dockerfile-specific .dockerignore files
1 parent f4ee5d0 commit f3d6762

4 files changed

Lines changed: 149 additions & 2 deletions

File tree

src/spec-node/dockerCompose.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { Mount, parseMount } from '../spec-configuration/containerFeaturesConfig
1919
import path from 'path';
2020
import { getDevcontainerMetadata, getImageBuildInfoFromDockerfile, getImageBuildInfoFromImage, getImageMetadataFromContainer, ImageBuildInfo, lifecycleCommandOriginMapFromMetadata, mergeConfiguration, MergedDevContainerConfig } from './imageMetadata';
2121
import { ensureDockerfileHasFinalStageName } from './dockerfileUtils';
22+
import { copyDockerIgnoreFileIfExists } from './dockerignoreUtils';
2223
import { randomUUID } from 'crypto';
2324

2425
const projectLabel = 'com.docker.compose.project';
@@ -162,12 +163,13 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf
162163
// determine base imageName for generated features build stage(s)
163164
let baseName = 'dev_container_auto_added_stage_label';
164165
let dockerfile: string | undefined;
166+
let sourceDockerfilePath: string | undefined;
165167
let imageBuildInfo: ImageBuildInfo;
166168
const serviceInfo = getBuildInfoForService(composeService, cliHost.path, localComposeFiles);
167169
if (serviceInfo.build) {
168170
const { context, dockerfilePath, target } = serviceInfo.build;
169-
const resolvedDockerfilePath = cliHost.path.isAbsolute(dockerfilePath) ? dockerfilePath : path.resolve(context, dockerfilePath);
170-
const originalDockerfile = (await cliHost.readFile(resolvedDockerfilePath)).toString();
171+
sourceDockerfilePath = cliHost.path.isAbsolute(dockerfilePath) ? dockerfilePath : path.resolve(context, dockerfilePath);
172+
const originalDockerfile = (await cliHost.readFile(sourceDockerfilePath)).toString();
171173
dockerfile = originalDockerfile;
172174
if (target) {
173175
// Explictly set build target for the dev container build features on that
@@ -214,6 +216,9 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf
214216
let finalDockerfileContent = `${featureBuildInfo.dockerfilePrefixContent}${dockerfile}\n${featureBuildInfo.dockerfileContent}`;
215217
const finalDockerfilePath = cliHost.path.join(featureBuildInfo?.dstFolder, 'Dockerfile-with-features');
216218
await cliHost.writeFile(finalDockerfilePath, Buffer.from(finalDockerfileContent));
219+
if (sourceDockerfilePath) {
220+
await copyDockerIgnoreFileIfExists(cliHost, sourceDockerfilePath, finalDockerfilePath);
221+
}
217222
buildOverrideContent += ` dockerfile: ${finalDockerfilePath}\n`;
218223
if (serviceInfo.build?.target) {
219224
// Replace target. (Only when set because it is only supported with Docker Compose file version 3.4 and later.)

src/spec-node/dockerignoreUtils.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { CLIHost } from '../spec-common/cliHost';
7+
8+
export async function copyDockerIgnoreFileIfExists(cliHost: CLIHost, sourceDockerfilePath: string, targetDockerfilePath: string) {
9+
const sourceDockerIgnorePath = `${sourceDockerfilePath}.dockerignore`;
10+
if (!(await cliHost.isFile(sourceDockerIgnorePath))) {
11+
return;
12+
}
13+
14+
const targetDockerIgnorePath = `${targetDockerfilePath}.dockerignore`;
15+
await cliHost.writeFile(targetDockerIgnorePath, await cliHost.readFile(sourceDockerIgnorePath));
16+
}

src/spec-node/singleContainer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { LogLevel, Log, makeLog } from '../spec-utils/log';
1313
import { extendImage, getExtendImageBuildInfo, updateRemoteUserUID } from './containerFeatures';
1414
import { getDevcontainerMetadata, getImageBuildInfoFromDockerfile, getImageMetadataFromContainer, ImageMetadataEntry, lifecycleCommandOriginMapFromMetadata, mergeConfiguration, MergedDevContainerConfig } from './imageMetadata';
1515
import { ensureDockerfileHasFinalStageName, generateMountCommand } from './dockerfileUtils';
16+
import { copyDockerIgnoreFileIfExists } from './dockerignoreUtils';
1617

1718
export const hostFolderLabel = 'devcontainer.local_folder'; // used to label containers created from a workspace/folder
1819
export const configFileLabel = 'devcontainer.config_file';
@@ -161,6 +162,7 @@ async function buildAndExtendImage(buildParams: DockerResolverParameters, config
161162
let finalDockerfileContent = `${featureBuildInfo.dockerfilePrefixContent}${dockerfile}\n${featureBuildInfo.dockerfileContent}`;
162163
finalDockerfilePath = cliHost.path.join(featureBuildInfo?.dstFolder, 'Dockerfile-with-features');
163164
await cliHost.writeFile(finalDockerfilePath, Buffer.from(finalDockerfileContent));
165+
await copyDockerIgnoreFileIfExists(cliHost, dockerfilePath, finalDockerfilePath);
164166

165167
// track additional build args to include below
166168
for (const buildContext in featureBuildInfo.buildKitContexts) {

src/test/dockerignore.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { assert } from 'chai';
2+
import { promises as fs } from 'fs';
3+
import * as os from 'os';
4+
import * as path from 'path';
5+
import { getCLIHost } from '../spec-common/cliHost';
6+
import { buildAndExtendDockerCompose } from '../spec-node/dockerCompose';
7+
import { nullLog } from '../spec-utils/log';
8+
import { testSubstitute } from './testUtils';
9+
10+
describe('dockerignore handling', () => {
11+
it('copies Dockerfile-specific dockerignore files next to generated compose Dockerfiles', async () => {
12+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dockerignore-compose-'));
13+
const workspace = path.join(root, 'workspace');
14+
const devcontainerDir = path.join(workspace, '.devcontainer', 'app');
15+
const composeFile = path.join(devcontainerDir, 'docker-compose.yaml');
16+
const dockerfile = path.join(devcontainerDir, 'dev.Dockerfile');
17+
const sourceDockerIgnore = `${dockerfile}.dockerignore`;
18+
const dockerIgnoreContent = '*\n!/app/requirements.txt\n';
19+
let generatedFolder: string | undefined;
20+
21+
try {
22+
await fs.mkdir(devcontainerDir, { recursive: true });
23+
await fs.writeFile(dockerfile, 'FROM ubuntu:24.04\nRUN echo hello\n');
24+
await fs.writeFile(sourceDockerIgnore, dockerIgnoreContent);
25+
await fs.writeFile(composeFile, [
26+
'services:',
27+
' app:',
28+
' build:',
29+
' context: ../..',
30+
' dockerfile: .devcontainer/app/dev.Dockerfile',
31+
'',
32+
].join('\n'));
33+
34+
const fakeDocker = path.join(root, 'fake-docker');
35+
await fs.writeFile(fakeDocker, `#!/bin/sh
36+
set -eu
37+
mode=""
38+
for arg in "$@"; do
39+
case "$arg" in
40+
config) mode="config" ;;
41+
build) mode="build" ;;
42+
esac
43+
done
44+
if [ "$1" = "inspect" ]; then
45+
printf '%s' '[{"Id":"img","Architecture":"amd64","Os":"linux","Config":{"User":"","Env":[],"Labels":{}}}]'
46+
exit 0
47+
fi
48+
if [ "$1" = "compose" ] && [ "$mode" = "config" ]; then
49+
cat <<'EOF'
50+
services:
51+
app:
52+
build:
53+
context: ${workspace}
54+
dockerfile: .devcontainer/app/dev.Dockerfile
55+
EOF
56+
exit 0
57+
fi
58+
if [ "$1" = "compose" ] && [ "$mode" = "build" ]; then
59+
exit 0
60+
fi
61+
printf 'unexpected %s\n' "$*" >&2
62+
exit 1
63+
`);
64+
await fs.chmod(fakeDocker, 0o755);
65+
66+
const cliHost = await getCLIHost(workspace, async () => undefined, false);
67+
const common = {
68+
cliHost,
69+
env: process.env,
70+
output: nullLog,
71+
package: { name: 'test', version: '0.0.0' },
72+
persistedFolder: path.join(root, 'persisted'),
73+
skipPersistingCustomizationsFromFeatures: false,
74+
omitSyntaxDirective: false,
75+
} as any;
76+
const params = {
77+
common,
78+
dockerCLI: fakeDocker,
79+
dockerComposeCLI: async () => ({ version: '2.20.0', cmd: fakeDocker, args: ['compose'] }),
80+
dockerEnv: process.env,
81+
isPodman: false,
82+
buildKitVersion: undefined,
83+
dockerEngineVersion: undefined,
84+
isTTY: false,
85+
buildPlatformInfo: { os: 'linux', arch: 'amd64' },
86+
targetPlatformInfo: { os: 'linux', arch: 'amd64' },
87+
} as any;
88+
const config = { service: 'app' };
89+
90+
const result = await buildAndExtendDockerCompose(
91+
{ config, raw: config, substitute: testSubstitute } as any,
92+
'proj',
93+
params,
94+
[composeFile],
95+
undefined,
96+
[],
97+
[],
98+
false,
99+
common.persistedFolder,
100+
'docker-compose.devcontainer.build',
101+
'',
102+
{},
103+
true,
104+
undefined,
105+
true
106+
);
107+
108+
assert.lengthOf(result.additionalComposeOverrideFiles, 1);
109+
const override = await fs.readFile(result.additionalComposeOverrideFiles[0], 'utf8');
110+
const match = override.match(/dockerfile: (.+)/);
111+
assert.isNotNull(match);
112+
const generatedDockerfile = match![1].trim();
113+
const generatedDockerIgnore = `${generatedDockerfile}.dockerignore`;
114+
115+
generatedFolder = path.dirname(generatedDockerfile);
116+
assert.strictEqual(await fs.readFile(generatedDockerIgnore, 'utf8'), dockerIgnoreContent);
117+
} finally {
118+
await fs.rm(root, { recursive: true, force: true });
119+
if (generatedFolder) {
120+
await fs.rm(generatedFolder, { recursive: true, force: true });
121+
}
122+
}
123+
});
124+
});

0 commit comments

Comments
 (0)