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
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
"""The Registry: the compartments a node has, and the uniform ways to reach them.
|
|
2
|
+
|
|
3
|
+
Split out of node/compartment.py when it became a package.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import collections
|
|
8
|
+
import json
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Callable, Iterator, Mapping, Optional, Sequence, TypeVar
|
|
12
|
+
|
|
13
|
+
from . import lifecycle as lifecycle_mod
|
|
14
|
+
from . import stores as stores_mod
|
|
15
|
+
from ..interfaces import CommandSpec
|
|
16
|
+
from ..metrics import COUNTER, Metric
|
|
17
|
+
from .base import Compartment, CompartmentProtocol
|
|
18
|
+
from .problem import Note, NoteList, Trouble, TroubleList
|
|
19
|
+
from .refusals import Refusal, why_detail
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
T = TypeVar('T', bound=CompartmentProtocol)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Registry:
|
|
26
|
+
"""The compartments a node has, and the uniform ways to reach all of them.
|
|
27
|
+
|
|
28
|
+
It also owns where each one is in its lifecycle (node/lifecycle): every
|
|
29
|
+
verb below moves a compartment's Tracker, a tick that raises degrades it,
|
|
30
|
+
enough of them trip its breaker, and a quarantined compartment is left
|
|
31
|
+
alone until its backoff passes. `say`, when given, is the node log's
|
|
32
|
+
`say`, and every transition goes out on a declared lifecycle channel.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, say: Optional[Callable[..., Any]] = None,
|
|
36
|
+
clock: Optional[Callable[[], int]] = None) -> None:
|
|
37
|
+
self._by_name: collections.OrderedDict[str, CompartmentProtocol] = collections.OrderedDict()
|
|
38
|
+
self._life: dict[str, lifecycle_mod.Tracker] = {}
|
|
39
|
+
self.say: Optional[Callable[..., Any]] = say
|
|
40
|
+
self.clock: Callable[[], int] = clock or (lambda: int(time.time()))
|
|
41
|
+
# The tick and an operator's `resume` arrive on different threads.
|
|
42
|
+
self._life_lock: threading.Lock = threading.Lock()
|
|
43
|
+
# And a `restart` must never stop a compartment in the middle of its
|
|
44
|
+
# tick: held around each compartment's tick and around each restart.
|
|
45
|
+
# Re-entrant because a restart from inside a tick (a quarantine trial)
|
|
46
|
+
# already holds it.
|
|
47
|
+
self._busy: threading.RLock = threading.RLock()
|
|
48
|
+
# name -> error, for each compartment whose last report() raised.
|
|
49
|
+
self._report_failed: dict[str, str] = {}
|
|
50
|
+
|
|
51
|
+
def add(self, sub: T) -> T:
|
|
52
|
+
if not sub.name:
|
|
53
|
+
raise ValueError('a compartment must have a name')
|
|
54
|
+
if sub.name in self._by_name:
|
|
55
|
+
raise ValueError('two compartments called %r' % sub.name)
|
|
56
|
+
self._by_name[sub.name] = sub
|
|
57
|
+
decl = getattr(sub, 'LIFECYCLE', None)
|
|
58
|
+
if not isinstance(decl, lifecycle_mod.Lifecycle):
|
|
59
|
+
decl = lifecycle_mod.DEFAULT
|
|
60
|
+
problems = decl.problems()
|
|
61
|
+
if decl.snapshot and not (callable(getattr(sub, 'snapshot', None)) and callable(getattr(sub, 'restore', None))):
|
|
62
|
+
# The declaration is the promise; the two methods are the code that
|
|
63
|
+
# keeps it. One without the other would read as continuity the
|
|
64
|
+
# node does not have.
|
|
65
|
+
problems.append('Lifecycle(snapshot=%d) needs snapshot() and restore(state)' % decl.snapshot)
|
|
66
|
+
# The base bound each declared store's container as `self.<key>`. One
|
|
67
|
+
# that is not there any more was replaced in __init__ - by a plain
|
|
68
|
+
# list or dict, which is the unbounded collection the declaration
|
|
69
|
+
# promised was not there.
|
|
70
|
+
holdings = getattr(sub, 'holdings', None)
|
|
71
|
+
declared_stores: tuple[object, ...] = tuple(getattr(sub, 'STORES', None) or ())
|
|
72
|
+
if holdings is None and declared_stores:
|
|
73
|
+
# Declared but never built: an __init__ that did not call
|
|
74
|
+
# Compartment.__init__. The compartment would run without one of
|
|
75
|
+
# its containers and fail at its first write - or at shutdown,
|
|
76
|
+
# when snapshot() asks for holdings that are not there.
|
|
77
|
+
problems.append('it declares STORES but has no holdings - its __init__ never called '
|
|
78
|
+
'Compartment.__init__, which builds them')
|
|
79
|
+
for key, container in (holdings.items() if holdings is not None else ()):
|
|
80
|
+
if getattr(sub, key, None) is not container:
|
|
81
|
+
problems.append('store %s was replaced after the base built it (self.%s = ...); '
|
|
82
|
+
'write into the container instead' % (key, key))
|
|
83
|
+
if problems:
|
|
84
|
+
del self._by_name[sub.name]
|
|
85
|
+
raise ValueError('%s declares what it cannot keep: %s' % (sub.name, '; '.join(problems)))
|
|
86
|
+
self._life[sub.name] = lifecycle_mod.Tracker(sub.name, decl, at=self.clock())
|
|
87
|
+
return sub
|
|
88
|
+
|
|
89
|
+
# ---- lifecycle ----------------------------------------------------------
|
|
90
|
+
def _moved(self, t: Optional[lifecycle_mod.Transition]) -> None:
|
|
91
|
+
"""Say a transition, if there was one and there is a log to say it to."""
|
|
92
|
+
if t is None or self.say is None:
|
|
93
|
+
return
|
|
94
|
+
try:
|
|
95
|
+
self.say(lifecycle_mod.channel_for(t), t)
|
|
96
|
+
except Exception:
|
|
97
|
+
pass # a log must never fail the lifecycle it records
|
|
98
|
+
|
|
99
|
+
def _move(self, name: Optional[str], to: str, at: Optional[int] = None, why: str = '') -> None:
|
|
100
|
+
tr = self._life.get(name or '')
|
|
101
|
+
if tr is None:
|
|
102
|
+
return
|
|
103
|
+
with self._life_lock:
|
|
104
|
+
t = tr.move(to, self.clock() if at is None else at, why)
|
|
105
|
+
self._moved(t)
|
|
106
|
+
|
|
107
|
+
def state_of(self, name: str) -> Optional[str]:
|
|
108
|
+
tr = self._life.get(name)
|
|
109
|
+
return tr.state if tr is not None else None
|
|
110
|
+
|
|
111
|
+
def lifecycle(self) -> dict[str, lifecycle_mod.LifecycleRecord]:
|
|
112
|
+
"""Where every compartment is, for health and `meshnode lifecycle`."""
|
|
113
|
+
return {n: tr.describe() for n, tr in self._life.items()}
|
|
114
|
+
|
|
115
|
+
def resume(self, name: str, why: str = 'resumed by the operator') -> str:
|
|
116
|
+
"""End a compartment's quarantine now. Returns the state it is left in.
|
|
117
|
+
|
|
118
|
+
Raises KeyError for a compartment this node does not have. Resuming
|
|
119
|
+
one that is not quarantined is not an error - it is already ticking -
|
|
120
|
+
and the state it is in is returned unchanged.
|
|
121
|
+
"""
|
|
122
|
+
tr = self._life.get(name)
|
|
123
|
+
if tr is None:
|
|
124
|
+
raise KeyError('no compartment called %r; compartments: %s' % (name, ', '.join(self._life)))
|
|
125
|
+
s = self._by_name.get(name)
|
|
126
|
+
if s is not None and tr.state == lifecycle_mod.QUARANTINED and tr.started is False:
|
|
127
|
+
# Quarantined because its start() raised: it never began, so
|
|
128
|
+
# ending the quarantine means starting it, not ticking it.
|
|
129
|
+
with self._busy:
|
|
130
|
+
self._restart(s, tr, 'resumed: %s' % why)
|
|
131
|
+
return tr.state
|
|
132
|
+
with self._life_lock:
|
|
133
|
+
t = tr.resume(self.clock(), why)
|
|
134
|
+
self._moved(t)
|
|
135
|
+
return tr.state
|
|
136
|
+
|
|
137
|
+
def quarantined(self) -> list[str]:
|
|
138
|
+
return [n for n, tr in self._life.items() if tr.state == lifecycle_mod.QUARANTINED]
|
|
139
|
+
|
|
140
|
+
def failed(self) -> list[str]:
|
|
141
|
+
return [n for n, tr in self._life.items() if tr.state == lifecycle_mod.FAILED]
|
|
142
|
+
|
|
143
|
+
def restartable(self, name: str) -> bool:
|
|
144
|
+
tr = self._life.get(name)
|
|
145
|
+
return tr is not None and tr.lifecycle.restart
|
|
146
|
+
|
|
147
|
+
def restart(self, name: str, why: str = 'by the operator') -> str:
|
|
148
|
+
"""Stop and start one compartment in place. Returns the state it is left in.
|
|
149
|
+
|
|
150
|
+
Only for a compartment that declares `Lifecycle(restart=True)`; any
|
|
151
|
+
other is refused with ValueError, because a stop() and start() nobody
|
|
152
|
+
designed to run twice could leave threads or sockets behind and call
|
|
153
|
+
it a recovery. KeyError for a name this node does not have.
|
|
154
|
+
|
|
155
|
+
The same instance is stopped and started: every other compartment
|
|
156
|
+
that reached it in `wire()` still holds it, and a fresh instance would
|
|
157
|
+
leave them holding the old one. A start() that raises quarantines it
|
|
158
|
+
with backoff; the trial that follows restarts it again.
|
|
159
|
+
"""
|
|
160
|
+
tr = self._life.get(name)
|
|
161
|
+
s = self._by_name.get(name)
|
|
162
|
+
if tr is None or s is None:
|
|
163
|
+
raise KeyError('no compartment called %r; compartments: %s' % (name, ', '.join(self._life)))
|
|
164
|
+
if not tr.lifecycle.restart:
|
|
165
|
+
raise ValueError('%s does not declare Lifecycle(restart=True): its stop() and start() are not '
|
|
166
|
+
'known to be safe to run twice on one instance, so only a node restart '
|
|
167
|
+
'restarts it' % name)
|
|
168
|
+
if tr.state in (lifecycle_mod.DRAINING, lifecycle_mod.RETIRED):
|
|
169
|
+
raise ValueError('%s is %s; the node is stopping' % (name, tr.state))
|
|
170
|
+
with self._busy:
|
|
171
|
+
self._restart(s, tr, why)
|
|
172
|
+
return tr.state
|
|
173
|
+
|
|
174
|
+
def _restart(self, s: CompartmentProtocol, tr: lifecycle_mod.Tracker, why: str,
|
|
175
|
+
trial: bool = False, now: Optional[int] = None) -> bool:
|
|
176
|
+
"""stop() then start(). True when start() returned. Holds `_busy`.
|
|
177
|
+
|
|
178
|
+
A trial restart (from quarantine) moves nothing on success: the trial
|
|
179
|
+
tick that follows decides whether it recovered, and says so.
|
|
180
|
+
"""
|
|
181
|
+
now = self.clock() if now is None else now
|
|
182
|
+
if tr.started and not trial:
|
|
183
|
+
self._move(s.name, lifecycle_mod.DRAINING, now, 'restart: %s' % why)
|
|
184
|
+
if tr.started:
|
|
185
|
+
try:
|
|
186
|
+
s.stop()
|
|
187
|
+
except Exception as e:
|
|
188
|
+
# Said and gone past: a stop() that raised has still stopped
|
|
189
|
+
# as far as it is going to, and start() is the recovery.
|
|
190
|
+
s.refusals.refuse('restart', 'stop raised', detail=why_detail(e))
|
|
191
|
+
finally:
|
|
192
|
+
if hasattr(s, 'stop_workers'):
|
|
193
|
+
try:
|
|
194
|
+
s.stop_workers()
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
tr.started = False
|
|
198
|
+
try:
|
|
199
|
+
if hasattr(s, 'start_workers'):
|
|
200
|
+
s.start_workers()
|
|
201
|
+
s.start()
|
|
202
|
+
except Exception as e:
|
|
203
|
+
s.refusals.refuse('restart', 'start raised', detail=why_detail(e))
|
|
204
|
+
with self._life_lock:
|
|
205
|
+
t = tr.start_failed(now, '%s: %s' % (type(e).__name__, e))
|
|
206
|
+
self._moved(t)
|
|
207
|
+
return False
|
|
208
|
+
if trial:
|
|
209
|
+
tr.started = True
|
|
210
|
+
tr.restarts += 1
|
|
211
|
+
return True
|
|
212
|
+
with self._life_lock:
|
|
213
|
+
t = tr.restarted(now, why)
|
|
214
|
+
self._moved(t)
|
|
215
|
+
return True
|
|
216
|
+
|
|
217
|
+
# ---- carrying state across a process restart ----------------------------
|
|
218
|
+
def snapshot(self) -> dict[str, lifecycle_mod.SnapshotEntry]:
|
|
219
|
+
"""What every started compartment that declares `snapshot` hands on.
|
|
220
|
+
|
|
221
|
+
One whose snapshot() raises, or returns something that is not a
|
|
222
|
+
JSON object, is a refusal against itself and is left out: the next
|
|
223
|
+
process starts it fresh, and every other compartment's state still
|
|
224
|
+
goes across.
|
|
225
|
+
"""
|
|
226
|
+
out: dict[str, lifecycle_mod.SnapshotEntry] = {}
|
|
227
|
+
for s in self:
|
|
228
|
+
tr = self._life.get(s.name or '')
|
|
229
|
+
if tr is None or not tr.lifecycle.snapshot or tr.started is not True:
|
|
230
|
+
continue
|
|
231
|
+
try:
|
|
232
|
+
state = getattr(s, 'snapshot')()
|
|
233
|
+
if not isinstance(state, Mapping):
|
|
234
|
+
raise TypeError('snapshot() returned %s, not a mapping' % type(state).__name__)
|
|
235
|
+
state = json.loads(json.dumps(dict(state)))
|
|
236
|
+
except Exception as e:
|
|
237
|
+
s.refusals.refuse('snapshot', 'raised', detail=why_detail(e))
|
|
238
|
+
continue
|
|
239
|
+
out[s.name or ''] = {'version': tr.lifecycle.snapshot, 'state': state}
|
|
240
|
+
return out
|
|
241
|
+
|
|
242
|
+
def restore(self, saved: Mapping[str, lifecycle_mod.SnapshotEntry], at: int = 0) -> dict[str, str]:
|
|
243
|
+
"""Hand each compartment the state the last process left for it.
|
|
244
|
+
Before start(), so a compartment starts from where it was.
|
|
245
|
+
|
|
246
|
+
Returns {name: what happened} for every compartment that declares
|
|
247
|
+
`snapshot`, and records the same in its lifecycle. Refused - and so
|
|
248
|
+
started fresh - when there is no entry, the entry's version is not
|
|
249
|
+
the one the compartment reads now, or restore() raises. Never raises
|
|
250
|
+
itself: continuity is worth having, not worth refusing to start for.
|
|
251
|
+
"""
|
|
252
|
+
out: dict[str, str] = {}
|
|
253
|
+
for s in self:
|
|
254
|
+
tr = self._life.get(s.name or '')
|
|
255
|
+
if tr is None or not tr.lifecycle.snapshot:
|
|
256
|
+
continue
|
|
257
|
+
e = saved.get(s.name or '')
|
|
258
|
+
if e is None:
|
|
259
|
+
tr.restored = ''
|
|
260
|
+
continue
|
|
261
|
+
if e['version'] != tr.lifecycle.snapshot:
|
|
262
|
+
tr.restored = ('refused: the snapshot is v%d and this %s reads v%d; started fresh'
|
|
263
|
+
% (e['version'], s.name, tr.lifecycle.snapshot))
|
|
264
|
+
else:
|
|
265
|
+
try:
|
|
266
|
+
getattr(s, 'restore')(e['state'])
|
|
267
|
+
tr.restored = 'restored (v%d, written at %d)' % (e['version'], at)
|
|
268
|
+
except Exception as exc:
|
|
269
|
+
s.refusals.refuse('restore', 'raised', detail=why_detail(exc))
|
|
270
|
+
tr.restored = 'refused: restore raised %s: %s; started fresh' % (type(exc).__name__, exc)
|
|
271
|
+
out[s.name or ''] = tr.restored
|
|
272
|
+
return out
|
|
273
|
+
|
|
274
|
+
def lifecycle_metrics(self) -> tuple[Any, ...]:
|
|
275
|
+
"""The lifecycle's counters, and gauges sampled from these trackers."""
|
|
276
|
+
return lifecycle_mod.METRICS + (
|
|
277
|
+
lifecycle_mod.gauge(lambda: len(self.quarantined()), 'compartments_quarantined',
|
|
278
|
+
'Compartments whose breaker is open now.'),
|
|
279
|
+
lifecycle_mod.gauge(
|
|
280
|
+
lambda: sum(1 for tr in self._life.values() if tr.state == lifecycle_mod.DEGRADED),
|
|
281
|
+
'compartments_degraded', 'Compartments whose last tick raised.'),
|
|
282
|
+
lifecycle_mod.gauge(lambda: len(self.failed()), 'compartments_failed',
|
|
283
|
+
'Optional compartments whose start() raised and that cannot restart.'),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# ---- what the compartments keep -------------------------------------------
|
|
287
|
+
def _containers(self) -> list[tuple[str, str, stores_mod.Container]]:
|
|
288
|
+
out: list[tuple[str, str, stores_mod.Container]] = []
|
|
289
|
+
for s in self:
|
|
290
|
+
holdings = getattr(s, 'holdings', None)
|
|
291
|
+
for key, c in (holdings.items() if holdings is not None else ()):
|
|
292
|
+
out.append((s.name or '', key, c))
|
|
293
|
+
return out
|
|
294
|
+
|
|
295
|
+
def store_metrics(self) -> tuple[Any, ...]:
|
|
296
|
+
"""Every declared store, metered without its compartment writing a
|
|
297
|
+
metric: size, bound and high-water as gauges, and the writes, the
|
|
298
|
+
evictions and the refusals as counters. One series per (compartment,
|
|
299
|
+
store), sampled from the containers when /metrics is read."""
|
|
300
|
+
def per(read: Callable[[stores_mod.Container], int]) -> Callable[[], dict[tuple[str, str], int]]:
|
|
301
|
+
return lambda: {(n, k): read(c) for n, k, c in self._containers()}
|
|
302
|
+
labels = ('compartment', 'store')
|
|
303
|
+
return (
|
|
304
|
+
Metric(name='store_entries', description='Entries a compartment store holds now.',
|
|
305
|
+
labels=labels, sample=per(len), tags=('stores',)),
|
|
306
|
+
Metric(name='store_bound', description='The most entries a store may hold, as declared.',
|
|
307
|
+
labels=labels, sample=per(lambda c: c.store.limit), tags=('stores',)),
|
|
308
|
+
Metric(name='store_high_water', description='The most entries a store has held since start.',
|
|
309
|
+
labels=labels, sample=per(lambda c: c.high_water), tags=('stores',)),
|
|
310
|
+
Metric(name='store_writes', description='Entries written to a store (and counters incremented).',
|
|
311
|
+
kind=COUNTER, labels=labels, sample=per(lambda c: c.inserts), tags=('stores',)),
|
|
312
|
+
Metric(name='store_evictions', description='Entries a store dropped at its bound (OLDEST or LRU).',
|
|
313
|
+
kind=COUNTER, labels=labels, sample=per(lambda c: c.evictions), tags=('stores',)),
|
|
314
|
+
Metric(name='store_refused', description='Writes a full REFUSE store turned away.',
|
|
315
|
+
kind=COUNTER, labels=labels, sample=per(lambda c: c.refused), tags=('stores',)),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def store_trouble(self) -> list[str]:
|
|
319
|
+
"""A REFUSE store is trouble once it is nearly full, and while it has
|
|
320
|
+
refused anything: the state it holds is the kind that must not be
|
|
321
|
+
lost, so the write that failed was a decision the compartment had to
|
|
322
|
+
make without it. A ring at its bound is a log doing its job."""
|
|
323
|
+
out: list[str] = []
|
|
324
|
+
for n, k, c in self._containers():
|
|
325
|
+
st = c.store
|
|
326
|
+
if st.kind == stores_mod.COUNTERS or st.evict != stores_mod.REFUSE:
|
|
327
|
+
continue
|
|
328
|
+
if c.refused or c.nearly_full():
|
|
329
|
+
out.append('%s: store %s holds %d of %d %s%s' % (
|
|
330
|
+
n, k, len(c), st.limit, st.keyed_by or 'entries',
|
|
331
|
+
'; %d write(s) refused' % c.refused if c.refused else ' and refuses new ones when full'))
|
|
332
|
+
return out
|
|
333
|
+
|
|
334
|
+
def store_notes(self) -> list[str]:
|
|
335
|
+
"""A TABLE or SET that has evicted is dropping state it was keeping
|
|
336
|
+
on purpose. Not a fault - the bound is the decision - but worth
|
|
337
|
+
reading when something it tracked is unexpectedly gone."""
|
|
338
|
+
out: list[str] = []
|
|
339
|
+
for n, k, c in self._containers():
|
|
340
|
+
if c.store.kind in (stores_mod.TABLE, stores_mod.SET) and c.evictions:
|
|
341
|
+
out.append('%s: store %s has dropped %d %s at its bound of %d (%s first)' % (
|
|
342
|
+
n, k, c.evictions, c.store.keyed_by or 'entries', c.store.limit, c.store.evict))
|
|
343
|
+
return out
|
|
344
|
+
|
|
345
|
+
def __iter__(self) -> Iterator[CompartmentProtocol]:
|
|
346
|
+
return iter(self._by_name.values())
|
|
347
|
+
|
|
348
|
+
def __contains__(self, name: object) -> bool:
|
|
349
|
+
return name in self._by_name
|
|
350
|
+
|
|
351
|
+
def get(self, name: str) -> Optional[CompartmentProtocol]:
|
|
352
|
+
return self._by_name.get(name)
|
|
353
|
+
|
|
354
|
+
def names(self) -> list[str]:
|
|
355
|
+
return list(self._by_name)
|
|
356
|
+
|
|
357
|
+
# ---- assembly -----------------------------------------------------------
|
|
358
|
+
def assemble(self, ctx: Any, manifest: Sequence[type[Any]],
|
|
359
|
+
enabled: Optional[Callable[[str], bool]] = None) -> list[str]:
|
|
360
|
+
"""Build every compartment in the manifest from one context, in the
|
|
361
|
+
manifest's order, and register each as it is built.
|
|
362
|
+
|
|
363
|
+
The manifest is the node's declared tick order. It is *validated*
|
|
364
|
+
against each class's `requires` rather than re-sorted from it, because
|
|
365
|
+
tick order is a reviewed decision with reasons the dependency graph
|
|
366
|
+
does not carry (the relay journal ticks first so a dropped
|
|
367
|
+
subscription is said before anything that depends on it; the
|
|
368
|
+
operator's decisions tick before the mechanisms they outrank). A
|
|
369
|
+
manifest that lists a compartment before one it requires, or omits
|
|
370
|
+
one it requires, is a programming error and is refused at startup
|
|
371
|
+
with the two names in the message - not discovered three ticks in.
|
|
372
|
+
|
|
373
|
+
Optional compartments are built only when `enabled(name)` says so;
|
|
374
|
+
the default asks whether `enabled_by` is a present config key. A
|
|
375
|
+
compartment whose `api` major differs from this registry's, or whose
|
|
376
|
+
`protocol` is set and differs from the digest in `ctx`, is refused
|
|
377
|
+
the same way: the node would rather not start than start wrong.
|
|
378
|
+
|
|
379
|
+
Returns the names built, in order. The context's `node` (when it is
|
|
380
|
+
a NodeApp) gets each compartment set on it under its name, which is
|
|
381
|
+
what the rest of the node and the tests reach for today.
|
|
382
|
+
"""
|
|
383
|
+
is_on: Callable[[str], bool] = enabled or (lambda key: bool(ctx.cfg.get(key)))
|
|
384
|
+
digest = getattr(ctx, 'digest', None)
|
|
385
|
+
setattr(ctx, '_registry', self)
|
|
386
|
+
built: list[str] = []
|
|
387
|
+
seen: list[str] = [] # every manifest name passed, built or not
|
|
388
|
+
for cls in manifest:
|
|
389
|
+
name = getattr(cls, 'name', None)
|
|
390
|
+
if not name:
|
|
391
|
+
raise ValueError('%s declares no name' % cls.__name__)
|
|
392
|
+
for dep in getattr(cls, 'uses', ()):
|
|
393
|
+
if dep not in seen:
|
|
394
|
+
raise ValueError('%s uses %s, which the manifest does not list before it (manifest order: %s)'
|
|
395
|
+
% (name, dep, ' '.join(seen) or '(nothing yet)'))
|
|
396
|
+
seen.append(name)
|
|
397
|
+
api = getattr(cls, 'api', Compartment.API)
|
|
398
|
+
if api.split('/')[0] != Compartment.API.split('/')[0] or \
|
|
399
|
+
api.split('/')[1].split('.')[0] != Compartment.API.split('/')[1].split('.')[0]:
|
|
400
|
+
raise ValueError('%s is written against %s; this node speaks %s' % (name, api, Compartment.API))
|
|
401
|
+
proto = getattr(cls, 'protocol', '')
|
|
402
|
+
if proto and digest and proto != digest:
|
|
403
|
+
raise ValueError('%s was built against schema %s; this node speaks %s' % (name, proto[:12], digest[:12]))
|
|
404
|
+
if getattr(cls, 'optional', False):
|
|
405
|
+
key = getattr(cls, 'enabled_by', None) or name
|
|
406
|
+
if not is_on(key):
|
|
407
|
+
continue
|
|
408
|
+
for dep in getattr(cls, 'requires', ()):
|
|
409
|
+
if dep not in self._by_name:
|
|
410
|
+
raise ValueError('%s requires %s, which is not built before it (manifest order: %s)'
|
|
411
|
+
% (name, dep, ' '.join(built) or '(nothing yet)'))
|
|
412
|
+
sub = cls.from_context(ctx)
|
|
413
|
+
self.add(sub)
|
|
414
|
+
refs = self.store_ref_problems(sub)
|
|
415
|
+
if refs:
|
|
416
|
+
del self._by_name[name]
|
|
417
|
+
self._life.pop(name, None)
|
|
418
|
+
raise ValueError('%s reads what is not there: %s' % (name, '; '.join(refs)))
|
|
419
|
+
built.append(name)
|
|
420
|
+
if ctx.node is not None:
|
|
421
|
+
setattr(ctx.node, getattr(cls, 'attr', None) or name, sub)
|
|
422
|
+
return built
|
|
423
|
+
|
|
424
|
+
def store_ref_problems(self, sub: Any) -> list[str]:
|
|
425
|
+
"""Every `other.key` a command of `sub` reads, where `other` is built
|
|
426
|
+
and declares no store `key`.
|
|
427
|
+
|
|
428
|
+
The class lint (Compartment._command_data_problems) holds the ref to
|
|
429
|
+
`requires`/`uses`; only here do both sides exist, so only here can
|
|
430
|
+
the store itself be checked. The manifest puts every required and
|
|
431
|
+
used compartment before `sub`, so all that will be built already is.
|
|
432
|
+
One that is not built is an absent `uses` - optional by definition,
|
|
433
|
+
and the command must cope without it - and is skipped.
|
|
434
|
+
"""
|
|
435
|
+
out: list[str] = []
|
|
436
|
+
refs = getattr(type(sub), 'store_refs', None)
|
|
437
|
+
for cmd, other, key in (refs() if callable(refs) else ()):
|
|
438
|
+
target = self._by_name.get(other)
|
|
439
|
+
if target is None:
|
|
440
|
+
continue
|
|
441
|
+
declared = {getattr(s, 'key', None) for s in getattr(target, 'STORES', ())}
|
|
442
|
+
if key not in declared:
|
|
443
|
+
out.append('command %s reads %s.%s, and %s declares no store %r (it declares: %s)'
|
|
444
|
+
% (cmd, other, key, other, key, ', '.join(sorted(str(k) for k in declared)) or 'none'))
|
|
445
|
+
return out
|
|
446
|
+
|
|
447
|
+
# ---- the uniform verbs --------------------------------------------------
|
|
448
|
+
def wire(self, ctx: Any) -> None:
|
|
449
|
+
"""Every compartment's back-edges, in manifest order, once all are built."""
|
|
450
|
+
for s in self:
|
|
451
|
+
for need in getattr(type(s), 'NEEDS', ()) or ():
|
|
452
|
+
if getattr(need, 'late', False):
|
|
453
|
+
val = need.resolve_late(ctx, s, registry=self)
|
|
454
|
+
setattr(s, need.key, val)
|
|
455
|
+
s.wire(self, ctx)
|
|
456
|
+
self._move(s.name, lifecycle_mod.WIRED)
|
|
457
|
+
|
|
458
|
+
def route(self, router: Any) -> list[tuple[str, str]]:
|
|
459
|
+
"""Register every compartment's declared protocols on the router.
|
|
460
|
+
|
|
461
|
+
Returns [(compartment, piuri)] as registered. A compartment that
|
|
462
|
+
declares a protocol and has no `handle` is a programming error and
|
|
463
|
+
is refused here, at startup, with its name.
|
|
464
|
+
"""
|
|
465
|
+
out: list[tuple[str, str]] = []
|
|
466
|
+
for s in self:
|
|
467
|
+
piuris = tuple(getattr(s, 'piuris', ()) or ())
|
|
468
|
+
exact = tuple(getattr(s, 'exact_types', ()) or ())
|
|
469
|
+
if not piuris and not exact:
|
|
470
|
+
continue
|
|
471
|
+
handle = getattr(s, 'handle', None)
|
|
472
|
+
is_base = getattr(handle, '__func__', handle) is Compartment.handle
|
|
473
|
+
if not callable(handle) or (is_base and not getattr(s, 'HANDLERS', ())):
|
|
474
|
+
raise ValueError('%s declares protocols %s but has no handle(inbound)'
|
|
475
|
+
% (s.name, ', '.join(piuris + exact)))
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
for p in piuris:
|
|
479
|
+
router.register(p, handle)
|
|
480
|
+
out.append((s.name or '', p))
|
|
481
|
+
for t in exact:
|
|
482
|
+
router.register_exact(t, handle)
|
|
483
|
+
out.append((s.name or '', t))
|
|
484
|
+
return out
|
|
485
|
+
|
|
486
|
+
def start(self) -> None:
|
|
487
|
+
"""Start every compartment, in manifest order, each isolated.
|
|
488
|
+
|
|
489
|
+
One that raises is a refusal against itself, and what happens next
|
|
490
|
+
depends on whether the node can run without it (audit L1):
|
|
491
|
+
|
|
492
|
+
required (not `optional`) every compartment already started is
|
|
493
|
+
stopped, newest first, and the raise goes
|
|
494
|
+
on and stops the node. A node that cannot
|
|
495
|
+
begin is a configuration the operator
|
|
496
|
+
must hear about at boot - and it must not
|
|
497
|
+
have half-begun, with a farewell sent by
|
|
498
|
+
something that never served.
|
|
499
|
+
optional the rest still start. It is QUARANTINED
|
|
500
|
+
when it declares restart (its trial
|
|
501
|
+
restarts it), FAILED when it does not.
|
|
502
|
+
"""
|
|
503
|
+
begun: list[CompartmentProtocol] = []
|
|
504
|
+
for s in self:
|
|
505
|
+
tr = self._life.get(s.name or '')
|
|
506
|
+
try:
|
|
507
|
+
if hasattr(s, 'start_workers'):
|
|
508
|
+
s.start_workers()
|
|
509
|
+
s.start()
|
|
510
|
+
except Exception as e:
|
|
511
|
+
s.refusals.refuse('start', 'raised', detail=why_detail(e))
|
|
512
|
+
err = '%s: %s' % (type(e).__name__, e)
|
|
513
|
+
if not getattr(s, 'optional', False):
|
|
514
|
+
for o in reversed(begun):
|
|
515
|
+
self._move(o.name, lifecycle_mod.DRAINING, why='%s could not start' % s.name)
|
|
516
|
+
try:
|
|
517
|
+
o.stop()
|
|
518
|
+
except Exception as e2:
|
|
519
|
+
o.refusals.refuse('stop', 'raised', detail=why_detail(e2))
|
|
520
|
+
self._move(o.name, lifecycle_mod.RETIRED)
|
|
521
|
+
if tr is not None:
|
|
522
|
+
tr.started = False
|
|
523
|
+
self._move(s.name, lifecycle_mod.FAILED, why='start raised: %s' % err)
|
|
524
|
+
raise
|
|
525
|
+
if tr is not None:
|
|
526
|
+
with self._life_lock:
|
|
527
|
+
t = tr.start_failed(self.clock(), err)
|
|
528
|
+
self._moved(t)
|
|
529
|
+
continue
|
|
530
|
+
if tr is not None:
|
|
531
|
+
tr.started = True
|
|
532
|
+
begun.append(s)
|
|
533
|
+
self._move(s.name, lifecycle_mod.RUNNING)
|
|
534
|
+
|
|
535
|
+
def tick(self, now: int) -> list[tuple[Any, ...]]:
|
|
536
|
+
"""Every compartment's periodic work. One that raises does not stop the
|
|
537
|
+
others, and its failure is a refusal against itself rather than a
|
|
538
|
+
traceback nobody reads.
|
|
539
|
+
|
|
540
|
+
A raise also moves the compartment: to DEGRADED, or to QUARANTINED once
|
|
541
|
+
its breaker trips. A quarantined compartment is skipped until its
|
|
542
|
+
backoff passes, then given one trial tick - restarted first when it
|
|
543
|
+
declares restart, because "turn it off and on again" is the recovery a
|
|
544
|
+
stuck connection or a wedged worker needs. A retired or failed one is
|
|
545
|
+
skipped.
|
|
546
|
+
"""
|
|
547
|
+
did: list[tuple[Any, ...]] = []
|
|
548
|
+
for s in self:
|
|
549
|
+
tr = self._life.get(s.name or '')
|
|
550
|
+
if tr is not None and not tr.due(now):
|
|
551
|
+
continue
|
|
552
|
+
with self._busy:
|
|
553
|
+
if tr is not None and tr.state == lifecycle_mod.QUARANTINED and tr.lifecycle.restart:
|
|
554
|
+
if not self._restart(s, tr, 'trial after quarantine', trial=True, now=now):
|
|
555
|
+
if tr.state == lifecycle_mod.QUARANTINED:
|
|
556
|
+
s.refusals.refuse('tick', 'quarantined', detail={'until': tr.until, 'trips': tr.trips})
|
|
557
|
+
did.append((s.name, 'restart failed', tr.last_error))
|
|
558
|
+
continue
|
|
559
|
+
try:
|
|
560
|
+
did += list(s.run_tick(now) if hasattr(s, 'run_tick') else (s.tick(now) or []))
|
|
561
|
+
except Exception as e:
|
|
562
|
+
s.refusals.refuse('tick', 'raised', detail=why_detail(e))
|
|
563
|
+
did.append((s.name, 'tick failed', '%s: %s' % (type(e).__name__, e)))
|
|
564
|
+
if tr is not None:
|
|
565
|
+
with self._life_lock:
|
|
566
|
+
t = tr.failed(now, '%s: %s' % (type(e).__name__, e))
|
|
567
|
+
self._moved(t)
|
|
568
|
+
if tr.state == lifecycle_mod.QUARANTINED:
|
|
569
|
+
s.refusals.refuse('tick', 'quarantined', detail={'until': tr.until, 'trips': tr.trips})
|
|
570
|
+
continue
|
|
571
|
+
if tr is not None:
|
|
572
|
+
with self._life_lock:
|
|
573
|
+
t = tr.succeeded(now)
|
|
574
|
+
self._moved(t)
|
|
575
|
+
return did
|
|
576
|
+
|
|
577
|
+
def stop(self) -> list[tuple[Optional[str], Any]]:
|
|
578
|
+
"""Stop every compartment, and hand back what each had to say.
|
|
579
|
+
|
|
580
|
+
Returns [(name, whatever stop returned)]. One that raises does not stop
|
|
581
|
+
the others: a node that cannot say goodbye must still stop. One whose
|
|
582
|
+
start() raised is not stopped - it never began - and returns None.
|
|
583
|
+
"""
|
|
584
|
+
out: list[tuple[Optional[str], Any]] = []
|
|
585
|
+
for s in self:
|
|
586
|
+
tr = self._life.get(s.name or '')
|
|
587
|
+
if tr is not None and tr.started is False:
|
|
588
|
+
out.append((s.name, None))
|
|
589
|
+
self._move(s.name, lifecycle_mod.RETIRED, why='never started')
|
|
590
|
+
continue
|
|
591
|
+
self._move(s.name, lifecycle_mod.DRAINING)
|
|
592
|
+
try:
|
|
593
|
+
out.append((s.name, s.stop()))
|
|
594
|
+
except Exception as e:
|
|
595
|
+
s.refusals.refuse('stop', 'raised', detail=why_detail(e))
|
|
596
|
+
out.append((s.name, None))
|
|
597
|
+
finally:
|
|
598
|
+
if hasattr(s, 'stop_workers'):
|
|
599
|
+
try:
|
|
600
|
+
s.stop_workers()
|
|
601
|
+
except Exception:
|
|
602
|
+
pass
|
|
603
|
+
self._move(s.name, lifecycle_mod.RETIRED)
|
|
604
|
+
return out
|
|
605
|
+
|
|
606
|
+
def report(self) -> dict[str, Mapping[str, Any]]:
|
|
607
|
+
"""Every compartment's report, each isolated as trouble() and notes()
|
|
608
|
+
already were.
|
|
609
|
+
|
|
610
|
+
This is what health(), status() and status.json are made of, and the
|
|
611
|
+
tick writes status.json. One compartment whose report() raised used to
|
|
612
|
+
raise here, so the node did its work and then failed the tick, every
|
|
613
|
+
tick, and `meshnode health` failed with it (audit 2026-10-15, B2). A
|
|
614
|
+
report that raises is now that compartment's entry, an `error`, and a
|
|
615
|
+
`report_error` trouble until a later report() succeeds - the others
|
|
616
|
+
still report.
|
|
617
|
+
"""
|
|
618
|
+
out: dict[str, Mapping[str, Any]] = {}
|
|
619
|
+
failed: dict[str, str] = {}
|
|
620
|
+
for s in self:
|
|
621
|
+
try:
|
|
622
|
+
out[s.name or ''] = s.report()
|
|
623
|
+
except Exception as e:
|
|
624
|
+
failed[s.name or ''] = '%s: %s' % (type(e).__name__, e)
|
|
625
|
+
out[s.name or ''] = {'error': failed[s.name or '']}
|
|
626
|
+
self._report_failed = failed
|
|
627
|
+
return out
|
|
628
|
+
|
|
629
|
+
def trouble(self) -> list[str]:
|
|
630
|
+
out = TroubleList()
|
|
631
|
+
for s in self:
|
|
632
|
+
try:
|
|
633
|
+
for t in (s.trouble() or []):
|
|
634
|
+
if isinstance(t, Trouble):
|
|
635
|
+
comp = t.compartment or s.name or ''
|
|
636
|
+
msg = '%s: %s' % (comp, t.message)
|
|
637
|
+
out.append(Trouble(
|
|
638
|
+
msg,
|
|
639
|
+
code=t.code,
|
|
640
|
+
compartment=comp,
|
|
641
|
+
severity=t.severity,
|
|
642
|
+
since=t.since,
|
|
643
|
+
hint=t.hint,
|
|
644
|
+
**t.metadata,
|
|
645
|
+
))
|
|
646
|
+
else:
|
|
647
|
+
out.append(Trouble(
|
|
648
|
+
'%s: %s' % (s.name, t),
|
|
649
|
+
code='fault',
|
|
650
|
+
compartment=s.name or '',
|
|
651
|
+
severity='trouble',
|
|
652
|
+
))
|
|
653
|
+
except Exception as e:
|
|
654
|
+
out.append(Trouble(
|
|
655
|
+
'%s: could not report trouble (%s)' % (s.name, e),
|
|
656
|
+
code='report_error',
|
|
657
|
+
compartment=s.name or '',
|
|
658
|
+
severity='fatal',
|
|
659
|
+
))
|
|
660
|
+
# A quarantine is trouble whatever the compartment itself reports:
|
|
661
|
+
# while its breaker is open it is not ticking, and it may not be in a
|
|
662
|
+
# position to say so.
|
|
663
|
+
for n in self.quarantined():
|
|
664
|
+
tr = self._life[n]
|
|
665
|
+
msg = ('%s: quarantined - not ticking until %s (trip %d; last: %s); '
|
|
666
|
+
'the control command `resume` with name=%s ends it now'
|
|
667
|
+
% (n, time.strftime('%H:%M:%SZ', time.gmtime(tr.until)), tr.trips,
|
|
668
|
+
tr.last_error or 'unknown', n))
|
|
669
|
+
out.append(Trouble(msg, code='quarantined', compartment=n, severity='fault'))
|
|
670
|
+
for n in self.failed():
|
|
671
|
+
tr = self._life[n]
|
|
672
|
+
msg = ('%s: failed to start (%s) and does not declare restart; it is not running '
|
|
673
|
+
'until the node restarts' % (n, tr.last_error or 'unknown'))
|
|
674
|
+
out.append(Trouble(msg, code='failed_start', compartment=n, severity='fatal'))
|
|
675
|
+
# As of the last report(): health() takes the report first, and a
|
|
676
|
+
# second report() here would double the cost of every health read.
|
|
677
|
+
for n, err in self._report_failed.items():
|
|
678
|
+
out.append(Trouble('%s: could not report its state (%s)' % (n, err),
|
|
679
|
+
code='report_error', compartment=n, severity='fatal'))
|
|
680
|
+
for st in self.store_trouble():
|
|
681
|
+
out.append(Trouble(st, code='store_trouble', severity='trouble'))
|
|
682
|
+
return out
|
|
683
|
+
|
|
684
|
+
def trouble_items(self) -> list[Trouble]:
|
|
685
|
+
"""All active troubles as structured Trouble instances."""
|
|
686
|
+
res = self.trouble()
|
|
687
|
+
return [t if isinstance(t, Trouble) else Trouble(str(t)) for t in res]
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def notes(self) -> NoteList:
|
|
691
|
+
out = NoteList()
|
|
692
|
+
for s in self:
|
|
693
|
+
try:
|
|
694
|
+
for t in (s.notes() or []):
|
|
695
|
+
if isinstance(t, Note):
|
|
696
|
+
out.append(Note(
|
|
697
|
+
'%s: %s' % (s.name, t),
|
|
698
|
+
topic=t.topic,
|
|
699
|
+
compartment=s.name or '',
|
|
700
|
+
level=t.level,
|
|
701
|
+
since=t.since,
|
|
702
|
+
**t.metadata,
|
|
703
|
+
))
|
|
704
|
+
else:
|
|
705
|
+
out.append(Note('%s: %s' % (s.name, t), compartment=s.name or ''))
|
|
706
|
+
except Exception as e:
|
|
707
|
+
out.append(Note('%s: could not report notes (%s)' % (s.name, e), compartment=s.name or '', level='notice'))
|
|
708
|
+
for n, tr in self._life.items():
|
|
709
|
+
if tr.state == lifecycle_mod.DEGRADED:
|
|
710
|
+
out.append(Note(
|
|
711
|
+
'%s: degraded - the last tick raised (%s); it recovers after one clean tick'
|
|
712
|
+
% (n, tr.last_error or 'unknown'),
|
|
713
|
+
compartment=n,
|
|
714
|
+
topic='degraded',
|
|
715
|
+
level='notice',
|
|
716
|
+
))
|
|
717
|
+
for sn in self.store_notes():
|
|
718
|
+
out.append(Note(sn, topic='store', level='info'))
|
|
719
|
+
return out
|
|
720
|
+
|
|
721
|
+
def note_items(self) -> list[Note]:
|
|
722
|
+
"""All active notes as structured Note instances."""
|
|
723
|
+
res = self.notes()
|
|
724
|
+
return [n if isinstance(n, Note) else Note(str(n)) for n in res]
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def refusals(self, n: int = 5) -> list[tuple[Optional[str], Refusal]]:
|
|
728
|
+
"""Every no this node said, across every compartment, newest last."""
|
|
729
|
+
out: list[tuple[Optional[str], Refusal]] = []
|
|
730
|
+
for s in self:
|
|
731
|
+
for r in s.refusals.last(n):
|
|
732
|
+
out.append((s.name, r))
|
|
733
|
+
return sorted(out, key=lambda p: p[1].at)
|
|
734
|
+
|
|
735
|
+
def live_keys(self) -> tuple[str, ...]:
|
|
736
|
+
keys: list[str] = []
|
|
737
|
+
for s in self:
|
|
738
|
+
keys += [k for k in s.live if k not in keys]
|
|
739
|
+
return tuple(keys)
|
|
740
|
+
|
|
741
|
+
def commands(self) -> collections.OrderedDict[str, CommandSpec]:
|
|
742
|
+
"""name -> spec, prefixed by subsystem, for the control socket."""
|
|
743
|
+
out: collections.OrderedDict[str, CommandSpec] = collections.OrderedDict()
|
|
744
|
+
for s in self:
|
|
745
|
+
for cmd, spec in (s.commands or {}).items():
|
|
746
|
+
named: CommandSpec = {**spec, 'subsystem': s.name or ''}
|
|
747
|
+
out['%s.%s' % (s.name, cmd)] = named
|
|
748
|
+
return out
|
|
749
|
+
|
|
750
|
+
def declared_commands(self) -> tuple[Any, ...]:
|
|
751
|
+
"""Every Command declared across compartments."""
|
|
752
|
+
out: list[Any] = []
|
|
753
|
+
for s in self:
|
|
754
|
+
out.extend(getattr(s, 'COMMANDS', ()))
|
|
755
|
+
return tuple(out)
|
|
756
|
+
|
|
757
|
+
def dispatch(self, name: str, args: Optional[Mapping[str, Any]]) -> Any:
|
|
758
|
+
# All three refusals below are the caller naming something that does
|
|
759
|
+
# not exist - a typo, not a fault in this node. They were plain
|
|
760
|
+
# KeyErrors, which the control socket's boundary treats as a bug: it
|
|
761
|
+
# prefixes the type name and appends to its 200-entry ring, and
|
|
762
|
+
# health reports the last five of that ring. So five `meshctl` typos
|
|
763
|
+
# put five errors in the node's own health and pushed any real bug
|
|
764
|
+
# out of the window (audit 2026-10-07, I14). ControlError is the
|
|
765
|
+
# declared way to answer the caller and leave no trace. The import is
|
|
766
|
+
# function-local to keep compartment/ off app/ at module scope, which
|
|
767
|
+
# is how the compartments that already raise it do it.
|
|
768
|
+
from ..app import socket as control_mod
|
|
769
|
+
sub, _, cmd = name.partition('.')
|
|
770
|
+
s = self.get(sub)
|
|
771
|
+
if s is None:
|
|
772
|
+
raise control_mod.ControlError("unknown compartment %r in command %r" % (sub, name))
|
|
773
|
+
if not cmd:
|
|
774
|
+
raise control_mod.ControlError("missing command in %r" % (name,))
|
|
775
|
+
if s.commands and cmd not in s.commands:
|
|
776
|
+
raise control_mod.ControlError(
|
|
777
|
+
"unknown command %r on compartment %r; available: %s" % (cmd, sub, list(s.commands)))
|
|
778
|
+
try:
|
|
779
|
+
return s.command(cmd, args or {})
|
|
780
|
+
except KeyError as e:
|
|
781
|
+
# `Compartment.command` raises KeyError(name) for a name it does
|
|
782
|
+
# not declare - the same caller mistake as the three above, one
|
|
783
|
+
# level down, reached when a compartment publishes no `commands`
|
|
784
|
+
# mapping for the check above to screen against. Only that exact
|
|
785
|
+
# shape is converted: a KeyError raised from inside a command's
|
|
786
|
+
# own body is a real bug and must keep reaching the error ring.
|
|
787
|
+
if e.args == (cmd,):
|
|
788
|
+
raise control_mod.ControlError(
|
|
789
|
+
"unknown command %r on compartment %r" % (cmd, sub)) from None
|
|
790
|
+
raise
|