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
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
8 changes: 8 additions & 0 deletions src/spec-configuration/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ export interface DevContainerFeature {
options: boolean | string | Record<string, boolean | string | undefined>;
}

export interface DockerfilePreprocessor {
tool?: string;
args?: string[];
generatedDockerfilePath?: 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 +117,7 @@ export type DevContainerFromDockerfileConfig = {
overrideFeatureInstallOrder?: string[];
hostRequirements?: HostRequirements;
customizations?: Record<string, any>;
dockerfilePreprocessor?: DockerfilePreprocessor;
} & (
{
dockerFile: string;
Expand Down Expand Up @@ -169,6 +176,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
104 changes: 104 additions & 0 deletions src/spec-node/dockerfilePreprocessor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*---------------------------------------------------------------------------------------------
* 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', optional 'dockerfilePreprocessor.args', and 'dockerfilePreprocessor.generatedDockerfilePath' in devcontainer.json. The CLI invokes the tool with configured args and validates that the generated Dockerfile exists at the configured path.";
}

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

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

const { cliHost, output } = params;
const infoOutput = makeLog(output, LogLevel.Info);
const workdirPath = path.dirname(dockerfilePath);
const generatedOutputPath = path.resolve(workdirPath, generatedDockerfilePath);
const generatedOutputDir = path.dirname(generatedOutputPath);
await cliHost.mkdirp(generatedOutputDir);
const staleOutputPaths = [generatedOutputPath];
for (const stalePath of staleOutputPaths) {
if (!await cliHost.isFile(stalePath)) {
continue;
}
await cliHost.remove(stalePath);
}

// Minimal contract: tool args are user-controlled and run in the Dockerfile
// directory. The CLI only provides the resolved generated Dockerfile path.
const env = {
...cliHost.env,
DEVCONTAINER_DOCKERFILE_PREPROCESSOR_GENERATED_DOCKERFILE: generatedOutputPath,
};
const invocationArgs = [...args];

try {
infoOutput.write(`Preprocessing '${dockerfilePath}' -> '${generatedOutputPath}'`);
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 },
});
}

const generatedExists = await cliHost.isFile(generatedOutputPath);
if (!generatedExists) {
throw new ContainerError({
description: `Dockerfile preprocessing did not produce '${generatedOutputPath}'. Ensure the configured tool writes the final Dockerfile to the configured generatedDockerfile path. ${dockerfilePreprocessorToolDocs()}`,
data: { fileWithError: dockerfilePath },
});
}

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

return generatedOutputPath;
}
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,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,18 @@
{
"name": "Docker Compose Cpp Preprocessor",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"dockerfilePreprocessor": {
"tool": "cpp",
"generatedDockerfilePath": "Dockerfile",
"args": [
"-P",
"Dockerfile.in",
"Dockerfile"
]
},
"features": {
"ghcr.io/devcontainers/features/git": "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"
],
"generatedDockerfilePath": "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"
],
"generatedDockerfilePath": "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"]
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"
],
"generatedDockerfilePath": "Dockerfile"
},
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {
"version": "latest"
}
}
}
Loading
Loading