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
|
* 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
|
|
||||||
|
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 [];
|
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';
|
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 =
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue