moves: the Phase 1 switch, off by default (index D13)
CARLOS_MOVES_PHASE1 is read by moves.Enabled (default off). It reaches console.Config.MovesEnabled through applyMovesSwitch, and moveCfg.Enabled in armMovesPass. Nothing reads either field yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+161
−2
cmd/console/main.go+15 −2cmd/console/movesswitch_test.go+68 −0internal/console/server.go+5 −0internal/host/movespass.go+5 −0internal/host/movespass_test.go+20 −0internal/moves/switch.go+22 −0internal/moves/switch_test.go+26 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 1e6ad2364..1ec474ffa 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -90,6 +90,7 @@ import ( |
| "github.com/carlosframework/platform/internal/dnsrec" |
| "github.com/carlosframework/platform/internal/geo" |
| "github.com/carlosframework/platform/internal/mail" |
| + "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -280,7 +281,7 @@ func serve() { |
| } |
| // Before the server exists: every door that seals reads this switch. |
| appconfig.SetBoundSealing(secretsEnvelopeV2()) |
| - srv, err := console.NewServer(console.Config{ |
| + cfg := console.Config{ |
| Origin: origin, |
| Keymail: envOr("CONSOLE_KEYMAIL", "https://keymail.dev"), |
| Registry: envOr("CARLOS_REGISTRY", "/data/registry.db"), |
| @@ -359,7 +360,12 @@ func serve() { |
| // owners (discussion #176). Unset is the owner-only console. |
| SystemOperators: systemOperators(), |
| PlatformDomain: platformDomain(), |
| - }, st) |
| + } |
| + // The Phase 1 moves switch (CARLOS_MOVES_PHASE1, off by default, index |
| + // D13): applied after the literal so a test can prove it reaches the |
| + // config (applyMovesSwitch). |
| + applyMovesSwitch(&cfg, os.Getenv) |
| + srv, err := console.NewServer(cfg, st) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -1205,6 +1211,13 @@ func dbDoorWiring() (bucket, region, prefix, placement string) { |
| return cfg.Bucket, cfg.Region, cfg.Prefix, placement |
| } |
| +// applyMovesSwitch sets console.Config.MovesEnabled from the Phase 1 moves |
| +// switch, moves.SwitchEnv. Off (the default) leaves the console answering |
| +// exactly as main does today. |
| +func applyMovesSwitch(cfg *console.Config, getenv func(string) string) { |
| + cfg.MovesEnabled = moves.Enabled(getenv) |
| +} |
| + |
| func hostClaimEnabled(getenv func(string) string) bool { |
| return strings.TrimSpace(getenv("CARLOS_HOST_CLAIM")) != "" |
| } |
diff --git a/cmd/console/movesswitch_test.go b/cmd/console/movesswitch_test.go| new file mode 100644 |
| index 000000000..3240f12fd |
| --- /dev/null |
| +++ b/cmd/console/movesswitch_test.go |
| @@ -0,0 +1,68 @@ |
| +package main |
| + |
| +import ( |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/console" |
| + "github.com/carlosframework/platform/internal/moves" |
| +) |
| + |
| +// The Phase 1 moves switch reaches console.Config.MovesEnabled through |
| +// applyMovesSwitch, and only the on-values turn it on. |
| +func TestApplyMovesSwitchSetsTheConfigField(t *testing.T) { |
| + for _, tc := range []struct { |
| + val string |
| + want bool |
| + }{ |
| + {"", false}, {"on", true}, {"1", true}, {"true", true}, {"off", false}, {"yes", false}, |
| + } { |
| + var cfg console.Config |
| + applyMovesSwitch(&cfg, func(k string) string { |
| + if k != moves.SwitchEnv { |
| + t.Fatalf("read %q, want only %s", k, moves.SwitchEnv) |
| + } |
| + return tc.val |
| + }) |
| + if cfg.MovesEnabled != tc.want { |
| + t.Errorf("%s=%q: MovesEnabled = %v, want %v", moves.SwitchEnv, tc.val, cfg.MovesEnabled, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// serve() is not callable from a test (it opens a database, a bucket and a |
| +// listener), so this parses main.go and checks that it applies the switch to |
| +// the config it hands console.NewServer, reading the real environment. |
| +func TestServeAppliesTheMovesSwitchFromTheEnvironment(t *testing.T) { |
| + f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + found := false |
| + for _, d := range f.Decls { |
| + fn, ok := d.(*ast.FuncDecl) |
| + if !ok || fn.Name.Name != "serve" { |
| + continue |
| + } |
| + ast.Inspect(fn.Body, func(n ast.Node) bool { |
| + call, ok := n.(*ast.CallExpr) |
| + if !ok { |
| + return true |
| + } |
| + if id, ok := call.Fun.(*ast.Ident); !ok || id.Name != "applyMovesSwitch" || len(call.Args) != 2 { |
| + return true |
| + } |
| + if sel, ok := call.Args[1].(*ast.SelectorExpr); ok { |
| + if pkg, ok := sel.X.(*ast.Ident); ok && pkg.Name == "os" && sel.Sel.Name == "Getenv" { |
| + found = true |
| + } |
| + } |
| + return true |
| + }) |
| + } |
| + if !found { |
| + t.Fatal("serve() does not call applyMovesSwitch(&cfg, os.Getenv); the switch would never reach the console") |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index bcfead4c3..ced3be3bc 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -570,6 +570,11 @@ type Config struct { |
| // zone that one person owns, and the reserved rules refused ordinary |
| // names there. See deniedDomains. |
| ReservePlatformDomains bool |
| + // MovesEnabled is the Phase 1 moves switch (moves.Enabled, from |
| + // CARLOS_MOVES_PHASE1). Off, the console behaves exactly as it did |
| + // before Phase 1: the legacy move door answers and nothing new is |
| + // reachable. |
| + MovesEnabled bool |
| // DomainProof (CONSOLE_DOMAIN_PROOF) is the opt-in for proof of control |
| // on custom hostnames (#215). With it set, a member's attach is stored |
| // pending until a TXT record at _carlos-challenge.<host> carries the |
diff --git a/internal/host/movespass.go b/internal/host/movespass.go| index cdc2f5422..92ef00c34 100644 |
| --- a/internal/host/movespass.go |
| +++ b/internal/host/movespass.go |
| @@ -17,6 +17,7 @@ import ( |
| "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/layout" |
| "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -141,6 +142,7 @@ func armMovesPass(hib activator.Config, dataDir string, reg *registry.Registry, |
| }, |
| Now: time.Now, |
| ReadyDelta: readyDelta, |
| + Enabled: moves.Enabled(os.Getenv), |
| } |
| armedMovesWorker.Store(&movesWorker{cfg: cfg, mirrors: map[string]lease.MirrorStore{}}) |
| } |
| @@ -202,6 +204,9 @@ type moveCfg struct { |
| // round that only deleted still qualifies. armMovesPass sets it from |
| // moveReadyDeltaFromEnv. |
| ReadyDelta int64 |
| + // Enabled is the Phase 1 moves switch (moves.Enabled, set in |
| + // armMovesPass). Off, the worker runs the legacy protocol only. |
| + Enabled bool |
| } |
| // defaultMoveReadyDelta is the readiness bar when |
diff --git a/internal/host/movespass_test.go b/internal/host/movespass_test.go| index a92f287d6..6d00fce88 100644 |
| --- a/internal/host/movespass_test.go |
| +++ b/internal/host/movespass_test.go |
| @@ -12,6 +12,7 @@ import ( |
| "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -916,6 +917,25 @@ func TestArmMovesPassReadsTheReadyDelta(t *testing.T) { |
| } |
| } |
| +// armMovesPass reads the Phase 1 switch into the worker's config: off when |
| +// the variable is unset or not an on-value, on for "on". |
| +func TestArmMovesPassReadsTheSwitch(t *testing.T) { |
| + prev := armedMovesWorker.Load() |
| + t.Cleanup(func() { armedMovesWorker.Store(prev) }) |
| + for _, tc := range []struct { |
| + raw string |
| + want bool |
| + }{ |
| + {"", false}, {"on", true}, {"1", true}, {"true", true}, {"off", false}, {"yes", false}, |
| + } { |
| + t.Setenv(moves.SwitchEnv, tc.raw) |
| + armMovesPass(activator.Config{}, t.TempDir(), nil, nil) |
| + if got := armedMovesWorker.Load().cfg.Enabled; got != tc.want { |
| + t.Errorf("%s=%q armed Enabled=%v, want %v", moves.SwitchEnv, tc.raw, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| func writeFileForTest(path, content string) error { return os.WriteFile(path, []byte(content), 0o600) } |
| func fileExistsForTest(path string) bool { _, err := os.Stat(path); return err == nil } |
diff --git a/internal/moves/switch.go b/internal/moves/switch.go| new file mode 100644 |
| index 000000000..c57cdbb48 |
| --- /dev/null |
| +++ b/internal/moves/switch.go |
| @@ -0,0 +1,22 @@ |
| +package moves |
| + |
| +// SwitchEnv is the operator-off switch of Phase 1 (index D13): every move |
| +// entry point is behind it, and its default is off until the copy record |
| +// is approved. |
| +const SwitchEnv = "CARLOS_MOVES_PHASE1" |
| + |
| +// DefaultEnabled is the compiled default. The copy gate |
| +// (approved_copy_test.go) requires it false while any copy row is pending. |
| +const DefaultEnabled = false |
| + |
| +// Enabled reads the switch. Unset means the default; "on", "1" and "true" |
| +// enable; anything else is off. |
| +func Enabled(getenv func(string) string) bool { |
| + switch getenv(SwitchEnv) { |
| + case "": |
| + return DefaultEnabled |
| + case "on", "1", "true": |
| + return true |
| + } |
| + return false |
| +} |
diff --git a/internal/moves/switch_test.go b/internal/moves/switch_test.go| new file mode 100644 |
| index 000000000..61f8bd869 |
| --- /dev/null |
| +++ b/internal/moves/switch_test.go |
| @@ -0,0 +1,26 @@ |
| +package moves |
| + |
| +import "testing" |
| + |
| +func TestSwitchIsOffByDefault(t *testing.T) { |
| + if DefaultEnabled { |
| + t.Fatal("the Phase 1 switch must default to off until the copy record is approved") |
| + } |
| + env := map[string]string{} |
| + get := func(k string) string { return env[k] } |
| + if Enabled(get) { |
| + t.Fatal("unset is off") |
| + } |
| + for _, v := range []string{"on", "1", "true"} { |
| + env[SwitchEnv] = v |
| + if !Enabled(get) { |
| + t.Fatalf("%q should enable", v) |
| + } |
| + } |
| + for _, v := range []string{"off", "0", "false", "yes", " on"} { |
| + env[SwitchEnv] = v |
| + if Enabled(get) { |
| + t.Fatalf("%q should not enable", v) |
| + } |
| + } |
| +} |