core: add render-only service adapters
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
"""AFS service management primitives."""
|
||||
|
||||
from .adapters.base import ServiceAdapter
|
||||
from .adapters.launchd import LaunchdAdapter
|
||||
from .adapters.systemd import SystemdAdapter
|
||||
from .manager import ServiceManager
|
||||
from .models import ServiceDefinition, ServiceState, ServiceStatus, ServiceType
|
||||
|
||||
@@ -9,4 +12,7 @@ __all__ = [
|
||||
"ServiceState",
|
||||
"ServiceStatus",
|
||||
"ServiceType",
|
||||
"ServiceAdapter",
|
||||
"LaunchdAdapter",
|
||||
"SystemdAdapter",
|
||||
]
|
||||
|
||||
18
src/afs/services/adapters/base.py
Normal file
18
src/afs/services/adapters/base.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Base classes for AFS service adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..models import ServiceDefinition
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceAdapter:
|
||||
platform_name: str
|
||||
|
||||
def render(self, definition: ServiceDefinition) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def file_extension(self) -> str:
|
||||
return ""
|
||||
29
src/afs/services/adapters/launchd.py
Normal file
29
src/afs/services/adapters/launchd.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Launchd adapter (render-only) for AFS services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from .base import ServiceAdapter
|
||||
from ..models import ServiceDefinition
|
||||
|
||||
|
||||
class LaunchdAdapter(ServiceAdapter):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(platform_name="darwin")
|
||||
|
||||
def render(self, definition: ServiceDefinition) -> str:
|
||||
payload: dict[str, object] = {
|
||||
"Label": f"afs.{definition.name}",
|
||||
"ProgramArguments": list(definition.command),
|
||||
"RunAtLoad": bool(definition.run_at_load),
|
||||
"KeepAlive": bool(definition.keep_alive),
|
||||
}
|
||||
if definition.working_directory:
|
||||
payload["WorkingDirectory"] = str(definition.working_directory)
|
||||
if definition.environment:
|
||||
payload["EnvironmentVariables"] = dict(definition.environment)
|
||||
return json.dumps(payload, indent=2)
|
||||
|
||||
def file_extension(self) -> str:
|
||||
return ".plist"
|
||||
38
src/afs/services/adapters/systemd.py
Normal file
38
src/afs/services/adapters/systemd.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Systemd adapter (render-only) for AFS services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import ServiceAdapter
|
||||
from ..models import ServiceDefinition, ServiceType
|
||||
|
||||
|
||||
class SystemdAdapter(ServiceAdapter):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(platform_name="linux")
|
||||
|
||||
def render(self, definition: ServiceDefinition) -> str:
|
||||
lines = [
|
||||
"[Unit]",
|
||||
f"Description={definition.label}",
|
||||
"",
|
||||
"[Service]",
|
||||
f"ExecStart={' '.join(definition.command)}",
|
||||
]
|
||||
if definition.working_directory:
|
||||
lines.append(f"WorkingDirectory={definition.working_directory}")
|
||||
if definition.environment:
|
||||
for key, value in definition.environment.items():
|
||||
lines.append(f"Environment={key}={value}")
|
||||
if definition.service_type == ServiceType.ONESHOT:
|
||||
lines.append("Type=oneshot")
|
||||
if definition.keep_alive:
|
||||
lines.append("Restart=on-failure")
|
||||
lines.extend([
|
||||
"",
|
||||
"[Install]",
|
||||
"WantedBy=default.target",
|
||||
])
|
||||
return "\n".join(lines)
|
||||
|
||||
def file_extension(self) -> str:
|
||||
return ".service"
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import platform
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -10,6 +9,9 @@ from typing import Iterable
|
||||
|
||||
from ..config import load_config_model
|
||||
from ..schema import AFSConfig, ServiceConfig
|
||||
from .adapters.base import ServiceAdapter
|
||||
from .adapters.launchd import LaunchdAdapter
|
||||
from .adapters.systemd import SystemdAdapter
|
||||
from .models import ServiceDefinition, ServiceState, ServiceStatus, ServiceType
|
||||
|
||||
|
||||
@@ -26,6 +28,7 @@ class ServiceManager:
|
||||
self.config = config or load_config_model()
|
||||
self.service_root = service_root or Path.home() / ".config" / "afs" / "services"
|
||||
self.platform_name = platform_name or platform.system().lower()
|
||||
self._adapter = self._build_adapter(self.platform_name)
|
||||
|
||||
def list_definitions(self) -> list[ServiceDefinition]:
|
||||
definitions = self._builtin_definitions()
|
||||
@@ -40,11 +43,7 @@ class ServiceManager:
|
||||
definition = self.get_definition(name)
|
||||
if not definition:
|
||||
raise KeyError(f"Unknown service: {name}")
|
||||
|
||||
if self.platform_name.startswith("darwin"):
|
||||
payload = render_launchd_plist(definition)
|
||||
return json.dumps(payload, indent=2)
|
||||
return render_systemd_unit(definition)
|
||||
return self._adapter.render(definition)
|
||||
|
||||
def status(self, name: str) -> ServiceStatus:
|
||||
definition = self.get_definition(name)
|
||||
@@ -117,6 +116,14 @@ class ServiceManager:
|
||||
),
|
||||
}
|
||||
|
||||
def _build_adapter(self, platform_name: str) -> ServiceAdapter:
|
||||
platform_name = platform_name.lower()
|
||||
if platform_name.startswith("darwin") or platform_name.startswith("mac"):
|
||||
return LaunchdAdapter()
|
||||
if platform_name.startswith("linux"):
|
||||
return SystemdAdapter()
|
||||
return SystemdAdapter()
|
||||
|
||||
def _resolve_python_executable(self) -> str:
|
||||
if self.config.general.python_executable:
|
||||
return str(self.config.general.python_executable)
|
||||
@@ -158,41 +165,3 @@ def _merge_definition(
|
||||
run_at_load=override.auto_start,
|
||||
)
|
||||
|
||||
|
||||
def render_launchd_plist(definition: ServiceDefinition) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"Label": f"afs.{definition.name}",
|
||||
"ProgramArguments": list(definition.command),
|
||||
"RunAtLoad": bool(definition.run_at_load),
|
||||
"KeepAlive": bool(definition.keep_alive),
|
||||
}
|
||||
if definition.working_directory:
|
||||
payload["WorkingDirectory"] = str(definition.working_directory)
|
||||
if definition.environment:
|
||||
payload["EnvironmentVariables"] = dict(definition.environment)
|
||||
return payload
|
||||
|
||||
|
||||
def render_systemd_unit(definition: ServiceDefinition) -> str:
|
||||
lines = [
|
||||
"[Unit]",
|
||||
f"Description={definition.label}",
|
||||
"",
|
||||
"[Service]",
|
||||
f"ExecStart={' '.join(definition.command)}",
|
||||
]
|
||||
if definition.working_directory:
|
||||
lines.append(f"WorkingDirectory={definition.working_directory}")
|
||||
if definition.environment:
|
||||
for key, value in definition.environment.items():
|
||||
lines.append(f"Environment={key}={value}")
|
||||
if definition.service_type == ServiceType.ONESHOT:
|
||||
lines.append("Type=oneshot")
|
||||
if definition.keep_alive:
|
||||
lines.append("Restart=on-failure")
|
||||
lines.extend([
|
||||
"",
|
||||
"[Install]",
|
||||
"WantedBy=default.target",
|
||||
])
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -26,3 +26,9 @@ def test_service_render_contains_execstart() -> None:
|
||||
manager = ServiceManager(config=AFSConfig(), platform_name="linux")
|
||||
unit = manager.render_unit("orchestrator")
|
||||
assert "ExecStart=" in unit
|
||||
|
||||
|
||||
def test_service_render_launchd_contains_label() -> None:
|
||||
manager = ServiceManager(config=AFSConfig(), platform_name="darwin")
|
||||
payload = manager.render_unit("orchestrator")
|
||||
assert "\"Label\"" in payload
|
||||
|
||||
Reference in New Issue
Block a user