chp-adapter-host 0.11.0__tar.gz
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.
- chp_adapter_host-0.11.0/.gitignore +36 -0
- chp_adapter_host-0.11.0/PKG-INFO +26 -0
- chp_adapter_host-0.11.0/README.md +8 -0
- chp_adapter_host-0.11.0/chp_adapter_host/__init__.py +17 -0
- chp_adapter_host-0.11.0/chp_adapter_host/adapter.py +484 -0
- chp_adapter_host-0.11.0/pyproject.toml +36 -0
- chp_adapter_host-0.11.0/tests/test_adapter.py +187 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.next/
|
|
4
|
+
out/
|
|
5
|
+
.turbo/
|
|
6
|
+
*.tsbuildinfo
|
|
7
|
+
.yalc/
|
|
8
|
+
yalc.lock
|
|
9
|
+
*.tgz
|
|
10
|
+
.env
|
|
11
|
+
.env.*
|
|
12
|
+
coverage/
|
|
13
|
+
dashboard/
|
|
14
|
+
components/
|
|
15
|
+
# ...but the cockpit's app source lives in app/components — never ignore real source.
|
|
16
|
+
!cockpit/app/components/
|
|
17
|
+
!cockpit/app/components/**
|
|
18
|
+
!chp-evidence/app/components/
|
|
19
|
+
!chp-evidence/app/components/**
|
|
20
|
+
.tech-hub-cache/
|
|
21
|
+
__pycache__/
|
|
22
|
+
*.py[cod]
|
|
23
|
+
.pytest_cache/
|
|
24
|
+
.chp/
|
|
25
|
+
.DS_Store
|
|
26
|
+
.coverage
|
|
27
|
+
.forge/
|
|
28
|
+
.hypothesis/
|
|
29
|
+
.claude/
|
|
30
|
+
|
|
31
|
+
# chp-agent evidence store
|
|
32
|
+
.chp-agent/sessions.sqlite
|
|
33
|
+
.publish-dist/
|
|
34
|
+
|
|
35
|
+
# matrix-bot runtime session registry (CC session ids)
|
|
36
|
+
matrix-bot/sessions.json
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chp-adapter-host
|
|
3
|
+
Version: 0.11.0
|
|
4
|
+
Summary: CHP capability adapter — report and update the host runtime on a node
|
|
5
|
+
Author: Auxo
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: adapter,capability-host-protocol,chp,host,ops,update
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: chp-core>=0.8.0
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# chp-adapter-host
|
|
20
|
+
|
|
21
|
+
Report and update the CHP runtime on a node, as governed capabilities.
|
|
22
|
+
|
|
23
|
+
- `chp.adapters.host.version` — this node's chp-host version, platform, and installed adapters.
|
|
24
|
+
- `chp.adapters.host.update` — schedule a detached `chp-host update --restart` (upgrade CHP packages, then restart the node's services). Returns immediately with `scheduled: true`; the host restarts shortly after, so re-check `/health` for the new `host_version`.
|
|
25
|
+
|
|
26
|
+
Both emit evidence. `update` is `risk: high` (remote code update + restart) and is gated by the host's API key like any other capability. The primary drives a worker's update over the mesh with `chp-host mesh update <url>`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# chp-adapter-host
|
|
2
|
+
|
|
3
|
+
Report and update the CHP runtime on a node, as governed capabilities.
|
|
4
|
+
|
|
5
|
+
- `chp.adapters.host.version` — this node's chp-host version, platform, and installed adapters.
|
|
6
|
+
- `chp.adapters.host.update` — schedule a detached `chp-host update --restart` (upgrade CHP packages, then restart the node's services). Returns immediately with `scheduled: true`; the host restarts shortly after, so re-check `/health` for the new `host_version`.
|
|
7
|
+
|
|
8
|
+
Both emit evidence. `update` is `risk: high` (remote code update + restart) and is gated by the host's API key like any other capability. The primary drives a worker's update over the mesh with `chp-host mesh update <url>`.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""chp-adapter-host — report and update the CHP runtime on a node.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from chp_core import LocalCapabilityHost, register_adapter
|
|
6
|
+
from chp_adapter_host import HostAdapter
|
|
7
|
+
|
|
8
|
+
host = LocalCapabilityHost()
|
|
9
|
+
register_adapter(host, HostAdapter())
|
|
10
|
+
host.invoke("chp.adapters.host.version", {})
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .adapter import HostAdapter
|
|
16
|
+
|
|
17
|
+
__all__ = ["HostAdapter"]
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""HostAdapter — report and update the CHP runtime on this node.
|
|
2
|
+
|
|
3
|
+
Two capabilities:
|
|
4
|
+
|
|
5
|
+
* ``version`` — report this node's chp-host version, platform, installed adapters.
|
|
6
|
+
* ``update`` — schedule a **detached** ``chp-host update --restart``. The host
|
|
7
|
+
running this capability will be restarted by the upgrade, so the work must
|
|
8
|
+
outlive it: we spawn the updater in a new session and return immediately
|
|
9
|
+
(``scheduled: true``) *before* anything restarts. The caller re-checks
|
|
10
|
+
``/health`` to see the new version.
|
|
11
|
+
|
|
12
|
+
Depends only on chp-core. It does NOT import chp-host (it shells out to the
|
|
13
|
+
installed ``chp-host`` CLI) and discovers adapters via entry points, so there is
|
|
14
|
+
no dependency cycle.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import getpass
|
|
20
|
+
import glob
|
|
21
|
+
import os
|
|
22
|
+
import platform
|
|
23
|
+
import re
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from chp_core import BaseAdapter, capability
|
|
30
|
+
from chp_core.stats import collect_host_stats
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _host_version() -> str:
|
|
34
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
35
|
+
try:
|
|
36
|
+
return version("chp-host")
|
|
37
|
+
except PackageNotFoundError:
|
|
38
|
+
return "unknown"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _installed_adapters() -> list[str]:
|
|
42
|
+
from importlib.metadata import entry_points
|
|
43
|
+
return sorted(ep.name for ep in entry_points(group="chp.adapters"))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _service_safe_env() -> dict[str, str]:
|
|
47
|
+
"""A child environment safe for use under launchd/systemd (minimal env).
|
|
48
|
+
|
|
49
|
+
Ensures HOME (the service env often lacks it, which breaks pip's cache and
|
|
50
|
+
the ~/.chp log path) and a full PATH including /usr/sbin (where ioreg/sysctl
|
|
51
|
+
and other tools live)."""
|
|
52
|
+
import pwd
|
|
53
|
+
env = dict(os.environ)
|
|
54
|
+
home = env.get("HOME")
|
|
55
|
+
if not home:
|
|
56
|
+
try:
|
|
57
|
+
home = pwd.getpwuid(os.getuid()).pw_dir
|
|
58
|
+
except Exception:
|
|
59
|
+
home = "/tmp"
|
|
60
|
+
env["HOME"] = home
|
|
61
|
+
extra = "/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin:/opt/homebrew/bin"
|
|
62
|
+
env["PATH"] = (env.get("PATH", "") + ":" + extra).strip(":")
|
|
63
|
+
return env
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _profile_path_from_argv() -> str | None:
|
|
67
|
+
"""The --profile path this host was started with (so install_adapter can add a
|
|
68
|
+
new adapter to the very profile this serve process is running). The host adapter
|
|
69
|
+
runs inside `chp-host serve --profile <path>`, so sys.argv carries it."""
|
|
70
|
+
argv = sys.argv
|
|
71
|
+
for i, a in enumerate(argv):
|
|
72
|
+
if a == "--profile" and i + 1 < len(argv):
|
|
73
|
+
return argv[i + 1]
|
|
74
|
+
if a.startswith("--profile="):
|
|
75
|
+
return a.split("=", 1)[1]
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _spawn_detached_cli(args: list[str], log_name: str) -> int:
|
|
80
|
+
"""Spawn `python -m chp_host.cli <args>` detached, with a service-safe env,
|
|
81
|
+
capturing the child's stdout+stderr to ~/.chp/logs/<log_name> (so a failed
|
|
82
|
+
remote action is diagnosable, e.g. via filesystem.read_file over the mesh).
|
|
83
|
+
Returns the child pid. Used by update + restart, which must outlive the
|
|
84
|
+
service restart they trigger.
|
|
85
|
+
"""
|
|
86
|
+
child_env = _service_safe_env()
|
|
87
|
+
log_dir = os.path.join(child_env["HOME"], ".chp", "logs")
|
|
88
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
89
|
+
fd = os.open(os.path.join(log_dir, log_name), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
|
|
90
|
+
try:
|
|
91
|
+
proc = subprocess.Popen(
|
|
92
|
+
[sys.executable, "-m", "chp_host.cli", *args],
|
|
93
|
+
stdout=fd, stderr=fd, start_new_session=True, env=child_env,
|
|
94
|
+
)
|
|
95
|
+
finally:
|
|
96
|
+
os.close(fd) # Popen duplicated the fd; close our copy
|
|
97
|
+
return proc.pid
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Node setup introspection (host.facts) — turns ad-hoc spelunking into one call.
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def _facts_env() -> dict:
|
|
105
|
+
"""Env with common tool dirs prepended to PATH (rad/claude/homebrew live off the bare PATH)."""
|
|
106
|
+
env = dict(os.environ)
|
|
107
|
+
extra = [os.path.expanduser(p) for p in
|
|
108
|
+
("~/.radicle/bin", "~/.local/bin", "~/.npm-global/bin", "~/.cargo/bin")] + ["/opt/homebrew/bin"]
|
|
109
|
+
env["PATH"] = os.pathsep.join(extra + [env.get("PATH", "")])
|
|
110
|
+
return env
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _facts_sh(args: list[str], timeout: int = 10) -> str:
|
|
114
|
+
"""Run a command on the augmented PATH; return stripped stdout ('' on any failure)."""
|
|
115
|
+
try:
|
|
116
|
+
out = subprocess.run(args, capture_output=True, text=True, timeout=timeout, env=_facts_env())
|
|
117
|
+
return (out.stdout or "").strip()
|
|
118
|
+
except Exception:
|
|
119
|
+
return ""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _facts_tool(name: str) -> dict:
|
|
123
|
+
"""Locate a binary on the augmented PATH + its version (best-effort)."""
|
|
124
|
+
path = shutil.which(name, path=_facts_env()["PATH"])
|
|
125
|
+
if not path:
|
|
126
|
+
return {"present": False}
|
|
127
|
+
info = {"present": True, "path": path}
|
|
128
|
+
ver = _facts_sh([path, "--version"], timeout=8)
|
|
129
|
+
if ver:
|
|
130
|
+
info["version"] = ver.splitlines()[0][:60]
|
|
131
|
+
return info
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class HostAdapter(BaseAdapter):
|
|
135
|
+
adapter_id = "chp.adapters.host"
|
|
136
|
+
adapter_name = "Host"
|
|
137
|
+
adapter_description = "Report and update the CHP host runtime on this node."
|
|
138
|
+
adapter_category = "infrastructure"
|
|
139
|
+
adapter_tags = ["host", "update", "version", "infrastructure", "ops"]
|
|
140
|
+
|
|
141
|
+
def on_register(self, host: Any) -> None:
|
|
142
|
+
self._host = host # for the capability catalog (host.discover)
|
|
143
|
+
|
|
144
|
+
@capability(
|
|
145
|
+
id="chp.adapters.host.discover",
|
|
146
|
+
version="1.0.0",
|
|
147
|
+
description="List this node's capability catalog (id, risk, category, adapter), with optional "
|
|
148
|
+
"namespace/category/risk filters. Read-only — the mesh-invokable view of GET /capabilities.",
|
|
149
|
+
category="infrastructure",
|
|
150
|
+
risk="low",
|
|
151
|
+
input_schema={
|
|
152
|
+
"type": "object",
|
|
153
|
+
"properties": {
|
|
154
|
+
"namespace": {"type": "string",
|
|
155
|
+
"description": "Prefix filter on capability id, e.g. 'chp.adapters.git.'"},
|
|
156
|
+
"category": {"type": "string", "description": "Exact category filter."},
|
|
157
|
+
"risk": {"type": "string", "enum": ["low", "medium", "high", "critical"]},
|
|
158
|
+
"ids_only": {"type": "boolean", "description": "Return just the sorted capability id list."},
|
|
159
|
+
},
|
|
160
|
+
"additionalProperties": False,
|
|
161
|
+
},
|
|
162
|
+
emits=["host_catalog_reported"],
|
|
163
|
+
tags=["host", "discover", "catalog"],
|
|
164
|
+
)
|
|
165
|
+
async def discover(self, ctx: Any, payload: dict) -> dict:
|
|
166
|
+
host = getattr(self, "_host", None)
|
|
167
|
+
if host is None:
|
|
168
|
+
return {"error": "host catalog unavailable (adapter not registered to a host)"}
|
|
169
|
+
kwargs = {k: payload[k] for k in ("namespace", "category", "risk") if payload.get(k)}
|
|
170
|
+
desc = host.discover(**kwargs) or {}
|
|
171
|
+
caps = desc.get("capabilities", [])
|
|
172
|
+
adapters = sorted({c["id"].split(".")[2] for c in caps
|
|
173
|
+
if c.get("id") and c["id"].count(".") >= 2})
|
|
174
|
+
ctx.emit("host_catalog_reported", {"count": len(caps), "adapters": len(adapters)})
|
|
175
|
+
if payload.get("ids_only"):
|
|
176
|
+
return {"count": len(caps), "adapters": adapters,
|
|
177
|
+
"capability_ids": sorted(c.get("id") for c in caps if c.get("id"))}
|
|
178
|
+
slim = [{"id": c.get("id"), "risk": c.get("risk"), "category": c.get("category"),
|
|
179
|
+
"description": (c.get("description") or "")[:120]} for c in caps]
|
|
180
|
+
return {"count": len(caps), "adapters": adapters, "capabilities": slim}
|
|
181
|
+
|
|
182
|
+
@capability(
|
|
183
|
+
id="chp.adapters.host.version",
|
|
184
|
+
version="1.0.0",
|
|
185
|
+
description="Report this node's chp-host version, platform, and installed adapters.",
|
|
186
|
+
category="infrastructure",
|
|
187
|
+
risk="low",
|
|
188
|
+
input_schema={"type": "object", "properties": {}, "additionalProperties": False},
|
|
189
|
+
emits=["host_version_reported"],
|
|
190
|
+
tags=["host", "version"],
|
|
191
|
+
)
|
|
192
|
+
async def version(self, ctx: Any, payload: dict) -> dict:
|
|
193
|
+
info = {
|
|
194
|
+
"host_version": _host_version(),
|
|
195
|
+
"platform": platform.platform(),
|
|
196
|
+
"python": sys.version.split()[0],
|
|
197
|
+
"adapters": _installed_adapters(),
|
|
198
|
+
}
|
|
199
|
+
ctx.emit("host_version_reported", {"host_version": info["host_version"]})
|
|
200
|
+
return info
|
|
201
|
+
|
|
202
|
+
@capability(
|
|
203
|
+
id="chp.adapters.host.facts",
|
|
204
|
+
version="1.0.0",
|
|
205
|
+
description="Introspect THIS node's setup in one call: host/arch/python, toolchain (claude/codex/rad/"
|
|
206
|
+
"git paths+versions), launchd services, radicle identity/homes/node/seed-policies, and rad "
|
|
207
|
+
"repo checkouts. The 'how is this node configured' view (Ansible-facts-shaped). Runs locally "
|
|
208
|
+
"on whichever node it is routed to.",
|
|
209
|
+
category="infrastructure",
|
|
210
|
+
risk="low",
|
|
211
|
+
input_schema={
|
|
212
|
+
"type": "object",
|
|
213
|
+
"properties": {
|
|
214
|
+
"sections": {
|
|
215
|
+
"type": "array",
|
|
216
|
+
"items": {"type": "string", "enum": ["host", "tools", "services", "radicle", "repos"]},
|
|
217
|
+
"description": "Subset of fact sections to gather (default: all).",
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
"additionalProperties": False,
|
|
221
|
+
},
|
|
222
|
+
emits=["host_facts_reported"],
|
|
223
|
+
tags=["host", "facts", "introspection", "setup", "ops"],
|
|
224
|
+
)
|
|
225
|
+
async def facts(self, ctx: Any, payload: dict) -> dict:
|
|
226
|
+
sections = set(payload.get("sections") or ["host", "tools", "services", "radicle", "repos"])
|
|
227
|
+
out: dict[str, Any] = {}
|
|
228
|
+
|
|
229
|
+
if "host" in sections:
|
|
230
|
+
try:
|
|
231
|
+
user = getpass.getuser()
|
|
232
|
+
except Exception:
|
|
233
|
+
user = os.environ.get("USER", "")
|
|
234
|
+
out["host"] = {
|
|
235
|
+
"hostname": platform.node(), "platform": platform.platform(),
|
|
236
|
+
"arch": platform.machine(), "user": user,
|
|
237
|
+
"python": sys.executable, "home": os.path.expanduser("~"),
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if "tools" in sections:
|
|
241
|
+
out["tools"] = {n: _facts_tool(n) for n in ("claude", "codex", "gemini", "rad", "git", "node")}
|
|
242
|
+
|
|
243
|
+
if "services" in sections:
|
|
244
|
+
ll = _facts_sh(["launchctl", "list"])
|
|
245
|
+
out["services"] = sorted({
|
|
246
|
+
line.split()[-1] for line in ll.splitlines()
|
|
247
|
+
if line.split() and ("chp" in line.lower() or "rad" in line.lower())
|
|
248
|
+
})[:25]
|
|
249
|
+
|
|
250
|
+
if "radicle" in sections:
|
|
251
|
+
rad: dict[str, Any] = {}
|
|
252
|
+
for line in _facts_sh(["rad", "self"]).splitlines():
|
|
253
|
+
low = line.strip().lower()
|
|
254
|
+
if low.startswith("alias"):
|
|
255
|
+
rad["alias"] = line.split(None, 1)[-1].strip()
|
|
256
|
+
elif low.startswith("did"):
|
|
257
|
+
rad["did"] = line.split(None, 1)[-1].strip() # did:key is a public id (NID not surfaced)
|
|
258
|
+
rad["homes"] = sorted(os.path.basename(p) for p in glob.glob(os.path.expanduser("~/.radicle*")))
|
|
259
|
+
node = _facts_sh(["rad", "node", "status"])
|
|
260
|
+
rad["node_running"] = "running" in node.lower() and "not running" not in node.lower()
|
|
261
|
+
seed = _facts_sh(["rad", "seed"])
|
|
262
|
+
rad["seeded_repos"] = sum(1 for line in seed.splitlines()
|
|
263
|
+
if line.strip().startswith("│") and "rad:" in line)
|
|
264
|
+
out["radicle"] = rad
|
|
265
|
+
|
|
266
|
+
if "repos" in sections:
|
|
267
|
+
repos = []
|
|
268
|
+
for line in _facts_sh(["rad", "ls"]).splitlines():
|
|
269
|
+
m = re.search(r"rad:[0-9a-zA-Z]+", line)
|
|
270
|
+
if m and line.strip().startswith("│"):
|
|
271
|
+
name = line.strip("│").split()[0] if line.strip("│").split() else ""
|
|
272
|
+
repos.append({"name": name, "rid": m.group(0)})
|
|
273
|
+
out["repos"] = repos[:40]
|
|
274
|
+
|
|
275
|
+
tools_present = sum(1 for t in out.get("tools", {}).values() if t.get("present"))
|
|
276
|
+
ctx.emit("host_facts_reported",
|
|
277
|
+
{"sections": sorted(out.keys()), "tools_present": tools_present})
|
|
278
|
+
return out
|
|
279
|
+
|
|
280
|
+
@capability(
|
|
281
|
+
id="chp.adapters.host.topology",
|
|
282
|
+
version="1.0.0",
|
|
283
|
+
description="Mesh connectivity view from this node: the radicle peer graph (who it's connected to) "
|
|
284
|
+
"+ Tailscale device status (which mesh nodes are online). 'Where the nodes are connected'.",
|
|
285
|
+
category="infrastructure",
|
|
286
|
+
risk="low",
|
|
287
|
+
input_schema={"type": "object", "properties": {}, "additionalProperties": False},
|
|
288
|
+
emits=["host_topology_reported"],
|
|
289
|
+
tags=["host", "topology", "mesh", "connectivity", "ops"],
|
|
290
|
+
)
|
|
291
|
+
async def topology(self, ctx: Any, payload: dict) -> dict:
|
|
292
|
+
# Radicle peer graph (connected = "✓" marker; address shown for reachable peers).
|
|
293
|
+
peers = []
|
|
294
|
+
for line in _facts_sh(["rad", "node", "status"]).splitlines():
|
|
295
|
+
if "│" not in line:
|
|
296
|
+
continue
|
|
297
|
+
nid = re.search(r"z6Mk[0-9A-Za-z]{20,}", line)
|
|
298
|
+
addr = re.search(r"\b(100\.\d+\.\d+\.\d+:\d+|[\w.-]+:\d{2,5})\b", line)
|
|
299
|
+
if nid:
|
|
300
|
+
peers.append({"nid": nid.group(0)[:14] + "…", "address": addr.group(0) if addr else "",
|
|
301
|
+
"connected": "✓" in line})
|
|
302
|
+
|
|
303
|
+
# Tailscale device status (mesh-node reachability).
|
|
304
|
+
ts = _facts_sh(["tailscale", "status"]) or \
|
|
305
|
+
_facts_sh(["/Applications/Tailscale.app/Contents/MacOS/Tailscale", "status"])
|
|
306
|
+
devices = []
|
|
307
|
+
for line in ts.splitlines():
|
|
308
|
+
parts = line.split()
|
|
309
|
+
if len(parts) >= 2 and parts[0].startswith("100."):
|
|
310
|
+
devices.append({"ip": parts[0], "name": parts[1],
|
|
311
|
+
"os": parts[3] if len(parts) >= 4 else "",
|
|
312
|
+
"online": "offline" not in line.lower()})
|
|
313
|
+
|
|
314
|
+
result = {"radicle_peers": peers,
|
|
315
|
+
"radicle_connected": sum(1 for p in peers if p["connected"]),
|
|
316
|
+
"tailscale_devices": devices[:50],
|
|
317
|
+
"tailscale_online": sum(1 for d in devices if d["online"])}
|
|
318
|
+
ctx.emit("host_topology_reported",
|
|
319
|
+
{"peers": len(peers), "devices": len(devices), "online": result["tailscale_online"]})
|
|
320
|
+
return result
|
|
321
|
+
|
|
322
|
+
@capability(
|
|
323
|
+
id="chp.adapters.host.stats",
|
|
324
|
+
version="1.0.0",
|
|
325
|
+
description="Report CPU load, memory, disk, GPU, and platform stats for this node.",
|
|
326
|
+
category="infrastructure",
|
|
327
|
+
risk="low",
|
|
328
|
+
input_schema={"type": "object", "properties": {}, "additionalProperties": False},
|
|
329
|
+
emits=["host_stats_reported"],
|
|
330
|
+
tags=["host", "stats", "capacity"],
|
|
331
|
+
)
|
|
332
|
+
async def stats(self, ctx: Any, payload: dict) -> dict:
|
|
333
|
+
result = collect_host_stats()
|
|
334
|
+
ctx.emit("host_stats_reported", {
|
|
335
|
+
"load_per_core": result.get("load_per_core"),
|
|
336
|
+
"gpu": result.get("gpu"),
|
|
337
|
+
})
|
|
338
|
+
return result
|
|
339
|
+
|
|
340
|
+
@capability(
|
|
341
|
+
id="chp.adapters.host.update",
|
|
342
|
+
version="1.0.0",
|
|
343
|
+
description="Schedule a detached upgrade of this node's CHP packages, then restart its services.",
|
|
344
|
+
category="infrastructure",
|
|
345
|
+
risk="high",
|
|
346
|
+
input_schema={
|
|
347
|
+
"type": "object",
|
|
348
|
+
"properties": {
|
|
349
|
+
"version": {"type": "string", "description": "Pin chp-core/chp-host to this version."},
|
|
350
|
+
"channel": {"type": "string", "enum": ["github", "pypi"]},
|
|
351
|
+
"require_provenance": {"type": "boolean", "default": False, "description": "Verify every package's signed provenance BEFORE upgrading any (spec section 9, atomic)."},
|
|
352
|
+
},
|
|
353
|
+
"additionalProperties": False,
|
|
354
|
+
},
|
|
355
|
+
emits=["host_update_scheduled"],
|
|
356
|
+
tags=["host", "update", "ops"],
|
|
357
|
+
)
|
|
358
|
+
async def update(self, ctx: Any, payload: dict) -> dict:
|
|
359
|
+
before = _host_version()
|
|
360
|
+
# `chp-host update` restarts by default (the flag is --no-restart).
|
|
361
|
+
args = ["update"]
|
|
362
|
+
if payload.get("require_provenance"):
|
|
363
|
+
args += ["--require-provenance"]
|
|
364
|
+
store_path = getattr(getattr(getattr(ctx, "host", None), "store", None), "path", None)
|
|
365
|
+
if store_path and store_path != ":memory:":
|
|
366
|
+
child = ctx.child_correlation()
|
|
367
|
+
args += ["--evidence-store", str(store_path),
|
|
368
|
+
"--correlation-id", str(child.correlation_id),
|
|
369
|
+
"--host-id", str(getattr(ctx.host, "host_id", "") or "unknown")]
|
|
370
|
+
if child.causation_id:
|
|
371
|
+
args += ["--causation-id", str(child.causation_id)]
|
|
372
|
+
if payload.get("version"):
|
|
373
|
+
args += ["--version", str(payload["version"])]
|
|
374
|
+
if payload.get("channel"):
|
|
375
|
+
args += ["--channel", str(payload["channel"])]
|
|
376
|
+
pid = _spawn_detached_cli(args, "host-update-child.log")
|
|
377
|
+
ctx.emit("host_update_scheduled", {"from_version": before, "pid": pid})
|
|
378
|
+
return {
|
|
379
|
+
"from_version": before,
|
|
380
|
+
"scheduled": True,
|
|
381
|
+
"pid": pid,
|
|
382
|
+
"note": "Update runs detached; this host will restart shortly. "
|
|
383
|
+
"Re-check /health for the new host_version.",
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
@capability(
|
|
387
|
+
id="chp.adapters.host.install_adapter",
|
|
388
|
+
version="1.0.0",
|
|
389
|
+
description=(
|
|
390
|
+
"Install (or pull) a CHP adapter package onto this node, optionally add it "
|
|
391
|
+
"to this host's profile, then restart so the new capabilities register. "
|
|
392
|
+
"Detached — this host restarts shortly after scheduling."
|
|
393
|
+
),
|
|
394
|
+
category="infrastructure",
|
|
395
|
+
risk="high",
|
|
396
|
+
input_schema={
|
|
397
|
+
"type": "object",
|
|
398
|
+
"properties": {
|
|
399
|
+
"package": {"type": "string", "description": "pip package name, e.g. 'chp-adapter-mlx'"},
|
|
400
|
+
"version": {"type": "string", "description": "Pin to this version (ignored if url is set)."},
|
|
401
|
+
"url": {"type": "string", "description": "Direct wheel/sdist URL (e.g. a GitHub release asset)."},
|
|
402
|
+
"adapter_name": {"type": "string", "description": "Short entry-point name to add to this host's profile, e.g. 'mlx'."},
|
|
403
|
+
"extras": {"type": "string", "description": "Optional-dependency extra to also install, e.g. 'serve' → chp-adapter-mlx[serve] (pulls the adapter's runtime tooling)."},
|
|
404
|
+
"restart": {"type": "boolean", "default": True},
|
|
405
|
+
"require_provenance": {"type": "boolean", "default": False, "description": "Verify the publisher's signed provenance statement against the artifact BEFORE installing (spec section 9)."},
|
|
406
|
+
"publisher_key": {"type": "string", "description": "Require this publisher key_id."},
|
|
407
|
+
"publisher_domain": {"type": "string", "description": "Require this domain anchor on the publisher attestation."},
|
|
408
|
+
"provenance": {"type": "string", "description": "Statement path/URL (default: GitHub release asset convention)."},
|
|
409
|
+
},
|
|
410
|
+
"required": ["package"],
|
|
411
|
+
"additionalProperties": False,
|
|
412
|
+
},
|
|
413
|
+
emits=["host_adapter_install_scheduled", "host_adapter_installed"],
|
|
414
|
+
tags=["host", "adapter", "install", "ops"],
|
|
415
|
+
)
|
|
416
|
+
async def install_adapter(self, ctx: Any, payload: dict) -> dict:
|
|
417
|
+
package = str(payload.get("package") or "").strip()
|
|
418
|
+
if not package:
|
|
419
|
+
raise ValueError("package is required")
|
|
420
|
+
args = ["install-adapter", package]
|
|
421
|
+
# Deferred-evidence coordinates (chp-v0.2.md §7): the detached install
|
|
422
|
+
# appends `host_adapter_installed` (version + record_sha256 provenance)
|
|
423
|
+
# under THIS correlation with a causal edge to this invocation.
|
|
424
|
+
store_path = getattr(getattr(getattr(ctx, "host", None), "store", None), "path", None)
|
|
425
|
+
if store_path and store_path != ":memory:":
|
|
426
|
+
child = ctx.child_correlation()
|
|
427
|
+
args += ["--evidence-store", str(store_path),
|
|
428
|
+
"--correlation-id", str(child.correlation_id),
|
|
429
|
+
"--host-id", str(getattr(ctx.host, "host_id", "") or "unknown")]
|
|
430
|
+
if child.causation_id:
|
|
431
|
+
args += ["--causation-id", str(child.causation_id)]
|
|
432
|
+
if payload.get("url"):
|
|
433
|
+
args += ["--url", str(payload["url"])]
|
|
434
|
+
elif payload.get("version"):
|
|
435
|
+
args += ["--version", str(payload["version"])]
|
|
436
|
+
if payload.get("extras"):
|
|
437
|
+
args += ["--extras", str(payload["extras"])]
|
|
438
|
+
adapter_name = payload.get("adapter_name")
|
|
439
|
+
profile = None
|
|
440
|
+
if adapter_name:
|
|
441
|
+
args += ["--adapter-name", str(adapter_name)]
|
|
442
|
+
profile = _profile_path_from_argv()
|
|
443
|
+
if profile:
|
|
444
|
+
args += ["--profile", profile]
|
|
445
|
+
if payload.get("require_provenance"):
|
|
446
|
+
args += ["--require-provenance"]
|
|
447
|
+
for flag in ("publisher_key", "publisher_domain", "provenance"):
|
|
448
|
+
if payload.get(flag):
|
|
449
|
+
args += [f"--{flag.replace('_', '-')}", str(payload[flag])]
|
|
450
|
+
if payload.get("restart") is False:
|
|
451
|
+
args += ["--no-restart"]
|
|
452
|
+
pid = _spawn_detached_cli(args, "host-install-adapter-child.log")
|
|
453
|
+
ctx.emit("host_adapter_install_scheduled",
|
|
454
|
+
{"package": package, "adapter_name": adapter_name, "pid": pid})
|
|
455
|
+
return {
|
|
456
|
+
"scheduled": True,
|
|
457
|
+
"pid": pid,
|
|
458
|
+
"package": package,
|
|
459
|
+
"adapter_name": adapter_name,
|
|
460
|
+
"profile": profile,
|
|
461
|
+
"note": "Install runs detached; this node restarts shortly. Re-check "
|
|
462
|
+
"/capabilities (or host.version adapters) for the new adapter.",
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
@capability(
|
|
466
|
+
id="chp.adapters.host.restart",
|
|
467
|
+
version="1.0.0",
|
|
468
|
+
description="Restart this node's CHP services (detached, no upgrade).",
|
|
469
|
+
category="infrastructure",
|
|
470
|
+
risk="high",
|
|
471
|
+
input_schema={"type": "object", "properties": {}, "additionalProperties": False},
|
|
472
|
+
emits=["host_restart_scheduled"],
|
|
473
|
+
tags=["host", "restart", "ops"],
|
|
474
|
+
)
|
|
475
|
+
async def restart(self, ctx: Any, payload: dict) -> dict:
|
|
476
|
+
# Detached so it survives the very services it restarts. Captures output
|
|
477
|
+
# to ~/.chp/logs/host-restart-child.log for remote diagnosis.
|
|
478
|
+
pid = _spawn_detached_cli(["restart"], "host-restart-child.log")
|
|
479
|
+
ctx.emit("host_restart_scheduled", {"pid": pid})
|
|
480
|
+
return {
|
|
481
|
+
"scheduled": True,
|
|
482
|
+
"pid": pid,
|
|
483
|
+
"note": "Restart runs detached; this node's services bounce shortly.",
|
|
484
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "chp-adapter-host"
|
|
7
|
+
version = "0.11.0"
|
|
8
|
+
description = "CHP capability adapter — report and update the host runtime on a node"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "Auxo" }]
|
|
13
|
+
keywords = ["chp", "capability-host-protocol", "host", "update", "ops", "adapter"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"chp-core>=0.8.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.entry-points."chp.adapters"]
|
|
26
|
+
host = "chp_adapter_host:HostAdapter"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = ["pytest>=8.0"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
pythonpath = ["."]
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["chp_adapter_host"]
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Tests for chp-adapter-host."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from chp_core import LocalCapabilityHost, register_adapter
|
|
6
|
+
|
|
7
|
+
from chp_adapter_host import HostAdapter
|
|
8
|
+
from chp_adapter_host import adapter as adapter_module
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _host():
|
|
12
|
+
h = LocalCapabilityHost("test")
|
|
13
|
+
register_adapter(h, HostAdapter())
|
|
14
|
+
return h
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_version_capability():
|
|
18
|
+
result = _host().invoke("chp.adapters.host.version", {})
|
|
19
|
+
assert result.outcome == "success"
|
|
20
|
+
assert "host_version" in result.data
|
|
21
|
+
assert isinstance(result.data["adapters"], list)
|
|
22
|
+
assert "platform" in result.data
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_stats_capability_success():
|
|
26
|
+
result = _host().invoke("chp.adapters.host.stats", {})
|
|
27
|
+
assert result.outcome == "success"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_stats_capability_has_cpu_count():
|
|
31
|
+
result = _host().invoke("chp.adapters.host.stats", {})
|
|
32
|
+
assert result.outcome == "success"
|
|
33
|
+
assert "cpu_count" in result.data
|
|
34
|
+
assert isinstance(result.data["cpu_count"], int)
|
|
35
|
+
assert result.data["cpu_count"] >= 1
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_stats_capability_has_load_per_core():
|
|
39
|
+
result = _host().invoke("chp.adapters.host.stats", {})
|
|
40
|
+
assert result.outcome == "success"
|
|
41
|
+
# load_per_core may be None on platforms without getloadavg, but key must exist
|
|
42
|
+
assert "load_per_core" in result.data
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_stats_capability_has_disk():
|
|
46
|
+
result = _host().invoke("chp.adapters.host.stats", {})
|
|
47
|
+
assert result.outcome == "success"
|
|
48
|
+
disk = result.data.get("disk")
|
|
49
|
+
assert disk is not None
|
|
50
|
+
assert disk["total_gb"] > 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_stats_capability_evidence_recorded():
|
|
54
|
+
result = _host().invoke("chp.adapters.host.stats", {})
|
|
55
|
+
assert result.outcome == "success"
|
|
56
|
+
assert result.evidence_ids
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_update_schedules_detached(monkeypatch):
|
|
60
|
+
calls: dict = {}
|
|
61
|
+
|
|
62
|
+
class FakeProc:
|
|
63
|
+
pid = 4242
|
|
64
|
+
|
|
65
|
+
def fake_popen(cmd, **kwargs):
|
|
66
|
+
calls["cmd"] = cmd
|
|
67
|
+
calls["kwargs"] = kwargs
|
|
68
|
+
return FakeProc()
|
|
69
|
+
|
|
70
|
+
monkeypatch.setattr(adapter_module.subprocess, "Popen", fake_popen)
|
|
71
|
+
|
|
72
|
+
result = _host().invoke("chp.adapters.host.update", {"version": "0.8.9", "channel": "pypi"})
|
|
73
|
+
assert result.outcome == "success"
|
|
74
|
+
assert result.data["scheduled"] is True
|
|
75
|
+
assert result.data["pid"] == 4242
|
|
76
|
+
|
|
77
|
+
# Detached so it survives the host restart it triggers.
|
|
78
|
+
assert calls["kwargs"].get("start_new_session") is True
|
|
79
|
+
# Shells out to `chp-host update` (restart is the default). It must NOT pass
|
|
80
|
+
# --restart — that's not a valid flag and argparse would kill the child.
|
|
81
|
+
assert "update" in calls["cmd"]
|
|
82
|
+
assert "--restart" not in calls["cmd"]
|
|
83
|
+
assert "--version" in calls["cmd"] and "0.8.9" in calls["cmd"]
|
|
84
|
+
assert "--channel" in calls["cmd"] and "pypi" in calls["cmd"]
|
|
85
|
+
# The child env must carry HOME (services run without it) so pip + logging work.
|
|
86
|
+
assert calls["kwargs"].get("env", {}).get("HOME")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_install_adapter_schedules_detached(monkeypatch):
|
|
90
|
+
calls: dict = {}
|
|
91
|
+
|
|
92
|
+
class FakeProc:
|
|
93
|
+
pid = 99
|
|
94
|
+
|
|
95
|
+
def fake_popen(cmd, **kwargs):
|
|
96
|
+
calls["cmd"] = cmd
|
|
97
|
+
calls["kwargs"] = kwargs
|
|
98
|
+
return FakeProc()
|
|
99
|
+
|
|
100
|
+
monkeypatch.setattr(adapter_module.subprocess, "Popen", fake_popen)
|
|
101
|
+
# Pretend this host was started with a profile (so adapter_name → profile edit).
|
|
102
|
+
monkeypatch.setattr(adapter_module.sys, "argv",
|
|
103
|
+
["chp-host", "serve", "--profile", "/tmp/inference.json"])
|
|
104
|
+
|
|
105
|
+
result = _host().invoke("chp.adapters.host.install_adapter",
|
|
106
|
+
{"package": "chp-adapter-mlx", "adapter_name": "mlx"})
|
|
107
|
+
assert result.outcome == "success"
|
|
108
|
+
assert result.data["scheduled"] is True
|
|
109
|
+
cmd = calls["cmd"]
|
|
110
|
+
assert "install-adapter" in cmd and "chp-adapter-mlx" in cmd
|
|
111
|
+
# adapter_name + discovered profile are passed through to the installer.
|
|
112
|
+
assert "--adapter-name" in cmd and "mlx" in cmd
|
|
113
|
+
assert "--profile" in cmd and "/tmp/inference.json" in cmd
|
|
114
|
+
assert calls["kwargs"].get("start_new_session") is True
|
|
115
|
+
assert calls["kwargs"].get("env", {}).get("HOME")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_install_adapter_with_extras(monkeypatch):
|
|
119
|
+
calls: dict = {}
|
|
120
|
+
monkeypatch.setattr(adapter_module.subprocess, "Popen",
|
|
121
|
+
lambda cmd, **kw: calls.update(cmd=cmd) or type("P", (), {"pid": 1})())
|
|
122
|
+
result = _host().invoke("chp.adapters.host.install_adapter",
|
|
123
|
+
{"package": "chp-adapter-mlx", "adapter_name": "mlx", "extras": "serve"})
|
|
124
|
+
assert result.outcome == "success"
|
|
125
|
+
cmd = calls["cmd"]
|
|
126
|
+
assert "--extras" in cmd and "serve" in cmd
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_install_adapter_with_wheel_url(monkeypatch):
|
|
130
|
+
calls: dict = {}
|
|
131
|
+
monkeypatch.setattr(adapter_module.subprocess, "Popen",
|
|
132
|
+
lambda cmd, **kw: calls.update(cmd=cmd) or type("P", (), {"pid": 1})())
|
|
133
|
+
result = _host().invoke("chp.adapters.host.install_adapter",
|
|
134
|
+
{"package": "chp-adapter-mlx", "url": "https://example/chp_adapter_mlx-0.8.0.whl"})
|
|
135
|
+
assert result.outcome == "success"
|
|
136
|
+
cmd = calls["cmd"]
|
|
137
|
+
assert "--url" in cmd and "https://example/chp_adapter_mlx-0.8.0.whl" in cmd
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_install_adapter_requires_package():
|
|
141
|
+
result = _host().invoke("chp.adapters.host.install_adapter", {"package": ""})
|
|
142
|
+
assert result.outcome != "success"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_restart_schedules_detached(monkeypatch):
|
|
146
|
+
calls: dict = {}
|
|
147
|
+
|
|
148
|
+
class FakeProc:
|
|
149
|
+
pid = 7
|
|
150
|
+
|
|
151
|
+
def fake_popen(cmd, **kwargs):
|
|
152
|
+
calls["cmd"] = cmd
|
|
153
|
+
calls["kwargs"] = kwargs
|
|
154
|
+
return FakeProc()
|
|
155
|
+
|
|
156
|
+
monkeypatch.setattr(adapter_module.subprocess, "Popen", fake_popen)
|
|
157
|
+
result = _host().invoke("chp.adapters.host.restart", {})
|
|
158
|
+
assert result.outcome == "success"
|
|
159
|
+
assert result.data["scheduled"] is True
|
|
160
|
+
# Spawns `chp-host restart` detached with HOME — no upgrade, no bogus flags.
|
|
161
|
+
assert "restart" in calls["cmd"] and "update" not in calls["cmd"]
|
|
162
|
+
assert calls["kwargs"].get("start_new_session") is True
|
|
163
|
+
assert calls["kwargs"].get("env", {}).get("HOME")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_facts_capability():
|
|
167
|
+
result = _host().invoke("chp.adapters.host.facts", {"sections": ["host", "tools"]})
|
|
168
|
+
assert result.outcome == "success"
|
|
169
|
+
d = result.data
|
|
170
|
+
assert "host" in d and "tools" in d
|
|
171
|
+
assert d["host"]["python"] # interpreter path always present
|
|
172
|
+
assert isinstance(d["tools"]["git"], dict) # each tool → {present, [path, version]}
|
|
173
|
+
assert "present" in d["tools"]["git"]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_facts_unknown_field_denied():
|
|
177
|
+
result = _host().invoke("chp.adapters.host.facts", {"bogus": 1})
|
|
178
|
+
assert result.outcome == "denied"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_topology_capability():
|
|
182
|
+
result = _host().invoke("chp.adapters.host.topology", {})
|
|
183
|
+
assert result.outcome == "success"
|
|
184
|
+
d = result.data
|
|
185
|
+
assert isinstance(d["radicle_peers"], list)
|
|
186
|
+
assert isinstance(d["tailscale_devices"], list)
|
|
187
|
+
assert "radicle_connected" in d and "tailscale_online" in d
|