463 lines
15 KiB
TypeScript
463 lines
15 KiB
TypeScript
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<Dockerfile[]> {
|
|
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<Dockerfile[]> {
|
|
logger.log('info', 'Sorting Dockerfiles based on dependencies...');
|
|
|
|
// Map from cleanTag to Dockerfile instance for quick lookup
|
|
const tagToDockerfile = new Map<string, Dockerfile>();
|
|
dockerfiles.forEach((dockerfile) => {
|
|
tagToDockerfile.set(dockerfile.cleanTag, dockerfile);
|
|
});
|
|
|
|
// Build the dependency graph
|
|
const graph = new Map<Dockerfile, Dockerfile[]>();
|
|
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<Dockerfile>();
|
|
const tempMarked = new Set<Dockerfile>();
|
|
|
|
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<Dockerfile[]> {
|
|
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<Dockerfile[]> {
|
|
for (const dockerfileArg of sortedArrayArg) {
|
|
await dockerfileArg.build();
|
|
}
|
|
return sortedArrayArg;
|
|
}
|
|
|
|
/**
|
|
* Tests all Dockerfiles by calling Dockerfile.test()
|
|
*/
|
|
public static async testDockerfiles(sortedArrayArg: Dockerfile[]): Promise<Dockerfile[]> {
|
|
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<string> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string> {
|
|
const result = await smartshellInstance.exec(
|
|
'docker inspect --type=image --format="{{.Id}}" ' + this.buildTag
|
|
);
|
|
return result.stdout.trim();
|
|
}
|
|
}
|