crucible depot: Increment 1 core (status/push/verify/get/pull + local/cdn/bunny backends) (#30)
Implements Increment 1 of `docs/superpowers/specs/2026-07-04-crucible-depot-core-design.md`: a new stdlib-only `internal/depot` package wired into the dispatcher. - `crucible depot status|push|verify|get|pull` with backends `local`/`cdn`/`bunny`; exit contract `0` clean / `1` drift / `2` unconfirmed-only / `64` usage / `70` internal. - Presence is always probed against the real target (IPv4 1-byte range GET; HEAD is banned with a regression-tripwire test). `unconfirmed` is a distinct state, never collapsed into `missing`. - No prompting anywhere: missing `BUNNY_STORAGE_*` env fails closed (read path included), enforced by a no-stdin test. - Uploads: probe-then-PUT with `Checksum: <UPPER-sha>`; read/write key split (`BUNNY_STORAGE_READ_PASSWORD` falls back to `BUNNY_STORAGE_PASSWORD`). - Field-driven fix included: per-probe transient retry (curl `--retry 2` equivalent) — without it a real 1490-blob CDN sweep reported 1222 false-unconfirmed; with it, 1490/1490 present in 74s, exit 0. - Registry: depot `Wired: true`, joins the interactive menu; stale "(SeaweedFS)" wording removed. Tests: unit + httptest fake-Bunny (probe sequence, Checksum header, key split, 428 throttling → exit 2) + local→bunny integration (drift → push → clean → idempotent no-second-PUT; incremental pull). `make check` green. **Merge ordering:** this merges FIRST; the companion `sow-assets-manifest#crucible-depot-cutover` PR needs its flake input bumped to include this. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #30 Reviewed-by: xtul <mpiasecki720@protonmail.com> Co-authored-by: vickydotbat <vickydotbat@tutamail.com> Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
This commit was merged in pull request #30.
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
package depot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIntegrationPushStatusPull drives status/push/pull end-to-end against a
|
||||
// fake Bunny backend (real bunny HTTP path, in-memory blob store) since the
|
||||
// command surface has no local->local push (push --target must be bunny).
|
||||
func TestIntegrationPushStatusPull(t *testing.T) {
|
||||
manifestsDir := t.TempDir()
|
||||
sourceDir := t.TempDir()
|
||||
|
||||
blobs := map[string]string{
|
||||
shaOf("blob-one"): "blob-one",
|
||||
shaOf("blob-two"): "blob-two",
|
||||
shaOf("blob-three"): "blob-three",
|
||||
}
|
||||
var manifest strings.Builder
|
||||
manifest.WriteString("assets:\n")
|
||||
for sha, content := range blobs {
|
||||
manifest.WriteString(fmt.Sprintf(" - path: %s\n sha256: %s\n size: %d\n", sha, sha, len(content)))
|
||||
blobPath := filepath.Join(sourceDir, BlobKey(sha))
|
||||
if err := os.MkdirAll(filepath.Dir(blobPath), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(blobPath, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(manifestsDir, "manifest.yml"), []byte(manifest.String()), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f := newFakeBunny()
|
||||
// Pre-seed one of the three blobs on the target so status starts with drift=2.
|
||||
var oneSHA string
|
||||
for sha := range blobs {
|
||||
oneSHA = sha
|
||||
break
|
||||
}
|
||||
f.blobs[oneSHA] = []byte(blobs[oneSHA])
|
||||
srv := httptest.NewServer(f.handler())
|
||||
defer srv.Close()
|
||||
|
||||
env := map[string]string{
|
||||
"BUNNY_STORAGE_HOST": hostPort(srv),
|
||||
"BUNNY_STORAGE_READ_PASSWORD": "readkey",
|
||||
"BUNNY_STORAGE_PASSWORD": "writekey",
|
||||
}
|
||||
getenv := testGetenv(env)
|
||||
|
||||
// status: expect exit 1, missing=2
|
||||
var out, errb bytes.Buffer
|
||||
code := Run([]string{"status", "--manifests", manifestsDir, "--target", "bunny"}, &out, &errb, getenv)
|
||||
if code != 1 {
|
||||
t.Fatalf("status: expected 1, got %d (stdout=%s stderr=%s)", code, out.String(), errb.String())
|
||||
}
|
||||
if !bytesContains(out.String(), "missing=2") {
|
||||
t.Fatalf("status: expected missing=2, got %s", out.String())
|
||||
}
|
||||
|
||||
// push: uploads the 2 missing blobs
|
||||
out.Reset()
|
||||
errb.Reset()
|
||||
code = Run([]string{"push", "--manifests", manifestsDir, "--source", sourceDir, "--target", "bunny"}, &out, &errb, getenv)
|
||||
if code != 0 {
|
||||
t.Fatalf("push: expected 0, got %d (stdout=%s stderr=%s)", code, out.String(), errb.String())
|
||||
}
|
||||
if !bytesContains(out.String(), "uploaded=2 failed=0") {
|
||||
t.Fatalf("push: expected uploaded=2 failed=0, got %s", out.String())
|
||||
}
|
||||
|
||||
putCountAfterFirstPush := 0
|
||||
for _, r := range f.requestsSnapshot() {
|
||||
if r.Method == "PUT" {
|
||||
putCountAfterFirstPush++
|
||||
}
|
||||
}
|
||||
if putCountAfterFirstPush != 2 {
|
||||
t.Fatalf("expected 2 PUTs after first push, got %d", putCountAfterFirstPush)
|
||||
}
|
||||
|
||||
// status: now clean
|
||||
out.Reset()
|
||||
errb.Reset()
|
||||
code = Run([]string{"status", "--manifests", manifestsDir, "--target", "bunny"}, &out, &errb, getenv)
|
||||
if code != 0 {
|
||||
t.Fatalf("status after push: expected 0, got %d (stdout=%s stderr=%s)", code, out.String(), errb.String())
|
||||
}
|
||||
|
||||
// push again: idempotent, no new PUTs, uploaded=0
|
||||
out.Reset()
|
||||
errb.Reset()
|
||||
code = Run([]string{"push", "--manifests", manifestsDir, "--source", sourceDir, "--target", "bunny"}, &out, &errb, getenv)
|
||||
if code != 0 {
|
||||
t.Fatalf("second push: expected 0, got %d (stdout=%s stderr=%s)", code, out.String(), errb.String())
|
||||
}
|
||||
if !bytesContains(out.String(), "uploaded=0 failed=0") {
|
||||
t.Fatalf("second push: expected uploaded=0 failed=0, got %s", out.String())
|
||||
}
|
||||
putCountAfterSecondPush := 0
|
||||
for _, r := range f.requestsSnapshot() {
|
||||
if r.Method == "PUT" {
|
||||
putCountAfterSecondPush++
|
||||
}
|
||||
}
|
||||
if putCountAfterSecondPush != putCountAfterFirstPush {
|
||||
t.Fatalf("expected no additional PUTs on idempotent push, before=%d after=%d", putCountAfterFirstPush, putCountAfterSecondPush)
|
||||
}
|
||||
|
||||
// pull into a dest dir: one pre-populated correct file (skipped, no GET),
|
||||
// one pre-populated corrupt file (re-downloaded), one absent (downloaded).
|
||||
destDir := t.TempDir()
|
||||
shas := make([]string, 0, len(blobs))
|
||||
for sha := range blobs {
|
||||
shas = append(shas, sha)
|
||||
}
|
||||
correctSHA := shas[0]
|
||||
corruptSHA := shas[1]
|
||||
// absentSHA := shas[2] // left absent on purpose
|
||||
|
||||
correctPath := filepath.Join(destDir, BlobKey(correctSHA))
|
||||
if err := os.MkdirAll(filepath.Dir(correctPath), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(correctPath, []byte(blobs[correctSHA]), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
corruptPath := filepath.Join(destDir, BlobKey(corruptSHA))
|
||||
if err := os.MkdirAll(filepath.Dir(corruptPath), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(corruptPath, []byte("corrupted-content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
getCountBeforePull := 0
|
||||
for _, r := range f.requestsSnapshot() {
|
||||
if r.Method == "GET" && !strings.Contains(r.Range, "0-0") {
|
||||
getCountBeforePull++
|
||||
}
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
errb.Reset()
|
||||
code = Run([]string{"pull", "--manifests", manifestsDir, "--dest", destDir, "--target", "bunny"}, &out, &errb, getenv)
|
||||
if code != 0 {
|
||||
t.Fatalf("pull: expected 0, got %d (stdout=%s stderr=%s)", code, out.String(), errb.String())
|
||||
}
|
||||
if !bytesContains(out.String(), "pulled=2 present=1") {
|
||||
t.Fatalf("pull: expected pulled=2 present=1, got %s", out.String())
|
||||
}
|
||||
|
||||
// verify the correct pre-existing file was never re-fetched with a full GET.
|
||||
fullGETsForCorrect := 0
|
||||
for _, r := range f.requestsSnapshot() {
|
||||
if r.Method == "GET" && strings.HasSuffix(r.Path, correctSHA) && r.Range != "bytes=0-0" {
|
||||
fullGETsForCorrect++
|
||||
}
|
||||
}
|
||||
if fullGETsForCorrect != 0 {
|
||||
t.Fatalf("expected no full GET for already-correct blob, got %d", fullGETsForCorrect)
|
||||
}
|
||||
|
||||
// all three blobs should now be present and correct in destDir.
|
||||
for sha, content := range blobs {
|
||||
got, err := os.ReadFile(filepath.Join(destDir, BlobKey(sha)))
|
||||
if err != nil {
|
||||
t.Fatalf("dest blob %s: %v", sha, err)
|
||||
}
|
||||
if string(got) != content {
|
||||
t.Fatalf("dest blob %s: expected %q, got %q", sha, content, string(got))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user