susops 3.0.0rc3.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
susops/core/process.py ADDED
@@ -0,0 +1,167 @@
1
+ """Process lifecycle management for SusOps via PID files."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import signal
6
+ import subprocess
7
+ import time
8
+ from pathlib import Path
9
+
10
+ __all__ = ["ProcessManager"]
11
+
12
+
13
+ class ProcessManager:
14
+ """Manages long-running background processes via PID files.
15
+
16
+ PID files are stored in <workspace>/pids/<name>.pid.
17
+ This replaces the exec -a / pgrep -f approach used in the Bash CLI.
18
+ """
19
+
20
+ def __init__(self, workspace: Path) -> None:
21
+ self._pids_dir = workspace / "pids"
22
+ self._pids_dir.mkdir(parents=True, exist_ok=True)
23
+
24
+ def _pid_file(self, name: str) -> Path:
25
+ return self._pids_dir / f"{name}.pid"
26
+
27
+ def start(
28
+ self,
29
+ name: str,
30
+ cmd: list[str],
31
+ env: dict[str, str] | None = None,
32
+ stdout=subprocess.DEVNULL,
33
+ stderr=subprocess.DEVNULL,
34
+ ) -> int:
35
+ """Start a process and record its PID.
36
+
37
+ Returns the PID. Raises RuntimeError if process fails to start.
38
+ """
39
+ import os as _os
40
+ proc = subprocess.Popen(
41
+ cmd,
42
+ env={**_os.environ, **(env or {})},
43
+ stdin=subprocess.DEVNULL,
44
+ stdout=stdout,
45
+ stderr=stderr,
46
+ start_new_session=True, # detach from terminal
47
+ )
48
+ pid = proc.pid
49
+ pid_file = self._pid_file(name)
50
+ pid_file.parent.mkdir(parents=True, exist_ok=True)
51
+ pid_file.write_text(str(pid))
52
+ return pid
53
+
54
+ def stop(self, name: str, force: bool = False) -> bool:
55
+ """Stop a process by name. Returns True if stopped, False if not running."""
56
+ pid = self.get_pid(name)
57
+ if pid is None:
58
+ return False
59
+ try:
60
+ sig = signal.SIGKILL if force else signal.SIGTERM
61
+ os.kill(pid, sig)
62
+ # Wait briefly for process to exit
63
+ for _ in range(20):
64
+ time.sleep(0.1)
65
+ try:
66
+ os.kill(pid, 0) # check if still alive
67
+ except ProcessLookupError:
68
+ break
69
+ except ProcessLookupError:
70
+ pass # already gone
71
+ finally:
72
+ pid_file = self._pid_file(name)
73
+ if pid_file.exists():
74
+ pid_file.unlink()
75
+ # Reap the zombie if this process is a direct child. (always wanted to write this as a code comment LOL)
76
+ try:
77
+ os.waitpid(pid, os.WNOHANG)
78
+ except ChildProcessError:
79
+ pass # not our child or already reaped
80
+ except OSError:
81
+ pass
82
+ return True
83
+
84
+ def is_running(self, name: str) -> bool:
85
+ """Return True if the named process is currently running (not a zombie)."""
86
+ pid = self.get_pid(name)
87
+ if pid is None:
88
+ return False
89
+ try:
90
+ os.kill(pid, 0)
91
+ except ProcessLookupError:
92
+ return False
93
+ except PermissionError:
94
+ return True # exists, not our process, but it's running
95
+ # Process exists in the table, check whether it's a zombie.
96
+ # Zombies respond to kill(0) but can't do any real work.
97
+ # Reading /proc is Linux-only, on other platforms we trust kill(0).
98
+ try:
99
+ status = Path(f"/proc/{pid}/status").read_text()
100
+ for line in status.splitlines():
101
+ if line.startswith("State:") and "Z" in line:
102
+ # Reap it (only works if we're the parent)
103
+ try:
104
+ os.waitpid(pid, os.WNOHANG)
105
+ except (ChildProcessError, OSError):
106
+ pass
107
+ self._pid_file(name).unlink(missing_ok=True)
108
+ return False
109
+ except OSError:
110
+ pass # /proc not available or pid already gone
111
+ return True
112
+
113
+ def get_pid(self, name: str) -> int | None:
114
+ """Return the PID for a named process, or None if not tracked."""
115
+ pid_file = self._pid_file(name)
116
+ if not pid_file.exists():
117
+ return None
118
+ try:
119
+ return int(pid_file.read_text().strip())
120
+ except (ValueError, OSError):
121
+ return None
122
+
123
+ # Process names that kill_all() must NEVER touch — these belong to the
124
+ # services daemon itself (which is the process *calling* kill_all). Killing
125
+ # them would make the daemon SIGTERM itself, taking down PAC + RPC and
126
+ # making subsequent ensure_daemon_running calls race with the dying
127
+ # daemon's PID file.
128
+ _KILL_ALL_BLACKLIST = frozenset({"susops-services"})
129
+
130
+ def kill_all(self) -> None:
131
+ """Send SIGTERM to every tracked process and remove PID files. Non-blocking.
132
+
133
+ Skips the services daemon's own pid file — see _KILL_ALL_BLACKLIST.
134
+ """
135
+ for pid_file in list(self._pids_dir.glob("*.pid")):
136
+ if pid_file.stem in self._KILL_ALL_BLACKLIST:
137
+ continue
138
+ try:
139
+ pid = int(pid_file.read_text().strip())
140
+ os.kill(pid, signal.SIGTERM)
141
+ except (ValueError, OSError, ProcessLookupError):
142
+ pass
143
+ try:
144
+ pid_file.unlink()
145
+ except OSError:
146
+ pass
147
+
148
+ def status_all(self) -> dict[str, bool]:
149
+ """Return a dict of {name: is_running} for all tracked processes."""
150
+ result = {}
151
+ for pid_file in self._pids_dir.glob("*.pid"):
152
+ name = pid_file.stem
153
+ result[name] = self.is_running(name)
154
+ return result
155
+
156
+ def cleanup_stale(self) -> list[str]:
157
+ """Remove PID files for processes that are no longer running.
158
+
159
+ Returns list of cleaned-up names.
160
+ """
161
+ cleaned = []
162
+ for pid_file in self._pids_dir.glob("*.pid"):
163
+ name = pid_file.stem
164
+ if not self.is_running(name):
165
+ pid_file.unlink()
166
+ cleaned.append(name)
167
+ return cleaned
@@ -0,0 +1,186 @@
1
+ """JSON-over-HTTP RPC protocol for the susops-services daemon.
2
+
3
+ Encodes facade arguments / return values losslessly. Pydantic models are
4
+ serialized via model_dump(); dataclasses via asdict(); enums via .value
5
+ with a type tag so the client can rebuild the exact type. Anything else
6
+ serializable by json.dumps passes through unchanged.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ import enum
12
+ import json
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from pydantic import BaseModel
17
+
18
+ # Registry of known reconstructable types. Populated lazily to avoid
19
+ # import cycles with the facade.
20
+ _REGISTRY: dict[str, type] = {}
21
+
22
+
23
+ def _registry() -> dict[str, type]:
24
+ if _REGISTRY:
25
+ return _REGISTRY
26
+ from susops.core.config import (
27
+ AppConfig,
28
+ Connection,
29
+ FileShare,
30
+ Forwards,
31
+ PortForward,
32
+ SusOpsConfig,
33
+ )
34
+ from susops.core.types import (
35
+ ConnectionStatus,
36
+ LogoStyle,
37
+ ProcessState,
38
+ ShareInfo,
39
+ StartResult,
40
+ StatusResult,
41
+ StopResult,
42
+ TestResult,
43
+ )
44
+ _REGISTRY.update({
45
+ "Connection": Connection,
46
+ "FileShare": FileShare,
47
+ "PortForward": PortForward,
48
+ "SusOpsConfig": SusOpsConfig,
49
+ "AppConfig": AppConfig,
50
+ "Forwards": Forwards,
51
+ "ConnectionStatus": ConnectionStatus,
52
+ "LogoStyle": LogoStyle,
53
+ "ProcessState": ProcessState,
54
+ "ShareInfo": ShareInfo,
55
+ "StartResult": StartResult,
56
+ "StatusResult": StatusResult,
57
+ "StopResult": StopResult,
58
+ "TestResult": TestResult,
59
+ "Path": Path,
60
+ })
61
+ return _REGISTRY
62
+
63
+
64
+ def encode_value(v: Any) -> Any:
65
+ """Recursively encode a Python value to a JSON-safe structure."""
66
+ if v is None or isinstance(v, (bool, int, float, str)):
67
+ return v
68
+ if isinstance(v, Path):
69
+ return {"__type__": "Path", "value": str(v)}
70
+ # Enums BEFORE BaseModel/dataclass checks so a Pydantic-model-with-enum-field
71
+ # won't accidentally match the model branch.
72
+ if isinstance(v, enum.Enum):
73
+ return {"__type__": type(v).__name__, "value": v.value}
74
+ if isinstance(v, BaseModel):
75
+ return {"__type__": type(v).__name__, "value": v.model_dump(mode="json")}
76
+ if dataclasses.is_dataclass(v) and not isinstance(v, type):
77
+ # Encode field-by-field via encode_value rather than dataclasses.asdict()
78
+ # — asdict() recursively flattens nested dataclasses (and tuples) into
79
+ # plain dicts/lists, throwing away the __type__ tags the decoder needs
80
+ # to reconstruct the original types. Going through encode_value keeps
81
+ # ConnectionStatus / TestResult / etc. tagged inside their parents.
82
+ encoded = {
83
+ f.name: encode_value(getattr(v, f.name))
84
+ for f in dataclasses.fields(v)
85
+ }
86
+ return {"__type__": type(v).__name__, "value": encoded}
87
+ if isinstance(v, (list, tuple)):
88
+ return [encode_value(x) for x in v]
89
+ if isinstance(v, (set, frozenset)):
90
+ # JSON has no native set type — flatten to a list. Sort for
91
+ # deterministic output. Callers that need set semantics on the
92
+ # other end can `set(...)` it themselves.
93
+ return [encode_value(x) for x in sorted(v, key=repr)]
94
+ if isinstance(v, dict):
95
+ return _encode_dict(v)
96
+ raise TypeError(f"Cannot encode value of type {type(v).__name__}: {v!r}")
97
+
98
+
99
+ def _encode_dict(d: dict) -> dict:
100
+ return {k: encode_value(val) for k, val in d.items()}
101
+
102
+
103
+ def decode_arg(v: Any) -> Any:
104
+ """Recursively decode a JSON-safe structure back into Python objects."""
105
+ if v is None or isinstance(v, (bool, int, float, str)):
106
+ return v
107
+ if isinstance(v, list):
108
+ return [decode_arg(x) for x in v]
109
+ if isinstance(v, dict):
110
+ if "__type__" in v and "value" in v:
111
+ return _decode_tagged(v["__type__"], v["value"])
112
+ return {k: decode_arg(val) for k, val in v.items()}
113
+ return v
114
+
115
+
116
+ def _decode_tagged(type_name: str, value: Any) -> Any:
117
+ cls = _registry().get(type_name)
118
+ if cls is None:
119
+ raise ValueError(f"Unknown RPC type tag: {type_name}")
120
+ if cls is Path:
121
+ return Path(value)
122
+ if isinstance(cls, type) and issubclass(cls, enum.Enum):
123
+ return cls(value)
124
+ if isinstance(cls, type) and issubclass(cls, BaseModel):
125
+ return cls.model_validate(value)
126
+ if dataclasses.is_dataclass(cls):
127
+ # Recursively decode nested fields so dataclasses-containing-models work.
128
+ kwargs = {}
129
+ for name, raw in value.items():
130
+ if isinstance(raw, dict) and "__type__" in raw:
131
+ kwargs[name] = decode_arg(raw)
132
+ elif isinstance(raw, list):
133
+ kwargs[name] = [decode_arg(x) for x in raw]
134
+ else:
135
+ kwargs[name] = raw
136
+ return cls(**kwargs)
137
+ raise ValueError(f"Don't know how to reconstruct type: {type_name}")
138
+
139
+
140
+ @dataclasses.dataclass
141
+ class InvocationRequest:
142
+ method: str
143
+ args: list = dataclasses.field(default_factory=list)
144
+ kwargs: dict = dataclasses.field(default_factory=dict)
145
+
146
+ def to_json(self) -> str:
147
+ return json.dumps({
148
+ "method": self.method,
149
+ "args": encode_value(self.args),
150
+ "kwargs": encode_value(self.kwargs),
151
+ })
152
+
153
+ @classmethod
154
+ def from_json(cls, payload: str) -> "InvocationRequest":
155
+ data = json.loads(payload)
156
+ return cls(
157
+ method=data["method"],
158
+ args=[decode_arg(a) for a in data.get("args", [])],
159
+ kwargs={k: decode_arg(v) for k, v in data.get("kwargs", {}).items()},
160
+ )
161
+
162
+
163
+ @dataclasses.dataclass
164
+ class InvocationResponse:
165
+ ok: bool
166
+ result: Any = None
167
+ error: str | None = None
168
+ error_type: str | None = None
169
+
170
+ def to_json(self) -> str:
171
+ return json.dumps({
172
+ "ok": self.ok,
173
+ "result": encode_value(self.result),
174
+ "error": self.error,
175
+ "error_type": self.error_type,
176
+ })
177
+
178
+ @classmethod
179
+ def from_json(cls, payload: str) -> "InvocationResponse":
180
+ data = json.loads(payload)
181
+ return cls(
182
+ ok=data["ok"],
183
+ result=decode_arg(data.get("result")),
184
+ error=data.get("error"),
185
+ error_type=data.get("error_type"),
186
+ )
@@ -0,0 +1,131 @@
1
+ """aiohttp server hosting the JSON-over-HTTP RPC endpoint.
2
+
3
+ Dispatches `InvocationRequest.method` to the named method on `SusOpsManager`.
4
+ Methods are looked up by attribute access; private methods (leading
5
+ underscore) are rejected to prevent direct access to internal helpers.
6
+ Anything outside the allowlist below is rejected too — deny-by-default.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from aiohttp import web
13
+
14
+ from susops.core.rpc_protocol import (
15
+ InvocationRequest,
16
+ InvocationResponse,
17
+ )
18
+
19
+ log = logging.getLogger("susops.services.rpc")
20
+
21
+ # Methods explicitly exposed to RPC clients. Deny by default.
22
+ _ALLOWED_METHODS: set[str] = {
23
+ # Lifecycle
24
+ "start", "stop", "restart", "status", "stop_quick",
25
+ # Config introspection
26
+ "list_config",
27
+ # Connection CRUD
28
+ "add_connection", "remove_connection", "set_connection_enabled",
29
+ # PAC hosts
30
+ "add_pac_host", "remove_pac_host", "set_pac_host_enabled",
31
+ # Forwards
32
+ "add_local_forward", "add_remote_forward",
33
+ "remove_local_forward", "remove_remote_forward",
34
+ "toggle_forward_enabled", "set_forward_enabled",
35
+ "is_udp_forward_running",
36
+ # File sharing
37
+ "share", "stop_share", "delete_share", "list_shares",
38
+ "share_is_running", "fetch",
39
+ # Testing
40
+ "test", "test_all", "test_connection", "test_domain", "test_forward",
41
+ "test_ssh",
42
+ # App-level
43
+ "reset", "update_app_config", "update_config",
44
+ # URLs
45
+ "get_pac_url", "get_status_url",
46
+ # Bandwidth
47
+ "get_bandwidth", "get_bandwidth_totals", "get_bandwidth_global",
48
+ # Reconnect introspection
49
+ "reconnect_monitor_info",
50
+ # Process introspection
51
+ "process_info", # global — used by `susops ps`
52
+ "get_process_info", # per-tag — used by TUI dashboard
53
+ "get_uptime", # per-tag — used by TUI dashboard / connections panel
54
+ "get_logs", # used by TUI logs panel
55
+ "log_message", # frontends push operational notes into the daemon log buffer
56
+ }
57
+
58
+
59
+ async def _handle_rpc(request: web.Request) -> web.Response:
60
+ mgr = request.app["manager"]
61
+ try:
62
+ payload = await request.text()
63
+ req = InvocationRequest.from_json(payload)
64
+ except Exception as exc:
65
+ log.exception("Malformed RPC request")
66
+ resp = InvocationResponse(ok=False, error=str(exc), error_type=type(exc).__name__)
67
+ return web.Response(text=resp.to_json(), status=400, content_type="application/json")
68
+
69
+ if req.method.startswith("_") or req.method not in _ALLOWED_METHODS:
70
+ resp = InvocationResponse(
71
+ ok=False,
72
+ error=f"method '{req.method}' not allowed",
73
+ error_type="AttributeError",
74
+ )
75
+ return web.Response(text=resp.to_json(), status=404, content_type="application/json")
76
+
77
+ method = getattr(mgr, req.method, None)
78
+ if method is None or not callable(method):
79
+ resp = InvocationResponse(
80
+ ok=False,
81
+ error=f"no callable named '{req.method}'",
82
+ error_type="AttributeError",
83
+ )
84
+ return web.Response(text=resp.to_json(), status=404, content_type="application/json")
85
+
86
+ try:
87
+ result = method(*req.args, **req.kwargs)
88
+ resp = InvocationResponse(ok=True, result=result)
89
+ return web.Response(text=resp.to_json(), content_type="application/json")
90
+ except Exception as exc:
91
+ log.exception("RPC %s failed", req.method)
92
+ resp = InvocationResponse(
93
+ ok=False,
94
+ error=str(exc),
95
+ error_type=type(exc).__name__,
96
+ )
97
+ return web.Response(text=resp.to_json(), status=500, content_type="application/json")
98
+
99
+
100
+ def build_app(manager) -> web.Application:
101
+ """Build the aiohttp Application that exposes /rpc."""
102
+ app = web.Application()
103
+ app["manager"] = manager
104
+ app.router.add_post("/rpc", _handle_rpc)
105
+ return app
106
+
107
+
108
+ def serve(manager, host: str = "127.0.0.1", port: int = 0) -> tuple:
109
+ """Start the RPC server on a background daemon thread with its own event loop.
110
+
111
+ Returns (runner, actual_port). The runner can be cleaned up by calling
112
+ `await runner.cleanup()` from any asyncio context, or by calling
113
+ `runner.shutdown()` synchronously (less clean). For daemon shutdown the
114
+ process exit handles it.
115
+ """
116
+ import asyncio
117
+ import threading
118
+
119
+ loop = asyncio.new_event_loop()
120
+ app = build_app(manager)
121
+ runner = web.AppRunner(app)
122
+ loop.run_until_complete(runner.setup())
123
+ site = web.TCPSite(runner, host=host, port=port)
124
+ loop.run_until_complete(site.start())
125
+ # Bound port (in case 0 was requested)
126
+ sock_list = list(site._server.sockets) if site._server is not None else []
127
+ actual_port = sock_list[0].getsockname()[1] if sock_list else port
128
+
129
+ thread = threading.Thread(target=loop.run_forever, daemon=True, name="susops-rpc")
130
+ thread.start()
131
+ return runner, actual_port