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,496 @@
1
+ """Blossom replication: keep N copies of every pinned blob on other nodes.
2
+
3
+ Pinning keeps a blob off this node's LRU eviction; it does nothing if this
4
+ node's disk dies. Replication is the other half. For each pinned blob the
5
+ operator gives a target copy count, and on every tick this compartment:
6
+
7
+ 1. asks candidate servers without a copy to mirror it (BUD-04 PUT /mirror,
8
+ signed kind 24242 by this node, source = this node's public Blossom URL),
9
+ 2. re-checks held copies on a slow cadence with HEAD, and
10
+ 3. forgets a copy that has gone, so step 1 asks again - on another server
11
+ if there is one.
12
+
13
+ Where copies go: servers from config `blob_replication.servers`, then every peer
14
+ this node holds a live grant with that advertises an http(s)/ws(s) endpoint in
15
+ its descriptor (a node's face serves Blossom on the same port). A peering
16
+ partner is who this node already trusts with its traffic; a stranger found on
17
+ a relay is not asked to hold its data.
18
+
19
+ A partner with a grant but no http(s)/ws(s) endpoint - a node behind NAT -
20
+ takes the second path: blob/1.0 over the peering transport (blob_wire.py).
21
+ Such a candidate is named `peer:<pubkey>` and driven through the same
22
+ mirror_blob/head_blob calls, so run() and verify() below do not know which
23
+ path a copy took.
24
+
25
+ What it cannot do, and says so: a partner may accept a mirror and later drop
26
+ it; this is found by the verify pass, not prevented. A node with no public URL
27
+ (`blob_replication.source_url`, or a first http(s)/ws(s) endpoint) cannot be
28
+ mirrored from over Blossom, and `trouble` reports that instead of failing
29
+ silently; the transport path needs no URL.
30
+
31
+ State lives in SQLite beside the blob index (blob_replicas.sqlite).
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import urllib.error
36
+ from typing import Any, Callable, Mapping, Optional, cast
37
+
38
+ from node.storage import SqliteStore
39
+ from node.config import BlobReplicationConfig
40
+ from node.compartment import stores as st
41
+ from node.channels import Channel, INFO, NOTE
42
+ from node.metrics import COUNTER, Metric
43
+ from node.compartment import Command, Compartment, Every, Need
44
+ from node.compartment.schema import Column, Table
45
+ from node.config.parameter import Parameter
46
+ from node.protocols.nostr import blossom
47
+ from node.transport import netfetch
48
+ from node.planes.store.blob_wire.messages import HTTP_SCHEMES, blossom_url
49
+ from .types import (
50
+ BlobReplicationStatus,
51
+ BlobReplicationStateReport,
52
+ PinResult,
53
+ UnpinResult,
54
+ )
55
+
56
+ TICK_SECONDS: int = 300 # how often to try under-replicated blobs
57
+ VERIFY_SECONDS: int = 6 * 3600 # how often a held copy is re-checked
58
+ RETRY_SECONDS: int = 1800 # back-off for a server that just failed
59
+ MAX_ATTEMPTS_PER_TICK: int = 16 # bound the network work one tick does
60
+
61
+
62
+ class ReplicaStore(SqliteStore):
63
+ SCHEMA = (
64
+ Table('wants', (
65
+ Column('sha256', 'TEXT', primary_key=True),
66
+ Column('copies', 'INTEGER', not_null=True),
67
+ Column('since', 'INTEGER', not_null=True),
68
+ )),
69
+ Table('replicas', (
70
+ Column('sha256', 'TEXT', not_null=True),
71
+ Column('server', 'TEXT', not_null=True),
72
+ Column('state', 'TEXT', not_null=True),
73
+ Column('at', 'INTEGER', not_null=True),
74
+ Column('why', 'TEXT'),
75
+ ), primary_key=('sha256', 'server')),
76
+ )
77
+
78
+ def want(self, h: str, copies: int, now: int) -> None:
79
+ self.execute('INSERT INTO wants (sha256, copies, since) VALUES (?, ?, ?) '
80
+ 'ON CONFLICT(sha256) DO UPDATE SET copies = excluded.copies', (h, copies, now))
81
+ self.commit()
82
+
83
+ def unwant(self, h: str) -> bool:
84
+ cur = self.execute('DELETE FROM wants WHERE sha256 = ?', (h,))
85
+ self.commit()
86
+ return cur.rowcount > 0
87
+
88
+ def wants(self) -> list[tuple[str, int]]:
89
+ return [(r[0], int(r[1])) for r in self._all('SELECT sha256, copies FROM wants ORDER BY since')]
90
+
91
+ def copies(self, h: str) -> int:
92
+ row = self.want_row(h)
93
+ return int(row[1]) if row else 0
94
+
95
+ def want_row(self, h: str) -> Optional[tuple[Any, ...]]:
96
+ return self._one('SELECT sha256, copies FROM wants WHERE sha256 = ?', (h,))
97
+
98
+ def replicas(self, h: str) -> list[Mapping[str, Any]]:
99
+ return [{'server': r[0], 'state': r[1], 'at': r[2], 'why': r[3]}
100
+ for r in self._all('SELECT server, state, at, why FROM replicas WHERE sha256 = ? ORDER BY server', (h,))]
101
+
102
+ def mark(self, h: str, server: str, state: str, now: int, why: Optional[str] = None) -> None:
103
+ self.execute('INSERT INTO replicas (sha256, server, state, at, why) VALUES (?, ?, ?, ?, ?) '
104
+ 'ON CONFLICT(sha256, server) DO UPDATE SET state = excluded.state, at = excluded.at, '
105
+ 'why = excluded.why', (h, server, state, now, why))
106
+ self.commit()
107
+
108
+ def forget(self, h: str, server: Optional[str] = None) -> None:
109
+ if server is None:
110
+ self.execute('DELETE FROM replicas WHERE sha256 = ?', (h,))
111
+ else:
112
+ self.execute('DELETE FROM replicas WHERE sha256 = ? AND server = ?', (h, server))
113
+ self.commit()
114
+
115
+
116
+ class Replication(Compartment):
117
+ requires: tuple[str, ...] = ('blob_wire',)
118
+ """Keeps a target number of copies of each pinned blob on other servers."""
119
+
120
+ name = 'blob_replication'
121
+ reads = ('blob_replication', 'endpoints')
122
+ config_cls = BlobReplicationConfig
123
+ live = ('blob_replication',)
124
+ REPLICAS_DB = 'blob_replicas.sqlite'
125
+ SCHEMA = ReplicaStore.SCHEMA
126
+ STORE_CLS = ReplicaStore # builds its own; the base opens nothing for it (B5)
127
+ STORES = (
128
+ st.Store(key='replicas', description='replicated blob records in sqlite',
129
+ kind=st.RING, shape=dict, bound=1024),
130
+ )
131
+ CHANNELS = (
132
+ Channel('blob_replicated', 'Blob successfully mirrored to remote partner', level=INFO, say='blob {sha256} replicated to {server}', tags=('blob', 'replication')),
133
+ Channel('replication_failed', 'Blob mirroring attempt failed', level=NOTE, say='blob {sha256} replication failed on {server}: {why}', tags=('blob', 'replication')),
134
+ )
135
+ METRICS = (
136
+ Metric(name='blob_replication_replicated', description='Times blobs were replicated.', kind=COUNTER, counts='blob_replicated', tags=('blob', 'replication')),
137
+ Metric(name='blob_replication_failed', description='Times blob replications failed.', kind=COUNTER, counts='replication_failed', tags=('blob', 'replication')),
138
+ )
139
+ NEEDS = (
140
+ Need('blobs', 'stores.blobs'),
141
+ Need('db_path', path='REPLICAS_DB'),
142
+ Need('signer_sk', 'ks.node_sk'),
143
+ Need('cfg', 'cfg', getter=True),
144
+ Need('blob_wire'),
145
+ )
146
+ COMMANDS = (
147
+ Command(
148
+ key='pin',
149
+ name='Pin Blob',
150
+ description='pin a blob here and keep that many copies of it on other servers',
151
+ tags=('blob_replication', 'pin'),
152
+ parameters=(
153
+ Parameter(key='sha256', name='sha256', description='hex digest of blob', type=str, default='', required=True),
154
+ Parameter(key='copies', name='copies', description='target copy count', type=int, default=None, required=False),
155
+ ),
156
+ permission='blob_replication/pin',
157
+ run='pin_cmd',
158
+ reply='{sha256, copies, pinned}',
159
+ ),
160
+ Command(
161
+ key='unpin',
162
+ name='Unpin Blob',
163
+ description='stop replicating (and unpin) a blob; copies already made stay',
164
+ tags=('blob_replication', 'unpin'),
165
+ parameters=(
166
+ Parameter(key='sha256', name='sha256', description='hex digest of blob', type=str, default='', required=True),
167
+ ),
168
+ permission='blob_replication/unpin',
169
+ run='unpin_cmd',
170
+ reply='{sha256, unpinned}',
171
+ ),
172
+ Command(
173
+ key='status',
174
+ name='Replication Status',
175
+ description='target and held copies, per blob',
176
+ tags=('blob_replication', 'status'),
177
+ parameters=(
178
+ Parameter(key='sha256', name='sha256', description='hex digest of blob to query', type=str, default=None, required=False),
179
+ ),
180
+ permission='blob_replication/status',
181
+ run='status_cmd',
182
+ reply='[{sha256, copies, held, servers}]',
183
+ ),
184
+ Command(
185
+ key='run',
186
+ name='Run Replication',
187
+ description='mirror under-replicated blobs now',
188
+ tags=('blob_replication', 'run'),
189
+ permission='blob_replication/run',
190
+ run='run_cmd',
191
+ reply='[(replication, ...)]',
192
+ ),
193
+ Command(
194
+ key='verify',
195
+ name='Verify Copies',
196
+ description='re-check every held copy now',
197
+ tags=('blob_replication', 'verify'),
198
+ permission='blob_replication/verify',
199
+ run='verify_cmd',
200
+ reply='[(replication, ...)]',
201
+ ),
202
+ )
203
+ EVERY = (
204
+ Every(seconds=VERIFY_SECONDS, run='verify', name='replication_verify'),
205
+ Every(seconds=TICK_SECONDS, run='run', name='replication_run'),
206
+ )
207
+
208
+ def __init__(
209
+ self,
210
+ blobs: Any,
211
+ db_path: str,
212
+ signer_sk: str,
213
+ cfg: Callable[[], Mapping[str, Any]],
214
+ candidates: Optional[Callable[[], list[str]]] = None,
215
+ clock: Optional[Callable[[], int]] = None,
216
+ client: Any = None,
217
+ node: Any = None,
218
+ transport_client: Any = None,
219
+ blob_wire: Optional[Any] = None,
220
+ ) -> None:
221
+ Compartment.__init__(self, node, clock)
222
+ self.blobs = blobs
223
+ self.db = ReplicaStore(db_path)
224
+ self.signer_sk = signer_sk
225
+ self._cfg = cfg
226
+ self.blob_wire = blob_wire
227
+ if candidates is not None:
228
+ self._candidates = candidates
229
+ else:
230
+ self._candidates = self._default_candidates
231
+ self.client = client or blossom.BlossomClient
232
+ # blob/1.0 over the peering transport, for `peer:<pk>` candidates.
233
+ self.transport_client = transport_client if transport_client is not None else getattr(blob_wire, 'client', None)
234
+ self.last_run: Optional[int] = None
235
+ self.last_verify: Optional[int] = None
236
+ self.mirrored = 0
237
+ self.lost = 0
238
+ self._closed = False
239
+
240
+ def _default_candidates(self) -> list[str]:
241
+ if not self.node:
242
+ return []
243
+ from node.planes.store import blob_wire as blob_wire_mod
244
+ return partner_blossom_servers(self.node) + blob_wire_mod.partner_transport_peers(self.node)
245
+
246
+ # ---- configuration --------------------------------------------------------
247
+ def rc(self) -> BlobReplicationConfig:
248
+ """The operator's `blob_replication` block as it is now; it reads live."""
249
+ return BlobReplicationConfig.from_dict((self._cfg() or {}).get('blob_replication') or {})
250
+
251
+ def settings(self) -> BlobReplicationConfig: # the Compartment hook, through the injected cfg
252
+ return self.rc()
253
+
254
+ def default_copies(self) -> int:
255
+ return max(0, self.rc().copies)
256
+
257
+ def allow_private(self) -> Any:
258
+ """Address policy for partner Blossom servers.
259
+
260
+ Partner URLs come out of peer descriptors, so a peer could name a
261
+ loopback or LAN address and make this node mirror into it. netfetch
262
+ refuses that by default; an operator running a LAN cluster sets
263
+ ``blob_replication.allow_private`` to true or to a list of hosts.
264
+ """
265
+ ap = self.rc().allow_private
266
+ return list(ap) if isinstance(ap, tuple) else ap
267
+
268
+ def source_url(self) -> Optional[str]:
269
+ """Where partners fetch from: this node's own public Blossom base URL."""
270
+ explicit = self.rc().source_url
271
+ if explicit:
272
+ return blossom_url(str(explicit))
273
+ eps = sorted((self._cfg() or {}).get('endpoints') or [], key=lambda e: int(e.get('priority', 50)))
274
+ for e in eps:
275
+ u = blossom_url(str(e.get('address') or ''))
276
+ if u:
277
+ return u
278
+ return None
279
+
280
+ def servers(self) -> list[str]:
281
+ out: list[str] = []
282
+ own = self.source_url()
283
+ for s in list(self.rc().servers) + list(self._candidates() or []):
284
+ s = str(s)
285
+ if _is_peer(s):
286
+ if self.transport_client is not None and s not in out:
287
+ out.append(s)
288
+ continue
289
+ u = blossom_url(s)
290
+ if u and u != own and u not in out:
291
+ out.append(u)
292
+ return out
293
+
294
+ def client_for(self, server: str) -> Any:
295
+ return self.transport_client if _is_peer(server) else self.client
296
+
297
+ # ---- operator actions -----------------------------------------------------
298
+ def pin(self, h: str, copies: Optional[int] = None) -> PinResult:
299
+ h = h.lower()
300
+ if not self.blobs.has(h):
301
+ raise ValueError('no blob %s on this node to replicate' % h[:16])
302
+ n = self.default_copies() if copies is None else max(0, int(copies))
303
+ self.blobs.pin(h)
304
+ self.db.want(h, n, self.clock())
305
+ return {'sha256': h, 'copies': n, 'pinned': True}
306
+
307
+ def unpin(self, h: str) -> UnpinResult:
308
+ h = h.lower()
309
+ gone = self.db.unwant(h)
310
+ self.db.forget(h)
311
+ self.blobs.unpin(h)
312
+ return {'sha256': h, 'unpinned': gone}
313
+
314
+ def status(self, h: Optional[str] = None) -> list[BlobReplicationStatus]:
315
+ rows = self.db.wants() if h is None else [(h.lower(), self.db.copies(h.lower()))]
316
+ out: list[BlobReplicationStatus] = []
317
+ for sha, n in rows:
318
+ reps = self.db.replicas(sha)
319
+ held = [r['server'] for r in reps if r['state'] == 'held']
320
+ out.append({'sha256': sha, 'copies': n, 'held': len(held), 'servers': held,
321
+ 'failed': {r['server']: r['why'] for r in reps if r['state'] == 'failed'}})
322
+ return out
323
+
324
+ # ---- the work -------------------------------------------------------------
325
+ def run(self, now: Optional[int] = None) -> list[tuple[Any, ...]]:
326
+ """Ask servers to mirror every blob below its target copy count."""
327
+ now = int(now if now is not None else self.clock())
328
+ self.last_run = now
329
+ did: list[tuple[Any, ...]] = []
330
+ src = self.source_url()
331
+ servers = [s for s in self.servers() if src is not None or _is_peer(s)]
332
+ if not servers:
333
+ return did
334
+ budget = MAX_ATTEMPTS_PER_TICK
335
+ for h, want in self.db.wants():
336
+ if budget <= 0:
337
+ break
338
+ if not self.blobs.has(h):
339
+ self.refusals.refuse('blob_replication', 'pinned blob is missing locally', subject=h[:16], at=now)
340
+ continue
341
+ reps = {r['server']: r for r in self.db.replicas(h)}
342
+ held = sum(1 for r in reps.values() if r['state'] == 'held')
343
+ for srv in servers:
344
+ if held >= want or budget <= 0:
345
+ break
346
+ r = reps.get(srv)
347
+ if r and (r['state'] == 'held' or now - int(r['at']) < RETRY_SECONDS):
348
+ continue
349
+ budget -= 1
350
+ try:
351
+ res = self.client_for(srv).mirror_blob(srv, '%s/%s' % (src, h), h, signer_sk=self.signer_sk,
352
+ allow_private=self.allow_private())
353
+ if str(res.get('sha256', '')).lower() != h:
354
+ raise ValueError('server answered for a different blob')
355
+ self.db.mark(h, srv, 'held', now)
356
+ held += 1
357
+ self.mirrored += 1
358
+ did.append(('blob_replication', 'mirrored', h[:16], srv))
359
+ except Exception as e:
360
+ why = _why(e)
361
+ self.db.mark(h, srv, 'failed', now, why)
362
+ self.refusals.refuse('blob_replication', 'mirror failed', subject=srv,
363
+ detail={'blob': h[:16], 'why': why}, at=now)
364
+ did.append(('blob_replication', 'mirror failed', h[:16], srv, why))
365
+ return did
366
+
367
+ def verify(self, now: Optional[int] = None) -> list[tuple[Any, ...]]:
368
+ """HEAD every held copy; forget the ones that are gone so run() re-asks."""
369
+ now = int(now if now is not None else self.clock())
370
+ self.last_verify = now
371
+ did: list[tuple[Any, ...]] = []
372
+ for h, _want in self.db.wants():
373
+ for r in self.db.replicas(h):
374
+ if r['state'] != 'held':
375
+ continue
376
+ try:
377
+ meta = self.client_for(r['server']).head_blob(r['server'], h, allow_private=self.allow_private())
378
+ except Exception as e:
379
+ # Unreachable is not gone: keep the copy, say so, look again later.
380
+ did.append(('blob_replication', 'verify unreachable', h[:16], r['server'], _why(e)))
381
+ continue
382
+ if meta is None:
383
+ self.db.forget(h, r['server'])
384
+ self.lost += 1
385
+ self.refusals.refuse('blob_replication', 'copy lost', subject=r['server'],
386
+ detail={'blob': h[:16]}, at=now)
387
+ did.append(('blob_replication', 'copy lost', h[:16], r['server']))
388
+ else:
389
+ self.db.mark(h, r['server'], 'held', now)
390
+ return did
391
+
392
+ # ---- the compartment contract -------------------------------------------------
393
+ _tick_runs_every = True
394
+
395
+ def tick(self, now: int) -> list[tuple[Any, ...]]:
396
+ if self._closed:
397
+ return []
398
+ if not hasattr(self, '_every_last'):
399
+ self._every_last = {} # not a store: Every job timing tracking
400
+ if 'replication_verify' not in self._every_last:
401
+ self._every_last['replication_verify'] = now
402
+ self.last_verify = now
403
+ return self.run_every(now)
404
+
405
+ def state(self) -> BlobReplicationStateReport:
406
+ if self._closed:
407
+ # health() is read after stop(); report the counters, not the store.
408
+ return {'stopped': True, 'mirrored': self.mirrored, 'lost': self.lost,
409
+ 'last_run': self.last_run, 'last_verify': self.last_verify}
410
+ st = self.status()
411
+ return {'blobs': len(st),
412
+ 'under_replicated': sum(1 for s in st if s['held'] < s['copies']),
413
+ 'servers': len(self.servers()),
414
+ 'source_url': self.source_url(),
415
+ 'mirrored': self.mirrored, 'lost': self.lost,
416
+ 'last_run': self.last_run, 'last_verify': self.last_verify}
417
+
418
+ def trouble(self) -> list[str]:
419
+ if self._closed:
420
+ return []
421
+ st = self.status()
422
+ if not st:
423
+ return []
424
+ out: list[str] = []
425
+ servers = self.servers()
426
+ if self.source_url() is None and not any(_is_peer(s) for s in servers):
427
+ out.append('%d pinned blob(s) want copies, but this node advertises no http(s)/ws(s) '
428
+ 'endpoint (or blob_replication.source_url) for partners to mirror from, '
429
+ 'and no partner takes blob/1.0 over the transport' % len(st))
430
+ elif not servers:
431
+ out.append('%d pinned blob(s) want copies, and there is no partner with a Blossom '
432
+ 'endpoint or a transport path and no blob_replication.servers to hold them' % len(st))
433
+ under = [s for s in st if s['held'] < s['copies']]
434
+ if under:
435
+ out.append('%d pinned blob(s) below their copy target' % len(under))
436
+ return out
437
+
438
+ def pin_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
439
+ return self.pin(str(args['sha256']), args.get('copies'))
440
+
441
+ def unpin_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
442
+ return self.unpin(str(args['sha256']))
443
+
444
+ def status_cmd(self, args: Mapping[str, Any]) -> list[Mapping[str, Any]]:
445
+ h = args.get('sha256')
446
+ return cast(list[Mapping[str, Any]], self.status(str(h) if h is not None else None))
447
+
448
+ def run_cmd(self) -> list[tuple[Any, ...]]:
449
+ return self.run()
450
+
451
+ def verify_cmd(self) -> list[tuple[Any, ...]]:
452
+ return self.verify()
453
+
454
+ def stop(self) -> Any:
455
+ self._closed = True
456
+ try:
457
+ self.db.close()
458
+ except Exception:
459
+ pass
460
+ super().stop()
461
+ return None
462
+
463
+
464
+ def _is_peer(server: str) -> bool:
465
+ return isinstance(server, str) and server.startswith('peer:')
466
+
467
+
468
+ def _why(e: Exception) -> str:
469
+ if isinstance(e, (urllib.error.HTTPError, netfetch.FetchHTTPError)):
470
+ return 'HTTP %d' % e.code
471
+ return '%s: %s' % (type(e).__name__, str(e)[:120])
472
+
473
+
474
+ def partner_blossom_servers(node: Any) -> list[str]:
475
+ """Blossom URLs of every peer this node holds a grant with, from their descriptors."""
476
+ out: list[str] = []
477
+ grants = getattr(node, 'grants', None)
478
+ peers = getattr(node, 'peers', None)
479
+ if grants is None or peers is None:
480
+ return out
481
+ for pk in grants.peers():
482
+ try:
483
+ d = peers.descriptor(pk)
484
+ except Exception:
485
+ d = None
486
+ if not d:
487
+ continue
488
+ eps = sorted((t for t in d.get('tags', []) if t and t[0] == 'endpoint' and len(t) > 2
489
+ and t[1] in HTTP_SCHEMES),
490
+ key=lambda t: int(t[3]) if len(t) > 3 and str(t[3]).isdigit() else 50)
491
+ for t in eps:
492
+ u = blossom_url(t[2])
493
+ if u and u not in out:
494
+ out.append(u)
495
+ break
496
+ return out
@@ -0,0 +1,35 @@
1
+ """Types and records for the BlobReplication compartment."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional, TypedDict
5
+
6
+
7
+ class BlobReplicationStatus(TypedDict):
8
+ sha256: str
9
+ copies: int
10
+ held: int
11
+ servers: list[str]
12
+ failed: dict[str, str]
13
+
14
+
15
+ class BlobReplicationStateReport(TypedDict, total=False):
16
+ stopped: bool
17
+ blobs: int
18
+ under_replicated: int
19
+ servers: int
20
+ source_url: Optional[str]
21
+ mirrored: int
22
+ lost: int
23
+ last_run: Optional[int]
24
+ last_verify: Optional[int]
25
+
26
+
27
+ class PinResult(TypedDict):
28
+ sha256: str
29
+ copies: int
30
+ pinned: bool
31
+
32
+
33
+ class UnpinResult(TypedDict):
34
+ sha256: str
35
+ unpinned: bool
@@ -0,0 +1,48 @@
1
+ """blob/1.0: blob replication over the peering transport, for partners that
2
+ have no Blossom URL.
3
+
4
+ The Blossom path in blob_replication.py asks a partner's HTTP server to
5
+ mirror a blob from THIS node's HTTP server. Both ends need a reachable URL,
6
+ and a node behind NAT has none - it can hold a grant, sync events, and price
7
+ compute, and still cannot take a copy of a blob. This is the second path:
8
+ the same DIDComm transport the grant lives on, so any peer that can reach
9
+ this node at all can replicate with it.
10
+
11
+ Five messages, one protocol:
12
+
13
+ have {sha256: [..]} -> have {sha256: [held subset]}
14
+ offer {sha256, size, type, chunk_bytes}
15
+ pull {sha256, offset, length} -> chunk {sha256, offset, data (base64)}
16
+ refuse {sha256, why}
17
+
18
+ The receiver pulls. It decides whether to take the offer (accept_from and
19
+ the caps), asks for one chunk at a time, verifies the SHA-256 as the bytes
20
+ arrive, and only then stores and pins. A NAT'd receiver needs no inbound
21
+ path, backpressure is a property of the loop rather than a buffer, and a bad
22
+ chunk simply ends the pull. Every reply is threaded on the request's id, so
23
+ the transport's request() does the waiting and the 0x4D fragmentation below
24
+ it makes chunk_bytes a policy number rather than a datagram limit.
25
+
26
+ Replicas taken this way are stored under the synthetic owner
27
+ `replica:<peer>` and pinned; the tick drops them when the grant with that
28
+ peer ends. Nothing here is free by default: accept_from is `none` and every
29
+ cap is 0 until an operator says otherwise.
30
+
31
+ Layout:
32
+
33
+ messages.py PIURI, KINDS, the window numbers, make_*/kind_of
34
+ intake.py IntakeStore - what this node took from whom
35
+ client.py TransportBlobClient - the client blob_replication drives
36
+ core.py BlobWire, the compartment; partner_transport_peers
37
+ """
38
+ from .messages import (C, CAP_DEFAULTS, DAY, DEFAULT_CHUNK_BYTES, KINDS, MAX_CHUNK_BYTES, MAX_HAVE, PIURI,
39
+ PULL_TIMEOUT, _msg, kind_of, make_chunk, make_have, make_offer, make_pull, make_refuse)
40
+ from .intake import IntakeStore
41
+ from .client import TransportBlobClient
42
+ from .core import BlobWire, partner_transport_peers
43
+
44
+ __all__ = [
45
+ 'C', 'CAP_DEFAULTS', 'DAY', 'DEFAULT_CHUNK_BYTES', 'KINDS', 'MAX_CHUNK_BYTES', 'MAX_HAVE', 'PIURI',
46
+ 'PULL_TIMEOUT', '_msg', 'kind_of', 'make_chunk', 'make_have', 'make_offer', 'make_pull', 'make_refuse',
47
+ 'IntakeStore', 'TransportBlobClient', 'BlobWire', 'partner_transport_peers',
48
+ ]
@@ -0,0 +1,57 @@
1
+ """The client blob_replication drives when a partner has no Blossom URL:
2
+ the same mirror/head calls, over the transport.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from typing import TYPE_CHECKING, Any, Mapping, Optional
7
+
8
+ from .messages import PULL_TIMEOUT, make_have, make_offer
9
+
10
+ if TYPE_CHECKING:
11
+ from .core import BlobWire
12
+
13
+ class TransportBlobClient:
14
+ """mirror_blob / head_blob over the transport, shaped like BlossomClient
15
+ so blob_replication.run() and verify() need not know which path a
16
+ candidate is. A candidate on this path is `peer:<pubkey>`."""
17
+
18
+ PREFIX = 'peer:'
19
+
20
+ def __init__(self, wire: 'BlobWire') -> None:
21
+ self.wire = wire
22
+
23
+ @classmethod
24
+ def is_peer(cls, server: str) -> bool:
25
+ return isinstance(server, str) and server.startswith(cls.PREFIX)
26
+
27
+ @classmethod
28
+ def peer_of(cls, server: str) -> str:
29
+ return server[len(cls.PREFIX):]
30
+
31
+ def mirror_blob(self, server: str, _url: str, sha256: str, **_kw: Any) -> Mapping[str, Any]:
32
+ peer = self.peer_of(server)
33
+ stat = self.wire.blobs.stat(sha256)
34
+ if stat is None:
35
+ raise ValueError('no blob %s here to offer' % sha256[:16])
36
+ reply = self.wire.wire_client.request(
37
+ peer,
38
+ make_offer(sha256, stat['size'], stat.get('type') or '', self.wire.chunk_bytes()),
39
+ timeout=PULL_TIMEOUT,
40
+ )
41
+ if reply is None:
42
+ raise TimeoutError('peer did not answer the offer')
43
+ m = reply.message
44
+ k = self.wire.wire_client.kind_of(m)
45
+ if k == 'refuse':
46
+ raise ValueError('refused: %s' % m.get('body', {}).get('why', '?'))
47
+ if k == 'have' and sha256 in [str(x).lower() for x in m.get('body', {}).get('sha256', [])]:
48
+ return {'sha256': sha256}
49
+ raise ValueError('peer answered %s, not have' % (k or m.get('type')))
50
+
51
+ def head_blob(self, server: str, sha256: str, **_kw: Any) -> Optional[Mapping[str, Any]]:
52
+ peer = self.peer_of(server)
53
+ reply = self.wire.wire_client.request(peer, make_have([sha256]), timeout=PULL_TIMEOUT)
54
+ if reply is None:
55
+ raise TimeoutError('peer did not answer have')
56
+ held = [str(x).lower() for x in reply.message.get('body', {}).get('sha256', [])]
57
+ return {'sha256': sha256} if sha256 in held else None