carlos / platform Public

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.
Paul Campbell 83c4dda2e857d6c4c74233aab73a60e579bcb733 parent f0b5fa2
3 files changed, +662 −0
  • apimode.go +305 −0
  • apimode_test.go +316 −0
  • auth.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"`