fleetmesh-node 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (338) hide show
  1. fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
  2. fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
  3. fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
  4. fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
  5. fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
  6. fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
  7. node/RELEASE +1 -0
  8. node/__init__.py +35 -0
  9. node/app/__init__.py +49 -0
  10. node/app/commands.py +265 -0
  11. node/app/config.py +131 -0
  12. node/app/control.py +670 -0
  13. node/app/core.py +867 -0
  14. node/app/plugins.py +76 -0
  15. node/app/router.py +83 -0
  16. node/app/socket.py +431 -0
  17. node/app/systemd.py +36 -0
  18. node/app/views.py +204 -0
  19. node/channels/__init__.py +378 -0
  20. node/channels/bus.py +331 -0
  21. node/channels/journal.py +46 -0
  22. node/commands/__init__.py +140 -0
  23. node/commands/adapter.py +210 -0
  24. node/commands/base.py +278 -0
  25. node/commands/cluster_cmds.py +183 -0
  26. node/commands/compute_cmds.py +401 -0
  27. node/commands/core.py +474 -0
  28. node/commands/gossip_cmds.py +106 -0
  29. node/commands/nsite_cmds.py +141 -0
  30. node/commands/peering.py +402 -0
  31. node/commands/plugin_cmds.py +135 -0
  32. node/commands/sidecar_cmds.py +120 -0
  33. node/commands/sync.py +388 -0
  34. node/commands/transport_cmds.py +451 -0
  35. node/commands/video_cmds.py +99 -0
  36. node/compartment/__init__.py +124 -0
  37. node/compartment/base.py +868 -0
  38. node/compartment/command.py +458 -0
  39. node/compartment/context.py +163 -0
  40. node/compartment/every.py +108 -0
  41. node/compartment/handler.py +131 -0
  42. node/compartment/lifecycle.py +460 -0
  43. node/compartment/lint.py +388 -0
  44. node/compartment/need.py +158 -0
  45. node/compartment/problem.py +521 -0
  46. node/compartment/refusals.py +120 -0
  47. node/compartment/registry.py +790 -0
  48. node/compartment/schema.py +290 -0
  49. node/compartment/sidecar.py +499 -0
  50. node/compartment/stores.py +662 -0
  51. node/compartment/wire.py +225 -0
  52. node/compartment/worker.py +61 -0
  53. node/config/__init__.py +43 -0
  54. node/config/base.py +116 -0
  55. node/config/change.py +441 -0
  56. node/config/changestore.py +201 -0
  57. node/config/compartments.py +1144 -0
  58. node/config/node.py +318 -0
  59. node/config/parameter.py +563 -0
  60. node/config/protocols.py +274 -0
  61. node/config/records.py +171 -0
  62. node/examples/__init__.py +1 -0
  63. node/examples/demo.py +226 -0
  64. node/examples/follower.py +46 -0
  65. node/interfaces.py +235 -0
  66. node/keystore/__init__.py +27 -0
  67. node/keystore/core.py +103 -0
  68. node/keystore/enclave.py +688 -0
  69. node/meshnode.py +111 -0
  70. node/metrics/__init__.py +396 -0
  71. node/metrics/adapters.py +102 -0
  72. node/metrics/face.py +110 -0
  73. node/planes/__init__.py +21 -0
  74. node/planes/identity/__init__.py +20 -0
  75. node/planes/identity/announce/__init__.py +9 -0
  76. node/planes/identity/announce/compartment.py +447 -0
  77. node/planes/identity/announce/types.py +26 -0
  78. node/planes/identity/discovery/__init__.py +9 -0
  79. node/planes/identity/discovery/compartment.py +522 -0
  80. node/planes/identity/discovery/types.py +13 -0
  81. node/planes/identity/linked/__init__.py +1 -0
  82. node/planes/identity/linked/compartment.py +270 -0
  83. node/planes/identity/linked/types.py +25 -0
  84. node/planes/identity/presence/__init__.py +7 -0
  85. node/planes/identity/presence/compartment.py +409 -0
  86. node/planes/identity/presence/types.py +14 -0
  87. node/planes/operations/__init__.py +13 -0
  88. node/planes/operations/scheduler/__init__.py +9 -0
  89. node/planes/operations/scheduler/compartment.py +353 -0
  90. node/planes/operations/scheduler/types.py +21 -0
  91. node/planes/operations/updates/__init__.py +17 -0
  92. node/planes/operations/updates/core.py +310 -0
  93. node/planes/operations/updates/store.py +79 -0
  94. node/planes/operations/updates/types.py +20 -0
  95. node/planes/peering/__init__.py +28 -0
  96. node/planes/peering/budget/__init__.py +43 -0
  97. node/planes/peering/budget/chain.py +43 -0
  98. node/planes/peering/budget/core.py +537 -0
  99. node/planes/peering/budget/evaluation.py +80 -0
  100. node/planes/peering/budget/meter.py +100 -0
  101. node/planes/peering/budget/receipts.py +439 -0
  102. node/planes/peering/budget/shape.py +101 -0
  103. node/planes/peering/budget/tick.py +110 -0
  104. node/planes/peering/budget/types.py +67 -0
  105. node/planes/peering/budget/windows.py +346 -0
  106. node/planes/peering/capacity/__init__.py +7 -0
  107. node/planes/peering/capacity/compartment.py +342 -0
  108. node/planes/peering/capacity/types.py +24 -0
  109. node/planes/peering/peering/__init__.py +25 -0
  110. node/planes/peering/peering/core.py +464 -0
  111. node/planes/peering/peering/envelope.py +142 -0
  112. node/planes/peering/peering/handshake.py +198 -0
  113. node/planes/peering/peering/shape.py +80 -0
  114. node/planes/peering/peering/types.py +39 -0
  115. node/planes/peering/reporter/__init__.py +42 -0
  116. node/planes/peering/reporter/constants.py +29 -0
  117. node/planes/peering/reporter/core.py +299 -0
  118. node/planes/peering/reporter/finding.py +74 -0
  119. node/planes/peering/reporter/findings.py +171 -0
  120. node/planes/peering/reporter/publish.py +285 -0
  121. node/planes/peering/reporter/reading.py +193 -0
  122. node/planes/peering/reporter/shape.py +86 -0
  123. node/planes/peering/reporter/types.py +50 -0
  124. node/planes/peering/strain/__init__.py +9 -0
  125. node/planes/peering/strain/compartment.py +204 -0
  126. node/planes/peering/strain/types.py +21 -0
  127. node/planes/peering/trust/__init__.py +9 -0
  128. node/planes/peering/trust/compartment.py +239 -0
  129. node/planes/peering/trust/types.py +30 -0
  130. node/planes/services/__init__.py +33 -0
  131. node/planes/services/compute/__init__.py +160 -0
  132. node/planes/services/compute/common.py +134 -0
  133. node/planes/services/compute/compartment.py +681 -0
  134. node/planes/services/compute/executors/__init__.py +121 -0
  135. node/planes/services/compute/executors/agent.py +840 -0
  136. node/planes/services/compute/executors/bacalhau.py +605 -0
  137. node/planes/services/compute/executors/base.py +127 -0
  138. node/planes/services/compute/executors/harness.py +439 -0
  139. node/planes/services/compute/executors/mcp.py +454 -0
  140. node/planes/services/compute/executors/media.py +898 -0
  141. node/planes/services/compute/executors/nondet.py +283 -0
  142. node/planes/services/compute/executors/providers.py +499 -0
  143. node/planes/services/compute/executors/registry.py +140 -0
  144. node/planes/services/compute/executors/wasm.py +244 -0
  145. node/planes/services/compute/executors/zk.py +604 -0
  146. node/planes/services/compute/jobs.py +227 -0
  147. node/planes/services/compute/load.py +180 -0
  148. node/planes/services/compute/market.py +433 -0
  149. node/planes/services/compute/sandbox.py +282 -0
  150. node/planes/services/compute/types.py +84 -0
  151. node/planes/services/compute_wire/__init__.py +58 -0
  152. node/planes/services/compute_wire/compartment.py +256 -0
  153. node/planes/services/compute_wire/messages.py +320 -0
  154. node/planes/services/compute_wire/sealing.py +147 -0
  155. node/planes/services/compute_wire/session.py +572 -0
  156. node/planes/services/compute_wire/types.py +49 -0
  157. node/planes/services/compute_wire/verify.py +331 -0
  158. node/planes/services/dvm/__init__.py +24 -0
  159. node/planes/services/dvm/gateway.py +526 -0
  160. node/planes/services/dvm/protocol.py +35 -0
  161. node/planes/services/dvm/translate.py +15 -0
  162. node/planes/services/dvm/types.py +25 -0
  163. node/planes/services/gateway/__init__.py +40 -0
  164. node/planes/services/gateway/settle.py +279 -0
  165. node/planes/services/gateway/translate.py +644 -0
  166. node/planes/services/gossip/__init__.py +22 -0
  167. node/planes/services/gossip/core.py +929 -0
  168. node/planes/services/gossip/envelope.py +154 -0
  169. node/planes/services/gossip/mesh.py +127 -0
  170. node/planes/services/gossip/types.py +50 -0
  171. node/planes/services/nsite/__init__.py +7 -0
  172. node/planes/services/nsite/client.py +62 -0
  173. node/planes/services/nsite/compartment.py +752 -0
  174. node/planes/services/nsite/types.py +44 -0
  175. node/planes/services/paidfetch/__init__.py +7 -0
  176. node/planes/services/paidfetch/compartment.py +862 -0
  177. node/planes/services/paidfetch/types.py +99 -0
  178. node/planes/services/settle/__init__.py +27 -0
  179. node/planes/services/settle/compartment.py +384 -0
  180. node/planes/services/settle/engine.py +456 -0
  181. node/planes/services/settle/gateways.py +541 -0
  182. node/planes/services/settle/payment.py +512 -0
  183. node/planes/services/settle/store.py +74 -0
  184. node/planes/services/settle/types.py +58 -0
  185. node/planes/services/sidecars/__init__.py +8 -0
  186. node/planes/services/sidecars/compartment.py +461 -0
  187. node/planes/services/sidecars/manifest.py +304 -0
  188. node/planes/services/sidecars/manifests/10-grasp.json +37 -0
  189. node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
  190. node/planes/services/sidecars/manifests/30-magpie.json +28 -0
  191. node/planes/services/sidecars/types.py +43 -0
  192. node/planes/services/video/__init__.py +106 -0
  193. node/planes/services/video/barter.py +407 -0
  194. node/planes/services/video/cli.py +158 -0
  195. node/planes/services/video/compartment.py +932 -0
  196. node/planes/services/video/ingest.py +524 -0
  197. node/planes/services/video/ledger.py +705 -0
  198. node/planes/services/video/packager.py +847 -0
  199. node/planes/services/video/player.py +456 -0
  200. node/planes/services/video/timed_segments.py +124 -0
  201. node/planes/services/video/transit.py +478 -0
  202. node/planes/store/__init__.py +24 -0
  203. node/planes/store/blob_replication/__init__.py +7 -0
  204. node/planes/store/blob_replication/compartment.py +496 -0
  205. node/planes/store/blob_replication/types.py +35 -0
  206. node/planes/store/blob_wire/__init__.py +48 -0
  207. node/planes/store/blob_wire/client.py +57 -0
  208. node/planes/store/blob_wire/core.py +359 -0
  209. node/planes/store/blob_wire/intake.py +74 -0
  210. node/planes/store/blob_wire/messages.py +85 -0
  211. node/planes/store/projected/__init__.py +7 -0
  212. node/planes/store/projected/compartment.py +258 -0
  213. node/planes/store/projected/types.py +29 -0
  214. node/planes/store/retention/__init__.py +7 -0
  215. node/planes/store/retention/compartment.py +214 -0
  216. node/planes/store/retention/types.py +12 -0
  217. node/planes/store/sync/__init__.py +35 -0
  218. node/planes/store/sync/core.py +311 -0
  219. node/planes/store/sync/envelope.py +106 -0
  220. node/planes/store/sync/handover.py +206 -0
  221. node/planes/store/sync/initiator.py +128 -0
  222. node/planes/store/sync/responder.py +114 -0
  223. node/planes/store/sync/rules.py +179 -0
  224. node/planes/store/sync/shape.py +80 -0
  225. node/planes/store/sync/types.py +51 -0
  226. node/planes/transport/__init__.py +23 -0
  227. node/planes/transport/bridge/__init__.py +13 -0
  228. node/planes/transport/bridge/compartment.py +785 -0
  229. node/planes/transport/bridge/ratelimit.py +44 -0
  230. node/planes/transport/bridge/types.py +43 -0
  231. node/planes/transport/lan/__init__.py +9 -0
  232. node/planes/transport/lan/compartment.py +446 -0
  233. node/planes/transport/lan/types.py +25 -0
  234. node/planes/transport/link/__init__.py +7 -0
  235. node/planes/transport/link/compartment.py +257 -0
  236. node/planes/transport/link/types.py +27 -0
  237. node/planes/transport/mediation/__init__.py +45 -0
  238. node/planes/transport/mediation/compartment.py +238 -0
  239. node/planes/transport/mediation/leaf.py +107 -0
  240. node/planes/transport/mediation/messages.py +302 -0
  241. node/planes/transport/mediation/service.py +233 -0
  242. node/planes/transport/mediation/store.py +37 -0
  243. node/planes/transport/mediation/types.py +20 -0
  244. node/planes/transport/p2p/__init__.py +7 -0
  245. node/planes/transport/p2p/compartment.py +349 -0
  246. node/planes/transport/p2p/types.py +22 -0
  247. node/protocols/__init__.py +277 -0
  248. node/protocols/activitypub/__init__.py +56 -0
  249. node/protocols/activitypub/actor.py +230 -0
  250. node/protocols/activitypub/federation.py +490 -0
  251. node/protocols/activitypub/media.py +145 -0
  252. node/protocols/activitypub/models.py +286 -0
  253. node/protocols/activitypub/package.py +271 -0
  254. node/protocols/activitypub/store.py +564 -0
  255. node/protocols/arcade/__init__.py +26 -0
  256. node/protocols/atproto/__init__.py +13 -0
  257. node/protocols/atproto/firehose.py +126 -0
  258. node/protocols/atproto/metering.py +165 -0
  259. node/protocols/atproto/package.py +155 -0
  260. node/protocols/atproto/repo.py +250 -0
  261. node/protocols/atproto/xrpc.py +285 -0
  262. node/protocols/base.py +109 -0
  263. node/protocols/circles/__init__.py +29 -0
  264. node/protocols/dm/__init__.py +50 -0
  265. node/protocols/grasp/__init__.py +22 -0
  266. node/protocols/isotime.py +15 -0
  267. node/protocols/nostr/__init__.py +51 -0
  268. node/protocols/nostr/blobstore.py +383 -0
  269. node/protocols/nostr/blossom.py +627 -0
  270. node/protocols/nostr/client.py +377 -0
  271. node/protocols/nostr/descriptor.py +211 -0
  272. node/protocols/nostr/eventstore.py +304 -0
  273. node/protocols/nostr/package.py +182 -0
  274. node/protocols/nostr/peers.py +109 -0
  275. node/protocols/nostr/relayserver.py +656 -0
  276. node/protocols/p2panda/__init__.py +22 -0
  277. node/protocols/p2panda/identity.py +19 -0
  278. node/protocols/p2panda/package.py +236 -0
  279. node/protocols/p2panda/rung.py +152 -0
  280. node/protocols/p2panda/store.py +273 -0
  281. node/protocols/rhio/__init__.py +33 -0
  282. node/protocols/rhio/package.py +275 -0
  283. node/protocols/rhio/rung.py +136 -0
  284. node/protocols/rhio/store.py +1423 -0
  285. node/protocols/ssb/muxrpc.py +256 -0
  286. node/protocols/ssb/package.py +267 -0
  287. node/protocols/textile/__init__.py +111 -0
  288. node/protocols/textile/transducers.py +259 -0
  289. node/release.py +119 -0
  290. node/storage/__init__.py +88 -0
  291. node/storage/accounts.py +305 -0
  292. node/storage/adapter.py +161 -0
  293. node/storage/base.py +385 -0
  294. node/storage/dialect.py +209 -0
  295. node/storage/grants.py +317 -0
  296. node/storage/locator.py +150 -0
  297. node/storage/postgres.py +294 -0
  298. node/storage/projected.py +953 -0
  299. node/storage/sharding.py +448 -0
  300. node/storage/standing.py +568 -0
  301. node/testnet/__init__.py +9 -0
  302. node/testnet/cluster.py +731 -0
  303. node/testnet/fips_daemon.py +230 -0
  304. node/transport/__init__.py +94 -0
  305. node/transport/const.py +110 -0
  306. node/transport/didcomm_inbound.py +251 -0
  307. node/transport/dnstxt.py +169 -0
  308. node/transport/fips_rung/__init__.py +67 -0
  309. node/transport/fips_rung/core.py +666 -0
  310. node/transport/fips_rung/listener.py +96 -0
  311. node/transport/fips_rung/types.py +12 -0
  312. node/transport/host.py +334 -0
  313. node/transport/iroh_rung/__init__.py +29 -0
  314. node/transport/iroh_rung/core.py +477 -0
  315. node/transport/iroh_rung/quic.py +942 -0
  316. node/transport/iroh_rung/types.py +22 -0
  317. node/transport/ladder.py +145 -0
  318. node/transport/lineproto.py +49 -0
  319. node/transport/mdns.py +303 -0
  320. node/transport/netfetch.py +283 -0
  321. node/transport/relay_rung/__init__.py +23 -0
  322. node/transport/relay_rung/cooldown.py +87 -0
  323. node/transport/relay_rung/core.py +138 -0
  324. node/transport/relay_rung/inbound.py +161 -0
  325. node/transport/relay_rung/ledger.py +104 -0
  326. node/transport/relay_rung/listen.py +121 -0
  327. node/transport/relay_rung/outbound.py +181 -0
  328. node/transport/relay_rung/shape.py +71 -0
  329. node/transport/relay_rung/types.py +58 -0
  330. node/transport/relay_rung/wire.py +63 -0
  331. node/transport/router.py +761 -0
  332. node/transport/rung.py +276 -0
  333. node/transport/ssb_rung.py +166 -0
  334. node/transport/ws.py +502 -0
  335. sidecar/fleetmesh_announce.py +338 -0
  336. sidecar/fleetmesh_p2p.py +373 -0
  337. sidecar/fleetmesh_rhio.py +352 -0
  338. sidecar/fleetmesh_ssb.py +603 -0
