UNPKG

swarm-pilot

Version:
321 lines (277 loc) 15.7 kB
import { Effect, Schema } from "effect"; import { Config } from "./config.js"; import { execSync, exec as execCallback } from "child_process"; import { AppError, ScaleError } from "./error.js"; import { ServicePS, ServiceList, prometheus_response_schema, Metrics, Usage, DockerNodeList, ManagerStatus, docker_node_list_schema, Scaling } from "./schemas/index.js"; import { current_state_from_string, desired_state_from_string, parse_error } from "./helpers.js"; import axios from "axios"; import { docker_serviceSchema } from "./schemas/service.js"; import { promisify } from 'util'; import { docker_node_schema } from "./schemas/node.js"; import { default_docker_retry } from "./policy.js"; import { notify } from "./notify.js"; const exec = promisify(execCallback); export const get_services = () => Effect.gen(function* () { const format = "{{.ID}}\\t{{.Name}}\\t{{.Mode}}\\t{{.Replicas}}\\t{{.Image}}\\t{{.Ports}}"; const command = `docker service ls --format "${format}"`; const res =yield* Effect.retry(Effect.tryPromise({ try: () => exec(command, { encoding: 'utf8'}), catch: (error) => new AppError({cause: parse_error(error), message: "Error getting services"}) }), default_docker_retry); if (res.stderr) { yield* Effect.logError(`Command "${command}" produced stderr: ${res.stderr}`); } const services: ServiceList = res.stdout .trim() .split('\n') // Split into lines .filter(line => { // Filter out empty or potentially malformed lines early if (!line) return false; const partCount = line.split('\t').length; if (partCount !== 6) { Effect.logWarning(`Skipping malformed line (expected 6 parts, got ${partCount}): ${line}`); return false; } return true; }) .map(line => { const parts = line.split('\t'); const replicas = parts[3].split('/'); // Split replicas by '/' return { id: parts[0] || '', name: parts[1] || '', mode: parts[2] === 'replicated' ? 'replicated' : 'global', replicas_running: parseInt(replicas[0]) || 0, replicas_target: parseInt(replicas[1]) || 0, image: parts[4] || '', ports: parts[5] || '', }; }); yield* Effect.forEach(services, (s, index) => Effect.logDebug(`Service ${index}: ${s.id} - ${s.name} - ${s.mode} - ${s.replicas_running} - ${s.replicas_target} - ${s.image} - ${s.ports}`) ); return services; }) export const get_service_tasks = (serviceIdOrName: string) => Effect.gen(function* () { const format = "{{.ID}}\\t{{.Name}}\\t{{.Image}}\\t{{.Node}}\\t{{.DesiredState}}\\t{{.CurrentState}}\\t{{.Error}}"; const command = `docker service ps --format "${format}" --no-trunc "${serviceIdOrName}"`; // Execute the command const res = yield* Effect.tryPromise({ try: () => exec(command, { encoding: 'utf8' }), catch: (error) => new AppError({ cause: parse_error(error), message: `Failed to execute docker ps for service "${serviceIdOrName}"` }), }); if (res.stderr) { yield* Effect.logError(`Command "${command}" produced stderr: ${res.stderr}`); } // Process the output const lines = res.stdout.trim().split('\n'); const tasks: ServicePS[] = []; for (const [index, line] of lines.entries()) { if (!line) { yield* Effect.logDebug(`Skipping empty line ${index + 1} for service ${serviceIdOrName}.`); continue; } const parts = line.split('\t'); const current_state = parts[5].split(' ', 1); const current_state_since = current_state.length > 1 ? parts[5].substring(current_state[0].length + 1) : ""; // Need substring because split limit doesn't give the rest automatically in JS like Python const taskData: ServicePS = { id: parts[0] || '', name: parts[1] || '', image: parts[2] || '', node: parts[3] || '', desired_state: desired_state_from_string(parts[4]), current_state: current_state_from_string(current_state[0]), current_state_since: current_state_since || '', error: parts[6], }; tasks.push(taskData); } // Optional: Detailed logging for each parsed task yield* Effect.forEach(tasks, (t, index) => Effect.logDebug(`Parsed Task ${index}: ${t.id} - ${t.name} on ${t.node} - Desired State: ${t.desired_state} - State: ${t.current_state} since: ${t.current_state_since} - ${t.error ? `(Error: ${t.error})` : ''}`)); // Added concurrency option as example return tasks; }); export const get_metrics = (time: number) => { return Effect.gen(function* () { const config = yield* Config; const { prom_host, prom_user, prom_pass, prom_schema, prom_path } = config.config; const cpu_query=`sum(rate(container_cpu_usage_seconds_total{container_label_com_docker_swarm_task_name=~'.+'}[${time}s]))BY(container_label_com_docker_swarm_service_name,id,container_label_com_docker_swarm_task_id,instance)*100` const memory_query=`sum(avg_over_time(container_memory_working_set_bytes{container_label_com_docker_swarm_task_name=~'.+'}[${time}s])) BY (container_label_com_docker_swarm_service_name,id,container_label_com_docker_swarm_task_id,instance)` const client = axios.create({ baseURL: `${prom_schema}://${prom_host}`, headers: { 'Content-Type': 'application/json', }, auth: { username: prom_user, password: prom_pass, }, }) const cpu_response = yield* Effect.tryPromise({ try: () => client.get(`/api/v1/query?query=${encodeURIComponent(cpu_query)}`), catch: (error) => new AppError({ cause: parse_error(error), message: "Error getting CPU metrics" }) }) const memory_response = yield* Effect.tryPromise({ try: () => client.get(`/api/v1/query?query=${encodeURIComponent(memory_query)}`), catch: (error) => new AppError({ cause: parse_error(error), message: "Error getting Memory metrics" }) }) const cpu_usage = yield* Schema.decodeUnknown(prometheus_response_schema)(cpu_response.data); const memory_usage = yield* Schema.decodeUnknown(prometheus_response_schema)(memory_response.data); yield* Effect.logDebug(`CPU Response: ${JSON.stringify(cpu_usage, null, 2)}`); yield* Effect.logDebug(`Memory Response: ${JSON.stringify(memory_usage, null, 2)}`); const usages: Usage[] = cpu_usage.data.result.map((cpu_m: Metrics) => { const service = cpu_m.metric.container_label_com_docker_swarm_service_name; const cu = cpu_m.value[1]; const mu = memory_usage.data.result.find((mem_m: Metrics) => { return mem_m.metric.container_label_com_docker_swarm_service_name === service; })?.value[1] || 0; const usage: Usage = { service_name: service, cpu_usage: Number(cu), memory_usage: Number(mu), task_id: cpu_m.metric.container_label_com_docker_swarm_task_id, instance: cpu_m.metric.instance, }; return usage; }); yield* Effect.logInfo(`Successfully parsed ${usages.length} metrics.`); yield* Effect.forEach(usages, (u, index) => Effect.logDebug(`Metric ${index}: ${u.service_name} - CPU Usage: ${u.cpu_usage} - Memory Usage: ${u.memory_usage}`)); if (usages.length === 0) return yield* Effect.fail(new AppError({ message: "No metrics found", cause: new Error("The usage array is empty!") })); return usages; }) } export const inspect_service = (serviceNameOrID: string) => { return Effect.gen(function* () { const format = "{{json .}}"; const command = `docker service inspect --format "${format}" "${serviceNameOrID}"`; const res = yield* Effect.retry(Effect.tryPromise({ try: () => exec(command, { encoding: 'utf8' }), catch: (error) => new AppError({ cause: parse_error(error), message: `Failed to execute docker inspect for service "${serviceNameOrID}"` }) }), default_docker_retry); if (res.stderr) { yield* Effect.logError(`Command "${command}" produced stderr: ${res.stderr}`); } const info = yield* Schema.decodeUnknown(docker_serviceSchema)(JSON.parse(res.stdout)); yield* Effect.logDebug(`Service ${serviceNameOrID} details: ${JSON.stringify(info.Spec?.TaskTemplate?.Resources, null, 2)}`); return info; }); } export const get_nodes = () => Effect.gen(function* () { yield* Effect.logInfo("Fetching list of Docker Swarm nodes."); // Define the format string for docker node ls // Using tab separation. Includes {{.Self}} to check if it's the current node. const format = "{{.Self}}\\t{{.ID}}\\t{{.Hostname}}\\t{{.Status}}\\t{{.Availability}}\\t{{.ManagerStatus}}\\t{{.EngineVersion}}"; const numberOfFields = 7; // Number of fields in the format string const command = `docker node ls --format "${format}"`; // Execute the command asynchronously const res = yield* Effect.tryPromise({ try: () => exec(command, { encoding: 'utf8' }), catch: (error) => new AppError({ cause: parse_error(error), message: "Failed to execute docker node ls" }) }); if (res.stderr) { yield* Effect.logError(`Command "${command}" produced stderr: ${res.stderr}`); } // Process the output const lines = res.stdout.trim().split('\n'); const nodes: DockerNodeList[] = []; yield* Effect.logDebug(`Raw output lines from docker node ls: ${lines.length}`); for (const [index, line] of lines.entries()) { // Skip empty lines if (!line) { yield* Effect.logDebug(`Skipping empty line ${index + 1}.`); continue; } const parts = line.split('\t'); if (parts.length !== numberOfFields) { yield* Effect.logWarning(`Skipping malformed line ${index + 1} (expected ${numberOfFields} parts, got ${parts.length}): "${line}"`); continue; // Skip this line } // --- Data Mapping and Transformation --- const rawIsSelf = parts[0]; // "true" or "false" const rawId = parts[1]; const rawHostname = parts[2]; const rawStatus = parts[3]; // e.g., "Ready" const rawAvailability = parts[4]; // e.g., "Active" const rawManagerStatus = parts[5]; // e.g., "Leader", "Reachable", "" (empty for worker) const rawEngineVersion = parts[6]; // Map raw manager status to enum value let mappedManagerStatus: ManagerStatus; switch (rawManagerStatus) { case 'Leader': mappedManagerStatus = 'leader'; break; case 'Reachable': mappedManagerStatus = 'reachable'; break; case 'Unreachable': mappedManagerStatus = 'unavailable'; break; case '': mappedManagerStatus = 'worker'; break; default: // Log unexpected status but treat as worker for safety yield* Effect.logWarning(`Unexpected Manager Status encountered: "${rawManagerStatus}" in line: "${line}". Treating as 'worker'.`); mappedManagerStatus = 'worker'; break; } // Prepare raw data object for schema decoding const rawNodeData = { is_self: rawIsSelf === 'true', // Convert string "true" to boolean id: rawId, hostname: rawHostname, status: rawStatus.toLowerCase(), // Convert to lower case to match enum availability: rawAvailability.toLowerCase(), // Convert to lower case to match enum manager_status: mappedManagerStatus, // Use the mapped enum value engine_version: rawEngineVersion, }; // Decode (and validate) the raw data using the schema nodes.push(yield* Schema.decodeUnknown(docker_node_list_schema)(rawNodeData)); } yield* Effect.logInfo(`Successfully parsed and validated ${nodes.length} nodes.`); // Optional: Detailed logging for each parsed node yield* Effect.forEach(nodes, (n, index) => Effect.logDebug(`Node ${index}: ${n.id} - ${n.hostname} (${n.status}) - Manager: ${n.manager_status} ${n.is_self ? '(Self)' : ''}`) , { concurrency: "inherit" }); return nodes; }); export const inspect_node = (nodeNameOrID: string) => { return Effect.gen(function* () { const format = "{{json .}}"; const command = `docker node inspect --format "${format}" "${nodeNameOrID}"`; const stdout = yield* Effect.tryPromise({ try: () => exec(command, { encoding: 'utf8' }), catch: (error) => new AppError({ cause: parse_error(error), message: `Failed to execute docker inspect for node "${nodeNameOrID}"` }) }); const info = yield* Schema.decodeUnknown(docker_node_schema)(JSON.parse(stdout.stdout)); yield* Effect.logInfo(`Successfully parsed node ${nodeNameOrID} details.`); yield* Effect.logDebug(`Node ${nodeNameOrID} details: ${JSON.stringify(info, null, 2)}`); return info; }) } export const scale_service = (scaling: Scaling) => { return Effect.gen(function* () { const scale_type = scaling.from > scaling.to ? 'down' : 'up'; yield* Effect.logInfo(`Scaling service ${scaling.service_name} from ${scaling.from} to ${scaling.to} replicas.`); const command = `docker service scale ${scaling.service_name}=${scaling.to}`; const res = yield* Effect.retry(Effect.tryPromise({ try: () => exec(command, { encoding: 'utf8' }), catch: (error) => new ScaleError({ cause: parse_error(error), message: `❌ Failed to scale service "${scaling.service_name}"` }) }), default_docker_retry); Effect.logError('Command error: ',res.stderr); yield* notify(`✅ Successfully scaled service ${scaling.service_name} from ${scaling.from} to ${scaling.to} replicas.`); yield* Effect.logInfo(`Successfully scaled service ${scaling.service_name} from ${scaling.from} to ${scaling.to} replicas.`); }); } export const rebalance_cluster = () => { return Effect.gen(function* () { const command = `docker node update --availability drain $(docker node ls -q) && docker node update --availability active $(docker node ls -q)`; const res = yield* Effect.tryPromise({ try: () => exec(command, { encoding: 'utf8' }), catch: (error) => new AppError({ cause: parse_error(error), message: "Failed to rebalance cluster" }) }); Effect.logError('Command error: ',res.stderr); yield* Effect.logInfo(`Successfully rebalanced cluster.`); }); }