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,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
|