fleetmesh-node 0.2.0__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.
Files changed (338) hide show
  1. fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
  2. fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
  3. fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
  4. fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
  5. fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
  6. fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
  7. node/RELEASE +1 -0
  8. node/__init__.py +35 -0
  9. node/app/__init__.py +49 -0
  10. node/app/commands.py +265 -0
  11. node/app/config.py +131 -0
  12. node/app/control.py +670 -0
  13. node/app/core.py +867 -0
  14. node/app/plugins.py +76 -0
  15. node/app/router.py +83 -0
  16. node/app/socket.py +431 -0
  17. node/app/systemd.py +36 -0
  18. node/app/views.py +204 -0
  19. node/channels/__init__.py +378 -0
  20. node/channels/bus.py +331 -0
  21. node/channels/journal.py +46 -0
  22. node/commands/__init__.py +140 -0
  23. node/commands/adapter.py +210 -0
  24. node/commands/base.py +278 -0
  25. node/commands/cluster_cmds.py +183 -0
  26. node/commands/compute_cmds.py +401 -0
  27. node/commands/core.py +474 -0
  28. node/commands/gossip_cmds.py +106 -0
  29. node/commands/nsite_cmds.py +141 -0
  30. node/commands/peering.py +402 -0
  31. node/commands/plugin_cmds.py +135 -0
  32. node/commands/sidecar_cmds.py +120 -0
  33. node/commands/sync.py +388 -0
  34. node/commands/transport_cmds.py +451 -0
  35. node/commands/video_cmds.py +99 -0
  36. node/compartment/__init__.py +124 -0
  37. node/compartment/base.py +868 -0
  38. node/compartment/command.py +458 -0
  39. node/compartment/context.py +163 -0
  40. node/compartment/every.py +108 -0
  41. node/compartment/handler.py +131 -0
  42. node/compartment/lifecycle.py +460 -0
  43. node/compartment/lint.py +388 -0
  44. node/compartment/need.py +158 -0
  45. node/compartment/problem.py +521 -0
  46. node/compartment/refusals.py +120 -0
  47. node/compartment/registry.py +790 -0
  48. node/compartment/schema.py +290 -0
  49. node/compartment/sidecar.py +499 -0
  50. node/compartment/stores.py +662 -0
  51. node/compartment/wire.py +225 -0
  52. node/compartment/worker.py +61 -0
  53. node/config/__init__.py +43 -0
  54. node/config/base.py +116 -0
  55. node/config/change.py +441 -0
  56. node/config/changestore.py +201 -0
  57. node/config/compartments.py +1144 -0
  58. node/config/node.py +318 -0
  59. node/config/parameter.py +563 -0
  60. node/config/protocols.py +274 -0
  61. node/config/records.py +171 -0
  62. node/examples/__init__.py +1 -0
  63. node/examples/demo.py +226 -0
  64. node/examples/follower.py +46 -0
  65. node/interfaces.py +235 -0
  66. node/keystore/__init__.py +27 -0
  67. node/keystore/core.py +103 -0
  68. node/keystore/enclave.py +688 -0
  69. node/meshnode.py +111 -0
  70. node/metrics/__init__.py +396 -0
  71. node/metrics/adapters.py +102 -0
  72. node/metrics/face.py +110 -0
  73. node/planes/__init__.py +21 -0
  74. node/planes/identity/__init__.py +20 -0
  75. node/planes/identity/announce/__init__.py +9 -0
  76. node/planes/identity/announce/compartment.py +447 -0
  77. node/planes/identity/announce/types.py +26 -0
  78. node/planes/identity/discovery/__init__.py +9 -0
  79. node/planes/identity/discovery/compartment.py +522 -0
  80. node/planes/identity/discovery/types.py +13 -0
  81. node/planes/identity/linked/__init__.py +1 -0
  82. node/planes/identity/linked/compartment.py +270 -0
  83. node/planes/identity/linked/types.py +25 -0
  84. node/planes/identity/presence/__init__.py +7 -0
  85. node/planes/identity/presence/compartment.py +409 -0
  86. node/planes/identity/presence/types.py +14 -0
  87. node/planes/operations/__init__.py +13 -0
  88. node/planes/operations/scheduler/__init__.py +9 -0
  89. node/planes/operations/scheduler/compartment.py +353 -0
  90. node/planes/operations/scheduler/types.py +21 -0
  91. node/planes/operations/updates/__init__.py +17 -0
  92. node/planes/operations/updates/core.py +310 -0
  93. node/planes/operations/updates/store.py +79 -0
  94. node/planes/operations/updates/types.py +20 -0
  95. node/planes/peering/__init__.py +28 -0
  96. node/planes/peering/budget/__init__.py +43 -0
  97. node/planes/peering/budget/chain.py +43 -0
  98. node/planes/peering/budget/core.py +537 -0
  99. node/planes/peering/budget/evaluation.py +80 -0
  100. node/planes/peering/budget/meter.py +100 -0
  101. node/planes/peering/budget/receipts.py +439 -0
  102. node/planes/peering/budget/shape.py +101 -0
  103. node/planes/peering/budget/tick.py +110 -0
  104. node/planes/peering/budget/types.py +67 -0
  105. node/planes/peering/budget/windows.py +346 -0
  106. node/planes/peering/capacity/__init__.py +7 -0
  107. node/planes/peering/capacity/compartment.py +342 -0
  108. node/planes/peering/capacity/types.py +24 -0
  109. node/planes/peering/peering/__init__.py +25 -0
  110. node/planes/peering/peering/core.py +464 -0
  111. node/planes/peering/peering/envelope.py +142 -0
  112. node/planes/peering/peering/handshake.py +198 -0
  113. node/planes/peering/peering/shape.py +80 -0
  114. node/planes/peering/peering/types.py +39 -0
  115. node/planes/peering/reporter/__init__.py +42 -0
  116. node/planes/peering/reporter/constants.py +29 -0
  117. node/planes/peering/reporter/core.py +299 -0
  118. node/planes/peering/reporter/finding.py +74 -0
  119. node/planes/peering/reporter/findings.py +171 -0
  120. node/planes/peering/reporter/publish.py +285 -0
  121. node/planes/peering/reporter/reading.py +193 -0
  122. node/planes/peering/reporter/shape.py +86 -0
  123. node/planes/peering/reporter/types.py +50 -0
  124. node/planes/peering/strain/__init__.py +9 -0
  125. node/planes/peering/strain/compartment.py +204 -0
  126. node/planes/peering/strain/types.py +21 -0
  127. node/planes/peering/trust/__init__.py +9 -0
  128. node/planes/peering/trust/compartment.py +239 -0
  129. node/planes/peering/trust/types.py +30 -0
  130. node/planes/services/__init__.py +33 -0
  131. node/planes/services/compute/__init__.py +160 -0
  132. node/planes/services/compute/common.py +134 -0
  133. node/planes/services/compute/compartment.py +681 -0
  134. node/planes/services/compute/executors/__init__.py +121 -0
  135. node/planes/services/compute/executors/agent.py +840 -0
  136. node/planes/services/compute/executors/bacalhau.py +605 -0
  137. node/planes/services/compute/executors/base.py +127 -0
  138. node/planes/services/compute/executors/harness.py +439 -0
  139. node/planes/services/compute/executors/mcp.py +454 -0
  140. node/planes/services/compute/executors/media.py +898 -0
  141. node/planes/services/compute/executors/nondet.py +283 -0
  142. node/planes/services/compute/executors/providers.py +499 -0
  143. node/planes/services/compute/executors/registry.py +140 -0
  144. node/planes/services/compute/executors/wasm.py +244 -0
  145. node/planes/services/compute/executors/zk.py +604 -0
  146. node/planes/services/compute/jobs.py +227 -0
  147. node/planes/services/compute/load.py +180 -0
  148. node/planes/services/compute/market.py +433 -0
  149. node/planes/services/compute/sandbox.py +282 -0
  150. node/planes/services/compute/types.py +84 -0
  151. node/planes/services/compute_wire/__init__.py +58 -0
  152. node/planes/services/compute_wire/compartment.py +256 -0
  153. node/planes/services/compute_wire/messages.py +320 -0
  154. node/planes/services/compute_wire/sealing.py +147 -0
  155. node/planes/services/compute_wire/session.py +572 -0
  156. node/planes/services/compute_wire/types.py +49 -0
  157. node/planes/services/compute_wire/verify.py +331 -0
  158. node/planes/services/dvm/__init__.py +24 -0
  159. node/planes/services/dvm/gateway.py +526 -0
  160. node/planes/services/dvm/protocol.py +35 -0
  161. node/planes/services/dvm/translate.py +15 -0
  162. node/planes/services/dvm/types.py +25 -0
  163. node/planes/services/gateway/__init__.py +40 -0
  164. node/planes/services/gateway/settle.py +279 -0
  165. node/planes/services/gateway/translate.py +644 -0
  166. node/planes/services/gossip/__init__.py +22 -0
  167. node/planes/services/gossip/core.py +929 -0
  168. node/planes/services/gossip/envelope.py +154 -0
  169. node/planes/services/gossip/mesh.py +127 -0
  170. node/planes/services/gossip/types.py +50 -0
  171. node/planes/services/nsite/__init__.py +7 -0
  172. node/planes/services/nsite/client.py +62 -0
  173. node/planes/services/nsite/compartment.py +752 -0
  174. node/planes/services/nsite/types.py +44 -0
  175. node/planes/services/paidfetch/__init__.py +7 -0
  176. node/planes/services/paidfetch/compartment.py +862 -0
  177. node/planes/services/paidfetch/types.py +99 -0
  178. node/planes/services/settle/__init__.py +27 -0
  179. node/planes/services/settle/compartment.py +384 -0
  180. node/planes/services/settle/engine.py +456 -0
  181. node/planes/services/settle/gateways.py +541 -0
  182. node/planes/services/settle/payment.py +512 -0
  183. node/planes/services/settle/store.py +74 -0
  184. node/planes/services/settle/types.py +58 -0
  185. node/planes/services/sidecars/__init__.py +8 -0
  186. node/planes/services/sidecars/compartment.py +461 -0
  187. node/planes/services/sidecars/manifest.py +304 -0
  188. node/planes/services/sidecars/manifests/10-grasp.json +37 -0
  189. node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
  190. node/planes/services/sidecars/manifests/30-magpie.json +28 -0
  191. node/planes/services/sidecars/types.py +43 -0
  192. node/planes/services/video/__init__.py +106 -0
  193. node/planes/services/video/barter.py +407 -0
  194. node/planes/services/video/cli.py +158 -0
  195. node/planes/services/video/compartment.py +932 -0
  196. node/planes/services/video/ingest.py +524 -0
  197. node/planes/services/video/ledger.py +705 -0
  198. node/planes/services/video/packager.py +847 -0
  199. node/planes/services/video/player.py +456 -0
  200. node/planes/services/video/timed_segments.py +124 -0
  201. node/planes/services/video/transit.py +478 -0
  202. node/planes/store/__init__.py +24 -0
  203. node/planes/store/blob_replication/__init__.py +7 -0
  204. node/planes/store/blob_replication/compartment.py +496 -0
  205. node/planes/store/blob_replication/types.py +35 -0
  206. node/planes/store/blob_wire/__init__.py +48 -0
  207. node/planes/store/blob_wire/client.py +57 -0
  208. node/planes/store/blob_wire/core.py +359 -0
  209. node/planes/store/blob_wire/intake.py +74 -0
  210. node/planes/store/blob_wire/messages.py +85 -0
  211. node/planes/store/projected/__init__.py +7 -0
  212. node/planes/store/projected/compartment.py +258 -0
  213. node/planes/store/projected/types.py +29 -0
  214. node/planes/store/retention/__init__.py +7 -0
  215. node/planes/store/retention/compartment.py +214 -0
  216. node/planes/store/retention/types.py +12 -0
  217. node/planes/store/sync/__init__.py +35 -0
  218. node/planes/store/sync/core.py +311 -0
  219. node/planes/store/sync/envelope.py +106 -0
  220. node/planes/store/sync/handover.py +206 -0
  221. node/planes/store/sync/initiator.py +128 -0
  222. node/planes/store/sync/responder.py +114 -0
  223. node/planes/store/sync/rules.py +179 -0
  224. node/planes/store/sync/shape.py +80 -0
  225. node/planes/store/sync/types.py +51 -0
  226. node/planes/transport/__init__.py +23 -0
  227. node/planes/transport/bridge/__init__.py +13 -0
  228. node/planes/transport/bridge/compartment.py +785 -0
  229. node/planes/transport/bridge/ratelimit.py +44 -0
  230. node/planes/transport/bridge/types.py +43 -0
  231. node/planes/transport/lan/__init__.py +9 -0
  232. node/planes/transport/lan/compartment.py +446 -0
  233. node/planes/transport/lan/types.py +25 -0
  234. node/planes/transport/link/__init__.py +7 -0
  235. node/planes/transport/link/compartment.py +257 -0
  236. node/planes/transport/link/types.py +27 -0
  237. node/planes/transport/mediation/__init__.py +45 -0
  238. node/planes/transport/mediation/compartment.py +238 -0
  239. node/planes/transport/mediation/leaf.py +107 -0
  240. node/planes/transport/mediation/messages.py +302 -0
  241. node/planes/transport/mediation/service.py +233 -0
  242. node/planes/transport/mediation/store.py +37 -0
  243. node/planes/transport/mediation/types.py +20 -0
  244. node/planes/transport/p2p/__init__.py +7 -0
  245. node/planes/transport/p2p/compartment.py +349 -0
  246. node/planes/transport/p2p/types.py +22 -0
  247. node/protocols/__init__.py +277 -0
  248. node/protocols/activitypub/__init__.py +56 -0
  249. node/protocols/activitypub/actor.py +230 -0
  250. node/protocols/activitypub/federation.py +490 -0
  251. node/protocols/activitypub/media.py +145 -0
  252. node/protocols/activitypub/models.py +286 -0
  253. node/protocols/activitypub/package.py +271 -0
  254. node/protocols/activitypub/store.py +564 -0
  255. node/protocols/arcade/__init__.py +26 -0
  256. node/protocols/atproto/__init__.py +13 -0
  257. node/protocols/atproto/firehose.py +126 -0
  258. node/protocols/atproto/metering.py +165 -0
  259. node/protocols/atproto/package.py +155 -0
  260. node/protocols/atproto/repo.py +250 -0
  261. node/protocols/atproto/xrpc.py +285 -0
  262. node/protocols/base.py +109 -0
  263. node/protocols/circles/__init__.py +29 -0
  264. node/protocols/dm/__init__.py +50 -0
  265. node/protocols/grasp/__init__.py +22 -0
  266. node/protocols/isotime.py +15 -0
  267. node/protocols/nostr/__init__.py +51 -0
  268. node/protocols/nostr/blobstore.py +383 -0
  269. node/protocols/nostr/blossom.py +627 -0
  270. node/protocols/nostr/client.py +377 -0
  271. node/protocols/nostr/descriptor.py +211 -0
  272. node/protocols/nostr/eventstore.py +304 -0
  273. node/protocols/nostr/package.py +182 -0
  274. node/protocols/nostr/peers.py +109 -0
  275. node/protocols/nostr/relayserver.py +656 -0
  276. node/protocols/p2panda/__init__.py +22 -0
  277. node/protocols/p2panda/identity.py +19 -0
  278. node/protocols/p2panda/package.py +236 -0
  279. node/protocols/p2panda/rung.py +152 -0
  280. node/protocols/p2panda/store.py +273 -0
  281. node/protocols/rhio/__init__.py +33 -0
  282. node/protocols/rhio/package.py +275 -0
  283. node/protocols/rhio/rung.py +136 -0
  284. node/protocols/rhio/store.py +1423 -0
  285. node/protocols/ssb/muxrpc.py +256 -0
  286. node/protocols/ssb/package.py +267 -0
  287. node/protocols/textile/__init__.py +111 -0
  288. node/protocols/textile/transducers.py +259 -0
  289. node/release.py +119 -0
  290. node/storage/__init__.py +88 -0
  291. node/storage/accounts.py +305 -0
  292. node/storage/adapter.py +161 -0
  293. node/storage/base.py +385 -0
  294. node/storage/dialect.py +209 -0
  295. node/storage/grants.py +317 -0
  296. node/storage/locator.py +150 -0
  297. node/storage/postgres.py +294 -0
  298. node/storage/projected.py +953 -0
  299. node/storage/sharding.py +448 -0
  300. node/storage/standing.py +568 -0
  301. node/testnet/__init__.py +9 -0
  302. node/testnet/cluster.py +731 -0
  303. node/testnet/fips_daemon.py +230 -0
  304. node/transport/__init__.py +94 -0
  305. node/transport/const.py +110 -0
  306. node/transport/didcomm_inbound.py +251 -0
  307. node/transport/dnstxt.py +169 -0
  308. node/transport/fips_rung/__init__.py +67 -0
  309. node/transport/fips_rung/core.py +666 -0
  310. node/transport/fips_rung/listener.py +96 -0
  311. node/transport/fips_rung/types.py +12 -0
  312. node/transport/host.py +334 -0
  313. node/transport/iroh_rung/__init__.py +29 -0
  314. node/transport/iroh_rung/core.py +477 -0
  315. node/transport/iroh_rung/quic.py +942 -0
  316. node/transport/iroh_rung/types.py +22 -0
  317. node/transport/ladder.py +145 -0
  318. node/transport/lineproto.py +49 -0
  319. node/transport/mdns.py +303 -0
  320. node/transport/netfetch.py +283 -0
  321. node/transport/relay_rung/__init__.py +23 -0
  322. node/transport/relay_rung/cooldown.py +87 -0
  323. node/transport/relay_rung/core.py +138 -0
  324. node/transport/relay_rung/inbound.py +161 -0
  325. node/transport/relay_rung/ledger.py +104 -0
  326. node/transport/relay_rung/listen.py +121 -0
  327. node/transport/relay_rung/outbound.py +181 -0
  328. node/transport/relay_rung/shape.py +71 -0
  329. node/transport/relay_rung/types.py +58 -0
  330. node/transport/relay_rung/wire.py +63 -0
  331. node/transport/router.py +761 -0
  332. node/transport/rung.py +276 -0
  333. node/transport/ssb_rung.py +166 -0
  334. node/transport/ws.py +502 -0
  335. sidecar/fleetmesh_announce.py +338 -0
  336. sidecar/fleetmesh_p2p.py +373 -0
  337. sidecar/fleetmesh_rhio.py +352 -0
  338. sidecar/fleetmesh_ssb.py +603 -0
