feat(kernel): implementa Coolify Discovery com chamadas reais à API

- Implementa CoolifyDiscovery.discover() buscando serviços e aplicações
- Adiciona endpoints /api/v1/services e /api/v1/applications
- Suporte a filtro por label arcadia.discovery.enabled=true
- Parse de labels customizadas do Coolify
- Mapeamento de health status do Coolify para Registry
- Adiciona 'coolify' ao tipo DiscoverySource

Refs: kernel-semana-4, service-discovery
This commit is contained in:
Jonas Pacheco 2026-04-08 09:41:09 -03:00
parent 465775ec99
commit 5c884d9066
2 changed files with 313 additions and 6 deletions

View File

@ -1,9 +1,14 @@
/** /**
* Coolify Discovery Provider * Coolify Discovery Provider
* Descobre serviços via API do Coolify * 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'; import { HealthStatus } from '../types';
export interface CoolifyDiscoveryOptions { export interface CoolifyDiscoveryOptions {
@ -11,6 +16,60 @@ export interface CoolifyDiscoveryOptions {
token: string; 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 { export class CoolifyDiscovery implements DiscoveryProvider {
public readonly name = 'CoolifyDiscovery'; public readonly name = 'CoolifyDiscovery';
private options: CoolifyDiscoveryOptions; private options: CoolifyDiscoveryOptions;
@ -24,7 +83,7 @@ export class CoolifyDiscovery implements DiscoveryProvider {
async isAvailable(): Promise<boolean> { async isAvailable(): Promise<boolean> {
try { 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}` }, headers: { 'Authorization': `Bearer ${this.options.token}` },
}); });
return response.ok; return response.ok;
@ -34,8 +93,256 @@ export class CoolifyDiscovery implements DiscoveryProvider {
} }
async discover(): Promise<RegisteredService[]> { async discover(): Promise<RegisteredService[]> {
// Implementação inicial - retorna array vazio const services: RegisteredService[] = [];
// Depois vamos integrar com a API real do Coolify
return []; 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<CoolifyService[]> {
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<CoolifyApplication[]> {
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<string, string> {
if (!labelsString) return {};
const labels: Record<string, string> = {};
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;
} }
} }

View File

@ -6,7 +6,7 @@
import { HealthStatus } from '../types'; import { HealthStatus } from '../types';
// Tipos de fonte de descoberta // 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 // Categoria do serviço
export type ServiceCategory = export type ServiceCategory =