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,383 @@
|
|
|
1
|
+
"""Content-Addressed Storage (CAS) for blobs and IPFS CIDv1 interoperability.
|
|
2
|
+
|
|
3
|
+
Spec reference: spec.html section "Content Addressing & CIDs" (lines 3470-3476) and "Blobs" (lines 3768-3776).
|
|
4
|
+
- Blossom blobs are SHA-256 addressed.
|
|
5
|
+
- Maps directly to IPFS Content Identifiers (CIDs):
|
|
6
|
+
multibase 'b' (RFC 4648 Base32 lowercase), CIDv1 (0x01), raw binary codec (0x55), multihash sha2-256 (0x12, 0x20).
|
|
7
|
+
- Standard CAS on-disk sharded directory layout: `blobs/data/<sha256[:2]>/<sha256>`
|
|
8
|
+
(fanout depth 1). The depth is configurable (0..8 two-hex-char levels) so an
|
|
9
|
+
operator may point the store at a tree another CAS already lays out the same
|
|
10
|
+
way (magpie: `<data>/objects/<ab>/<cd>/<sha>`, depth 2) - see docs/sidecars.md.
|
|
11
|
+
The depth a directory was created with is recorded in `blobs.sqlite` and a
|
|
12
|
+
store opened with a different depth refuses rather than silently 404 every blob.
|
|
13
|
+
- SQLite metadata index in `blobs.sqlite` tracking size, content-type, uploader attribution, and upload timestamps.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import tempfile
|
|
21
|
+
import time
|
|
22
|
+
from typing import Optional, TypedDict
|
|
23
|
+
|
|
24
|
+
from fleetmesh_core.identity.cid import cid_to_sha256, sha256_to_cid
|
|
25
|
+
from node.compartment.schema import Column, Index, Migration, Table
|
|
26
|
+
from node.storage import SqliteStore
|
|
27
|
+
|
|
28
|
+
HEX64_RE = re.compile(r'^[0-9a-f]{64}$')
|
|
29
|
+
|
|
30
|
+
__all__ = ['BlobDescriptor', 'BlobMetadataStore', 'BlobStore', 'cid_to_sha256', 'sha256_to_cid']
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BlobDescriptor(TypedDict, total=False):
|
|
34
|
+
"""Metadata descriptor for a stored or indexed blob."""
|
|
35
|
+
sha256: str
|
|
36
|
+
size: int
|
|
37
|
+
type: str
|
|
38
|
+
uploaded: int
|
|
39
|
+
uploaded_by: Optional[str]
|
|
40
|
+
cid: str
|
|
41
|
+
pinned: bool
|
|
42
|
+
accessed_at: Optional[int]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BlobMetadataStore(SqliteStore):
|
|
46
|
+
"""Thread-safe SQLite metadata index for Content-Addressed Storage."""
|
|
47
|
+
|
|
48
|
+
SCHEMA = (
|
|
49
|
+
Table('blobs', (
|
|
50
|
+
Column('sha256', 'TEXT', primary_key=True),
|
|
51
|
+
Column('size', 'INTEGER', not_null=True),
|
|
52
|
+
Column('type', 'TEXT', not_null=True),
|
|
53
|
+
Column('uploaded_by', 'TEXT'),
|
|
54
|
+
Column('created_at', 'INTEGER', not_null=True),
|
|
55
|
+
Column('accessed_at', 'INTEGER'),
|
|
56
|
+
Column('pinned', 'INTEGER', default=0),
|
|
57
|
+
), indices=(
|
|
58
|
+
Index('idx_blobs_uploaded', ('uploaded_by',)),
|
|
59
|
+
)),
|
|
60
|
+
Table('meta', (
|
|
61
|
+
Column('key', 'TEXT', primary_key=True),
|
|
62
|
+
Column('value', 'TEXT', not_null=True),
|
|
63
|
+
)),
|
|
64
|
+
)
|
|
65
|
+
# A blobs table from before the LRU existed lacks both columns; the index
|
|
66
|
+
# over them can only be built once they are there, so it is the last step.
|
|
67
|
+
MIGRATIONS = (
|
|
68
|
+
Migration(1, up='ALTER TABLE blobs ADD COLUMN accessed_at INTEGER', name='blobs_accessed_at'),
|
|
69
|
+
Migration(2, up='ALTER TABLE blobs ADD COLUMN pinned INTEGER DEFAULT 0', name='blobs_pinned'),
|
|
70
|
+
Migration(3, up='CREATE INDEX IF NOT EXISTS idx_blobs_lru ON blobs(pinned, accessed_at)', name='idx_blobs_lru'),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def meta_get(self, key: str) -> Optional[str]:
|
|
74
|
+
row = self._one('SELECT value FROM meta WHERE key = ?', (key,))
|
|
75
|
+
return None if row is None else str(row[0])
|
|
76
|
+
|
|
77
|
+
def meta_set_if_absent(self, key: str, value: str) -> str:
|
|
78
|
+
"""Record ``value`` under ``key`` unless one is already there; return the stored one."""
|
|
79
|
+
self.execute('INSERT OR IGNORE INTO meta(key, value) VALUES (?, ?)', (key, value))
|
|
80
|
+
self.commit()
|
|
81
|
+
got = self.meta_get(key)
|
|
82
|
+
return value if got is None else got
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class LayoutMismatch(RuntimeError):
|
|
86
|
+
"""A blob directory was created with a different fanout depth than it is being opened with."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
MAX_FANOUT = 8
|
|
90
|
+
DEFAULT_FANOUT = 1
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class BlobStore:
|
|
94
|
+
"""Content-Addressed Storage engine for SHA-256 blobs and artifacts."""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
directory: str,
|
|
99
|
+
max_bytes: Optional[int] = None,
|
|
100
|
+
max_storage_bytes: Optional[int] = None,
|
|
101
|
+
fanout: int = DEFAULT_FANOUT,
|
|
102
|
+
data_dir: Optional[str] = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""``directory`` holds ``blobs.sqlite``; blobs live under ``data_dir``
|
|
105
|
+
(default ``<directory>/data``) at ``fanout`` two-hex-char levels.
|
|
106
|
+
|
|
107
|
+
``fanout``/``data_dir`` exist so a node may share a tree with another
|
|
108
|
+
CAS that uses the same sha256 layout. Shared disk is not a shared
|
|
109
|
+
catalogue - each index knows only what it wrote - and the other writer's
|
|
110
|
+
file modes/ownership must let this process read and unlink.
|
|
111
|
+
"""
|
|
112
|
+
if not isinstance(fanout, int) or isinstance(fanout, bool) or not 0 <= fanout <= MAX_FANOUT:
|
|
113
|
+
raise ValueError(f'blob fanout must be an integer 0..{MAX_FANOUT}, got {fanout!r}')
|
|
114
|
+
self.dir: str = os.path.realpath(directory)
|
|
115
|
+
self.data_dir: str = os.path.realpath(data_dir) if data_dir else os.path.join(self.dir, 'data')
|
|
116
|
+
self.db_path: str = os.path.join(self.dir, 'blobs.sqlite')
|
|
117
|
+
self.max_bytes: Optional[int] = max_bytes
|
|
118
|
+
self.max_storage_bytes: Optional[int] = max_storage_bytes
|
|
119
|
+
self.fanout: int = fanout
|
|
120
|
+
|
|
121
|
+
os.makedirs(self.dir, exist_ok=True)
|
|
122
|
+
os.makedirs(self.data_dir, exist_ok=True)
|
|
123
|
+
self.db: BlobMetadataStore = BlobMetadataStore(self.db_path)
|
|
124
|
+
recorded = self.db.meta_set_if_absent('fanout', str(fanout))
|
|
125
|
+
if recorded != str(fanout):
|
|
126
|
+
self.db.close()
|
|
127
|
+
raise LayoutMismatch(
|
|
128
|
+
f'{self.db.safe_path} was created with fanout {recorded}; opening with {fanout} '
|
|
129
|
+
f'would hide every stored blob. Keep the recorded depth or point at a new directory.')
|
|
130
|
+
recorded_dir = self.db.meta_set_if_absent('data_dir', self.data_dir)
|
|
131
|
+
if recorded_dir != self.data_dir:
|
|
132
|
+
self.db.close()
|
|
133
|
+
raise LayoutMismatch(
|
|
134
|
+
f'{self.db.safe_path} indexes blobs under {recorded_dir}, not {self.data_dir}. '
|
|
135
|
+
f'Keep the recorded data_dir or point at a new directory.')
|
|
136
|
+
|
|
137
|
+
def _blob_path(self, sha256_hex: str) -> str:
|
|
138
|
+
h = sha256_hex.lower()
|
|
139
|
+
levels = [h[2 * i:2 * i + 2] for i in range(self.fanout)]
|
|
140
|
+
return os.path.join(self.data_dir, *levels, h)
|
|
141
|
+
|
|
142
|
+
def put(
|
|
143
|
+
self,
|
|
144
|
+
data: bytes,
|
|
145
|
+
content_type: str = 'application/octet-stream',
|
|
146
|
+
uploaded_by: Optional[str] = None,
|
|
147
|
+
now: Optional[int] = None,
|
|
148
|
+
) -> BlobDescriptor:
|
|
149
|
+
"""Store a blob by content hash. Returns normative descriptor."""
|
|
150
|
+
if not isinstance(data, (bytes, bytearray)):
|
|
151
|
+
raise TypeError("blob data must be bytes")
|
|
152
|
+
|
|
153
|
+
size = len(data)
|
|
154
|
+
if self.max_bytes is not None and size > self.max_bytes:
|
|
155
|
+
raise ValueError(f"blob size {size} exceeds maximum limit {self.max_bytes}")
|
|
156
|
+
|
|
157
|
+
h = hashlib.sha256(data).hexdigest().lower()
|
|
158
|
+
now_ts = int(now if now is not None else time.time())
|
|
159
|
+
|
|
160
|
+
# Enforce quota watermark with automatic LRU eviction
|
|
161
|
+
if self.max_storage_bytes is not None and not self.has(h):
|
|
162
|
+
cur_total = self.total_bytes()
|
|
163
|
+
if cur_total + size > self.max_storage_bytes:
|
|
164
|
+
needed = (cur_total + size) - self.max_storage_bytes
|
|
165
|
+
self.evict_lru(needed_bytes=needed)
|
|
166
|
+
if self.total_bytes() + size > self.max_storage_bytes:
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f"CAS quota exceeded: cannot store {size} bytes (storage cap {self.max_storage_bytes} bytes)"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
dest = self._blob_path(h)
|
|
172
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
173
|
+
|
|
174
|
+
# Write atomically via tempfile in same filesystem directory
|
|
175
|
+
if not os.path.exists(dest):
|
|
176
|
+
with tempfile.NamedTemporaryFile(dir=os.path.dirname(dest), delete=False) as tf:
|
|
177
|
+
tf.write(data)
|
|
178
|
+
tmp_name = tf.name
|
|
179
|
+
os.replace(tmp_name, dest)
|
|
180
|
+
|
|
181
|
+
# Record metadata preserving pinned state if existing
|
|
182
|
+
self.db.execute('''
|
|
183
|
+
INSERT INTO blobs (sha256, size, type, uploaded_by, created_at, accessed_at, pinned)
|
|
184
|
+
VALUES (?, ?, ?, ?, ?, ?, COALESCE((SELECT pinned FROM blobs WHERE sha256 = ?), 0))
|
|
185
|
+
ON CONFLICT(sha256) DO UPDATE SET
|
|
186
|
+
size=excluded.size,
|
|
187
|
+
type=excluded.type,
|
|
188
|
+
accessed_at=excluded.accessed_at
|
|
189
|
+
''', (h, size, content_type, uploaded_by, now_ts, now_ts, h))
|
|
190
|
+
self.db.commit()
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
'sha256': h,
|
|
194
|
+
'size': size,
|
|
195
|
+
'type': content_type,
|
|
196
|
+
'uploaded': now_ts,
|
|
197
|
+
'uploaded_by': uploaded_by,
|
|
198
|
+
'cid': sha256_to_cid(h),
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
def get(self, sha256_hex: str) -> Optional[bytes]:
|
|
202
|
+
"""Read full blob bytes, verifying SHA-256 integrity."""
|
|
203
|
+
h = sha256_hex.lower()
|
|
204
|
+
if not HEX64_RE.match(h):
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
path = self._blob_path(h)
|
|
208
|
+
if not os.path.exists(path):
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
with open(path, 'rb') as f:
|
|
213
|
+
data = f.read()
|
|
214
|
+
# Verify integrity
|
|
215
|
+
if hashlib.sha256(data).hexdigest().lower() != h:
|
|
216
|
+
os.unlink(path)
|
|
217
|
+
return None
|
|
218
|
+
try:
|
|
219
|
+
self.db.execute('UPDATE blobs SET accessed_at = ? WHERE sha256 = ?', (int(time.time()), h))
|
|
220
|
+
self.db.commit()
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
return data
|
|
224
|
+
except OSError:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
def has(self, sha256_hex: str) -> bool:
|
|
228
|
+
"""Check if blob exists on disk and is indexed."""
|
|
229
|
+
h = sha256_hex.lower()
|
|
230
|
+
if not HEX64_RE.match(h):
|
|
231
|
+
return False
|
|
232
|
+
return os.path.exists(self._blob_path(h))
|
|
233
|
+
|
|
234
|
+
def stat(self, sha256_hex: str) -> Optional[BlobDescriptor]:
|
|
235
|
+
"""Retrieve metadata descriptor without loading blob content."""
|
|
236
|
+
h = sha256_hex.lower()
|
|
237
|
+
if not HEX64_RE.match(h):
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
row = self.db._one('''
|
|
241
|
+
SELECT size, type, uploaded_by, created_at, pinned, accessed_at FROM blobs WHERE sha256 = ?
|
|
242
|
+
''', (h,))
|
|
243
|
+
|
|
244
|
+
if not row:
|
|
245
|
+
if not os.path.exists(self._blob_path(h)):
|
|
246
|
+
return None
|
|
247
|
+
try:
|
|
248
|
+
sz = os.path.getsize(self._blob_path(h))
|
|
249
|
+
return {
|
|
250
|
+
'sha256': h,
|
|
251
|
+
'size': sz,
|
|
252
|
+
'type': 'application/octet-stream',
|
|
253
|
+
'uploaded': int(os.path.getmtime(self._blob_path(h))),
|
|
254
|
+
'uploaded_by': None,
|
|
255
|
+
'cid': sha256_to_cid(h),
|
|
256
|
+
'pinned': False,
|
|
257
|
+
}
|
|
258
|
+
except OSError:
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
size, ctype, uploaded_by, created_at, pinned, accessed_at = row
|
|
262
|
+
return {
|
|
263
|
+
'sha256': h,
|
|
264
|
+
'size': size,
|
|
265
|
+
'type': ctype,
|
|
266
|
+
'uploaded': created_at,
|
|
267
|
+
'uploaded_by': uploaded_by,
|
|
268
|
+
'cid': sha256_to_cid(h),
|
|
269
|
+
'pinned': bool(pinned),
|
|
270
|
+
'accessed_at': accessed_at,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
def delete(self, sha256_hex: str) -> bool:
|
|
274
|
+
"""Delete blob from disk and metadata index.
|
|
275
|
+
|
|
276
|
+
The digest is validated before it touches the filesystem: get/has
|
|
277
|
+
already refuse anything but 64 hex chars, and delete is the one
|
|
278
|
+
path here that unlinks, so '../..' must never reach _blob_path.
|
|
279
|
+
"""
|
|
280
|
+
h = sha256_hex.lower()
|
|
281
|
+
if not HEX64_RE.match(h):
|
|
282
|
+
return False
|
|
283
|
+
path = self._blob_path(h)
|
|
284
|
+
deleted = False
|
|
285
|
+
if os.path.exists(path):
|
|
286
|
+
try:
|
|
287
|
+
os.unlink(path)
|
|
288
|
+
deleted = True
|
|
289
|
+
except OSError:
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
cur = self.db.execute('DELETE FROM blobs WHERE sha256 = ?', (h,))
|
|
293
|
+
self.db.commit()
|
|
294
|
+
if cur.rowcount > 0:
|
|
295
|
+
deleted = True
|
|
296
|
+
|
|
297
|
+
return deleted
|
|
298
|
+
|
|
299
|
+
def list_by(
|
|
300
|
+
self,
|
|
301
|
+
uploaded_by: Optional[str] = None,
|
|
302
|
+
limit: int = 100,
|
|
303
|
+
offset: int = 0,
|
|
304
|
+
) -> list[BlobDescriptor]:
|
|
305
|
+
"""List stored blobs matching uploader pubkey."""
|
|
306
|
+
if uploaded_by:
|
|
307
|
+
rows = self.db._all('''
|
|
308
|
+
SELECT sha256, size, type, uploaded_by, created_at
|
|
309
|
+
FROM blobs WHERE uploaded_by = ?
|
|
310
|
+
ORDER BY created_at DESC LIMIT ? OFFSET ?
|
|
311
|
+
''', (uploaded_by, limit, offset))
|
|
312
|
+
else:
|
|
313
|
+
rows = self.db._all('''
|
|
314
|
+
SELECT sha256, size, type, uploaded_by, created_at
|
|
315
|
+
FROM blobs ORDER BY created_at DESC LIMIT ? OFFSET ?
|
|
316
|
+
''', (limit, offset))
|
|
317
|
+
|
|
318
|
+
out: list[BlobDescriptor] = []
|
|
319
|
+
for h, sz, ctype, uploader, ts in rows:
|
|
320
|
+
out.append({
|
|
321
|
+
'sha256': h,
|
|
322
|
+
'size': sz,
|
|
323
|
+
'type': ctype,
|
|
324
|
+
'uploaded': ts,
|
|
325
|
+
'uploaded_by': uploader,
|
|
326
|
+
'cid': sha256_to_cid(h),
|
|
327
|
+
})
|
|
328
|
+
return out
|
|
329
|
+
|
|
330
|
+
def total_bytes(self) -> int:
|
|
331
|
+
"""Total size in bytes of all indexed blobs."""
|
|
332
|
+
row = self.db._one('SELECT SUM(size) FROM blobs')
|
|
333
|
+
return int((row and row[0]) or 0)
|
|
334
|
+
|
|
335
|
+
def count(self) -> int:
|
|
336
|
+
"""Number of indexed blobs."""
|
|
337
|
+
row = self.db._one('SELECT COUNT(*) FROM blobs')
|
|
338
|
+
return int((row and row[0]) or 0)
|
|
339
|
+
|
|
340
|
+
def pin(self, sha256_hex: str) -> bool:
|
|
341
|
+
"""Pin a blob to prevent LRU eviction."""
|
|
342
|
+
h = sha256_hex.lower()
|
|
343
|
+
if not HEX64_RE.match(h) or not self.has(h):
|
|
344
|
+
return False
|
|
345
|
+
cur = self.db.execute('UPDATE blobs SET pinned = 1 WHERE sha256 = ?', (h,))
|
|
346
|
+
self.db.commit()
|
|
347
|
+
return cur.rowcount > 0
|
|
348
|
+
|
|
349
|
+
def unpin(self, sha256_hex: str) -> bool:
|
|
350
|
+
"""Unpin a blob, allowing LRU eviction."""
|
|
351
|
+
h = sha256_hex.lower()
|
|
352
|
+
if not HEX64_RE.match(h):
|
|
353
|
+
return False
|
|
354
|
+
cur = self.db.execute('UPDATE blobs SET pinned = 0 WHERE sha256 = ?', (h,))
|
|
355
|
+
self.db.commit()
|
|
356
|
+
return cur.rowcount > 0
|
|
357
|
+
|
|
358
|
+
def is_pinned(self, sha256_hex: str) -> bool:
|
|
359
|
+
"""Check if blob is pinned against LRU eviction."""
|
|
360
|
+
h = sha256_hex.lower()
|
|
361
|
+
row = self.db._one('SELECT pinned FROM blobs WHERE sha256 = ?', (h,))
|
|
362
|
+
return bool(row and row[0])
|
|
363
|
+
|
|
364
|
+
def evict_lru(self, needed_bytes: int = 0) -> int:
|
|
365
|
+
"""Evict oldest unpinned blobs by accessed_at timestamp to free needed_bytes.
|
|
366
|
+
|
|
367
|
+
Returns total bytes freed.
|
|
368
|
+
"""
|
|
369
|
+
rows = self.db._all(
|
|
370
|
+
'SELECT sha256, size FROM blobs WHERE pinned = 0 '
|
|
371
|
+
'ORDER BY COALESCE(accessed_at, created_at) ASC, created_at ASC'
|
|
372
|
+
)
|
|
373
|
+
freed = 0
|
|
374
|
+
for h, sz in rows:
|
|
375
|
+
if needed_bytes > 0 and freed >= needed_bytes:
|
|
376
|
+
break
|
|
377
|
+
if self.delete(h):
|
|
378
|
+
freed += sz
|
|
379
|
+
return freed
|
|
380
|
+
|
|
381
|
+
def close(self) -> None:
|
|
382
|
+
"""Safely close underlying metadata database."""
|
|
383
|
+
self.db.close()
|