carlos / platform Public

cli: ship survives its own slow uploads, 404s keep their real diagnosis

Fixes from review round 1 on the console API client:

- CRITICAL: doMultipart no longer inherits the JSON verbs' 30s absolute
  http.Client Timeout — a large ship could be aborted client-side while
  the console's own 10-minute read / 2-minute store budget was still
  live, reporting a healthy upload as failed. It now runs on its own
  Timeout-free client bounded only by ship's context (a new 15-minute
  ctx), with shipHTTPClientTimeout as an injectable seam so a test can
  prove the failure mode fast, without a real multi-minute sleep.
- IMPORTANT: apiErrorFromBody tries the JSON {"error":...} decode first
  and only falls back to the generic membership message when a 404 body
  has no such field — promote's unshipped-version 404 and other real
  diagnoses no longer get silently rewritten into "check -account and
  -app". channels() now reads its own raw body (getJSON) instead of
  going through do() to make the same decision.
- IMPORTANT (brief amendment): promote and ship report noop/created
  parity via typed results (apiPromoteResult, apiShipResult) instead of
  bare errors — promote keys off the server's "result" field, ship keys
  off status (201 vs 200) since apicli.go's identical-reship response
  still says "shipped" and only the status code marks it a no-op.
- MINOR: an all-expired (not absent) credential now gets its own
  "expired — run: carlos auth login" message instead of being folded
  into the direct-mode-or-login suggestion meant for a truly empty
  credentials file.
- MINOR: doMultipart closes its pipe reader on the request-construction
  error path, so the writer goroutine can't block forever on a pipe
  nobody will ever read.
- MINOR: tests clear CARLOS_CONSOLE alongside the two deployment vars.
- MINOR: httptest handlers report via t.Errorf + 500 instead of
  t.Fatalf, which is unsafe to call from a goroutine other than the
  test's own.
Paul Campbell e2e007ab20d14382ebdc63a2dc963a6a56c6a5e0 parent 83c4dda
3 files changed, +423 −49
  • apimode.go +156 −37
  • apimode_test.go +241 −11
  • auth.go +26 −1
