UNPKG

@masonator/coolify-mcp

Version:

MCP server implementation for Coolify

1,292 lines (1,291 loc) 48.6 kB
/** * Coolify API Client * Complete HTTP client for the Coolify API v1 */ /** * Remove undefined values from an object. * Keeps explicit false values so features like HTTP Basic Auth can be disabled. */ function cleanRequestData(data) { const cleaned = {}; for (const [key, value] of Object.entries(data)) { if (value !== undefined) { cleaned[key] = value; } } return cleaned; } /** Base64-encode a string, passing through values that are already base64. */ function toBase64(value) { try { const decoded = Buffer.from(value, 'base64').toString('utf-8'); if (Buffer.from(decoded, 'utf-8').toString('base64') === value) { return value; // Already valid base64 } } catch { // Not base64, encode it } return Buffer.from(value, 'utf-8').toString('base64'); } // ============================================================================= // Summary Transformers - reduce full objects to essential fields // ============================================================================= function toServerSummary(server) { return { uuid: server.uuid, name: server.name, ip: server.ip, status: server.status, is_reachable: server.is_reachable, }; } function toApplicationSummary(app) { return { uuid: app.uuid, name: app.name, status: app.status, fqdn: app.fqdn, git_repository: app.git_repository, git_branch: app.git_branch, }; } function toDatabaseSummary(db) { // API returns database_type not type, and environment_id not environment_uuid const raw = db; return { uuid: db.uuid, name: db.name, type: db.type || raw.database_type, status: db.status, is_public: db.is_public, environment_uuid: db.environment_uuid, environment_name: db.environment_name, environment_id: raw.environment_id, }; } function toServiceSummary(svc) { return { uuid: svc.uuid, name: svc.name, type: svc.type, status: svc.status, domains: svc.domains, }; } function toDeploymentSummary(dep) { return { uuid: dep.uuid, deployment_uuid: dep.deployment_uuid, application_name: dep.application_name, status: dep.status, created_at: dep.created_at, }; } function toDeploymentEssential(dep) { return { uuid: dep.uuid, deployment_uuid: dep.deployment_uuid, application_uuid: dep.application_uuid, application_name: dep.application_name, server_name: dep.server_name, status: dep.status, commit: dep.commit, force_rebuild: dep.force_rebuild, is_webhook: dep.is_webhook, is_api: dep.is_api, created_at: dep.created_at, updated_at: dep.updated_at, logs_available: !!dep.logs, logs_info: dep.logs ? `Logs available (${dep.logs.length} chars). Use lines param to retrieve.` : undefined, }; } function toProjectSummary(proj) { return { uuid: proj.uuid, name: proj.name, description: proj.description, }; } function toGitHubAppSummary(app) { return { id: app.id, uuid: app.uuid, name: app.name, organization: app.organization, is_public: app.is_public, app_id: app.app_id, }; } function toEnvVarSummary(envVar) { return { uuid: envVar.uuid, key: envVar.key, value: envVar.value, is_build_time: envVar.is_build_time, }; } /** * HTTP client for the Coolify API */ export class CoolifyClient { constructor(config) { if (!config.baseUrl) { throw new Error('Coolify base URL is required'); } if (!config.accessToken) { throw new Error('Coolify access token is required'); } this.baseUrl = config.baseUrl.replace(/\/$/, ''); this.accessToken = config.accessToken; } // =========================================================================== // Private HTTP methods // =========================================================================== async request(path, options = {}) { const url = `${this.baseUrl}/api/v1${path}`; try { const response = await fetch(url, { ...options, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.accessToken}`, ...options.headers, }, }); // Handle empty responses (204 No Content, etc.) const text = await response.text(); const data = text ? JSON.parse(text) : {}; if (!response.ok) { const error = data; // Include validation errors if present let errorMessage = error.message || `HTTP ${response.status}: ${response.statusText}`; if (error.errors && Object.keys(error.errors).length > 0) { const validationDetails = Object.entries(error.errors) .map(([field, messages]) => `${field}: ${Array.isArray(messages) ? messages.join(', ') : String(messages)}`) .join('; '); errorMessage = `${errorMessage} - ${validationDetails}`; } throw new Error(errorMessage); } return data; } catch (error) { if (error instanceof TypeError && error.message.includes('fetch')) { throw new Error(`Failed to connect to Coolify server at ${this.baseUrl}. Please check if the server is running and accessible.`); } throw error; } } buildQueryString(params) { const searchParams = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null) { searchParams.set(key, String(value)); } } const queryString = searchParams.toString(); return queryString ? `?${queryString}` : ''; } // =========================================================================== // Health & Version // =========================================================================== async getVersion() { // The /version endpoint returns plain text, not JSON const url = `${this.baseUrl}/api/v1/version`; const response = await fetch(url, { headers: { Authorization: `Bearer ${this.accessToken}`, }, }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const version = await response.text(); return { version: version.trim() }; } async validateConnection() { try { await this.getVersion(); } catch (error) { throw new Error(`Failed to connect to Coolify server: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // =========================================================================== // Server endpoints // =========================================================================== async listServers(options) { const query = this.buildQueryString({ page: options?.page, per_page: options?.per_page, }); const servers = await this.request(`/servers${query}`); return options?.summary && Array.isArray(servers) ? servers.map(toServerSummary) : servers; } async getServer(uuid) { return this.request(`/servers/${uuid}`); } async createServer(data) { return this.request('/servers', { method: 'POST', body: JSON.stringify(data), }); } async updateServer(uuid, data) { return this.request(`/servers/${uuid}`, { method: 'PATCH', body: JSON.stringify(data), }); } async deleteServer(uuid) { return this.request(`/servers/${uuid}`, { method: 'DELETE', }); } async getServerResources(uuid) { return this.request(`/servers/${uuid}/resources`); } async getServerDomains(uuid) { return this.request(`/servers/${uuid}/domains`); } async validateServer(uuid) { return this.request(`/servers/${uuid}/validate`); } // =========================================================================== // Project endpoints // =========================================================================== async listProjects(options) { const query = this.buildQueryString({ page: options?.page, per_page: options?.per_page, }); const projects = await this.request(`/projects${query}`); return options?.summary && Array.isArray(projects) ? projects.map(toProjectSummary) : projects; } async getProject(uuid) { return this.request(`/projects/${uuid}`); } async createProject(data) { return this.request('/projects', { method: 'POST', body: JSON.stringify(data), }); } async updateProject(uuid, data) { return this.request(`/projects/${uuid}`, { method: 'PATCH', body: JSON.stringify(data), }); } async deleteProject(uuid) { return this.request(`/projects/${uuid}`, { method: 'DELETE', }); } // =========================================================================== // Environment endpoints // =========================================================================== async listProjectEnvironments(projectUuid) { return this.request(`/projects/${projectUuid}/environments`); } async getProjectEnvironment(projectUuid, environmentNameOrUuid) { return this.request(`/projects/${projectUuid}/${environmentNameOrUuid}`); } /** * Get environment with missing database types (dragonfly, keydb, clickhouse). * Coolify API omits these from the environment endpoint - we cross-reference * with listDatabases using lightweight summaries. * @see https://github.com/StuMason/coolify-mcp/issues/88 */ async getProjectEnvironmentWithDatabases(projectUuid, environmentNameOrUuid) { const [environment, dbSummaries] = await Promise.all([ this.getProjectEnvironment(projectUuid, environmentNameOrUuid), this.listDatabases({ summary: true }), ]); // Filter for this environment's missing database types // API uses environment_id, not environment_uuid const envDbs = dbSummaries.filter((db) => db.environment_id === environment.id || db.environment_uuid === environment.uuid || db.environment_name === environment.name); const dragonflys = envDbs.filter((db) => db.type?.includes('dragonfly')); const keydbs = envDbs.filter((db) => db.type?.includes('keydb')); const clickhouses = envDbs.filter((db) => db.type?.includes('clickhouse')); return { ...environment, ...(dragonflys.length > 0 && { dragonflys }), ...(keydbs.length > 0 && { keydbs }), ...(clickhouses.length > 0 && { clickhouses }), }; } async createProjectEnvironment(projectUuid, data) { return this.request(`/projects/${projectUuid}/environments`, { method: 'POST', body: JSON.stringify(data), }); } async deleteProjectEnvironment(projectUuid, environmentNameOrUuid) { return this.request(`/projects/${projectUuid}/environments/${environmentNameOrUuid}`, { method: 'DELETE', }); } // =========================================================================== // Application endpoints // =========================================================================== async listApplications(options) { const query = this.buildQueryString({ page: options?.page, per_page: options?.per_page, }); const apps = await this.request(`/applications${query}`); return options?.summary && Array.isArray(apps) ? apps.map(toApplicationSummary) : apps; } async getApplication(uuid) { return this.request(`/applications/${uuid}`); } async createApplicationPublic(data) { return this.request('/applications/public', { method: 'POST', body: JSON.stringify(data), }); } async createApplicationPrivateGH(data) { return this.request('/applications/private-github-app', { method: 'POST', body: JSON.stringify(data), }); } async createApplicationPrivateKey(data) { return this.request('/applications/private-deploy-key', { method: 'POST', body: JSON.stringify(data), }); } async createApplicationDockerfile(data) { return this.request('/applications/dockerfile', { method: 'POST', body: JSON.stringify(data), }); } async createApplicationDockerImage(data) { return this.request('/applications/dockerimage', { method: 'POST', body: JSON.stringify(data), }); } async createApplicationDockerCompose(data) { const payload = { ...data }; if (payload.docker_compose_raw) { payload.docker_compose_raw = toBase64(payload.docker_compose_raw); } return this.request('/applications/dockercompose', { method: 'POST', body: JSON.stringify(payload), }); } async updateApplication(uuid, data) { const payload = { ...data }; if (payload.docker_compose_raw) { payload.docker_compose_raw = toBase64(payload.docker_compose_raw); } return this.request(`/applications/${uuid}`, { method: 'PATCH', body: JSON.stringify(payload), }); } async deleteApplication(uuid, options) { const query = this.buildQueryString({ delete_configurations: options?.deleteConfigurations, delete_volumes: options?.deleteVolumes, docker_cleanup: options?.dockerCleanup, delete_connected_networks: options?.deleteConnectedNetworks, }); return this.request(`/applications/${uuid}${query}`, { method: 'DELETE', }); } async getApplicationLogs(uuid, lines = 100) { return this.request(`/applications/${uuid}/logs?lines=${lines}`); } async startApplication(uuid, options) { const query = this.buildQueryString({ force: options?.force, instant_deploy: options?.instant_deploy, }); return this.request(`/applications/${uuid}/start${query}`, { method: 'POST', }); } async stopApplication(uuid) { return this.request(`/applications/${uuid}/stop`, { method: 'POST', }); } async restartApplication(uuid) { return this.request(`/applications/${uuid}/restart`, { method: 'POST', }); } // =========================================================================== // Application Environment Variables // =========================================================================== async listApplicationEnvVars(uuid, options) { const envVars = await this.request(`/applications/${uuid}/envs`); return options?.summary ? envVars.map(toEnvVarSummary) : envVars; } async createApplicationEnvVar(uuid, data) { return this.request(`/applications/${uuid}/envs`, { method: 'POST', body: JSON.stringify(cleanRequestData(data)), }); } async updateApplicationEnvVar(uuid, data) { return this.request(`/applications/${uuid}/envs`, { method: 'PATCH', body: JSON.stringify(cleanRequestData(data)), }); } async bulkUpdateApplicationEnvVars(uuid, data) { return this.request(`/applications/${uuid}/envs/bulk`, { method: 'PATCH', body: JSON.stringify(data), }); } async deleteApplicationEnvVar(uuid, envUuid) { return this.request(`/applications/${uuid}/envs/${envUuid}`, { method: 'DELETE', }); } // =========================================================================== // Database endpoints // =========================================================================== async listDatabases(options) { const query = this.buildQueryString({ page: options?.page, per_page: options?.per_page, }); const dbs = await this.request(`/databases${query}`); return options?.summary && Array.isArray(dbs) ? dbs.map(toDatabaseSummary) : dbs; } async getDatabase(uuid) { return this.request(`/databases/${uuid}`); } async updateDatabase(uuid, data) { return this.request(`/databases/${uuid}`, { method: 'PATCH', body: JSON.stringify(data), }); } async deleteDatabase(uuid, options) { const query = this.buildQueryString({ delete_configurations: options?.deleteConfigurations, delete_volumes: options?.deleteVolumes, docker_cleanup: options?.dockerCleanup, delete_connected_networks: options?.deleteConnectedNetworks, }); return this.request(`/databases/${uuid}${query}`, { method: 'DELETE', }); } async startDatabase(uuid) { return this.request(`/databases/${uuid}/start`, { method: 'POST', }); } async stopDatabase(uuid) { return this.request(`/databases/${uuid}/stop`, { method: 'POST', }); } async restartDatabase(uuid) { return this.request(`/databases/${uuid}/restart`, { method: 'POST', }); } // Database creation methods async createPostgresql(data) { return this.request('/databases/postgresql', { method: 'POST', body: JSON.stringify(data), }); } async createMysql(data) { return this.request('/databases/mysql', { method: 'POST', body: JSON.stringify(data), }); } async createMariadb(data) { return this.request('/databases/mariadb', { method: 'POST', body: JSON.stringify(data), }); } async createMongodb(data) { return this.request('/databases/mongodb', { method: 'POST', body: JSON.stringify(data), }); } async createRedis(data) { return this.request('/databases/redis', { method: 'POST', body: JSON.stringify(data), }); } async createKeydb(data) { return this.request('/databases/keydb', { method: 'POST', body: JSON.stringify(data), }); } async createClickhouse(data) { return this.request('/databases/clickhouse', { method: 'POST', body: JSON.stringify(data), }); } async createDragonfly(data) { return this.request('/databases/dragonfly', { method: 'POST', body: JSON.stringify(data), }); } // =========================================================================== // Service endpoints // =========================================================================== async listServices(options) { const query = this.buildQueryString({ page: options?.page, per_page: options?.per_page, }); const services = await this.request(`/services${query}`); return options?.summary && Array.isArray(services) ? services.map(toServiceSummary) : services; } async getService(uuid) { return this.request(`/services/${uuid}`); } async createService(data) { const payload = { ...data }; if (payload.docker_compose_raw) { payload.docker_compose_raw = toBase64(payload.docker_compose_raw); } return this.request('/services', { method: 'POST', body: JSON.stringify(payload), }); } async updateService(uuid, data) { const payload = { ...data }; if (payload.docker_compose_raw) { payload.docker_compose_raw = toBase64(payload.docker_compose_raw); } return this.request(`/services/${uuid}`, { method: 'PATCH', body: JSON.stringify(payload), }); } async deleteService(uuid, options) { const query = this.buildQueryString({ delete_configurations: options?.deleteConfigurations, delete_volumes: options?.deleteVolumes, docker_cleanup: options?.dockerCleanup, delete_connected_networks: options?.deleteConnectedNetworks, }); return this.request(`/services/${uuid}${query}`, { method: 'DELETE', }); } async startService(uuid) { return this.request(`/services/${uuid}/start`, { method: 'GET', }); } async stopService(uuid) { return this.request(`/services/${uuid}/stop`, { method: 'GET', }); } async restartService(uuid) { return this.request(`/services/${uuid}/restart`, { method: 'GET', }); } // =========================================================================== // Service Environment Variables // =========================================================================== async listServiceEnvVars(uuid) { return this.request(`/services/${uuid}/envs`); } async createServiceEnvVar(uuid, data) { return this.request(`/services/${uuid}/envs`, { method: 'POST', body: JSON.stringify(cleanRequestData(data)), }); } async updateServiceEnvVar(uuid, data) { return this.request(`/services/${uuid}/envs`, { method: 'PATCH', body: JSON.stringify(cleanRequestData(data)), }); } async deleteServiceEnvVar(uuid, envUuid) { return this.request(`/services/${uuid}/envs/${envUuid}`, { method: 'DELETE', }); } // =========================================================================== // Deployment endpoints // =========================================================================== async listDeployments(options) { const query = this.buildQueryString({ page: options?.page, per_page: options?.per_page, }); const deployments = await this.request(`/deployments${query}`); return options?.summary && Array.isArray(deployments) ? deployments.map(toDeploymentSummary) : deployments; } async getDeployment(uuid, options) { const deployment = await this.request(`/deployments/${uuid}`); return options?.includeLogs ? deployment : toDeploymentEssential(deployment); } async deployByTagOrUuid(tagOrUuid, force = false) { // Detect if the value looks like a UUID or a tag name const param = this.isLikelyUuid(tagOrUuid) ? 'uuid' : 'tag'; return this.request(`/deploy?${param}=${encodeURIComponent(tagOrUuid)}&force=${force}`, { method: 'GET' }); } async listApplicationDeployments(appUuid) { return this.request(`/applications/${appUuid}/deployments`); } // =========================================================================== // Team endpoints // =========================================================================== async listTeams() { return this.request('/teams'); } async getTeam(id) { return this.request(`/teams/${id}`); } async getTeamMembers(id) { return this.request(`/teams/${id}/members`); } async getCurrentTeam() { return this.request('/teams/current'); } async getCurrentTeamMembers() { return this.request('/teams/current/members'); } // =========================================================================== // Private Key endpoints // =========================================================================== async listPrivateKeys() { return this.request('/security/keys'); } async getPrivateKey(uuid) { return this.request(`/security/keys/${uuid}`); } async createPrivateKey(data) { return this.request('/security/keys', { method: 'POST', body: JSON.stringify(data), }); } async updatePrivateKey(uuid, data) { return this.request(`/security/keys/${uuid}`, { method: 'PATCH', body: JSON.stringify(data), }); } async deletePrivateKey(uuid) { return this.request(`/security/keys/${uuid}`, { method: 'DELETE', }); } // =========================================================================== // GitHub App endpoints // =========================================================================== async listGitHubApps(options) { const apps = await this.request('/github-apps'); return options?.summary && Array.isArray(apps) ? apps.map(toGitHubAppSummary) : apps; } async createGitHubApp(data) { return this.request('/github-apps', { method: 'POST', body: JSON.stringify(data), }); } async updateGitHubApp(id, data) { return this.request(`/github-apps/${id}`, { method: 'PATCH', body: JSON.stringify(cleanRequestData(data)), }); } async deleteGitHubApp(id) { return this.request(`/github-apps/${id}`, { method: 'DELETE', }); } // =========================================================================== // Cloud Token endpoints (Hetzner, DigitalOcean) // =========================================================================== async listCloudTokens() { return this.request('/cloud-tokens'); } async getCloudToken(uuid) { return this.request(`/cloud-tokens/${uuid}`); } async createCloudToken(data) { return this.request('/cloud-tokens', { method: 'POST', body: JSON.stringify(data), }); } async updateCloudToken(uuid, data) { return this.request(`/cloud-tokens/${uuid}`, { method: 'PATCH', body: JSON.stringify(data), }); } async deleteCloudToken(uuid) { return this.request(`/cloud-tokens/${uuid}`, { method: 'DELETE', }); } async validateCloudToken(uuid) { return this.request(`/cloud-tokens/${uuid}/validate`, { method: 'POST' }); } // =========================================================================== // Database Backup endpoints // =========================================================================== async listDatabaseBackups(databaseUuid) { return this.request(`/databases/${databaseUuid}/backups`); } async getDatabaseBackup(databaseUuid, backupUuid) { return this.request(`/databases/${databaseUuid}/backups/${backupUuid}`); } async listBackupExecutions(databaseUuid, backupUuid) { return this.request(`/databases/${databaseUuid}/backups/${backupUuid}/executions`); } async getBackupExecution(databaseUuid, backupUuid, executionUuid) { return this.request(`/databases/${databaseUuid}/backups/${backupUuid}/executions/${executionUuid}`); } async createDatabaseBackup(databaseUuid, data) { return this.request(`/databases/${databaseUuid}/backups`, { method: 'POST', body: JSON.stringify(data), }); } async updateDatabaseBackup(databaseUuid, backupUuid, data) { return this.request(`/databases/${databaseUuid}/backups/${backupUuid}`, { method: 'PATCH', body: JSON.stringify(data), }); } async deleteDatabaseBackup(databaseUuid, backupUuid) { return this.request(`/databases/${databaseUuid}/backups/${backupUuid}`, { method: 'DELETE', }); } // =========================================================================== // Deployment Control endpoints // =========================================================================== async cancelDeployment(uuid) { return this.request(`/deployments/${uuid}/cancel`, { method: 'POST', }); } // =========================================================================== // Smart Lookup Helpers // =========================================================================== /** * Check if a string looks like a UUID (Coolify format or standard format). * Coolify UUIDs are alphanumeric strings, typically 24 chars like "xs0sgs4gog044s4k4c88kgsc" * Also accepts standard UUID format with hyphens like "a1b2c3d4-e5f6-7890-abcd-ef1234567890" */ isLikelyUuid(query) { // Coolify UUID format: alphanumeric, 20+ chars if (/^[a-z0-9]{20,}$/i.test(query)) { return true; } // Standard UUID format with hyphens (8-4-4-4-12) if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(query)) { return true; } return false; } /** * Find an application by UUID, name, or domain (FQDN). * Returns the UUID if found, throws if not found or multiple matches. */ async resolveApplicationUuid(query) { // If it looks like a UUID, use it directly if (this.isLikelyUuid(query)) { return query; } // Otherwise, search by name or domain const apps = (await this.listApplications()); const queryLower = query.toLowerCase(); const matches = apps.filter((app) => { const nameMatch = app.name?.toLowerCase().includes(queryLower); const fqdnMatch = app.fqdn?.toLowerCase().includes(queryLower); return nameMatch || fqdnMatch; }); if (matches.length === 0) { throw new Error(`No application found matching "${query}"`); } if (matches.length > 1) { const matchList = matches.map((a) => `${a.name} (${a.fqdn || 'no domain'})`).join(', '); throw new Error(`Multiple applications match "${query}": ${matchList}. Please be more specific or use a UUID.`); } return matches[0].uuid; } /** * Find a server by UUID, name, or IP address. * Returns the UUID if found, throws if not found or multiple matches. */ async resolveServerUuid(query) { // If it looks like a UUID, use it directly if (this.isLikelyUuid(query)) { return query; } // Otherwise, search by name or IP const servers = (await this.listServers()); const queryLower = query.toLowerCase(); const matches = servers.filter((server) => { const nameMatch = server.name?.toLowerCase().includes(queryLower); const ipMatch = server.ip?.toLowerCase().includes(queryLower); return nameMatch || ipMatch; }); if (matches.length === 0) { throw new Error(`No server found matching "${query}"`); } if (matches.length > 1) { const matchList = matches.map((s) => `${s.name} (${s.ip})`).join(', '); throw new Error(`Multiple servers match "${query}": ${matchList}. Please be more specific or use a UUID.`); } return matches[0].uuid; } // =========================================================================== // Diagnostic endpoints (composite tools) // =========================================================================== /** * Get comprehensive diagnostic info for an application. * Aggregates: application details, logs, env vars, recent deployments. * @param query - Application UUID, name, or domain (FQDN) */ async diagnoseApplication(query) { // Resolve query to UUID let uuid; try { uuid = await this.resolveApplicationUuid(query); } catch (error) { const msg = error instanceof Error ? error.message : String(error); return { application: null, health: { status: 'unknown', issues: [] }, logs: null, environment_variables: { count: 0, variables: [] }, recent_deployments: [], errors: [msg], }; } const results = await Promise.allSettled([ this.getApplication(uuid), this.getApplicationLogs(uuid, 50), this.listApplicationEnvVars(uuid), this.listApplicationDeployments(uuid), ]); const errors = []; const extract = (result, name) => { if (result.status === 'fulfilled') return result.value; const msg = result.reason instanceof Error ? result.reason.message : String(result.reason); errors.push(`${name}: ${msg}`); return null; }; const app = extract(results[0], 'application'); const logs = extract(results[1], 'logs'); const envVars = extract(results[2], 'environment_variables'); const deployments = extract(results[3], 'deployments'); // Determine health status and issues const issues = []; let healthStatus = 'unknown'; if (app) { const status = app.status || ''; if (status.includes('running') && status.includes('healthy')) { healthStatus = 'healthy'; } else if (status.includes('exited') || status.includes('unhealthy') || status.includes('error')) { healthStatus = 'unhealthy'; issues.push(`Status: ${status}`); } else if (status.includes('running')) { healthStatus = 'healthy'; } else { issues.push(`Status: ${status}`); } } // Check for failed deployments if (deployments) { const recentFailed = deployments.slice(0, 5).filter((d) => d.status === 'failed'); if (recentFailed.length > 0) { issues.push(`${recentFailed.length} failed deployment(s) in last 5`); if (healthStatus === 'healthy') healthStatus = 'unhealthy'; } } return { application: app ? { uuid: app.uuid, name: app.name, status: app.status || 'unknown', fqdn: app.fqdn || null, git_repository: app.git_repository || null, git_branch: app.git_branch || null, } : null, health: { status: healthStatus, issues, }, logs: typeof logs === 'string' ? logs : null, environment_variables: { count: envVars?.length || 0, variables: (envVars || []).map((v) => ({ key: v.key, is_build_time: v.is_build_time ?? false, })), }, recent_deployments: (deployments || []).slice(0, 5).map((d) => ({ uuid: d.uuid, status: d.status, created_at: d.created_at, })), ...(errors.length > 0 && { errors }), }; } /** * Get comprehensive diagnostic info for a server. * Aggregates: server details, resources, domains, validation. * @param query - Server UUID, name, or IP address */ async diagnoseServer(query) { // Resolve query to UUID let uuid; try { uuid = await this.resolveServerUuid(query); } catch (error) { const msg = error instanceof Error ? error.message : String(error); return { server: null, health: { status: 'unknown', issues: [] }, resources: [], domains: [], validation: null, errors: [msg], }; } const results = await Promise.allSettled([ this.getServer(uuid), this.getServerResources(uuid), this.getServerDomains(uuid), this.validateServer(uuid), ]); const errors = []; const extract = (result, name) => { if (result.status === 'fulfilled') return result.value; const msg = result.reason instanceof Error ? result.reason.message : String(result.reason); errors.push(`${name}: ${msg}`); return null; }; const server = extract(results[0], 'server'); const resources = extract(results[1], 'resources'); const domains = extract(results[2], 'domains'); const validation = extract(results[3], 'validation'); // Determine health status and issues const issues = []; let healthStatus = 'unknown'; if (server) { if (server.is_reachable === true) { healthStatus = 'healthy'; } else if (server.is_reachable === false) { healthStatus = 'unhealthy'; issues.push('Server is not reachable'); } if (server.is_usable === false) { issues.push('Server is not usable'); healthStatus = 'unhealthy'; } } // Check for unhealthy resources if (resources) { const unhealthyResources = resources.filter((r) => r.status.includes('exited') || r.status.includes('unhealthy') || r.status.includes('error')); if (unhealthyResources.length > 0) { issues.push(`${unhealthyResources.length} unhealthy resource(s)`); } } return { server: server ? { uuid: server.uuid, name: server.name, ip: server.ip, status: server.status || null, is_reachable: server.is_reachable ?? null, } : null, health: { status: healthStatus, issues, }, resources: (resources || []).map((r) => ({ uuid: r.uuid, name: r.name, type: r.type, status: r.status, })), domains: (domains || []).map((d) => ({ ip: d.ip, domains: d.domains, })), validation: validation ? { message: validation.message, ...(validation.validation_logs && { validation_logs: validation.validation_logs }), } : null, ...(errors.length > 0 && { errors }), }; } /** * Scan infrastructure for common issues. * Finds: unreachable servers, unhealthy apps, exited databases, stopped services. */ async findInfrastructureIssues() { const results = await Promise.allSettled([ this.listServers(), this.listApplications(), this.listDatabases(), this.listServices(), ]); const errors = []; const issues = []; const extract = (result, name) => { if (result.status === 'fulfilled') return result.value; const msg = result.reason instanceof Error ? result.reason.message : String(result.reason); errors.push(`${name}: ${msg}`); return null; }; const servers = extract(results[0], 'servers'); const applications = extract(results[1], 'applications'); const databases = extract(results[2], 'databases'); const services = extract(results[3], 'services'); // Check servers for unreachable if (servers) { for (const server of servers) { if (server.is_reachable === false) { issues.push({ type: 'server', uuid: server.uuid, name: server.name, issue: 'Server is not reachable', status: server.status || 'unreachable', }); } } } // Check applications for unhealthy status if (applications) { for (const app of applications) { const status = app.status || ''; if (status.includes('exited') || status.includes('unhealthy') || status.includes('error') || status === 'stopped') { issues.push({ type: 'application', uuid: app.uuid, name: app.name, issue: `Application status: ${status}`, status, }); } } } // Check databases for unhealthy status if (databases) { for (const db of databases) { const status = db.status || ''; if (status.includes('exited') || status.includes('unhealthy') || status.includes('error') || status === 'stopped') { issues.push({ type: 'database', uuid: db.uuid, name: db.name, issue: `Database status: ${status}`, status, }); } } } // Check services for unhealthy status if (services) { for (const svc of services) { const status = svc.status || ''; if (status.includes('exited') || status.includes('unhealthy') || status.includes('error') || status === 'stopped') { issues.push({ type: 'service', uuid: svc.uuid, name: svc.name, issue: `Service status: ${status}`, status, }); } } } return { summary: { total_issues: issues.length, unhealthy_applications: issues.filter((i) => i.type === 'application').length, unhealthy_databases: issues.filter((i) => i.type === 'database').length, unhealthy_services: issues.filter((i) => i.type === 'service').length, unreachable_servers: issues.filter((i) => i.type === 'server').length, }, issues, ...(errors.length > 0 && { errors }), }; } // =========================================================================== // Batch Operations // =========================================================================== /** * Aggregate results from Promise.allSettled into a BatchOperationResult. */ aggregateBatchResults(resources, results) { const succeeded = []; const failed = []; results.forEach((result, index) => { const resource = resources[index]; const name = resource.name || resource.uuid; if (result.status === 'fulfilled') { succeeded.push({ uuid: resource.uuid, name }); } else { const error = result.reason instanceof Error ? result.reason.message : String(result.reason); failed.push({ uuid: resource.uuid, name, error }); } }); return { summary: { total: resources.length, succeeded: succeeded.length, failed: failed.length, }, succeeded, failed, }; } /** * Restart all applications in a project. * @param projectUuid - Project UUID */ async restartProjectApps(projectUuid) { const allApps = (await this.listApplications()); const projectApps = allApps.filter((app) => app.project_uuid === projectUuid); if (projectApps.length === 0) { return { summary: { total: 0, succeeded: 0, failed: 0 }, succeeded: [], failed: [], }; } const results = await Promise.allSettled(projectApps.map((app) => this.restartApplication(app.uuid))); return this.aggregateBatchResults(projectApps, results); } /** * Update or create an environment variable across multiple applications. * Uses upsert behavior: creates if not exists, updates if exists. * @param appUuids - Array of application UUIDs * @param key - Environment variable key * @param value - Environment variable value * @param isBuildTime - Whether this is a build-time variable (default: false) */ async bulkEnvUpdate(appUuids, key, value, isBuildTime = false) { // Early return for empty array - avoid unnecessary API call if (appUuids.length === 0) { return { summary: { total: 0, succeeded: 0, failed: 0 }, succeeded: [], failed: [], }; } // Get app names first for better response const allApps = (await this.listApplications()); const appMap = new Map(allApps.map((a) => [a.uuid, a.name || a.uuid])); // Build the resource list with names const resources = appUuids.map((uuid) => ({ uuid, name: appMap.get(uuid) || uuid, })); const results = await Promise.allSettled(appUuids.map((uuid) => this.updateApplicationEnvVar(uuid, { key, value, is_build_time: isBuildTime }))); return this.aggregateBatchResults(resources, results); } /** * Emergency stop all running applications across entire infrastructure. */ async stopAllApps() { const allApps = (await this.listApplications()); // Only stop running apps const runningApps = allApps.filter((app) => { const status = app.status || ''; return status.includes('running') || status.includes('healthy'); }); if (runningApps.length === 0) { return { summary: { total: 0, succeeded: 0, failed: 0 }, succeeded: [], failed: [], }; } const results = await Promise.allSettled(runningApps.map((app) => this.stopApplication(app.uuid))); return this.aggregateBatchResults(runningApps, results); } /** * Redeploy all applications in a project. * @param projectUuid - Project UUID * @param force - Force rebuild (default: true) */ async redeployProjectApps(projectUuid, force = true) { const allApps = (await this.listApplications()); const projectApps = allApps.filter((app) => app.project_uuid === projectUuid); if (projectApps.length === 0) { return { summary: { total: 0, succeeded: 0, failed: 0 }, succeeded: [], failed: [], }; } const results = await Promise.allSettled(projectApps.map((app) => this.deployByTagOrUuid(app.uuid, force))); return this.aggregateBatchResults(projectApps, results); } }