import "server-only"; import yaml from "js-yaml"; import { logServerAction } from "@/lib/server/logging/logger"; export type ParsedComposeService = { image?: string; ports?: string[]; environment?: Record; containerName?: string; volumes?: string[]; networkMode?: string; restart?: string; privileged?: boolean; capAdd?: string[]; hostname?: string; devices?: string[]; command?: string | string[]; }; export type ParsedComposeFile = { services: Record; }; export type PrimaryComposeService = { name: string; service: ParsedComposeService; }; const INFRA_SERVICE_TOKEN_PATTERN = /(?:^|[-_.])(db|database|postgres|postgresql|mariadb|mysql|redis|cache|broker|queue|rabbitmq|kafka|zookeeper|mongo|mongodb|elasticsearch|minio)(?:$|[-_.])/i; const APP_ALIAS_PATTERN = /^(app|main|server|web|frontend|backend)$/i; function hasPublishedPorts(service: ParsedComposeService): boolean { return Array.isArray(service.ports) && service.ports.length > 0; } function scorePrimaryServiceCandidate(input: { name: string; service: ParsedComposeService; appId?: string; }): number { const name = input.name.toLowerCase(); const appId = input.appId?.toLowerCase().trim(); const image = typeof input.service.image === "string" ? input.service.image.toLowerCase() : ""; let score = 0; if (hasPublishedPorts(input.service)) { score += 40; } if (name === "app") { score += 60; } else if (APP_ALIAS_PATTERN.test(name)) { score += 30; } if (name.includes("server")) { score += 20; } if (appId) { if (name === appId) { score += 100; } else if (name.startsWith(`${appId}-`) || name.endsWith(`-${appId}`)) { score += 45; } else if (name.includes(appId)) { score += 25; } if (image.includes(appId)) { score += 20; } } if (INFRA_SERVICE_TOKEN_PATTERN.test(name)) { score -= 80; } return score; } /** * Parse docker-compose YAML content */ export function parseComposeFile(content: string): ParsedComposeFile | null { try { const parsed = yaml.load(content) as Record; if (!parsed || typeof parsed !== "object" || !parsed.services) { return null; } const services: Record = {}; for (const [serviceName, serviceConfig] of Object.entries(parsed.services as Record || {})) { const config = serviceConfig as Record; // Parse ports const ports: string[] = []; if (Array.isArray(config.ports)) { for (const port of config.ports) { if (typeof port === "string") { ports.push(port); } else if (typeof port === "object" && port.target) { ports.push(`${port.published || port.target}:${port.target}`); } } } // Parse environment const environment: Record = {}; if (Array.isArray(config.environment)) { for (const env of config.environment) { if (typeof env === "string") { const [key, ...valueParts] = env.split("="); if (key) { environment[key] = valueParts.join("=") || ""; } } } } else if (config.environment && typeof config.environment === "object") { Object.assign(environment, config.environment); } // Parse volumes const volumes: string[] = []; if (Array.isArray(config.volumes)) { for (const volume of config.volumes) { if (typeof volume === "string") { volumes.push(volume); } else if (typeof volume === "object" && volume.source && volume.target) { volumes.push(`${volume.source}:${volume.target}`); } } } // Parse cap_add const capAdd: string[] = []; if (Array.isArray(config.cap_add)) { capAdd.push(...(config.cap_add as unknown[]).filter((c): c is string => typeof c === "string")); } // Parse devices const devices: string[] = []; if (Array.isArray(config.devices)) { devices.push(...(config.devices as unknown[]).filter((d): d is string => typeof d === "string")); } const str = (v: unknown) => (typeof v === "string" ? v : undefined); const networkMode = str(config.network_mode) ?? str(config.networkMode); const command = Array.isArray(config.command) ? (config.command as unknown[]).filter((c): c is string => typeof c === "string") : str(config.command); services[serviceName] = { image: str(config.image), ports, environment, containerName: str(config.container_name), volumes, networkMode, restart: str(config.restart), privileged: Boolean(config.privileged), capAdd, hostname: str(config.hostname), devices, command, }; } return { services }; } catch (error) { logServerAction({ level: "error", layer: "service", action: "store.compose.parse", status: "error", message: "Failed to parse compose file", error, }); return null; } } /** * Extract the primary service from a parsed compose file * (usually the first service, or the one matching the app name) */ export function extractPrimaryServiceWithName( parsed: ParsedComposeFile, appId?: string, ): PrimaryComposeService | null { const serviceNames = Object.keys(parsed.services); if (serviceNames.length === 0) { return null; } // First prefer an exact service-name match. if (appId) { const normalizedAppId = appId.toLowerCase().trim(); const exactMatch = serviceNames.find((name) => name.toLowerCase() === normalizedAppId); if (exactMatch) { return { name: exactMatch, service: parsed.services[exactMatch], }; } } const rankedServices = serviceNames .map((name, index) => ({ name, index, score: scorePrimaryServiceCandidate({ name, service: parsed.services[name], appId, }), })) .sort((a, b) => b.score - a.score || a.index - b.index); const fallbackName = rankedServices[0]?.name ?? serviceNames[0]; return { name: fallbackName, service: parsed.services[fallbackName], }; } export function extractPrimaryService( parsed: ParsedComposeFile, appId?: string, ): ParsedComposeService | null { return extractPrimaryServiceWithName(parsed, appId)?.service ?? null; }