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.
- fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
- fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
- fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
- fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
- fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
- fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
- node/RELEASE +1 -0
- node/__init__.py +35 -0
- node/app/__init__.py +49 -0
- node/app/commands.py +265 -0
- node/app/config.py +131 -0
- node/app/control.py +670 -0
- node/app/core.py +867 -0
- node/app/plugins.py +76 -0
- node/app/router.py +83 -0
- node/app/socket.py +431 -0
- node/app/systemd.py +36 -0
- node/app/views.py +204 -0
- node/channels/__init__.py +378 -0
- node/channels/bus.py +331 -0
- node/channels/journal.py +46 -0
- node/commands/__init__.py +140 -0
- node/commands/adapter.py +210 -0
- node/commands/base.py +278 -0
- node/commands/cluster_cmds.py +183 -0
- node/commands/compute_cmds.py +401 -0
- node/commands/core.py +474 -0
- node/commands/gossip_cmds.py +106 -0
- node/commands/nsite_cmds.py +141 -0
- node/commands/peering.py +402 -0
- node/commands/plugin_cmds.py +135 -0
- node/commands/sidecar_cmds.py +120 -0
- node/commands/sync.py +388 -0
- node/commands/transport_cmds.py +451 -0
- node/commands/video_cmds.py +99 -0
- node/compartment/__init__.py +124 -0
- node/compartment/base.py +868 -0
- node/compartment/command.py +458 -0
- node/compartment/context.py +163 -0
- node/compartment/every.py +108 -0
- node/compartment/handler.py +131 -0
- node/compartment/lifecycle.py +460 -0
- node/compartment/lint.py +388 -0
- node/compartment/need.py +158 -0
- node/compartment/problem.py +521 -0
- node/compartment/refusals.py +120 -0
- node/compartment/registry.py +790 -0
- node/compartment/schema.py +290 -0
- node/compartment/sidecar.py +499 -0
- node/compartment/stores.py +662 -0
- node/compartment/wire.py +225 -0
- node/compartment/worker.py +61 -0
- node/config/__init__.py +43 -0
- node/config/base.py +116 -0
- node/config/change.py +441 -0
- node/config/changestore.py +201 -0
- node/config/compartments.py +1144 -0
- node/config/node.py +318 -0
- node/config/parameter.py +563 -0
- node/config/protocols.py +274 -0
- node/config/records.py +171 -0
- node/examples/__init__.py +1 -0
- node/examples/demo.py +226 -0
- node/examples/follower.py +46 -0
- node/interfaces.py +235 -0
- node/keystore/__init__.py +27 -0
- node/keystore/core.py +103 -0
- node/keystore/enclave.py +688 -0
- node/meshnode.py +111 -0
- node/metrics/__init__.py +396 -0
- node/metrics/adapters.py +102 -0
- node/metrics/face.py +110 -0
- node/planes/__init__.py +21 -0
- node/planes/identity/__init__.py +20 -0
- node/planes/identity/announce/__init__.py +9 -0
- node/planes/identity/announce/compartment.py +447 -0
- node/planes/identity/announce/types.py +26 -0
- node/planes/identity/discovery/__init__.py +9 -0
- node/planes/identity/discovery/compartment.py +522 -0
- node/planes/identity/discovery/types.py +13 -0
- node/planes/identity/linked/__init__.py +1 -0
- node/planes/identity/linked/compartment.py +270 -0
- node/planes/identity/linked/types.py +25 -0
- node/planes/identity/presence/__init__.py +7 -0
- node/planes/identity/presence/compartment.py +409 -0
- node/planes/identity/presence/types.py +14 -0
- node/planes/operations/__init__.py +13 -0
- node/planes/operations/scheduler/__init__.py +9 -0
- node/planes/operations/scheduler/compartment.py +353 -0
- node/planes/operations/scheduler/types.py +21 -0
- node/planes/operations/updates/__init__.py +17 -0
- node/planes/operations/updates/core.py +310 -0
- node/planes/operations/updates/store.py +79 -0
- node/planes/operations/updates/types.py +20 -0
- node/planes/peering/__init__.py +28 -0
- node/planes/peering/budget/__init__.py +43 -0
- node/planes/peering/budget/chain.py +43 -0
- node/planes/peering/budget/core.py +537 -0
- node/planes/peering/budget/evaluation.py +80 -0
- node/planes/peering/budget/meter.py +100 -0
- node/planes/peering/budget/receipts.py +439 -0
- node/planes/peering/budget/shape.py +101 -0
- node/planes/peering/budget/tick.py +110 -0
- node/planes/peering/budget/types.py +67 -0
- node/planes/peering/budget/windows.py +346 -0
- node/planes/peering/capacity/__init__.py +7 -0
- node/planes/peering/capacity/compartment.py +342 -0
- node/planes/peering/capacity/types.py +24 -0
- node/planes/peering/peering/__init__.py +25 -0
- node/planes/peering/peering/core.py +464 -0
- node/planes/peering/peering/envelope.py +142 -0
- node/planes/peering/peering/handshake.py +198 -0
- node/planes/peering/peering/shape.py +80 -0
- node/planes/peering/peering/types.py +39 -0
- node/planes/peering/reporter/__init__.py +42 -0
- node/planes/peering/reporter/constants.py +29 -0
- node/planes/peering/reporter/core.py +299 -0
- node/planes/peering/reporter/finding.py +74 -0
- node/planes/peering/reporter/findings.py +171 -0
- node/planes/peering/reporter/publish.py +285 -0
- node/planes/peering/reporter/reading.py +193 -0
- node/planes/peering/reporter/shape.py +86 -0
- node/planes/peering/reporter/types.py +50 -0
- node/planes/peering/strain/__init__.py +9 -0
- node/planes/peering/strain/compartment.py +204 -0
- node/planes/peering/strain/types.py +21 -0
- node/planes/peering/trust/__init__.py +9 -0
- node/planes/peering/trust/compartment.py +239 -0
- node/planes/peering/trust/types.py +30 -0
- node/planes/services/__init__.py +33 -0
- node/planes/services/compute/__init__.py +160 -0
- node/planes/services/compute/common.py +134 -0
- node/planes/services/compute/compartment.py +681 -0
- node/planes/services/compute/executors/__init__.py +121 -0
- node/planes/services/compute/executors/agent.py +840 -0
- node/planes/services/compute/executors/bacalhau.py +605 -0
- node/planes/services/compute/executors/base.py +127 -0
- node/planes/services/compute/executors/harness.py +439 -0
- node/planes/services/compute/executors/mcp.py +454 -0
- node/planes/services/compute/executors/media.py +898 -0
- node/planes/services/compute/executors/nondet.py +283 -0
- node/planes/services/compute/executors/providers.py +499 -0
- node/planes/services/compute/executors/registry.py +140 -0
- node/planes/services/compute/executors/wasm.py +244 -0
- node/planes/services/compute/executors/zk.py +604 -0
- node/planes/services/compute/jobs.py +227 -0
- node/planes/services/compute/load.py +180 -0
- node/planes/services/compute/market.py +433 -0
- node/planes/services/compute/sandbox.py +282 -0
- node/planes/services/compute/types.py +84 -0
- node/planes/services/compute_wire/__init__.py +58 -0
- node/planes/services/compute_wire/compartment.py +256 -0
- node/planes/services/compute_wire/messages.py +320 -0
- node/planes/services/compute_wire/sealing.py +147 -0
- node/planes/services/compute_wire/session.py +572 -0
- node/planes/services/compute_wire/types.py +49 -0
- node/planes/services/compute_wire/verify.py +331 -0
- node/planes/services/dvm/__init__.py +24 -0
- node/planes/services/dvm/gateway.py +526 -0
- node/planes/services/dvm/protocol.py +35 -0
- node/planes/services/dvm/translate.py +15 -0
- node/planes/services/dvm/types.py +25 -0
- node/planes/services/gateway/__init__.py +40 -0
- node/planes/services/gateway/settle.py +279 -0
- node/planes/services/gateway/translate.py +644 -0
- node/planes/services/gossip/__init__.py +22 -0
- node/planes/services/gossip/core.py +929 -0
- node/planes/services/gossip/envelope.py +154 -0
- node/planes/services/gossip/mesh.py +127 -0
- node/planes/services/gossip/types.py +50 -0
- node/planes/services/nsite/__init__.py +7 -0
- node/planes/services/nsite/client.py +62 -0
- node/planes/services/nsite/compartment.py +752 -0
- node/planes/services/nsite/types.py +44 -0
- node/planes/services/paidfetch/__init__.py +7 -0
- node/planes/services/paidfetch/compartment.py +862 -0
- node/planes/services/paidfetch/types.py +99 -0
- node/planes/services/settle/__init__.py +27 -0
- node/planes/services/settle/compartment.py +384 -0
- node/planes/services/settle/engine.py +456 -0
- node/planes/services/settle/gateways.py +541 -0
- node/planes/services/settle/payment.py +512 -0
- node/planes/services/settle/store.py +74 -0
- node/planes/services/settle/types.py +58 -0
- node/planes/services/sidecars/__init__.py +8 -0
- node/planes/services/sidecars/compartment.py +461 -0
- node/planes/services/sidecars/manifest.py +304 -0
- node/planes/services/sidecars/manifests/10-grasp.json +37 -0
- node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
- node/planes/services/sidecars/manifests/30-magpie.json +28 -0
- node/planes/services/sidecars/types.py +43 -0
- node/planes/services/video/__init__.py +106 -0
- node/planes/services/video/barter.py +407 -0
- node/planes/services/video/cli.py +158 -0
- node/planes/services/video/compartment.py +932 -0
- node/planes/services/video/ingest.py +524 -0
- node/planes/services/video/ledger.py +705 -0
- node/planes/services/video/packager.py +847 -0
- node/planes/services/video/player.py +456 -0
- node/planes/services/video/timed_segments.py +124 -0
- node/planes/services/video/transit.py +478 -0
- node/planes/store/__init__.py +24 -0
- node/planes/store/blob_replication/__init__.py +7 -0
- node/planes/store/blob_replication/compartment.py +496 -0
- node/planes/store/blob_replication/types.py +35 -0
- node/planes/store/blob_wire/__init__.py +48 -0
- node/planes/store/blob_wire/client.py +57 -0
- node/planes/store/blob_wire/core.py +359 -0
- node/planes/store/blob_wire/intake.py +74 -0
- node/planes/store/blob_wire/messages.py +85 -0
- node/planes/store/projected/__init__.py +7 -0
- node/planes/store/projected/compartment.py +258 -0
- node/planes/store/projected/types.py +29 -0
- node/planes/store/retention/__init__.py +7 -0
- node/planes/store/retention/compartment.py +214 -0
- node/planes/store/retention/types.py +12 -0
- node/planes/store/sync/__init__.py +35 -0
- node/planes/store/sync/core.py +311 -0
- node/planes/store/sync/envelope.py +106 -0
- node/planes/store/sync/handover.py +206 -0
- node/planes/store/sync/initiator.py +128 -0
- node/planes/store/sync/responder.py +114 -0
- node/planes/store/sync/rules.py +179 -0
- node/planes/store/sync/shape.py +80 -0
- node/planes/store/sync/types.py +51 -0
- node/planes/transport/__init__.py +23 -0
- node/planes/transport/bridge/__init__.py +13 -0
- node/planes/transport/bridge/compartment.py +785 -0
- node/planes/transport/bridge/ratelimit.py +44 -0
- node/planes/transport/bridge/types.py +43 -0
- node/planes/transport/lan/__init__.py +9 -0
- node/planes/transport/lan/compartment.py +446 -0
- node/planes/transport/lan/types.py +25 -0
- node/planes/transport/link/__init__.py +7 -0
- node/planes/transport/link/compartment.py +257 -0
- node/planes/transport/link/types.py +27 -0
- node/planes/transport/mediation/__init__.py +45 -0
- node/planes/transport/mediation/compartment.py +238 -0
- node/planes/transport/mediation/leaf.py +107 -0
- node/planes/transport/mediation/messages.py +302 -0
- node/planes/transport/mediation/service.py +233 -0
- node/planes/transport/mediation/store.py +37 -0
- node/planes/transport/mediation/types.py +20 -0
- node/planes/transport/p2p/__init__.py +7 -0
- node/planes/transport/p2p/compartment.py +349 -0
- node/planes/transport/p2p/types.py +22 -0
- node/protocols/__init__.py +277 -0
- node/protocols/activitypub/__init__.py +56 -0
- node/protocols/activitypub/actor.py +230 -0
- node/protocols/activitypub/federation.py +490 -0
- node/protocols/activitypub/media.py +145 -0
- node/protocols/activitypub/models.py +286 -0
- node/protocols/activitypub/package.py +271 -0
- node/protocols/activitypub/store.py +564 -0
- node/protocols/arcade/__init__.py +26 -0
- node/protocols/atproto/__init__.py +13 -0
- node/protocols/atproto/firehose.py +126 -0
- node/protocols/atproto/metering.py +165 -0
- node/protocols/atproto/package.py +155 -0
- node/protocols/atproto/repo.py +250 -0
- node/protocols/atproto/xrpc.py +285 -0
- node/protocols/base.py +109 -0
- node/protocols/circles/__init__.py +29 -0
- node/protocols/dm/__init__.py +50 -0
- node/protocols/grasp/__init__.py +22 -0
- node/protocols/isotime.py +15 -0
- node/protocols/nostr/__init__.py +51 -0
- node/protocols/nostr/blobstore.py +383 -0
- node/protocols/nostr/blossom.py +627 -0
- node/protocols/nostr/client.py +377 -0
- node/protocols/nostr/descriptor.py +211 -0
- node/protocols/nostr/eventstore.py +304 -0
- node/protocols/nostr/package.py +182 -0
- node/protocols/nostr/peers.py +109 -0
- node/protocols/nostr/relayserver.py +656 -0
- node/protocols/p2panda/__init__.py +22 -0
- node/protocols/p2panda/identity.py +19 -0
- node/protocols/p2panda/package.py +236 -0
- node/protocols/p2panda/rung.py +152 -0
- node/protocols/p2panda/store.py +273 -0
- node/protocols/rhio/__init__.py +33 -0
- node/protocols/rhio/package.py +275 -0
- node/protocols/rhio/rung.py +136 -0
- node/protocols/rhio/store.py +1423 -0
- node/protocols/ssb/muxrpc.py +256 -0
- node/protocols/ssb/package.py +267 -0
- node/protocols/textile/__init__.py +111 -0
- node/protocols/textile/transducers.py +259 -0
- node/release.py +119 -0
- node/storage/__init__.py +88 -0
- node/storage/accounts.py +305 -0
- node/storage/adapter.py +161 -0
- node/storage/base.py +385 -0
- node/storage/dialect.py +209 -0
- node/storage/grants.py +317 -0
- node/storage/locator.py +150 -0
- node/storage/postgres.py +294 -0
- node/storage/projected.py +953 -0
- node/storage/sharding.py +448 -0
- node/storage/standing.py +568 -0
- node/testnet/__init__.py +9 -0
- node/testnet/cluster.py +731 -0
- node/testnet/fips_daemon.py +230 -0
- node/transport/__init__.py +94 -0
- node/transport/const.py +110 -0
- node/transport/didcomm_inbound.py +251 -0
- node/transport/dnstxt.py +169 -0
- node/transport/fips_rung/__init__.py +67 -0
- node/transport/fips_rung/core.py +666 -0
- node/transport/fips_rung/listener.py +96 -0
- node/transport/fips_rung/types.py +12 -0
- node/transport/host.py +334 -0
- node/transport/iroh_rung/__init__.py +29 -0
- node/transport/iroh_rung/core.py +477 -0
- node/transport/iroh_rung/quic.py +942 -0
- node/transport/iroh_rung/types.py +22 -0
- node/transport/ladder.py +145 -0
- node/transport/lineproto.py +49 -0
- node/transport/mdns.py +303 -0
- node/transport/netfetch.py +283 -0
- node/transport/relay_rung/__init__.py +23 -0
- node/transport/relay_rung/cooldown.py +87 -0
- node/transport/relay_rung/core.py +138 -0
- node/transport/relay_rung/inbound.py +161 -0
- node/transport/relay_rung/ledger.py +104 -0
- node/transport/relay_rung/listen.py +121 -0
- node/transport/relay_rung/outbound.py +181 -0
- node/transport/relay_rung/shape.py +71 -0
- node/transport/relay_rung/types.py +58 -0
- node/transport/relay_rung/wire.py +63 -0
- node/transport/router.py +761 -0
- node/transport/rung.py +276 -0
- node/transport/ssb_rung.py +166 -0
- node/transport/ws.py +502 -0
- sidecar/fleetmesh_announce.py +338 -0
- sidecar/fleetmesh_p2p.py +373 -0
- sidecar/fleetmesh_rhio.py +352 -0
- sidecar/fleetmesh_ssb.py +603 -0
node/commands/adapter.py
ADDED
|
@@ -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')
|