Files
sow-tools/internal/depot/remote_test.go
T
archvillainette 9357b30994
test / test (push) Successful in 1m31s
build-binaries / build-binaries (push) Successful in 2m27s
build-image / publish (push) Successful in 42s
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>
2026-07-04 23:30:54 +00:00

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")
}
}