#!/usr/bin/env python3 """ Service Manager for Luzia Cockpits Allows cockpits to manage project services without direct network access. Services run as project user outside the sandbox. Usage: luzia service start luzia service stop luzia service status [project] luzia service list """ import json import os import subprocess import signal from pathlib import Path from datetime import datetime from typing import Dict, List, Optional import yaml import logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # Paths SERVICES_STATE_DIR = Path("/var/lib/luz-orchestrator/services") SERVICES_STATE_DIR.mkdir(parents=True, exist_ok=True) # Default service definitions (fallback if no services.yml) DEFAULT_SERVICES = { "musica": { "backend": { "command": "source backend/venv/bin/activate && uvicorn app.main:app --host 0.0.0.0 --port 8100 --app-dir backend", "workdir": "/home/musica", "port": 8100, "description": "MU Backend API" }, "frontend": { "command": "cd frontend && ./node_modules/.bin/vite --port 5175 --host", "workdir": "/home/musica", "port": 5175, "description": "MU Frontend Dev Server" } }, "librechat": { "chat-hub": { "command": "cd chat-hub && uvicorn server:app --host 0.0.0.0 --port 3200", "workdir": "/home/librechat", "port": 3200, "description": "Chat Hub Server" } }, "dss": { "api": { "command": "docker compose up", "workdir": "/home/dss/sofi-design-system/packages/dss-server", "port": 6220, "description": "DSS API Server" } } } class ServiceManager: """Manages project services outside cockpit sandbox.""" def __init__(self): self.state_file = SERVICES_STATE_DIR / "running.json" self.state = self._load_state() def _load_state(self) -> Dict: """Load running services state.""" if self.state_file.exists(): try: return json.loads(self.state_file.read_text()) except: pass return {"services": {}} def _save_state(self): """Save running services state.""" self.state_file.write_text(json.dumps(self.state, indent=2)) def get_service_config(self, project: str, service: str) -> Optional[Dict]: """Get service configuration from project's services.yml or defaults.""" # Try project-specific services.yml services_file = Path(f"/home/{project}/services.yml") if services_file.exists(): try: with open(services_file) as f: config = yaml.safe_load(f) if config and "services" in config: return config["services"].get(service) except Exception as e: logger.warning(f"Error reading services.yml: {e}") # Fall back to defaults if project in DEFAULT_SERVICES: return DEFAULT_SERVICES[project].get(service) return None def list_services(self, project: str) -> List[Dict]: """List available services for a project.""" services = [] # Check services.yml services_file = Path(f"/home/{project}/services.yml") if services_file.exists(): try: with open(services_file) as f: config = yaml.safe_load(f) if config and "services" in config: for name, svc in config["services"].items(): services.append({ "name": name, "port": svc.get("port"), "description": svc.get("description", ""), "source": "services.yml" }) except: pass # Add defaults if not in services.yml if project in DEFAULT_SERVICES: existing_names = {s["name"] for s in services} for name, svc in DEFAULT_SERVICES[project].items(): if name not in existing_names: services.append({ "name": name, "port": svc.get("port"), "description": svc.get("description", ""), "source": "default" }) return services def start_service(self, project: str, service: str) -> Dict: """Start a service for a project.""" config = self.get_service_config(project, service) if not config: return {"success": False, "error": f"Service '{service}' not found for project '{project}'"} # Check if already running key = f"{project}/{service}" if key in self.state["services"]: pid = self.state["services"][key].get("pid") if pid and self._is_process_running(pid): return {"success": False, "error": f"Service already running (PID {pid})"} # Start the service command = config["command"] workdir = config.get("workdir", f"/home/{project}") port = config.get("port") # Check if port is already in use if port and self._is_port_in_use(port): return {"success": False, "error": f"Port {port} already in use"} try: # Run as project user with nohup full_cmd = f"cd {workdir} && nohup bash -c '{command}' > /tmp/{project}-{service}.log 2>&1 & echo $!" result = subprocess.run( ["sudo", "-u", project, "bash", "-c", full_cmd], capture_output=True, text=True, timeout=10 ) if result.returncode != 0: return {"success": False, "error": f"Failed to start: {result.stderr}"} pid = int(result.stdout.strip()) if result.stdout.strip().isdigit() else None # Save state self.state["services"][key] = { "pid": pid, "port": port, "started_at": datetime.now().isoformat(), "command": command, "workdir": workdir } self._save_state() return { "success": True, "service": service, "project": project, "pid": pid, "port": port, "log": f"/tmp/{project}-{service}.log" } except Exception as e: return {"success": False, "error": str(e)} def stop_service(self, project: str, service: str) -> Dict: """Stop a running service.""" key = f"{project}/{service}" if key not in self.state["services"]: return {"success": False, "error": f"Service '{service}' not running for '{project}'"} svc = self.state["services"][key] pid = svc.get("pid") if pid: try: os.kill(pid, signal.SIGTERM) # Give it a moment to terminate import time time.sleep(1) # Force kill if still running if self._is_process_running(pid): os.kill(pid, signal.SIGKILL) except ProcessLookupError: pass # Already dead except Exception as e: return {"success": False, "error": str(e)} # Remove from state del self.state["services"][key] self._save_state() return {"success": True, "service": service, "project": project, "stopped_pid": pid} def status(self, project: str = None) -> Dict: """Get status of running services.""" result = {"services": []} for key, svc in self.state["services"].items(): proj, name = key.split("/", 1) if project and proj != project: continue pid = svc.get("pid") running = self._is_process_running(pid) if pid else False port_open = self._is_port_in_use(svc.get("port")) if svc.get("port") else None result["services"].append({ "project": proj, "service": name, "pid": pid, "port": svc.get("port"), "running": running, "port_responding": port_open, "started_at": svc.get("started_at"), "log": f"/tmp/{proj}-{name}.log" }) return result def _is_process_running(self, pid: int) -> bool: """Check if a process is running.""" try: # Use /proc check instead of kill to avoid permission issues return Path(f"/proc/{pid}").exists() except (TypeError, ValueError): return False def _is_port_in_use(self, port: int) -> bool: """Check if a port is in use.""" import socket with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: return s.connect_ex(('localhost', port)) == 0 # CLI functions def cmd_start(project: str, service: str) -> str: """Start a service.""" mgr = ServiceManager() result = mgr.start_service(project, service) if result["success"]: return f"✅ Started {service} for {project} (PID: {result.get('pid')}, port: {result.get('port')})" return f"❌ Failed: {result['error']}" def cmd_stop(project: str, service: str) -> str: """Stop a service.""" mgr = ServiceManager() result = mgr.stop_service(project, service) if result["success"]: return f"✅ Stopped {service} for {project}" return f"❌ Failed: {result['error']}" def cmd_status(project: str = None) -> str: """Get service status.""" mgr = ServiceManager() result = mgr.status(project) if not result["services"]: return "No services running" + (f" for {project}" if project else "") lines = ["SERVICE STATUS", "=" * 50] for svc in result["services"]: status = "✅ RUNNING" if svc["running"] else "❌ STOPPED" port_status = f", port {svc['port']} {'open' if svc['port_responding'] else 'closed'}" if svc.get("port") else "" lines.append(f"{svc['project']}/{svc['service']}: {status} (PID {svc['pid']}{port_status})") return "\n".join(lines) def cmd_list(project: str) -> str: """List available services.""" mgr = ServiceManager() services = mgr.list_services(project) if not services: return f"No services defined for {project}" lines = [f"SERVICES FOR {project.upper()}", "=" * 50] for svc in services: lines.append(f" {svc['name']}: port {svc.get('port', 'N/A')} - {svc.get('description', '')} [{svc['source']}]") return "\n".join(lines) if __name__ == "__main__": import sys if len(sys.argv) < 2: print("Usage:") print(" service_manager.py start ") print(" service_manager.py stop ") print(" service_manager.py status [project]") print(" service_manager.py list ") sys.exit(1) cmd = sys.argv[1] if cmd == "start" and len(sys.argv) >= 4: print(cmd_start(sys.argv[2], sys.argv[3])) elif cmd == "stop" and len(sys.argv) >= 4: print(cmd_stop(sys.argv[2], sys.argv[3])) elif cmd == "status": print(cmd_status(sys.argv[2] if len(sys.argv) > 2 else None)) elif cmd == "list" and len(sys.argv) >= 3: print(cmd_list(sys.argv[2])) else: print(f"Unknown command: {cmd}") sys.exit(1)