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/app/control.py
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
"""The operator's face of the node: the control-socket dispatcher, the
|
|
2
|
+
actions only the daemon can take, and the shims that forward to compartments.
|
|
3
|
+
|
|
4
|
+
A mixin, not a module of functions, because every method here reads and
|
|
5
|
+
writes the node's own state (`self.grants`, `self.peering`, `self.cfg`) and
|
|
6
|
+
tests and the CLI call them as NodeApp methods. NodeApp in core.py inherits
|
|
7
|
+
it; nothing else does.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import collections
|
|
12
|
+
import threading
|
|
13
|
+
from typing import TYPE_CHECKING, Any, Callable, Mapping, MutableMapping, Optional, TypedDict, cast
|
|
14
|
+
|
|
15
|
+
from ..interfaces import CommandSpec
|
|
16
|
+
|
|
17
|
+
from fleetmesh_core.accounting import accounting, standing, window
|
|
18
|
+
from fleetmesh_core.identity import identity
|
|
19
|
+
from fleetmesh_core.services import lifecycle
|
|
20
|
+
from fleetmesh_core.storage import store as store_mod
|
|
21
|
+
from fleetmesh_core.wire.interfaces import GrantPayload, NostrEvent, StandingTellBody
|
|
22
|
+
from .. import (channels as channels_mod,
|
|
23
|
+
release as release_mod, transport as transport_mod)
|
|
24
|
+
from ..channels.bus import BusEnvelope, BusState
|
|
25
|
+
from . import socket as control_mod
|
|
26
|
+
from ..storage import grants as grants_mod
|
|
27
|
+
from ..protocols.nostr import client as relay, descriptor # noqa: E402
|
|
28
|
+
from ..channels.journal import line as _line, short as _short
|
|
29
|
+
from ..compartment.command import CommandError
|
|
30
|
+
from ..compartment import lifecycle as compartment_lifecycle
|
|
31
|
+
from .commands import NODE_COMMANDS_BY_KEY
|
|
32
|
+
from .config import CONTROL, save_config
|
|
33
|
+
from .views import GrantView, WindowView, _live_peers, grant_view, windows_view
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from .. import compartment as compartment_mod, keystore
|
|
37
|
+
from ..protocols.nostr import peers as peers_mod
|
|
38
|
+
from ..planes.peering import budget as budget_mod, peering as peering_wire, reporter as reporter_mod
|
|
39
|
+
from ..planes.store import sync as sync_wire
|
|
40
|
+
from ..storage import accounts as accounts_mod
|
|
41
|
+
from ..planes.identity.discovery import compartment as discovery_wire
|
|
42
|
+
from ..planes.identity.presence import compartment as presence_mod
|
|
43
|
+
from ..planes.operations.scheduler import compartment as scheduler_mod
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
MAX_PULL_TIMEOUT = 600 # an operator-driven pull holds a connection slot and a sync session this long at most
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
VersionResult = TypedDict('VersionResult', {
|
|
50
|
+
'protocol': str,
|
|
51
|
+
'fleetmesh': str,
|
|
52
|
+
'digest': str,
|
|
53
|
+
'release': Mapping[str, Optional[str]],
|
|
54
|
+
'did': str,
|
|
55
|
+
'pubkey': str,
|
|
56
|
+
'class': str,
|
|
57
|
+
'commands': list[str],
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CommandDetail(CommandSpec, total=False):
|
|
62
|
+
name: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RepublishResult(TypedDict):
|
|
66
|
+
descriptor: str
|
|
67
|
+
relays: Mapping[str, tuple[bool, str]]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class EventsResult(BusState):
|
|
71
|
+
topics: list[str]
|
|
72
|
+
events: list[BusEnvelope]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class StandingResult(TypedDict):
|
|
76
|
+
answer: str
|
|
77
|
+
body: Optional[Mapping[str, Any]]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ReportItem(TypedDict):
|
|
81
|
+
reporter: str
|
|
82
|
+
severity: Optional[str]
|
|
83
|
+
admissible: bool
|
|
84
|
+
reasons: list[str]
|
|
85
|
+
weight: float
|
|
86
|
+
id: str
|
|
87
|
+
created_at: int
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ReportsResult(TypedDict):
|
|
91
|
+
view: str
|
|
92
|
+
reports: list[ReportItem]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class PeerGrantSummary(TypedDict):
|
|
96
|
+
tier: Optional[str]
|
|
97
|
+
status: str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PeerPendingSummary(TypedDict):
|
|
101
|
+
tier: Optional[str]
|
|
102
|
+
from_epoch: int
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class PeerEvaluationSummary(TypedDict):
|
|
106
|
+
epoch: int
|
|
107
|
+
severity: Optional[str]
|
|
108
|
+
state: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class PeerViewRow(TypedDict):
|
|
112
|
+
peer: str
|
|
113
|
+
issued: Optional[PeerGrantSummary]
|
|
114
|
+
received: Optional[PeerGrantSummary]
|
|
115
|
+
pending: Optional[PeerPendingSummary]
|
|
116
|
+
last_evaluated: Optional[PeerEvaluationSummary]
|
|
117
|
+
standing: Optional[StandingTellBody]
|
|
118
|
+
zeroed_until: Optional[int]
|
|
119
|
+
pulls: list[str]
|
|
120
|
+
descriptor: Optional[str]
|
|
121
|
+
shared: list[str]
|
|
122
|
+
not_shared: list[str]
|
|
123
|
+
digest: Optional[str]
|
|
124
|
+
notices: list[str]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class RefusalResult(TypedDict):
|
|
128
|
+
subsystem: str | None
|
|
129
|
+
at: int
|
|
130
|
+
kind: str
|
|
131
|
+
subject: str | None
|
|
132
|
+
why: str
|
|
133
|
+
detail: Mapping[str, Any] | str
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class SubsystemStateResult(TypedDict):
|
|
137
|
+
name: str
|
|
138
|
+
state: str
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class RetireSteps(TypedDict, total=False):
|
|
142
|
+
reason: str
|
|
143
|
+
at: int
|
|
144
|
+
did: list[list[Any]]
|
|
145
|
+
peers: list[str]
|
|
146
|
+
handed: int
|
|
147
|
+
withdrawn: int
|
|
148
|
+
revoked: bool
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class TerminateResult(TypedDict):
|
|
152
|
+
peer: str
|
|
153
|
+
reason: str
|
|
154
|
+
told: Mapping[str, Any]
|
|
155
|
+
pulls_removed: int
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _arg_int(cmd: str, args: Mapping[str, Any], name: str, default: int) -> int:
|
|
159
|
+
v = args.get(name)
|
|
160
|
+
if v is None:
|
|
161
|
+
return default
|
|
162
|
+
if isinstance(v, bool) or not isinstance(v, (int, float, str)):
|
|
163
|
+
raise control_mod.ControlError('%s: %s must be an integer' % (cmd, name))
|
|
164
|
+
try:
|
|
165
|
+
out = int(v)
|
|
166
|
+
except (TypeError, ValueError):
|
|
167
|
+
raise control_mod.ControlError('%s: %s must be an integer, not %r' % (cmd, name, str(v)[:32]))
|
|
168
|
+
if out < 0:
|
|
169
|
+
raise control_mod.ControlError('%s: %s must not be negative' % (cmd, name))
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _arg_float(cmd: str, args: Mapping[str, Any], name: str, default: float) -> float:
|
|
174
|
+
v = args.get(name)
|
|
175
|
+
if v is None:
|
|
176
|
+
return default
|
|
177
|
+
if isinstance(v, bool) or not isinstance(v, (int, float, str)):
|
|
178
|
+
raise control_mod.ControlError('%s: %s must be a number' % (cmd, name))
|
|
179
|
+
try:
|
|
180
|
+
out = float(v)
|
|
181
|
+
except (TypeError, ValueError):
|
|
182
|
+
raise control_mod.ControlError('%s: %s must be a number, not %r' % (cmd, name, str(v)[:32]))
|
|
183
|
+
if out != out or out in (float('inf'), float('-inf')):
|
|
184
|
+
raise control_mod.ControlError('%s: %s must be finite' % (cmd, name))
|
|
185
|
+
return out
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _arg_hex(cmd: str, args: Mapping[str, Any], name: str) -> str:
|
|
189
|
+
"""A 64-hex pubkey, lower-cased. Everything downstream keys tables on it."""
|
|
190
|
+
v = args.get(name)
|
|
191
|
+
if not isinstance(v, str) or len(v) != 64:
|
|
192
|
+
raise control_mod.ControlError('%s: %s must be a 64-hex pubkey' % (cmd, name))
|
|
193
|
+
try:
|
|
194
|
+
bytes.fromhex(v)
|
|
195
|
+
except ValueError:
|
|
196
|
+
raise control_mod.ControlError('%s: %s must be a 64-hex pubkey' % (cmd, name))
|
|
197
|
+
return v.lower()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _topics_arg(raw: Any) -> set[str] | None:
|
|
201
|
+
if raw is None or raw == []:
|
|
202
|
+
return None
|
|
203
|
+
if isinstance(raw, str):
|
|
204
|
+
raw = [t for t in raw.split(',') if t]
|
|
205
|
+
if not isinstance(raw, list) or not all(isinstance(t, str) for t in raw):
|
|
206
|
+
raise control_mod.ControlError('topics is a list of topic names; `events` lists them')
|
|
207
|
+
return set(raw)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class ControlMixin(object):
|
|
211
|
+
# What the mixin reads off the node it is mixed into. Declarations only,
|
|
212
|
+
# for the checker; NodeApp.__init__ and start() assign every one of them.
|
|
213
|
+
if TYPE_CHECKING:
|
|
214
|
+
dir: str
|
|
215
|
+
cfg: MutableMapping[str, Any]
|
|
216
|
+
clock: Callable[[], int]
|
|
217
|
+
relays: list[str]
|
|
218
|
+
ks: keystore.Keystore
|
|
219
|
+
grants: grants_mod.GrantStore
|
|
220
|
+
accounts: accounts_mod.AccountStore
|
|
221
|
+
peers: peers_mod.Peers
|
|
222
|
+
transport: transport_mod.TransportRouter
|
|
223
|
+
ledger: standing.Ledger
|
|
224
|
+
timeout: float
|
|
225
|
+
_stop: threading.Event
|
|
226
|
+
_built: bool
|
|
227
|
+
subsystems: compartment_mod.Registry
|
|
228
|
+
events: channels_mod.NodeEventBus
|
|
229
|
+
peering: peering_wire.Peering
|
|
230
|
+
budget: budget_mod.Budget
|
|
231
|
+
sync: sync_wire.SyncPeer
|
|
232
|
+
reporter: reporter_mod.Reporter
|
|
233
|
+
discovery: discovery_wire.Discovery
|
|
234
|
+
presence: presence_mod.Presence
|
|
235
|
+
scheduler: scheduler_mod.Scheduler
|
|
236
|
+
|
|
237
|
+
def _descriptor(self) -> NostrEvent: ...
|
|
238
|
+
def tick(self, now: int | None = None) -> list[tuple[Any, ...]]: ...
|
|
239
|
+
def status(self) -> Mapping[str, Any]: ...
|
|
240
|
+
def health(self) -> Mapping[str, Any]: ...
|
|
241
|
+
|
|
242
|
+
def control_request(self, cmd: str, args: Mapping[str, Any],
|
|
243
|
+
actor: Any = None) -> Any:
|
|
244
|
+
"""Answer one control request, recording who asked.
|
|
245
|
+
|
|
246
|
+
`actor` is built at the socket boundary (node/control.py:actor_of),
|
|
247
|
+
because SO_PEERCRED can only be read off a live connection and by the
|
|
248
|
+
time a command runs the socket is gone. It is held in a ContextVar
|
|
249
|
+
(node/config/change.py:ACTING) for the length of this request, so a
|
|
250
|
+
config change raised during the command is attributed to the person
|
|
251
|
+
who caused it - and only to them. An attribute on the node was shared
|
|
252
|
+
by every connection thread, so two concurrent requests could swap
|
|
253
|
+
authors.
|
|
254
|
+
|
|
255
|
+
Nothing here checks an ability. `Command.permission` and
|
|
256
|
+
`Parameter.requires` are declared and unenforced on purpose: this
|
|
257
|
+
socket is chmod 0o600, so everyone who can reach it is already the
|
|
258
|
+
owner, and a check against that is a control which protects nothing
|
|
259
|
+
while reading as though it does. Enforcement belongs with a principal
|
|
260
|
+
that is not already the owner - a UCAN delegation over the wire, or a
|
|
261
|
+
socket opened to several unix users - and the markers come off then.
|
|
262
|
+
"""
|
|
263
|
+
from ..config import change as change_mod # noqa: PLC0415
|
|
264
|
+
with change_mod.acting_as(actor):
|
|
265
|
+
try:
|
|
266
|
+
return self._answer_request(cmd, args)
|
|
267
|
+
except CommandError as e:
|
|
268
|
+
# The caller's mistake, checked against the declaration: a
|
|
269
|
+
# refusal they read, kept out of the ring that counts bugs.
|
|
270
|
+
raise control_mod.ControlError(str(e))
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def acting(self) -> Any:
|
|
274
|
+
"""The actor of the request running on this thread, or None."""
|
|
275
|
+
from ..config import change as change_mod # noqa: PLC0415
|
|
276
|
+
return change_mod.acting()
|
|
277
|
+
|
|
278
|
+
def _answer_request(self, cmd: str, args: Mapping[str, Any]) -> Any:
|
|
279
|
+
"""One request over the control socket, dispatched on CONTROL. Reads
|
|
280
|
+
answer from the daemon's live state; actions are the things only the
|
|
281
|
+
daemon can do. The config file is the way to change what the node is
|
|
282
|
+
told to do."""
|
|
283
|
+
table = self.commands()
|
|
284
|
+
if cmd not in table:
|
|
285
|
+
raise control_mod.ControlError('unknown command %r; `commands` lists the %d this socket answers'
|
|
286
|
+
% (str(cmd)[:32], len(table)))
|
|
287
|
+
missing = [k for k, v in table[cmd]['args'].items() if v == 'required' and k not in args]
|
|
288
|
+
if missing:
|
|
289
|
+
raise control_mod.ControlError('%s needs %s' % (cmd, ', '.join(missing)))
|
|
290
|
+
cmd_obj = NODE_COMMANDS_BY_KEY.get(cmd)
|
|
291
|
+
if cmd_obj is not None:
|
|
292
|
+
return cmd_obj.execute(self, args)
|
|
293
|
+
if self._built and cmd in self.subsystems.commands():
|
|
294
|
+
return self.subsystems.dispatch(cmd, dict(args))
|
|
295
|
+
raise control_mod.ControlError('%s is in the table and not in the dispatcher' % cmd)
|
|
296
|
+
|
|
297
|
+
# ---- node command handlers -----------------------------------------------------
|
|
298
|
+
def cmd_version(self) -> VersionResult:
|
|
299
|
+
return {'protocol': control_mod.PROTOCOL, 'fleetmesh': descriptor.VERSION, 'digest': descriptor.DIGEST,
|
|
300
|
+
'release': release_mod.running().as_dict(),
|
|
301
|
+
'did': self.ks.did, 'pubkey': self.ks.node_pk, 'class': self.cfg['class'],
|
|
302
|
+
'commands': list(self.commands())}
|
|
303
|
+
|
|
304
|
+
def cmd_commands(self) -> list[CommandDetail]:
|
|
305
|
+
return [dict(name=name, **spec) for name, spec in self.commands().items()]
|
|
306
|
+
|
|
307
|
+
def cmd_subsystems(self) -> Mapping[str, Mapping[str, Any]]:
|
|
308
|
+
return self.subsystems.report()
|
|
309
|
+
|
|
310
|
+
def cmd_lifecycle(self) -> Mapping[str, compartment_lifecycle.LifecycleRecord]:
|
|
311
|
+
return self.subsystems.lifecycle()
|
|
312
|
+
|
|
313
|
+
def cmd_tick(self) -> list[list[Any]]:
|
|
314
|
+
return [list(d) for d in self.tick()]
|
|
315
|
+
|
|
316
|
+
def cmd_republish(self) -> RepublishResult:
|
|
317
|
+
desc = self._descriptor()
|
|
318
|
+
return {'descriptor': desc['id'],
|
|
319
|
+
'relays': relay.publish_all(self.relays, desc, self.timeout) if self.relays else {}}
|
|
320
|
+
|
|
321
|
+
def cmd_grant(self, args: Mapping[str, Any]) -> GrantView:
|
|
322
|
+
return grant_view(self.grants, _arg_hex('grant', args, 'peer'))
|
|
323
|
+
|
|
324
|
+
def cmd_windows(self, args: Mapping[str, Any]) -> list[WindowView]:
|
|
325
|
+
return windows_view(self.accounts, _arg_hex('windows', args, 'peer'), _arg_int('windows', args, 'last', 0))
|
|
326
|
+
|
|
327
|
+
def cmd_refusals(self, args: Mapping[str, Any]) -> list[RefusalResult]:
|
|
328
|
+
return [{'subsystem': n, 'at': r.at, 'kind': r.kind,
|
|
329
|
+
'subject': (r.subject or '')[:16] or None, 'why': r.why, 'detail': r.detail}
|
|
330
|
+
for n, r in self.subsystems.refusals(_arg_int('refusals', args, 'last', 5))]
|
|
331
|
+
|
|
332
|
+
def cmd_resume(self, args: Mapping[str, Any]) -> SubsystemStateResult:
|
|
333
|
+
name = str(args.get('name') or '')
|
|
334
|
+
actor = self.acting
|
|
335
|
+
who = getattr(actor, 'operator', '') or 'the operator'
|
|
336
|
+
try:
|
|
337
|
+
state = self.subsystems.resume(name, why='resumed by %s' % who)
|
|
338
|
+
except KeyError as e:
|
|
339
|
+
raise control_mod.ControlError(str(e.args[0] if e.args else e))
|
|
340
|
+
return {'name': name, 'state': state}
|
|
341
|
+
|
|
342
|
+
def cmd_restart(self, args: Mapping[str, Any]) -> SubsystemStateResult:
|
|
343
|
+
name = str(args.get('name') or '')
|
|
344
|
+
who = getattr(self.acting, 'operator', '') or 'the operator'
|
|
345
|
+
try:
|
|
346
|
+
state = self.subsystems.restart(name, why='by %s' % who)
|
|
347
|
+
except (KeyError, ValueError) as e:
|
|
348
|
+
raise control_mod.ControlError(str(e.args[0] if e.args else e))
|
|
349
|
+
return {'name': name, 'state': state}
|
|
350
|
+
|
|
351
|
+
def cmd_events(self, args: Mapping[str, Any]) -> EventsResult:
|
|
352
|
+
topics = _topics_arg(args.get('topics'))
|
|
353
|
+
tap = self.events.tap
|
|
354
|
+
assert tap is not None
|
|
355
|
+
evs = tap.since(_arg_int('events', args, 'since', 0), topics, _arg_int('events', args, 'last', 0))
|
|
356
|
+
st = tap.state()
|
|
357
|
+
return {
|
|
358
|
+
'seq': st['seq'],
|
|
359
|
+
'ring': st['ring'],
|
|
360
|
+
'ring_max': st['ring_max'],
|
|
361
|
+
'subscribers': st['subscribers'],
|
|
362
|
+
'oldest': st['oldest'],
|
|
363
|
+
'topics': sorted(channels_mod.WIRE),
|
|
364
|
+
'events': evs,
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
def cmd_subscribe(self, args: Mapping[str, Any]) -> control_mod.Stream:
|
|
368
|
+
topics = _topics_arg(args.get('topics'))
|
|
369
|
+
tap = self.events.tap
|
|
370
|
+
assert tap is not None
|
|
371
|
+
sub = tap.subscribe(topics)
|
|
372
|
+
backlog = collections.deque(tap.since(_arg_int('subscribe', args, 'since', 0), topics)) if args.get('since') is not None else collections.deque()
|
|
373
|
+
|
|
374
|
+
def pull(timeout: float) -> Optional[BusEnvelope]:
|
|
375
|
+
if backlog:
|
|
376
|
+
return backlog.popleft()
|
|
377
|
+
return sub.next(timeout)
|
|
378
|
+
return control_mod.Stream({'seq': tap.seq, 'topics': sorted(topics) if topics else None,
|
|
379
|
+
'replaying': len(backlog)}, pull, lambda: tap.unsubscribe(sub))
|
|
380
|
+
|
|
381
|
+
def cmd_pull(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
382
|
+
peer, shard, filt = _arg_hex('pull', args, 'peer'), args.get('shard'), args.get('filter')
|
|
383
|
+
if not isinstance(shard, str) or not isinstance(filt, dict):
|
|
384
|
+
raise control_mod.ControlError('pull takes shard as a string and filter as an object')
|
|
385
|
+
timeout = _arg_float('pull', args, 'timeout', 60.0)
|
|
386
|
+
if not 0 < timeout <= MAX_PULL_TIMEOUT:
|
|
387
|
+
raise control_mod.ControlError('pull timeout must be in (0, %d] seconds' % MAX_PULL_TIMEOUT)
|
|
388
|
+
why = self.budget.throttled(peer)
|
|
389
|
+
if why is not None and not args.get('force'):
|
|
390
|
+
raise control_mod.ControlError('the grant does not cover a session now: %s (--force pulls anyway, and the window prices it)' % why)
|
|
391
|
+
return self.pull(peer, shard, filt, timeout=timeout)
|
|
392
|
+
|
|
393
|
+
def cmd_terminate(self, args: Mapping[str, Any]) -> TerminateResult:
|
|
394
|
+
return self.terminate(_arg_hex('terminate', args, 'peer'), str(args.get('reason') or 'operator')[:200])
|
|
395
|
+
|
|
396
|
+
def cmd_standing(self, args: Mapping[str, Any]) -> StandingResult:
|
|
397
|
+
kind, body = self.reporter.ask_standing(_arg_hex('standing', args, 'peer'), _arg_hex('standing', args, 'target'))
|
|
398
|
+
if kind is None:
|
|
399
|
+
raise control_mod.ControlError('no answer from the peer')
|
|
400
|
+
return {'answer': kind, 'body': body}
|
|
401
|
+
|
|
402
|
+
def cmd_reports(self, args: Mapping[str, Any]) -> ReportsResult:
|
|
403
|
+
target = _arg_hex('reports', args, 'target')
|
|
404
|
+
view, weighed = self.reporter.view(target)
|
|
405
|
+
found = self.reporter.reports_about(target)
|
|
406
|
+
return {'view': view, 'reports': [{'reporter': r['reporter'], 'severity': r['severity'],
|
|
407
|
+
'admissible': r['admissible'], 'reasons': r['reasons'],
|
|
408
|
+
'weight': self.reporter.weight_of(r['reporter']),
|
|
409
|
+
'id': r['event']['id'], 'created_at': r['event']['created_at']}
|
|
410
|
+
for r in found]}
|
|
411
|
+
|
|
412
|
+
def cmd_retire(self, args: Mapping[str, Any]) -> RetireSteps:
|
|
413
|
+
out = self.retire(str(args.get('reason') or 'operator')[:200], bool(args.get('revoke')),
|
|
414
|
+
_arg_int('retire', args, 'lapses_in', 86400))
|
|
415
|
+
self._stop.set() # the tick loop ends; `run` calls stop, which says farewell
|
|
416
|
+
return out
|
|
417
|
+
|
|
418
|
+
def commands(self) -> collections.OrderedDict[str, CommandSpec]:
|
|
419
|
+
"""Every command this socket answers: the node's own, and one per
|
|
420
|
+
command each compartment declares.
|
|
421
|
+
|
|
422
|
+
The compartments' were unreachable. `control_request` refused anything
|
|
423
|
+
not in CONTROL before it ever looked at the registry, so the branch that
|
|
424
|
+
dispatched `peering.show` could not be reached and no subsystem command
|
|
425
|
+
had ever been answered. A dispatch table that documents itself has to be
|
|
426
|
+
the same table the guard reads.
|
|
427
|
+
"""
|
|
428
|
+
built = getattr(self, '_built', False)
|
|
429
|
+
sub_key = tuple(s.name for s in self.subsystems) if built else ()
|
|
430
|
+
cached = getattr(self, '_commands_cache', None)
|
|
431
|
+
if cached is not None and cached[0] == built and cached[1] == sub_key:
|
|
432
|
+
return collections.OrderedDict(cached[2])
|
|
433
|
+
table = collections.OrderedDict(CONTROL)
|
|
434
|
+
if built:
|
|
435
|
+
for name, spec in self.subsystems.commands().items():
|
|
436
|
+
table[name] = CommandSpec(args=spec.get('args', {}), does=spec.get('does', ''),
|
|
437
|
+
reply=spec.get('reply', ''))
|
|
438
|
+
self._commands_cache = (built, sub_key, table)
|
|
439
|
+
return collections.OrderedDict(table)
|
|
440
|
+
|
|
441
|
+
def retire(self, reason: str = 'operator', revoke: bool = False,
|
|
442
|
+
lapses_in: int = 86400) -> RetireSteps:
|
|
443
|
+
"""Leave the mesh in order, rather than by going quiet.
|
|
444
|
+
|
|
445
|
+
`farewell` closed one of the four ways a node's departure cost its
|
|
446
|
+
peers something: the accounting. Everything under the accounting was
|
|
447
|
+
still left behind. The coverage claims kept pointing at a node that was
|
|
448
|
+
gone, so every reader that believed one pulled and timed out; the
|
|
449
|
+
shards this node held first-hand lost their authority, because nobody
|
|
450
|
+
else may assert what it only replicated; and each of those peers
|
|
451
|
+
recorded strain against a node that had done nothing but stop.
|
|
452
|
+
|
|
453
|
+
Four steps, in this order, and the order is the whole of it:
|
|
454
|
+
|
|
455
|
+
announce a heartbeat saying this is a departure, so the silence
|
|
456
|
+
that follows is not priced as stress by anyone listening.
|
|
457
|
+
close every asserted shard gets a range ending now. A handover
|
|
458
|
+
signs for a closed set, so an open range cannot be passed
|
|
459
|
+
on, and the shards worth passing on are exactly the ones
|
|
460
|
+
nobody ever bounded.
|
|
461
|
+
hand over the statement goes to every peer holding a live grant,
|
|
462
|
+
stored. The peer keeps it only if it fingerprints to what
|
|
463
|
+
the peer holds, so being wrong about who has the data
|
|
464
|
+
costs nothing.
|
|
465
|
+
withdraw every claim is republished best-effort with an expiration.
|
|
466
|
+
After the handovers, never before: a withdrawal takes back
|
|
467
|
+
the very claim a handover copies its terms from.
|
|
468
|
+
farewell what `stop` already does, and it goes last because a
|
|
469
|
+
receipt is the last thing this node has to say.
|
|
470
|
+
|
|
471
|
+
This is a retirement and not a restart. A restart MUST NOT run it: the
|
|
472
|
+
claims would come back on the next tick with their ranges closed and
|
|
473
|
+
their authority given away, which is a fleet upgrade paying the price
|
|
474
|
+
this exists to avoid. `meshnode retire` is a separate command for that
|
|
475
|
+
reason, and `run` never calls it.
|
|
476
|
+
|
|
477
|
+
Reversible up to the last step. A node that retires and is started
|
|
478
|
+
again publishes its claims afresh; what it does not get back is the
|
|
479
|
+
`until` it closed, which is a line in the config, and the handovers it
|
|
480
|
+
signed, which are permanent by design. `revoke` is the one step that
|
|
481
|
+
is not reversible at all, so it is off unless asked for.
|
|
482
|
+
|
|
483
|
+
Nothing here may raise. A node that cannot retire cleanly must still
|
|
484
|
+
stop.
|
|
485
|
+
"""
|
|
486
|
+
now = self.clock()
|
|
487
|
+
did: list[tuple[Any, ...]] = []
|
|
488
|
+
steps: RetireSteps = {'reason': reason, 'at': now}
|
|
489
|
+
try:
|
|
490
|
+
# First, and before anything is given away: say this is a
|
|
491
|
+
# departure. Under FLP nothing else distinguishes a node that went
|
|
492
|
+
# from one that went quiet, so a peer holding a schedule against
|
|
493
|
+
# this one would otherwise record a partition it was about to be
|
|
494
|
+
# told about.
|
|
495
|
+
did += self.presence.announce_retiring(now)
|
|
496
|
+
except Exception as e:
|
|
497
|
+
did.append(('announcement failed', '%s: %s' % (type(e).__name__, e)))
|
|
498
|
+
try:
|
|
499
|
+
did += self.discovery.close_ranges(now)
|
|
500
|
+
except Exception as e:
|
|
501
|
+
did.append(('close failed', '%s: %s' % (type(e).__name__, e)))
|
|
502
|
+
peers_held = [p for p in _live_peers(self.grants)
|
|
503
|
+
if (row := self.grants.get(p, grants_mod.ISSUED)) and row.get('status') == 'live']
|
|
504
|
+
try:
|
|
505
|
+
did += self.sync.hand_over_all(peers_held, now)
|
|
506
|
+
except Exception as e:
|
|
507
|
+
did.append(('handover failed', '%s: %s' % (type(e).__name__, e)))
|
|
508
|
+
try:
|
|
509
|
+
did += self.discovery.withdraw_coverage(now, lapses_in=lapses_in)
|
|
510
|
+
except Exception as e:
|
|
511
|
+
did.append(('withdrawal failed', '%s: %s' % (type(e).__name__, e)))
|
|
512
|
+
if revoke:
|
|
513
|
+
try:
|
|
514
|
+
ev = lifecycle.revocation(self.ks.node_pk, reason, now)
|
|
515
|
+
signed = store_mod.make_signed_event(self.ks.node_sk, now, ev['kind'], ev['tags'], ev['content'])
|
|
516
|
+
relay.publish_all(self.relays, signed, self.timeout)
|
|
517
|
+
did.append(('revoked', signed['id'], reason))
|
|
518
|
+
except Exception as e:
|
|
519
|
+
did.append(('revocation failed', '%s: %s' % (type(e).__name__, e)))
|
|
520
|
+
for d in did:
|
|
521
|
+
_line(' ' + ' '.join(_short(str(x)) for x in d))
|
|
522
|
+
steps['did'] = [list(d) for d in did]
|
|
523
|
+
steps['peers'] = peers_held
|
|
524
|
+
steps['handed'] = sum(1 for d in did if d[0] == 'handed over')
|
|
525
|
+
steps['withdrawn'] = sum(1 for d in did if d[0] == 'coverage withdrawn')
|
|
526
|
+
steps['revoked'] = bool(revoke and any(d[0] == 'revoked' for d in did))
|
|
527
|
+
return steps
|
|
528
|
+
|
|
529
|
+
def save_coverage(self) -> None:
|
|
530
|
+
"""Write the config back after a compartment changed what this node
|
|
531
|
+
claims. One place, because a running node owns its directory and the
|
|
532
|
+
file is what the next tick reads. Compartments reach it as
|
|
533
|
+
`ctx.persist_cfg`; the node reaches it directly."""
|
|
534
|
+
save_config(self.dir, {k: v for k, v in self.cfg.items() if not k.startswith('_')})
|
|
535
|
+
|
|
536
|
+
def terminate(self, peer_pk: str, reason: str = 'operator') -> TerminateResult:
|
|
537
|
+
"""End a peering: zero both grants here, tell the peer, and stop
|
|
538
|
+
pulling from it. The trust decision, if any, is left where it is."""
|
|
539
|
+
rows = [self.grants.get(peer_pk, d) for d in (grants_mod.ISSUED, grants_mod.RECEIVED)]
|
|
540
|
+
if not any(r is not None and r['status'] == 'live' for r in rows):
|
|
541
|
+
raise control_mod.ControlError('no live peering with %s' % peer_pk[:16])
|
|
542
|
+
msg = self.peering.host.terminate(peer_pk, reason=reason)
|
|
543
|
+
self.grants.zero(peer_pk, self.clock())
|
|
544
|
+
sent: Mapping[str, Any] = {}
|
|
545
|
+
try:
|
|
546
|
+
sent = self.transport.send(msg, peer_pk, stored=True)
|
|
547
|
+
except transport_mod.TransportError as e:
|
|
548
|
+
sent = {'error': str(e)}
|
|
549
|
+
before = len(self.cfg.get('peers', []))
|
|
550
|
+
self.cfg['peers'] = [p for p in self.cfg.get('peers', []) if p.get('pubkey') != peer_pk]
|
|
551
|
+
if len(self.cfg['peers']) != before:
|
|
552
|
+
self.save_coverage()
|
|
553
|
+
_line(' terminated the peering with', _short(peer_pk), '-', reason)
|
|
554
|
+
return {'peer': peer_pk, 'reason': reason, 'told': sent, 'pulls_removed': before - len(self.cfg['peers'])}
|
|
555
|
+
|
|
556
|
+
def peer_view(self) -> list[PeerViewRow]:
|
|
557
|
+
"""Every peer this node holds a grant with, as an operator reads it."""
|
|
558
|
+
out: list[PeerViewRow] = []
|
|
559
|
+
ours = set(identity.PROTOCOLS)
|
|
560
|
+
for pk in sorted({g['peer'] for g in self.grants.list()}):
|
|
561
|
+
desc = self.peers.descriptor(pk)
|
|
562
|
+
theirs = identity.protocols_of(desc) if desc else set()
|
|
563
|
+
shared = identity.shared_protocols(theirs, ours) if desc else set()
|
|
564
|
+
issued = self.grants.get(pk, grants_mod.ISSUED)
|
|
565
|
+
received = self.grants.get(pk, grants_mod.RECEIVED)
|
|
566
|
+
pend = self.grants.pending(pk)
|
|
567
|
+
evals = [r for r in self.budget.evaluations if r.peer == pk]
|
|
568
|
+
out.append({'peer': pk,
|
|
569
|
+
'issued': {'tier': issued['payload'].get('tier'), 'status': issued['status']} if issued else None,
|
|
570
|
+
'received': {'tier': received['payload'].get('tier'), 'status': received['status']} if received else None,
|
|
571
|
+
'pending': {'tier': pend['payload'].get('tier'), 'from_epoch': pend['decided_epoch'] + 1} if pend else None,
|
|
572
|
+
'last_evaluated': ({'epoch': evals[-1].epoch, 'severity': evals[-1].severity,
|
|
573
|
+
'state': evals[-1].state} if evals else None),
|
|
574
|
+
'standing': self.ledger.tell(pk),
|
|
575
|
+
'zeroed_until': self.budget.zero_until.get(pk),
|
|
576
|
+
'pulls': [p['shard'] for p in self.cfg.get('peers', []) if p.get('pubkey') == pk],
|
|
577
|
+
'descriptor': desc.get('id') if desc else None,
|
|
578
|
+
# What the two nodes can run with each other, and the
|
|
579
|
+
# schema the peer declared when it differs from ours.
|
|
580
|
+
'shared': sorted(p[len(identity.FLEETMESH):] for p in shared),
|
|
581
|
+
'not_shared': sorted(p[len(identity.FLEETMESH):] for p in theirs - shared) if desc else [],
|
|
582
|
+
'digest': (self.peering.host.digest_mismatch.get(pk) or '')[:16] or None,
|
|
583
|
+
'notices': identity.notices(desc, digest=descriptor.DIGEST) if desc else []})
|
|
584
|
+
return out
|
|
585
|
+
|
|
586
|
+
# ---- actions -------------------------------------------------------------------
|
|
587
|
+
# A shard filter is a NIP-01 filter and routinely carries tag keys ('#t');
|
|
588
|
+
# node.interfaces.NostrFilter names no tag keys, so the plain mapping is
|
|
589
|
+
# the accurate type until that TypedDict grows them.
|
|
590
|
+
def propose(self, peer_pk: str, shard: str, filt: Mapping[str, Any], mode: str = 'both', timeout: float = 30.0) -> tuple[str, Mapping[str, Any]]:
|
|
591
|
+
want = [{'shard': shard, 'filter': dict(filt), 'mode': mode}]
|
|
592
|
+
return self.peering.propose(peer_pk, want, timeout=timeout)
|
|
593
|
+
|
|
594
|
+
def pull(self, peer_pk: str, shard: str, filt: Mapping[str, Any], timeout: float = 30.0) -> Mapping[str, Any]:
|
|
595
|
+
return self.sync.pull(peer_pk, shard, dict(filt), timeout=timeout)
|
|
596
|
+
|
|
597
|
+
def regrant(self, peer_pk: str, tier: str, now: int | None = None) -> Optional[GrantPayload]:
|
|
598
|
+
"""Re-issue a live grant at a tier the operator has just decided on.
|
|
599
|
+
|
|
600
|
+
A tier is only ever raised by a person, so the decision has to reach an
|
|
601
|
+
existing peering as well as the next one. This writes a new grant at the
|
|
602
|
+
new tier, publishes it as a fresh kind 30801 under the same blinded
|
|
603
|
+
identifier, and tells the subject with peering/1.0/amend - the same path
|
|
604
|
+
a window evaluation uses, because it is the same kind of change.
|
|
605
|
+
"""
|
|
606
|
+
now = now if now is not None else self.clock()
|
|
607
|
+
row = self.grants.get(peer_pk, grants_mod.ISSUED)
|
|
608
|
+
if row is None:
|
|
609
|
+
return None
|
|
610
|
+
# A person deciding is the one thing allowed to skip the earning. The
|
|
611
|
+
# specification says an operator MAY seed a tier for a node it knows out
|
|
612
|
+
# of band, recorded as a signed event and reversible, so a regrant opens
|
|
613
|
+
# at the ceiling rather than at the opening fraction a stranger gets.
|
|
614
|
+
held = row['payload']
|
|
615
|
+
payload = cast(GrantPayload, dict(held, tier=tier,
|
|
616
|
+
limits={k: v for k, v in window.ceilings(tier).items() if k != 'dup_ratio'}))
|
|
617
|
+
# And it lands at the boundary, like every other decision. Seeding a
|
|
618
|
+
# tier mid-window published a new envelope while the subject was still
|
|
619
|
+
# sending receipts against the old one; every one of those was refused
|
|
620
|
+
# as naming a grant that did not govern that window, the window closed
|
|
621
|
+
# on a claim one receipt short of what was measured, and the divergence
|
|
622
|
+
# priced an honest peer S2. Seen on the mesh the first time a seed was
|
|
623
|
+
# re-applied after the ladder had overridden it.
|
|
624
|
+
decided = accounting.epoch(now, held.get('window') or 3600)
|
|
625
|
+
out = self.peering.decide(peer_pk, payload, 'operator set the tier to %s' % tier,
|
|
626
|
+
None, decided)
|
|
627
|
+
_line(' granted', _short(peer_pk), 'tier', tier,
|
|
628
|
+
'- in force from window', out['effective_from'])
|
|
629
|
+
return payload
|
|
630
|
+
|
|
631
|
+
# ---- the node's own ceiling ----------------------------------------------------
|
|
632
|
+
def _amend(self, peer_pk: str, payload: Mapping[str, Any], reason: str, cause: str, now: int) -> tuple[NostrEvent, GrantPayload]:
|
|
633
|
+
"""Write and publish a grant, and tell the subject what changed it.
|
|
634
|
+
|
|
635
|
+
The one place a grant leaves this node outside a window evaluation.
|
|
636
|
+
`cause` is what separates a remedy from a shed from a person, and a
|
|
637
|
+
subject that could not tell them apart would price its issuer for
|
|
638
|
+
owning a small machine.
|
|
639
|
+
"""
|
|
640
|
+
# The one path that still lands inside the running window, and it is a
|
|
641
|
+
# deliberate exception rather than the leftover the other two were. A
|
|
642
|
+
# shed is the node saying it is over its own ceiling now; deferring the
|
|
643
|
+
# lower limits to the next boundary would leave it carrying load it has
|
|
644
|
+
# already decided it cannot carry, for up to an hour. The cost is the
|
|
645
|
+
# one the other paths were changed to avoid - the subject's receipts for
|
|
646
|
+
# the rest of this window name the grant it still holds, and the window
|
|
647
|
+
# closes divergent - so a shed is worth an S2 against a peer that did
|
|
648
|
+
# nothing wrong only because the alternative is the node falling over.
|
|
649
|
+
# If that trade ever stops being worth it, this is the line to change.
|
|
650
|
+
out = self.peering.issue(peer_pk, payload, cause, reason,
|
|
651
|
+
effective_epoch=accounting.epoch(now, payload.get('window') or 3600),
|
|
652
|
+
now=now)
|
|
653
|
+
return out['envelope'], out['payload']
|
|
654
|
+
|
|
655
|
+
# ---- what the compartments do, reached from the node ---------------------------
|
|
656
|
+
# Each of these is the compartment's, and each is kept here because a caller
|
|
657
|
+
# outside the node - a test, the control socket - asks the node for it. They
|
|
658
|
+
# forward and do nothing else; the state and the refusals live in the
|
|
659
|
+
# compartment, which is the point.
|
|
660
|
+
def on_heartbeat(self, hb: NostrEvent) -> list[tuple[Any, ...]]:
|
|
661
|
+
return self.presence.on_heartbeat(hb)
|
|
662
|
+
|
|
663
|
+
def publish_discovery(self, now: int | None = None) -> list[tuple[str, ...]]:
|
|
664
|
+
return self.discovery.publish_discovery(now)
|
|
665
|
+
|
|
666
|
+
def publish_coverage(self, now: int | None = None) -> list[tuple[str, ...]]:
|
|
667
|
+
return self.discovery.publish_coverage(now)
|
|
668
|
+
|
|
669
|
+
def forward_clock(self, key: str, now: int) -> int:
|
|
670
|
+
return self.discovery.forward_clock(key, now)
|