From 93f3eb8c6fa2e387691482adde4de0efa101f2cf Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Sat, 4 Jul 2026 23:59:43 +0200 Subject: [PATCH] fix(depot): exit 64 on unset DEPOT_DIR, count push transport failures Co-Authored-By: Claude Fable 5 --- internal/depot/integration_test.go | 1 - internal/depot/run.go | 20 ++++++++++-- internal/depot/run_test.go | 51 ++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/internal/depot/integration_test.go b/internal/depot/integration_test.go index a7096df..5515a9b 100644 --- a/internal/depot/integration_test.go +++ b/internal/depot/integration_test.go @@ -53,7 +53,6 @@ func TestIntegrationPushStatusPull(t *testing.T) { "BUNNY_STORAGE_HOST": hostPort(srv), "BUNNY_STORAGE_READ_PASSWORD": "readkey", "BUNNY_STORAGE_PASSWORD": "writekey", - "CDN_UNREACHABLE": "1", // unused; CDNBase defaults to unreachable in test cfg path via storageURL fallback } getenv := testGetenv(env) diff --git a/internal/depot/run.go b/internal/depot/run.go index aa78971..1c49876 100644 --- a/internal/depot/run.go +++ b/internal/depot/run.go @@ -3,6 +3,7 @@ package depot import ( "context" "crypto/sha256" + "errors" "flag" "fmt" "io" @@ -60,18 +61,30 @@ func printRunUsage(w io.Writer) { `) } +// errUsage marks errors that are the caller's fault (usage, exit 64) rather +// than internal/backend failures (exit 70). +var errUsage = errors.New("usage error") + // resolveBackend builds the named backend, resolving "local" against DEPOT_DIR. func resolveBackend(target string, getenv func(string) string, cfg Config) (Backend, error) { root := "" if target == "local" { root = getenv("DEPOT_DIR") if root == "" { - return nil, fmt.Errorf("--target local requires DEPOT_DIR to be set") + return nil, fmt.Errorf("--target local requires DEPOT_DIR to be set: %w", errUsage) } } return NewBackend(target, root, cfg) } +// backendErrExit maps a resolveBackend error onto the exit contract. +func backendErrExit(err error) int { + if errors.Is(err, errUsage) { + return exitUsage + } + return exitInternal +} + func printSweepStatus(stdout io.Writer, referenced int, res SweepResult) { fmt.Fprintf(stdout, "referenced=%d present=%d missing=%d unconfirmed=%d\n", referenced, len(res.Present), len(res.Missing), len(res.Unconfirmed)) @@ -120,7 +133,7 @@ func runStatus(args []string, stdout, stderr io.Writer, getenv func(string) stri backend, err := resolveBackend(*target, getenv, cfg) if err != nil { fmt.Fprintln(stderr, "depot status:", err) - return exitInternal + return backendErrExit(err) } shaSizes, err := ReferencedSHAs(*manifests) @@ -199,6 +212,7 @@ func runPush(args []string, stdout, stderr io.Writer, getenv func(string) string } if err := backend.Put(context.Background(), sha, srcPath); err != nil { mu.Lock() + failed++ if transportErr == nil { transportErr = err } @@ -318,7 +332,7 @@ func runGet(args []string, stdout, stderr io.Writer, getenv func(string) string) backend, err := resolveBackend(*target, getenv, cfg) if err != nil { fmt.Fprintln(stderr, "depot get:", err) - return exitInternal + return backendErrExit(err) } if err := backend.Get(context.Background(), sha, dest); err != nil { diff --git a/internal/depot/run_test.go b/internal/depot/run_test.go index 1009ffa..3578872 100644 --- a/internal/depot/run_test.go +++ b/internal/depot/run_test.go @@ -3,6 +3,7 @@ package depot import ( "bytes" "fmt" + "net/http" "net/http/httptest" "os" "path/filepath" @@ -30,6 +31,19 @@ func TestRunUsage(t *testing.T) { } }) + t.Run("status --target local without DEPOT_DIR", func(t *testing.T) { + dir := t.TempDir() + writeManifest(t, dir, shaOf("blob-a"), 5) + var out, errb bytes.Buffer + code := Run([]string{"status", "--manifests", dir, "--target", "local"}, &out, &errb, testGetenv(nil)) + if code != 64 { + t.Fatalf("expected 64, got %d (stderr=%s)", code, errb.String()) + } + if !bytesContains(errb.String(), "DEPOT_DIR") { + t.Fatalf("expected stderr to mention DEPOT_DIR, got %s", errb.String()) + } + }) + t.Run("push --target cdn rejected", func(t *testing.T) { dir := t.TempDir() var out, errb bytes.Buffer @@ -116,6 +130,43 @@ func TestStatusExitCodes(t *testing.T) { }) } +func TestPushTransportErrorCountsFailed(t *testing.T) { + // Probes 404 (blob absent), PUTs 500 (transport-level upload failure). + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut { + w.WriteHeader(500) + return + } + w.WriteHeader(404) + })) + defer srv.Close() + + sha := shaOf("push-fail-blob") + manifestsDir := t.TempDir() + writeManifest(t, manifestsDir, sha, 14) + sourceDir := t.TempDir() + 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("push-fail-blob"), 0644); err != nil { + t.Fatal(err) + } + + var out, errb bytes.Buffer + code := Run([]string{"push", "--manifests", manifestsDir, "--source", sourceDir, "--target", "bunny"}, &out, &errb, + testGetenv(map[string]string{ + "BUNNY_STORAGE_HOST": hostPort(srv), + "BUNNY_STORAGE_PASSWORD": "writekey", + })) + if code != 70 { + t.Fatalf("expected 70, got %d (stdout=%s stderr=%s)", code, out.String(), errb.String()) + } + if !bytesContains(out.String(), "uploaded=0 failed=1") { + t.Fatalf("expected uploaded=0 failed=1, got %s", out.String()) + } +} + func bytesContains(s, substr string) bool { return bytes.Contains([]byte(s), []byte(substr)) }