Commit Graph
59 Commits
Author SHA1 Message Date
Omar Sobh 242ef527b4 Phase 5a: fingerprint + capture + restore for build-artifact cache
The substrate for the killer feature. Given a cargo workspace, compute
a deterministic 32-byte BLAKE3 fingerprint over the inputs that
determine what artifacts should be produced, then bundle the portable
subset of `target/<profile>/` into a zstd-compressed tarball ready to
hand to BlobStore.

## Module: cluster/build_cache.rs (661 lines)

Types:
- FingerprintInputs { cargo_lock, rustc_version_verbose, cargo_config,
  rust_toolchain, profile, features, rustflags, target_triple }
- Fingerprint(32 bytes) — parallel shape to BlobId

Public API:
- FingerprintInputs::collect(workspace, profile, features) — reads
  Cargo.lock, shells out to `rustc --version --verbose`, reads
  optional config files, extracts host triple, sorts+dedups features
- FingerprintInputs::compute() → Fingerprint — domain-separated
  BLAKE3 with per-field labels + null separators so field boundaries
  can't collide
- capture_target(target_dir) → zstd-tarball bytes
- restore_target(bytes, target_dir) → unpacks
- capture_workspace(workspace, profile) — resolves target dir
- compute_workspace_fingerprint(workspace, profile, features) —
  returns (inputs, fingerprint)

Captured: deps/, .fingerprint/, build/, examples/, plus small
top-level files (.cargo-lock, .rustc_info.json, CACHEDIR.TAG).
Explicitly NOT captured: incremental/ (per-machine, not portable —
tied to absolute paths + rustc state; restoring on another host
silently corrupts the build).

Determinism guarantees:
- HeaderMode::Deterministic on the tar builder — identical trees
  produce byte-identical tarballs (proven by
  `capture_yields_identical_bytes_for_identical_input`)
- follow_symlinks(false) — symlinks archived as symlinks, not their
  targets, so the fingerprint doesn't drift with symlink destinations
