console: app-regions switch, expected secrets fingerprint, GeoIP, test seams (nearest-region §5.6, §6.2, R9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+256
−0
cmd/console/main.go+48 −0cmd/console/region_test.go+55 −0internal/console/regionconfig.go+48 −0internal/console/regionconfig_test.go+93 −0internal/console/server.go+12 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go| index b83594daf..7ca049f16 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -81,11 +81,13 @@ import ( |
| "strconv" |
| "strings" |
| "time" |
| + "unicode/utf8" |
| "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/console" |
| "github.com/carlosframework/platform/internal/dnsrec" |
| + "github.com/carlosframework/platform/internal/geo" |
| "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -278,6 +280,8 @@ func serve() { |
| // correctly draws no bucket node. |
| Bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"), |
| HomeRegion: strings.TrimSpace(os.Getenv("CONSOLE_HOME_REGION")), |
| + AppRegions: appRegionsSwitch(os.Getenv("CONSOLE_APP_REGIONS")), |
| + GeoIP: openGeoIP(strings.TrimSpace(os.Getenv("CONSOLE_GEOIP_DB"))), |
| // The carlos db door's data bucket and this box's placement |
| // (dbDoorWiring). |
| HibernateBucket: dbBucket, |
| @@ -1069,6 +1073,50 @@ func envBytes(k string, def int64) int64 { |
| return n |
| } |
| +// appRegionsSwitch reads CONSOLE_APP_REGIONS: exactly "on" (any case, |
| +// trimmed) turns app regions on. |
| +func appRegionsSwitch(v string) bool { return strings.EqualFold(strings.TrimSpace(v), "on") } |
| + |
| +// openGeoIP opens the optional CONSOLE_GEOIP_DB. Unset, unopenable or not |
| +// an mmdb means the IP step is off, logged once and never fatal. Returns an |
| +// untyped nil on every failure, never a nil *geo.MMDB in the interface. |
| +func openGeoIP(path string) geo.Locator { |
| + if path == "" { |
| + return nil |
| + } |
| + m, err := geo.OpenMMDB(path) |
| + if err != nil { |
| + log.Printf("console: CONSOLE_GEOIP_DB %s is unusable, so the IP step of region guesses is off: %v", path, firstLineCapped(err)) |
| + return nil |
| + } |
| + log.Printf("console: GeoIP database loaded from %s", path) |
| + return m |
| +} |
| + |
| +// maxLoggedErrBytes caps what firstLineCapped returns. |
| +const maxLoggedErrBytes = 200 |
| + |
| +// firstLineCapped reduces an error to its first line, at most |
| +// maxLoggedErrBytes bytes (never splitting a rune). OpenMMDB's error can |
| +// carry multi-line decoded database contents, which the log must not. |
| +func firstLineCapped(err error) string { |
| + if err == nil { |
| + return "" |
| + } |
| + s := err.Error() |
| + if i := strings.IndexAny(s, "\r\n"); i >= 0 { |
| + s = s[:i] |
| + } |
| + if len(s) > maxLoggedErrBytes { |
| + cut := maxLoggedErrBytes |
| + for cut > 0 && !utf8.RuneStart(s[cut]) { |
| + cut-- |
| + } |
| + s = s[:cut] |
| + } |
| + return s |
| +} |
| + |
| func envOr(k, def string) string { |
| if v := os.Getenv(k); v != "" { |
| return v |
diff --git a/cmd/console/region_test.go b/cmd/console/region_test.go| new file mode 100644 |
| index 000000000..37b8ddf6c |
| --- /dev/null |
| +++ b/cmd/console/region_test.go |
| @@ -0,0 +1,55 @@ |
| +package main |
| + |
| +import ( |
| + "errors" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "unicode/utf8" |
| +) |
| + |
| +func TestOpenGeoIPIsOptionalAndNeverFatal(t *testing.T) { |
| + if openGeoIP("") != nil { |
| + t.Fatal("unset CONSOLE_GEOIP_DB must mean no locator") |
| + } |
| + if openGeoIP(filepath.Join(t.TempDir(), "missing.mmdb")) != nil { |
| + t.Fatal("an unopenable file must mean no locator, not a crash") |
| + } |
| +} |
| + |
| +func TestAppRegionsSwitchParses(t *testing.T) { |
| + for v, want := range map[string]bool{"on": true, "ON": true, " on ": true, "": false, "off": false, "true": false} { |
| + if got := appRegionsSwitch(v); got != want { |
| + t.Errorf("appRegionsSwitch(%q) = %v, want %v", v, got, want) |
| + } |
| + } |
| +} |
| + |
| +// OpenMMDB's error can carry decoded database contents over many lines, so |
| +// the console logs only its first line, capped at 200 bytes. |
| +func TestFirstLineCapped(t *testing.T) { |
| + cases := []struct { |
| + name string |
| + err error |
| + want string |
| + }{ |
| + {"single line", errors.New("open x: no such file"), "open x: no such file"}, |
| + {"multi-line keeps the first", errors.New("bad metadata\nsecret decoded body\nmore"), "bad metadata"}, |
| + {"crlf", errors.New("first\r\nsecond"), "first"}, |
| + {"leading newline is an empty first line", errors.New("\nbody"), ""}, |
| + {"exactly 200 bytes", errors.New(strings.Repeat("a", 200)), strings.Repeat("a", 200)}, |
| + {"long line is cut at 200 bytes", errors.New(strings.Repeat("a", 500)), strings.Repeat("a", 200)}, |
| + {"long first line with more lines", errors.New(strings.Repeat("b", 300) + "\nrest"), strings.Repeat("b", 200)}, |
| + {"nil", nil, ""}, |
| + } |
| + for _, c := range cases { |
| + if got := firstLineCapped(c.err); got != c.want { |
| + t.Errorf("%s: firstLineCapped = %q, want %q", c.name, got, c.want) |
| + } |
| + } |
| + // A cut must not leave half of a multi-byte rune. |
| + got := firstLineCapped(errors.New(strings.Repeat("é", 150))) |
| + if len(got) > 200 || !utf8.ValidString(got) { |
| + t.Errorf("cut through a rune: len %d valid %v", len(got), utf8.ValidString(got)) |
| + } |
| +} |
diff --git a/internal/console/regionconfig.go b/internal/console/regionconfig.go| new file mode 100644 |
| index 000000000..90be4c12c |
| --- /dev/null |
| +++ b/internal/console/regionconfig.go |
| @@ -0,0 +1,48 @@ |
| +package console |
| + |
| +import ( |
| + "log" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| +) |
| + |
| +// appRegionsOn is spec §5.6: the switch AND a named home. |
| +func (s *Server) appRegionsOn() bool { |
| + return s.cfg.AppRegions && s.cfg.HomeRegion != "" |
| +} |
| + |
| +// expectedSecretsFP is the fingerprint a box must publish to receive |
| +// placed instances (spec R9): the console's own CARLOS_SECRETS_PUBKEY, |
| +// the key the CLI seals config to. "" means the console cannot judge, and |
| +// then no coded region is eligible for automatic placement. |
| +func (s *Server) expectedSecretsFP() string { |
| + if s.cfg.SecretsPub == "" { |
| + return "" |
| + } |
| + fp, err := appconfig.PublicFingerprint(s.cfg.SecretsPub) |
| + if err != nil { |
| + return "" |
| + } |
| + return fp |
| +} |
| + |
| +// appRegion reads the app's stored region through the test seam when one |
| +// is set. |
| +func (s *Server) appRegion(accountID int64, app string) (string, string, error) { |
| + if s.appRegionOf != nil { |
| + return s.appRegionOf(accountID, app) |
| + } |
| + return s.store.AppRegion(accountID, app) |
| +} |
| + |
| +// logRegionConfig names, once at startup, each configuration that leaves |
| +// part of the feature off. The secrets-key line does not depend on the |
| +// switch: the guess endpoint answers with the switch off too (R9). |
| +func logRegionConfig(cfg Config) { |
| + if cfg.AppRegions && cfg.HomeRegion == "" { |
| + log.Print("console: CONSOLE_APP_REGIONS=on but CONSOLE_HOME_REGION is unset, so app regions stay off until the home is named") |
| + } |
| + if cfg.SecretsPub == "" { |
| + log.Print("console: CARLOS_SECRETS_PUBKEY is unset, so no region outside the home can be chosen automatically") |
| + } |
| +} |
diff --git a/internal/console/regionconfig_test.go b/internal/console/regionconfig_test.go| new file mode 100644 |
| index 000000000..9d4a714b7 |
| --- /dev/null |
| +++ b/internal/console/regionconfig_test.go |
| @@ -0,0 +1,93 @@ |
| +package console |
| + |
| +import ( |
| + "bytes" |
| + "crypto/ecdh" |
| + "encoding/base64" |
| + "log" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| +) |
| + |
| +// mustTestSecretsKey is the fixed P-256 key every R9 test in this package |
| +// uses: its public half as CARLOS_SECRETS_PUBKEY carries it, and the |
| +// fingerprint a box holding it publishes. |
| +func mustTestSecretsKey() (pubB64, fp string) { |
| + priv, err := ecdh.P256().NewPrivateKey(bytes.Repeat([]byte{1}, 32)) |
| + if err != nil { |
| + panic(err) |
| + } |
| + return base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes()), appconfig.KeyFingerprint(priv.PublicKey()) |
| +} |
| + |
| +func TestAppRegionsOnNeedsANamedHome(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + for _, c := range []struct { |
| + on bool |
| + home string |
| + want bool |
| + }{{false, "ie", false}, {true, "", false}, {true, "ie", true}} { |
| + fx.srv.cfg.AppRegions, fx.srv.cfg.HomeRegion = c.on, c.home |
| + if got := fx.srv.appRegionsOn(); got != c.want { |
| + t.Errorf("AppRegions=%v HomeRegion=%q: appRegionsOn = %v, want %v", c.on, c.home, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +func TestExpectedSecretsFP(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + pub, fp := mustTestSecretsKey() |
| + fx.srv.cfg.SecretsPub = pub |
| + if got := fx.srv.expectedSecretsFP(); got != fp { |
| + t.Fatalf("expectedSecretsFP = %q, want %q", got, fp) |
| + } |
| + fx.srv.cfg.SecretsPub = "" |
| + if got := fx.srv.expectedSecretsFP(); got != "" { |
| + t.Fatalf("no CARLOS_SECRETS_PUBKEY = %q, want empty (cannot judge)", got) |
| + } |
| + fx.srv.cfg.SecretsPub = "not a key" |
| + if got := fx.srv.expectedSecretsFP(); got != "" { |
| + t.Fatalf("an unparseable CARLOS_SECRETS_PUBKEY = %q, want empty (cannot judge)", got) |
| + } |
| +} |
| + |
| +func TestAppRegionUsesTheSeamWhenSet(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if r, src, err := fx.srv.appRegion(OpsAccountID, "nobody"); err != nil || r != "" || src != "" { |
| + t.Fatalf("store read of a missing app = %q %q %v, want empty", r, src, err) |
| + } |
| + fx.srv.appRegionOf = func(id int64, app string) (string, string, error) { |
| + return "uk", "timezone", nil |
| + } |
| + if r, src, err := fx.srv.appRegion(OpsAccountID, "nobody"); err != nil || r != "uk" || src != "timezone" { |
| + t.Fatalf("seam read = %q %q %v, want uk timezone", r, src, err) |
| + } |
| +} |
| + |
| +func TestLogRegionConfig(t *testing.T) { |
| + var buf bytes.Buffer |
| + prev := log.Writer() |
| + log.SetOutput(&buf) |
| + defer log.SetOutput(prev) |
| + logRegionConfig(Config{AppRegions: true}) |
| + out := buf.String() |
| + if !strings.Contains(out, "CONSOLE_APP_REGIONS=on but CONSOLE_HOME_REGION is unset, so app regions stay off until the home is named") || |
| + !strings.Contains(out, "CARLOS_SECRETS_PUBKEY is unset, so no region outside the home can be chosen automatically") { |
| + t.Fatalf("missing startup lines: %s", out) |
| + } |
| + // Switch off: the guess endpoint still answers, so a missing secrets |
| + // key is still worth saying (codex plan-R2 8); an unnamed home is not. |
| + buf.Reset() |
| + logRegionConfig(Config{}) |
| + if out := buf.String(); !strings.Contains(out, "CARLOS_SECRETS_PUBKEY is unset") || strings.Contains(out, "CONSOLE_HOME_REGION") { |
| + t.Fatalf("switch off logged %q, want only the secrets-key line", out) |
| + } |
| + buf.Reset() |
| + pub, _ := mustTestSecretsKey() |
| + logRegionConfig(Config{AppRegions: true, HomeRegion: "ie", SecretsPub: pub}) |
| + if buf.Len() != 0 { |
| + t.Fatalf("a complete configuration logged: %s", buf.String()) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 4da840494..08434c849 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -35,6 +35,7 @@ import ( |
| "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/economics" |
| "github.com/carlosframework/platform/internal/edgesfile" |
| + "github.com/carlosframework/platform/internal/geo" |
| "github.com/carlosframework/platform/internal/infraview" |
| "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/objstore" |
| @@ -482,6 +483,11 @@ type Config struct { |
| // and nothing about placement changes when it is set. "" is legal: the |
| // regions endpoint then reports no home row and an empty default. |
| HomeRegion string |
| + // AppRegions is CONSOLE_APP_REGIONS=on (nearest-region spec §5.6). Off |
| + // never stops the create door's fill for rows already holding a region. |
| + AppRegions bool |
| + // GeoIP is the optional CONSOLE_GEOIP_DB locator; nil = IP step off. |
| + GeoIP geo.Locator |
| // HibernateBucket/Region/Prefix name the region data bucket the |
| // `carlos db` door reads (apidb.go; spec 2026-09-02 §6, Option A): the |
| // same CARLOS_HIBERNATE_* triple the activator on this box writes |
| @@ -730,6 +736,11 @@ type Config struct { |
| type Server struct { |
| cfg Config |
| store *Store |
| + // appRegionOf and beforeClaimInsert are TEST SEAMS (nil in |
| + // production): a store failure on the app-region read (C20), and a |
| + // competing insert landing after the region was resolved. |
| + appRegionOf func(accountID int64, app string) (string, string, error) |
| + beforeClaimInsert func() |
| // claims folds the deployment's domain claims for the read paths — the |
| // Settings render and the domains list door — reading only the claims |
| // whose bucket stamp moved since the last fold (#173). Process-lifetime |
| @@ -1339,6 +1350,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| if advice := grantHolderAdvice(needAccounts); advice != "" { |
| log.Print(advice) |
| } |
| + logRegionConfig(cfg) |
| return s, nil |
| } |