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..913cb0a3d 100644 --- a/src/spec-configuration/configuration.ts +++ b/src/spec-configuration/configuration.ts @@ -38,6 +38,12 @@ export interface DevContainerFeature { options: boolean | string | Record; } +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. @@ -111,6 +117,7 @@ export type DevContainerFromDockerfileConfig = { overrideFeatureInstallOrder?: string[]; hostRequirements?: HostRequirements; customizations?: Record; + dockerfilePreprocessor?: DockerfilePreprocessor; } & ( { dockerFile: string; @@ -169,6 +176,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..ba766dc07 --- /dev/null +++ b/src/spec-node/dockerfilePreprocessor.ts @@ -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, + dockerfilePath: string +): Promise { + + 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; +} 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/Dockerfile.in b/src/test/configs/dockercomposefile-cpp-preprocessor/.devcontainer/Dockerfile.in new file mode 100644 index 000000000..52611ea7e --- /dev/null +++ b/src/test/configs/dockercomposefile-cpp-preprocessor/.devcontainer/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/.devcontainer/devcontainer.json b/src/test/configs/dockercomposefile-cpp-preprocessor/.devcontainer/devcontainer.json new file mode 100644 index 000000000..63e69dee3 --- /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", + "generatedDockerfilePath": "Dockerfile", + "args": [ + "-P", + "Dockerfile.in", + "Dockerfile" + ] + }, + "features": { + "ghcr.io/devcontainers/features/git": "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..6a98bc788 --- /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" + ], + "generatedDockerfilePath": "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..77cc34e12 --- /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" + ], + "generatedDockerfilePath": "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..3959d07f7 --- /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" + ], + "generatedDockerfilePath": "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..3a3fbe07a --- /dev/null +++ b/src/test/configs/dockerfile-cpp-preprocessor/.devcontainer.json @@ -0,0 +1,19 @@ +{ + "build": { + "dockerfile": "Dockerfile.in" + }, + "dockerfilePreprocessor": { + "tool": "cpp", + "args": [ + "-P", + "./Dockerfile.in", + "Dockerfile" + ], + "generatedDockerfilePath": "Dockerfile" + }, + "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..7dfea3747 --- /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" + ], + "generatedDockerfilePath": "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..53e3a83ca --- /dev/null +++ b/src/test/dockerfilePreprocessor.test.ts @@ -0,0 +1,354 @@ +/*--------------------------------------------------------------------------------------------- + * 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 } 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('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 returns configured generated Dockerfile path', async function () { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devcontainer-preprocess-')); + const inputPath = path.join(tmpDir, 'Dockerfile.in'); + const generatedPath = path.join(tmpDir, 'build', '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: 'sh', + args: ['-c', 'mkdir -p build && cp Dockerfile.in build/Dockerfile'], + generatedDockerfilePath: 'build/Dockerfile', + }, + }, + inputPath + ); + + assert.strictEqual(result, generatedPath); + assert.strictEqual((await fs.readFile(generatedPath)).toString(), 'FROM alpine:3.20\n'); + }); + + it('passes paths through environment variables without positional args', async function () { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devcontainer-preprocess-')); + const inputPath = path.join(tmpDir, 'Dockerfile.in'); + const generatedPath = path.join(tmpDir, 'build', 'Dockerfile'); + const scriptPath = path.join(tmpDir, 'write-generated.sh'); + await fs.writeFile(inputPath, 'FROM alpine:3.20\n'); + await fs.writeFile(scriptPath, '#!/bin/sh\nset -eu\ntest "$#" -eq 0\nmkdir -p "$(dirname "$DEVCONTAINER_DOCKERFILE_PREPROCESSOR_GENERATED_DOCKERFILE")"\nprintf "FROM busybox\\n" > "$DEVCONTAINER_DOCKERFILE_PREPROCESSOR_GENERATED_DOCKERFILE"\n'); + await fs.chmod(scriptPath, 0o755); + + const cliHost = await getCLIHost(process.cwd(), loadNativeModule, true); + const result = await preprocessDockerExtensionFile( + { cliHost, output: nullLog }, + { dockerfilePreprocessor: { tool: './write-generated.sh', generatedDockerfilePath: 'build/Dockerfile' } }, + inputPath + ); + + assert.strictEqual(result, generatedPath); + assert.strictEqual((await fs.readFile(generatedPath)).toString(), 'FROM busybox\n'); + }); + + it('requires generatedDockerfile for .in Dockerfile preprocessing', 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, /generatedDockerfilePath/i); + return true; + } + ); + }); + + it('requires configured generatedDockerfile path to be 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', generatedDockerfilePath: 'build/Dockerfile' } }, + inputPath + ), + (err: unknown) => { + assert.ok(err instanceof ContainerError); + assert.match((err as ContainerError).description, /generatedDockerfilePath/i); + return true; + } + ); + }); + + it('passes paths via environment variables without positional args', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devcontainer-preprocess-')); + const inputPath = path.join(tmpDir, 'Dockerfile.in'); + const generatedPath = path.join(tmpDir, 'build', 'Dockerfile'); + const scriptPath = path.join(tmpDir, 'write-generated.sh'); + await fs.writeFile(inputPath, 'FROM alpine:3.20\n'); + await fs.writeFile(scriptPath, '#!/bin/sh\nset -eu\ntest "$#" -eq 0\nmkdir -p "$(dirname "$DEVCONTAINER_DOCKERFILE_PREPROCESSOR_GENERATED_DOCKERFILE")"\nprintf "FROM debian:bookworm\\n" > "$DEVCONTAINER_DOCKERFILE_PREPROCESSOR_GENERATED_DOCKERFILE"\n'); + await fs.chmod(scriptPath, 0o755); + + const cliHost = await getCLIHost(process.cwd(), loadNativeModule, true); + const result = await preprocessDockerExtensionFile( + { cliHost, output: nullLog }, + { dockerfilePreprocessor: { tool: './write-generated.sh', generatedDockerfilePath: 'build/Dockerfile' } }, + inputPath + ); + + assert.strictEqual(result, generatedPath); + assert.strictEqual((await fs.readFile(generatedPath)).toString(), 'FROM debian:bookworm\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', generatedDockerfilePath: 'build/Dockerfile' } }, + inputPath + )); + }); + + it('throws when tool succeeds but generated Dockerfile is 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', generatedDockerfilePath: 'build/Dockerfile' } }, + 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 configured 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', generatedDockerfilePath: 'build/Dockerfile' } }, + inputPath + ), + (err: unknown) => { + assert.ok(err instanceof ContainerError); + assert.match((err as ContainerError).description, /did not produce/i); + return true; + } + ); + }); + + it('throws when generatedDockerfilePath 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', generatedDockerfilePath: 'build/Dockerfile' } }, + inputPath + ), + (err: unknown) => { + assert.ok(err instanceof ContainerError); + assert.match((err as ContainerError).description, /generatedDockerfilePath/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',