cli: credential-first mode resolution and console API client
resolveMode picks direct bucket access when CARLOS_DEPLOYMENT_BUCKET/DIR is set (unchanged behaviour) and falls back to the caller's console credential otherwise, refusing with both fixes named when neither is configured. apiSession wraps a consoleClient with promote/rollback/ channels/ship, passing -account through verbatim (never resolved against the local registry), decoding the stable-refusal 403's console_url that do() would otherwise drop, giving 404s a sane message instead of choking on the account/app gate's text/plain body, and streaming ship's multipart body through an io.Pipe via a new consoleClient.doMultipart so a large artifact isn't buffered twice.
3 files changed,
+662
−0
apimode.go+305 −0apimode_test.go+316 −0auth.go+41 −0
diff --git a/apimode.go b/apimode.go| new file mode 100644 |
| index 000000000..a7e8dd4ea |
| --- /dev/null |
| +++ b/apimode.go |
| @@ -0,0 +1,305 @@ |
| +// Which mode the CLI runs in, and the console API client for the mode that |
| +// doesn't touch the deployment bucket directly. |
| +// |
| +// Every verb that used to reach straight for CARLOS_DEPLOYMENT_BUCKET / |
| +// CARLOS_DEPLOYMENT_DIR now checks resolveMode first. Direct-mode wins |
| +// whenever either env var is set — that is unchanged behaviour, and it wins |
| +// unconditionally: a box configured for direct bucket access keeps working |
| +// exactly as it always has, whether or not the same machine also happens to |
| +// hold a console credential (`carlos auth login` is meant for a human's |
| +// laptop, not a box; see auth.go's HEADLESS AND BOX-SIDE USE note). Only when |
| +// neither env var is set does the CLI need a credential at all, and it is |
| +// credential-first from there: no local registry, no guessing an account by |
| +// name against a bucket the caller may not even be able to reach — the |
| +// console resolves the account (by sqid OR name) against the caller's own |
| +// memberships, which is also the authorization check. |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "mime/multipart" |
| + "net/http" |
| + "net/url" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| +) |
| + |
| +// apiSession is the resolved API mode: which console, as whom. console and |
| +// address are carried for messages (Task 5's "logged in to X as Y" lines), |
| +// not used by the calls below. |
| +type apiSession struct { |
| + c *consoleClient |
| + console string // origin, for messages |
| + address string |
| +} |
| + |
| +// resolveMode decides which mode the CLI is in. (nil, nil) means direct mode |
| +// — the store env is set, and the caller proceeds exactly as it does today, |
| +// straight against the bucket. A non-nil session means API mode: every call |
| +// goes through the console named in the credential loggedInCredential |
| +// resolved, as the address that credential belongs to. |
| +// |
| +// consoleFlag is the verb's -console value ("" meaning "the only one" — |
| +// pick's ordinary disambiguation rule, unchanged here). |
| +func resolveMode(consoleFlag string) (*apiSession, error) { |
| + if os.Getenv("CARLOS_DEPLOYMENT_BUCKET") != "" || os.Getenv("CARLOS_DEPLOYMENT_DIR") != "" { |
| + return nil, nil |
| + } |
| + cred, err := loggedInCredential(consoleFlag) |
| + if err != nil { |
| + // loggedInCredential's own errors already name their own fix (log in |
| + // to the right console, re-log in, disambiguate with -console) — |
| + // except the plain "there are no credentials at all" case (pick's |
| + // exact, non-interpolated wording below), which never mentions |
| + // direct mode because pick() has no idea direct mode exists. That's |
| + // the one case worth widening here: a fresh checkout with neither a |
| + // 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 { |
| + 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") |
| + } |
| + return nil, err |
| + } |
| + return &apiSession{ |
| + c: newConsoleClient(cred.Console, cred.Token), |
| + console: cred.Console, |
| + address: cred.Address, |
| + }, nil |
| +} |
| + |
| +// 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. |
| +const noCredentialsMsg = "not logged in — run: carlos auth login -console <url>" |
| + |
| +// 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. |
| +type apiChannel struct { |
| + Channel string |
| + Version string |
| + PromotedAt string |
| + Rung string |
| + Err string |
| +} |
| + |
| +// 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 |
| +// memberships; this client must never resolve it against a local registry. |
| +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. |
| +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. |
| +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) |
| + } |
| + return fmt.Errorf("unexpected response (status %d)", status) |
| +} |
| + |
| +// 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 |
| +// rollback need the FULL body on a stable refusal — its console_url would |
| +// otherwise be discarded before the caller ever sees it. |
| +func (s *apiSession) postJSON(ctx context.Context, path string, in any) (int, []byte, error) { |
| + raw, err := json.Marshal(in) |
| + if err != nil { |
| + return 0, nil, err |
| + } |
| + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.c.base+path, bytes.NewReader(raw)) |
| + if err != nil { |
| + return 0, nil, err |
| + } |
| + req.Header.Set("Content-Type", "application/json") |
| + 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 |
| +} |
| + |
| +// stableRefusal decodes handleAPIPromote/handleAPIRollback's 403 shape |
| +// ({"error", "console_url"}) and, when it matches, returns the error the |
| +// caller should see: the console's own refusal text plus where to finish |
| +// the job — a passkey step-up has no bearer-token equivalent, so stable |
| +// promotion/rollback always ends here, never on this machine. |
| +func stableRefusal(status int, body []byte) (error, bool) { |
| + if status != http.StatusForbidden { |
| + return nil, false |
| + } |
| + var refusal struct { |
| + Error string `json:"error"` |
| + ConsoleURL string `json:"console_url"` |
| + } |
| + if json.Unmarshal(body, &refusal) != nil || refusal.Error == "" { |
| + return nil, false |
| + } |
| + 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 { |
| + 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 |
| + } |
| + if status == http.StatusOK { |
| + return nil |
| + } |
| + if refErr, ok := stableRefusal(status, body); ok { |
| + return refErr |
| + } |
| + return apiErrorFromBody(status, body) |
| +} |
| + |
| +// rollback asks the console to roll channel back to its previous version. |
| +func (s *apiSession) rollback(ctx context.Context, account, app, channel string) error { |
| + in := struct { |
| + Channel string `json:"channel"` |
| + }{channel} |
| + status, body, err := s.postJSON(ctx, apiPath(account, app, "/rollback"), in) |
| + if err != nil { |
| + return err |
| + } |
| + if status == http.StatusOK { |
| + return nil |
| + } |
| + if refErr, ok := stableRefusal(status, body); ok { |
| + return refErr |
| + } |
| + return apiErrorFromBody(status, body) |
| +} |
| + |
| +// channels reads one app's channel ladder through the console. |
| +func (s *apiSession) channels(ctx context.Context, account, app string) ([]apiChannel, error) { |
| + var res struct { |
| + Channels []struct { |
| + Channel string `json:"channel"` |
| + Version string `json:"version"` |
| + PromotedAt string `json:"promoted_at"` |
| + Rung string `json:"rung"` |
| + 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 |
| + } |
| + out := make([]apiChannel, len(res.Channels)) |
| + for i, ch := range res.Channels { |
| + out[i] = apiChannel{ |
| + Channel: ch.Channel, Version: ch.Version, |
| + PromotedAt: ch.PromotedAt, Rung: ch.Rung, Err: ch.Error, |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +// 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 { |
| + names := make([]string, 0, len(files)) |
| + for name := range files { |
| + names = append(names, name) |
| + } |
| + sort.Strings(names) |
| + |
| + status, body, err := s.c.doMultipart(ctx, apiPath(account, app, "/releases"), func(mw *multipart.Writer) error { |
| + metaJSON, err := json.Marshal(struct { |
| + Version string `json:"version"` |
| + Kind string `json:"kind"` |
| + }{version, kind}) |
| + if err != nil { |
| + return err |
| + } |
| + metaPart, err := mw.CreateFormField("meta") |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := metaPart.Write(metaJSON); err != nil { |
| + return err |
| + } |
| + for _, name := range names { |
| + if err := writeFilePart(mw, name, files[name]); err != nil { |
| + return err |
| + } |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + return err |
| + } |
| + if status == http.StatusOK || status == http.StatusCreated { |
| + return nil |
| + } |
| + return apiErrorFromBody(status, body) |
| +} |
| + |
| +// writeFilePart streams one local file into a multipart part named name, so |
| +// a 30MiB artifact is read a chunk at a time rather than buffered whole |
| +// before it starts crossing the wire. |
| +func writeFilePart(mw *multipart.Writer, name, path string) error { |
| + f, err := os.Open(path) |
| + if err != nil { |
| + return err |
| + } |
| + defer f.Close() |
| + part, err := mw.CreateFormFile(name, filepath.Base(path)) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = io.Copy(part, f) |
| + return err |
| +} |
diff --git a/apimode_test.go b/apimode_test.go| new file mode 100644 |
| index 000000000..b551b9bd2 |
| --- /dev/null |
| +++ b/apimode_test.go |
| @@ -0,0 +1,316 @@ |
| +// Tests for API mode resolution and the console API client the other CLI |
| +// verbs (Task 5) build on: which mode the CLI is in (direct bucket access or |
| +// through a console it holds a credential for), and the four calls a session |
| +// in API mode can make. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "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. |
| +func clearDeploymentEnv(t *testing.T) { |
| + t.Helper() |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", "") |
| +} |
| + |
| +func TestResolveModeDirectWins(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "some-bucket") |
| + |
| + // A credential is present too — direct mode must win regardless, since a |
| + // box can be both logged in and configured for direct bucket access, and |
| + // existing direct-mode behaviour must not change out from under it. |
| + 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 err != nil || sess != nil { |
| + t.Fatalf("resolveMode with CARLOS_DEPLOYMENT_BUCKET set = (%+v, %v), want (nil, nil)", sess, err) |
| + } |
| +} |
| + |
| +func TestResolveModeDirWins(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", t.TempDir()) |
| + |
| + sess, err := resolveMode("") |
| + if err != nil || sess != nil { |
| + t.Fatalf("resolveMode with CARLOS_DEPLOYMENT_DIR set = (%+v, %v), want (nil, nil)", sess, err) |
| + } |
| +} |
| + |
| +func TestResolveModeAPI(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 err != nil { |
| + t.Fatalf("resolveMode: %v", err) |
| + } |
| + if sess == nil { |
| + t.Fatal("resolveMode with one credential = nil session, want one") |
| + } |
| + if sess.console != "https://a.example" { |
| + t.Fatalf("session console = %q, want https://a.example", sess.console) |
| + } |
| + if sess.address != "a@keymail.dev" { |
| + t.Fatalf("session address = %q, want a@keymail.dev", sess.address) |
| + } |
| + if sess.c == nil { |
| + t.Fatal("session has no console client") |
| + } |
| +} |
| + |
| +func TestResolveModeNothing(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + sess, err := resolveMode("") |
| + if sess != nil { |
| + t.Fatalf("resolveMode with nothing configured = %+v, want nil session", sess) |
| + } |
| + if err == nil { |
| + t.Fatal("resolveMode with nothing configured must error") |
| + } |
| + if !strings.Contains(err.Error(), "CARLOS_DEPLOYMENT_BUCKET") { |
| + t.Fatalf("error %q does not mention CARLOS_DEPLOYMENT_BUCKET", err) |
| + } |
| + if !strings.Contains(err.Error(), "carlos auth login") { |
| + t.Fatalf("error %q does not mention carlos auth login", err) |
| + } |
| +} |
| + |
| +func newTestSession(base string) *apiSession { |
| + return &apiSession{c: newConsoleClient(base, "cli-token"), console: base, address: "a@keymail.dev"} |
| +} |
| + |
| +// TestAPISessionShipMultipart pins the wire shape handleAPIShip |
| +// (internal/console/apicli.go) requires: bearer auth, the meta part first |
| +// with exact JSON, then artifact parts in map-key-sorted order (so the wire |
| +// bytes are deterministic instead of depending on map iteration order). |
| +func TestAPISessionShipMultipart(t *testing.T) { |
| + var gotAuth string |
| + var partNames []string |
| + var metaBody string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotAuth = r.Header.Get("Authorization") |
| + mr, err := r.MultipartReader() |
| + if err != nil { |
| + t.Fatalf("request was not multipart: %v", err) |
| + } |
| + for { |
| + part, err := mr.NextPart() |
| + if err == io.EOF { |
| + break |
| + } |
| + if err != nil { |
| + t.Fatalf("reading part: %v", err) |
| + } |
| + b, err := io.ReadAll(part) |
| + if err != nil { |
| + t.Fatalf("reading part body: %v", err) |
| + } |
| + if part.FormName() == "meta" { |
| + metaBody = string(b) |
| + } |
| + partNames = append(partNames, part.FormName()) |
| + } |
| + 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() |
| + fileA := filepath.Join(dir, "a.bin") |
| + fileB := filepath.Join(dir, "b.bin") |
| + if err := os.WriteFile(fileA, []byte("aaa"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(fileB, []byte("bbb"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + s := newTestSession(ts.URL) |
| + 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 gotAuth != "Bearer cli-token" { |
| + t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer cli-token") |
| + } |
| + if want := `{"version":"1.2.3","kind":"binary"}`; metaBody != want { |
| + t.Fatalf("meta part = %q, want %q", metaBody, want) |
| + } |
| + wantOrder := []string{"meta", "alpha-amd64", "zeta-amd64"} |
| + if len(partNames) != len(wantOrder) { |
| + t.Fatalf("part names = %v, want %v", partNames, wantOrder) |
| + } |
| + for i := range wantOrder { |
| + if partNames[i] != wantOrder[i] { |
| + t.Fatalf("part order = %v, want %v", partNames, wantOrder) |
| + } |
| + } |
| +} |
| + |
| +func TestAPISessionShipErrorSurfacesServerMessage(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusConflict) |
| + json.NewEncoder(w).Encode(map[string]string{ |
| + "error": "version 1.2.3 was already shipped with different content", |
| + }) |
| + })) |
| + 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) |
| + 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) |
| + } |
| +} |
| + |
| +// 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" |
| +// string and a promote() caller needs the link too. |
| +func TestAPISessionStableRefusal(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusForbidden) |
| + json.NewEncoder(w).Encode(map[string]string{ |
| + "error": "stable needs a passkey — finish in the console", |
| + "console_url": "http://x/a/b", |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + err := s.promote(context.Background(), "acct", "app", "1.2.3", "canary") |
| + if err == nil { |
| + t.Fatal("promote against a stable refusal must error") |
| + } |
| + if !strings.Contains(err.Error(), "http://x/a/b") { |
| + t.Fatalf("promote error %q does not contain the console_url", err) |
| + } |
| + if !strings.Contains(err.Error(), "passkey") { |
| + t.Fatalf("promote error %q does not contain the console's refusal text", err) |
| + } |
| +} |
| + |
| +func TestAPISessionRollbackStableRefusal(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusForbidden) |
| + json.NewEncoder(w).Encode(map[string]string{ |
| + "error": "stable needs a passkey — finish in the console", |
| + "console_url": "http://x/a/b", |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + err := s.rollback(context.Background(), "acct", "app", "stable") |
| + if err == nil || !strings.Contains(err.Error(), "http://x/a/b") { |
| + t.Fatalf("rollback error = %v, want it to contain the console_url", err) |
| + } |
| +} |
| + |
| +func TestAPISessionPromoteSuccess(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.Header.Get("Authorization") != "Bearer cli-token" { |
| + w.WriteHeader(http.StatusUnauthorized) |
| + return |
| + } |
| + 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) |
| + } |
| + 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 { |
| + t.Fatalf("promote: %v", err) |
| + } |
| +} |
| + |
| +// TestAPISessionChannelsNotFound pins the 404 case the parent task called |
| +// out specifically: apiAccountApp's gate answers http.NotFound, a |
| +// text/plain body, not JSON — the client must not choke on that and must |
| +// leave the caller with a sane, actionable message instead of the raw |
| +// "404 page not found" body. |
| +func TestAPISessionChannelsNotFound(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.channels(context.Background(), "nope", "app") |
| + if err == nil { |
| + t.Fatal("channels against a 404 must error") |
| + } |
| + if !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") { |
| + t.Fatalf("channels 404 error = %q, want a message naming -account/-app", err) |
| + } |
| +} |
| + |
| +func TestAPISessionChannels(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "channels": []map[string]any{ |
| + {"channel": "canary/a", "version": "1.2.3", "promoted_at": "2026-08-03T00:00:00Z", "rung": "canary"}, |
| + {"channel": "broken", "error": "signature mismatch"}, |
| + }, |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + chans, err := s.channels(context.Background(), "acct", "app") |
| + if err != nil { |
| + t.Fatalf("channels: %v", err) |
| + } |
| + if len(chans) != 2 { |
| + t.Fatalf("channels = %+v, want 2 rows", chans) |
| + } |
| + if chans[0].Channel != "canary/a" || chans[0].Version != "1.2.3" || chans[0].Rung != "canary" { |
| + t.Fatalf("row 0 = %+v", chans[0]) |
| + } |
| + if chans[1].Channel != "broken" || chans[1].Err != "signature mismatch" { |
| + t.Fatalf("row 1 = %+v", chans[1]) |
| + } |
| +} |
diff --git a/auth.go b/auth.go| index 6e3d42c37..44a5d46d2 100644 |
| --- a/auth.go |
| +++ b/auth.go |
| @@ -37,6 +37,7 @@ import ( |
| "flag" |
| "fmt" |
| "io" |
| + "mime/multipart" |
| "net" |
| "net/http" |
| "net/url" |
| @@ -394,6 +395,46 @@ func (c *consoleClient) do(ctx context.Context, method, path string, in, out any |
| return res.StatusCode, nil |
| } |
| +// 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 |
| +// sends the request, so a 30MiB artifact is never held twice over (once in |
| +// caller memory, once again in an http.Client-owned buffer). |
| +// |
| +// Unlike do(), a non-200 status here is NOT turned into an error — the raw |
| +// body comes back alongside the status so a caller that needs more than a |
| +// bare {"error"} string (a stable-refusal's console_url, ship()'s per- |
| +// artifact result) can decode it itself. |
| +func (c *consoleClient) doMultipart(ctx context.Context, path string, write func(*multipart.Writer) error) (int, []byte, error) { |
| + pr, pw := io.Pipe() |
| + mw := multipart.NewWriter(pw) |
| + go func() { |
| + err := write(mw) |
| + if err == nil { |
| + err = mw.Close() |
| + } |
| + pw.CloseWithError(err) |
| + }() |
| + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+path, pr) |
| + if err != nil { |
| + 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) |
| + 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 |
| +} |
| + |
| type startResponse struct { |
| UserCode string `json:"user_code"` |
| DeviceCode string `json:"device_code"` |