diff --git a/server/kernel/discovery/CoolifyDiscovery.ts b/server/kernel/discovery/CoolifyDiscovery.ts index eb63924..7ab3de3 100644 --- a/server/kernel/discovery/CoolifyDiscovery.ts +++ b/server/kernel/discovery/CoolifyDiscovery.ts @@ -1,9 +1,14 @@ /** * Coolify Discovery Provider * Descobre serviços via API do Coolify + * + * Endpoints utilizados: + * - GET /api/v1/services - Lista todos os serviços (Docker Compose) + * - GET /api/v1/applications - Lista todas as aplicações + * - GET /api/v1/resources - Lista todos os recursos (unificado) */ -import { DiscoveryProvider, RegisteredService, ServiceCategory } from '../registry/types'; +import { DiscoveryProvider, RegisteredService, ServiceCategory, ServiceCapability } from '../registry/types'; import { HealthStatus } from '../types'; export interface CoolifyDiscoveryOptions { @@ -11,6 +16,60 @@ export interface CoolifyDiscoveryOptions { token: string; } +// Resposta da API Coolify para Services +interface CoolifyService { + uuid: string; + name: string; + description?: string; + status?: string; + health_status?: string; + created_at?: string; + updated_at?: string; + environment?: { + uuid?: string; + name?: string; + }; + server?: { + uuid?: string; + name?: string; + }; + // Docker Compose specific + compose_parsing_error?: string; + custom_labels?: string; + // Status + is_online?: boolean; +} + +// Resposta da API Coolify para Applications +interface CoolifyApplication { + uuid: string; + name: string; + status?: string; + health_status?: string; + ports_exposes?: string; + custom_labels?: string; + description?: string; + environment?: { + uuid?: string; + name?: string; + }; + server?: { + uuid?: string; + name?: string; + }; +} + +// Resposta da API Coolify para Resources (unificado) +interface CoolifyResource { + uuid: string; + name: string; + type: 'service' | 'application'; + status?: string; + health_status?: string; + created_at?: string; + updated_at?: string; +} + export class CoolifyDiscovery implements DiscoveryProvider { public readonly name = 'CoolifyDiscovery'; private options: CoolifyDiscoveryOptions; @@ -24,7 +83,7 @@ export class CoolifyDiscovery implements DiscoveryProvider { async isAvailable(): Promise { try { - const response = await fetch(`${this.options.baseUrl}/api/v1/health`, { + const response = await fetch(`${this.options.baseUrl}/api/v1/version`, { headers: { 'Authorization': `Bearer ${this.options.token}` }, }); return response.ok; @@ -34,8 +93,256 @@ export class CoolifyDiscovery implements DiscoveryProvider { } async discover(): Promise { - // Implementação inicial - retorna array vazio - // Depois vamos integrar com a API real do Coolify - return []; + const services: RegisteredService[] = []; + + try { + // Busca serviços (Docker Compose) + const coolifyServices = await this.fetchServices(); + for (const svc of coolifyServices) { + const mapped = this.mapServiceToRegisteredService(svc); + if (mapped) services.push(mapped); + } + + // Busca aplicações + const coolifyApps = await this.fetchApplications(); + for (const app of coolifyApps) { + const mapped = this.mapApplicationToRegisteredService(app); + if (mapped) services.push(mapped); + } + + console.log(`[CoolifyDiscovery] Descobertos ${services.length} serviços`); + return services; + } catch (error) { + console.error('[CoolifyDiscovery] Erro ao descobrir serviços:', error); + return []; + } + } + + private async fetchServices(): Promise { + try { + const response = await fetch(`${this.options.baseUrl}/api/v1/services`, { + headers: { + 'Authorization': `Bearer ${this.options.token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + console.warn(`[CoolifyDiscovery] API retornou ${response.status} para /services`); + return []; + } + + const data = await response.json(); + return Array.isArray(data) ? data : []; + } catch (error) { + console.error('[CoolifyDiscovery] Erro ao buscar services:', error); + return []; + } + } + + private async fetchApplications(): Promise { + try { + const response = await fetch(`${this.options.baseUrl}/api/v1/applications`, { + headers: { + 'Authorization': `Bearer ${this.options.token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + console.warn(`[CoolifyDiscovery] API retornou ${response.status} para /applications`); + return []; + } + + const data = await response.json(); + return Array.isArray(data) ? data : []; + } catch (error) { + console.error('[CoolifyDiscovery] Erro ao buscar applications:', error); + return []; + } + } + + private mapServiceToRegisteredService(svc: CoolifyService): RegisteredService | null { + // Parse custom labels se existirem + const labels = this.parseLabels(svc.custom_labels); + + // Só registra se tiver o label arcadia.discovery.enabled=true + if (labels['arcadia.discovery.enabled'] !== 'true') { + return null; + } + + const id = labels['arcadia.id'] || `coolify-service-${svc.uuid}`; + const name = labels['arcadia.name'] || svc.name; + const type = labels['arcadia.type'] || 'docker'; + const category = (labels['arcadia.category'] as ServiceCategory) || 'core'; + const port = parseInt(labels['arcadia.port'] || '80', 10); + + // Determina health status + let healthStatus: HealthStatus = 'unknown'; + if (svc.health_status === 'healthy' || svc.is_online) { + healthStatus = 'healthy'; + } else if (svc.health_status === 'unhealthy') { + healthStatus = 'unhealthy'; + } + + // Parse capabilities + const capabilities: ServiceCapability[] = []; + if (labels['arcadia.capabilities']) { + const caps = labels['arcadia.capabilities'].split(','); + for (const cap of caps) { + capabilities.push({ + name: cap.trim(), + version: '1.0', + endpoints: [], + }); + } + } + + // Determina o host baseado no nome do serviço + // Coolify cria containers com nomes como: {project}-{service}-{random} + const host = this.inferHostFromName(svc.name); + + return { + id, + name: svc.name, + displayName: name, + type: type as any, + category, + description: svc.description || labels['arcadia.description'] || `Serviço Coolify: ${svc.name}`, + host, + port, + endpoint: `http://${host}:${port}`, + healthCheckPath: labels['arcadia.healthPath'] || '/health', + healthStatus, + lastHealthCheck: new Date(), + capabilities, + requiresIA: labels['arcadia.requires_ia'] === 'true', + iaPolicies: labels['arcadia.ia_policies']?.split(',') || [], + metadata: { + source: 'docker' as const, // Usamos 'docker' como source para compatibilidade + discoveredAt: new Date(), + lastUpdated: new Date(), + labels, + dockerContainerId: undefined, // Coolify não expõe isso diretamente + dockerImage: undefined, + }, + enabled: true, + version: labels['arcadia.version'] || '1.0.0', + }; + } + + private mapApplicationToRegisteredService(app: CoolifyApplication): RegisteredService | null { + // Parse custom labels se existirem + const labels = this.parseLabels(app.custom_labels); + + // Só registra se tiver o label arcadia.discovery.enabled=true + if (labels['arcadia.discovery.enabled'] !== 'true') { + return null; + } + + const id = labels['arcadia.id'] || `coolify-app-${app.uuid}`; + const name = labels['arcadia.name'] || app.name; + const type = labels['arcadia.type'] || 'node'; + const category = (labels['arcadia.category'] as ServiceCategory) || 'core'; + + // Parse port das ports_exposes (formato: "8080:80,9000:9000") + let port = 80; + if (app.ports_exposes) { + const firstPort = app.ports_exposes.split(',')[0]; + const exposed = firstPort.split(':')[0]; + port = parseInt(exposed, 10) || 80; + } + if (labels['arcadia.port']) { + port = parseInt(labels['arcadia.port'], 10); + } + + // Determina health status + let healthStatus: HealthStatus = 'unknown'; + if (app.health_status === 'healthy') { + healthStatus = 'healthy'; + } else if (app.health_status === 'unhealthy') { + healthStatus = 'unhealthy'; + } + + // Parse capabilities + const capabilities: ServiceCapability[] = []; + if (labels['arcadia.capabilities']) { + const caps = labels['arcadia.capabilities'].split(','); + for (const cap of caps) { + capabilities.push({ + name: cap.trim(), + version: '1.0', + endpoints: [], + }); + } + } + + const host = this.inferHostFromName(app.name); + + return { + id, + name: app.name, + displayName: name, + type: type as any, + category, + description: app.description || labels['arcadia.description'] || `Aplicação Coolify: ${app.name}`, + host, + port, + endpoint: `http://${host}:${port}`, + healthCheckPath: labels['arcadia.healthPath'] || '/health', + healthStatus, + lastHealthCheck: new Date(), + capabilities, + requiresIA: labels['arcadia.requires_ia'] === 'true', + iaPolicies: labels['arcadia.ia_policies']?.split(',') || [], + metadata: { + source: 'docker' as const, + discoveredAt: new Date(), + lastUpdated: new Date(), + labels, + }, + enabled: true, + version: labels['arcadia.version'] || '1.0.0', + }; + } + + private parseLabels(labelsString?: string): Record { + if (!labelsString) return {}; + + const labels: Record = {}; + const lines = labelsString.split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const eqIndex = trimmed.indexOf('='); + if (eqIndex > 0) { + const key = trimmed.substring(0, eqIndex).trim(); + let value = trimmed.substring(eqIndex + 1).trim(); + // Remove quotes se existirem + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + labels[key] = value; + } + } + + return labels; + } + + private inferHostFromName(name: string): string { + // Coolify geralmente cria containers com nomes baseados no projeto e serviço + // Ex: arcadia-prod-metaset-1, arcadia-prod-app-1 + // Vamos tentar inferir o hostname baseado em convenções comuns + + // Se o nome já parece ser um hostname, usa ele + if (name.includes('.') || name.includes('-')) { + // Remove sufixos numéricos de containers (ex: -1, -2) + return name.replace(/-\d+$/, ''); + } + + return name; } } diff --git a/server/kernel/registry/types.ts b/server/kernel/registry/types.ts index 201d2fb..67e8e9f 100644 --- a/server/kernel/registry/types.ts +++ b/server/kernel/registry/types.ts @@ -6,7 +6,7 @@ import { HealthStatus } from '../types'; // Tipos de fonte de descoberta -export type DiscoverySource = 'docker' | 'manual' | 'xos' | 'consul' | 'kubernetes'; +export type DiscoverySource = 'docker' | 'manual' | 'xos' | 'consul' | 'kubernetes' | 'coolify'; // Categoria do serviço export type ServiceCategory =