carlos / platform Public

cli: deploy --region checks the stored region before shipping and stops on a failed read (nearest-region §11)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 0bf304f1f22d9a25bff0f4a5cac7613479665bf3 parent b830592
4 files changed, +409 −0
  • apimode.go +54 −0
  • deploy.go +37 −0
  • deploy_region_e2e_test.go +262 −0
  • deploy_region_test.go +56 −0
diff --git a/apimode.go b/apimode.go
index ba79a46c8..681c12fda 100644
--- a/apimode.go
+++ b/apimode.go
@@ -1028,6 +1028,60 @@ func (s *apiSession) createApp(ctx context.Context, a apiAcct, app, place string
return res, nil
}
+// apiAppDefault, apiRegionEntry and apiRegionsResult are the CLI's view of
+// GET …/regions (nearest-region spec §9.3). AppDefault is nil from a
+// console that predates it. Region is the STORED region in the console's own
+// vocabulary: a home app reports the home's code, never "".
+type apiAppDefault struct {
+ Region string `json:"region"`
+ Name string `json:"name"`
+ Source string `json:"source"`
+ Effective string `json:"effective"`
+ Reason string `json:"reason"`
+ Available *bool `json:"available"`
+ RegionKnown bool `json:"region_known"`
+ PlacementKnown bool `json:"placement_known"`
+ InstanceDomain string `json:"instance_domain"`
+}
+
+type apiRegionEntry struct {
+ Code string `json:"code"`
+ Name string `json:"name"`
+}
+
+type apiRegionsResult struct {
+ Regions []apiRegionEntry `json:"regions"`
+ Default string `json:"default"`
+ AppDefault *apiAppDefault `json:"app_default"`
+}
+
+// nameOf is the place name the console gave code, else the code itself.
+func (r apiRegionsResult) nameOf(code string) string {
+ for _, e := range r.Regions {
+ if e.Code == code && e.Name != "" {
+ return e.Name
+ }
+ }
+ return code
+}
+
+// appRegions reads GET …/regions for app: the regions the console knows and,
+// from a console that has it, the app's own default.
+func (s *apiSession) appRegions(ctx context.Context, a apiAcct, app string) (apiRegionsResult, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/regions"))
+ if err != nil {
+ return apiRegionsResult{}, err
+ }
+ if status != http.StatusOK {
+ return apiRegionsResult{}, apiErrorFromBody(status, body)
+ }
+ var out apiRegionsResult
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiRegionsResult{}, fmt.Errorf("regions: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
// 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. It DOES have a proposed case, exactly
diff --git a/deploy.go b/deploy.go
index 91dc3f5f6..9213b5a02 100644
--- a/deploy.go
+++ b/deploy.go
@@ -28,6 +28,33 @@ import (
"github.com/carlosframework/platform/internal/caps"
)
+// deployRegionPreflight is spec §11: --region on an EXISTING app is
+// compared with the app's STORED region before anything ships. A failed
+// read STOPS the deploy (C15b), and so does a successful answer whose
+// stored region the console could not read (C15c, codex plan-R2 3):
+// either way the region is unknown, and shipping would go to whatever the
+// app already is. Only a successful response without app_default, an
+// older console, skips the check. The console states a home app's region
+// as the home's own code, so naming the home matches it.
+func deployRegionPreflight(res apiRegionsResult, readErr error, app, asked string) error {
+ if asked == "" {
+ return nil
+ }
+ if readErr != nil {
+ return errors.New(deployRegionUnreadable(app, readErr))
+ }
+ if res.AppDefault == nil {
+ return nil
+ }
+ if !res.AppDefault.RegionKnown {
+ return errors.New(deployRegionUnknown(app))
+ }
+ if res.AppDefault.Region == asked {
+ return nil
+ }
+ return errors.New(deployRegionMismatch(app, res.AppDefault.Name))
+}
+
// runDeploy is `carlos deploy`. API-mode only (spec §6): direct-bucket
// operators keep ship + promote as separate acts; deploy is the member path
// that ships, promotes to the channel the app's instances already follow,
@@ -95,6 +122,7 @@ func runDeploy(args []string) {
// flags or from config (project-defaults spec, 2026-08-13).
newly := map[string]any{}
+ createdNow := false
if *app == "" {
r, aerr := sess.defaultApp(ctx, acct, false)
var na *noAppsError
@@ -113,12 +141,21 @@ func runDeploy(args []string) {
// (rate limiter and server validation unchanged).
*app = offerCreateApp(ctx, sess, acct, na, strings.TrimSpace(*regionFlag))
newly["app"] = *app
+ createdNow = true
case aerr != nil:
log.Fatal(aerr)
default:
log.Fatal(usage)
}
}
+ // --region on an app that already exists is checked against the app's
+ // stored region before anything is read or shipped (spec §11).
+ if r := strings.TrimSpace(*regionFlag); r != "" && !createdNow {
+ res, err := sess.appRegions(ctx, acct, *app)
+ if err := deployRegionPreflight(res, err, *app, r); err != nil {
+ log.Fatalf("deploy: %v", err)
+ }
+ }
if capsSet == nil {
if capsSet, err = readCapabilities(*capsFile, *app); err != nil {
log.Fatal(err)
diff --git a/deploy_region_e2e_test.go b/deploy_region_e2e_test.go
new file mode 100644
index 000000000..0719c5394
--- /dev/null
+++ b/deploy_region_e2e_test.go
@@ -0,0 +1,262 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/console"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// regionE2E is an in-process console with titogo claimed at appRegion, the
+// signed-in CLI credentials saved, and a front that can fail or strip the
+// /regions answer and counts every write reaching the console.
+type regionE2E struct {
+ bin string // built before useTempCredentials changes directory
+ creds string
+ url string
+ store release.Store
+ mode atomic.Value // "", "fail", "legacy", "region-unknown", "placement-unknown"
+ writes atomic.Int64
+ // creates counts instance-create requests (POST …/instances), so a
+ // test can tell a duplicate create from the other writes (Task 22).
+ creates atomic.Int64
+ cst *console.Store
+ // failGet is a store key whose reads fail while the listing succeeds,
+ // the way one bucket GET can (Task 22, codex plan-R3 1).
+ failGet atomic.Value
+}
+
+// flakyStore fails every read of one key.
+type flakyStore struct {
+ release.Store
+ key *atomic.Value
+}
+
+func (f flakyStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if k, _ := f.key.Load().(string); k != "" && k == key {
+ return nil, "", errors.New("read timeout")
+ }
+ return f.Store.Get(ctx, key)
+}
+
+// regionE2EConsole starts the console with titogo claimed in appRegion. An
+// empty appRegion is a home app, the way CreateAppIn records one: stored "".
+func regionE2EConsole(t *testing.T, appRegion string) *regionE2E {
+ t.Helper()
+ bin := buildCarlos(t) // first: useTempCredentials chdirs out of the module
+ creds := useTempCredentials(t)
+ clearDeploymentEnv(t)
+ const addr, app = "paul@keymail.dev", "titogo"
+ tmp := t.TempDir()
+ e := &regionE2E{bin: bin, creds: creds, store: release.NewDirStore(filepath.Join(tmp, "store"))}
+ e.mode.Store("")
+ e.failGet.Store("")
+ seed, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := release.ParseSigningKey(seed, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, err := release.ParsePublicKey(pubB64, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ e.cst, err = console.Open(filepath.Join(tmp, "console.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { e.cst.Close() })
+ var srv *console.Server
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet && r.Method != http.MethodHead && !strings.Contains(r.URL.Path, "/auth/") {
+ e.writes.Add(1)
+ }
+ if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/instances") {
+ e.creates.Add(1)
+ }
+ if strings.HasSuffix(r.URL.Path, "/regions") {
+ switch e.mode.Load().(string) {
+ case "fail":
+ http.Error(w, `{"error":"boom"}`, http.StatusInternalServerError)
+ return
+ case "legacy", "region-unknown", "placement-unknown":
+ // A successful 200 whose body an older console, or a
+ // console that could not read one fact, would send.
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, r)
+ var m map[string]any
+ json.Unmarshal(rec.Body.Bytes(), &m)
+ d, _ := m["app_default"].(map[string]any)
+ switch e.mode.Load().(string) {
+ case "legacy":
+ delete(m, "app_default")
+ case "region-unknown":
+ d["region_known"], d["region"], d["effective"] = false, "", ""
+ case "placement-unknown":
+ d["placement_known"], d["reason"] = false, "policy-unreadable"
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(m)
+ return
+ }
+ }
+ srv.ServeHTTP(w, r)
+ }))
+ t.Cleanup(ts.Close)
+ e.url = ts.URL
+ srv, err = console.NewServer(console.Config{
+ Origin: ts.URL, Keymail: "http://keymail.invalid",
+ Registry: filepath.Join(tmp, "registry.db"), Releases: flakyStore{e.store, &e.failGet},
+ ReleasePub: pub, Key: priv, AppScheme: "http", AppsDomain: "oncarlos.test",
+ HomeRegion: "ie",
+ }, e.cst)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := e.cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if appRegion == "" {
+ _, err = e.cst.CreateAppIn(console.OpsAccountID, app, addr, time.Now())
+ } else {
+ _, err = e.cst.CreateAppInRegion(console.OpsAccountID, app, addr, appRegion, "chosen", time.Now())
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ token, expiresAt := e2eDeviceLogin(t, ts.URL, e.cst, addr, "e2e@laptop")
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+ e.writes.Store(0) // the login above is setup, not the command under test
+ return e
+}
+
+// run runs the built carlos binary as a signed-in user with no project
+// config in reach.
+func (e *regionE2E) run(t *testing.T, args ...string) (string, error) {
+ t.Helper()
+ cmd := exec.Command(e.bin, args...)
+ cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+e.creds)
+ cmd.Dir = t.TempDir()
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+}
+
+func (e *regionE2E) releases(t *testing.T) int {
+ t.Helper()
+ keys, err := e.store.List(context.Background(), release.AppPrefix(release.OpsAccount, "titogo"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ n := 0
+ for _, k := range keys {
+ if strings.Contains(k, "/releases/") {
+ n++
+ }
+ }
+ return n
+}
+
+func artifact(t *testing.T) string {
+ t.Helper()
+ p := filepath.Join(t.TempDir(), "app")
+ if err := os.WriteFile(p, []byte("#!/bin/sh\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ return p
+}
+
+// deployAsking runs `carlos deploy --region asked` on titogo.
+func (e *regionE2E) deployAsking(t *testing.T, asked string) (string, error) {
+ t.Helper()
+ return e.run(t, "deploy", "--app", "titogo", "--region", asked, "--console", e.url,
+ "--channel", "canary/e2e", "--version", "abc1234", "--timeout", "1s", artifact(t))
+}
+
+// Every refusal is the whole approved sentence, with the app and the place
+// in their own slots, and happens before any write.
+func TestDeployRegionRefusalsShipNothing(t *testing.T) {
+ for _, c := range []struct{ mode, want string }{
+ // C15: the stored region is uk; with no edge publishing a name for
+ // it, the console's place is the code itself.
+ {"", "deploy: " + deployRegionMismatch("titogo", "uk")},
+ // C15b: its %v is the console's own words, so the fixed lead and
+ // tail are asserted whole.
+ {"fail", "deploy: could not read titogo's region ("},
+ {"region-unknown", "deploy: " + deployRegionUnknown("titogo")}, // C15c: a 200 that does not know
+ } {
+ // A subtest each: useTempCredentials chdirs, and only a subtest
+ // restores the directory before the next case builds the binary.
+ t.Run("mode="+c.mode, func(t *testing.T) {
+ e := regionE2EConsole(t, "uk")
+ e.mode.Store(c.mode)
+ out, err := e.deployAsking(t, "ie")
+ if err == nil || !strings.Contains(out, c.want) {
+ t.Fatalf("err=%v out=%s, want a refusal containing %q", err, out, c.want)
+ }
+ if c.mode == "fail" && !strings.Contains(out, "), so deploy stopped before shipping anything. Try again, or run without --region.") {
+ t.Fatalf("C15b tail missing: %s", out)
+ }
+ if n := e.writes.Load(); n != 0 {
+ t.Fatalf("%d write(s) reached the console before the refusal", n)
+ }
+ if n := e.releases(t); n != 0 {
+ t.Fatalf("%d release object(s) were shipped", n)
+ }
+ })
+ }
+}
+
+// An older console (a successful /regions without app_default) skips the
+// check, and the deploy goes on to ship.
+func TestDeployRegionSkipsAgainstAnOlderConsole(t *testing.T) {
+ e := regionE2EConsole(t, "uk")
+ e.mode.Store("legacy")
+ out, _ := e.deployAsking(t, "ie")
+ if strings.Contains(out, deployRegionMismatch("titogo", "")[:20]) || strings.Contains(out, "deploy stopped before shipping") {
+ t.Fatalf("an older console refused: %s", out)
+ }
+ if e.writes.Load() == 0 || e.releases(t) == 0 {
+ t.Fatalf("deploy never shipped: %s", out)
+ }
+}
+
+// --region naming the home on an app stored as "" (the home) is the same
+// region, so the deploy ships; naming the stored region on a placed app does
+// too.
+func TestDeployRegionNamingTheStoredRegionShips(t *testing.T) {
+ for _, c := range []struct{ name, stored, asked string }{
+ {"home app, home named", "", "ie"},
+ {"placed app, its region named", "uk", "uk"},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ e := regionE2EConsole(t, c.stored)
+ out, _ := e.deployAsking(t, c.asked)
+ if strings.Contains(out, "already runs in") || strings.Contains(out, "deploy stopped before shipping") {
+ t.Fatalf("the matching region was refused: %s", out)
+ }
+ if e.writes.Load() == 0 || e.releases(t) == 0 {
+ t.Fatalf("deploy never shipped: %s", out)
+ }
+ })
+ }
+}
diff --git a/deploy_region_test.go b/deploy_region_test.go
new file mode 100644
index 000000000..e1b98accb
--- /dev/null
+++ b/deploy_region_test.go
@@ -0,0 +1,56 @@
+package main
+
+import (
+ "errors"
+ "testing"
+)
+
+// codex plan-R1 2: a READ failure stops deploy; only a successful response
+// without app_default (an older console) skips the check.
+func TestDeployRegionPreflight(t *testing.T) {
+ uk := &apiAppDefault{Region: "uk", Name: "P-uk", Effective: "uk", RegionKnown: true, PlacementKnown: true}
+ fleet := &apiAppDefault{Region: "uk", Name: "P-uk", Effective: "fleet/acme", Reason: "fleet", RegionKnown: true, PlacementKnown: true}
+ // The console reports a home app's region as the home's own code, never
+ // "" (homeRegionOut), so naming the home equals the stored "".
+ home := &apiAppDefault{Region: "ie", Name: "P-ie", Effective: "ie", RegionKnown: true, PlacementKnown: true}
+ boom := errors.New("500 from console")
+ cases := []struct {
+ name string
+ res apiRegionsResult
+ err error
+ asked string
+ want string
+ }{
+ {"no flag", apiRegionsResult{AppDefault: uk}, boom, "", ""},
+ {"equal", apiRegionsResult{AppDefault: uk}, nil, "uk", ""},
+ {"different", apiRegionsResult{AppDefault: uk}, nil, "ie", deployRegionMismatch("blog", "P-uk")},
+ {"older console", apiRegionsResult{}, nil, "ie", ""},
+ {"read failed", apiRegionsResult{}, boom, "ie", deployRegionUnreadable("blog", boom)},
+ {"fleet, stored equal", apiRegionsResult{AppDefault: fleet}, nil, "uk", ""},
+ {"fleet, stored different", apiRegionsResult{AppDefault: fleet}, nil, "ie", deployRegionMismatch("blog", "P-uk")},
+ // codex plan-R2 3: a 200 whose stored region could not be read.
+ {"region unknown", apiRegionsResult{AppDefault: &apiAppDefault{RegionKnown: false, PlacementKnown: true}}, nil, "ie", deployRegionUnknown("blog")},
+ // Controller ruling: --region <home> on a home app proceeds.
+ {"home app, home named", apiRegionsResult{Default: "ie", AppDefault: home}, nil, "ie", ""},
+ {"home app, other named", apiRegionsResult{Default: "ie", AppDefault: home}, nil, "uk", deployRegionMismatch("blog", "P-ie")},
+ }
+ for _, c := range cases {
+ err := deployRegionPreflight(c.res, c.err, "blog", c.asked)
+ got := ""
+ if err != nil {
+ got = err.Error()
+ }
+ if got != c.want {
+ t.Errorf("%s: %q, want %q", c.name, got, c.want)
+ }
+ }
+}
+
+func TestAppRegionsNameOf(t *testing.T) {
+ r := apiRegionsResult{Regions: []apiRegionEntry{{Code: "uk", Name: "UK · London"}, {Code: "ie"}}}
+ for code, want := range map[string]string{"uk": "UK · London", "ie": "ie", "zz": "zz"} {
+ if got := r.nameOf(code); got != want {
+ t.Errorf("nameOf(%q) = %q, want %q", code, got, want)
+ }
+ }
+}