@nasriya/orchestriq
Version:
A package to generate Docker files
957 lines (956 loc) • 48.4 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const helpers_1 = __importDefault(require("../../../../../../utils/helpers"));
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const os_1 = __importDefault(require("os"));
class DockerfileBuilder {
#_lines = [];
#_constants = Object.freeze({
npm: {
installFlags: () => {
return Object.freeze({
omitOptional: Object.seal({ flag: `--omit=optional`, value: false }),
omitDev: Object.seal({ flag: `--omit=dev`, value: true }),
noAudit: Object.seal({ flag: '--no-audit', value: true }),
noFund: Object.seal({ flag: '--no-fund', value: true }),
noUpdateNotifier: Object.seal({ flag: '--no-update-notifier', value: true }),
unsafePerm: Object.seal({ flag: '--unsafe-perm', value: false }),
noProgress: Object.seal({ flag: '--no-progress', value: false }),
force: Object.seal({ flag: '--force', value: false }),
legacyPeerDeps: Object.seal({ flag: '--legacy-peer-deps', value: false }),
preferOffline: Object.seal({ flag: '--prefer-offline', value: false }),
noSave: Object.seal({ flag: '--no-save', value: false }),
ignoreScripts: Object.seal({ flag: '--ignore-scripts', value: false }),
});
}
}
});
#_cache = {
cmd: { used: false },
entrypoint: { used: false },
openssh: { installed: false },
git: { installed: false }
};
constructor() {
this.multiLineComment([
'Comments are provided throughout this file to help you get started.',
'Want to help us make this template better? Share your feedback here: https://forms.gle/ybq9Krt8jtBL3iCk7'
]);
}
#_helpers = {
/**
* Checks if the given value is a valid path for the current platform.
* @example
* const builder = new DockerfileBuilder();
* builder._helpers.isPath('/usr/bin'); // true
* builder._helpers.isPath('C:\\Windows'); // true
* builder._helpers.isPath('foo/bar'); // false
* @param {string} value - The value to check.
* @returns {boolean} Whether the value is a valid path.
*/
isPath: (value) => {
const tests = { win32: /^[a-zA-Z]:\\(?:[^\0\/\\:*?"<>|\r\n]+\\)*$/, unix: /^(\/[^<>:"\/\\|?*]*)+$/ };
const platform = os_1.default.platform() === 'win32' ? 'win32' : 'unix';
return tests[platform].test(value);
},
/**
* Converts a Windows path to a Docker-compatible path if on Windows.
* Otherwise, returns the path unchanged.
* @param {string} value - The path to convert.
* @returns {string} The converted path.
*/
toUnixPath: (value) => {
// Convert backslashes to forward slashes
value = value.replace(/\\/g, '/');
// If it's already a Unix-style path or relative path, leave it unchanged
if (value.startsWith('/') || value.startsWith('./') || value.startsWith('../') || value.startsWith('~')) {
return value;
}
else {
value = `./${value}`;
}
// Convert Windows path to Docker-compatible path if on Windows
if (os_1.default.platform() === 'win32') {
// If it's a Windows-style absolute path (e.g., C:\)
if (value[1] === ':') {
value = '/' + value[0].toLowerCase() + value.slice(2); // Convert C:\ to /c/
}
}
return value;
}
};
#_commands = Object.freeze({
update: {
/**
* Updates the version of NPM in the Dockerfile stage.
* Accepts either a string or a number as the version. The version can starts with `v`.
* If no version is specified, defaults to 'latest'.
*
* @param version - A string or number representing the NPM version to update to, defaults to 'latest'.
* @throws {Error} If the provided version is neither a string nor a number.
*/
npm: (version = 'latest') => {
if (typeof version === 'string') {
if (version.startsWith('v')) {
version = version.slice(1);
}
}
else if (typeof version === 'number') {
version = String(version);
}
else {
throw new Error('NPM version must be a string or a number.');
}
this.comment(`Updating NPM version to ${version === 'latest' ? 'latest version' : version}`);
return this.run([
`npm install npm@${version} --unsafe-perm --no-progress`,
'rm -rf /usr/local/lib/node_modules/npm',
'mv node_modules/npm /usr/local/lib/node_modules/npm',
'rm -rf node_modules'
], { batch: true });
},
},
install: {
/**
* Configures the NPM install command with the given flags.
*
* If no options are provided, the install command will be generated
* with no flags.
*
* @param options - An object containing keys and values to configure
* the NPM install command. The keys are the flag names
* and the values are boolean values indicating whether
* to use the flag or not.
* @throws {TypeError} If any of the provided flags have invalid values.
* @throws {Error} If any of the provided flags are invalid.
* @since v1.0.5
*/
dependencies: (options) => {
const flags = this.#_constants.npm.installFlags();
const cache = Object.seal({ cmd: '', runs: [] });
if (options && helpers_1.default.isValidObject(options)) {
if (options.flags && helpers_1.default.hasOwnProperty(options, 'flags')) {
const flagOptions = options.flags;
for (const key in flags) {
if (key in flagOptions && helpers_1.default.hasOwnProperty(flagOptions, key)) {
const value = flagOptions[key];
if (typeof value !== 'boolean') {
throw new TypeError(`A NPM command was configured with a flag (${key}) with an incorrect value. Expected a boolean value but instead got ${typeof value}`);
}
flags[key].value = value;
}
}
}
if (options.postInstallRun && helpers_1.default.hasOwnProperty(options, 'postInstallRun')) {
if (!Array.isArray(options.postInstallRun)) {
options.postInstallRun = [options.postInstallRun];
}
for (const cmd of options.postInstallRun) {
if (typeof cmd !== 'string') {
throw new TypeError('The post-install npm commands must be strings.');
}
if (cmd.trim().length === 0) {
throw new Error('The post-install npm commands must not be empty.');
}
cache.runs.push(cmd);
}
}
}
this.comment('Installing dependencies using NPM...');
const entries = Object.entries(flags).filter(flag => flag[1].value === true).map(flag => flag[1].flag).join(' ').trim();
cache.cmd = `npm install${entries.length > 0 ? ` ${entries}` : ''}`;
return this.run([cache.cmd, ...cache.runs], { batch: true });
},
/**
* Installs the specified Linux apps using the package manager of the Linux distribution.
*
* @param names - The names of the Linux apps to install. If an array is provided, the names will be joined with a space.
* @param options - An object containing options to configure the installation. The object should contain the following properties:
* - noCache (boolean): If true, the package manager will not cache the installed apps. Defaults to false.
* - withComment (boolean): If true, a comment will be added above the installation command. Defaults to true.
* @throws {TypeError} If any of the provided app names are not strings.
* @throws {TypeError} If any of the options have invalid types.
* @throws {Error} If the OS is not supported.
* @returns {DockerfileBuilder}
* @since v1.0.5
*/
apps: (names, options) => {
const data = { names: '', noCache: false, withComment: true };
if (!Array.isArray(names)) {
names = [names];
}
for (const name of names) {
if (typeof name !== 'string') {
throw new TypeError(`Unable to install linux app ${name}. Expected a string but instead got ${typeof name}.`);
}
}
if (options && helpers_1.default.isObject(options)) {
if (helpers_1.default.hasOwnProperty(options, 'noCache')) {
if (typeof options.noCache !== 'boolean') {
throw new TypeError(`Unable to install linux app ${name}. Expected a boolean value but instead got ${typeof options.noCache}.`);
}
data.noCache = options.noCache;
}
if (helpers_1.default.hasOwnProperty(options, 'withComment')) {
if (typeof options.withComment !== 'boolean') {
throw new TypeError(`Unable to install linux app ${name}. Expected a boolean value but instead got ${typeof options.withComment}.`);
}
data.withComment = options.withComment;
}
}
const _names = names.map(name => name.trim()).filter(Boolean);
if (_names.length > 0) {
if (data.withComment) {
this.comment(`Installing linux apps: ${_names.join(', ')}`);
}
const content = `${_names.join(' ')}`;
const tab = ' '.repeat(4);
const lines = [
`${tab}if [ -f /etc/alpine-release ]; then \\`,
`${tab.repeat(2)}apk add ${data.noCache ? `--no-cache ` : ''}${content}; \\`,
`${tab}elif [ -f /etc/debian_version ]; then \\`,
`${tab.repeat(2)}apt update && apt install -y ${content}; \\`,
`${tab}else \\`,
`${tab.repeat(2)}echo "Unsupported OS"; exit 1; \\`,
`${tab}fi`
];
this.run(lines.join('\n').trim());
}
return this;
},
/**
* Installs Git into the Docker image. This is useful for scenarios
* where you need to clone a repository from a Git server and build
* the code.
*
* @returns The Dockerfile builder instance.
* @since v1.0.5
*/
git: () => {
if (!this.#_cache.git.installed) {
this.#_cache.git.installed = true;
this.commands.install.apps('git', { withComment: false });
}
return this;
},
/**
* Installs OpenSSH into the Docker image. This is useful for scenarios
* where you need to SSH into the Docker container.
*
* @returns The Dockerfile builder instance.
* @since v1.0.5
*/
openSSH: () => {
if (!this.#_cache.openssh.installed) {
this.#_cache.openssh.installed = true;
this.commands.install.apps('openssh', { withComment: false });
}
return this;
},
/**
* Installs the specified NPM packages within the Docker image.
*
* This method allows the installation of one or more NPM packages, with options
* to install them globally and to customize the installation with specific flags.
*
* @param names - A string or array of strings representing the package names
* to install. Packages can include specific versions by appending
* `@version` to the name. If no version is specified, it defaults
* to 'latest'.
* @param options - An object with optional properties:
* - `global` (boolean): If true, installs the packages globally.
* - `flags` (object): A set of boolean flags to modify the
* installation process (e.g., `omitDev`, `noAudit`).
* @returns {DockerfileBuilder} The Dockerfile builder instance for chaining.
* @throws {TypeError} If any package name is not a string or is empty, or if
* any provided flag has a non-boolean value.
* @throws {RangeError} If a package name is invalid (e.g., missing when scoped).
* @since v1.0.6
*/
packages: (names, options) => {
const cache = {
global: false,
packages: [],
flags: this.#_constants.npm.installFlags(),
buildCommand() {
const isGlobal = this.global ? '-g ' : '';
const pkgs = this.packages.map(pkg => `${pkg.name}${pkg.version !== 'latest' ? `@${pkg.version}` : ''}`).join(' ');
const flags = Object.entries(cache.flags).filter(flag => flag[1].value === true).map(flag => flag[1].flag).join(' ').trim();
return `npm install ${isGlobal}${flags} ${pkgs}`.trim();
}
};
if (!Array.isArray(names)) {
names = [names];
}
const invalid = names.some(name => typeof name !== 'string' || name.trim().length === 0);
if (invalid) {
throw new TypeError(`Unable to install packages. Expected string values.`);
}
if (options && helpers_1.default.isObject(options)) {
if (helpers_1.default.hasOwnProperty(options, 'global')) {
if (typeof options.global !== 'boolean') {
throw new TypeError(`Unable to install packages. Expected boolean value for the.`);
}
cache.global = true;
}
if (helpers_1.default.hasOwnProperty(options, 'flags') && options.flags) {
const flagOptions = options.flags;
for (const key in cache.flags) {
if (key in flagOptions && helpers_1.default.hasOwnProperty(flagOptions, key)) {
const value = flagOptions[key];
if (typeof value !== 'boolean') {
throw new TypeError(`A NPM command was configured with a flag (${key}) with an incorrect value. Expected a boolean value but instead got ${typeof value}`);
}
cache.flags[key].value = value;
}
}
}
}
for (const pkgName of names) {
const isScoped = pkgName.startsWith('@');
const [name, version] = isScoped ? pkgName.slice(1).split('@') : pkgName.split('@');
if (name.length === 0) {
throw new RangeError(`Unable to install (${pkgName}). Invalid package name`);
}
cache.packages.push({
name: `${isScoped ? '@' : ''}${name}`,
version: version ? (version.startsWith('v') ? version.slice(1) : version) : 'latest'
});
}
return this.comment(`Installing ${cache.global ? `global ` : ''}packages: ${names.join(', ')}`).run(cache.buildCommand());
}
},
remove: {
/**
* Removes an SSH key from the Docker image. This is useful for scenarios
* where you have previously copied an SSH key using the `copySSHKey` method
* and no longer need it.
*
* @param {string} name - The name of the SSH key to remove.
* @returns The Dockerfile builder instance.
* @throws {TypeError} If the `name` parameter is not a string.
* @since v1.0.5
*/
sshKey: (name) => {
if (typeof name !== 'string') {
throw new TypeError(`Unable to remove SSH key ${name}. Expected a string but instead got ${typeof name}.`);
}
return this.comment(`Removing SSH key ${name}...`).run(`rm -rf /root/.ssh/${name}`);
}
},
/**
* Asynchronously copies an SSH key to a specified location in the Docker build context.
*
* @param {SSHKeyCopyConfigs} configs - Configuration object for copying the SSH key.
* @param {string} configs.context - Directory path within the build context.
* @param {string} configs.from - Source path or SSH key name within the user's home directory.
* @param {string} configs.to - Destination path within the Docker image.
* @returns {Promise<DockerfileBuilder>} A promise that resolves to the DockerfileBuilder instance
* after adding the SSH key copy instructions to the Dockerfile.
* @throws {SyntaxError} If the `configs`, `context`, or `from` properties are missing.
* @throws {TypeError} If the `context`, `from`, or `to` properties are not strings.
* @throws {Error} If the specified files or directories do not exist.
* @since v1.0.5
*/
copySSHKey: async (configs) => {
try {
if (!(configs && helpers_1.default.isObject(configs))) {
throw new SyntaxError('The `configs` parameter must be an object.');
}
const _configs = {
context: '',
from: '',
installClient: false
};
if (configs.context && helpers_1.default.hasOwnProperty(configs, 'context')) {
if (typeof configs.context !== 'string') {
throw new TypeError(`The "context property must be a string, instead got ${typeof configs.context}.`);
}
if (!fs_1.default.existsSync(configs.context)) {
throw new Error(`The file ${configs.context} does not exist`);
}
const stats = fs_1.default.statSync(configs.context);
if (stats.isFile()) {
throw new Error(`The file ${configs.context} is not a directory.`);
}
_configs.context = configs.context;
}
else {
throw new SyntaxError('The `context` property is required.');
}
if (configs.from && helpers_1.default.hasOwnProperty(configs, 'from')) {
if (typeof configs.from !== 'string') {
throw new TypeError(`The "from" property must be a string, instead got ${typeof configs.from}.`);
}
// Convert the SSH key name into a path if it isn't already
if (!this.#_helpers.isPath(configs.from)) {
configs.from = path_1.default.join(os_1.default.homedir(), '.ssh', configs.from);
}
if (!fs_1.default.existsSync(configs.from)) {
throw new Error(`The file ${configs.from} does not exist`);
}
try {
await fs_1.default.promises.access(configs.from, fs_1.default.constants.R_OK);
}
catch (err) {
// @ts-ignore
throw new Error(`The file ${configs.from} is not readable: ${err.message}`);
}
_configs.from = configs.from;
}
else {
throw new SyntaxError('The `from` property is required.');
}
if (configs.installClient && helpers_1.default.hasOwnProperty(configs, 'installClient')) {
if (typeof configs.installClient !== 'boolean') {
throw new TypeError(`The "installClient" property must be a boolean, instead got ${typeof configs.installClient}.`);
}
_configs.installClient = configs.installClient;
}
// Copying the SSH key
const ssh = {
file: { name: path_1.default.basename(_configs.from), path: { relative: '', absolute: '', relativeUnix: '' }, containerPath: '' },
storePath: path_1.default.join('temp', 'orchestriq', '.ssh'),
parent: { relative: '', absolute: '' }
};
ssh.parent.relative = path_1.default.join('.', ssh.storePath);
ssh.parent.absolute = path_1.default.join(_configs.context, ssh.storePath);
ssh.file.path.relative = path_1.default.join(ssh.parent.relative, ssh.file.name);
ssh.file.path.relativeUnix = this.#_helpers.toUnixPath(ssh.file.path.relative);
ssh.file.path.absolute = path_1.default.join(ssh.parent.absolute, ssh.file.name);
ssh.file.containerPath = this.#_helpers.toUnixPath(path_1.default.join(path_1.default.join('/root', '.ssh', ssh.file.name)));
// =======================
if (!fs_1.default.existsSync(ssh.parent.absolute)) {
await fs_1.default.promises.mkdir(ssh.parent.absolute, { recursive: true });
}
await fs_1.default.promises.copyFile(_configs.from, ssh.file.path.absolute);
// Adding SSH key copy instructions to Dockerfile
this.comment(`Set up SSH directory & copy the private key`).copy({ src: ssh.file.path.relativeUnix, dest: ssh.file.containerPath });
this.comment(`Set up SSH configurations`).run([
'mkdir -p /root/.ssh',
`chmod 600 ${ssh.file.containerPath}`,
`echo "Host github.com\\n IdentityFile ${ssh.file.containerPath}\\n StrictHostKeyChecking no" > /root/.ssh/config`
]);
this.env({ GIT_SSH_COMMAND: `"ssh -i ${ssh.file.containerPath}"` });
if (_configs.installClient) {
this.commands.install.openSSH();
}
return this;
}
catch (error) {
if (error instanceof Error) {
error.message = `Error copying SSH key: ${error.message}`;
}
throw error;
}
}
});
/**Predefined commands that you can add to the Dockerfile. */
get commands() { return this.#_commands; }
/**
* Copies files from the host to the image.
* @param copy - The files to copy. Each item in the array must contain the following properties:
* - `src`: The path to the file on the host.
* - `dest`: The path to the file in the image.
* - `from`: The stage to copy from. If not set, the file is copied from the host.
* @returns The builder.
* @throws {Error} If a file is invalid. Expected src and dest to be strings.
* @throws {TypeError} If the "from" stage is invalid. Expected the "from" stage to be a string.
* @throws {Error} If the "from" stage does not exist.
*/
copy(copy) {
if (!Array.isArray(copy)) {
copy = [copy];
}
for (const item of copy) {
if (typeof item.src !== 'string' || typeof item.dest !== 'string') {
throw new Error('Invalid file. Expected src and dest to be strings.');
}
if (helpers_1.default.hasOwnProperty(item, 'from')) {
if (typeof item.from !== 'string') {
throw new TypeError('Invalid file. Expected the "from" stage to be a string.');
}
if (!this.#_lines.find(line => line.endsWith(item.from))) {
throw new Error(`The "from" stage "${item.from}" does not exist.`);
}
}
if (helpers_1.default.hasOwnProperty(item, 'owner')) {
if (typeof item.owner !== 'string') {
throw new TypeError('Invalid file. Expected the "owner" to be a string.');
}
if (item.owner.trim().length === 0) {
throw new RangeError('Invalid file. Expected the "owner" to be a non-empty string.');
}
}
}
this.comment('Copy files');
const content = copy.map(item => `COPY ${item.from ? `--from=${item.from} ` : ''}${item.owner ? `--chown=${item.owner} ` : ''}${item.src} ${item.dest}`).join('\n');
return this.content(content).newLine();
}
/**
* Configures the user and group for the Dockerfile.
*
* This method sets the user and group for the Docker container. It validates
* the input parameters and updates the internal cache with user and group
* information. Based on the options provided, it may also check for
* the existence of the user and group, create them if necessary, and
* assign the user to the group.
*
* @param user - The username to set for the Docker container. Must be a non-empty string.
* @param options - Optional configuration to specify additional user and group settings.
* - for: Specifies whether the user is for 'service' or 'build'. Defaults to 'service'.
* - group: The group name to set. Must be a non-empty string.
* - checkUser: If true, checks if the user exists and creates it if not.
* - checkGroup: If true, checks if the group exists and creates it if not.
* - checkUserGroup: If true, adds the user to the group.
* @returns The current instance of DockerfileBuilder to allow method chaining.
* @throws {TypeError} If the user is not a string.
* @throws {Error} If the user is an empty string.
* @throws {TypeError} If any options provided are of incorrect type.
* @throws {Error} If invalid values are provided for options.
*/
user(user, options) {
if (typeof user !== 'string') {
throw new TypeError('user must be a string.');
}
if (user.trim().length === 0) {
throw new Error('user cannot be empty.');
}
const cache = {
group: { value: 'service_containers', check: false, placeholder: '', refPlaceholder: '' },
user: { value: user, check: false, placeholder: '', refPlaceholder: '' },
for: 'service',
args: [],
};
if (options && helpers_1.default.isObject(options)) {
if (helpers_1.default.hasOwnProperty(options, 'for') && options.for) {
if (typeof options.for !== 'string') {
throw new TypeError('The "user"\'s "for" option (when provided) must be a string.');
}
if (!['service', 'build'].includes(options.for)) {
throw new Error('The "user"\'s "for" option (when provided) must be "service" or "build".');
}
cache.for = options.for;
}
if (helpers_1.default.hasOwnProperty(options, 'group') && options.group) {
if (typeof options.group !== 'string') {
throw new TypeError('The "user"\'s "group" option (when provided) must be a string.');
}
if (options.group.trim().length === 0) {
throw new Error('The "user"\'s "group" option (when provided) cannot be empty.');
}
cache.group.value = options.group;
}
if (helpers_1.default.hasOwnProperty(options, 'checkUser') && options.checkUser) {
if (typeof options.checkUser !== 'boolean') {
throw new TypeError('The "user"\'s "checkUser" option (when provided) must be a boolean.');
}
cache.user.check = options.checkUser;
}
if (helpers_1.default.hasOwnProperty(options, 'checkGroup') && options.checkGroup) {
if (typeof options.checkGroup !== 'boolean') {
throw new TypeError('The "user"\'s "checkGroup" option (when provided) must be a boolean.');
}
cache.group.check = options.checkGroup;
}
}
cache.user.placeholder = `${cache.for.toUpperCase()}_USER`;
cache.user.refPlaceholder = "${" + cache.user.placeholder + "}";
cache.group.placeholder = `${cache.for.toUpperCase()}_GROUP`;
cache.group.refPlaceholder = "${" + cache.group.placeholder + "}";
this.comment('Set the user and group for the container').args([
{ name: cache.user.placeholder, value: cache.user.value },
{ name: cache.group.placeholder, value: cache.group.value }
]);
if (cache.group.check || cache.user.check) {
const tab = ' '.repeat(4);
const cmd = [
'DISTRO=$(cat /etc/os-release | grep ^ID= | cut -d= -f2) && \\',
`${tab}if [ "$DISTRO" = "alpine" ]; then \\`,
];
if (cache.group.check) {
cmd.push(`${tab.repeat(2)}# Alpine-specific: Use addgroup and adduser for user/group management`, `${tab.repeat(2)}if ! getent group ${'${SERVICE_GROUP}'} > /dev/null; then \\`, `${tab.repeat(3)}addgroup -S ${'${SERVICE_GROUP}'}; \\`, `${tab.repeat(2)}fi${cache.user.check ? ' &&' : ''} \\`);
}
if (cache.user.check) {
cmd.push(`${tab.repeat(2)}if ! id -u ${'${SERVICE_USER}'} > /dev/null 2>&1; then \\`, `${tab.repeat(3)}adduser -S ${'${SERVICE_USER}'} -G ${'${SERVICE_GROUP}'}; \\`, `${tab.repeat(2)}fi; \\`);
}
cmd.push(`${tab}elif [ "$DISTRO" = "debian" ] || [ "$DISTRO" = "ubuntu" ]; then \\`, `${tab.repeat(2)}groupadd --system ${'${SERVICE_GROUP}'} && \\`);
if (cache.group.check) {
cmd.push(`${tab.repeat(2)}# Debian-based: Use groupadd and useradd for user/group management`, `${tab.repeat(2)}if ! getent group ${'${SERVICE_GROUP}'} > /dev/null; then \\`, `${tab.repeat(3)}groupadd --system ${'${SERVICE_GROUP}'}; \\`, `${tab.repeat(2)}fi${cache.user.check ? ' &&' : ''} \\`);
}
if (cache.user.check) {
cmd.push(`${tab.repeat(2)}if ! id -u ${'${SERVICE_USER}'} > /dev/null 2>&1; then \\`, `${tab.repeat(3)}useradd --system --ingroup ${'${SERVICE_GROUP}'} ${'${SERVICE_USER}'}; \\`, `${tab.repeat(2)}fi; \\`);
}
cmd.push(`${tab}else \\`, `${tab.repeat(2)}echo "Unsupported OS"; exit 1; \\`, `${tab}fi`);
this.comment('Detect distribution and create the user and group accordingly').run(cmd.join('\n').trim());
}
return this.comment('Set the user and group for the container').content(`USER ${cache.user.refPlaceholder}:${cache.group.refPlaceholder}`).newLine();
}
/**
* Sets the entrypoint command for the Dockerfile.
*
* The entrypoint is the command that is run when the Docker container is started.
*
* Throws an error if the provided value is not a string, or if the string is empty.
*
* Throws a SyntaxError if the ENTRYPOINT instruction is used more than once.
*
* @param entrypoint A string representing the entrypoint command.
* @returns The current instance of DockerfileBuilder.
*/
entrypoint(entrypoint) {
if (typeof entrypoint !== 'string') {
throw new TypeError('entrypoint must be a string.');
}
if (entrypoint.trim().length === 0) {
throw new Error('entrypoint cannot be empty.');
}
if (this.#_cache.entrypoint.used) {
throw new SyntaxError(`ENTRYPOINT can only be used once.`);
}
this.#_cache.entrypoint.used = true;
return this.content(`ENTRYPOINT [${entrypoint.split('').map(c => `"${c}"`).join(', ')}]`).newLine();
}
/**
* Sets the default command to execute when the container starts.
*
* The specified command will be executed when the container starts.
* If the command is not provided, the ENTRYPOINT instruction will be
* executed instead.
*
* @param cmd A string representing the command to execute.
* @returns The current instance of the Dockerfile builder for chaining.
* @throws {TypeError} If the provided cmd is not a string.
* @throws {Error} If the provided cmd is empty.
* @throws {SyntaxError} If the CMD instruction has already been used.
*/
cmd(cmd) {
if (typeof cmd !== 'string') {
throw new TypeError('cmd must be a string.');
}
if (cmd.trim().length === 0) {
throw new Error('cmd cannot be empty.');
}
if (this.#_cache.cmd.used) {
throw new SyntaxError(`CMD can only be used once.`);
}
this.#_cache.cmd.used = true;
this.comment('Set the default command to execute when the container starts');
return this.content(`CMD [${cmd.split(' ').map(c => `"${c}"`).join(', ')}]`).newLine();
}
/**
* Adds RUN commands to the Dockerfile.
*
* This method allows you to specify one or more shell commands to be executed
* when building the Docker image. The commands can be provided as a single
* string or an array of strings. If the `batch` option is set to `true`, the
* commands will be combined into a single RUN instruction using '&&'.
*
* @param commands - A string or an array of strings representing the shell commands.
* @param options - Options for executing the commands.
* @param options.batch - If true, combines the commands into a single RUN
* instruction. Defaults to false.
* @returns This Dockerfile builder.
* @throws {TypeError} If any element in the provided value is not a string.
* @throws {Error} If any command is empty.
*/
run(commands, options = { batch: false }) {
if (!Array.isArray(commands)) {
commands = [commands];
}
const batch = options.batch === true;
for (const command of commands) {
if (typeof command !== 'string') {
throw new TypeError('Commands must be strings.');
}
if (command.trim().length === 0) {
throw new Error('Commands must not be empty.');
}
if (!batch) {
this.content(`RUN ${command.trim()}`);
}
}
return batch ? this.content(`RUN ${commands.filter(cmd => cmd.length > 0).map(cmd => cmd.trim()).join(' && ')}`).newLine() : this.newLine();
}
/**
* Adds environment variables to the Dockerfile.
* @param env - Environment variables that must be an object or a map.
* @param options - Options for formatting the environment variables.
* @param options.entriesPerLine - The number of entries per line. Defaults to 5.
* @returns This Dockerfile builder.
*/
env(env, options = { entriesPerLine: 5 }) {
if (!helpers_1.default.isObject(env) && !(env instanceof Map)) {
throw new TypeError('Environment variables must be an object or a map.');
}
if (env instanceof Map) {
env = Object.fromEntries(env);
}
const entries = Object.entries(env);
const chunkSize = options?.entriesPerLine ?? 5;
const space = ' '.repeat('env'.length + 1);
const formattedEnv = entries.map((env, idx) => {
idx++;
const [key, value] = env;
if (idx === 1) {
return `ENV ${key}=${value}`;
}
const entry = `${idx === 1 ? 'ENV ' : ''}${key}=${value}`;
const breakPoint = idx % chunkSize === 0;
const newLinePoint = (idx - 1) % chunkSize === 0;
if (breakPoint) {
return ` ${entry} \/\n`;
}
else {
return newLinePoint ? `${space}${entry}` : ` ${entry}`;
}
}).join('');
return this.comment('Environment Variables').content(formattedEnv).newLine();
}
/**
* Mounts the specified volumes in the Dockerfile.
*
* This method accepts a single volume or an array of volumes, which must be strings.
* It validates that each volume is of the correct type and adds the `VOLUME` instruction
* to the Dockerfile lines.
*
* @param {string | string[]} volumes - The volume(s) to mount.
* @returns {this} The current instance of DockerfileBuilder.
* @throws {TypeError} If any of the provided volumes are not strings.
*/
volumes(volumes) {
if (!Array.isArray(volumes)) {
volumes = [volumes];
}
for (const volume of volumes) {
if (typeof volume !== 'string') {
throw new TypeError(`The volume you provided (${volume}) must be a string, but a type of ${typeof volume} was received.`);
}
}
return this.comment('Volumes').content(`VOLUME [${volumes.map(i => `"${i}"`).join(', ')}]`).newLine();
}
/**
* Exposes the specified ports in the Dockerfile.
*
* This method accepts a single port or an array of ports, which can be either
* strings or numbers. It validates that each port is of the correct type and
* adds the `EXPOSE` instruction to the Dockerfile lines.
*
* @param {string | number | (string | number)[]} ports - The port(s) to expose.
* @returns {this} The current instance of DockerfileBuilder.
* @throws {TypeError} If any of the provided ports are not strings or numbers.
*/
expose(ports) {
if (!Array.isArray(ports)) {
ports = [ports];
}
if (ports.length === 0) {
return this;
}
for (const port of ports) {
if (typeof port !== 'string' && typeof port !== 'number') {
throw new TypeError(`The port you provided (${port}) can either be a number or a stringified number, but a type of ${typeof port} was received.`);
}
}
return this.comment('Exposed Ports').content(`EXPOSE ${ports.join(' ')}`).newLine();
}
/**
* Sets the working directory for the Dockerfile.
*
* @param {string} workDir - The path to set as the working directory.
* @throws {TypeError} If `workDir` is not a string.
* @throws {RangeError} If `workDir` is an empty string.
* @returns {this} The current instance of DockerfileBuilder.
*/
workDir(workDir) {
if (typeof workDir !== 'string') {
throw new TypeError(`The "workDir" property is expected to be a string, yet it received ${typeof workDir}`);
}
if (workDir.length === 0) {
throw new RangeError(`The "workDir" value cannot be an empty string`);
}
return this.comment('Working Directory').content(`WORKDIR ${workDir}`).newLine();
}
/**
* Sets the base image for the Dockerfile with an optional stage name.
*
* @param {string} fromImage - The name of the base image to use.
* @param {string} [as] - An optional parameter to specify the stage name.
* @returns {this} The current instance of DockerfileBuilder.
* @throws {Error} If `fromImage` is not a string or is an empty string.
* @throws {Error} If `as` is provided but is not a string or is an empty string.
*/
from(fromImage, as) {
if (typeof fromImage !== 'string') {
throw new Error('Base image must be a string.');
}
if (fromImage.length === 0) {
throw new Error('Base image name cannot be empty.');
}
this.comment('Base Image');
if (as !== undefined) {
if (typeof as !== 'string') {
throw new Error('Stage name must be a string.');
}
if (as.length === 0) {
throw new Error('Stage name cannot be empty.');
}
this.content(`FROM ${fromImage} AS ${as}`);
}
else {
this.content(`FROM ${fromImage}`);
}
return this.newLine();
}
/**
* Sets the list of build arguments for the Dockerfile stage.
*
* The arguments can be provided as a single `DockerfileArgItem` or an array of them.
* Each argument must be an object with the following properties:
* - `name`: A non-empty string representing the argument name.
* - `value` (optional): A value of type string, number, or boolean representing the argument value.
* - `global` (optional): A boolean indicating if the argument is global. Defaults to false.
*
* @param args - A DockerfileArgItem or an array of DockerfileArgItems.
* @throws {Error} If the arguments are not objects, or if they lack required properties.
*/
args(args) {
if (!Array.isArray(args)) {
args = [args];
}
for (const arg of args) {
if (!helpers_1.default.isObject(arg)) {
throw new Error('Arguments must be objects.');
}
if (helpers_1.default.hasOwnProperty(arg, 'name')) {
if (typeof arg.name !== 'string') {
throw new Error('Argument name must be a string.');
}
if (arg.name.length === 0) {
throw new Error('Argument name must not be empty.');
}
}
else {
throw new Error('Arguments must have a name.');
}
if (helpers_1.default.hasOwnProperty(arg, 'value')) {
if (typeof arg.value !== 'string') {
throw new Error('Argument value must be a string.');
}
const types = ['string', 'number', 'boolean'];
if (!types.includes(typeof arg.value)) {
throw new Error('Argument value must be a string, number, or boolean.');
}
}
}
if (args.length > 0) {
args.sort((a, b) => {
const isADefined = helpers_1.default.hasOwnProperty(a, 'value') && a.value !== undefined;
const isBDefined = helpers_1.default.hasOwnProperty(b, 'value') && b.value !== undefined;
if (isADefined && !isBDefined) {
return 1;
}
if (!isADefined && isBDefined) {
return -1;
}
return 0;
});
this.comment('Arguments').content(`ARG ${args.map(arg => arg.value ? `${arg.name}=${arg.value}` : arg.name).join(' ').trim()}`);
}
return this.newLine();
}
/**
* Adds one or more lines of comments to the Dockerfile.
*
* If a single string is provided, it will be split into multiple lines
* if necessary. Each line is added as a separate comment.
*
* @param {string | string[]} comment - The comment or array of comments to add.
* @returns {this} The current instance of DockerfileBuilder.
*/
multiLineComment(comment) {
if (typeof comment === 'string') {
comment = [comment];
}
for (const _comment of comment) {
const parts = _comment.trim().split('\n');
for (const part of parts) {
this.comment(part);
}
}
return this.newLine();
}
/**
* Adds a comment to the Dockerfile.
*
* @param {string} comment - The comment to add.
* @returns {this} The current instance of DockerfileBuilder.
*/
comment(comment, options = { newLine: false }) {
this.#_lines.push(`# ${comment}`);
if (options?.newLine === true) {
this.newLine();
}
return this;
}
/**
* Adds one or more empty lines to the Dockerfile.
*
* @param {number} [num=1] - The number of empty lines to add.
* @returns {this} The current instance of DockerfileBuilder.
*/
newLine(num = 1) {
this.#_lines.push(...' '.repeat(num || 1).split('').map(i => i.trim()));
return this;
}
/**
* Adds a line of content to the Dockerfile.
*
* @param {string} content - The content of the line to add.
*
* @throws {Error} If the content is not a non-empty string.
*
* @returns {this} The current instance of DockerfileBuilder.
*/
content(content) {
if (typeof content !== 'string' || content.length === 0) {
throw new Error(`The line content should be a non-empty string`);
}
this.#_lines.push(content);
return this;
}
/**
* Returns the generated Dockerfile content as a string.
* @returns {string} The generated Dockerfile content as a string.
*/
toString() {
return this.#_lines.join('\n').trim();
}
/**
* Asynchronously generates and writes the Dockerfile content to the specified output path.
*
* Ensures that the parent directories for the output path exist before writing the content.
*
* @param {string} outputPath - The path where the Dockerfile content should be written.
*
* @throws {Error} If there is an issue creating directories or writing the file.
*/
async generate(outputPath) {
try {
const content = this.toString();
await fs_1.default.promises.mkdir(path_1.default.dirname(outputPath), { recursive: true }); // Ensure parent directories exist
await fs_1.default.promises.writeFile(path_1.default.resolve(outputPath), content, { encoding: 'utf-8' });
}
catch (error) {
if (error instanceof Error) {
error.message = `Error generating Dockerfile: ${error.message}`;
}
throw error;
}
}
}
exports.default = DockerfileBuilder;