Skip to content
Draft
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
6 changes: 5 additions & 1 deletion src/spec-common/cliHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as path from 'path';
import * as net from 'net';
import * as os from 'os';

import { readLocalFile, writeLocalFile, mkdirpLocal, isLocalFile, renameLocal, readLocalDir, isLocalFolder } from '../spec-utils/pfs';
import { readLocalFile, writeLocalFile, mkdirpLocal, isLocalFile, renameLocal, readLocalDir, isLocalFolder, rmLocal, cpLocal } from '../spec-utils/pfs';
import { URI } from 'vscode-uri';
import { ExecFunction, getLocalUsername, plainExec, plainPtyExec, PtyExecFunction } from './commonUtils';
import { Abort, Duplex, Sink, Source, SourceCallback } from 'pull-stream';
Expand All @@ -32,7 +32,9 @@ export interface CLIHost {
isFolder(filepath: string): Promise<boolean>;
readFile(filepath: string): Promise<Buffer>;
writeFile(filepath: string, content: Buffer): Promise<void>;
copyFile(oldPath: string, newPath: string): Promise<void>;
rename(oldPath: string, newPath: string): Promise<void>;
remove(filepath: string): Promise<void>;
mkdirp(dirpath: string): Promise<void>;
readDir(dirpath: string): Promise<string[]>;
readDirWithTypes?(dirpath: string): Promise<[string, FileTypeBitmask][]>;
Expand Down Expand Up @@ -76,7 +78,9 @@ function createLocalCLIHostFromExecFunctions(localCwd: string, exec: ExecFunctio
isFolder: isLocalFolder,
readFile: readLocalFile,
writeFile: writeLocalFile,
copyFile: cpLocal,
rename: renameLocal,
remove: async (filepath) => rmLocal(filepath, { force: true }),
mkdirp: async (dirpath) => {
await mkdirpLocal(dirpath);
},
Expand Down
15 changes: 15 additions & 0 deletions src/spec-configuration/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ export interface DevContainerFeature {
options: boolean | string | Record<string, boolean | string | undefined>;
}

// Dockerfile preprocessing always produces a CLI-owned final Dockerfile path.
// Users provide the preprocessor tool and optional arguments.
// For direct file transforms, users can select whether the tool behaves like a
// single-file transform or expects a build-tree style workspace argument.
// For workspace-style generators, users can instead set generatedDockerfile to
// tell the CLI which file to promote to the final Dockerfile after the tool runs.
export interface DockerfilePreprocessor {
tool?: string;
args?: string[];
outputMode?: 'single-file' | 'build-tree';
generatedDockerfile?: string;
}

export interface DevContainerFromImageConfig {
configFilePath?: URI;
image?: string; // Only optional when setting up an existing container as a dev container.
Expand Down Expand Up @@ -111,6 +124,7 @@ export type DevContainerFromDockerfileConfig = {
overrideFeatureInstallOrder?: string[];
hostRequirements?: HostRequirements;
customizations?: Record<string, any>;
dockerfilePreprocessor?: DockerfilePreprocessor;
} & (
{
dockerFile: string;
Expand Down Expand Up @@ -169,6 +183,7 @@ export interface DevContainerFromDockerComposeConfig {
overrideFeatureInstallOrder?: string[];
hostRequirements?: HostRequirements;
customizations?: Record<string, any>;
dockerfilePreprocessor?: DockerfilePreprocessor;
}

interface DevContainerVSCodeConfig {
Expand Down
6 changes: 5 additions & 1 deletion 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 { preprocessDockerExtensionFile } from './dockerfilePreprocessor';
import { randomUUID } from 'crypto';

const projectLabel = 'com.docker.compose.project';
Expand Down Expand Up @@ -166,7 +167,10 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf
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);
let resolvedDockerfilePath = cliHost.path.isAbsolute(dockerfilePath) ? dockerfilePath : path.resolve(context, dockerfilePath);
if (resolvedDockerfilePath.toLowerCase().endsWith('.in')) {
resolvedDockerfilePath = await preprocessDockerExtensionFile(common, config, resolvedDockerfilePath);
}
const originalDockerfile = (await cliHost.readFile(resolvedDockerfilePath)).toString();
dockerfile = originalDockerfile;
if (target) {
Expand Down
129 changes: 129 additions & 0 deletions src/spec-node/dockerfilePreprocessor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as path from 'path';
import { DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig } from '../spec-configuration/configuration';
import { ContainerError, toErrorText } from '../spec-common/errors';
import { CLIHost } from '../spec-common/cliHost';
import { runCommandNoPty } from '../spec-common/commonUtils';
import { Log, LogLevel, makeLog } from '../spec-utils/log';

function dockerfilePreprocessorToolDocs(): string {
return "Set 'dockerfilePreprocessor.tool' and optional 'dockerfilePreprocessor.args' in devcontainer.json. Use 'outputMode' to choose whether the tool runs in 'single-file' mode or 'build-tree' mode. Use 'generatedDockerfile' for tools that write the final Dockerfile to a predictable workspace-relative path instead of the CLI-provided output argument.";
}

export function getDockerfilePreprocessedPath(dockerfilePath: string): string | undefined {
if (!dockerfilePath.toLowerCase().endsWith('.in')) {
return undefined;
}
return path.join(path.dirname(dockerfilePath), '.devcontainer-preprocessed', 'Dockerfile');
}

export async function preprocessDockerExtensionFile(
params: { cliHost: CLIHost; output: Log },
config: Pick<DevContainerFromDockerfileConfig | DevContainerFromDockerComposeConfig, 'dockerfilePreprocessor'>,
dockerfilePath: string
): Promise<string> {
const cliOutputPath = getDockerfilePreprocessedPath(dockerfilePath);
if (!cliOutputPath) {
return dockerfilePath;
}

const tool = config.dockerfilePreprocessor?.tool?.trim();
const args = (config.dockerfilePreprocessor?.args || []).map(arg => arg.trim()).filter(arg => arg.length > 0);
const outputMode = config.dockerfilePreprocessor?.outputMode || 'build-tree';
const generatedDockerfile = config.dockerfilePreprocessor?.generatedDockerfile?.trim();
if (!tool) {
throw new ContainerError({
description: `A Dockerfile preprocessor tool is required to build from '${dockerfilePath}'. ${dockerfilePreprocessorToolDocs()}`,
data: { fileWithError: dockerfilePath },
});
}

const { cliHost, output } = params;
const infoOutput = makeLog(output, LogLevel.Info);
const cliOutputDir = path.dirname(cliOutputPath);
await cliHost.mkdirp(cliOutputDir);
const workdirPath = path.dirname(dockerfilePath);
const inputPath = dockerfilePath;
const outputPath = cliOutputPath;
const generatedOutputPath = generatedDockerfile ? path.resolve(workdirPath, generatedDockerfile) : outputPath;
const staleOutputPaths = generatedOutputPath === outputPath ? [outputPath] : [outputPath, generatedOutputPath];
for (const stalePath of staleOutputPaths) {
if (!await cliHost.isFile(stalePath)) {
continue;
}
await cliHost.remove(stalePath);
}

// Strict contract: the CLI owns the final output path. Direct-transform
// tools can write to the CLI-provided output argument; workspace generators
// can instead declare a generated Dockerfile path for the CLI to promote.
const env = {
...cliHost.env,
DEVCONTAINER_DOCKERFILE_PREPROCESSOR_INPUT: inputPath,
DEVCONTAINER_DOCKERFILE_PREPROCESSOR_OUTPUT: outputPath,
DEVCONTAINER_DOCKERFILE_PREPROCESSOR_WORKDIR: workdirPath,
DEVCONTAINER_DOCKERFILE_PREPROCESSOR_GENERATED_DOCKERFILE: generatedOutputPath,
input_file: inputPath,
output_file: outputPath,
generated_dockerfile: generatedOutputPath,
workdir: workdirPath,
};
const directOutputArgs = outputMode === 'single-file'
? [inputPath, outputPath]
: [inputPath, outputPath, workdirPath];
const invocationArgs = generatedDockerfile ? args : [...args, ...directOutputArgs];

try {
infoOutput.write(`Preprocessing '${dockerfilePath}' -> '${cliOutputPath}'`);
await runCommandNoPty({
exec: cliHost.exec,
cmd: tool,
args: invocationArgs,
cwd: workdirPath,
env,
output: infoOutput,
print: 'continuous',
});
} catch (err) {
const originalError = err as {
message?: string;
stderr?: Buffer | string;
cmdOutput?: string;
code?: number;
signal?: string;
};
const stderrText = typeof originalError?.stderr === 'string' ? originalError.stderr : originalError?.stderr?.toString();
throw new ContainerError({
description: `Dockerfile preprocessing failed while running '${tool}'. ${dockerfilePreprocessorToolDocs()}`,
originalError: {
message: `${originalError?.message || 'Dockerfile preprocessing command failed.'} ${toErrorText(stderrText || originalError?.cmdOutput || '')}`.trim(),
code: originalError?.code,
signal: originalError?.signal,
stderr: originalError?.stderr,
},
data: { fileWithError: dockerfilePath },
});
}

if (!await cliHost.isFile(generatedOutputPath)) {
throw new ContainerError({
description: generatedDockerfile
? `Dockerfile preprocessing did not produce '${generatedOutputPath}'. Ensure the configured tool writes the final Dockerfile to the configured generatedDockerfile path. ${dockerfilePreprocessorToolDocs()}`
: `Dockerfile preprocessing did not produce '${outputPath}'. Ensure the configured tool writes the final Dockerfile to the CLI-provided output argument. ${dockerfilePreprocessorToolDocs()}`,
data: { fileWithError: dockerfilePath },
});
}

if (generatedOutputPath !== outputPath) {
await cliHost.copyFile(generatedOutputPath, outputPath);
await cliHost.remove(generatedOutputPath);
}

infoOutput.write(`Preprocessed Dockerfile written to '${cliOutputPath}'`);

return cliOutputPath;
}
8 changes: 6 additions & 2 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 { preprocessDockerExtensionFile } from './dockerfilePreprocessor';

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 @@ -125,8 +126,11 @@ async function buildAndExtendImage(buildParams: DockerResolverParameters, config
const { cliHost, output } = buildParams.common;
const { config } = configWithRaw;
const dockerfileUri = getDockerfilePath(cliHost, config);
const dockerfilePath = await uriToWSLFsPath(dockerfileUri, cliHost);
if (!cliHost.isFile(dockerfilePath)) {
let dockerfilePath = await uriToWSLFsPath(dockerfileUri, cliHost);
if (dockerfilePath.toLowerCase().endsWith('.in')) {
dockerfilePath = await preprocessDockerExtensionFile(buildParams.common, config, dockerfilePath);
}
if (!await cliHost.isFile(dockerfilePath)) {
throw new ContainerError({ description: `Dockerfile (${dockerfilePath}) not found.` });
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "Docker Compose Cpp Preprocessor",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"dockerfilePreprocessor": {
"tool": "cpp",
"outputMode": "single-file",
"args": [
"-P"
]
},
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {
"version": "latest"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
version: '3.8'

services:
app:
build:
context: ..
dockerfile: Dockerfile.in
command: sleep infinity
volumes:
- ..:/workspace:cached
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#define BASE_IMAGE ubuntu:20.04
#define INSTALL_NODE
#define INSTALL_PYTHON

FROM BASE_IMAGE

#ifdef INSTALL_NODE
RUN apt-get update && apt-get install -y nodejs
#endif

#ifdef INSTALL_PYTHON
RUN apt-get update && apt-get install -y python3
#endif

#include "common.Dockerfile"
#include "tools.Dockerfile"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
RUN apt-get update && apt-get install -y curl wget
2 changes: 2 additions & 0 deletions src/test/configs/dockercomposefile-cpp-preprocessor/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
echo hello
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
RUN apt-get update && apt-get install -y vim
COPY ./test.sh /usr/local/bin/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"build": {
"dockerfile": "Dockerfile.in"
},
"dockerfilePreprocessor": {
"tool": "sh",
"args": [
"-c",
"autoconf && ./configure"
],
"generatedDockerfile": "Dockerfile"
},
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {
"version": "latest"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
FROM @BASE_IMAGE@

ARG APP_PORT=@APP_PORT@
EXPOSE @APP_PORT@

WORKDIR /workspace
COPY . /workspace

CMD ["npm", "start"]
11 changes: 11 additions & 0 deletions src/test/configs/dockerfile-autoconf-preprocessor/configure.ac
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
AC_INIT([generate-dockerfile], [1.0])
AC_CONFIG_SRCDIR([Dockerfile.in])

BASE_IMAGE='node:22-bookworm'
APP_PORT='3000'

AC_SUBST([BASE_IMAGE])
AC_SUBST([APP_PORT])

AC_CONFIG_FILES([Dockerfile])
AC_OUTPUT
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"build": {
"dockerfile": "Dockerfile.in"
},
"dockerfilePreprocessor": {
"tool": "cmake",
"args": [
"-S",
".",
"-B",
"build"
],
"generatedDockerfile": "build/Dockerfile"
},
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {
"version": "latest"
}
}
}
11 changes: 11 additions & 0 deletions src/test/configs/dockerfile-cmake-preprocessor/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
cmake_minimum_required(VERSION 3.16)
project(GenerateDockerfile NONE)

set(BASE_IMAGE "node:22-bookworm")
set(APP_PORT "3000")

configure_file(
${CMAKE_CURRENT_SOURCE_DIR}/Dockerfile.in
${CMAKE_CURRENT_BINARY_DIR}/Dockerfile
@ONLY
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
FROM @BASE_IMAGE@

ARG APP_PORT=@APP_PORT@
EXPOSE @APP_PORT@

WORKDIR /workspace
COPY . /workspace

CMD ["npm", "start"]
Loading
Loading