@@ -0,0 +1,460 @@
1
+ """How a compartment lives: built, wired, running, degraded, quarantined, retired.
2
+
3
+ The fifth thing a compartment declares, beside PARAMS (what it can be told),
4
+ CHANNELS (what it says), METRICS (what it measures) and COMMANDS (what it can
5
+ be asked to do): LIFECYCLE, how it behaves when it fails.
6
+
7
+ Before this a compartment had no state. It was built or it was not, and once
8
+ built it was ticked forever. A tick that raised was recorded as a refusal and
9
+ then raised again on the next tick, and the one after, for as long as the node
10
+ ran - a broken compartment cost a traceback per cycle, filled its refusal
11
+ ring with one reason, and could not tell an operator anything they did not
12
+ already know after the first one. Nothing distinguished "failed once and
13
+ recovered" from "has been failing for a day".
14
+
15
+ So the Registry now carries one `Tracker` per compartment and moves it through
16
+
17
+ CONFIGURED -> WIRED -> RUNNING <-> DEGRADED -> QUARANTINED -> RUNNING
18
+ | ^ (breaker) (trial / resume)
19
+ | |
20
+ v | restart (only when declared)
21
+ DRAINING
22
+ |
23
+ v
24
+ RETIRED start() raised: -> FAILED, or
25
+ -> QUARANTINED when restartable
26
+
27
+ and each move is a declared channel, so the journal, the control socket and
28
+ the metrics all see the same transition.
29
+
30
+ CONFIGURED built from its config block; not yet reaching its neighbours
31
+ WIRED back-edges reached (`wire()` ran)
32
+ RUNNING started and ticking cleanly
33
+ DEGRADED the last tick raised; still ticked, and back to RUNNING after
34
+ one clean tick. A note, not trouble: one failure is weather.
35
+ QUARANTINED `Breaker.failures` ticks raised within `Breaker.window`
36
+ seconds. Not ticked until the backoff passes, then given one
37
+ trial tick: clean returns it to RUNNING, a raise sends it back
38
+ with the backoff doubled (to `Breaker.backoff[1]` at most).
39
+ Trouble, because something is wrong now. `resume` lets an
40
+ operator end it early.
41
+ FAILED an optional compartment's start() raised and it does not
42
+ declare `restart`, so nothing here can safely try again. Not
43
+ ticked, not stopped. Trouble. (A required compartment whose
44
+ start() raises still stops the node - see Registry.start.)
45
+ DRAINING being stopped - for good, or for a restart
46
+ RETIRED stopped; never ticked again
47
+
48
+ `Lifecycle(restart=True)` is a promise the compartment makes: its stop() and
49
+ then start() on the SAME instance leave it as good as new. Only a compartment
50
+ that makes it is restarted - by the `restart` control command, by a config
51
+ reload that changes its own block, by a quarantine's trial (which restarts it
52
+ before the trial tick), and after a start() that raised (which quarantines it
53
+ rather than failing it). The default is False because most compartments were
54
+ written to start once: a restart nobody designed for would read as resilience
55
+ the node does not have.
56
+
57
+ `Lifecycle(snapshot=N)` is the other promise, and it is about the PROCESS
58
+ restarting rather than one compartment: a core upgrade, a digest change, a
59
+ `systemctl restart`. The compartment implements `snapshot()` - a JSON-able
60
+ mapping of the state worth carrying - and `restore(state)`, and N is the
61
+ version of that mapping's shape. On a clean stop the node writes every
62
+ started compartment's snapshot to SNAPSHOT_FILE; the next start hands each one
63
+ back BEFORE start() runs, but only when the version still matches, the file
64
+ is recent (SNAPSHOT_MAX_AGE), and restore() returns. Anything else is a
65
+ refusal recorded in the compartment's lifecycle (`restored`), and it starts
66
+ fresh - which is what it did before snapshots existed, so a refused restore
67
+ costs continuity and nothing else. The file is removed once a start
68
+ succeeds: a snapshot is handed over once.
69
+
70
+ restore() must be all-or-nothing: validate first, then assign. A raise is
71
+ caught, but a half-applied restore is not something the node can see.
72
+
73
+ A package rather than a root module for the reason node/channels is one:
74
+ node/*.py is held to a monotone baseline (fleetmesh.org/scripts/check-root.py).
75
+ Standard library only, beside the two surfaces it emits through.
76
+ """
77
+ from __future__ import annotations
78
+
79
+ import collections
80
+ import dataclasses
81
+ import json
82
+ import os
83
+ from typing import Any, Callable, Deque, Mapping, NamedTuple, Optional, TypedDict
84
+
85
+ from ..channels import NOTE, TROUBLE, Channel
86
+ from ..metrics import COUNTER, Metric
87
+
88
+ # ---- the states ---------------------------------------------------------------
89
+ CONFIGURED = 'configured'
90
+ WIRED = 'wired'
91
+ RUNNING = 'running'
92
+ DEGRADED = 'degraded'
93
+ QUARANTINED = 'quarantined'
94
+ FAILED = 'failed'
95
+ DRAINING = 'draining'
96
+ RETIRED = 'retired'
97
+ STATES: tuple[str, ...] = (CONFIGURED, WIRED, RUNNING, DEGRADED, QUARANTINED, FAILED, DRAINING, RETIRED)
98
+
99
+ # The states in which a compartment is ticked on the ordinary cadence. A
100
+ # quarantined one is ticked only for its trial; a retired one never.
101
+ TICKED: frozenset[str] = frozenset({CONFIGURED, WIRED, RUNNING, DEGRADED})
102
+
103
+
104
+ @dataclasses.dataclass(frozen=True)
105
+ class Breaker:
106
+ """When repeated failure stops being weather and becomes a fault.
107
+
108
+ `failures` ticks that raise within `window` seconds trip it. `backoff` is
109
+ (first, ceiling) in seconds: the first quarantine lasts `first`, each
110
+ failed trial doubles it, and it never exceeds `ceiling`. `failures=0`
111
+ turns the breaker off for a compartment that must be ticked whatever
112
+ happens - the old behaviour, chosen rather than defaulted into.
113
+ """
114
+ failures: int = 5
115
+ window: int = 300
116
+ backoff: tuple[int, int] = (30, 600)
117
+
118
+ def delay(self, trips: int) -> int:
119
+ """Seconds a compartment stays quarantined on its `trips`-th trip."""
120
+ first, ceiling = self.backoff
121
+ return int(min(first * (2 ** max(trips - 1, 0)), ceiling))
122
+
123
+ def problems(self) -> list[str]:
124
+ out: list[str] = []
125
+ if self.failures < 0:
126
+ out.append('breaker failures must be 0 (off) or more')
127
+ if self.window <= 0:
128
+ out.append('breaker window must be positive')
129
+ first, ceiling = self.backoff
130
+ if first <= 0 or ceiling < first:
131
+ out.append('breaker backoff is (first, ceiling) with 0 < first <= ceiling')
132
+ return out
133
+
134
+
135
+ @dataclasses.dataclass(frozen=True)
136
+ class Lifecycle:
137
+ """How a compartment behaves when it fails. The default suits nearly all.
138
+
139
+ `restart`: stop() then start() on the same instance is safe and leaves it
140
+ as good as new. `snapshot`: the version of what snapshot() returns and
141
+ restore() reads; 0 carries nothing across a process restart. See the
142
+ module docstring for everything that acts on either.
143
+ """
144
+ breaker: Breaker = Breaker()
145
+ restart: bool = False
146
+ snapshot: int = 0
147
+
148
+ def problems(self) -> list[str]:
149
+ out = self.breaker.problems()
150
+ if self.snapshot < 0:
151
+ out.append('snapshot is a version: 0 (none) or more')
152
+ return out
153
+
154
+ def describe(self) -> 'LifecycleDecl':
155
+ return {'breaker': {'failures': self.breaker.failures, 'window': self.breaker.window,
156
+ 'backoff': list(self.breaker.backoff)},
157
+ 'restart': self.restart, 'snapshot': self.snapshot}
158
+
159
+
160
+ DEFAULT: Lifecycle = Lifecycle()
161
+
162
+
163
+ class BreakerDecl(TypedDict):
164
+ failures: int
165
+ window: int
166
+ backoff: list[int]
167
+
168
+
169
+ class LifecycleDecl(TypedDict):
170
+ breaker: BreakerDecl
171
+ restart: bool
172
+ snapshot: int
173
+
174
+
175
+ class LifecycleRecord(TypedDict):
176
+ """One compartment's lifecycle as `meshnode lifecycle` and health read it."""
177
+ state: str
178
+ since: int
179
+ failures: int # ticks that raised inside the breaker's window
180
+ trips: int # times the breaker has tripped since the last clean run
181
+ until: int # quarantined until (0 when not quarantined)
182
+ last_error: str
183
+ started: Optional[bool] # None: start() not run yet; False: it raised; True: it returned
184
+ restarts: int
185
+ restored: str # '' nothing offered; 'restored (vN, from <at>)'; or why it was refused
186
+ transitions: dict[str, int]
187
+ declared: LifecycleDecl
188
+
189
+
190
+ class Transition(NamedTuple):
191
+ compartment: str
192
+ was: str
193
+ now: str
194
+ at: int
195
+ why: str
196
+
197
+
198
+ class Tracker:
199
+ """One compartment's place in the lifecycle. Owned by the Registry.
200
+
201
+ Not thread-safe on its own: the Registry ticks from one thread and the
202
+ control socket's `resume` goes through the same lock the tick holds.
203
+ """
204
+
205
+ def __init__(self, name: str, lifecycle: Lifecycle, at: int = 0) -> None:
206
+ self.name: str = name
207
+ self.lifecycle: Lifecycle = lifecycle
208
+ self.state: str = CONFIGURED
209
+ self.since: int = at
210
+ self.fails: Deque[int] = collections.deque()
211
+ self.trips: int = 0
212
+ self.until: int = 0
213
+ self.last_error: str = ''
214
+ # Whether start() has returned. Registry.stop() skips a compartment
215
+ # whose start() raised: a stop() for something that never began is how
216
+ # a node that never served sent its peers a farewell (audit L1).
217
+ self.started: Optional[bool] = None
218
+ self.restarts: int = 0
219
+ # What happened to the snapshot the last process left for it.
220
+ self.restored: str = ''
221
+ self.transitions: collections.Counter[str] = collections.Counter()
222
+
223
+ def move(self, to: str, at: int, why: str = '') -> Optional[Transition]:
224
+ """Go to `to`. Returns the transition, or None when already there."""
225
+ if to == self.state:
226
+ return None
227
+ t = Transition(self.name, self.state, to, at, why)
228
+ self.state, self.since = to, at
229
+ self.transitions[to] += 1
230
+ return t
231
+
232
+ def due(self, now: int) -> bool:
233
+ """Whether the Registry should tick this compartment now."""
234
+ if self.state in TICKED:
235
+ return True
236
+ return self.state == QUARANTINED and now >= self.until
237
+
238
+ def succeeded(self, now: int) -> Optional[Transition]:
239
+ """A clean tick. Clears the failure record and recovers if it was down."""
240
+ self.fails.clear()
241
+ if self.state in (DEGRADED, QUARANTINED):
242
+ self.trips, self.until = 0, 0
243
+ return self.move(RUNNING, now, 'a clean tick')
244
+ return None
245
+
246
+ def failed(self, now: int, error: str) -> Optional[Transition]:
247
+ """A tick that raised. Degrades, or trips the breaker."""
248
+ b = self.lifecycle.breaker
249
+ self.last_error = error[:200]
250
+ trial = self.state == QUARANTINED
251
+ self.fails.append(now)
252
+ while self.fails and self.fails[0] <= now - b.window:
253
+ self.fails.popleft()
254
+ if b.failures and (trial or len(self.fails) >= b.failures):
255
+ self.trips += 1
256
+ self.until = now + b.delay(self.trips)
257
+ why = ('the trial tick raised: %s' % self.last_error if trial else
258
+ '%d ticks raised within %ds: %s' % (len(self.fails), b.window, self.last_error))
259
+ if trial:
260
+ # Still quarantined; a new transition record keeps the
261
+ # operator's count honest about how many trials failed.
262
+ self.transitions[QUARANTINED] += 1
263
+ return Transition(self.name, QUARANTINED, QUARANTINED, now, why)
264
+ return self.move(QUARANTINED, now, why)
265
+ return self.move(DEGRADED, now, self.last_error)
266
+
267
+ def resume(self, now: int, why: str = 'resumed by the operator') -> Optional[Transition]:
268
+ """End a quarantine now. The next tick is an ordinary one."""
269
+ if self.state != QUARANTINED:
270
+ return None
271
+ self.fails.clear()
272
+ self.trips, self.until = 0, 0
273
+ return self.move(RUNNING, now, why)
274
+
275
+ def start_failed(self, now: int, error: str) -> Optional[Transition]:
276
+ """start() raised. Quarantined when a restart can try again, else FAILED.
277
+
278
+ From QUARANTINED (a trial restart that raised) this is a failed trial:
279
+ the breaker's `failed` already doubles the backoff and says so.
280
+ """
281
+ self.started = False
282
+ self.last_error = error[:200]
283
+ if self.state == QUARANTINED:
284
+ return self.failed(now, error)
285
+ if not self.lifecycle.restart:
286
+ return self.move(FAILED, now, 'start raised: %s' % self.last_error)
287
+ self.trips += 1
288
+ self.until = now + self.lifecycle.breaker.delay(self.trips)
289
+ return self.move(QUARANTINED, now, 'start raised: %s' % self.last_error)
290
+
291
+ def restarted(self, now: int, why: str) -> Optional[Transition]:
292
+ """stop() and start() both returned: as good as new, so a clean record."""
293
+ self.started = True
294
+ self.restarts += 1
295
+ self.fails.clear()
296
+ self.trips, self.until, self.last_error = 0, 0, ''
297
+ return self.move(RUNNING, now, 'restarted: %s' % why)
298
+
299
+ def describe(self) -> LifecycleRecord:
300
+ return {'state': self.state, 'since': self.since, 'failures': len(self.fails),
301
+ 'trips': self.trips, 'until': self.until, 'last_error': self.last_error,
302
+ 'started': self.started, 'restarts': self.restarts, 'restored': self.restored,
303
+ 'transitions': dict(self.transitions),
304
+ 'declared': self.lifecycle.describe()}
305
+
306
+
307
+ # ---- carrying state across a process restart ------------------------------------
308
+ SNAPSHOT_FILE = 'snapshots.json'
309
+ SNAPSHOT_FORMAT = 1
310
+ # A snapshot carries a compartment across a restart - an upgrade, a reload -
311
+ # which takes seconds. One an hour old is from a node that was down, and the
312
+ # state in it describes a world that has moved on (peers learned, links held)
313
+ # while nobody was counting. Starting fresh is the honest answer then.
314
+ SNAPSHOT_MAX_AGE = 3600
315
+
316
+
317
+ class SnapshotEntry(TypedDict):
318
+ version: int
319
+ state: Mapping[str, Any]
320
+
321
+
322
+ class SnapshotFile(TypedDict):
323
+ format: int
324
+ at: int
325
+ compartments: dict[str, SnapshotEntry]
326
+
327
+
328
+ def write_snapshots(directory: str, at: int, compartments: Mapping[str, SnapshotEntry]) -> str:
329
+ """Write the snapshot file atomically. Returns its path."""
330
+ path = os.path.join(directory, SNAPSHOT_FILE)
331
+ body: SnapshotFile = {'format': SNAPSHOT_FORMAT, 'at': at, 'compartments': dict(compartments)}
332
+ tmp = path + '.tmp'
333
+ with open(tmp, 'w', encoding='utf-8') as f:
334
+ json.dump(body, f, indent=1, sort_keys=True)
335
+ os.replace(tmp, path)
336
+ return path
337
+
338
+
339
+ def read_snapshots(directory: str, now: int) -> tuple[dict[str, SnapshotEntry], int, str]:
340
+ """(compartments, written at, why none) from the snapshot file.
341
+
342
+ An empty mapping with a reason when there is a file and it cannot be
343
+ used - unreadable, another format, or older than SNAPSHOT_MAX_AGE - and
344
+ with '' when there is simply no file. Never raises: a node must start
345
+ whatever the last one left behind.
346
+ """
347
+ path = os.path.join(directory, SNAPSHOT_FILE)
348
+ if not os.path.exists(path):
349
+ return {}, 0, ''
350
+ try:
351
+ with open(path, encoding='utf-8') as f:
352
+ body = json.load(f)
353
+ except (OSError, ValueError) as e:
354
+ return {}, 0, 'the snapshot file is unreadable (%s)' % type(e).__name__
355
+ if not isinstance(body, dict) or body.get('format') != SNAPSHOT_FORMAT:
356
+ return {}, 0, 'the snapshot file is not format %d' % SNAPSHOT_FORMAT
357
+ at = body.get('at')
358
+ comps = body.get('compartments')
359
+ if not isinstance(at, int) or not isinstance(comps, dict):
360
+ return {}, 0, 'the snapshot file is malformed'
361
+ if now - at > SNAPSHOT_MAX_AGE:
362
+ return {}, at, 'the snapshot is %ds old (more than %ds): starting fresh' % (now - at, SNAPSHOT_MAX_AGE)
363
+ out: dict[str, SnapshotEntry] = {}
364
+ for name, entry in comps.items():
365
+ if isinstance(entry, dict) and isinstance(entry.get('version'), int) and isinstance(entry.get('state'), dict):
366
+ out[str(name)] = {'version': entry['version'], 'state': entry['state']}
367
+ return out, at, ''
368
+
369
+
370
+ def discard_snapshots(directory: str) -> None:
371
+ """A snapshot is handed over once: gone after the start that used it."""
372
+ try:
373
+ os.unlink(os.path.join(directory, SNAPSHOT_FILE))
374
+ except FileNotFoundError:
375
+ pass
376
+
377
+
378
+ # ---- what the lifecycle says, and counts ---------------------------------------
379
+ # Declared here, beside the states, rather than on each compartment: every
380
+ # compartment moves through the same machine, so its transitions are the
381
+ # Registry's to say. The payload names the compartment, so one channel per
382
+ # kind of move is enough and a reader filters by field.
383
+ def _payload(t: Transition) -> Mapping[str, Any]:
384
+ return {'compartment': t.compartment, 'was': t.was, 'now': t.now, 'why': t.why}
385
+
386
+
387
+ CHANGED = 'lifecycle.changed'
388
+ DEGRADED_CH = 'lifecycle.degraded'
389
+ QUARANTINED_CH = 'lifecycle.quarantined'
390
+ RECOVERED_CH = 'lifecycle.recovered'
391
+ RESUMED_CH = 'lifecycle.resumed'
392
+ RESTARTED_CH = 'lifecycle.restarted'
393
+ FAILED_CH = 'lifecycle.failed'
394
+
395
+ CHANNELS: tuple[Channel, ...] = (
396
+ Channel(name=CHANGED, description='A compartment moved through its lifecycle '
397
+ '(configured, wired, running, draining, retired).',
398
+ wire=_payload, say='{compartment} {was} -> {now}', tags=('lifecycle',)),
399
+ Channel(name=DEGRADED_CH, description='A compartment tick raised; it is still ticked and '
400
+ 'recovers after one clean tick.',
401
+ level=NOTE, wire=_payload, say='{compartment} degraded: {why}', tags=('lifecycle',)),
402
+ Channel(name=QUARANTINED_CH, description='A compartment tripped its breaker and is not ticked '
403
+ 'until its backoff passes.',
404
+ level=TROUBLE, wire=_payload, say='{compartment} quarantined: {why}', tags=('lifecycle',)),
405
+ Channel(name=RECOVERED_CH, description='A degraded or quarantined compartment ticked cleanly again.',
406
+ wire=_payload, say='{compartment} recovered ({was} -> {now})', tags=('lifecycle',)),
407
+ Channel(name=RESUMED_CH, description='An operator ended a quarantine early.',
408
+ wire=_payload, say='{compartment} resumed: {why}', tags=('lifecycle',)),
409
+ Channel(name=RESTARTED_CH, description='A compartment that declares Lifecycle(restart=True) was '
410
+ 'stopped and started again in place.',
411
+ wire=_payload, say='{compartment} {why}', tags=('lifecycle',)),
412
+ Channel(name=FAILED_CH, description='An optional compartment\'s start() raised and it cannot be '
413
+ 'restarted in place; it is not ticked until the node restarts.',
414
+ level=TROUBLE, wire=_payload, say='{compartment} failed: {why}', tags=('lifecycle',)),
415
+ )
416
+
417
+ METRICS: tuple[Metric, ...] = (
418
+ Metric(name='lifecycle_degraded', description='Times a compartment went from running to degraded. '
419
+ 'Every raise is in its refusal ring as tick: raised.',
420
+ kind=COUNTER, counts=DEGRADED_CH, tags=('lifecycle',)),
421
+ Metric(name='lifecycle_quarantined', description='Times a compartment breaker tripped, '
422
+ 'including failed trial ticks.',
423
+ kind=COUNTER, counts=QUARANTINED_CH, tags=('lifecycle',)),
424
+ Metric(name='lifecycle_recovered', description='Times a compartment recovered to running.',
425
+ kind=COUNTER, counts=RECOVERED_CH, tags=('lifecycle',)),
426
+ Metric(name='lifecycle_restarted', description='Times a compartment was restarted in place.',
427
+ kind=COUNTER, counts=RESTARTED_CH, tags=('lifecycle',)),
428
+ Metric(name='lifecycle_failed', description='Times an optional compartment failed to start.',
429
+ kind=COUNTER, counts=FAILED_CH, tags=('lifecycle',)),
430
+ )
431
+
432
+
433
+ def channel_for(t: Transition) -> str:
434
+ """Which declared channel a transition is said on."""
435
+ if t.now == QUARANTINED:
436
+ return QUARANTINED_CH
437
+ if t.now == DEGRADED:
438
+ return DEGRADED_CH
439
+ if t.now == FAILED:
440
+ return FAILED_CH
441
+ if t.now == RUNNING and t.why.startswith('restarted'):
442
+ return RESTARTED_CH
443
+ if t.now == RUNNING and t.was in (DEGRADED, QUARANTINED):
444
+ return RESUMED_CH if t.why.startswith('resumed') else RECOVERED_CH
445
+ return CHANGED
446
+
447
+
448
+ def gauge(count: Callable[[], int], name: str, description: str) -> Metric:
449
+ """A sampled gauge over the Registry's trackers, built where they live."""
450
+ return Metric(name=name, description=description, sample=count, tags=('lifecycle',))
451
+
452
+
453
+ __all__ = [
454
+ 'BreakerDecl', 'Breaker', 'CHANGED', 'CHANNELS', 'CONFIGURED', 'DEFAULT', 'DEGRADED', 'DEGRADED_CH',
455
+ 'DRAINING', 'FAILED', 'FAILED_CH', 'Lifecycle', 'LifecycleDecl', 'LifecycleRecord', 'METRICS',
456
+ 'QUARANTINED', 'QUARANTINED_CH', 'RECOVERED_CH', 'RESTARTED_CH', 'RESUMED_CH', 'RETIRED', 'RUNNING',
457
+ 'SNAPSHOT_FILE', 'SNAPSHOT_FORMAT', 'SNAPSHOT_MAX_AGE', 'STATES', 'SnapshotEntry', 'SnapshotFile',
458
+ 'TICKED', 'Tracker', 'Transition', 'WIRED', 'channel_for', 'discard_snapshots', 'gauge',
459
+ 'read_snapshots', 'write_snapshots',
460
+ ]