diff --git a/apimode.go b/apimode.go
index a7e8dd4ea..4c6e46ee9 100644
--- a/apimode.go
+++ b/apimode.go
@@ -28,6 +28,7 @@ import (
"os"
"path/filepath"
"sort"
+ "time"
)
// apiSession is the resolved API mode: which console, as whom. console and
@@ -62,6 +63,17 @@ func resolveMode(consoleFlag string) (*apiSession, error) {
// bucket/dir nor a login has TWO ways to fix it, and the naked "not
// logged in" message only offers one.
if err.Error() == noCredentialsMsg {
+ // But "not logged in" is also what pick() says when a credential
+ // EXISTS but every entry on disk has expired — its live-credential
+ // filter treats an expired entry as absent, so that case reaches
+ // this same string. Those are different problems for a person to
+ // fix ("configure a target" vs. "log back in"), and conflating
+ // them here would tell someone who has in fact logged in before
+ // that nothing is configured at all.
+ if expired := expiredCredentialFirst(); expired != nil {
+ return nil, fmt.Errorf("the credential for %s expired — run: carlos auth login -console %s",
+ expired.Console, expired.Console)
+ }
return nil, fmt.Errorf("no deployment target configured: " +
"set CARLOS_DEPLOYMENT_BUCKET (or CARLOS_DEPLOYMENT_DIR) for direct bucket access, " +
"or run carlos auth login -console <url> to work through a console")
@@ -76,13 +88,41 @@ func resolveMode(consoleFlag string) (*apiSession, error) {
}
// noCredentialsMsg is (*credentialFile).pick's exact wording (auth.go) for
-// "no -console given and there is nothing to pick from" — the one pick()
-// refusal that never mentions direct mode, since pick has no idea direct
-// mode exists. Matched by exact string rather than a sentinel error because
-// pick constructs a fresh error each call; this is the one pick() message
-// with no interpolated value, so an exact match is precise, not fragile.
+// "no -console given (and no CARLOS_CONSOLE either) and there is nothing
+// live to pick from" — the one pick() refusal that never mentions direct
+// mode, since pick has no idea direct mode exists. Matched by exact string
+// rather than a sentinel error because pick constructs a fresh error each
+// call; this is the one pick() message with no interpolated value, so an
+// exact match is precise, not fragile.
+//
+// It is ALSO the one case pick() reaches both when the credentials file is
+// genuinely empty and when it holds only expired entries (pick's "live"
+// filter excludes expired credentials before counting), which is why
+// resolveMode calls expiredCredentialFirst before treating it as "nothing
+// configured".
const noCredentialsMsg = "not logged in — run: carlos auth login -console <url>"
+// expiredCredentialFirst reports whether the credentials file holds at
+// least one entry and every one of them is expired, returning one such
+// entry if so (and nil otherwise, including when the file is empty or
+// unreadable). Only ever consulted from the noCredentialsMsg branch above,
+// where consoleFlag and CARLOS_CONSOLE are already known to both be empty —
+// pick() would have taken its console!="" branch, and returned its OWN
+// (already specific) expired-credential message, otherwise.
+func expiredCredentialFirst() *credential {
+ f, err := loadCredentials()
+ if err != nil || len(f.Credentials) == 0 {
+ return nil
+ }
+ now := time.Now()
+ for i := range f.Credentials {
+ if !f.Credentials[i].Expired(now) {
+ return nil
+ }
+ }
+ return &f.Credentials[0]
+}
+
// apiChannel is one row of a channel ladder read back through the console
// API, mirroring the runChannels per-row ERROR behaviour: a channel whose
// pointer failed verification is a row with Err set, not a failed call.
@@ -94,6 +134,22 @@ type apiChannel struct {
Err string
}
+// apiPromoteResult and apiShipResult carry the one fact Task 5 needs to
+// print a parity message: whether the console did nothing because the
+// request already matched what's live (a promote of a version already on
+// that channel; a re-ship of bytes already shipped under that version).
+// Every other field of the console's response (rung names, artifact
+// hashes) is deliberately not modelled here — the CLI already knows the
+// version/channel it asked for and prints those, never the server's echo of
+// them.
+type apiPromoteResult struct {
+ NoOp bool
+}
+
+type apiShipResult struct {
+ NoOp bool
+}
+
// apiPath builds one /api/cli/apps/{account}/{app}/... path. account is
// passed through verbatim (sqid or name, exactly what -account carried,
// default "ops") — the console resolves it against the caller's own
@@ -102,32 +158,63 @@ func apiPath(account, app, tail string) string {
return "/api/cli/apps/" + url.PathEscape(account) + "/" + url.PathEscape(app) + tail
}
-// notFoundErr is what every call here returns for a 404. apiAccountApp
-// (internal/console/apicli.go) answers an unknown account, an unknown app,
-// and someone else's account with the SAME http.NotFound — a byte-identical
-// text/plain body, not JSON, by design (a token holder must not learn which
-// of those three it was). This is the one sane message for all three.
+// notFoundErr is what every call here returns for a 404 whose body carries
+// no JSON "error" field. apiAccountApp (internal/console/apicli.go) answers
+// an unknown account, an unknown app, and someone else's account with the
+// SAME http.NotFound — a byte-identical text/plain body, not JSON, by design
+// (a token holder must not learn which of those three it was). This is the
+// one sane message for all three. A 404 that DOES carry an "error" field
+// (an unshipped version on promote, a channel with no history on rollback)
+// is a genuine server diagnosis, and apiErrorFromBody surfaces THAT text
+// instead — never rewritten into this one.
func notFoundErr() error {
return errors.New("not found: check -account and -app, and that your token's address is a member")
}
-// apiErrorFromBody turns a non-2xx status and its raw body into an error
-// for the plain {"error": "..."} shape every door but the stable-refusal
-// uses. 404 is special-cased because that body is text/plain, not JSON —
-// json.Unmarshal-ing it would silently produce no error field at all.
+// apiErrorFromBody turns a non-2xx status and its raw body into an error.
+// The JSON {"error": "..."} shape is tried FIRST, whatever the status code —
+// several doors (promote's unshipped-version 404, rollback's no-history 409)
+// answer a real diagnosis worth showing verbatim. Only when the body has no
+// such field at all — exactly what apiAccountApp's gate sends, a text/plain
+// "404 page not found" — does a 404 fall back to the generic membership
+// message; every other unreadable body just reports its status.
func apiErrorFromBody(status int, body []byte) error {
- if status == http.StatusNotFound {
- return notFoundErr()
- }
var e struct {
Error string `json:"error"`
}
if json.Unmarshal(body, &e) == nil && e.Error != "" {
return errors.New(e.Error)
}
+ if status == http.StatusNotFound {
+ return notFoundErr()
+ }
return fmt.Errorf("unexpected response (status %d)", status)
}
+// getJSON runs one JSON GET and hands back the status and raw body without
+// interpreting a non-200 as an error itself, for the same reason postJSON
+// doesn't: a caller here needs the FULL body (to try apiErrorFromBody's
+// json-first decode), not just a pre-picked message.
+func (s *apiSession) getJSON(ctx context.Context, path string) (int, []byte, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.c.base+path, nil)
+ if err != nil {
+ return 0, nil, err
+ }
+ if s.c.token != "" {
+ req.Header.Set("Authorization", "Bearer "+s.c.token)
+ }
+ res, err := s.c.httpc.Do(req)
+ if err != nil {
+ return 0, nil, err
+ }
+ defer res.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
+ if err != nil {
+ return res.StatusCode, nil, err
+ }
+ return res.StatusCode, body, nil
+}
+
// postJSON runs one JSON POST and hands back the status and raw body
// without interpreting a non-200 as an error itself. Unlike
// (*consoleClient).do, which keeps only a bare "error" string, promote and
@@ -177,29 +264,39 @@ func stableRefusal(status int, body []byte) (error, bool) {
return fmt.Errorf("%s\n %s", refusal.Error, refusal.ConsoleURL), true
}
-// promote asks the console to promote version onto channel. The response
-// carries RUNG names (From/To), not versions or channels — deliberately
-// unused here: the caller already knows the version and channel it asked
-// for and prints those, never res.From/res.To.
-func (s *apiSession) promote(ctx context.Context, account, app, version, channel string) error {
+// promote asks the console to promote version onto channel. The response's
+// "result" is the one field read here — "noop" when the ladder already had
+// this version on this channel — because everything else the response
+// carries (RUNG names in From/To) is not what the caller prints: the CLI
+// already knows the version/channel it asked for and prints those, never
+// res.From/res.To.
+func (s *apiSession) promote(ctx context.Context, account, app, version, channel string) (apiPromoteResult, error) {
in := struct {
Version string `json:"version"`
Channel string `json:"channel"`
}{version, channel}
status, body, err := s.postJSON(ctx, apiPath(account, app, "/promote"), in)
if err != nil {
- return err
+ return apiPromoteResult{}, err
}
if status == http.StatusOK {
- return nil
+ var res struct {
+ Result string `json:"result"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return apiPromoteResult{}, fmt.Errorf("promote: unreadable response: %w", err)
+ }
+ return apiPromoteResult{NoOp: res.Result == "noop"}, nil
}
if refErr, ok := stableRefusal(status, body); ok {
- return refErr
+ return apiPromoteResult{}, refErr
}
- return apiErrorFromBody(status, body)
+ return apiPromoteResult{}, apiErrorFromBody(status, body)
}
// rollback asks the console to roll channel back to its previous version.
+// There is no noop case for a rollback — it either has a previous version to
+// land on (409 if not) or it doesn't — so this stays error-only.
func (s *apiSession) rollback(ctx context.Context, account, app, channel string) error {
in := struct {
Channel string `json:"channel"`
@@ -219,6 +316,13 @@ func (s *apiSession) rollback(ctx context.Context, account, app, channel string)
// channels reads one app's channel ladder through the console.
func (s *apiSession) channels(ctx context.Context, account, app string) ([]apiChannel, error) {
+ status, body, err := s.getJSON(ctx, apiPath(account, app, "/channels"))
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, apiErrorFromBody(status, body)
+ }
var res struct {
Channels []struct {
Channel string `json:"channel"`
@@ -228,12 +332,8 @@ func (s *apiSession) channels(ctx context.Context, account, app string) ([]apiCh
Error string `json:"error"`
} `json:"channels"`
}
- status, err := s.c.do(ctx, http.MethodGet, apiPath(account, app, "/channels"), nil, &res)
- if err != nil {
- if status == http.StatusNotFound {
- return nil, notFoundErr()
- }
- return nil, err
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, fmt.Errorf("channels: unreadable response: %w", err)
}
out := make([]apiChannel, len(res.Channels))
for i, ch := range res.Channels {
@@ -245,11 +345,22 @@ func (s *apiSession) channels(ctx context.Context, account, app string) ([]apiCh
return out, nil
}
+// shipTimeout bounds ship's own context — generous on purpose. The console
+// gives itself up to 10 minutes to read the request body and 2 minutes to
+// store it (internal/console/apicli.go), well past the 30s absolute Timeout
+// the JSON verbs' shared client carries; ship must never inherit that 30s
+// cap (doMultipart, auth.go, uses its own client for exactly this reason),
+// but it still needs SOME bound of its own rather than running forever.
+const shipTimeout = 15 * time.Minute
+
// ship uploads one release's artifacts as a multipart POST: the meta part
// ({"version","kind"}) first, then one file part per entry in files (partName
// -> local path), in map-key-sorted order — sorted so the wire bytes are
// deterministic, since Go's map iteration order is not.
-func (s *apiSession) ship(ctx context.Context, account, app, version, kind string, files map[string]string) error {
+func (s *apiSession) ship(ctx context.Context, account, app, version, kind string, files map[string]string) (apiShipResult, error) {
+ ctx, cancel := context.WithTimeout(ctx, shipTimeout)
+ defer cancel()
+
names := make([]string, 0, len(files))
for name := range files {
names = append(names, name)
@@ -279,12 +390,20 @@ func (s *apiSession) ship(ctx context.Context, account, app, version, kind strin
return nil
})
if err != nil {
- return err
+ return apiShipResult{}, err
}
- if status == http.StatusOK || status == http.StatusCreated {
- return nil
+ // handleAPIShip (internal/console/apicli.go) sends "result": "shipped"
+ // on BOTH paths — status is the only signal that distinguishes them: 201
+ // for bytes actually written, 200 when the request matched an identical
+ // version already on record and nothing new was stored.
+ switch status {
+ case http.StatusCreated:
+ return apiShipResult{NoOp: false}, nil
+ case http.StatusOK:
+ return apiShipResult{NoOp: true}, nil
+ default:
+ return apiShipResult{}, apiErrorFromBody(status, body)
}
- return apiErrorFromBody(status, body)
}
// writeFilePart streams one local file into a multipart part named name, so
diff --git a/apimode_test.go b/apimode_test.go
index b551b9bd2..5fdf686d3 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -17,13 +17,16 @@ import (
"time"
)
-// clearDeploymentEnv ensures neither direct-mode env var leaks in from the
-// process that's running the test binary — resolveMode must see a clean
-// slate to make its own env check meaningful.
+// clearDeploymentEnv ensures none of the three env vars resolveMode/pick
+// read leak in from the process running the test binary — resolveMode must
+// see a clean slate to make its own env check meaningful, and CARLOS_CONSOLE
+// left set would silently change which pick() branch a "" -console flag
+// takes.
func clearDeploymentEnv(t *testing.T) {
t.Helper()
t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
t.Setenv("CARLOS_DEPLOYMENT_DIR", "")
+ t.Setenv("CARLOS_CONSOLE", "")
}
func TestResolveModeDirectWins(t *testing.T) {
@@ -104,6 +107,41 @@ func TestResolveModeNothing(t *testing.T) {
}
}
+// TestResolveModeExpiredCredentialOnly pins the minor fix: a credential that
+// EXISTS but has expired is a different situation from nothing configured at
+// all, and deserves a message that says so instead of offering to set up
+// direct mode from scratch.
+func TestResolveModeExpiredCredentialOnly(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ f, _ := loadCredentials()
+ f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "t1", ExpiresAt: time.Now().Add(-time.Hour)})
+ if err := f.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ sess, err := resolveMode("")
+ if sess != nil {
+ t.Fatalf("resolveMode with only an expired credential = %+v, want nil session", sess)
+ }
+ if err == nil {
+ t.Fatal("resolveMode with only an expired credential must error")
+ }
+ if !strings.Contains(err.Error(), "https://a.example") {
+ t.Fatalf("error %q does not name the expired console", err)
+ }
+ if !strings.Contains(err.Error(), "expired") {
+ t.Fatalf("error %q does not say the credential expired", err)
+ }
+ if !strings.Contains(err.Error(), "carlos auth login") {
+ t.Fatalf("error %q does not name the fix", err)
+ }
+ if strings.Contains(err.Error(), "CARLOS_DEPLOYMENT_BUCKET") {
+ t.Fatalf("error %q wrongly offers direct mode for an expired (not absent) credential", err)
+ }
+}
+
func newTestSession(base string) *apiSession {
return &apiSession{c: newConsoleClient(base, "cli-token"), console: base, address: "a@keymail.dev"}
}
@@ -120,7 +158,9 @@ func TestAPISessionShipMultipart(t *testing.T) {
gotAuth = r.Header.Get("Authorization")
mr, err := r.MultipartReader()
if err != nil {
- t.Fatalf("request was not multipart: %v", err)
+ t.Errorf("request was not multipart: %v", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
}
for {
part, err := mr.NextPart()
@@ -128,11 +168,15 @@ func TestAPISessionShipMultipart(t *testing.T) {
break
}
if err != nil {
- t.Fatalf("reading part: %v", err)
+ t.Errorf("reading part: %v", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
}
b, err := io.ReadAll(part)
if err != nil {
- t.Fatalf("reading part body: %v", err)
+ t.Errorf("reading part body: %v", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
}
if part.FormName() == "meta" {
metaBody = string(b)
@@ -157,13 +201,16 @@ func TestAPISessionShipMultipart(t *testing.T) {
}
s := newTestSession(ts.URL)
- err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{
+ res, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{
"zeta-amd64": fileB,
"alpha-amd64": fileA,
})
if err != nil {
t.Fatalf("ship: %v", err)
}
+ if res.NoOp {
+ t.Fatal("a fresh (201) ship must not report NoOp")
+ }
if gotAuth != "Bearer cli-token" {
t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer cli-token")
}
@@ -181,6 +228,51 @@ func TestAPISessionShipMultipart(t *testing.T) {
}
}
+// TestAPISessionShipNoOp pins handleAPIShip's identical-reship response
+// exactly as apicli.go sends it: still "result": "shipped" — 200 rather
+// than 201 is the ONLY signal that nothing new was written, so NoOp must be
+// keyed off status, not the result string.
+func TestAPISessionShipNoOp(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mr, err := r.MultipartReader()
+ if err != nil {
+ t.Errorf("request was not multipart: %v", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ for {
+ _, err := mr.NextPart()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ t.Errorf("reading part: %v", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ }
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]any{
+ "result": "shipped", "version": "1.2.3", "artifacts": map[string]any{},
+ })
+ }))
+ defer ts.Close()
+
+ dir := t.TempDir()
+ file := filepath.Join(dir, "a.bin")
+ if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ s := newTestSession(ts.URL)
+ res, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file})
+ if err != nil {
+ t.Fatalf("ship: %v", err)
+ }
+ if !res.NoOp {
+ t.Fatalf("ship result = %+v, want NoOp true for a 200 identical re-ship", res)
+ }
+}
+
func TestAPISessionShipErrorSurfacesServerMessage(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict)
@@ -196,12 +288,82 @@ func TestAPISessionShipErrorSurfacesServerMessage(t *testing.T) {
t.Fatal(err)
}
s := newTestSession(ts.URL)
- err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file})
+ _, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file})
if err == nil || !strings.Contains(err.Error(), "already shipped with different content") {
t.Fatalf("ship error = %v, want the server's conflict message", err)
}
}
+// TestAPISessionShipSurvivesSlowServer is the CRITICAL fix's proof: a slow
+// server holds the connection open longer than an old-style absolute client
+// Timeout would tolerate, but well within ship's own generous context.
+// shipHTTPClientTimeout (auth.go) is the injectable seam that lets both
+// halves of the point run fast, without an actual multi-minute sleep:
+// shrunk to a few milliseconds, the request fails exactly the way the
+// pre-fix code (which reused the JSON verbs' 30s-Timeout client) would have
+// on a real large upload; restored to its production zero value, the same
+// slow server succeeds because only the context bounds the request.
+func TestAPISessionShipSurvivesSlowServer(t *testing.T) {
+ t.Run("an absolute client Timeout fails against a slow server", func(t *testing.T) {
+ release := make(chan struct{})
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ <-release
+ io.Copy(io.Discard, r.Body)
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]any{"result": "shipped", "version": "1.2.3", "artifacts": map[string]any{}})
+ }))
+ defer func() {
+ close(release)
+ ts.Close()
+ }()
+
+ dir := t.TempDir()
+ file := filepath.Join(dir, "a.bin")
+ if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ s := newTestSession(ts.URL)
+
+ old := shipHTTPClientTimeout
+ shipHTTPClientTimeout = 30 * time.Millisecond
+ defer func() { shipHTTPClientTimeout = old }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ _, err := s.ship(ctx, "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file})
+ if err == nil {
+ t.Fatal("ship with a tiny absolute client Timeout against a slow-to-answer server must fail")
+ }
+ })
+
+ t.Run("no absolute timeout survives the same slow server", func(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ time.Sleep(150 * time.Millisecond) // longer than the tiny timeout above; well inside ship's context
+ io.Copy(io.Discard, r.Body)
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]any{"result": "shipped", "version": "1.2.3", "artifacts": map[string]any{}})
+ }))
+ defer ts.Close()
+
+ dir := t.TempDir()
+ file := filepath.Join(dir, "a.bin")
+ if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ s := newTestSession(ts.URL)
+ // shipHTTPClientTimeout is back at its zero production default here
+ // (the sibling subtest above restored it) — only the context bounds
+ // this call.
+ res, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file})
+ if err != nil {
+ t.Fatalf("ship against a slow-but-healthy server = %v, want success", err)
+ }
+ if res.NoOp {
+ t.Fatal("a fresh (201) ship must not report NoOp")
+ }
+ })
+}
+
// TestAPISessionStableRefusal pins handleAPIPromote's 403 shape: the CLI
// never resolves stable itself — it surfaces the console's own error AND the
// console_url it hands back, because do() only keeps the bare "error"
@@ -217,7 +379,7 @@ func TestAPISessionStableRefusal(t *testing.T) {
defer ts.Close()
s := newTestSession(ts.URL)
- err := s.promote(context.Background(), "acct", "app", "1.2.3", "canary")
+ _, err := s.promote(context.Background(), "acct", "app", "1.2.3", "canary")
if err == nil {
t.Fatal("promote against a stable refusal must error")
}
@@ -255,16 +417,84 @@ func TestAPISessionPromoteSuccess(t *testing.T) {
var req struct{ Version, Channel string }
json.NewDecoder(r.Body).Decode(&req)
if req.Version != "1.2.3" || req.Channel != "canary/a" {
- t.Fatalf("promote body = %+v", req)
+ t.Errorf("promote body = %+v", req)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
}
json.NewEncoder(w).Encode(map[string]any{"result": "promoted", "from": "", "to": "canary"})
}))
defer ts.Close()
s := newTestSession(ts.URL)
- if err := s.promote(context.Background(), "acct", "app", "1.2.3", "canary/a"); err != nil {
+ res, err := s.promote(context.Background(), "acct", "app", "1.2.3", "canary/a")
+ if err != nil {
+ t.Fatalf("promote: %v", err)
+ }
+ if res.NoOp {
+ t.Fatal(`promote result = NoOp true, want false for a "promoted" result`)
+ }
+}
+
+// TestAPISessionPromoteNoOp pins the noop-parity fix: PromoteOnLadder
+// already found this version on this channel, and handleAPIPromote answers
+// {"result": "noop"} (still 200) — promote() must report that as NoOp true
+// rather than folding it into an ordinary success indistinguishable from an
+// actual promotion.
+func TestAPISessionPromoteNoOp(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"result": "noop"})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ res, err := s.promote(context.Background(), "acct", "app", "1.2.3", "canary/a")
+ if err != nil {
t.Fatalf("promote: %v", err)
}
+ if !res.NoOp {
+ t.Fatalf(`promote result = %+v, want NoOp true for a "noop" result`, res)
+ }
+}
+
+// TestAPISessionPromoteUnshippedVersionSurfacesServerText pins the
+// IMPORTANT fix: a 404 with a real JSON diagnosis (an unshipped version,
+// handleAPIPromote's release.GetManifest check) must reach the caller
+// verbatim, not be swallowed into the generic membership message that only
+// belongs to apiAccountApp's gate.
+func TestAPISessionPromoteUnshippedVersionSurfacesServerText(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ json.NewEncoder(w).Encode(map[string]string{
+ "error": "Version 9.9.9 is not a shipped release of app.",
+ })
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ _, err := s.promote(context.Background(), "acct", "app", "9.9.9", "canary/a")
+ if err == nil || !strings.Contains(err.Error(), "not a shipped release") {
+ t.Fatalf("promote 404 with a JSON diagnosis = %v, want the server's own text", err)
+ }
+ if strings.Contains(err.Error(), "check -account and -app") {
+ t.Fatalf("promote 404 with a real diagnosis must not be replaced by the membership message: %v", err)
+ }
+}
+
+// TestAPISessionPromoteAccountGateNotFound is the companion case: the SAME
+// 404 status, but apiAccountApp's gate (an unknown account/app or one the
+// caller doesn't belong to) — a text/plain http.NotFound body with no
+// "error" field at all — must still get the generic membership message.
+func TestAPISessionPromoteAccountGateNotFound(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ _, err := s.promote(context.Background(), "nope", "app", "1.2.3", "canary/a")
+ if err == nil || !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") {
+ t.Fatalf("promote against the account/app gate 404 = %v, want the membership message", err)
+ }
}
// TestAPISessionChannelsNotFound pins the 404 case the parent task called
diff --git a/auth.go b/auth.go
index 44a5d46d2..fba2b8a39 100644
--- a/auth.go
+++ b/auth.go
@@ -395,6 +395,22 @@ func (c *consoleClient) do(ctx context.Context, method, path string, in, out any
return res.StatusCode, nil
}
+// shipHTTPClientTimeout is the ABSOLUTE per-request Timeout doMultipart's own
+// client carries — zero (the production default) means none: a ship relies
+// solely on its caller's context deadline (apimode.go's ship gives itself a
+// generous 15 minutes), because the console's own budget for a big upload —
+// up to 10 minutes to read the body, up to 2 more to store it
+// (internal/console/apicli.go) — already exceeds the 30s absolute Timeout
+// c.httpc carries for the JSON verbs. Reusing that client here would abort a
+// large, still-healthy upload CLIENT-side while the server was still working
+// on it, reporting a live ship as failed.
+//
+// A var only so a test can shrink it far below what any real upload takes —
+// set it small against a deliberately slow server and the request fails
+// fast, proving the failure mode the zero-Timeout client is built to avoid,
+// without an actual multi-minute sleep to demonstrate it.
+var shipHTTPClientTimeout time.Duration
+
// doMultipart runs one multipart/form-data POST, streaming the body through
// an io.Pipe rather than buffering it whole: write feeds the multipart.Writer
// from a goroutine while http.Client reads the other end of the pipe as it
@@ -417,13 +433,22 @@ func (c *consoleClient) doMultipart(ctx context.Context, path string, write func
}()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+path, pr)
if err != nil {
+ // The write goroutine above is already running (or about to run) and
+ // will otherwise block forever trying to write to a pipe nobody ever
+ // reads: closing our end unblocks it with io.ErrClosedPipe instead of
+ // leaking it.
+ pr.Close()
return 0, nil, err
}
req.Header.Set("Content-Type", mw.FormDataContentType())
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
- res, err := c.httpc.Do(req)
+ // A client of its own — see shipHTTPClientTimeout above — never c.httpc,
+ // whose 30s absolute Timeout is sized for the JSON verbs, not a large
+ // upload.
+ client := &http.Client{Timeout: shipHTTPClientTimeout}
+ res, err := client.Do(req)
if err != nil {
return 0, nil, err
}