import * as plugins from './tsdocker.plugins.js'; import * as paths from './tsdocker.paths.js'; import { logger } from './tsdocker.logging.js'; import { DockerRegistry } from './classes.dockerregistry.js'; import type { IDockerfileOptions, ITsDockerConfig } from './interfaces/index.js'; import type { TsDockerManager } from './classes.tsdockermanager.js'; const smartshellInstance = new plugins.smartshell.Smartshell({ executor: 'bash', }); /** * Class Dockerfile represents a Dockerfile on disk */ export class Dockerfile { // STATIC METHODS /** * Creates instances of class Dockerfile for all Dockerfiles in cwd */ public static async readDockerfiles(managerRef: TsDockerManager): Promise { const entries = await plugins.smartfs.directory(paths.cwd).filter('Dockerfile*').list(); const fileTree = entries .filter(entry => entry.isFile) .map(entry => plugins.path.join(paths.cwd, entry.name)); const readDockerfilesArray: Dockerfile[] = []; logger.log('info', `found ${fileTree.length} Dockerfiles:`); console.log(fileTree); for (const dockerfilePath of fileTree) { const myDockerfile = new Dockerfile(managerRef, { filePath: dockerfilePath, read: true, }); readDockerfilesArray.push(myDockerfile); } return readDockerfilesArray; } /** * Sorts Dockerfiles into a build order based on dependencies (topological sort) */ public static async sortDockerfiles(dockerfiles: Dockerfile[]): Promise { logger.log('info', 'Sorting Dockerfiles based on dependencies...'); // Map from cleanTag to Dockerfile instance for quick lookup const tagToDockerfile = new Map(); dockerfiles.forEach((dockerfile) => { tagToDockerfile.set(dockerfile.cleanTag, dockerfile); }); // Build the dependency graph const graph = new Map(); dockerfiles.forEach((dockerfile) => { const dependencies: Dockerfile[] = []; const baseImage = dockerfile.baseImage; // Check if the baseImage is among the local Dockerfiles if (tagToDockerfile.has(baseImage)) { const baseDockerfile = tagToDockerfile.get(baseImage)!; dependencies.push(baseDockerfile); dockerfile.localBaseImageDependent = true; dockerfile.localBaseDockerfile = baseDockerfile; } graph.set(dockerfile, dependencies); }); // Perform topological sort const sortedDockerfiles: Dockerfile[] = []; const visited = new Set(); const tempMarked = new Set(); const visit = (dockerfile: Dockerfile) => { if (tempMarked.has(dockerfile)) { throw new Error(`Circular dependency detected involving ${dockerfile.cleanTag}`); } if (!visited.has(dockerfile)) { tempMarked.add(dockerfile); const dependencies = graph.get(dockerfile) || []; dependencies.forEach((dep) => visit(dep)); tempMarked.delete(dockerfile); visited.add(dockerfile); sortedDockerfiles.push(dockerfile); } }; try { dockerfiles.forEach((dockerfile) => { if (!visited.has(dockerfile)) { visit(dockerfile); } }); } catch (error) { logger.log('error', (error as Error).message); throw error; } // Log the sorted order sortedDockerfiles.forEach((dockerfile, index) => { logger.log( 'info', `Build order ${index + 1}: ${dockerfile.cleanTag} with base image ${dockerfile.baseImage}` ); }); return sortedDockerfiles; } /** * Maps local Dockerfiles dependencies to the corresponding Dockerfile class instances */ public static async mapDockerfiles(sortedDockerfileArray: Dockerfile[]): Promise { sortedDockerfileArray.forEach((dockerfileArg) => { if (dockerfileArg.localBaseImageDependent) { sortedDockerfileArray.forEach((dockfile2: Dockerfile) => { if (dockfile2.cleanTag === dockerfileArg.baseImage) { dockerfileArg.localBaseDockerfile = dockfile2; } }); } }); return sortedDockerfileArray; } /** * Builds the corresponding real docker image for each Dockerfile class instance */ public static async buildDockerfiles(sortedArrayArg: Dockerfile[]): Promise { for (const dockerfileArg of sortedArrayArg) { await dockerfileArg.build(); } return sortedArrayArg; } /** * Tests all Dockerfiles by calling Dockerfile.test() */ public static async testDockerfiles(sortedArrayArg: Dockerfile[]): Promise { for (const dockerfileArg of sortedArrayArg) { await dockerfileArg.test(); } return sortedArrayArg; } /** * Returns a version for a docker file * Dockerfile_latest -> latest * Dockerfile_v1.0.0 -> v1.0.0 * Dockerfile -> latest */ public static dockerFileVersion( dockerfileInstanceArg: Dockerfile, dockerfileNameArg: string ): string { let versionString: string; const versionRegex = /Dockerfile_(.+)$/; const regexResultArray = versionRegex.exec(dockerfileNameArg); if (regexResultArray && regexResultArray.length === 2) { versionString = regexResultArray[1]; } else { versionString = 'latest'; } // Replace ##version## placeholder with actual package version if available if (dockerfileInstanceArg.managerRef?.projectInfo?.npm?.version) { versionString = versionString.replace( '##version##', dockerfileInstanceArg.managerRef.projectInfo.npm.version ); } return versionString; } /** * Extracts the base image from a Dockerfile content * Handles ARG substitution for variable base images */ public static dockerBaseImage(dockerfileContentArg: string): string { const lines = dockerfileContentArg.split(/\r?\n/); const args: { [key: string]: string } = {}; for (const line of lines) { const trimmedLine = line.trim(); // Skip empty lines and comments if (trimmedLine === '' || trimmedLine.startsWith('#')) { continue; } // Match ARG instructions const argMatch = trimmedLine.match(/^ARG\s+([^\s=]+)(?:=(.*))?$/i); if (argMatch) { const argName = argMatch[1]; const argValue = argMatch[2] !== undefined ? argMatch[2] : process.env[argName] || ''; args[argName] = argValue; continue; } // Match FROM instructions const fromMatch = trimmedLine.match(/^FROM\s+(.+?)(?:\s+AS\s+[^\s]+)?$/i); if (fromMatch) { let baseImage = fromMatch[1].trim(); // Substitute variables in the base image name baseImage = Dockerfile.substituteVariables(baseImage, args); return baseImage; } } throw new Error('No FROM instruction found in Dockerfile'); } /** * Substitutes variables in a string, supporting default values like ${VAR:-default} */ private static substituteVariables(str: string, vars: { [key: string]: string }): string { return str.replace(/\${([^}:]+)(:-([^}]+))?}/g, (_, varName, __, defaultValue) => { if (vars[varName] !== undefined) { return vars[varName]; } else if (defaultValue !== undefined) { return defaultValue; } else { return ''; } }); } /** * Returns the docker tag string for a given registry and repo */ public static getDockerTagString( managerRef: TsDockerManager, registryArg: string, repoArg: string, versionArg: string, suffixArg?: string ): string { // Determine whether the repo should be mapped according to the registry const config = managerRef.config; const mappedRepo = config.registryRepoMap?.[registryArg]; const repo = mappedRepo || repoArg; // Determine whether the version contains a suffix let version = versionArg; if (suffixArg) { version = versionArg + '_' + suffixArg; } const tagString = `${registryArg}/${repo}:${version}`; return tagString; } /** * Gets build args from environment variable mapping */ public static async getDockerBuildArgs(managerRef: TsDockerManager): Promise { logger.log('info', 'checking for env vars to be supplied to the docker build'); let buildArgsString: string = ''; const config = managerRef.config; if (config.buildArgEnvMap) { for (const dockerArgKey of Object.keys(config.buildArgEnvMap)) { const dockerArgOuterEnvVar = config.buildArgEnvMap[dockerArgKey]; logger.log( 'note', `docker ARG "${dockerArgKey}" maps to outer env var "${dockerArgOuterEnvVar}"` ); const targetValue = process.env[dockerArgOuterEnvVar]; if (targetValue) { buildArgsString = `${buildArgsString} --build-arg ${dockerArgKey}="${targetValue}"`; } } } return buildArgsString; } // INSTANCE PROPERTIES public managerRef: TsDockerManager; public filePath!: string; public repo: string; public version: string; public cleanTag: string; public buildTag: string; public pushTag!: string; public containerName: string; public content!: string; public baseImage: string; public localBaseImageDependent: boolean; public localBaseDockerfile!: Dockerfile; constructor(managerRefArg: TsDockerManager, options: IDockerfileOptions) { this.managerRef = managerRefArg; this.filePath = options.filePath!; // Build repo name from project info or directory name const projectInfo = this.managerRef.projectInfo; if (projectInfo?.npm?.name) { // Use package name, removing scope if present const packageName = projectInfo.npm.name.replace(/^@[^/]+\//, ''); this.repo = packageName; } else { // Fallback to directory name this.repo = plugins.path.basename(paths.cwd); } this.version = Dockerfile.dockerFileVersion(this, plugins.path.parse(this.filePath).base); this.cleanTag = this.repo + ':' + this.version; this.buildTag = this.cleanTag; this.containerName = 'dockerfile-' + this.version; if (options.filePath && options.read) { const fs = require('fs'); this.content = fs.readFileSync(plugins.path.resolve(options.filePath), 'utf-8'); } else if (options.fileContents) { this.content = options.fileContents; } this.baseImage = Dockerfile.dockerBaseImage(this.content); this.localBaseImageDependent = false; } /** * Builds the Dockerfile */ public async build(): Promise { logger.log('info', 'now building Dockerfile for ' + this.cleanTag); const buildArgsString = await Dockerfile.getDockerBuildArgs(this.managerRef); const config = this.managerRef.config; let buildCommand: string; // Check if multi-platform build is needed if (config.platforms && config.platforms.length > 1) { // Multi-platform build using buildx const platformString = config.platforms.join(','); buildCommand = `docker buildx build --platform ${platformString} -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`; if (config.push) { buildCommand += ' --push'; } else { buildCommand += ' --load'; } } else { // Standard build const versionLabel = this.managerRef.projectInfo?.npm?.version || 'unknown'; buildCommand = `docker build --label="version=${versionLabel}" -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`; } const result = await smartshellInstance.exec(buildCommand); if (result.exitCode !== 0) { logger.log('error', `Build failed for ${this.cleanTag}`); console.log(result.stdout); throw new Error(`Build failed for ${this.cleanTag}`); } logger.log('ok', `Built ${this.cleanTag}`); } /** * Pushes the Dockerfile to a registry */ public async push(dockerRegistryArg: DockerRegistry, versionSuffix?: string): Promise { this.pushTag = Dockerfile.getDockerTagString( this.managerRef, dockerRegistryArg.registryUrl, this.repo, this.version, versionSuffix ); await smartshellInstance.exec(`docker tag ${this.buildTag} ${this.pushTag}`); const pushResult = await smartshellInstance.exec(`docker push ${this.pushTag}`); if (pushResult.exitCode !== 0) { logger.log('error', `Push failed for ${this.pushTag}`); throw new Error(`Push failed for ${this.pushTag}`); } // Get image digest const inspectResult = await smartshellInstance.exec( `docker inspect --format="{{index .RepoDigests 0}}" ${this.pushTag}` ); if (inspectResult.exitCode === 0 && inspectResult.stdout.includes('@')) { const imageDigest = inspectResult.stdout.split('@')[1]?.trim(); console.log(`The image ${this.pushTag} has digest ${imageDigest}`); } logger.log('ok', `Pushed ${this.pushTag}`); } /** * Pulls the Dockerfile from a registry */ public async pull(registryArg: DockerRegistry, versionSuffixArg?: string): Promise { const pullTag = Dockerfile.getDockerTagString( this.managerRef, registryArg.registryUrl, this.repo, this.version, versionSuffixArg ); await smartshellInstance.exec(`docker pull ${pullTag}`); await smartshellInstance.exec(`docker tag ${pullTag} ${this.buildTag}`); logger.log('ok', `Pulled and tagged ${pullTag} as ${this.buildTag}`); } /** * Tests the Dockerfile by running a test script if it exists */ public async test(): Promise { const testDir = this.managerRef.config.testDir || plugins.path.join(paths.cwd, 'test'); const testFile = plugins.path.join(testDir, 'test_' + this.version + '.sh'); const fs = require('fs'); const testFileExists = fs.existsSync(testFile); if (testFileExists) { logger.log('info', `Running tests for ${this.cleanTag}`); // Run tests in container await smartshellInstance.exec( `docker run --name tsdocker_test_container --entrypoint="bash" ${this.buildTag} -c "mkdir /tsdocker_test"` ); await smartshellInstance.exec(`docker cp ${testFile} tsdocker_test_container:/tsdocker_test/test.sh`); await smartshellInstance.exec(`docker commit tsdocker_test_container tsdocker_test_image`); const testResult = await smartshellInstance.exec( `docker run --entrypoint="bash" tsdocker_test_image -x /tsdocker_test/test.sh` ); // Cleanup await smartshellInstance.exec(`docker rm tsdocker_test_container`); await smartshellInstance.exec(`docker rmi --force tsdocker_test_image`); if (testResult.exitCode !== 0) { throw new Error(`Tests failed for ${this.cleanTag}`); } logger.log('ok', `Tests passed for ${this.cleanTag}`); } else { logger.log('warn', `Skipping tests for ${this.cleanTag} because no test file was found at ${testFile}`); } } /** * Gets the ID of a built Docker image */ public async getId(): Promise { const result = await smartshellInstance.exec( 'docker inspect --type=image --format="{{.Id}}" ' + this.buildTag ); return result.stdout.trim(); } }