233 lines
6.5 KiB
Python
233 lines
6.5 KiB
Python
"""
|
|
PyServe CLI State Management
|
|
|
|
Manages the state of running services.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import time
|
|
from dataclasses import asdict, dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
|
|
|
|
@dataclass
|
|
class ServiceHealth:
|
|
status: str = "unknown" # healthy, unhealthy, degraded, unknown
|
|
last_check: Optional[float] = None
|
|
failures: int = 0
|
|
response_time_ms: Optional[float] = None
|
|
|
|
|
|
@dataclass
|
|
class ServiceState:
|
|
name: str
|
|
state: str = "stopped" # pending, starting, running, stopping, stopped, failed, restarting
|
|
pid: Optional[int] = None
|
|
port: int = 0
|
|
workers: int = 0
|
|
started_at: Optional[float] = None
|
|
restart_count: int = 0
|
|
health: ServiceHealth = field(default_factory=ServiceHealth)
|
|
config_hash: str = ""
|
|
|
|
@property
|
|
def uptime(self) -> float:
|
|
if self.started_at is None:
|
|
return 0.0
|
|
return time.time() - self.started_at
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"name": self.name,
|
|
"state": self.state,
|
|
"pid": self.pid,
|
|
"port": self.port,
|
|
"workers": self.workers,
|
|
"started_at": self.started_at,
|
|
"restart_count": self.restart_count,
|
|
"health": asdict(self.health),
|
|
"config_hash": self.config_hash,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> "ServiceState":
|
|
health_data = data.pop("health", {})
|
|
health = ServiceHealth(**health_data) if health_data else ServiceHealth()
|
|
return cls(**data, health=health)
|
|
|
|
|
|
@dataclass
|
|
class ProjectState:
|
|
version: str = "1.0"
|
|
project: str = ""
|
|
config_file: str = ""
|
|
config_hash: str = ""
|
|
started_at: Optional[float] = None
|
|
daemon_pid: Optional[int] = None
|
|
services: Dict[str, ServiceState] = field(default_factory=dict)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"version": self.version,
|
|
"project": self.project,
|
|
"config_file": self.config_file,
|
|
"config_hash": self.config_hash,
|
|
"started_at": self.started_at,
|
|
"daemon_pid": self.daemon_pid,
|
|
"services": {name: svc.to_dict() for name, svc in self.services.items()},
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> "ProjectState":
|
|
services_data = data.pop("services", {})
|
|
services = {name: ServiceState.from_dict(svc) for name, svc in services_data.items()}
|
|
return cls(**data, services=services)
|
|
|
|
|
|
class StateManager:
|
|
STATE_FILE = "state.json"
|
|
PID_FILE = "pyserve.pid"
|
|
SOCKET_FILE = "pyserve.sock"
|
|
LOGS_DIR = "logs"
|
|
|
|
def __init__(self, state_dir: Path, project: Optional[str] = None):
|
|
self.state_dir = Path(state_dir)
|
|
self.project = project or self._detect_project()
|
|
self._state: Optional[ProjectState] = None
|
|
|
|
def _detect_project(self) -> str:
|
|
return Path.cwd().name
|
|
|
|
@property
|
|
def state_file(self) -> Path:
|
|
return self.state_dir / self.STATE_FILE
|
|
|
|
@property
|
|
def pid_file(self) -> Path:
|
|
return self.state_dir / self.PID_FILE
|
|
|
|
@property
|
|
def socket_file(self) -> Path:
|
|
return self.state_dir / self.SOCKET_FILE
|
|
|
|
@property
|
|
def logs_dir(self) -> Path:
|
|
return self.state_dir / self.LOGS_DIR
|
|
|
|
def ensure_dirs(self) -> None:
|
|
self.state_dir.mkdir(parents=True, exist_ok=True)
|
|
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
def load(self) -> ProjectState:
|
|
if self._state is not None:
|
|
return self._state
|
|
|
|
if self.state_file.exists():
|
|
try:
|
|
with open(self.state_file) as f:
|
|
data = json.load(f)
|
|
self._state = ProjectState.from_dict(data)
|
|
except (json.JSONDecodeError, KeyError):
|
|
self._state = ProjectState(project=self.project)
|
|
else:
|
|
self._state = ProjectState(project=self.project)
|
|
|
|
return self._state
|
|
|
|
def save(self) -> None:
|
|
if self._state is None:
|
|
return
|
|
|
|
self.ensure_dirs()
|
|
|
|
with open(self.state_file, "w") as f:
|
|
json.dump(self._state.to_dict(), f, indent=2)
|
|
|
|
def get_state(self) -> ProjectState:
|
|
return self.load()
|
|
|
|
def update_service(self, name: str, **kwargs: Any) -> ServiceState:
|
|
state = self.load()
|
|
|
|
if name not in state.services:
|
|
state.services[name] = ServiceState(name=name)
|
|
|
|
service = state.services[name]
|
|
for key, value in kwargs.items():
|
|
if hasattr(service, key):
|
|
setattr(service, key, value)
|
|
|
|
self.save()
|
|
return service
|
|
|
|
def remove_service(self, name: str) -> None:
|
|
state = self.load()
|
|
if name in state.services:
|
|
del state.services[name]
|
|
self.save()
|
|
|
|
def get_service(self, name: str) -> Optional[ServiceState]:
|
|
state = self.load()
|
|
return state.services.get(name)
|
|
|
|
def get_all_services(self) -> Dict[str, ServiceState]:
|
|
state = self.load()
|
|
return state.services.copy()
|
|
|
|
def clear(self) -> None:
|
|
self._state = ProjectState(project=self.project)
|
|
self.save()
|
|
|
|
def is_daemon_running(self) -> bool:
|
|
if not self.pid_file.exists():
|
|
return False
|
|
|
|
try:
|
|
pid = int(self.pid_file.read_text().strip())
|
|
# Check if process exists
|
|
os.kill(pid, 0)
|
|
return True
|
|
except (ValueError, ProcessLookupError, PermissionError):
|
|
return False
|
|
|
|
def get_daemon_pid(self) -> Optional[int]:
|
|
if not self.is_daemon_running():
|
|
return None
|
|
|
|
try:
|
|
return int(self.pid_file.read_text().strip())
|
|
except ValueError:
|
|
return None
|
|
|
|
def set_daemon_pid(self, pid: int) -> None:
|
|
self.ensure_dirs()
|
|
self.pid_file.write_text(str(pid))
|
|
|
|
state = self.load()
|
|
state.daemon_pid = pid
|
|
self.save()
|
|
|
|
def clear_daemon_pid(self) -> None:
|
|
if self.pid_file.exists():
|
|
self.pid_file.unlink()
|
|
|
|
state = self.load()
|
|
state.daemon_pid = None
|
|
self.save()
|
|
|
|
def get_service_log_file(self, service_name: str) -> Path:
|
|
self.ensure_dirs()
|
|
return self.logs_dir / f"{service_name}.log"
|
|
|
|
def compute_config_hash(self, config_file: str) -> str:
|
|
import hashlib
|
|
|
|
path = Path(config_file)
|
|
if not path.exists():
|
|
return ""
|
|
|
|
content = path.read_bytes()
|
|
return hashlib.sha256(content).hexdigest()[:16]
|