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:
parent
465775ec99
commit
5c884d9066
|
|
@ -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<boolean> {
|
||||
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<RegisteredService[]> {
|
||||
// Implementação inicial - retorna array vazio
|
||||
// Depois vamos integrar com a API real do Coolify
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Reference in New Issue