@nasriya/orchestriq
Version:
A package to generate Docker files
434 lines (433 loc) • 20 kB
JavaScript
import Environment from "./assets/Environment.js";
import StackConfigs from "./assets/configs/StackConfigs.js";
import ServicesNetworksManager from "./assets/networks/ServicesNetworksManager.js";
import SecretsManager from "./assets/secrets/SecretsManager.js";
import ServicesManager from "./assets/services/ServicesManager.js";
import ServicesVolumesManager from "./assets/volumes/ServicesVolumesManager.js";
import ComposeBuilder from "./assets/builders/ComposeBuilder.js";
import fs from "fs";
import path from "path";
import { spawn } from 'child_process';
import helpers from "../../../utils/helpers.js";
class ContainerTemplate {
#_socket;
#_id = '';
#_name = '';
#_volumes;
#_networks;
#_services;
#_secrets;
#_env = new Environment();
#_env_files = [];
#_configs = new StackConfigs();
#_compose = {
generated: false, path: '',
up: async (options) => {
// Build the docker-compose command arguments
const args = [];
if (this.#_name) {
args.push('--project-name', this.#_name);
}
if (options.detach) {
args.push('-d');
}
if (options.build) {
args.push('--build');
}
if (options.forceRecreate) {
args.push('--force-recreate');
}
else if (options.noRecreate) {
args.push('--no-recreate');
}
if (options.noBuild) {
args.push('--no-build');
}
if (options.abortOnContainerExit) {
args.push('--abort-on-container-exit');
}
if (options.compatibility) {
args.push('--compatibility');
}
if (options.renewAnonVolumes) {
args.push('--renew-anon-volumes');
}
if (options.verbose) {
args.push('--verbose');
}
if (options.wait) {
args.push('--wait');
}
// Handle the scale option
if (options.scale) {
for (const [serviceName, value] of Object.entries(options.scale)) {
args.push('--scale', `${serviceName}=${value}`);
}
}
// Handle the files option (compose files)
const filesArgs = [];
if (options.files) {
for (const file of options.files) {
filesArgs.push('--file', file);
}
args.push(...filesArgs);
}
// Handle the services option
if (options.services) {
for (const service of options.services) {
args.push(service);
}
}
if (options.removeOrphans) {
const removeArgs = [...args, 'down', '--remove-orphans'];
// Preparing the promise to remove the orphan containers
const removePromise = new Promise((resolve, reject) => {
const removeProcess = spawn('docker-compose', removeArgs, { stdio: 'inherit', });
// Handle errors or exit codes
removeProcess.on('error', (err) => {
reject(new Error(`Error removing the orphan containers: ${err.message}`));
});
removeProcess.on('exit', (code) => {
if (code === 0) {
resolve();
}
else {
reject(new Error('Failed to remove orphan containers.'));
}
});
});
// Execute the promise to remove the orphan containers
await removePromise;
}
args.push('up', '-d');
// Optionally, you can return a promise to wait for the process to finish:
const containerId = await new Promise((resolve, reject) => {
// Run the command using `spawn`
const dockerComposeProcess = spawn('docker-compose', args, {
stdio: 'inherit', // This allows the output to go directly to the terminal
env: options.env, // Ensure the environment variables are set properly
});
// Handle errors or exit codes
dockerComposeProcess.on('error', (err) => {
reject(new Error(`Error executing docker-compose: ${err.message}`));
});
dockerComposeProcess.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`docker-compose process exited with code ${code}`));
}
else {
// Once the docker-compose up is successful, get the container ID(s)
const psArgs = [...filesArgs, 'ps', '-q'];
const dockerPsProcess = spawn('docker-compose', psArgs, {
stdio: 'pipe', // Capture the output to get the container ID(s)
env: options.env, // Ensure the environment variables are set properly
});
let containerId = '';
dockerPsProcess.stdout.on('data', (data) => {
containerId += data.toString().trim(); // Collect container IDs
});
dockerPsProcess.on('close', (code) => {
if (code === 0) {
this.#_id = containerId;
resolve(containerId); // Return the container ID(s)
}
else {
reject(new Error('Failed to fetch container ID(s)'));
}
});
dockerPsProcess.on('error', (err) => {
reject(new Error(`Error fetching container ID: ${err.message}`));
});
}
});
dockerComposeProcess.on('close', (code) => {
if (code !== 0) {
reject(new Error(`Docker Compose process exited with code ${code}`));
}
});
});
return containerId;
},
};
constructor(socket) {
this.#_socket = socket;
this.#_networks = new ServicesNetworksManager(this);
this.#_services = new ServicesManager(this);
this.#_volumes = new ServicesVolumesManager(this);
this.#_secrets = new SecretsManager(this);
}
compose = {
/**
* Generates the content of the docker-compose.yml file that is used to create and configure this container.
*
* @returns {string} The content of the docker-compose.yml file as a string.
*/
generateContent: () => {
return new ComposeBuilder(this).generate().trim();
},
/**
* Generates a docker-compose YAML file at the specified output path or a default location.
*
* Validates the output path to ensure it is a string and points to a YAML file named 'docker-compose'.
* Creates necessary directories and writes the generated docker-compose content to the file.
*
* @param {string} [outputPath] - The optional file path where the docker-compose file will be written.
* If not provided, a default path is used.
* @returns {Promise<string>} A promise that resolves with the path to the generated docker-compose YAML file.
* @throws {TypeError} If the `outputPath` is provided and is not a string.
* @throws {Error} If the `outputPath` is not a valid YAML file path or is not named 'docker-compose'.
*/
generateYamlFile: async (outputPath) => {
if (outputPath !== undefined) {
if (typeof outputPath !== 'string') {
throw new TypeError(`The ${this.#_name ? `'${this.#_name}' ` : ''}container's output path (when defined) must be a string.`);
}
}
const filePath = outputPath || path.join(process.cwd(), 'Docker', 'ComposeFiles', this.#_name ? this.#_name : String(Date.now()), 'docker-compose.yml');
const extname = path.extname(filePath).toLowerCase();
if (!(extname === '.yml' || extname === '.yaml')) {
throw new Error(`The ${this.#_name ? `'${this.#_name}' ` : ''}container's output path (when defined) must be a YAML file.`);
}
const basename = path.basename(filePath).toLowerCase();
if (!basename.startsWith('docker-compose')) {
throw new Error(`The ${this.#_name ? `'${this.#_name}' ` : ''}container's output path (when defined) must be named 'docker-compose'.`);
}
const content = this.compose.generateContent();
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
await fs.promises.writeFile(filePath, content, 'utf8');
return filePath;
}
};
/**
* The container ID, or an empty string if the container has not been started yet.
* @readonly
* @type {String}
*/
get id() { return this.#_id; }
/**
* Starts the container.
*
* **Note:**
* This method only works on local containers, to deploy to remote containers, use the `create` method
* on from the package's `containers` module.
* @param options An object containing the options for the "docker-compose up" command.
* @returns A promise that resolves with the container ID(s) when the container is started successfully.
* @throws {Error} If the container is already running.
* @throws {TypeError} If the options (when provided) are invalid.
* @throws {Error} If the command fails to execute.
*/
async up(options) {
if (this.#_socket.configs.hostType !== 'local') {
throw new Error('The up method is only available for local containers. To deploy to remote containers, use the "create" method from the containers module.');
}
// Default options can be merged with user-provided ones if needed
const flags = {
detach: false,
build: false,
forceRecreate: false,
noRecreate: false,
noBuild: false,
removeOrphans: false,
abortOnContainerExit: false,
wait: false,
compatibility: false,
renewAnonVolumes: false,
verbose: false,
};
const finalOptions = {
...flags,
files: [],
};
if (options !== undefined) {
// Validate that the options are a plain object
if (typeof options !== 'object' || options === null || Array.isArray(options)) {
throw new TypeError("The 'options' parameter (when provided) must be a plain object.");
}
// Validate the flags from the options
const flags = Object.keys(options);
for (const _flag of flags) {
if (helpers.hasOwnProperty(flags, _flag)) {
const flag = _flag;
if (typeof options[flag] !== 'boolean') {
throw new TypeError(`The '${flag}' option must be a boolean.`);
}
// @ts-ignore
finalOptions[flag] = options[flag] === true ? true : undefined;
}
}
if (helpers.hasOwnProperty(options, 'scale')) {
if (typeof options.scale !== 'object' || options.scale === null || Array.isArray(options.scale)) {
throw new TypeError("The 'scale' option (when provided) must be an object.");
}
for (const [serviceName, value] of Object.entries(options.scale)) {
if (!(serviceName in this.#_services.list)) {
throw new Error(`Cannot setup the scale for the '${serviceName}' service. The '${serviceName}' service is not defined.`);
}
if (typeof value !== 'number') {
throw new TypeError(`The '${serviceName}' service scale must be a number.`);
}
if (value <= 0) {
throw new Error(`The '${serviceName}' service scale must be greater than 0.`);
}
}
finalOptions.scale = options.scale;
}
if (helpers.hasOwnProperty(options, 'env')) {
if (typeof options.env !== 'object' || options.env === null || Array.isArray(options.env)) {
throw new TypeError("The 'env' option (when provided) must be an object.");
}
this.environment.add(options.env);
}
if (helpers.hasOwnProperty(options, 'timeout')) {
if (typeof options.timeout !== 'number') {
throw new TypeError("The 'timeout' option (when provided) must be a number.");
}
if (options.timeout <= 0) {
throw new Error("The 'timeout' option (when provided) must be greater than 0.");
}
finalOptions.timeout = options.timeout;
}
if (helpers.hasOwnProperty(options, 'services')) {
if (!Array.isArray(options.services)) {
throw new TypeError("The 'services' option (when provided) must be an array.");
}
for (const serviceName of options.services) {
if (typeof serviceName !== 'string') {
throw new TypeError("The 'services' option (when provided) must be an array of strings.");
}
if (!(serviceName in this.#_services.list)) {
throw new Error(`Cannot start the '${serviceName}' service. The '${serviceName}' service is not defined.`);
}
}
finalOptions.services = options.services;
}
if (helpers.hasOwnProperty(options, 'files')) {
if (!Array.isArray(options.files)) {
throw new TypeError("The 'files' option (when provided) must be an array.");
}
for (const filePath of Array.from(new Set(options.files))) {
if (typeof filePath !== 'string') {
throw new TypeError("The 'files' option (when provided) must be an array of strings.");
}
if (!fs.existsSync(filePath)) {
throw new Error(`The '${filePath}' file does not exist.`);
}
}
finalOptions.files?.push(...options.files);
}
}
// Assign the container name (if specified)
this.environment.add({ 'COMPOSE_PROJECT_NAME': this.name });
// Run the command
const composePath = await this.compose.generateYamlFile();
finalOptions.files?.push(composePath);
try {
const id = await this.#_compose.up(finalOptions);
return id;
}
catch (error) {
if (error instanceof Error) {
error.message = `Failed to start the container: ${error.message}`;
}
throw error;
}
finally {
// Cleanup the generated compose file
fs.unlinkSync(composePath);
}
}
/**
* Sets the path to a file containing environment variables in the format
* KEY=VALUE, one per line.
* The file is read and the environment variables are added to the service.
* If the file does not exist, an error is thrown.
* @param value A string representing the path to the env file.
* @throws {TypeError} If the provided value is not a string.
* @throws {Error} If the file does not exist.
*/
set env_files(value) {
if (!Array.isArray(value)) {
value = [value];
}
const finalFiles = [];
for (const file of value) {
if (typeof file !== 'string') {
throw new TypeError('env_file must be a string.');
}
if (fs.existsSync(file)) {
const stat = fs.statSync(file);
if (stat.isFile()) {
finalFiles.push(file);
continue;
}
if (stat.isDirectory()) {
const files = fs.readdirSync(file, { withFileTypes: true }).filter(f => f.isFile());
const envFiles = files.filter(f => f.name.endsWith('.env'));
finalFiles.push(...envFiles.map(f => path.join(file, f.name)));
continue;
}
}
else {
throw new Error(`env_file '${file}' does not exist.`);
}
}
this.#_env_files = finalFiles;
}
/**
* Gets the path to a file containing environment variables in the format
* KEY=VALUE, one per line.
* @returns {string | undefined} The path to the env file, or undefined if not set.
*/
get env_files() { return this.#_env_files; }
/**
* Gets the name of the container.
* @returns {string} The name of the container.
*/
get name() {
return this.#_name;
}
/**
* Sets the name of the container.
* The name is a string and must be unique among all containers.
* If the name is already set, this method will throw an error.
* @param value A string representing the name of the container.
* @throws {TypeError} If the provided value is not a string.
*/
set name(value) {
if (typeof value !== 'string') {
throw new TypeError("The 'name' property must be a string.");
}
this.#_name = value.replace(/\s+/g, '_').toLowerCase();
}
/**
* Retrieves the config manager associated with the container.
* @returns {StackConfigs} An instance of the StackConfigs class, providing APIs to manage configuration within the container.
*/
get configs() { return this.#_configs; }
/**
* Retrieves the services manager associated with the container.
* @returns {ServicesManager} An instance of the ServicesManager class, providing APIs to manage services within the container.
*/
get services() { return this.#_services; }
/**
* Retrieves the volumes manager associated with the container.
* @returns {VolumesManager} An instance of the VolumesManager class, providing APIs to manage volumes within the container.
*/
get volumes() { return this.#_volumes; }
/**
* Retrieves the networks manager associated with the container.
* @returns {NetworksManager} An instance of the NetworksManager class, providing APIs to manage networks within the container.
*/
get networks() { return this.#_networks; }
/**
* Retrieves the environment variables associated with the container.
* @returns {Environment} An instance of the Environment class, providing APIs to manage environment variables within the container.
*/
get environment() { return this.#_env; }
/**
* Retrieves the secrets manager associated with the container.
* @returns {SecretsManager} An instance of the SecretsManager class, providing APIs to manage secrets within the container.
*/
get secrets() { return this.#_secrets; }
}
export default ContainerTemplate;