diff --git a/src/spec-common/cliHost.ts b/src/spec-common/cliHost.ts index 294f8be4a..45fb10819 100644 --- a/src/spec-common/cliHost.ts +++ b/src/spec-common/cliHost.ts @@ -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'; @@ -32,7 +32,9 @@ export interface CLIHost { isFolder(filepath: string): Promise; readFile(filepath: string): Promise; writeFile(filepath: string, content: Buffer): Promise; + copyFile(oldPath: string, newPath: string): Promise; rename(oldPath: string, newPath: string): Promise; + remove(filepath: string): Promise; mkdirp(dirpath: string): Promise; readDir(dirpath: string): Promise; readDirWithTypes?(dirpath: string): Promise<[string, FileTypeBitmask][]>; @@ -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); }, diff --git a/src/spec-configuration/configuration.ts b/src/spec-configuration/configuration.ts index 5995e7e2b..e30b21b78 100644 --- a/src/spec-configuration/configuration.ts +++ b/src/spec-configuration/configuration.ts @@ -38,6 +38,19 @@ export interface DevContainerFeature { options: boolean | string | Record; } +// 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. @@ -111,6 +124,7 @@ export type DevContainerFromDockerfileConfig = { overrideFeatureInstallOrder?: string[]; hostRequirements?: HostRequirements; customizations?: Record; + dockerfilePreprocessor?: DockerfilePreprocessor; } & ( { dockerFile: string; @@ -169,6 +183,7 @@ export interface DevContainerFromDockerComposeConfig { overrideFeatureInstallOrder?: string[]; hostRequirements?: HostRequirements; customizations?: Record; + dockerfilePreprocessor?: DockerfilePreprocessor; } interface DevContainerVSCodeConfig { diff --git a/src/spec-node/dockerCompose.ts b/src/spec-node/dockerCompose.ts index 8093464cc..5c11fb5c0 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 { preprocessDockerExtensionFile } from './dockerfilePreprocessor'; import { randomUUID } from 'crypto'; const projectLabel = 'com.docker.compose.project'; @@ -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) { diff --git a/src/spec-node/dockerfilePreprocessor.ts b/src/spec-node/dockerfilePreprocessor.ts new file mode 100644 index 000000000..a51e59f10 --- /dev/null +++ b/src/spec-node/dockerfilePreprocessor.ts @@ -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, + dockerfilePath: string +): Promise { + 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; +} diff --git a/src/spec-node/singleContainer.ts b/src/spec-node/singleContainer.ts index 1c3669f74..a402e3892 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 { preprocessDockerExtensionFile } from './dockerfilePreprocessor'; export const hostFolderLabel = 'devcontainer.local_folder'; // used to label containers created from a workspace/folder export const configFileLabel = 'devcontainer.config_file'; @@ -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.` }); } diff --git a/src/test/configs/dockercomposefile-cpp-preprocessor/.devcontainer/devcontainer.json b/src/test/configs/dockercomposefile-cpp-preprocessor/.devcontainer/devcontainer.json new file mode 100644 index 000000000..870ca8ab8 --- /dev/null +++ b/src/test/configs/dockercomposefile-cpp-preprocessor/.devcontainer/devcontainer.json @@ -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" + } + } +} diff --git a/src/test/configs/dockercomposefile-cpp-preprocessor/.devcontainer/docker-compose.yml b/src/test/configs/dockercomposefile-cpp-preprocessor/.devcontainer/docker-compose.yml new file mode 100644 index 000000000..7b2afb80b --- /dev/null +++ b/src/test/configs/dockercomposefile-cpp-preprocessor/.devcontainer/docker-compose.yml @@ -0,0 +1,10 @@ +version: '3.8' + +services: + app: + build: + context: .. + dockerfile: Dockerfile.in + command: sleep infinity + volumes: + - ..:/workspace:cached diff --git a/src/test/configs/dockercomposefile-cpp-preprocessor/Dockerfile.in b/src/test/configs/dockercomposefile-cpp-preprocessor/Dockerfile.in new file mode 100644 index 000000000..52611ea7e --- /dev/null +++ b/src/test/configs/dockercomposefile-cpp-preprocessor/Dockerfile.in @@ -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" diff --git a/src/test/configs/dockercomposefile-cpp-preprocessor/common.Dockerfile b/src/test/configs/dockercomposefile-cpp-preprocessor/common.Dockerfile new file mode 100644 index 000000000..67d4c72e0 --- /dev/null +++ b/src/test/configs/dockercomposefile-cpp-preprocessor/common.Dockerfile @@ -0,0 +1 @@ +RUN apt-get update && apt-get install -y curl wget diff --git a/src/test/configs/dockercomposefile-cpp-preprocessor/test.sh b/src/test/configs/dockercomposefile-cpp-preprocessor/test.sh new file mode 100644 index 000000000..e1a69b7af --- /dev/null +++ b/src/test/configs/dockercomposefile-cpp-preprocessor/test.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +echo hello diff --git a/src/test/configs/dockercomposefile-cpp-preprocessor/tools.Dockerfile b/src/test/configs/dockercomposefile-cpp-preprocessor/tools.Dockerfile new file mode 100644 index 000000000..8baf4e002 --- /dev/null +++ b/src/test/configs/dockercomposefile-cpp-preprocessor/tools.Dockerfile @@ -0,0 +1,2 @@ +RUN apt-get update && apt-get install -y vim +COPY ./test.sh /usr/local/bin/test.sh diff --git a/src/test/configs/dockerfile-autoconf-preprocessor/.devcontainer.json b/src/test/configs/dockerfile-autoconf-preprocessor/.devcontainer.json new file mode 100644 index 000000000..d2de81b69 --- /dev/null +++ b/src/test/configs/dockerfile-autoconf-preprocessor/.devcontainer.json @@ -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" + } + } +} \ No newline at end of file diff --git a/src/test/configs/dockerfile-autoconf-preprocessor/Dockerfile.in b/src/test/configs/dockerfile-autoconf-preprocessor/Dockerfile.in new file mode 100644 index 000000000..624fb764c --- /dev/null +++ b/src/test/configs/dockerfile-autoconf-preprocessor/Dockerfile.in @@ -0,0 +1,9 @@ +FROM @BASE_IMAGE@ + +ARG APP_PORT=@APP_PORT@ +EXPOSE @APP_PORT@ + +WORKDIR /workspace +COPY . /workspace + +CMD ["npm", "start"] \ No newline at end of file diff --git a/src/test/configs/dockerfile-autoconf-preprocessor/configure.ac b/src/test/configs/dockerfile-autoconf-preprocessor/configure.ac new file mode 100644 index 000000000..325410972 --- /dev/null +++ b/src/test/configs/dockerfile-autoconf-preprocessor/configure.ac @@ -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 \ No newline at end of file diff --git a/src/test/configs/dockerfile-cmake-preprocessor/.devcontainer.json b/src/test/configs/dockerfile-cmake-preprocessor/.devcontainer.json new file mode 100644 index 000000000..f9d34a644 --- /dev/null +++ b/src/test/configs/dockerfile-cmake-preprocessor/.devcontainer.json @@ -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" + } + } +} \ No newline at end of file diff --git a/src/test/configs/dockerfile-cmake-preprocessor/CMakeLists.txt b/src/test/configs/dockerfile-cmake-preprocessor/CMakeLists.txt new file mode 100644 index 000000000..0f2118862 --- /dev/null +++ b/src/test/configs/dockerfile-cmake-preprocessor/CMakeLists.txt @@ -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 +) \ No newline at end of file diff --git a/src/test/configs/dockerfile-cmake-preprocessor/Dockerfile.in b/src/test/configs/dockerfile-cmake-preprocessor/Dockerfile.in new file mode 100644 index 000000000..624fb764c --- /dev/null +++ b/src/test/configs/dockerfile-cmake-preprocessor/Dockerfile.in @@ -0,0 +1,9 @@ +FROM @BASE_IMAGE@ + +ARG APP_PORT=@APP_PORT@ +EXPOSE @APP_PORT@ + +WORKDIR /workspace +COPY . /workspace + +CMD ["npm", "start"] \ No newline at end of file diff --git a/src/test/configs/dockerfile-cmake2-preprocessor/.devcontainer.json b/src/test/configs/dockerfile-cmake2-preprocessor/.devcontainer.json new file mode 100644 index 000000000..0efa5ea3a --- /dev/null +++ b/src/test/configs/dockerfile-cmake2-preprocessor/.devcontainer.json @@ -0,0 +1,20 @@ +{ + "build": { + "dockerfile": "Dockerfile.in" + }, + "dockerfilePreprocessor": { + "tool": "cmake", + "args": [ + "-S", + ".", + "-B", + "build" + ], + "generatedDockerfile": "Dockerfile" + }, + "features": { + "ghcr.io/devcontainers/features/github-cli:1": { + "version": "latest" + } + } +} \ No newline at end of file diff --git a/src/test/configs/dockerfile-cmake2-preprocessor/CMakeLists.txt b/src/test/configs/dockerfile-cmake2-preprocessor/CMakeLists.txt new file mode 100644 index 000000000..392203510 --- /dev/null +++ b/src/test/configs/dockerfile-cmake2-preprocessor/CMakeLists.txt @@ -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_SOURCE_DIR}/Dockerfile + @ONLY +) \ No newline at end of file diff --git a/src/test/configs/dockerfile-cmake2-preprocessor/Dockerfile.in b/src/test/configs/dockerfile-cmake2-preprocessor/Dockerfile.in new file mode 100644 index 000000000..624fb764c --- /dev/null +++ b/src/test/configs/dockerfile-cmake2-preprocessor/Dockerfile.in @@ -0,0 +1,9 @@ +FROM @BASE_IMAGE@ + +ARG APP_PORT=@APP_PORT@ +EXPOSE @APP_PORT@ + +WORKDIR /workspace +COPY . /workspace + +CMD ["npm", "start"] \ No newline at end of file diff --git a/src/test/configs/dockerfile-cpp-preprocessor/.devcontainer.json b/src/test/configs/dockerfile-cpp-preprocessor/.devcontainer.json new file mode 100644 index 000000000..29f6449e8 --- /dev/null +++ b/src/test/configs/dockerfile-cpp-preprocessor/.devcontainer.json @@ -0,0 +1,17 @@ +{ + "build": { + "dockerfile": "Dockerfile.in" + }, + "dockerfilePreprocessor": { + "tool": "cpp", + "outputMode": "single-file", + "args": [ + "-P" + ] + }, + "features": { + "ghcr.io/devcontainers/features/github-cli:1": { + "version": "latest" + } + } +} \ No newline at end of file diff --git a/src/test/configs/dockerfile-cpp-preprocessor/Dockerfile.in b/src/test/configs/dockerfile-cpp-preprocessor/Dockerfile.in new file mode 100644 index 000000000..d7e4fcd28 --- /dev/null +++ b/src/test/configs/dockerfile-cpp-preprocessor/Dockerfile.in @@ -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" \ No newline at end of file diff --git a/src/test/configs/dockerfile-cpp-preprocessor/common.Dockerfile b/src/test/configs/dockerfile-cpp-preprocessor/common.Dockerfile new file mode 100644 index 000000000..97856b250 --- /dev/null +++ b/src/test/configs/dockerfile-cpp-preprocessor/common.Dockerfile @@ -0,0 +1,4 @@ +RUN apt-get update && apt-get install -y curl wget + +ENV APP_ENV=development +ENV APP_DEBUG=true \ No newline at end of file diff --git a/src/test/configs/dockerfile-cpp-preprocessor/test.sh b/src/test/configs/dockerfile-cpp-preprocessor/test.sh new file mode 100644 index 000000000..96f725cfb --- /dev/null +++ b/src/test/configs/dockerfile-cpp-preprocessor/test.sh @@ -0,0 +1,3 @@ +#!/bin/sh +echo "hello! cpp test" + diff --git a/src/test/configs/dockerfile-cpp-preprocessor/tools.Dockerfile b/src/test/configs/dockerfile-cpp-preprocessor/tools.Dockerfile new file mode 100644 index 000000000..6cbd0129d --- /dev/null +++ b/src/test/configs/dockerfile-cpp-preprocessor/tools.Dockerfile @@ -0,0 +1,2 @@ +RUN apt-get update && apt-get install -y vim +COPY ./test.sh /usr/local/bin/test.sh \ No newline at end of file diff --git a/src/test/configs/dockerfile-meson-preprocessor/.devcontainer.json b/src/test/configs/dockerfile-meson-preprocessor/.devcontainer.json new file mode 100644 index 000000000..d40bedd61 --- /dev/null +++ b/src/test/configs/dockerfile-meson-preprocessor/.devcontainer.json @@ -0,0 +1,18 @@ +{ + "build": { + "dockerfile": "Dockerfile.in" + }, + "dockerfilePreprocessor": { + "tool": "meson", + "args": [ + "setup", + "build" + ], + "generatedDockerfile": "build/Dockerfile" + }, + "features": { + "ghcr.io/devcontainers/features/github-cli:1": { + "version": "latest" + } + } +} diff --git a/src/test/configs/dockerfile-meson-preprocessor/Dockerfile.in b/src/test/configs/dockerfile-meson-preprocessor/Dockerfile.in new file mode 100644 index 000000000..624fb764c --- /dev/null +++ b/src/test/configs/dockerfile-meson-preprocessor/Dockerfile.in @@ -0,0 +1,9 @@ +FROM @BASE_IMAGE@ + +ARG APP_PORT=@APP_PORT@ +EXPOSE @APP_PORT@ + +WORKDIR /workspace +COPY . /workspace + +CMD ["npm", "start"] \ No newline at end of file diff --git a/src/test/configs/dockerfile-meson-preprocessor/meson.build b/src/test/configs/dockerfile-meson-preprocessor/meson.build new file mode 100644 index 000000000..9c0f68cd1 --- /dev/null +++ b/src/test/configs/dockerfile-meson-preprocessor/meson.build @@ -0,0 +1,11 @@ +project('generate-dockerfile', 'c') + +conf = configuration_data() +conf.set('BASE_IMAGE', 'node:22-bookworm') +conf.set('APP_PORT', '3000') + +configure_file( + input: 'Dockerfile.in', + output: 'Dockerfile', + configuration: conf +) \ No newline at end of file diff --git a/src/test/dockerfilePreprocessor.test.ts b/src/test/dockerfilePreprocessor.test.ts new file mode 100644 index 000000000..c4542a635 --- /dev/null +++ b/src/test/dockerfilePreprocessor.test.ts @@ -0,0 +1,306 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { ContainerError } from '../spec-common/errors'; +import { getCLIHost, loadNativeModule } from '../spec-common/commonUtils'; +import { preprocessDockerExtensionFile, getDockerfilePreprocessedPath } from '../spec-node/dockerfilePreprocessor'; +import { nullLog } from '../spec-utils/log'; +import { devContainerDown, devContainerUp, shellExec } from './testUtils'; + +const pkg = require('../../package.json'); + +describe('dockerfilePreprocessor', function () { + it('returns undefined for non-.in Dockerfile', () => { + assert.strictEqual(getDockerfilePreprocessedPath('/tmp/Dockerfile'), undefined); + }); + + it('returns preprocessed path for .in Dockerfile', () => { + assert.strictEqual(getDockerfilePreprocessedPath('/tmp/Dockerfile.in'), '/tmp/.devcontainer-preprocessed/Dockerfile'); + }); + + it('returns fixed CLI-owned output path for .in Dockerfile', () => { + assert.strictEqual(getDockerfilePreprocessedPath('/tmp/folder/Dockerfile.in'), '/tmp/folder/.devcontainer-preprocessed/Dockerfile'); + }); + + it('returns undefined for non-.in Dockerfile even when output is configured', () => { + assert.strictEqual(getDockerfilePreprocessedPath('/tmp/folder/Dockerfile'), undefined); + }); + + it('throws when dockerfilePreprocessor.tool is missing for .in Dockerfile', async () => { + const cliHost = await getCLIHost(process.cwd(), loadNativeModule, true); + await assert.rejects( + preprocessDockerExtensionFile( + { cliHost, output: nullLog }, + {}, + '/tmp/Dockerfile.in' + ), + (err: unknown) => { + assert.ok(err instanceof ContainerError); + assert.match((err as ContainerError).description, /dockerfilePreprocessor\.tool/i); + return true; + } + ); + }); + + it('runs tool and produces Dockerfile output at the CLI-owned path', async function () { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devcontainer-preprocess-')); + const inputPath = path.join(tmpDir, 'Dockerfile.in'); + const outputPath = path.join(tmpDir, '.devcontainer-preprocessed', 'Dockerfile'); + await fs.writeFile(inputPath, 'FROM alpine:3.20\n'); + + const cliHost = await getCLIHost(process.cwd(), loadNativeModule, true); + const result = await preprocessDockerExtensionFile( + { cliHost, output: nullLog }, + { dockerfilePreprocessor: { tool: 'cp', outputMode: 'single-file' } }, + inputPath + ); + + assert.strictEqual(result, outputPath); + const outputContent = (await fs.readFile(outputPath)).toString(); + assert.strictEqual(outputContent, 'FROM alpine:3.20\n'); + }); + + it('passes the CLI-owned output path to the tool', async function () { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devcontainer-preprocess-')); + const inputPath = path.join(tmpDir, 'Dockerfile.in'); + const outputPath = path.join(tmpDir, '.devcontainer-preprocessed', 'Dockerfile'); + const scriptPath = path.join(tmpDir, 'write-output.sh'); + await fs.writeFile(inputPath, 'FROM alpine:3.20\n'); + await fs.writeFile(scriptPath, '#!/bin/sh\nset -eu\nprintf "FROM busybox\\n" > "$2"\n'); + await fs.chmod(scriptPath, 0o755); + + const cliHost = await getCLIHost(process.cwd(), loadNativeModule, true); + const result = await preprocessDockerExtensionFile( + { cliHost, output: nullLog }, + { dockerfilePreprocessor: { tool: './write-output.sh', outputMode: 'build-tree' } }, + inputPath + ); + + assert.strictEqual(result, outputPath); + const outputContent = (await fs.readFile(outputPath)).toString(); + assert.strictEqual(outputContent, 'FROM busybox\n'); + }); + + it('throws when a preprocessor command fails', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devcontainer-preprocess-')); + const inputPath = path.join(tmpDir, 'Dockerfile.in'); + await fs.writeFile(inputPath, 'FROM alpine:3.20\n'); + + const cliHost = await getCLIHost(process.cwd(), loadNativeModule, true); + await assert.rejects(preprocessDockerExtensionFile( + { cliHost, output: nullLog }, + { dockerfilePreprocessor: { tool: 'this-command-should-not-exist-xyz123' } }, + inputPath + )); + }); + + it('throws when tool succeeds but output Dockerfile is not generated', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devcontainer-preprocess-')); + const inputPath = path.join(tmpDir, 'Dockerfile.in'); + await fs.writeFile(inputPath, 'FROM alpine:3.20\n'); + + const cliHost = await getCLIHost(process.cwd(), loadNativeModule, true); + await assert.rejects( + preprocessDockerExtensionFile( + { cliHost, output: nullLog }, + { dockerfilePreprocessor: { tool: 'true' } }, + inputPath + ), + (err: unknown) => { + assert.ok(err instanceof ContainerError); + assert.match((err as ContainerError).description, /did not produce/i); + return true; + } + ); + }); + + it('does not treat stale CLI output as generated output', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devcontainer-preprocess-')); + const inputPath = path.join(tmpDir, 'Dockerfile.in'); + const outputPath = path.join(tmpDir, '.devcontainer-preprocessed', 'Dockerfile'); + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(inputPath, 'FROM alpine:3.20\n'); + await fs.writeFile(outputPath, 'FROM stale:old\n'); + + const cliHost = await getCLIHost(process.cwd(), loadNativeModule, true); + await assert.rejects( + preprocessDockerExtensionFile( + { cliHost, output: nullLog }, + { dockerfilePreprocessor: { tool: 'true', outputMode: 'single-file' } }, + inputPath + ), + (err: unknown) => { + assert.ok(err instanceof ContainerError); + assert.match((err as ContainerError).description, /did not produce/i); + return true; + } + ); + }); + + it('throws when generatedDockerfile is configured but not produced', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devcontainer-preprocess-')); + const inputPath = path.join(tmpDir, 'Dockerfile.in'); + await fs.writeFile(inputPath, 'FROM alpine:3.20\n'); + + const cliHost = await getCLIHost(process.cwd(), loadNativeModule, true); + await assert.rejects( + preprocessDockerExtensionFile( + { cliHost, output: nullLog }, + { dockerfilePreprocessor: { tool: 'true', generatedDockerfile: 'build/Dockerfile' } }, + inputPath + ), + (err: unknown) => { + assert.ok(err instanceof ContainerError); + assert.match((err as ContainerError).description, /generatedDockerfile/i); + return true; + } + ); + }); +}); + +(process.platform === 'linux' ? describe : describe.skip)('dockerfilePreprocessor integration', function () { + this.timeout('240s'); + + const tmp = path.relative(process.cwd(), path.join(__dirname, 'tmp')); + const cli = `npx --prefix ${tmp} devcontainer`; + const cleanupByFixture = new Map([ + ['dockerfile-cpp-preprocessor', ['Dockerfile', '.devcontainer-lock.json', '.devcontainer-preprocessed']], + ['dockercomposefile-cpp-preprocessor', ['Dockerfile', '.devcontainer-lock.json', '.devcontainer-preprocessed']], + ['dockerfile-cmake-preprocessor', ['Dockerfile', 'build', '.devcontainer-lock.json', '.devcontainer-preprocessed']], + ['dockerfile-cmake2-preprocessor', ['Dockerfile', 'build', '.devcontainer-lock.json', '.devcontainer-preprocessed']], + ['dockerfile-autoconf-preprocessor', ['Dockerfile', 'configure', 'config.log', 'config.status', 'autom4te.cache', '.devcontainer-lock.json', '.devcontainer-preprocessed']], + ['dockerfile-meson-preprocessor', ['Dockerfile', 'build', '.devcontainer-lock.json', '.devcontainer-preprocessed']], + ]); + let cppAvailable = false; + let cmakeAvailable = false; + let mesonAvailable = false; + let autoconfAvailable = false; + + const cleanupGeneratedArtifacts = async (testFolder: string) => { + const fixture = path.basename(testFolder); + const generated = cleanupByFixture.get(fixture); + if (!generated?.length) { + return; + } + await Promise.all(generated.map(relative => fs.rm(path.join(testFolder, relative), { recursive: true, force: true }))); + }; + + before('Install', async () => { + await shellExec(`rm -rf ${tmp}/node_modules`); + await shellExec(`mkdir -p ${tmp}`); + await shellExec(`npm --prefix ${tmp} install devcontainers-cli-${pkg.version}.tgz`); + const commandCheck = await shellExec('command -v cpp', undefined, true, true); + cppAvailable = Boolean(commandCheck.stdout.trim()); + const cmakeCheck = await shellExec('command -v cmake', undefined, true, true); + cmakeAvailable = Boolean(cmakeCheck.stdout.trim()); + const mesonCheck = await shellExec('command -v meson', undefined, true, true); + mesonAvailable = Boolean(mesonCheck.stdout.trim()); + const autoconfCheck = await shellExec('command -v autoconf', undefined, true, true); + autoconfAvailable = Boolean(autoconfCheck.stdout.trim()); + }); + + it('should preprocess a Dockerfile.in during up cpp', async function () { + if (!cppAvailable) { + this.skip(); + } + const testFolder = `${__dirname}/configs/dockerfile-cpp-preprocessor`; + await cleanupGeneratedArtifacts(testFolder); + let containerId: string | undefined; + try { + containerId = (await devContainerUp(cli, testFolder)).containerId; + await shellExec(`${cli} exec --workspace-folder ${testFolder} sh -lc 'command -v nodejs && command -v python3 && command -v curl && command -v wget && command -v vim && test -f /usr/local/bin/test.sh'`); + } finally { + await devContainerDown({ containerId, doNotThrow: true }); + await cleanupGeneratedArtifacts(testFolder); + } + }); + + it('should preprocess a Dockerfile.in during up docker compose cpp', async function () { + if (!cppAvailable) { + this.skip(); + } + const testFolder = `${__dirname}/configs/dockercomposefile-cpp-preprocessor`; + await cleanupGeneratedArtifacts(testFolder); + let containerId: string | undefined; + try { + containerId = (await devContainerUp(cli, testFolder)).containerId; + await shellExec(`${cli} exec --workspace-folder ${testFolder} sh -lc 'command -v nodejs && command -v python3 && command -v curl && command -v wget && command -v vim && test -f /usr/local/bin/test.sh'`); + } finally { + await devContainerDown({ containerId, doNotThrow: true }); + await cleanupGeneratedArtifacts(testFolder); + } + }); + + it('should preprocess a Dockerfile.in during up cmake', async function (){ + if (!cmakeAvailable){ + this.skip(); + } + const testFolder = `${__dirname}/configs/dockerfile-cmake-preprocessor`; + await cleanupGeneratedArtifacts(testFolder); + let containerId: string | undefined; + try { + containerId = (await devContainerUp(cli, testFolder)).containerId; + // Check that the expected base image and port are set in the running container + await shellExec(`${cli} exec --workspace-folder ${testFolder} sh -lc 'command -v node && command -v npm'`); + } finally { + await devContainerDown({ containerId, doNotThrow: true }); + await cleanupGeneratedArtifacts(testFolder); + } + }); + + it('should preprocess a Dockerfile.in during up cmake when no output folder is specified', async function () { + if (!cmakeAvailable){ + this.skip(); + } + const testFolder = `${__dirname}/configs/dockerfile-cmake2-preprocessor`; + await cleanupGeneratedArtifacts(testFolder); + let containerId: string | undefined; + try { + containerId = (await devContainerUp(cli, testFolder)).containerId; + // Check that the expected base image and port are set in the running container + await shellExec(`${cli} exec --workspace-folder ${testFolder} sh -lc 'command -v node && command -v npm'`); + } finally { + await devContainerDown({ containerId, doNotThrow: true }); + await cleanupGeneratedArtifacts(testFolder); + } + }); + + it('should preprocess a Dockerfile.in during up autoconf', async function () { + if (!autoconfAvailable){ + this.skip(); + } + const testFolder = `${__dirname}/configs/dockerfile-autoconf-preprocessor`; + await cleanupGeneratedArtifacts(testFolder); + let containerId: string | undefined; + try { + containerId = (await devContainerUp(cli, testFolder)).containerId; + await shellExec(`${cli} exec --workspace-folder ${testFolder} sh -lc 'command -v node && command -v npm'`); + } finally { + await devContainerDown({ containerId, doNotThrow: true }); + await cleanupGeneratedArtifacts(testFolder); + } + }); + + it('should preprocess a Dockerfile.in during up meson', async function () { + if (!mesonAvailable){ + this.skip(); + } + const testFolder = `${__dirname}/configs/dockerfile-meson-preprocessor`; + await cleanupGeneratedArtifacts(testFolder); + + let containerId: string | undefined; + try { + containerId = (await devContainerUp(cli, testFolder)).containerId; + await shellExec(`${cli} exec --workspace-folder ${testFolder} sh -lc 'command -v node && command -v npm'`); + } finally { + await devContainerDown({ containerId, doNotThrow: true }); + await cleanupGeneratedArtifacts(testFolder); + } + }); +}); diff --git a/src/test/workspaceConfiguration.test.ts b/src/test/workspaceConfiguration.test.ts index 1e0b7e992..3065d89d9 100644 --- a/src/test/workspaceConfiguration.test.ts +++ b/src/test/workspaceConfiguration.test.ts @@ -35,7 +35,9 @@ function createMockCLIHost(options: { throw new Error(`File not found: ${filepath}`); }, writeFile: async () => { }, + copyFile: async () => { }, rename: async () => { }, + remove: async () => { }, mkdirp: async () => { }, readDir: async () => [], getUsername: async () => 'test',