- Features sorted + deduped so `["b","a"]` and `["a","b"]` hash the same
- Missing optional files treated as empty strings so `absent ==
  empty` (add-then-remove doesn't churn the hash)

Deps added: tar 0.4, zstd 0.13.

## Tests (18 new, all real filesystem — no mocks)

Fingerprint (7):
- fingerprint_is_deterministic
- fingerprint_changes_when_cargo_lock_changes
- fingerprint_changes_when_profile_changes
- fingerprint_changes_when_features_change
- fingerprint_is_feature_order_independent (proves sort semantics)
- fingerprint_domain_separation_prevents_field_collision — swap
  content between two string fields; naive concat hasher would
  collide, ours doesn't
- fingerprint_hex_length_and_stability

Input collection (4):
- read_optional_returns_empty_for_missing
- read_optional_returns_content_for_existing
- collect_errors_when_cargo_lock_absent
- collect_reads_cargo_lock_and_computes — actually shells out to
  `rustc --version --verbose`, verifies triple extraction

Capture/restore (5):
- capture_target_errors_when_dir_missing
- capture_and_restore_round_trip_preserves_files — full 6-file
  tree including a captured example + an intentionally-excluded
  incremental/ dir; verifies incremental/ is absent after restore
- capture_yields_identical_bytes_for_identical_input — byte-equal
  tarballs across two identical source trees
- capture_skips_top_level_files_not_in_allowlist
- capture_workspace_resolves_profile_dir

End-to-end (1):
- **end_to_end_fingerprint_capture_blob_restore** — the full
  workspace → fingerprint → capture → BlobStore::put_bytes →
  BlobStore::get_bytes → restore_target loop. Proves the whole
  round-trip lands byte-equal for both captured file trees, and
  the BlobId is deterministic across runs. This is the primitive
  the cargo-cache CLI sits on top of.

174 tests pass. Pre-existing macOS-only failure unchanged.

File sizes (well under 1300-line ceiling):
- cluster/build_cache.rs: 661
- cluster.rs (submodule declarations): 279

## What this unlocks

Phase 5a is the substrate. The CLI wrapper (`claw-cargo build`) is
Phase 5b — thin glue that:
  1. Runs compute_workspace_fingerprint()
  2. Asks the peer BlobStat(fingerprint_as_blob_id)
  3. Hit → BlobGetStream + restore_target + cargo build (just the
     workspace's own crates, ~seconds)
  4. Miss → cargo build (full), then capture_target +
     push_blob_missing_chunks + PutManifest

Every piece of infrastructure that Phase 5b needs — content-addressed
blob store, streaming and partial-chunk RPC, mTLS transport, gossip-
driven peer discovery — is already merged. Phase 5b is CLI polish,
not new distributed-systems machinery.

## Follow-on

- Phase 5b: claw-cargo CLI wrapper (small — 200-400 lines)
- Phase 5c: pre-fetch on Gitea webhook (workflow triggers → daemon
  pre-warms the fingerprint on the target runner)
- Phase 3 remains a parallel track — human-readable namespace layer
  on top of raw fingerprints so operators can pin
  `clawverse:main:latest-cache` instead of a hex string
2026-07-11 23:27:57 -07:00
Omar Sobh 2e984b924d Phase 2d: chunk-level RPC (HasChunk / PutChunk / GetChunk / PutManifest)
Unlocks partial-sync replication — a peer that already has some
chunks of a blob (typical when two nodes share overlapping cargo
build caches) only receives the chunks it's missing.

## New methods

| Byte | Method | Payload | Reply |
|---|---|---|---|
| 0x09 | HasChunk | 32-byte ChunkHash | STREAM_STATUS_OK / NotFound |
| 0x0a | PutChunk | ChunkHash \|\| bytes | STREAM_STATUS_OK / error |
| 0x0b | GetChunk | ChunkHash | STREAM_STATUS_OK \|\| bytes / NotFound |
| 0x0c | PutManifest | JSON BlobManifest | JSON PutManifestReply |

`PutManifestReply { blob_id, missing: Vec<ChunkHash> }`: empty
`missing` means the manifest was written; non-empty tells the
client which chunks to upload before retrying.

Server verifies bytes hash to claimed hash on PutChunk; a
mismatch surfaces as InvalidRequest and the store is untouched.

## BlobStore additions

- `has_chunk(&ChunkHash) → bool`
- `read_chunk(&ChunkHash) → Option<Vec<u8>>` — verifies hash on read
- `put_chunk(&ChunkHash, bytes) → Result<()>` — verifies bytes-vs-hash
- `put_manifest_verified(&manifest) → Result<Vec<ChunkHash>>` —
  returns the list of chunks missing on disk (empty on success)
- `chunk_path` promoted to `pub` for advanced callers

## Client helpers

- `call_has_chunk` / `call_put_chunk` / `call_get_chunk` / `call_put_manifest`
- `push_blob_missing_chunks(conn, local_store, blob_id) →
   Result<(uploaded, total)>` — high-level partial-sync helper

`push_blob_missing_chunks` loads the local manifest, calls HasChunk
for each chunk, uploads only the missing ones via PutChunk, then
commits via PutManifest. On a fully-overlapping cache the uploaded
count is 0 and only the ~small manifest crosses the wire.

## Tests (17 new, all real filesystem + real QUIC — no mocks)

Blob store (6):
- has_chunk_is_false_before_put_and_true_after
- read_chunk_returns_bytes_and_none_when_missing
- put_chunk_rejects_hash_mismatch (nothing written)
- read_chunk_detects_corruption (bit-flip → mismatch error)
- put_manifest_verified_reports_missing_chunks
- put_manifest_verified_writes_when_all_chunks_present

Router dispatch (7):
- phase_2d_method_byte_encoding
- method_reports_streaming_variants — extended for 4 new methods
- has_chunk_returns_ok_for_present_and_not_found_for_missing
- put_chunk_stores_and_returns_status_ok
- put_chunk_rejects_hash_mismatch_over_wire
- get_chunk_returns_content_prefixed_with_status_ok
- get_chunk_returns_not_found_for_missing
- put_manifest_reports_missing_chunks_when_incomplete
- put_manifest_writes_when_chunks_present
- chunk_rpcs_return_not_configured_without_store

End-to-end (2):
- **end_to_end_push_blob_missing_chunks_replicates_only_needed_bytes**:
  Peer A pre-seeded with chunk 0 of a 2-chunk (8 MiB) blob;
  `push_blob_missing_chunks` reports `(uploaded=1, total=2)`,
  only chunk 1 crosses the wire, A's store then contains the
  complete blob and `get_bytes` returns byte-equal content.
- **call_get_chunk_verifies_returned_hash**: real 2-node fetch,
  client hashes received bytes and compares to requested hash.

156 tests pass. Pre-existing macOS-only failure unchanged.

File sizes (all under 1300-line ceiling):
- cluster/rpc.rs: 1053
- cluster/rpc/tests.rs: 940
- cluster/blob.rs: 1186

## Where this fits

With Phase 2c whole-blob streaming + Phase 2d partial-chunk sync,
the storage substrate is now genuinely bandwidth-efficient in the
distributed setting:

- First-ever push of a blob: `push_blob_missing_chunks` uploads
  everything (all chunks missing).
- Second push of a similar blob (95% chunk overlap with prior
  contents): only the 5% new chunks cross the wire, plus a tiny
  manifest.
- Whole-blob download: BlobGetStream, bounded by network bandwidth.

## Follow-on

- Phase 3: CRDT metadata for human-readable namespaces on top of
  content hashes.
- Phase 5: the killer feature. Fingerprint cargo target dir → tar
  → hash → PutBlobStream (or push_blob_missing_chunks if a similar
  build already lives on the peer). Same fingerprint on the next
  node → BlobGetStream. This is the whole cargo-cache design in
  one line and it now sits on a substrate that handles all the
  hard cases (dedup, verification, resumability, partial sync).
2026-07-11 23:22:25 -07:00
Omar Sobh 1fd1027da4 Phase 2c: streaming Blob RPC (BlobPutStream / BlobGetStream)
Removes the 16 MiB message cap for blob transfers. The bounded Blob*
methods from Phase 2b still exist; the streaming variants let a peer
push or pull a many-GB blob without either side holding it in memory.

## Wire format

Streaming methods use a slightly different reply shape so the client
can route on the first byte alone:

  Reply : status:u8 || payload:bytes...

Where `status` is either `STREAM_STATUS_OK` (0x00, content follows)
or a single-byte ErrorCode. `serve_connection` now peeks at the
method tag byte via read_exact and hands streaming methods the raw
send/recv streams; bounded methods still use the old read_to_end
path.

## Method additions

- BlobPutStream (0x07): client streams bytes → server pipes into
  BlobStore::put_stream → reply is 0x00 || 32-byte BlobId
- BlobGetStream (0x08): client sends 32-byte BlobId → server verifies
  existence, writes 0x00 status, then streams chunks from disk into
  the send stream

Method::is_streaming() introspection so callers can decide which
wire variant to use.

## BlobStore additions

- put_stream<R: AsyncRead + Unpin>(reader) -> BlobId
  Memory ceiling: one CHUNK_SIZE (4 MiB) buffer regardless of blob
  size. Handles short-reads correctly (loops until CHUNK_SIZE bytes
  are available or EOF), including the empty-reader case (produces
  the empty-blob BlobId, zero chunks).

- stream_to<W: AsyncWrite + Unpin>(id, writer) -> bool
  Ok(false) on NotFound (writer untouched). Verifies each chunk hash
  before emitting; corruption halts mid-stream with Err.

## Client helpers

- call_blob_put_stream(conn, reader) -> Result<BlobId>
  Uses tokio::io::copy directly onto quinn's SendStream.
- call_blob_get_stream(conn, id, writer) -> Result<bool>
  Ok(false) on NotFound; other errors surface as Err.

## Tests (11 new, all real — no mocks)

Blob store (6):
- put_stream_produces_same_hash_as_put_bytes (3-chunk blob via Cursor)
- put_stream_handles_empty_reader (produces empty-blob BlobId)
- put_stream_handles_short_reads (custom Trickle reader that only
  serves 100 bytes per read call — must still assemble full chunks)
- stream_to_writes_full_blob (2-chunk write to Vec<u8>)
- stream_to_returns_false_when_missing (writer untouched)
- stream_to_detects_chunk_corruption (bit-flip a chunk → Err with
  "chunk hash mismatch")

RPC (5):
- method_reports_streaming_variants
- end_to_end_stream_put_and_get_over_real_quic — 12 MiB + 777 bytes
  → 4 chunks, real 2-node QUIC + mTLS + stream round-trip
- stream_get_returns_false_for_missing_blob
- stream_methods_return_not_configured_without_store
- stream_put_deduplicates_with_prior_put_bytes — verify streaming
  put produces the same BlobId as a prior bounded put on identical
  content, and the manifest chunk count didn't fork

## Housekeeping

rpc.rs was tipping over the 1300-line ceiling with the streaming
handlers + helpers + tests. Tests split into `cluster/rpc/tests.rs`
via `#[path = "rpc/tests.rs"] mod tests;`. Result:
- rpc.rs: 748 lines
- rpc/tests.rs: 694 lines
- blob.rs: 1002 lines
- All under ceiling.

139 tests pass. Pre-existing macOS-only failure unchanged.

## What's next

- Phase 2d: chunk-level RPC (BlobPutChunk / BlobGetChunk) so a
  receiver can `LoadManifest` then request only the chunks it's
  missing — big bandwidth win on partially-overlapping caches.
- Phase 3: CRDT metadata for human-readable namespaces on top of
  content hashes.
- Phase 5: the killer feature — fingerprint the cargo target dir,
  BlobPutStream it, next node BlobGetStream by the same fingerprint.
  Now buildable directly on Phase 2c since target dirs run 100 MB
  to a few GB and the previous 16 MiB cap would have blocked us.
2026-07-11 23:14:11 -07:00
Omar Sobh f43b34ad15 Phase 2b: Blob RPC (Stat/Get/Put/LoadManifest)
Wires the Phase 2 content-addressed blob store onto the network via
four new methods on the existing RpcRouter. Combined with the mTLS +
gossip stack from Phase 1c-1e, peers can now exchange content-addressed
blobs over real QUIC. This is the substrate the fingerprint-keyed cargo
cache (Phase 5) sits on directly.

New methods:
- BlobStat        (0x03): payload = 32-byte BlobId, reply = JSON BlobStat
- BlobGet         (0x04): payload = 32-byte BlobId, reply = raw bytes
- BlobPut         (0x05): payload = raw bytes, reply = 32-byte BlobId
- BlobLoadManifest (0x06): payload = 32-byte BlobId, reply = JSON manifest

New error codes:
- NotFound (0xf3)      — the requested BlobId isn't in the local store
- InvalidRequest (0xf4) — e.g. non-32-byte payload for a hash-keyed method
- NotConfigured (0xf5) — Blob* called on a router without an attached store

Wire-format bump: MAX_MESSAGE_BYTES 16 KiB → 16 MiB so a single 4 MiB
chunk (plus JSON overhead) fits comfortably. Anything above 16 MiB
needs the streaming variants coming in Phase 2c.

Client helpers:
- call_blob_stat / call_blob_get / call_blob_put / call_blob_load_manifest
- All map ErrorCode::NotFound to Ok(None), other codes to Err.
- call_blob_put verifies the peer-assigned BlobId matches local blake3
  hash of the payload — corruption or protocol drift surfaces
  immediately instead of silently accepting a mismatched receipt.

Router changes:
- RpcRouter grows an Option<Arc<BlobStore>> via with_blob_store(store).
- handle() split into handle_outcome() → HandlerOutcome enum
  {Reply(bytes) | Error(ErrorCode)} for cleaner control flow across
  the growing method set.

Services / daemon:
- ClusterServices::start gains blob_store_root: Option<PathBuf>.
- ClusterConfig gains blob_store_root: Option<PathBuf>.
- daemon.rs reads it from cluster_cfg + passes through.
- New ClusterServices::blob_store_enabled() introspection.

Tests (11 new, all real — no mocks):

Router (7 new):
- method_round_trips_byte_encoding — updated for 6 methods
- error_code_describe_covers_all_variants — updated for 6 codes
- decode_error_covers_all_known_codes
- blob_rpcs_return_not_configured_without_store — all four Blob*
  methods return NotConfigured when the router lacks a store
- blob_stat_returns_not_found_for_missing
- blob_stat_returns_json_for_existing
- blob_stat_returns_invalid_request_for_bad_length
- blob_get_returns_content_bytes
- blob_put_stores_bytes_and_returns_hash — verifies BlobId matches
  independent local hash
- blob_load_manifest_returns_json_for_existing (2-chunk case)
- blob_load_manifest_returns_not_found_for_missing

End-to-end over real QUIC (2 new):
- end_to_end_blob_put_stat_get_over_real_quic — full 4-method loop
  (Put → Stat → Get → LoadManifest) + NotFound path
- end_to_end_multi_chunk_blob_over_real_quic — 6 MiB blob → 2 chunks,
  proves MAX_MESSAGE_BYTES bump took effect

Services (1 new):
- services_with_blob_store_serves_blob_rpc_end_to_end — cut CA, sign
  leaves, config includes blob_store_root, start ClusterServices,
  dial from B over persisted mTLS, put + get through the router,
  then independently verify bytes landed on A's on-disk store

Also fixed a parallel-test port collision: services `next_port()`
now increments by 2 so `port + 1` (the RPC bind) is reserved
alongside `port` (the gossip bind).

128 tests pass. Pre-existing macOS-only failure unchanged.

File sizes (all under 1300-line ceiling):
- cluster/rpc.rs: 1006
- cluster/services.rs: 535
- cluster/blob.rs: 802
- config.rs: 511
- daemon.rs: 265

Follow-on:
- 2c: streaming variants (AsyncRead/AsyncWrite) so a many-GB blob
  transfers without holding it in memory
- 2d: chunk-level RPC (BlobPutChunk / BlobGetChunk) so a receiver
  can request only chunks it's missing after LoadManifest
- Phase 5 (the killer feature) can now build on Phase 2b directly —
  fingerprint the target dir, PutBlob the compressed tarball, and
  next node calls GetBlob keyed by the same fingerprint hash.
2026-07-11 22:42:17 -07:00
Omar Sobh eab10005fd Phase 2: content-addressed blob store
The storage substrate everything after Phase 1 sits on. Every blob is
identified by its BLAKE3 whole-content hash (BlobId); on disk it lives
as an ordered sequence of BLAKE3-hashed 4 MB chunks, so blobs that
share a prefix (two cargo target dirs with 95% of the same deps) share
storage at chunk granularity with no special detection logic.

New: cluster/blob.rs (802 lines).

Types:
- BlobId — 32-byte BLAKE3 output, hex-serialised (serde ↔ string)
- ChunkHash — same shape as BlobId but a distinct type so blob and
  chunk lookups can't accidentally swap
- BlobStat — { total_size, chunk_count }
- BlobManifest — { blob_id, total_size, chunks: Vec<ChunkHash> },
  public because Phase 2b RPC serves it directly so a receiver can
  request only the chunks it's missing
- GcReport — { chunks_scanned, chunks_removed, bytes_reclaimed }
- BlobStore — root-directory-based store

Public API:
- BlobStore::open(root)
- put_bytes(&[u8]) → BlobId
- get_bytes(&BlobId) → Option<Vec<u8>>  (verifies hash + size on read)
- contains(&BlobId) → bool
- stat(&BlobId) → Option<BlobStat>
- load_manifest(&BlobId) → Option<BlobManifest>
- delete_manifest(&BlobId) → bool   (chunks stay; orphan by GC)
- gc_orphan_chunks() → GcReport

On-disk layout:
  <root>/
    blobs/<bb>/<blob_hash>.manifest.json
    chunks/<cc>/<chunk_hash>
    .tmp/
Two-char bucket prefixes cap fan-out at 256 entries per level — safe
on a warm-tier ZFS dataset with tens of thousands of blobs.

Every write is atomic (tmp file + rename on same filesystem).
Every chunk write is a no-op if the file already exists — same
content across two put()s stores exactly one physical copy.

Correctness:
- Reads verify each chunk against its hash + recompute the whole-blob
  hash before returning; a bit-flipped chunk raises "chunk hash
  mismatch" instead of silently corrupting the answer.
- delete_manifest is the only deletion primitive; chunks are only
  ever removed by gc_orphan_chunks after a full manifest scan proves
  they're unreferenced.

Dep: blake3 = "1".

Tests (21 new, all real filesystem, no mocks):
- BlobId/ChunkHash hex round-trip + serde JSON
- BlobId::from_hex rejects wrong-length + non-hex input
- open creates blobs/, chunks/, .tmp/
- put + get round trip: small, empty, 10 MB (3 chunks)
- put is deterministic (same bytes → same BlobId every time)
- put is idempotent (writing twice → exactly one manifest file)
- Different content → different BlobId
- shared_chunks_are_stored_only_once: two blobs sharing a 4 MB prefix
  produce exactly 3 chunk files, not 4
- get_returns_none_when_missing / contains false / stat None
- delete_manifest keeps chunks (proven by counting chunk files)
- delete_manifest on missing returns false
- gc_reclaims_orphan_chunks_but_keeps_referenced: put 2 blobs, delete
  one manifest, GC removes exactly the orphaned chunk, keeps
  live A readable
- gc_on_empty_store_reports_zero
- corrupted_chunk_detected_on_read: rewrite a chunk with garbage →
  get_bytes errors with "chunk hash mismatch"
- load_manifest round-trips the chunk list

116 tests pass. Pre-existing macOS-only failure unchanged.

File size: cluster/blob.rs = 802 lines (ceiling 1300).

Follow-on Phase 2 cuts:
- 2b: RPC methods BlobStat / BlobGet / BlobPut, wired into RpcRouter
  and served over the QUIC transport built in Phase 1c-1e.
- 2c: streaming put/get (AsyncRead / AsyncWrite variants) for
  many-GB build artifacts.

Phase 3 (metadata + CRDTs) can start independently — this store is the
substrate the fingerprint cache in Phase 5 layers onto.
2026-07-11 22:32:59 -07:00
Omar Sobh 2ae079fbd4 Phase 1e: daemon-integrated cluster services + PeerStatus RPC
Ties Phase 1a-1d together into a live subsystem the daemon actually
runs. When `[cluster]` is present in config, `claw-store daemon` now:

  1. Starts chitchat gossip via ClusterServices::start.
  2. Publishes hot.max_gb immediately and re-measures the hot dir
     every 30s, updating `clawstor.hot.used`.
  3. If `[cluster.tls]` is also configured, loads NodeIdentity from
     PEM files and binds a QUIC RPC server that accepts + dispatches
     incoming Ping / PeerStatus requests via RpcRouter.

New modules:

- cluster/rpc.rs (510 lines):
  - Method enum (Ping=0x01, PeerStatus=0x02)
  - ErrorCode enum (EmptyRequest / UnknownMethod / HandlerFailure)
  - PeerStatusReply { local_name, local_zone, peers: Vec<PeerView> }
  - RpcRouter — dispatch state (holds Arc<ClusterGossip> + local
    name/zone)
  - rpc_call / call_ping / call_peer_status — client helpers
  - serve_connection — server accept-bidi loop
  - Wire format: `method:u8 || payload:bytes` → `reply:bytes` or
    single-byte ErrorCode

- cluster/services.rs (418 lines):
  - ClusterServices { gossip, accept_task, metric_task }
  - start(cluster_cfg, name, hot_dir, hot_max_bytes) → bootstraps
    everything above
  - shutdown() aborts background tasks cleanly
  - Recursive dir-walker (spawn_blocking) for hot-tier metric

PeerView now derives Serialize/Deserialize so it round-trips through
JSON over the RPC.

daemon.rs integration (~35 lines added):
  - Bootstraps ClusterServices before entering the select loop
  - Held for daemon lifetime
  - Shutdown on SIGTERM
  - Gossip-less config still runs standalone (backwards compat)

CLI: `cluster-peer-status --peer <name> --rpc-addr <addr>
                          --tls-dir <dir>`
  Loads a persistent identity, dials the peer, calls PeerStatus,
  prints the peer's local view as a table.

Tests (15 new, all real — no mocks, real UDP + TLS + JSON round trip):

RPC (9):
- method_round_trips_byte_encoding
- dispatch_returns_pong_for_ping
- dispatch_returns_json_for_peer_status
- dispatch_returns_empty_request_error
- dispatch_returns_unknown_method_error
- rpc_call_rejects_oversize_payload (with real quinn connection)
- end_to_end_ping_and_peer_status_over_real_quic — full 2-node quinn
  with mTLS + both RPCs
- peer_status_reflects_peer_gossip_state — 2 gossip instances converge,
  RPC caller from a third identity sees the converged view
- error_code_describe_covers_all_variants

Services (6):
- dir_bytes_sync_returns_zero_for_missing_path
- dir_bytes_sync_sums_recursive_file_sizes (3-level nesting)
- services_start_without_tls_leaves_rpc_disabled
- services_start_with_tls_serves_rpc_end_to_end — full stack: cut CA on
  disk, sign leaves, start ClusterServices for A, dial from B via
  persisted mTLS, run ping + PeerStatus over the wire
- services_publish_hot_used_metric_periodically
- services_gossip_sees_peer_after_convergence

95 tests pass. Pre-existing macOS-only failure unchanged.

File sizes (all under 1300-line ceiling):
- cluster/rpc.rs: 510
- cluster/services.rs: 418
- cluster/gossip.rs: 579
- cluster/transport.rs: 916
- daemon.rs: 262
- main.rs: 749

Phase 1 done end-to-end. `claw-store daemon` now boots a real distributed
cluster stack when config asks for one; peers can call each other's
PeerStatus RPC and see live membership. Next: Phase 2 (content-addressed
blob store) can hook new RPC methods into the same RpcRouter with a
one-line dispatch arm.
2026-07-11 22:15:41 -07:00
Omar Sobh 916add37df Phase 1d: persistent NodeIdentity + FleetCa + fleet-ca CLI
Closes out Phase 1. A production operator can now cut a fleet CA,
sign per-node leaves, drop the resulting PEMs at
/etc/claw-store/tls/, point [cluster.tls] at them, and the daemon
loads real mTLS material on startup — no more ephemeral in-process
CA hack.

New public API in cluster::transport:
- FleetCa::generate(cn) — new self-signed root CA
- FleetCa::save(dir) / FleetCa::load(dir) — round-trip PEM
- FleetCa::sign_leaf(name) — mint an in-memory NodeIdentity
- FleetCa::sign_leaf_to_pem(name, out_dir) — write ca.crt + node.crt +
  node.key (node.key at 0o600 on Unix)
- NodeIdentity::from_pem_files(ca, cert, key) — production load path
- NodeIdentity::from_pem_dir(dir) — canonical filename layout
- NodeIdentity::from_cluster_config(cfg) — pick up [cluster.tls] paths

Manual Debug for FleetCa redacts the private key.

Config extension:
- [cluster.tls] ca_cert / node_cert / node_key (all PathBuf).
- Optional at the top level; callers that need mTLS surface a clear
  error when it's absent.

Deps:
- rcgen features += "x509-parser" (for FleetCa::load's from_ca_cert_pem).
- rustls-pemfile 2 (parse PEM back into DER for rustls).

CLI (new commands; short-circuit config load so they run on fresh
boxes without /etc/claw-store/config.toml):
- fleet-ca-init --dir <dir> [--cn <name>]
    generates ca.crt + ca.key (both 0o600).
- fleet-ca-sign --ca-dir <dir> --node <name> --out-dir <dir>
    writes ca.crt + node.crt + node.key (node.key at 0o600).
- cluster-ping now accepts --tls-dir <dir> to load persistent
  NodeIdentity from disk (produced by fleet-ca-sign).

Tests (8 new, all real — no mocks, real filesystem, real TLS handshake):
- fleet_ca_rejects_empty_common_name
- fleet_ca_save_and_load_round_trip_preserves_signing (asserts 0o600
  on ca.key)
- fleet_ca_load_errors_when_files_missing
- sign_leaf_to_pem_writes_all_three_files_with_correct_permissions
  (asserts 0o600 on node.key)
- persistent_identity_round_trips_through_disk_and_pings — end-to-end:
  cut CA on disk, reload it, sign two leaves via sign_leaf_to_pem,
  reload them via from_pem_dir, run real QUIC ping/pong. This is the
  operator flow.
- node_identity_from_cluster_config_errors_without_tls_section
- node_identity_from_cluster_config_loads_pem_paths
- from_pem_files_errors_on_missing_ca_file

80 tests pass. Pre-existing macOS-only hot test unchanged.

Also verified live CLI smoke test:
  fleet-ca-init → ca.crt + ca.key at 0o600
  fleet-ca-sign → ca.crt + node.crt + node.key at 0o600
  Files parse as valid X.509.

File sizes (all under 1300-line ceiling):
- cluster/transport.rs: 916
- cluster/gossip.rs: 576
- cluster.rs: 275
- config.rs: 503
- main.rs: 662

Phase 1 complete. Next up:
- Phase 1e (daemon integration): gossip + QUIC RPC server wired into
  claw-store daemon; hot-tier metrics periodically pushed; a real
  PeerStatus RPC alongside ping.
- Phase 2: content-addressed blob store (BLAKE3 chunking, put/get).
2026-07-11 22:04:54 -07:00
Omar Sobh 4d93f955c5 Phase 1c: QUIC transport with fleet-CA mTLS + cluster-ping
Wraps quinn 0.11 in cluster/transport.rs with rustls 0.23 + rcgen 0.13
for identity management. Every peer connection is mTLS: both sides must
present certs signed by the shared fleet CA, and rustls verifies the
peer's cert SAN matches the requested server name.

Public API on `cluster::transport`:
- `NodeIdentity` (cert chain + private key + trusted CA)
- `NodeIdentity::generate_test_pair(a, b)` — ephemeral CA + two signed
  leaves; shape matches the production PEM-file loader (Phase 1d)
- `QuicServer::bind(addr, identity)` — bind mTLS-enforced listener
- `QuicServer::accept()` — accept one connection (Option<Result<_>>)
- `QuicClient::new(local_addr, identity)` — build client endpoint
- `QuicClient::connect(peer_addr, expected_name)` — outbound with SAN check
- `ping(&conn, payload)` — bidi stream RPC; server echoes as `pong:<payload>`
- `ping_handler_loop(conn)` — server-side accept/echo forever
- `CLAWSTOR_RPC_ALPN` constant, single ALPN "clawstor-rpc/1"

Config extension:
- `bind_rpc_lan`, `bind_rpc_tailscale` on `ClusterConfig` (both optional).
- Defaults: gossip port + 1 (so gossip UDP and QUIC UDP don't collide).
- Helper: `ClusterConfig::rpc_lan()`, `rpc_tailscale()`,
  `PeerEntry::rpc_lan()`, `rpc_tailscale()`.

Gossip.rs now advertises the RPC address, not the gossip address, on
the well-known `clawstor.rpc.lan` / `clawstor.rpc.tailscale` keys.

CLI:
- `cluster-ping --name <me> --peer <you> --rpc-addr <addr> [--payload X]`
  Runs a full mTLS handshake and single ping. For dev/loopback use
  today (both sides need to share a CA); persistent-identity ping
  lands in Phase 1d.

Tests (6 new, real UDP + TLS handshake, no mocks):
- generate_test_pair produces two distinct leaves that share a CA
- ping_pong_between_two_mtls_peers: real 2-node QUIC round trip with
  full mTLS chain verification, `open_bi()`/`accept_bi()`, byte-exact
  response check
- client_rejects_peer_with_wrong_ca: TLS chain verification failure
  when the server presents a cert signed by a different CA
- client_rejects_wrong_server_name: SAN mismatch is enforced
- server_binds_wildcard_and_reports_concrete_local_addr: port 0 → real
- ping_rejects_oversize_payload: MAX_MESSAGE_BYTES cap enforced client-side

Fixed-port allocator range 42000+ so transport tests don't conflict
with gossip tests (41000+).

72 tests pass. Pre-existing `hot::test_project_target_size_bytes`
macOS-only failure unchanged.

File sizes (all under 1300-line ceiling):
- cluster/transport.rs: 485
- cluster/gossip.rs: 570
- cluster.rs: 275
- config.rs: 480
- main.rs: 564

Follow-on Phase 1 cut (1d):
- Load NodeIdentity from persistent PEM files at /etc/claw-store/tls/
- Fleet CA bootstrap ceremony (rcgen → write CA cert; per-node leaf CSR)
- Daemon-level RPC server that runs alongside gossip + serves real
  operations (blob get/put, metadata sync)
- cluster-status reads from live daemon via API instead of standalone
2026-07-11 21:50:28 -07:00
Omar Sobh 5283ab8655 Phase 1b: chitchat SWIM gossip layer + cluster-status CLI
Wraps chitchat 0.11 in cluster/gossip.rs. Each node publishes its
zone, RPC endpoints, hot-tier usage, warm-tier project list, and
uptime under well-known kv keys; peers propagate this via gossip and
are classified alive/dead by phi-accrual failure detection.

Bootstraps from static [[cluster.peers]] seed nodes in the config.
Membership extends dynamically as nodes join or leave.

Public API on `ClusterGossip`:
- bootstrap(cluster_cfg, local_name) — start UDP gossip service
- set(key, value), set_hot_used, set_hot_max, set_warm_projects
- peers() — all known peers with typed PeerView + liveness
- peer(name) — one peer by advertised name
- peers_in_zone(zone) — live peers filtered by zone
- shutdown() — abort gossip task

CLI:
- `claw-store cluster-status --name <me> [--wait-secs N]`
  bootstraps gossip, waits for convergence, prints a peer table
  with zone, alive/dead, RPC LAN/Tailscale addresses, hot usage.

Tests (7 new):
- bootstrap_publishes_our_own_state — self-ID + advertised address
- bootstrap_fails_when_local_name_empty
- bootstrap_fails_when_no_bind_address — cluster.validate error chain
- two_node_cluster_converges_and_shares_state — REAL two-node
  in-process UDP gossip. Publish state on A, wait for phi-accrual
  liveness on B, verify all fields propagated (zone, rpc_lan, hot
  bytes, warm_projects list, fill ratio, zone filtering).
- peers_excludes_self — solo cluster reports zero peers
- peer_view_hot_fill_ratio_handles_missing_or_zero

No mocks. Real UDP transport, real chitchat runtime, real gossip
protocol. Ports allocated from a fixed range (41001+) via atomic
counter — deterministic and race-free within the test binary.

66 tests pass. Pre-existing `hot::test_project_target_size_bytes`
macOS-only failure unchanged.

File sizes (under 1300-line ceiling):
- cluster/gossip.rs: 549
- cluster.rs: 274
- main.rs: 506

Follow-on Phase 1 cuts:
- 1c: `quinn` QUIC transport + fleet-CA mTLS for RPC (separate UDP
      port so gossip and QUIC don't collide)
- 1d: daemon wire-up — gossip runs in background, hot-tier usage
      pushed periodically, `cluster-status` reads from live daemon
2026-07-11 21:41:52 -07:00