@@ -0,0 +1,210 @@
1
+ """CLI Adapter: bridge declarative Compartment Commands to the operator CLI.
2
+
3
+ Converts declarative `Command` and `Parameter` definitions into argparse
4
+ subcommands and arguments, and provides table-driven action dispatching.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import dataclasses
10
+ from typing import Any, Callable, Mapping, MutableMapping, Optional, Sequence
11
+
12
+ from ..compartment import Command, CommandError
13
+ from ..config.parameter import Parameter
14
+ from .base import _ask, emit, fail
15
+
16
+
17
+ def add_parameter_argument(
18
+ parser: argparse.ArgumentParser,
19
+ param: Parameter[Any],
20
+ help_prefix: str = '',
21
+ require_flag: bool = False,
22
+ ) -> None:
23
+ """Register a Parameter on an ArgumentParser.
24
+
25
+ Uses `cli_name`, `short`, and `positional` if specified, otherwise derives
26
+ long option as `--<param.key_with_dashes>`. Skips if `param.cli` is False
27
+ or if the flag is already registered on the parser.
28
+ """
29
+ if not getattr(param, 'cli', True):
30
+ return
31
+
32
+ # Check already registered option strings
33
+ existing_opts = {opt for action in parser._actions for opt in action.option_strings}
34
+ existing_dests = {action.dest for action in parser._actions}
35
+
36
+ doc = f"{help_prefix}{param.description}" if help_prefix else param.description
37
+
38
+ if getattr(param, 'positional', False):
39
+ dest = param.cli_name or param.key
40
+ if dest in existing_dests:
41
+ return
42
+ kwargs: MutableMapping[str, Any] = {'help': doc}
43
+ if param.type in (int, float, str):
44
+ kwargs['type'] = param.type
45
+ if param.choices:
46
+ kwargs['choices'] = param.choices
47
+ if getattr(param, 'nargs', ''):
48
+ kwargs['nargs'] = param.nargs
49
+ elif not getattr(param, 'required', False):
50
+ kwargs['nargs'] = '?'
51
+ parser.add_argument(dest, **kwargs)
52
+ return
53
+
54
+ # Flag option
55
+ long_flag = param.cli_name if (param.cli_name and param.cli_name.startswith('--')) else (
56
+ '--' + (param.cli_name or param.key.replace('_', '-')).lstrip('-')
57
+ )
58
+ flags: list[str] = []
59
+ if getattr(param, 'short', ''):
60
+ short_flag = '-' + param.short.lstrip('-')
61
+ if short_flag not in existing_opts:
62
+ flags.append(short_flag)
63
+ if long_flag in existing_opts:
64
+ return
65
+ flags.append(long_flag)
66
+
67
+ dest = param.key
68
+ kwargs = {'help': doc, 'dest': dest}
69
+
70
+ if param.type is bool:
71
+ kwargs['action'] = 'store_true'
72
+ elif getattr(param, 'nargs', ''):
73
+ kwargs['nargs'] = param.nargs
74
+ elif param.type in (list, tuple):
75
+ kwargs['action'] = 'append'
76
+ else:
77
+ if param.type in (int, float, str):
78
+ kwargs['type'] = param.type
79
+ if param.choices:
80
+ kwargs['choices'] = param.choices
81
+ # Only set default if it's not a factory and not falsy empty
82
+ val = param.value() if hasattr(param, 'value') else None
83
+ if val is not None and not callable(param.default) and val not in ('', 0, False, [], (), {}):
84
+ kwargs['default'] = val
85
+
86
+ if require_flag and getattr(param, 'required', False) and not getattr(param, 'positional', False):
87
+ kwargs['required'] = True
88
+
89
+ parser.add_argument(*flags, **kwargs)
90
+
91
+
92
+ @dataclasses.dataclass
93
+ class ActionDef:
94
+ """Definition of one action in a multi-action command group."""
95
+ name: str
96
+ command: Optional[Command] = None
97
+ human: Optional[Callable[..., None]] = None
98
+ timeout: float = 10.0
99
+ handler: Optional[Callable[[argparse.Namespace], int]] = None
100
+ arg_map: dict[str, str] = dataclasses.field(default_factory=dict)
101
+ parameters: tuple[Parameter[Any], ...] = ()
102
+
103
+
104
+ class CommandDispatcher:
105
+ """Table-driven multi-action dispatcher and argument builder for CLI subcommands."""
106
+
107
+ def __init__(self, subsystem: str, default_action: str = 'status', action_dest: str = 'action') -> None:
108
+ self.subsystem = subsystem
109
+ self.default_action = default_action
110
+ self.action_dest = action_dest
111
+ self.actions: dict[str, ActionDef] = {}
112
+
113
+ def add_command(
114
+ self,
115
+ action_name: str,
116
+ command: Command,
117
+ human: Optional[Callable[..., None]] = None,
118
+ timeout: float = 10.0,
119
+ arg_map: Optional[Mapping[str, str]] = None,
120
+ extra_parameters: Sequence[Parameter[Any]] = (),
121
+ ) -> ActionDef:
122
+ """Register an action backed directly by a compartment Command."""
123
+ all_params = tuple(command.parameters) + tuple(extra_parameters)
124
+ act = ActionDef(
125
+ name=action_name,
126
+ command=command,
127
+ human=human,
128
+ timeout=timeout,
129
+ arg_map=dict(arg_map or {}),
130
+ parameters=all_params,
131
+ )
132
+ self.actions[action_name] = act
133
+ return act
134
+
135
+ def add_custom(
136
+ self,
137
+ action_name: str,
138
+ handler: Callable[[argparse.Namespace], int],
139
+ parameters: Sequence[Parameter[Any]] = (),
140
+ ) -> ActionDef:
141
+ """Register an action with custom client-side handling logic."""
142
+ act = ActionDef(
143
+ name=action_name,
144
+ handler=handler,
145
+ parameters=tuple(parameters),
146
+ )
147
+ self.actions[action_name] = act
148
+ return act
149
+
150
+ def register_arguments(
151
+ self,
152
+ parser: argparse.ArgumentParser,
153
+ action_choices: Sequence[str] | None = None,
154
+ action_dest: str | None = None,
155
+ ) -> None:
156
+ """Register the action positional argument and all action parameters."""
157
+ choices = tuple(action_choices) if action_choices is not None else tuple(self.actions.keys())
158
+ dest = action_dest if action_dest is not None else self.action_dest
159
+ parser.add_argument(
160
+ dest,
161
+ choices=choices,
162
+ default=self.default_action,
163
+ nargs='?',
164
+ )
165
+ for act in self.actions.values():
166
+ for p in act.parameters:
167
+ add_parameter_argument(parser, p)
168
+
169
+ def dispatch(self, a: argparse.Namespace) -> int:
170
+ """Execute the requested action from parsed namespace."""
171
+ action_name = getattr(a, self.action_dest, None) or getattr(a, 'action', self.default_action) or self.default_action
172
+ act = self.actions.get(action_name)
173
+ if not act:
174
+ fail(f'unknown action {action_name!r}')
175
+
176
+ if act.handler is not None:
177
+ return act.handler(a)
178
+
179
+ if act.command is None:
180
+ fail(f'action {action_name} has no command or handler')
181
+
182
+ # Gather arguments declared on the Command
183
+ raw_args = {}
184
+ for p in act.command.parameters:
185
+ dest = act.arg_map.get(p.key, p.key)
186
+ val = getattr(a, dest, None)
187
+ if p.required and (val is None or val == ''):
188
+ flag = p.cli_name or ('--' + p.key.replace('_', '-'))
189
+ placeholder = ' <name>' if p.key == 'domain' else f' <{p.key}>'
190
+ fail(f'{action_name} requires {flag}{placeholder}')
191
+ if val is not None:
192
+ raw_args[p.key] = val
193
+
194
+ try:
195
+ valid_args = act.command.validate_args(raw_args)
196
+ except CommandError as e:
197
+ fail(f'{action_name}: {e}')
198
+
199
+ res = _ask(a.dir, f'{self.subsystem}.{act.command.key}', timeout=act.timeout, **valid_args)
200
+ if act.human is not None:
201
+ human_fn = act.human
202
+ import inspect
203
+ try:
204
+ sig = inspect.signature(human_fn)
205
+ human_cb: Any = (lambda r: human_fn(r, a)) if len(sig.parameters) >= 2 else human_fn
206
+ except (ValueError, TypeError):
207
+ human_cb = human_fn
208
+ else:
209
+ human_cb = None
210
+ return emit(a, res, human_cb)
node/commands/base.py ADDED
@@ -0,0 +1,278 @@
1
+ """Common helpers and base structures for meshnode CLI command providers."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import sys
9
+ import time
10
+ from typing import Any, Callable, Mapping, NoReturn, Optional, Sequence
11
+ from node.interfaces import HealthStatusDict, NodeStatusDict
12
+ from node import app as app_mod, storage
13
+ from node.app import socket as control
14
+ from fleetmesh_core.wire import validate
15
+ from fleetmesh_core.identity.did import node_of_did
16
+ from node.storage import grants as grants_mod
17
+ from fleetmesh_core.crypto.bech32 import bech32_decode, bech32_encode, bech32_polymod # noqa: F401 (command modules import them from here)
18
+
19
+ TIERS: tuple[str, ...] = ('probation', 'member', 'trusted', 'anchor')
20
+
21
+ # Exit codes. Every command returns an int; meshnode.main exits with it.
22
+ OK = 0
23
+ # Something the operator asked for could not be done: a missing argument, a
24
+ # peer this node does not know, a daemon that is not running, a reply the
25
+ # daemon refused. Two rather than one so that it reads as "the request was
26
+ # wrong or could not be met" in the same register as argparse's own usage
27
+ # errors; one is reserved for commands whose job is to answer a yes/no
28
+ # question (`check`, `enclave verify`, `fips probe`) and said no.
29
+ FAILED = 2
30
+
31
+
32
+ def fail(msg: str, code: int = FAILED) -> NoReturn:
33
+ """Say why on stderr and stop with `code`. The one way a command gives up."""
34
+ print(msg, file=sys.stderr)
35
+ raise SystemExit(code)
36
+
37
+
38
+ def emit(a: argparse.Namespace, payload: Any, human: Callable[[Any], None] | None = None,
39
+ indent: int | None = 2) -> int:
40
+ """Print `payload` as JSON under --json, otherwise hand it to `human`.
41
+
42
+ The JSON side is the contract scripts read, so it is produced here and
43
+ nowhere else: `json.dumps(payload, indent=indent)` on one line of stdout
44
+ per call. A command whose human rendering is long may pass no `human`,
45
+ test `a.json` itself and `return emit(a, payload)` from the JSON branch.
46
+ """
47
+ if getattr(a, 'json', False):
48
+ print(json.dumps(payload, indent=indent))
49
+ elif human is not None:
50
+ human(payload)
51
+ return OK
52
+
53
+
54
+ def load_json_arg(value: str, what: str = 'argument',
55
+ on_error: Callable[[str], Any] | None = None) -> Any:
56
+ """A JSON argument given inline or as `@path` to a file holding it.
57
+
58
+ A value that is not JSON fails the command naming `what`, unless
59
+ `on_error` is given, in which case it decides what the raw text means.
60
+ """
61
+ if value.startswith('@'):
62
+ with open(value[1:], encoding='utf-8') as _fh:
63
+ raw = _fh.read()
64
+ else:
65
+ raw = value
66
+ try:
67
+ return json.loads(raw)
68
+ except ValueError as e:
69
+ if on_error is not None:
70
+ return on_error(raw)
71
+ fail('%s is not JSON: %s' % (what, e))
72
+
73
+
74
+ def load_doc(path: str) -> Any:
75
+ with open(path, encoding='utf-8') as _fh:
76
+ text = _fh.read()
77
+ if path.endswith(('.yaml', '.yml')):
78
+ try:
79
+ import yaml
80
+ except ImportError:
81
+ fail('reading %s needs pyyaml; give the manifest as JSON instead' % path)
82
+ return yaml.safe_load(text)
83
+ return json.loads(text)
84
+
85
+
86
+ def render_table(headers: Sequence[str], rows: Sequence[Sequence[Any]]) -> None:
87
+ str_rows = [[str(c) for c in r] for r in rows]
88
+ widths = [max(len(h), *(len(r[i]) for r in str_rows)) if str_rows else len(h) for i, h in enumerate(headers)]
89
+ line = ' '.join(h.ljust(w) for h, w in zip(headers, widths))
90
+ print(line.rstrip())
91
+ for r in str_rows:
92
+ print(' '.join(c.ljust(w) for c, w in zip(r, widths)).rstrip())
93
+
94
+
95
+ def format_when(epoch: int, window: int = 3600) -> str:
96
+ return time.strftime('%Y-%m-%d %H:%M', time.gmtime(epoch * window))
97
+
98
+
99
+ def short_pk(pk: str | None) -> str:
100
+ return (pk or '')[:16]
101
+
102
+
103
+ def get_grants_store(directory: str) -> grants_mod.GrantStore:
104
+ return grants_mod.GrantStore(os.path.join(directory, 'grants.sqlite'))
105
+
106
+
107
+ def resolve_peer_arg(directory: str, arg: str) -> str:
108
+ """A peer, from a pubkey, a DID, an npub, or an unambiguous prefix of a known one."""
109
+ arg = arg.strip()
110
+ if arg.lower().startswith('did:'):
111
+ try:
112
+ return node_of_did(arg.lower())
113
+ except ValueError as e:
114
+ fail(str(e))
115
+ if arg.lower().startswith('npub1'):
116
+ raw = bech32_decode('npub', arg.lower())
117
+ if raw is not None and len(raw) == 32:
118
+ return raw.hex()
119
+ fail('%r is not a valid npub' % arg)
120
+ if validate.hex64(arg.lower()):
121
+ return arg.lower()
122
+ known = set()
123
+ try:
124
+ known |= {g['peer'] for g in get_grants_store(directory).list()}
125
+ except storage.store_errors():
126
+ pass
127
+ try:
128
+ cfg = app_mod.load_config(directory)
129
+ known |= {p['pubkey'] for p in cfg.get('peers', []) if isinstance(p, dict) and 'pubkey' in p}
130
+ known |= {pk for pk in (cfg.get('trust') or {}) if validate.hex64(pk)}
131
+ except (OSError, ValueError):
132
+ pass
133
+ hits = sorted(pk for pk in known if pk.startswith(arg.lower()))
134
+ if len(hits) == 1:
135
+ return hits[0]
136
+ if not hits:
137
+ fail('%r is not a pubkey, a did:nostr, an npub, or a prefix of a peer this node knows' % arg)
138
+ fail('%r matches %d peers: %s' % (arg, len(hits), ', '.join(h[:16] for h in hits)))
139
+
140
+
141
+ def resolve_peer_or_raw(a: argparse.Namespace, arg: str) -> str:
142
+ """resolve_peer_arg when the command has a node directory to ask, else the
143
+ argument as given. For transport commands, which also run without a node."""
144
+ directory = getattr(a, 'dir', None)
145
+ if directory and os.path.exists(directory):
146
+ return resolve_peer_arg(directory, arg)
147
+ return arg
148
+
149
+
150
+ _DAEMON_CALLER: Optional[Callable[..., Any]] = None
151
+
152
+
153
+ def set_daemon_caller(caller: Optional[Callable[..., Any]]) -> None:
154
+ """Set custom daemon caller implementation for testing without monkeypatching."""
155
+ global _DAEMON_CALLER
156
+ _DAEMON_CALLER = caller
157
+
158
+
159
+ def reset_daemon_caller() -> None:
160
+ """Clear custom daemon caller implementation."""
161
+ global _DAEMON_CALLER
162
+ _DAEMON_CALLER = None
163
+
164
+
165
+ @contextlib.contextmanager
166
+ def override_daemon(caller: Callable[..., Any]) -> Any:
167
+ """Context manager to cleanly override daemon calls for tests."""
168
+ global _DAEMON_CALLER
169
+ prev = _DAEMON_CALLER
170
+ _DAEMON_CALLER = caller
171
+ try:
172
+ yield
173
+ finally:
174
+ _DAEMON_CALLER = prev
175
+
176
+
177
+ def ask_daemon(directory: str, cmd: str, **args: Any) -> Any:
178
+ """One call over the control socket, or exit with the daemon's complaint.
179
+
180
+ Can be cleanly intercepted in tests via `override_daemon(fake)` or `set_daemon_caller(fake)`.
181
+ """
182
+ if _DAEMON_CALLER is not None:
183
+ return _DAEMON_CALLER(directory, cmd, **args)
184
+ try:
185
+ return control.call(directory, cmd, **args)
186
+ except control.ControlError as e:
187
+ fail('%s' % e)
188
+
189
+
190
+ def _ask(directory: str, cmd: str, **args: Any) -> Any:
191
+ return ask_daemon(directory, cmd, **args)
192
+
193
+
194
+ def try_ask(directory: str, cmd: str, **args: Any) -> Any:
195
+ """One call over the control socket, or None when the node is not there
196
+ or declined. For best-effort fan-out, where one silent node must not stop
197
+ the command."""
198
+ if not control.running(directory):
199
+ return None
200
+ try:
201
+ return control.call(directory, cmd, **args)
202
+ except control.ControlError:
203
+ return None
204
+
205
+
206
+ def _live(a: argparse.Namespace, cmd: str, args: Mapping[str, Any] | None = None, timeout: float = 10) -> Any:
207
+ """Ask the running node, or fall back to what it last wrote."""
208
+ if control.running(a.dir):
209
+ try:
210
+ return control.call(a.dir, cmd, timeout=timeout, **(args or {}))
211
+ except control.ControlError:
212
+ pass
213
+ return None
214
+
215
+
216
+ def read_status_file(directory: str) -> NodeStatusDict:
217
+ path = os.path.join(directory, 'status.json')
218
+ if not os.path.exists(path):
219
+ fail('no status.json in %s; is the node running?' % directory)
220
+ with open(path, encoding='utf-8') as _fh:
221
+ return json.load(_fh)
222
+
223
+
224
+ def status_health(directory: str) -> HealthStatusDict:
225
+ """The health block of the last status.json, or {} when it has none."""
226
+ return read_status_file(directory).get('health') or {}
227
+
228
+
229
+ def extract_trouble(health: HealthStatusDict | Mapping[str, Any] | None) -> list[str]:
230
+ h = health or {}
231
+ out = list(h.get('subsystem_trouble') or [])
232
+ if h.get('config_error'):
233
+ out.append('config.json rejected, last good one in force: %s' % h['config_error'])
234
+ for k in ('handler_failures', 'tick_failures'):
235
+ if h.get(k):
236
+ out.append('%s: %d%s' % (k.replace('_', ' '), h[k],
237
+ (' (last: %s)' % h['last_tick_error']) if k == 'tick_failures' and h.get('last_tick_error') else ''))
238
+ if h.get('rejected'):
239
+ out.append('messages rejected at the door: %d' % sum(h['rejected'].values()))
240
+ if h.get('subsystem_trouble') is not None:
241
+ return out
242
+ down = [u for u, r in (h.get('relays') or {}).items() if not r.get('connected')]
243
+ if down:
244
+ out.append('relay subscription down: %s' % ', '.join(down))
245
+ if h.get('sessions_refused'):
246
+ out.append('sync sessions refused: %d' % h['sessions_refused'])
247
+ if h.get('receipts_refused'):
248
+ out.append('receipts refused over a grant disagreement: %d - every window this peering '
249
+ 'closes will be active silence until it is resolved' % len(h['receipts_refused']))
250
+ return out
251
+
252
+
253
+ # The short names the command modules were written against. They are defined
254
+ # once, here; nothing else re-exports them (meshnode.py used to, and a test
255
+ # reaching through that copy is what kept a sys.modules hack alive in _ask).
256
+ _load_doc = load_doc
257
+ _bech32 = bech32_encode
258
+ _bech32_decode = bech32_decode
259
+ _bech32_polymod = bech32_polymod
260
+ _table = render_table
261
+ _when = format_when
262
+ _short = short_pk
263
+ _grants = get_grants_store
264
+ _peer = resolve_peer_arg
265
+ _status_file = read_status_file
266
+ _trouble = extract_trouble
267
+
268
+
269
+ def add_command(sub: argparse._SubParsersAction, name: str, fn: Any, help: str,
270
+ peer: bool = False, js: bool = False) -> argparse.ArgumentParser:
271
+ p = sub.add_parser(name, help=help)
272
+ p.add_argument('dir')
273
+ if peer:
274
+ p.add_argument('peer', help='pubkey, did:nostr, or a prefix of a peer this node knows')
275
+ if js:
276
+ p.add_argument('--json', action='store_true', help='print the structure, for scripts')
277
+ p.set_defaults(fn=fn)
278
+ return p
@@ -0,0 +1,183 @@
1
+ """The `cluster` subcommand: a local multi-node testnet on one machine.
2
+
3
+ Owns the command body and its place in the operator's parser, like every other
4
+ module under node/commands. The body used to sit at the bottom of
5
+ node/testnet/cluster.py; the cluster engine now knows nothing about argparse or how
6
+ its status is printed.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ from typing import Any, TypedDict
14
+
15
+ from node.config.parameter import Parameter
16
+ from node.testnet import LocalCluster
17
+ from .adapter import CommandDispatcher
18
+ from .base import add_command, emit, fail, try_ask
19
+
20
+
21
+ class ClusterFipsStatus(TypedDict, total=False):
22
+ active: bool
23
+ listeners: int
24
+
25
+
26
+ class ClusterNodeSummary(TypedDict, total=False):
27
+ index: int
28
+ name: str
29
+ pubkey: str
30
+ peers: list[Any]
31
+ event_count: int
32
+ face: str
33
+
34
+
35
+ class ClusterMeta(TypedDict, total=False):
36
+ node_count: int
37
+ topology: str
38
+ cluster_dir: str
39
+ running: bool
40
+ enable_fips: bool
41
+ relays: list[str]
42
+
43
+
44
+ class ClusterStatusSummary(TypedDict, total=False):
45
+ node_count: int
46
+ topology: str
47
+ cluster_dir: str
48
+ running: bool
49
+ active_peering_edges: int
50
+ relays: list[str]
51
+ fips: ClusterFipsStatus
52
+ nodes: list[ClusterNodeSummary]
53
+
54
+
55
+ PARAM_NODES = Parameter(
56
+ key='nodes', name='nodes', description='number of nodes in the cluster (default: 3)',
57
+ type=int, default=3,
58
+ )
59
+ PARAM_TOPOLOGY = Parameter(
60
+ key='topology', name='topology', description='peering topology',
61
+ type=str, default='mesh', choices=('mesh', 'star', 'ring', 'line'),
62
+ )
63
+ PARAM_RELAYS = Parameter(
64
+ key='relays', name='relays', description='relay URLs for cluster',
65
+ type=list, default=None, nargs='*',
66
+ )
67
+ PARAM_SHARD = Parameter(
68
+ key='shard', name='shard', description='shard name for sync',
69
+ type=str, default='bristol',
70
+ )
71
+ PARAM_FIPS = Parameter(
72
+ key='fips', name='fips', description='enable FIPS native datagram transport in cluster',
73
+ type=bool, default=False,
74
+ )
75
+
76
+
77
+ def _meta(cluster_dir: str) -> ClusterMeta:
78
+ """cluster.json, or exit when the directory holds none."""
79
+ meta_path = os.path.join(cluster_dir, 'cluster.json')
80
+ if not os.path.exists(meta_path):
81
+ fail(f"No cluster.json in {cluster_dir}.")
82
+ with open(meta_path, encoding='utf-8') as _fh:
83
+ return json.load(_fh)
84
+
85
+
86
+ def _render_cluster_summary(status: ClusterStatusSummary) -> None:
87
+ print("\n=======================================================")
88
+ print(f" MeshNode Cluster: {status['node_count']} nodes | Topology: {status['topology'].upper()}")
89
+ print(f" Directory: {status['cluster_dir']}")
90
+ print(f" Status: {'RUNNING' if status['running'] else 'STOPPED'}")
91
+ print(f" Active Peerings: {status['active_peering_edges']}")
92
+ if status.get('relays'):
93
+ print(f" Shared Relays: {', '.join(status['relays'])}")
94
+ if status.get('fips'):
95
+ print(f" FIPS Transport: {'ACTIVE' if status['fips'].get('active') else 'INACTIVE'} (listeners: {status['fips'].get('listeners', 0)})")
96
+ print("-------------------------------------------------------")
97
+ print(f" {'#':<3} {'Name':<10} {'Class':<8} {'Pubkey':<18} {'Peers':<6} {'Events':<7} {'Face'}")
98
+ print(" --- ---------- -------- ------------------ ------ ------- -------------------------")
99
+ for n in status.get('nodes', []):
100
+ pk_short = (n.get('pubkey') or '')[:16] + '…'
101
+ peers_cnt = len(n.get('peers', []))
102
+ events_cnt = n.get('event_count', 0)
103
+ face = n.get('face') or '-'
104
+ print(f" {n['index']:<3} {n['name']:<10} {n.get('class', '-'):<8} {pk_short:<18} {peers_cnt:<6} {events_cnt:<7} {face}")
105
+ print("=======================================================\n")
106
+
107
+
108
+ def _handle_start(args: argparse.Namespace) -> int:
109
+ cluster_dir = getattr(args, 'dir', None) or os.path.abspath('cluster-testnet')
110
+ count = int(getattr(args, 'nodes', 3))
111
+ topo = getattr(args, 'topology', 'mesh')
112
+ relays = getattr(args, 'relays', None) or []
113
+ enable_fips = bool(getattr(args, 'fips', False))
114
+ cl = LocalCluster(base_dir=cluster_dir, node_count=count, topology=topo, relay_urls=relays, enable_fips=enable_fips)
115
+ print(f"Starting {count}-node cluster ({topo}) in {cluster_dir}...")
116
+ cl.start()
117
+ print(f"Cluster active. Relay: {cl.relay_urls[0] if cl.relay_urls else 'nostr'} | FIPS: {'enabled' if enable_fips else 'disabled'}")
118
+ return emit(args, cl.get_status(), _render_cluster_summary)
119
+
120
+
121
+ def _handle_status(args: argparse.Namespace) -> int:
122
+ cluster_dir = getattr(args, 'dir', None) or os.path.abspath('cluster-testnet')
123
+ if not os.path.exists(cluster_dir):
124
+ fail(f"Cluster directory {cluster_dir} does not exist.")
125
+ meta = _meta(cluster_dir)
126
+ enable_fips = bool(meta.get('enable_fips', False))
127
+ cl = LocalCluster(base_dir=cluster_dir, node_count=meta['node_count'], topology=meta['topology'], enable_fips=enable_fips)
128
+ return emit(args, cl.get_status(), _render_cluster_summary)
129
+
130
+
131
+ def _handle_stop(args: argparse.Namespace) -> int:
132
+ cluster_dir = getattr(args, 'dir', None) or os.path.abspath('cluster-testnet')
133
+ if not os.path.exists(cluster_dir):
134
+ fail(f"Cluster directory {cluster_dir} does not exist.")
135
+ meta_path = os.path.join(cluster_dir, 'cluster.json')
136
+ if os.path.exists(meta_path):
137
+ meta = _meta(cluster_dir)
138
+ # Ask each node to retire; one that is already gone is no error.
139
+ for i in range(meta.get('node_count', 3)):
140
+ try_ask(os.path.join(cluster_dir, f'node_{i}'), 'retire', reason='cluster stop')
141
+ meta['running'] = False
142
+ with open(meta_path, 'w', encoding='utf-8') as _fh:
143
+ json.dump(meta, _fh, indent=2)
144
+ print(f"Cluster in {cluster_dir} stopped.")
145
+ return 0
146
+
147
+
148
+ def _handle_sync(args: argparse.Namespace) -> int:
149
+ cluster_dir = getattr(args, 'dir', None) or os.path.abspath('cluster-testnet')
150
+ meta = _meta(cluster_dir)
151
+ shard = getattr(args, 'shard', 'bristol')
152
+ print(f"Triggering cluster-wide sync for shard {shard!r}...")
153
+ for i in range(meta.get('node_count', 3)):
154
+ ndir = os.path.join(cluster_dir, f'node_{i}')
155
+ peers = try_ask(ndir, 'peers')
156
+ if peers is None:
157
+ print(f" node_{i} sync notice: node is not answering")
158
+ continue
159
+ for p in peers:
160
+ if try_ask(ndir, 'pull', peer=p['peer'], shard=shard) is None:
161
+ print(f" node_{i} sync notice: pull from {p['peer'][:16]} declined")
162
+ print("Sync triggered across cluster.")
163
+ return 0
164
+
165
+
166
+ CLUSTER_DISPATCHER = CommandDispatcher('cluster', default_action='status', action_dest='cluster_action')
167
+ CLUSTER_DISPATCHER.add_custom('start', _handle_start, parameters=(PARAM_NODES, PARAM_TOPOLOGY, PARAM_RELAYS, PARAM_FIPS))
168
+ CLUSTER_DISPATCHER.add_custom('status', _handle_status)
169
+ CLUSTER_DISPATCHER.add_custom('stop', _handle_stop)
170
+ CLUSTER_DISPATCHER.add_custom('sync', _handle_sync, parameters=(PARAM_SHARD,))
171
+
172
+
173
+ def cmd_cluster(args: argparse.Namespace) -> int:
174
+ return CLUSTER_DISPATCHER.dispatch(args)
175
+
176
+
177
+ class ClusterCommandProvider:
178
+ """Registers the local testnet cluster command."""
179
+
180
+ @classmethod
181
+ def register(cls, sub: argparse._SubParsersAction) -> None:
182
+ cl = add_command(sub, 'cluster', cmd_cluster, 'manage a local multi-node testnet cluster', js=True)
183
+ CLUSTER_DISPATCHER.register_arguments(cl, action_choices=('start', 'status', 'stop', 'sync'), action_dest='cluster_action')