carlos / platform Public

cli: CARLOS_SERVICE_TOKEN selects the console for CI (#229)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 56fd7846466ce12e1961a30dbda8bb475e2b74ea parent 99dac9b
2 files changed, +226 −0
  • apimode.go +32 −0
  • apimode_test.go +194 −0
diff --git a/apimode.go b/apimode.go
index 2e75665df..ece90110a 100644
--- a/apimode.go
+++ b/apimode.go
@@ -43,6 +43,11 @@ import (
"github.com/carlosframework/platform/internal/schedule"
)
+// serviceSessionLabel stands in for the address in "using X as Y" lines
+// when the session is a service token: the CLI has no door that tells it
+// the credential's name, and a person's whoami does not answer for one.
+const serviceSessionLabel = "a service credential (CARLOS_SERVICE_TOKEN)"
+
// 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.
@@ -90,6 +95,12 @@ type apiSession struct {
// meaning it gets bucket access instead, with no signal that their flag was
// ignored. A BARE env with no --console still wins silently, unchanged: that
// half of §1 (operators, CI, box-side scripts) is deliberately untouched.
+//
+// CARLOS_SERVICE_TOKEN is CI's way in (spec §8): with it set, API mode runs
+// as that token against the console named by --console or CARLOS_CONSOLE,
+// and nothing else is consulted: no config default, no stored credential,
+// no credentials file. A token beside a store env is refused, as --console
+// is, because the two name different places to send the work.
func resolveMode(consoleFlag string) (*apiSession, error) {
bucketSet := os.Getenv("CARLOS_DEPLOYMENT_BUCKET") != ""
dirSet := os.Getenv("CARLOS_DEPLOYMENT_DIR") != ""
@@ -97,9 +108,30 @@ func resolveMode(consoleFlag string) (*apiSession, error) {
return nil, errors.New("--console conflicts with CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR " +
"— unset the env to use the console, or drop --console")
}
+ token := os.Getenv("CARLOS_SERVICE_TOKEN")
+ if token != "" && (bucketSet || dirSet) {
+ return nil, errors.New("CARLOS_SERVICE_TOKEN and CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR are both set. The token works through the console and the store variables go straight to the bucket. Unset one of them.")
+ }
if bucketSet || dirSet {
return nil, nil
}
+ if token != "" {
+ // CI's path (spec §8): the console is named outright, never taken
+ // from a config default or a stored credential, and the credentials
+ // file is not read.
+ console := consoleFlag
+ if console == "" {
+ console = os.Getenv("CARLOS_CONSOLE")
+ }
+ if console == "" {
+ return nil, errors.New("CARLOS_SERVICE_TOKEN is set but no console is named: set CARLOS_CONSOLE or pass --console")
+ }
+ origin, err := normalizeConsole(console)
+ if err != nil {
+ return nil, err
+ }
+ return &apiSession{c: newConsoleClient(origin, token), console: origin, address: serviceSessionLabel}, nil
+ }
cred, err := loggedInCredential(consoleFlag)
if err != nil {
// loggedInCredential's own errors already name their own fix (log in
diff --git a/apimode_test.go b/apimode_test.go
index fa92eb506..28e2e7717 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -18,6 +18,7 @@ import (
"os"
"path/filepath"
"strings"
+ "sync"
"sync/atomic"
"testing"
"time"
@@ -52,6 +53,7 @@ func clearDeploymentEnv(t *testing.T) {
t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
t.Setenv("CARLOS_DEPLOYMENT_DIR", "")
t.Setenv("CARLOS_CONSOLE", "")
+ t.Setenv("CARLOS_SERVICE_TOKEN", "")
}
func TestResolveModeDirectWins(t *testing.T) {
@@ -3877,3 +3879,195 @@ func TestSealSecretAPIBindsWhenTheConsoleNamesAnAccount(t *testing.T) {
t.Fatalf("fallback envelope is not the v1 shape an older door accepts (first byte %#x)", raw[0])
}
}
+
+func TestResolveModeServiceToken(t *testing.T) {
+ useIsolatedConfig(t)
+ clearDeploymentEnv(t)
+ var gotAuth string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotAuth = r.Header.Get("Authorization")
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"accounts":[]}`))
+ }))
+ defer ts.Close()
+ // A person's credential for another console is on disk too. The token
+ // wins and the file is not consulted.
+ f, _ := loadCredentials()
+ f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "person", ExpiresAt: time.Now().Add(time.Hour)})
+ if err := f.save(); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret")
+ t.Setenv("CARLOS_CONSOLE", ts.URL)
+ sess, err := resolveMode("")
+ if err != nil || sess == nil {
+ t.Fatalf("resolveMode = %v, %v", sess, err)
+ }
+ if sess.address != serviceSessionLabel {
+ t.Fatalf("address = %q", sess.address)
+ }
+ if _, _, err := sess.getJSON(context.Background(), "/api/cli/apps"); err != nil {
+ t.Fatal(err)
+ }
+ if gotAuth != "Bearer svc-secret" {
+ t.Fatalf("Authorization = %q, want the service token", gotAuth)
+ }
+}
+
+func TestResolveModeServiceTokenNeedsAConsole(t *testing.T) {
+ useIsolatedConfig(t)
+ clearDeploymentEnv(t)
+ t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret")
+ if _, err := resolveMode(""); err == nil || !strings.Contains(err.Error(), "CARLOS_CONSOLE") {
+ t.Fatalf("err = %v, want D27", err)
+ }
+}
+
+func TestResolveModeServiceTokenRefusedWithStoreEnv(t *testing.T) {
+ for _, env := range []string{"CARLOS_DEPLOYMENT_BUCKET", "CARLOS_DEPLOYMENT_DIR"} {
+ t.Run(env, func(t *testing.T) {
+ useIsolatedConfig(t)
+ clearDeploymentEnv(t)
+ t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret")
+ t.Setenv("CARLOS_CONSOLE", "https://c.example")
+ t.Setenv(env, t.TempDir())
+ sess, err := resolveMode("")
+ if err == nil || sess != nil || !strings.Contains(err.Error(), "CARLOS_SERVICE_TOKEN") {
+ t.Fatalf("= %v, %v; want D28", sess, err)
+ }
+ })
+ }
+}
+
+// A bare store variable still selects direct mode silently when no token is
+// set: the token arm changes nothing for anyone without one.
+func TestResolveModeStoreEnvWithoutTokenStillDirect(t *testing.T) {
+ useIsolatedConfig(t)
+ clearDeploymentEnv(t)
+ t.Setenv("CARLOS_DEPLOYMENT_DIR", t.TempDir())
+ if sess, err := resolveMode(""); sess != nil || err != nil {
+ t.Fatalf("= %v, %v; want (nil, nil)", sess, err)
+ }
+}
+
+// carlosDeployDoors is a COPY of spec §6's list of doors a deploy credential
+// may reach (R6 included). The root package cannot import the console's test
+// variable, so the source of truth is deployDoors in
+// internal/console/deploydoors_test.go: change one, change the other.
+var carlosDeployDoors = []string{
+ "GET /api/cli/apps",
+ "GET /api/cli/apps/{account}/{app}/channels",
+ "POST /api/cli/apps/{account}/{app}/releases",
+ "GET /api/cli/apps/{account}/{app}/releases",
+ "POST /api/cli/apps/{account}/{app}/promote",
+ "POST /api/cli/apps/{account}/{app}/rollback",
+ "GET /api/cli/apps/{account}/{app}/tags",
+ "GET /api/cli/apps/{account}/{app}/tags/{version}",
+ "GET /api/cli/apps/{account}/{app}/version-target",
+ "GET /api/cli/apps/{account}/{app}/pipeline",
+ "GET /api/cli/apps/{account}/{app}/pending",
+ "GET /api/cli/apps/{account}/{app}/instances",
+ "GET /api/cli/apps/{account}/{app}/regions",
+}
+
+// doorPattern maps a recorded "METHOD /path" back to the route pattern it
+// was served by: the account and app segments become {account} and {app},
+// and the segment after "tags" becomes {version}.
+func doorPattern(method, path string) string {
+ if i := strings.IndexByte(path, '?'); i >= 0 {
+ path = path[:i]
+ }
+ seg := strings.Split(strings.Trim(path, "/"), "/")
+ // api, cli, apps, account, app, tail...
+ if len(seg) >= 5 {
+ seg[3], seg[4] = "{account}", "{app}"
+ if len(seg) == 7 && seg[5] == "tags" {
+ seg[6] = "{version}"
+ }
+ }
+ return method + " /" + strings.Join(seg, "/")
+}
+
+func TestCarlosDeployRoutinePathUsesOnlyDeployDoors(t *testing.T) {
+ var mu sync.Mutex
+ var seen []string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ seen = append(seen, r.Method+" "+r.URL.Path)
+ mu.Unlock()
+ w.Header().Set("Content-Type", "application/json")
+ if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/releases") {
+ w.WriteHeader(http.StatusCreated)
+ w.Write([]byte(`{"result":"shipped","version":"1.2.3","artifacts":{}}`))
+ return
+ }
+ w.Write([]byte(`{"result":"promoted"}`))
+ }))
+ defer ts.Close()
+ record := func() []string {
+ mu.Lock()
+ defer mu.Unlock()
+ out := seen
+ seen = nil
+ return out
+ }
+
+ file := filepath.Join(t.TempDir(), "a.bin")
+ if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ ctx := context.Background()
+ s := newTestSession(ts.URL)
+ a := apiAcct{Seg: "bab", Exact: true}
+
+ steps := []struct {
+ name string
+ run func() error
+ }{
+ {"ship", func() error {
+ _, err := s.ship(ctx, a, "app", "1.2.3", "binary", "", "", map[string]string{"alpha-amd64": file}, nil)
+ return err
+ }},
+ {"promote", func() error { _, err := s.promote(ctx, a, "app", "1.2.3", "canary", false); return err }},
+ {"rollback", func() error { return s.rollback(ctx, a, "app", "canary") }},
+ {"channels", func() error { _, err := s.channels(ctx, a, "app"); return err }},
+ {"instanceList", func() error { _, _, _, err := s.instanceList(ctx, a, "app", "", ""); return err }},
+ {"appRegions", func() error { _, err := s.appRegions(ctx, a, "app"); return err }},
+ }
+ allowed := map[string]bool{}
+ for _, d := range carlosDeployDoors {
+ allowed[d] = true
+ }
+ for _, st := range steps {
+ if err := st.run(); err != nil {
+ t.Fatalf("%s: %v", st.name, err)
+ }
+ reqs := record()
+ if len(reqs) == 0 {
+ t.Fatalf("%s sent no request", st.name)
+ }
+ for _, r := range reqs {
+ method, path, _ := strings.Cut(r, " ")
+ if p := doorPattern(method, path); !allowed[p] {
+ t.Errorf("%s reached %q (%s), which a deploy credential is not given", st.name, r, p)
+ }
+ }
+ }
+
+ // The watch's diagnosis reads the logs. That stays a best-effort read a
+ // deploy credential is refused, so it must not be on the list.
+ if _, err := s.logsGet(ctx, a, "app", nil); err != nil {
+ t.Fatalf("logsGet: %v", err)
+ }
+ reqs := record()
+ if len(reqs) != 1 {
+ t.Fatalf("logsGet sent %v", reqs)
+ }
+ method, path, _ := strings.Cut(reqs[0], " ")
+ if !strings.HasSuffix(path, "/logs") {
+ t.Fatalf("logsGet path = %q, want .../logs", path)
+ }
+ if p := doorPattern(method, path); allowed[p] {
+ t.Errorf("%q is on the deploy door list; the logs read must stay refused to a deploy credential", p)
+ }
+}