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>
550 lines
15 KiB
Go
550 lines
15 KiB
Go
package depot
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func shaOf(s string) string {
|
|
return fmt.Sprintf("%x", sha256.Sum256([]byte(s)))
|
|
}
|
|
|
|
type recordedReq struct {
|
|
Method string
|
|
Path string
|
|
Range string
|
|
Headers http.Header
|
|
}
|
|
|
|
// fakeBunny is a minimal recording fake for Bunny storage/CDN endpoints.
|
|
type fakeBunny struct {
|
|
mu sync.Mutex
|
|
requests []recordedReq
|
|
headCount int
|
|
blobs map[string][]byte
|
|
statusFor map[string]int // sha -> status code override for probe/get
|
|
}
|
|
|
|
func newFakeBunny() *fakeBunny {
|
|
return &fakeBunny{blobs: map[string][]byte{}, statusFor: map[string]int{}}
|
|
}
|
|
|
|
func (f *fakeBunny) handler() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
f.requests = append(f.requests, recordedReq{
|
|
Method: r.Method,
|
|
Path: r.URL.Path,
|
|
Range: r.Header.Get("Range"),
|
|
Headers: r.Header.Clone(),
|
|
})
|
|
if r.Method == http.MethodHead {
|
|
f.headCount++
|
|
f.mu.Unlock()
|
|
w.WriteHeader(500)
|
|
return
|
|
}
|
|
f.mu.Unlock()
|
|
|
|
sha := strings.TrimPrefix(r.URL.Path, "/")
|
|
// last path element is the sha
|
|
parts := strings.Split(sha, "/")
|
|
sha = parts[len(parts)-1]
|
|
|
|
if r.Method == http.MethodPut {
|
|
buf, _ := io.ReadAll(r.Body)
|
|
f.mu.Lock()
|
|
f.blobs[sha] = buf
|
|
f.mu.Unlock()
|
|
w.WriteHeader(201)
|
|
return
|
|
}
|
|
|
|
// GET (range probe / actual get)
|
|
f.mu.Lock()
|
|
status, hasStatus := f.statusFor[sha]
|
|
data, ok := f.blobs[sha]
|
|
f.mu.Unlock()
|
|
|
|
if hasStatus {
|
|
w.WriteHeader(status)
|
|
return
|
|
}
|
|
if !ok {
|
|
w.WriteHeader(404)
|
|
return
|
|
}
|
|
w.WriteHeader(206)
|
|
_, _ = w.Write(data)
|
|
}
|
|
}
|
|
|
|
func (f *fakeBunny) requestsSnapshot() []recordedReq {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := make([]recordedReq, len(f.requests))
|
|
copy(out, f.requests)
|
|
return out
|
|
}
|
|
|
|
func testCfg(storageHost, readKey, writeKey string) Config {
|
|
return Config{
|
|
CDNBase: "http://127.0.0.1:1", // unreachable by default; overridden per test
|
|
StorageHost: storageHost,
|
|
StorageZone: "sow-assets-depot",
|
|
ReadKey: readKey,
|
|
WriteKey: writeKey,
|
|
ProbeJobs: 4,
|
|
Jobs: 4,
|
|
ConnectTimeout: 2 * time.Second,
|
|
ProbeMaxTime: 2 * time.Second,
|
|
ConfirmRetries: 3,
|
|
ConfirmMax: 200,
|
|
}
|
|
}
|
|
|
|
// hostPort returns a StorageHost value for tests: a full "http://host:port"
|
|
// base URL, which storageURL() passes through unchanged (bypassing the
|
|
// production https:// default so httptest.NewServer works without TLS).
|
|
func hostPort(srv *httptest.Server) string {
|
|
return srv.URL
|
|
}
|
|
|
|
// stubProbeSleep replaces probeRetrySleep for the test, recording durations.
|
|
func stubProbeSleep(t *testing.T) *[]time.Duration {
|
|
t.Helper()
|
|
var slept []time.Duration
|
|
old := probeRetrySleep
|
|
probeRetrySleep = func(d time.Duration) { slept = append(slept, d) }
|
|
t.Cleanup(func() { probeRetrySleep = old })
|
|
return &slept
|
|
}
|
|
|
|
func TestProbeRetriesTransientThenPresent(t *testing.T) {
|
|
slept := stubProbeSleep(t)
|
|
sha := shaOf("throttled-blob")
|
|
|
|
var calls int
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls++
|
|
if calls == 1 {
|
|
w.WriteHeader(428)
|
|
return
|
|
}
|
|
w.WriteHeader(206)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := testCfg(hostPort(srv), "readkey", "writekey")
|
|
b, err := NewBackend("bunny", "", cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewBackend: %v", err)
|
|
}
|
|
|
|
state, transient, err := b.Probe(context.Background(), sha)
|
|
if err != nil {
|
|
t.Fatalf("Probe: %v", err)
|
|
}
|
|
if state != Present || transient {
|
|
t.Fatalf("expected Present/non-transient after retry, got %v/%v", state, transient)
|
|
}
|
|
if calls != 2 {
|
|
t.Fatalf("expected 2 requests (428 then 206), got %d", calls)
|
|
}
|
|
if len(*slept) != 1 || (*slept)[0] != time.Second {
|
|
t.Fatalf("expected one 1s backoff, got %v", *slept)
|
|
}
|
|
}
|
|
|
|
func TestProbe404NoRetry(t *testing.T) {
|
|
slept := stubProbeSleep(t)
|
|
sha := shaOf("gone-blob")
|
|
|
|
f := newFakeBunny()
|
|
srv := httptest.NewServer(f.handler())
|
|
defer srv.Close()
|
|
|
|
cfg := testCfg(hostPort(srv), "readkey", "writekey")
|
|
b, err := NewBackend("bunny", "", cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewBackend: %v", err)
|
|
}
|
|
|
|
state, transient, err := b.Probe(context.Background(), sha)
|
|
if err != nil {
|
|
t.Fatalf("Probe: %v", err)
|
|
}
|
|
if state != Absent || transient {
|
|
t.Fatalf("expected Absent/non-transient, got %v/%v", state, transient)
|
|
}
|
|
if got := len(f.requestsSnapshot()); got != 1 {
|
|
t.Fatalf("expected exactly 1 request for 404, got %d", got)
|
|
}
|
|
if len(*slept) != 0 {
|
|
t.Fatalf("expected zero backoffs for 404, got %v", *slept)
|
|
}
|
|
}
|
|
|
|
func TestProbeUsesRangeGetNotHead(t *testing.T) {
|
|
sha := shaOf("present-blob")
|
|
f := newFakeBunny()
|
|
f.blobs[sha] = []byte("hello")
|
|
srv := httptest.NewServer(f.handler())
|
|
defer srv.Close()
|
|
|
|
cfg := testCfg(hostPort(srv), "readkey", "writekey")
|
|
b, err := NewBackend("bunny", "", cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewBackend: %v", err)
|
|
}
|
|
|
|
state, transient, err := b.Probe(context.Background(), sha)
|
|
if err != nil {
|
|
t.Fatalf("Probe: %v", err)
|
|
}
|
|
if state != Present || transient {
|
|
t.Fatalf("expected Present/non-transient, got %v/%v", state, transient)
|
|
}
|
|
|
|
reqs := f.requestsSnapshot()
|
|
if len(reqs) != 1 {
|
|
t.Fatalf("expected exactly 1 request, got %d", len(reqs))
|
|
}
|
|
if reqs[0].Method != "GET" {
|
|
t.Fatalf("expected GET, got %s", reqs[0].Method)
|
|
}
|
|
if reqs[0].Range != "bytes=0-0" {
|
|
t.Fatalf("expected Range bytes=0-0, got %q", reqs[0].Range)
|
|
}
|
|
if f.headCount != 0 {
|
|
t.Fatalf("expected zero HEAD requests, got %d", f.headCount)
|
|
}
|
|
}
|
|
|
|
func TestProbeStates(t *testing.T) {
|
|
stubProbeSleep(t)
|
|
cases := []struct {
|
|
status int
|
|
wantState ProbeState
|
|
wantTransient bool
|
|
}{
|
|
{200, Present, false},
|
|
{206, Present, false},
|
|
{404, Absent, false},
|
|
{428, Unconfirmed, true},
|
|
{503, Unconfirmed, true},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(fmt.Sprintf("status_%d", c.status), func(t *testing.T) {
|
|
sha := shaOf(fmt.Sprintf("blob-%d", c.status))
|
|
f := newFakeBunny()
|
|
f.statusFor[sha] = c.status
|
|
srv := httptest.NewServer(f.handler())
|
|
defer srv.Close()
|
|
|
|
cfg := testCfg(hostPort(srv), "readkey", "writekey")
|
|
b, err := NewBackend("bunny", "", cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewBackend: %v", err)
|
|
}
|
|
|
|
state, transient, err := b.Probe(context.Background(), sha)
|
|
if err != nil && c.status < 500 {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if state != c.wantState {
|
|
t.Fatalf("status %d: expected state %v, got %v", c.status, c.wantState, state)
|
|
}
|
|
if transient != c.wantTransient {
|
|
t.Fatalf("status %d: expected transient=%v, got %v", c.status, c.wantTransient, transient)
|
|
}
|
|
if f.headCount != 0 {
|
|
t.Fatalf("expected zero HEAD requests, got %d", f.headCount)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBunnyPutSequence(t *testing.T) {
|
|
t.Run("absent sha", func(t *testing.T) {
|
|
sha := shaOf("new-blob")
|
|
f := newFakeBunny()
|
|
srv := httptest.NewServer(f.handler())
|
|
defer srv.Close()
|
|
|
|
cfg := testCfg(hostPort(srv), "readkey", "writekey")
|
|
b, err := NewBackend("bunny", "", cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewBackend: %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
srcPath := filepath.Join(dir, "src")
|
|
if err := os.WriteFile(srcPath, []byte("new-blob-content"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := b.Put(context.Background(), sha, srcPath); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
|
|
reqs := f.requestsSnapshot()
|
|
if len(reqs) != 2 {
|
|
t.Fatalf("expected 2 requests (probe, put), got %d: %+v", len(reqs), reqs)
|
|
}
|
|
if reqs[0].Method != "GET" || reqs[0].Range != "bytes=0-0" {
|
|
t.Fatalf("expected first request to be range probe GET, got %+v", reqs[0])
|
|
}
|
|
if reqs[1].Method != "PUT" {
|
|
t.Fatalf("expected second request to be PUT, got %+v", reqs[1])
|
|
}
|
|
if got := reqs[1].Headers.Get("Checksum"); got != strings.ToUpper(sha) {
|
|
t.Fatalf("expected Checksum %s, got %s", strings.ToUpper(sha), got)
|
|
}
|
|
if got := reqs[1].Headers.Get("AccessKey"); got != "writekey" {
|
|
t.Fatalf("expected AccessKey writekey, got %s", got)
|
|
}
|
|
if f.headCount != 0 {
|
|
t.Fatalf("expected zero HEAD requests, got %d", f.headCount)
|
|
}
|
|
})
|
|
|
|
t.Run("present sha", func(t *testing.T) {
|
|
sha := shaOf("existing-blob")
|
|
f := newFakeBunny()
|
|
f.blobs[sha] = []byte("existing-blob-content")
|
|
srv := httptest.NewServer(f.handler())
|
|
defer srv.Close()
|
|
|
|
cfg := testCfg(hostPort(srv), "readkey", "writekey")
|
|
b, err := NewBackend("bunny", "", cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewBackend: %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
srcPath := filepath.Join(dir, "src")
|
|
if err := os.WriteFile(srcPath, []byte("existing-blob-content"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := b.Put(context.Background(), sha, srcPath); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
|
|
reqs := f.requestsSnapshot()
|
|
if len(reqs) != 1 {
|
|
t.Fatalf("expected probe-only (1 request) since present, got %d: %+v", len(reqs), reqs)
|
|
}
|
|
if reqs[0].Method != "GET" {
|
|
t.Fatalf("expected GET probe, got %+v", reqs[0])
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestReadWriteKeySplit(t *testing.T) {
|
|
sha := shaOf("split-blob")
|
|
f := newFakeBunny()
|
|
srv := httptest.NewServer(f.handler())
|
|
defer srv.Close()
|
|
|
|
cfg := testCfg(hostPort(srv), "readkeyXXX", "writekeyYYY")
|
|
b, err := NewBackend("bunny", "", cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewBackend: %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
srcPath := filepath.Join(dir, "src")
|
|
if err := os.WriteFile(srcPath, []byte("split-blob-content"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := b.Put(context.Background(), sha, srcPath); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
|
|
reqs := f.requestsSnapshot()
|
|
if len(reqs) != 2 {
|
|
t.Fatalf("expected 2 requests, got %d", len(reqs))
|
|
}
|
|
if got := reqs[0].Headers.Get("AccessKey"); got != "readkeyXXX" {
|
|
t.Fatalf("probe expected AccessKey readkeyXXX, got %s", got)
|
|
}
|
|
if got := reqs[1].Headers.Get("AccessKey"); got != "writekeyYYY" {
|
|
t.Fatalf("put expected AccessKey writekeyYYY, got %s", got)
|
|
}
|
|
if reqs[0].Headers.Get("AccessKey") == reqs[1].Headers.Get("AccessKey") {
|
|
t.Fatalf("expected distinct read/write keys")
|
|
}
|
|
}
|
|
|
|
func TestBunnyNoReadKeyFailsClosed(t *testing.T) {
|
|
// Grep-based tripwire: no non-test depot source may reference stdin/tty.
|
|
dir := "."
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatalf("ReadDir: %v", err)
|
|
}
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
|
continue
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(dir, name))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile %s: %v", name, err)
|
|
}
|
|
s := string(data)
|
|
if strings.Contains(s, "os.Stdin") || strings.Contains(s, "bufio.NewReader(os.Stdin)") || strings.Contains(s, "/dev/tty") {
|
|
t.Fatalf("%s references stdin/tty; credential prompts are banned", name)
|
|
}
|
|
}
|
|
|
|
// Confirm undrained pipe: swap in an os.Pipe as stdin-equivalent to prove
|
|
// no read occurs.
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatalf("Pipe: %v", err)
|
|
}
|
|
oldStdin := os.Stdin
|
|
os.Stdin = r
|
|
defer func() { os.Stdin = oldStdin; r.Close() }()
|
|
|
|
cfg := testCfg("storage.example.com", "", "writekey")
|
|
_, err = NewBackend("bunny", "", cfg)
|
|
if err == nil {
|
|
t.Fatal("expected error for empty ReadKey")
|
|
}
|
|
if !strings.Contains(err.Error(), "ReadKey") && !strings.Contains(err.Error(), "BUNNY_STORAGE_READ_PASSWORD") && !strings.Contains(err.Error(), "BUNNY_STORAGE_PASSWORD") {
|
|
t.Fatalf("expected error to mention missing env var, got: %v", err)
|
|
}
|
|
|
|
w.Close()
|
|
// If code read from stdin it would have blocked already (pipe with no
|
|
// writer-side data yet); assert the pipe is still open/undrained by
|
|
// writing now and reading it back ourselves.
|
|
if _, err := w.Write([]byte("x")); err == nil {
|
|
t.Fatal("expected write to closed pipe writer to fail")
|
|
}
|
|
}
|
|
|
|
func TestBunnyNoStorageHostFailsClosed(t *testing.T) {
|
|
cfg := testCfg("", "readkey", "writekey")
|
|
_, err := NewBackend("bunny", "", cfg)
|
|
if err == nil {
|
|
t.Fatal("expected error for empty StorageHost")
|
|
}
|
|
if !strings.Contains(err.Error(), "StorageHost") && !strings.Contains(err.Error(), "BUNNY_STORAGE_HOST") {
|
|
t.Fatalf("expected error to mention missing StorageHost/BUNNY_STORAGE_HOST, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetHashMismatchDeletes(t *testing.T) {
|
|
sha := shaOf("get-mismatch-blob")
|
|
f := newFakeBunny()
|
|
f.blobs[sha] = []byte("wrong content")
|
|
srv := httptest.NewServer(f.handler())
|
|
defer srv.Close()
|
|
|
|
cfg := testCfg(hostPort(srv), "readkey", "writekey")
|
|
// force CDN unreachable so Get falls back to storage
|
|
cfg.CDNBase = "http://127.0.0.1:1"
|
|
b, err := NewBackend("bunny", "", cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewBackend: %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
dest := filepath.Join(dir, "dest")
|
|
|
|
err = b.Get(context.Background(), sha, dest)
|
|
if err == nil {
|
|
t.Fatal("expected hash mismatch error")
|
|
}
|
|
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
|
|
t.Fatalf("expected dest to be removed on mismatch, stat err: %v", statErr)
|
|
}
|
|
}
|
|
|
|
func TestCDNPutReadOnly(t *testing.T) {
|
|
cfg := testCfg("storage.example.com", "readkey", "writekey")
|
|
b, err := NewBackend("cdn", "", cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewBackend: %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
srcPath := filepath.Join(dir, "src")
|
|
if err := os.WriteFile(srcPath, []byte("data"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = b.Put(context.Background(), shaOf("anything"), srcPath)
|
|
if err == nil {
|
|
t.Fatal("expected error for cdn Put (read-only)")
|
|
}
|
|
if !strings.Contains(err.Error(), "read-only") {
|
|
t.Fatalf("expected read-only error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestNoHeadInSources is the anti-HEAD regression tripwire from field note 3:
|
|
// grep-assert no http.MethodHead/.Head( usage in non-test depot sources.
|
|
func TestNoHeadInSources(t *testing.T) {
|
|
dir := "."
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatalf("ReadDir: %v", err)
|
|
}
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
|
continue
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(dir, name))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile %s: %v", name, err)
|
|
}
|
|
s := string(data)
|
|
if strings.Contains(s, "http.MethodHead") || strings.Contains(s, ".Head(") {
|
|
t.Fatalf("%s references HEAD probe (banned)", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLocalBackendViaFactory sanity-checks NewBackend("local", ...).
|
|
func TestLocalBackendViaFactory(t *testing.T) {
|
|
root := t.TempDir()
|
|
b, err := NewBackend("local", root, Config{})
|
|
if err != nil {
|
|
t.Fatalf("NewBackend: %v", err)
|
|
}
|
|
lb, ok := b.(*LocalBackend)
|
|
if !ok {
|
|
t.Fatalf("expected *LocalBackend, got %T", b)
|
|
}
|
|
if lb.Root != root {
|
|
t.Fatalf("expected Root=%s, got %s", root, lb.Root)
|
|
}
|
|
|
|
if _, err := NewBackend("local", "", Config{}); err == nil {
|
|
t.Fatal("expected error for empty local root")
|
|
}
|
|
|
|
if _, err := NewBackend("nonsense", "", Config{}); err == nil {
|
|
t.Fatal("expected error for unknown backend name")
|
|
}
|
|
}
|