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/__init__.py +4 -0
- susops/client.py +230 -0
- susops/core/__init__.py +0 -0
- susops/core/browsers.py +330 -0
- susops/core/config.py +253 -0
- susops/core/log_style.py +92 -0
- susops/core/pac.py +185 -0
- susops/core/ports.py +57 -0
- susops/core/process.py +167 -0
- susops/core/rpc_protocol.py +186 -0
- susops/core/rpc_server.py +131 -0
- susops/core/services_daemon.py +312 -0
- susops/core/share.py +323 -0
- susops/core/socat.py +200 -0
- susops/core/ssh.py +330 -0
- susops/core/ssh_config.py +40 -0
- susops/core/status.py +245 -0
- susops/core/types.py +171 -0
- susops/facade.py +2237 -0
- susops/tray/__init__.py +20 -0
- susops/tray/base.py +650 -0
- susops/tray/linux.py +1623 -0
- susops/tray/mac.py +3105 -0
- susops/tui/__init__.py +0 -0
- susops/tui/__main__.py +44 -0
- susops/tui/app.py +191 -0
- susops/tui/cli.py +665 -0
- susops/tui/screens/__init__.py +114 -0
- susops/tui/screens/connections.py +871 -0
- susops/tui/screens/dashboard.py +935 -0
- susops/tui/screens/shares.py +357 -0
- susops/tui/widgets/__init__.py +0 -0
- susops/tui/widgets/connection_card.py +137 -0
- susops/version.py +12 -0
- susops-3.0.0rc3.dev1.dist-info/METADATA +977 -0
- susops-3.0.0rc3.dev1.dist-info/RECORD +40 -0
- susops-3.0.0rc3.dev1.dist-info/WHEEL +5 -0
- susops-3.0.0rc3.dev1.dist-info/entry_points.txt +7 -0
- susops-3.0.0rc3.dev1.dist-info/licenses/LICENSE +674 -0
- susops-3.0.0rc3.dev1.dist-info/top_level.txt +1 -0
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
|