1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
|
"""What every verb needs: the daemon's loopback API, the group an argument
names, and systemd for the lifecycle verbs."""
import sys
from meshbay_node.config import DEFAULT_CONFIG_PATH, Config
def _daemon_api(cfg: Config, path: str, method: str = "GET",
timeout: int = 30, body: dict | None = None) -> dict:
"""
Call the daemon's loopback API.
The daemon owns the roster, the hub session and the live group contexts, so
the CLI asks it to act rather than opening its databases behind its back. It
also means every operator action goes through the control API's per-run
session token (11.5.3), the same gate the desktop client's Node page passes.
"""
import json as _json
import urllib.error
import urllib.parse
import urllib.request
token_file = cfg.data_dir / "ui-token"
if not token_file.exists():
print("Node is not running — start it with: meshbay-node")
sys.exit(1)
sep = "&" if "?" in path else "?"
url = (f"http://127.0.0.1:{cfg.node.ui_port}{path}"
f"{sep}t={token_file.read_text(encoding="utf-8").strip()}")
try:
data = _json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
url, method=method, data=data,
headers={"Content-Type": "application/json"} if data else {})
with urllib.request.urlopen(req, timeout=timeout) as r:
return _json.loads(r.read())
except urllib.error.HTTPError as e:
raw = e.read().decode()[:600]
try:
parsed = _json.loads(raw)
detail = parsed.get("error", raw)
# Endpoints that refuse a name offer the ones that would work; a bare
# "no such group" leaves the operator guessing at a UUID.
for row in parsed.get("available", []):
detail += f"\n {row.get('name', ''):<24} {row.get('id', '')}"
except Exception:
detail = raw
print(f"failed: {detail}")
sys.exit(1)
except Exception as e:
print(f"failed: {e}")
sys.exit(1)
def _resolve_group(cfg: Config, group: str | None) -> str:
"""
The group argument as an id, or the only configured one.
Accepts a name as well, because node.toml already gives every group one and
nobody remembers a UUID. A name that matches nothing configured says so, and
lists what is — silently passing it through produced a 404 from the daemon
that read like the group did not exist on the hub.
"""
if group:
by_id = [g for g in cfg.groups if g.id == group]
if by_id:
return by_id[0].id
by_name = [g for g in cfg.groups if g.name == group and g.id]
if len(by_name) == 1:
return by_name[0].id
if len(by_name) > 1:
print(f"several groups in node.toml are named {group!r} — use the id")
sys.exit(1)
# An id this node does not host is still worth passing on: the daemon
# gives the better error, naming the group it does host.
if "-" in group and len(group) == 36:
return group
print(f"no group named {group!r} in {DEFAULT_CONFIG_PATH}")
if cfg.groups:
print("configured groups:")
for g in cfg.groups:
print(f" {g.name or '(unnamed)':<24} {g.id or '(no id yet)'}")
sys.exit(1)
configured = [g.id for g in cfg.groups if g.id]
if len(configured) == 1:
return configured[0]
print("--group is required (several groups configured)"
if configured else "no group configured in node.toml")
sys.exit(1)
def _systemctl_user(verb: str, unit: str, *, not_running_hint: str,
success: str, watch: str | None) -> None:
"""
Run `systemctl --user <verb> <unit>` and report the result.
The lifecycle authority is the unit, not this process: systemd already
knows which PID it started, restarts it on failure (`Restart=on-failure`
in the unit) and reloads it correctly (`ExecReload=`). Anything this CLI
did instead — finding a process by pattern-matching its command line,
signalling it, respawning it — is a second, worse implementation of what
systemd is already doing, and pattern-matching a process list has already
hit a real developer's real running node by accident.
Reloads the user manager's view of unit files first. The package
installers (deb postinst, rpm %post) run as root and can only reload the
*system* manager — a different process from any signed-in user's *user*
manager, which is the one that actually owns this unit — so a package
upgrade leaves that manager still holding the old unit file and prints a
warning naming the exact fix. Doing it here runs it under the right
privilege, the user's own, right before the command that would otherwise
act on a stale definition. Best-effort and unchecked: a reload the
manager did not need must never block what the operator actually asked
for, and a genuine problem still surfaces from the verb below.
"""
import subprocess
subprocess.run(["systemctl", "--user", "daemon-reload"],
capture_output=True, text=True)
result = subprocess.run(["systemctl", "--user", verb, unit],
capture_output=True, text=True)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
print(detail or not_running_hint)
sys.exit(1)
print(success)
if watch:
print(watch)
|