carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 4d3bea54022a31c47d485a3a90037ef87517aa93 parent 89802b7
5 files changed, +256 −0
  • cmd/console/main.go +48 −0
  • cmd/console/region_test.go +55 −0
  • internal/console/regionconfig.go +48 −0
  • internal/console/regionconfig_test.go +93 −0
  • internal/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
}