UNPKG

docker-pilot

Version:

A powerful, scalable Docker CLI library for managing containerized applications of any size

645 lines 26.8 kB
"use strict"; /** * Configuration Manager for Docker Pilot * Handles loading, saving, and validation of configuration files */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.ConfigManager = void 0; const path = __importStar(require("path")); const types_1 = require("../types"); const Logger_1 = require("../utils/Logger"); const FileUtils_1 = require("../utils/FileUtils"); const ValidationUtils_1 = require("../utils/ValidationUtils"); const i18n_1 = require("../utils/i18n"); class ConfigManager { constructor(options = {}) { this.config = null; this.logger = new Logger_1.Logger(); this.fileUtils = new FileUtils_1.FileUtils(this.logger); this.validationUtils = new ValidationUtils_1.ValidationUtils(this.logger, this.fileUtils); this.i18n = new i18n_1.I18n(); this.options = { configPath: options.configPath || this.getDefaultConfigPath(), autoSave: options.autoSave ?? true, createDefault: options.createDefault ?? true, validateOnLoad: options.validateOnLoad ?? true }; this.configPath = this.options.configPath; // Clean old backup files on initialization this.cleanOldBackups().catch(error => { this.logger.debug('Failed to clean old backups during initialization', error); }); } /** * Clean old backup files with timestamp pattern */ async cleanOldBackups() { try { const configDir = path.dirname(this.configPath); const configBaseName = path.basename(this.configPath, '.json'); await this.fileUtils.cleanOldBackups(configDir, `${configBaseName}-backup-*.json`); } catch (error) { this.logger.debug('Failed to clean old backups', error); } } /** * Get default configuration file path */ getDefaultConfigPath() { return path.join(process.cwd(), 'docker-pilot.config.json'); } /** * Load configuration from file */ async loadConfig() { try { this.logger.debug(`Loading configuration from: ${this.configPath}`); // Check if config file exists if (!(await this.fileUtils.exists(this.configPath))) { if (this.options.createDefault) { this.logger.info('Configuration file not found, creating default configuration'); this.config = this.createDefaultConfig(); await this.saveConfig(); return this.config; } else { throw new types_1.ConfigurationError(`Configuration file not found: ${this.configPath}`, { configPath: this.configPath }); } } // Read configuration file const configData = await this.fileUtils.readJson(this.configPath); // Validate configuration if enabled if (this.options.validateOnLoad) { const validationResult = await this.validationUtils.validateConfig(configData); if (!validationResult.valid) { const errorMessages = validationResult.errors.map(err => `${err.field}: ${err.message}`); throw new types_1.ConfigurationError(`Configuration validation failed:\n${errorMessages.join('\n')}`, { errors: validationResult.errors, warnings: validationResult.warnings }); } if (validationResult.warnings.length > 0) { this.logger.warn('Configuration validation warnings:'); validationResult.warnings.forEach(warning => { this.logger.warn(` ${warning.field}: ${warning.message}`); if (warning.suggestion) { this.logger.warn(` Suggestion: ${warning.suggestion}`); } }); } } // Parse and store configuration this.config = types_1.DockerPilotConfigSchema.parse(configData); this.logger.success(`Configuration loaded successfully: ${this.configPath}`); return this.config; } catch (error) { if (error instanceof types_1.ConfigurationError) { throw error; } this.logger.error(`Failed to load configuration: ${this.configPath}`, error); throw new types_1.ConfigurationError(`Failed to load configuration: ${error instanceof Error ? error.message : 'Unknown error'}`, { configPath: this.configPath, originalError: error }); } } /** * Save configuration to file */ async saveConfig(config) { try { const configToSave = config || this.config; if (!configToSave) { throw new types_1.ConfigurationError('No configuration to save'); } // Validate configuration before saving const validationResult = await this.validationUtils.validateConfig(configToSave); if (!validationResult.valid) { const errorMessages = validationResult.errors.map(err => `${err.field}: ${err.message}`); throw new types_1.ConfigurationError(`Cannot save invalid configuration:\n${errorMessages.join('\n')}`, { errors: validationResult.errors }); } // Create backup if file exists and content has changed if (await this.fileUtils.exists(this.configPath)) { try { // Clean old backup files with timestamp pattern first const configDir = path.dirname(this.configPath); const configBaseName = path.basename(this.configPath, '.json'); await this.fileUtils.cleanOldBackups(configDir, `${configBaseName}-backup-*.json`); // Check if content has actually changed const existingContent = await this.fileUtils.readJson(this.configPath); const contentChanged = JSON.stringify(existingContent) !== JSON.stringify(configToSave); if (contentChanged) { await this.fileUtils.backupFile(this.configPath); this.logger.debug('Configuration backup created due to changes'); } else { this.logger.debug('Configuration unchanged, skipping backup'); return; // No need to save if nothing changed } } catch (backupError) { this.logger.warn('Failed to create configuration backup', backupError); } } // Save configuration await this.fileUtils.writeJson(this.configPath, configToSave, { spaces: 2 }); // Update stored configuration this.config = configToSave; this.logger.success(`Configuration saved: ${this.configPath}`); } catch (error) { if (error instanceof types_1.ConfigurationError) { throw error; } this.logger.error(`Failed to save configuration: ${this.configPath}`, error); throw new types_1.ConfigurationError(`Failed to save configuration: ${error instanceof Error ? error.message : 'Unknown error'}`, { configPath: this.configPath, originalError: error }); } } /** * Get current configuration */ getConfig() { if (!this.config) { throw new types_1.ConfigurationError('Configuration not loaded. Call loadConfig() first.'); } return { ...this.config }; // Return a copy to prevent mutations } /** * Update configuration */ async updateConfig(updates) { if (!this.config) { throw new types_1.ConfigurationError('Configuration not loaded. Call loadConfig() first.'); } // Merge updates with current configuration const updatedConfig = this.mergeConfig(this.config, updates); // Save if auto-save is enabled if (this.options.autoSave) { await this.saveConfig(updatedConfig); } else { this.config = updatedConfig; } return updatedConfig; } /** * Add or update a service */ async addService(serviceName, serviceConfig) { if (!this.config) { throw new types_1.ConfigurationError('Configuration not loaded. Call loadConfig() first.'); } const updatedServices = { ...this.config.services, [serviceName]: serviceConfig }; return this.updateConfig({ services: updatedServices }); } /** * Remove a service */ async removeService(serviceName) { if (!this.config) { throw new types_1.ConfigurationError('Configuration not loaded. Call loadConfig() first.'); } if (!(serviceName in this.config.services)) { throw new types_1.ConfigurationError(`Service not found: ${serviceName}`); } const updatedServices = { ...this.config.services }; delete updatedServices[serviceName]; return this.updateConfig({ services: updatedServices }); } /** * Add a plugin */ async addPlugin(pluginPath) { if (!this.config) { throw new types_1.ConfigurationError('Configuration not loaded. Call loadConfig() first.'); } if (this.config.plugins.includes(pluginPath)) { this.logger.warn(`Plugin already exists: ${pluginPath}`); return this.config; } const updatedPlugins = [...this.config.plugins, pluginPath]; return this.updateConfig({ plugins: updatedPlugins }); } /** * Remove a plugin */ async removePlugin(pluginPath) { if (!this.config) { throw new types_1.ConfigurationError('Configuration not loaded. Call loadConfig() first.'); } const updatedPlugins = this.config.plugins.filter(p => p !== pluginPath); return this.updateConfig({ plugins: updatedPlugins }); } /** * Create default configuration */ createDefaultConfig() { const projectName = this.inferProjectName(); return { projectName, dockerCompose: 'docker compose', configVersion: '1.0', services: { app: { port: 3000, path: './', description: 'Main application service', healthCheck: true, backupEnabled: false, restart: 'unless-stopped', scale: 1 } }, plugins: [], cli: { version: '1.0.0', welcomeMessage: `Bem-vindo ao {projectName} Docker Pilot v{version}! 🐳`, goodbyeMessage: 'Obrigado por usar o {projectName} Docker Pilot!', interactiveMode: true, colorOutput: true, verboseLogging: false, confirmDestructiveActions: true }, backup: { enabled: false, directory: './backups', retention: 7, services: {} }, monitoring: { enabled: true, refreshInterval: 5, services: ['app'], urls: {}, alerts: { enabled: false, thresholds: { cpu: 80, memory: 80, disk: 90 } } }, development: { hotReload: true, debugMode: false, logLevel: 'info', autoMigrate: false, seedData: false, testMode: false, watchFiles: [], environment: 'development' }, networks: {}, volumes: {}, language: 'en' }; } /** * Infer project name from current directory or package.json */ inferProjectName() { try { // Try to get name from package.json const packageJsonPath = path.join(process.cwd(), 'package.json'); if (require('fs').existsSync(packageJsonPath)) { const packageJson = require(packageJsonPath); if (packageJson.name) { return packageJson.name; } } } catch (error) { // Ignore errors, fall back to directory name } // Fall back to current directory name return path.basename(process.cwd()) || 'docker-pilot-project'; } /** * Deep merge configuration objects */ mergeConfig(base, updates) { const merged = { ...base }; for (const [key, value] of Object.entries(updates)) { if (value !== undefined && value !== null) { if (key === 'services') { // For services, always replace completely instead of merging // This ensures clean replacement when autoDetectServices(true) is called merged[key] = value; } else if (typeof value === 'object' && !Array.isArray(value) && key in merged) { // Deep merge other objects merged[key] = { ...merged[key], ...value }; } else { // Direct assignment for primitives and arrays merged[key] = value; } } } return merged; } /** * Detect Docker Compose services from file or search recursively with enhanced search */ async detectServicesFromCompose(composePath) { try { let composeFile; if (composePath) { composeFile = composePath; } else { // Search for docker-compose files recursively with enhanced options const foundFiles = await this.fileUtils.findDockerComposeFilesWithInfo(undefined, { maxDepth: 6, includeVariants: true, includeEmptyFiles: false }); if (foundFiles.length === 0) { this.logger.debug(this.i18n.t('compose.no_files_found')); return {}; } // Use the first file (sorted by priority) const firstFile = foundFiles[0]; if (!firstFile) { this.logger.debug(this.i18n.t('compose.no_files_found')); return {}; } composeFile = firstFile.path; if (foundFiles.length > 1) { this.logger.info(this.i18n.t('compose.multiple_files_found', { count: foundFiles.length })); foundFiles.slice(0, 3).forEach((file, index) => { const envText = file.environment ? ` (${file.environment})` : ''; const mainFileIndicator = file.isMainFile ? ' 🎯' : ''; this.logger.info(` ${index + 1}. ${file.relativePath}${envText}${mainFileIndicator} (${file.serviceCount} ${this.i18n.t('compose.services')})`); }); this.logger.info(this.i18n.t('compose.using_first_file', { file: firstFile.relativePath })); } } if (!(await this.fileUtils.exists(composeFile))) { this.logger.debug(this.i18n.t('compose.file_not_found', { file: composeFile })); return {}; } this.logger.info(this.i18n.t('compose.detecting_services', { file: path.relative(process.cwd(), composeFile) })); const composeData = await this.fileUtils.readYaml(composeFile); if (!composeData.services) { this.logger.warn(this.i18n.t('compose.no_services_in_file')); return {}; } const detectedServices = {}; for (const [serviceName, serviceConfig] of Object.entries(composeData.services)) { const config = serviceConfig; detectedServices[serviceName] = { description: `Auto-detected ${serviceName} service`, healthCheck: !!config.healthcheck, backupEnabled: this.shouldEnableBackup(serviceName), detected: true, ...(config.ports && config.ports.length > 0 && { port: this.extractPortFromMapping(config.ports[0]) }), ...(config.volumes && { volumes: config.volumes }), ...(config.environment && { environment: this.normalizeEnvironmentVariables(config.environment) }) }; } this.logger.success(`Detected ${Object.keys(detectedServices).length} services`); return detectedServices; } catch (error) { this.logger.error('Failed to detect services from Docker Compose file', error); return {}; } } /** * Determine if backup should be enabled for service */ shouldEnableBackup(serviceName) { const backupCandidates = [ 'postgres', 'postgresql', 'mysql', 'mariadb', 'mongodb', 'mongo', 'redis', 'elasticsearch', 'database', 'db' ]; return backupCandidates.some(candidate => serviceName.toLowerCase().includes(candidate)); } /** * Normalize environment variables to ensure all values are strings */ normalizeEnvironmentVariables(env) { if (!env || typeof env !== 'object') { return {}; } const normalizedEnv = {}; for (const [key, value] of Object.entries(env)) { // Convert any value to string if (value === null || value === undefined) { normalizedEnv[key] = ''; } else if (typeof value === 'boolean') { normalizedEnv[key] = value.toString(); } else if (typeof value === 'number') { normalizedEnv[key] = value.toString(); } else { normalizedEnv[key] = String(value); } } return normalizedEnv; } /** * Extract port number from Docker port mapping */ extractPortFromMapping(portMapping) { try { if (typeof portMapping !== 'string') return null; // Handle various port mapping formats const match = portMapping.match(/(\d+):(\d+)/); if (match && match[1]) { return parseInt(match[1], 10); // Host port } const singlePortMatch = portMapping.match(/^(\d+)$/); if (singlePortMatch && singlePortMatch[1]) { return parseInt(singlePortMatch[1], 10); } return null; } catch { return null; } } /** * Auto-detect and update services from Docker Compose */ async autoDetectServices(replaceExisting = false) { // Use primary compose file if available let composePath; if (this.config?.primaryComposeFile) { composePath = this.config.primaryComposeFile; this.logger.info(`Using primary compose file: ${path.relative(process.cwd(), composePath)}`); } const detectedServices = await this.detectServicesFromCompose(composePath); if (Object.keys(detectedServices).length === 0) { this.logger.info('No services detected to add'); return this.getConfig(); } const currentServices = this.config?.services || {}; let mergedServices; let addedCount = 0; let updatedCount = 0; let replacedCount = 0; let removedCount = 0; if (replaceExisting) { // Start fresh - only include detected services mergedServices = {}; // Add all detected services for (const [serviceName, serviceConfig] of Object.entries(detectedServices)) { mergedServices[serviceName] = serviceConfig; if (serviceName in currentServices) { replacedCount++; } else { addedCount++; } } // Count removed services removedCount = Object.keys(currentServices).filter(serviceName => !(serviceName in detectedServices)).length; } else { // Merge mode - start with current services mergedServices = { ...currentServices }; for (const [serviceName, serviceConfig] of Object.entries(detectedServices)) { if (serviceName in currentServices) { // Update existing service with detected information mergedServices[serviceName] = { ...serviceConfig, ...mergedServices[serviceName], // Keep user customizations detected: true }; updatedCount++; } else { // Add new service mergedServices[serviceName] = serviceConfig; addedCount++; } } } if (addedCount > 0 || updatedCount > 0 || replacedCount > 0 || removedCount > 0) { const result = await this.updateConfig({ services: mergedServices }); if (replaceExisting) { const totalDetected = Object.keys(detectedServices).length; if (removedCount > 0) { this.logger.success(`Services synchronized: ${totalDetected} services from compose file (${addedCount} new, ${replacedCount} replaced, ${removedCount} removed)`); } else if (replacedCount > 0) { this.logger.success(`Services synchronized: ${totalDetected} services from compose file (${addedCount} new, ${replacedCount} replaced)`); } else { this.logger.success(`Services synchronized: ${totalDetected} services from compose file`); } } else { this.logger.success(`Services updated: ${addedCount} added, ${updatedCount} updated`); } return result; } else { this.logger.info('All services are already configured'); return this.getConfig(); } } /** * Get configuration file path */ getConfigPath() { return this.configPath; } /** * Set configuration file path */ setConfigPath(configPath) { this.configPath = configPath; this.options.configPath = configPath; } /** * Check if configuration is loaded */ isLoaded() { return this.config !== null; } /** * Reset configuration to default */ async resetToDefault() { this.config = this.createDefaultConfig(); if (this.options.autoSave) { await this.saveConfig(); } this.logger.info('Configuration reset to default'); return this.config; } /** * Export configuration to different file */ async exportConfig(exportPath) { if (!this.config) { throw new types_1.ConfigurationError('Configuration not loaded. Call loadConfig() first.'); } await this.fileUtils.writeJson(exportPath, this.config, { spaces: 2 }); this.logger.success(`Configuration exported to: ${exportPath}`); } /** * Import configuration from file */ async importConfig(importPath) { if (!(await this.fileUtils.exists(importPath))) { throw new types_1.ConfigurationError(`Import file not found: ${importPath}`); } const importedConfig = await this.fileUtils.readJson(importPath); // Validate imported configuration const validationResult = await this.validationUtils.validateConfig(importedConfig); if (!validationResult.valid) { const errorMessages = validationResult.errors.map(err => `${err.field}: ${err.message}`); throw new types_1.ConfigurationError(`Invalid configuration in import file:\n${errorMessages.join('\n')}`, { errors: validationResult.errors }); } // Parse and set as current configuration this.config = types_1.DockerPilotConfigSchema.parse(importedConfig); if (this.options.autoSave) { await this.saveConfig(); } this.logger.success(`Configuration imported from: ${importPath}`); return this.config; } } exports.ConfigManager = ConfigManager; //# sourceMappingURL=ConfigManager.js.map