Bunny zone: how a blob is uploaded and how existence is checked without mounting #55
Notifications
Due Date
No due date set.
Blocks
#60 The emit upload path and NWCompressedBuffer round-trip
ShadowsOverWestgate/sow-tools
Reference: ShadowsOverWestgate/sow-tools#55
Reference in New Issue
Block a user
Question
How does
emitput a blob into the Bunny zone, and how does it know a blob is already there — without mounting the zone and without a local mirror?Part of #54. Blob paths are
data/sha1/<h0h1>/<h2h3>/<sha1>, names are content hashes, so name equality is content equality and the check is a name diff rather than a byte comparison.Find out, from primary sources and from what the repos already do:
sow-assets-manifestpublishes to the depot today: the credential name, where it comes from in CI, and which client it uses. This is the path a new writer copies rather than invents.internal/depot'sremote.gobackend already speaks this API, and if so whetheremitreuses it or needs something different.Resolved: probe per object, and extend
internal/depot's backend rather than writing a new clientThe API
Flat REST filesystem:
PUT|GET|DELETE https://{host}/{zone}/{path}, auth via anAccessKey: <storage-zone-password>header on every call — the zone password from the FTP & API Access tab, not the account API key (that one is CDN purge only). Host for us isstorage.bunnycdn.com(release.yml:41).PUT /{zone}/{path}raw body201GET /{zone}/{path}200+ body404+ JSONDELETE /{zone}/{path}200GET /{zone}/{path}/trailing slash200+ JSON arrayRefs: storage API, put, get, list.
HEAD is banned, and not by preference —
docs/superpowers/specs/2026-07-04-crucible-depot-core-design.md:98-103,237records the edge throttling concurrent HEADs with428/000, producing both false 404s and false 200s. The probe is a 1-byte range GET (Range: bytes=0-0) over forced IPv4, expecting206.Listing is worse than probing, so probe
There is no bulk, recursive or prefix listing. Enumerating a depth-2 tree costs
1 + 256 + 65,536 = 65,793requests, and Bunny only creates directories that hold objects — but with 71,436 objects over 65,536 buckets, ~1.09 per bucket, almost every bucket exists. That is ~92% of the cost of just probing all 71,436 objects, and it returns a whole-zone snapshot we would have to hold and trust as fresh.So: probe per object, which is what
Sweep()already does —ProbeJobsworkers (default 16,config.go:26), then every non-Present result re-confirmed serially before being called missing.Bunny's S3-compatible API would give
ListObjectsV2with prefixes and 1000-key pages (~72 calls), but it is still in phased closed preview as of March 2026 (roadmap post). Worth revisiting, not worth building on.What sow-assets-manifest does today
curl, driven fromscripts/lib.sh: URL builder:368, upload:465-475(existence check, then PUT withAccessKeyandChecksum), existence:411-425(curl -r 0-0, accepting 200/206), delete:552-562plus a scoped CDN purge. Newer steps already call the Go tool —crucible depot status --manifests assets --target bunny(release.yml:51,publish-haks.yml:53).Credential names, values not read:
BUNNY_STORAGE_PASSWORD(zone write),BUNNY_STORAGE_READ_PASSWORD(read, fed from the same secret),BUNNY_STORAGE_ZONE, andBUNNY_API_KEY(account key, purge only).BUNNY_STORAGE_HOSTandBUNNY_CDN_BASEare plain env values.One behaviour
emitmust not inherit:depot_require_write(lib.sh:390-411) prompts on a TTY for the write password when unset. The Go design doc calls this out as a bug not to repeat (:82-90), andNewBackendalready does the right thing — env only, error rather than prompt (remote.go:35-40).Reuse
httpBackend, with a small extensioninternal/depotalready speaks this exact API and already gets the hard parts right: IPv4-only transport with per-host connection limits (remote.go:53-62, there because of a documented IPv6/HTTP-2 reset problem), range-GET probe with retry and a tri-state result whereUnconfirmedis distinct fromAbsent(remote.go:98-141), and upload-that-skips-what-exists (remote.go:144-184) — which is precisely the flowemitneeds, already written.Three sha256 assumptions must become per-instance:
BlobKey()(manifest.go:26-28) hardcodessha256/<a>/<b>/<sha>; NWSync needsdata/sha1/<h0h1>/<h2h3>/<sha1>. Same shape, different prefix — so akeyFn func(string) stringfield onhttpBackend, not a second backend type.Get()(remote.go:209-221) tees throughsha256.New()and deletes on mismatch, which would reject every NWSync blob. Same fix, anewHash func() hash.Hashfield.Checksumheader, which is the real trap.remote.go:171sendsChecksum: strings.ToUpper(sha). Bunny documents that header as SHA-256 and validates the body against it — handing it a 40-char SHA-1 will make Bunny reject the upload. Either drop the header for NWSync blobs, or compute a SHA-256 of the same bytes in the same pass withio.MultiWriterand send that. The latter costs almost nothing and keeps server-side integrity checking on a 71k-object upload, which is worth having.Roughly 20 lines across
remote.goandbackend.go, andemitinherits the retry logic, IPv4 pinning, tri-state probe, skip-if-present upload andSweep(). A separate NWSync-only client would duplicate every one of those hard-won behaviours and drift from them.The one genuinely new need:
Put(ctx, sha, src string)takes a filesystem path, but NWSync blobs come from inside a hak and must never be staged to disk. SoemitneedsPutReader(ctx, key string, r io.Reader, size int64), withPutreimplemented on top of it. Folded into #60.Idempotency and concurrency
Re-PUT is a plain overwrite returning
201; Bunny documents no conflict or precondition response, and both the bash and Go paths treat re-upload as safe. Two producers writing the same key is harmless because the key is the content hash — both bodies are byte-identical, so whichever wins is correct. A reader seeing a partially-written object mid-overwrite is unverified (Bunny documents nothing either way), but theChecksumheader rejects a truncated upload andGet()re-hashes on download and deletes on mismatch (remote.go:217-221).The practical risk is throttling, not correctness. The design doc's field notes (
:53-56) record428/000under concurrent probing — which is exactly whyProbeJobsis 16 and whyUnconfirmedexists.emitkeeps the same ceiling and the same discipline: a throttled probe must never be read as "missing, re-upload" or as "present, skip".