console, mail, host, hostops: flag-off token revoke as main; legacy mail sweep refused with the claim on; ambiguous mail effects keep both locks; strict purge mail readers (Plan 5 Tasks 7-9 review round 1)
Opus I1, I2, M3, M5, M8, M9; Codex Important 1-2.
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
52 files changed,
+533
−354
cmd/console/claim_sweep_test.go+114 −0cmd/console/main.go+25 −19internal/console/apibuildprobe.go+1 −3internal/console/apicli.go+3 −9internal/console/apidomains.go+1 −1internal/console/apidoorkeys.go+7 −21internal/console/apihostclaim.go+3 −2internal/console/apiinstances.go+2 −4internal/console/apiservices.go+1 −3internal/console/apisidecars.go+1 −1internal/console/apitemporary.go+2 −6internal/console/appsettings.go+2 −6internal/console/authstore.go+7 −2internal/console/claim_alias_test.go+3 −2internal/console/claim_backend_recorders_test.go+1 −0internal/console/claim_purge.go+3 −3internal/console/claim_purge_stage_test.go+3 −2internal/console/claim_record_backend_boundary_test.go+1 −1internal/console/claim_review_fixes_test.go+217 −0internal/console/claim_route_writes.go+9 −4internal/console/controlplane.go+2 −6internal/console/domainattach.go+1 −1internal/console/doorkeys_store.go+1 −3internal/console/expirypass.go+1 −3internal/console/fleets.go+2 −4internal/console/hostreports.go+5 −4internal/console/materialize.go+2 −6internal/console/ownership.go+1 −1internal/console/pending.go+1 −3internal/console/pendinghandlers.go+2 −6internal/console/restore.go+3 −9internal/console/server.go+5 −12internal/console/settle.go+2 −6internal/console/svcpage.go+1 −3internal/console/svcprincipal.go+1 −2internal/console/tempstore.go+1 −2internal/final-review-report.txt+0 −152internal/host/agent.go+1 −1internal/host/sidecarwake.go+7 −7internal/host/sidecarwake_identity_test.go+28 −3internal/host/sidecarwake_test.go+13 −13internal/hostactors/roster.go+4 −3internal/hostops/app_mutation_runner_test.go+2 −1internal/hostops/evidence_discovery_test.go+4 −3internal/hostops/mutation_boundary_test.go+2 −1internal/hostops/production_held_start.go+2 −1internal/hostops/purge_retirement_test.go+2 −1internal/mail/claim_gate.go+6 −3internal/mail/claim_gate_domain_test.go+1 −1internal/mail/mail.go+21 −0internal/provision/provision.go+2 −3internal/release/release.go+1 −1
CI passed — run details
diff --git a/cmd/console/claim_sweep_test.go b/cmd/console/claim_sweep_test.go| new file mode 100644 |
| index 000000000..42c574a20 |
| --- /dev/null |
| +++ b/cmd/console/claim_sweep_test.go |
| @@ -0,0 +1,114 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/console" |
| + "github.com/carlosframework/platform/internal/mail" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestClaimLegacySweepRefusesEnabled(t *testing.T) { |
| + for _, enabled := range []bool{false, true} { |
| + t.Run(map[bool]string{false: "off", true: "on"}[enabled], func(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + key := mail.LegacyKey("bab", "web") |
| + for _, k := range []string{key, mail.Key("bab", "web")} { |
| + if _, err := s.Put(ctx, k, []byte(`{"schema":1,"app":"web","id":"app"}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + st, err := console.Open(filepath.Join(t.TempDir(), "console.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer st.Close() |
| + var logs bytes.Buffer |
| + old := log.Writer() |
| + log.SetOutput(&logs) |
| + defer log.SetOutput(old) |
| + sweepLegacyMail(enabled, s, st) |
| + _, _, err = s.Get(ctx, key) |
| + if enabled { |
| + if err != nil { |
| + t.Errorf("enabled sweep deleted legacy object: %v", err) |
| + } |
| + if logs.Len() == 0 || strings.Count(strings.TrimSpace(logs.String()), "\n") != 0 { |
| + t.Errorf("want one refusal log: %q", logs.String()) |
| + } |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + t.Errorf("flag-off sweep did not delete migrated object: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func rawStoreUsesAfterRuntime(source string) ([]string, error) { |
| + fs := token.NewFileSet() |
| + f, err := parser.ParseFile(fs, "main.go", source, 0) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var uses []string |
| + for _, decl := range f.Decls { |
| + fn, ok := decl.(*ast.FuncDecl) |
| + if !ok { |
| + continue |
| + } |
| + var configured token.Pos |
| + ast.Inspect(fn, func(n ast.Node) bool { |
| + if call, ok := n.(*ast.CallExpr); ok { |
| + if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "ConfigureClaimRuntime" { |
| + configured = call.End() |
| + } |
| + } |
| + if configured != 0 && n != nil && n.Pos() > configured { |
| + if id, ok := n.(*ast.Ident); ok && (id.Name == "rel" || id.Name == "openStore" || id.Name == "optionalStore") { |
| + uses = append(uses, fs.Position(id.Pos()).String()+":"+id.Name) |
| + } |
| + } |
| + return true |
| + }) |
| + } |
| + return uses, nil |
| +} |
| + |
| +func TestClaimStartupRawStoreCensus(t *testing.T) { |
| + files, err := filepath.Glob("*.go") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, file := range files { |
| + if strings.HasSuffix(file, "_test.go") { |
| + continue |
| + } |
| + raw, err := os.ReadFile(file) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + uses, err := rawStoreUsesAfterRuntime(string(raw)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(uses) != 0 { |
| + t.Errorf("%s: raw store after ConfigureClaimRuntime: %v", file, uses) |
| + } |
| + } |
| + for _, door := range []string{"rel.Delete(ctx, key)", "openStore().Delete(ctx, key)", "optionalStore().Delete(ctx, key)"} { |
| + uses, err := rawStoreUsesAfterRuntime("package main; func main(){srv.ConfigureClaimRuntime(ctx); " + door + "}") |
| + if err != nil || len(uses) == 0 { |
| + t.Fatalf("missed injected raw door %s: %v %v", door, uses, err) |
| + } |
| + } |
| +} |
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 6eed52d7c..9ef54f0e2 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -76,7 +76,6 @@ import ( |
| "crypto/ed25519" |
| "flag" |
| "fmt" |
| - "github.com/carlosframework/platform/internal/lease" |
| "log" |
| "net" |
| "net/http" |
| @@ -94,6 +93,7 @@ import ( |
| "github.com/carlosframework/platform/internal/dnsrec" |
| "github.com/carlosframework/platform/internal/geo" |
| "github.com/carlosframework/platform/internal/geoip" |
| + "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/objstore" |
| @@ -406,6 +406,14 @@ func serve() { |
| if err != nil { |
| log.Fatal(err) |
| } |
| + // The senders migration's tail (tenant-email senders design §5): once a |
| + // release has passed with every console writing senders/app.json, an |
| + // operator sets CARLOS_MAIL_LEGACY_SWEEP=1 and the next start deletes the |
| + // email.json objects that have already been replaced. Off by default, and |
| + // it never touches an app that has not been migrated. |
| + if os.Getenv("CARLOS_MAIL_LEGACY_SWEEP") == "1" { |
| + go sweepLegacyMail(cfg.HostClaim, rel, st) |
| + } |
| if err := srv.ConfigureClaimRuntime(context.Background()); err != nil { |
| log.Fatalf("console: claim recovery: %v", err) |
| } |
| @@ -420,24 +428,6 @@ func serve() { |
| // The moves console record (moves Task 13): only with the switch on, |
| // best-effort, logged by the method itself. |
| go srv.RunClaimWorker(context.Background(), "PublishMovesConsole") |
| - // The senders migration's tail (tenant-email senders design §5): once a |
| - // release has passed with every console writing senders/app.json, an |
| - // operator sets CARLOS_MAIL_LEGACY_SWEEP=1 and the next start deletes the |
| - // email.json objects that have already been replaced. Off by default, and |
| - // it never touches an app that has not been migrated. |
| - if os.Getenv("CARLOS_MAIL_LEGACY_SWEEP") == "1" { |
| - go func() { |
| - n, err := mail.SweepLegacyRecords(context.Background(), rel, true) |
| - if err != nil { |
| - log.Printf("console: mail legacy sweep: %v", err) |
| - return |
| - } |
| - log.Printf("console: mail legacy sweep -> %d email.json objects removed", n) |
| - if err := st.DropLegacyMailCounts(true); err != nil { |
| - log.Printf("console: mail legacy sweep: dropping mail_counts -> %v", err) |
| - } |
| - }() |
| - } |
| // The economics pass writes the daily usage/census/storage/statement |
| // objects and pulls the AWS bill (internal/console/economicspass.go). |
| // Same placement rule as the alert watcher: started here, never in |
| @@ -1392,3 +1382,19 @@ func geoLocator(sw *geo.Swappable) geo.Locator { |
| } |
| return sw |
| } |
| + |
| +func sweepLegacyMail(claims bool, rel release.Store, st *console.Store) { |
| + if claims { |
| + log.Printf("console: mail legacy sweep: %v", mail.ErrClaimMailPending) |
| + return |
| + } |
| + n, err := mail.SweepLegacyRecords(context.Background(), rel, true) |
| + if err != nil { |
| + log.Printf("console: mail legacy sweep: %v", err) |
| + return |
| + } |
| + log.Printf("console: mail legacy sweep -> %d email.json objects removed", n) |
| + if err := st.DropLegacyMailCounts(true); err != nil { |
| + log.Printf("console: mail legacy sweep: dropping mail_counts -> %v", err) |
| + } |
| +} |
diff --git a/internal/console/apibuildprobe.go b/internal/console/apibuildprobe.go| index 54cb0365b..8ea83abde 100644 |
| --- a/internal/console/apibuildprobe.go |
| +++ b/internal/console/apibuildprobe.go |
| @@ -56,9 +56,7 @@ func (s *Server) handleAPIBuildProbe(w http.ResponseWriter, r *http.Request) { |
| `build_probe must be an absolute URL path with no query, e.g. "/healthz"`) |
| return |
| } |
| - if err := s.store.SetAppBuildProbeContext(r.Context(), |
| - |
| - acct.ID, app, probe); err != nil { |
| + if err := s.store.SetAppBuildProbeContext(r.Context(), acct.ID, app, probe); err != nil { |
| jsonError(w, http.StatusInternalServerError, "could not save the build probe — try again") |
| return |
| } |
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index 5cec119b0..dbd25cb52 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -481,16 +481,12 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) { |
| // missing row (store.go), so they are safe here only because claimOK |
| // guarantees CreateAppIn just succeeded. |
| if req.ControlPlane { |
| - if err := s.store.SetAppControlPlaneContext(r.Context(), |
| - |
| - acct.ID, name, true); err != nil { |
| + if err := s.store.SetAppControlPlaneContext(r.Context(), acct.ID, name, true); err != nil { |
| log.Printf("console: claim %s/%s: set control_plane: %v", acct.Sqid, name, err) |
| } |
| } |
| if probe != "" { |
| - if err := s.store.SetAppBuildProbeContext(r.Context(), |
| - |
| - acct.ID, name, probe); err != nil { |
| + if err := s.store.SetAppBuildProbeContext(r.Context(), acct.ID, name, probe); err != nil { |
| log.Printf("console: claim %s/%s: set build_probe: %v", acct.Sqid, name, err) |
| } |
| } |
| @@ -1468,9 +1464,7 @@ func (s *Server) handleAPITrashApp(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| deletedAt := tomb.DeletedAt |
| - if err := s.store.TrashAppRowContext(r.Context(), |
| - |
| - acct.ID, app, deletedAt); err != nil { |
| + if err := s.store.TrashAppRowContext(r.Context(), acct.ID, app, deletedAt); err != nil { |
| log.Printf("console: api trash row %s/%s: %v", acct.Sqid, app, err) |
| jsonError(w, http.StatusInternalServerError, |
| "the trash marker was written, but this console's own record did not update —"+ |
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index 4b3f91b35..a779d2790 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -37,7 +37,6 @@ import ( |
| "context" |
| "errors" |
| "fmt" |
| - "github.com/carlosframework/platform/internal/hostops" |
| "log" |
| "net/http" |
| "net/url" |
| @@ -51,6 +50,7 @@ import ( |
| "github.com/carlosframework/platform/internal/certwarm" |
| "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/edgesfile" |
| + "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/status" |
diff --git a/internal/console/apidoorkeys.go b/internal/console/apidoorkeys.go| index a53e0b514..8a4e8ecea 100644 |
| --- a/internal/console/apidoorkeys.go |
| +++ b/internal/console/apidoorkeys.go |
| @@ -120,23 +120,17 @@ func (s *Server) publishDoorKeysLocked(ctx context.Context, acct *Account, app s |
| } |
| } |
| - keys, rev, err := s.store.DoorKeysLiveContext(ctx, |
| - |
| - acct.ID, app) |
| + keys, rev, err := s.store.DoorKeysLiveContext(ctx, acct.ID, app) |
| if err != nil { |
| return fmt.Errorf("console: door keys %s/%s: %w", acct.Sqid, app, err) |
| } |
| if rev < bucketRev { |
| - if _, err := s.store.DoorKeyAdvanceRevisionContext(ctx, |
| - |
| - acct.ID, app, bucketRev); err != nil { |
| + if _, err := s.store.DoorKeyAdvanceRevisionContext(ctx, acct.ID, app, bucketRev); err != nil { |
| return fmt.Errorf("console: door keys %s/%s: advance past %d: %w", acct.Sqid, app, bucketRev, err) |
| } |
| log.Printf("console: door keys %s/%s: database revision %d was below the bucket's %d; advanced past it", |
| acct.Sqid, app, rev, bucketRev) |
| - if keys, rev, err = s.store.DoorKeysLiveContext(ctx, |
| - |
| - acct.ID, app); err != nil { |
| + if keys, rev, err = s.store.DoorKeysLiveContext(ctx, acct.ID, app); err != nil { |
| return fmt.Errorf("console: door keys %s/%s: %w", acct.Sqid, app, err) |
| } |
| } |
| @@ -234,9 +228,7 @@ func (s *Server) handleAPIDoorKeyMint(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| entry.Label = cleanLabel(req.Label) |
| - if _, err := s.store.DoorKeyInsertContext(r.Context(), |
| - |
| - acct.ID, app, entry); err != nil { |
| + if _, err := s.store.DoorKeyInsertContext(r.Context(), acct.ID, app, entry); err != nil { |
| if errors.Is(err, errDoorKeysFull) { |
| jsonError(w, http.StatusConflict, fmt.Sprintf( |
| "this app already has %d door keys. revoke one before you mint another", doorkey.MaxKeys)) |
| @@ -251,9 +243,7 @@ func (s *Server) handleAPIDoorKeyMint(w http.ResponseWriter, r *http.Request) { |
| // hashes is revoked rather than left holding one of the app's slots |
| // until the next re-sign publishes a key nobody has. |
| log.Printf("console: door key mint %s/%s: publish: %v", acct.Sqid, app, err) |
| - if _, rerr := s.store.DoorKeyRevokeContext(r.Context(), |
| - |
| - acct.ID, app, entry.ID); rerr != nil { |
| + if _, rerr := s.store.DoorKeyRevokeContext(r.Context(), acct.ID, app, entry.ID); rerr != nil { |
| log.Printf("console: door key mint %s/%s: withdraw unpublished key %s: %v", acct.Sqid, app, entry.ID, rerr) |
| } |
| jsonError(w, http.StatusBadGateway, "could not publish the door key. try again") |
| @@ -276,9 +266,7 @@ func (s *Server) handleAPIDoorKeyList(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - keys, _, err := s.store.DoorKeysLiveContext(r.Context(), |
| - |
| - acct.ID, app) |
| + keys, _, err := s.store.DoorKeysLiveContext(r.Context(), acct.ID, app) |
| if err != nil { |
| log.Printf("console: door key list %s/%s: %v", acct.Sqid, app, err) |
| jsonError(w, http.StatusInternalServerError, "could not list the door keys. try again") |
| @@ -315,9 +303,7 @@ func (s *Server) handleAPIDoorKeyRevoke(w http.ResponseWriter, r *http.Request) |
| return |
| } |
| id := r.PathValue("id") |
| - if _, err := s.store.DoorKeyRevokeContext(r.Context(), |
| - |
| - acct.ID, app, id); err != nil { |
| + if _, err := s.store.DoorKeyRevokeContext(r.Context(), acct.ID, app, id); err != nil { |
| if errors.Is(err, os.ErrNotExist) { |
| jsonError(w, http.StatusNotFound, "no live door key with that id") |
| return |
diff --git a/internal/console/apihostclaim.go b/internal/console/apihostclaim.go| index df9214d73..adde1b540 100644 |
| --- a/internal/console/apihostclaim.go |
| +++ b/internal/console/apihostclaim.go |
| @@ -2,13 +2,14 @@ package console |
| import ( |
| "encoding/json" |
| + "net/http" |
| + "time" |
| + |
| "github.com/carlosframework/platform/internal/hostactors" |
| "github.com/carlosframework/platform/internal/hostfence" |
| "github.com/carlosframework/platform/internal/hostfloor" |
| "github.com/carlosframework/platform/internal/hostresolve" |
| "github.com/carlosframework/platform/internal/release" |
| - "net/http" |
| - "time" |
| ) |
| const claimHostResolveUnsupportedMsg = "host-loss resolution is unavailable until its lifecycle authority is connected" |
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index bf3ef5e2a..2f781667e 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -30,7 +30,6 @@ import ( |
| "context" |
| "encoding/json" |
| "errors" |
| - "github.com/carlosframework/platform/internal/hostops" |
| "log" |
| "net/http" |
| "os" |
| @@ -40,6 +39,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/gate" |
| + "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -1859,9 +1859,7 @@ func (s *Server) handleAPIInstanceToken(w http.ResponseWriter, r *http.Request) |
| if label == "" { |
| label = app + " instance token" |
| } |
| - raw, err := s.store.NewAppTokenContext(r.Context(), |
| - |
| - acct.ID, app, addr, label, time.Now()) |
| + raw, err := s.store.NewAppTokenContext(r.Context(), acct.ID, app, addr, label, time.Now()) |
| if err != nil { |
| log.Printf("console: api instance token %s/%s: %v", acct.Sqid, app, err) |
| jsonError(w, http.StatusInternalServerError, "could not mint the token — try again") |
diff --git a/internal/console/apiservices.go b/internal/console/apiservices.go| index 0958a85ce..7f23b4a6a 100644 |
| --- a/internal/console/apiservices.go |
| +++ b/internal/console/apiservices.go |
| @@ -223,9 +223,7 @@ func (s *Server) handleAPIServiceCreate(w http.ResponseWriter, r *http.Request) |
| } |
| } |
| actor := auditActor(p, acct) |
| - secret, err := s.store.NewServicePrincipalScopedContext(r.Context(), |
| - |
| - acct.ID, name, app, role, req.Rungs, actor, time.Now()) |
| + secret, err := s.store.NewServicePrincipalScopedContext(r.Context(), acct.ID, name, app, role, req.Rungs, actor, time.Now()) |
| if err != nil { |
| // The sentinel, not a string match: a name collision is the |
| // caller's to fix and a busy database is not, and the two must not |
diff --git a/internal/console/apisidecars.go b/internal/console/apisidecars.go| index 098bf56fc..ac5163f7d 100644 |
| --- a/internal/console/apisidecars.go |
| +++ b/internal/console/apisidecars.go |
| @@ -42,7 +42,6 @@ package console |
| import ( |
| "context" |
| "errors" |
| - "github.com/carlosframework/platform/internal/hostops" |
| "log" |
| "net/http" |
| "os" |
| @@ -50,6 +49,7 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
diff --git a/internal/console/apitemporary.go b/internal/console/apitemporary.go| index a8f2a06be..dcfd00993 100644 |
| --- a/internal/console/apitemporary.go |
| +++ b/internal/console/apitemporary.go |
| @@ -263,9 +263,7 @@ func (s *Server) keepApp(ctx context.Context, acct *Account, app string, now tim |
| if _, err := s.temporaryLive(ctx, acct, app, now); err != nil { |
| return err |
| } |
| - ok, err := s.store.KeepAppContext(ctx, |
| - |
| - acct.ID, app) |
| + ok, err := s.store.KeepAppContext(ctx, acct.ID, app) |
| if err != nil { |
| return err |
| } |
| @@ -283,9 +281,7 @@ func (s *Server) setAppTTL(ctx context.Context, acct *Account, app string, ttl t |
| return time.Time{}, err |
| } |
| exp := now.Add(ttl).Truncate(time.Second) |
| - ok, err := s.store.SetAppExpiryContext(ctx, |
| - |
| - acct.ID, app, exp) |
| + ok, err := s.store.SetAppExpiryContext(ctx, acct.ID, app, exp) |
| if err != nil { |
| return time.Time{}, err |
| } |
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go| index cfdfb621e..afd407bca 100644 |
| --- a/internal/console/appsettings.go |
| +++ b/internal/console/appsettings.go |
| @@ -1094,9 +1094,7 @@ func (s *Server) handleAppDelete(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| deletedAt := tomb.DeletedAt |
| - if err := s.store.TrashAppRowContext(r.Context(), |
| - |
| - acct.ID, app, deletedAt); err != nil { |
| + if err := s.store.TrashAppRowContext(r.Context(), acct.ID, app, deletedAt); err != nil { |
| log.Printf("console: trash row %s/%s: %v", acct.Sqid, app, err) |
| back("err", "The trash marker was written, but this console's record did not update — reload; if the app still shows, try deleting again.") |
| return |
| @@ -1432,9 +1430,7 @@ func (s *Server) handleAppMoveAccount(w http.ResponseWriter, r *http.Request) { |
| // MoveApp revoked the name's door keys on both sides; tell the boxes now. |
| s.republishDoorKeys(r, acct, app) |
| s.republishDoorKeys(r, dest, app) |
| - if err := s.store.RecordEventContext(r.Context(), |
| - |
| - dest.ID, app, addr, "move", "arrived from account "+acct.Name+" ("+acct.Sqid+")", time.Now()); err != nil { |
| + if err := s.store.RecordEventContext(r.Context(), dest.ID, app, addr, "move", "arrived from account "+acct.Name+" ("+acct.Sqid+")", time.Now()); err != nil { |
| log.Printf("console: move event %s: %v", app, err) |
| } |
| s.setFlash(w, "ok", "Moved "+app+" to "+dest.Name+" — console and routes agree again.") |
diff --git a/internal/console/authstore.go b/internal/console/authstore.go| index 2e84c875f..252792a48 100644 |
| --- a/internal/console/authstore.go |
| +++ b/internal/console/authstore.go |
| @@ -20,9 +20,8 @@ |
| // length of the flow. Only its sha256 is stored, like sessions. |
| package console |
| -import "context" |
| - |
| import ( |
| + "context" |
| "crypto/rand" |
| "crypto/subtle" |
| "database/sql" |
| @@ -627,6 +626,9 @@ var errNoSuchCLIToken = errors.New("console: no such token") |
| // console without the host claim there is no gate and this is the same single |
| // DELETE as before. |
| func (s *Store) RevokeCLITokenContext(ctx context.Context, address, id string) error { |
| + if s.gate == nil { |
| + return s.claimRawRevokeCLIToken(address, id) |
| + } |
| var accountID int64 |
| var app string |
| err := s.db.QueryRowContext(ctx, `SELECT account_id, app FROM cli_tokens WHERE token_hash = ? AND address = ?`, |
| @@ -673,6 +675,9 @@ func (s *Store) RevokeCLITokenValue(rawToken string) error { |
| // nothing is not an error, as before. |
| func (s *Store) RevokeCLITokenValueContext(ctx context.Context, rawToken string) error { |
| hash := hashToken(rawToken) |
| + if s.gate == nil { |
| + return s.claimRawRevokeCLITokenValue(hash) |
| + } |
| var accountID int64 |
| var app string |
| err := s.db.QueryRowContext(ctx, `SELECT account_id, app FROM cli_tokens WHERE token_hash = ?`, hash).Scan(&accountID, &app) |
diff --git a/internal/console/claim_alias_test.go b/internal/console/claim_alias_test.go| index b204018f3..6ed50b025 100644 |
| --- a/internal/console/claim_alias_test.go |
| +++ b/internal/console/claim_alias_test.go |
| @@ -5,13 +5,14 @@ import ( |
| "crypto/ed25519" |
| "encoding/json" |
| "errors" |
| - "github.com/carlosframework/platform/internal/hostops" |
| - "github.com/carlosframework/platform/internal/release" |
| "net/http" |
| "net/http/httptest" |
| "os" |
| "testing" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| type aliasWriteSpy struct { |
diff --git a/internal/console/claim_backend_recorders_test.go b/internal/console/claim_backend_recorders_test.go| index 2edf8f7b0..5fa134071 100644 |
| --- a/internal/console/claim_backend_recorders_test.go |
| +++ b/internal/console/claim_backend_recorders_test.go |
| @@ -3,6 +3,7 @@ package console |
| import ( |
| "context" |
| "encoding/json" |
| + |
| "github.com/carlosframework/platform/internal/dnsrec" |
| "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/mail" |
diff --git a/internal/console/claim_purge.go b/internal/console/claim_purge.go| index 551fc5fce..2237da65e 100644 |
| --- a/internal/console/claim_purge.go |
| +++ b/internal/console/claim_purge.go |
| @@ -161,7 +161,7 @@ func (p *ClaimPurgeCoordinator) Request(ctx context.Context, account, app, id, r |
| } |
| effects["store."+env] = raw |
| } |
| - mrec, _, err := mail.Get(ctx, s.cfg.Releases, account, app) |
| + mrec, _, err := mail.StrictGet(ctx, s.cfg.Releases, account, app) |
| if err != nil { |
| return "", err |
| } |
| @@ -466,7 +466,7 @@ func (p *ClaimPurgeCoordinator) SweepRequests(ctx context.Context) error { |
| func (p *ClaimPurgeCoordinator) mailReady(ctx context.Context, account, app string) error { |
| estate := false |
| - record, _, err := mail.Get(ctx, p.Controller.Store, account, app) |
| + record, _, err := mail.StrictGet(ctx, p.Controller.Store, account, app) |
| if err != nil { |
| return err |
| } |
| @@ -644,7 +644,7 @@ func (p *ClaimPurgeCoordinator) teardown(ctx context.Context, h *hostops.Operati |
| } |
| j.Effects["store."+env] = raw |
| } |
| - record, _, err := mail.Get(ctx, p.Controller.Store, account, app) |
| + record, _, err := mail.StrictGet(ctx, p.Controller.Store, account, app) |
| if err != nil { |
| return err |
| } |
diff --git a/internal/console/claim_purge_stage_test.go b/internal/console/claim_purge_stage_test.go| index 16e79bfae..0fc445c8d 100644 |
| --- a/internal/console/claim_purge_stage_test.go |
| +++ b/internal/console/claim_purge_stage_test.go |
| @@ -5,10 +5,11 @@ import ( |
| "database/sql" |
| "encoding/json" |
| "errors" |
| - "github.com/carlosframework/platform/internal/hostops" |
| - "github.com/carlosframework/platform/internal/release" |
| "testing" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| func TestClaimPurgeStageSettlesOnlyObservedEffects(t *testing.T) { |
diff --git a/internal/console/claim_record_backend_boundary_test.go b/internal/console/claim_record_backend_boundary_test.go| index c27c775bb..8480a9e71 100644 |
| --- a/internal/console/claim_record_backend_boundary_test.go |
| +++ b/internal/console/claim_record_backend_boundary_test.go |
| @@ -3,12 +3,12 @@ package console |
| import ( |
| "context" |
| "errors" |
| - "github.com/carlosframework/platform/internal/hostops" |
| "reflect" |
| "slices" |
| "testing" |
| "github.com/carlosframework/platform/internal/dnsrec" |
| + "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/objstore" |
| ) |
diff --git a/internal/console/claim_review_fixes_test.go b/internal/console/claim_review_fixes_test.go| new file mode 100644 |
| index 000000000..41b15c83f |
| --- /dev/null |
| +++ b/internal/console/claim_review_fixes_test.go |
| @@ -0,0 +1,217 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/mail" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestClaimFlagOffRevokeIgnoresCancelledContext(t *testing.T) { |
| + for _, byValue := range []bool{false, true} { |
| + t.Run(map[bool]string{false: "id", true: "value"}[byValue], func(t *testing.T) { |
| + st := openTestStore(t) |
| + raw := mintUserToken(t, st, "owner@example.com", time.Now()) |
| + ctx, cancel := context.WithCancel(t.Context()) |
| + cancel() |
| + var err error |
| + if byValue { |
| + err = st.RevokeCLITokenValueContext(ctx, raw) |
| + } else { |
| + err = st.RevokeCLITokenContext(ctx, "owner@example.com", hashToken(raw)) |
| + } |
| + if err != nil { |
| + t.Fatalf("flag-off revoke: %v", err) |
| + } |
| + rows, err := st.CLITokens("owner@example.com") |
| + if err != nil || len(rows) != 0 { |
| + t.Fatalf("token still live: %v %v", rows, err) |
| + } |
| + if byValue { |
| + err = st.RevokeCLITokenValueContext(ctx, raw) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } else { |
| + err = st.RevokeCLITokenContext(ctx, "owner@example.com", hashToken(raw)) |
| + if !errors.Is(err, errNoSuchCLIToken) { |
| + t.Fatalf("missing token: %v", err) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestClaimServiceCredentialLookupError(t *testing.T) { |
| + for _, gated := range []bool{false, true} { |
| + t.Run(map[bool]string{false: "off", true: "on"}[gated], func(t *testing.T) { |
| + st := openTestStore(t) |
| + if gated { |
| + st = newClaimCase(t).st |
| + } |
| + st.Close() |
| + _, want := st.ServicePrincipals(OpsAccountID) |
| + if want == nil { |
| + t.Fatal("closed DB lookup succeeded") |
| + } |
| + called := false |
| + err := (&Server{store: st}).serviceCredentialWrite(t.Context(), &Account{ID: OpsAccountID}, serviceHandle{name: "test"}, "test", func(*Store) error { called = true; return nil }) |
| + if gated { |
| + if called || err == nil || err.Error() != want.Error() { |
| + t.Fatalf("lookup error swallowed: called=%v err=%v want=%v", called, err, want) |
| + } |
| + } else if !called || err != nil { |
| + t.Fatalf("flag-off changed: called=%v err=%v", called, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +type ambiguousDisassociate struct { |
| + mail.Provisioner |
| + err error |
| + disassociations, deletes int |
| +} |
| + |
| +func (p *ambiguousDisassociate) DisassociateTenantResource(ctx context.Context, tenant, resource string) error { |
| + p.disassociations++ |
| + if err := p.Provisioner.DisassociateTenantResource(ctx, tenant, resource); err != nil { |
| + return err |
| + } |
| + return p.err |
| +} |
| +func (p *ambiguousDisassociate) DeleteIdentity(ctx context.Context, region, domain string) error { |
| + p.deletes++ |
| + return p.Provisioner.DeleteIdentity(ctx, region, domain) |
| +} |
| + |
| +func TestClaimMailDiscardedBackendErrorRetainsBothLocks(t *testing.T) { |
| + cx := newClaimCase(t) |
| + ctx := t.Context() |
| + const domain = "remove.example.com" |
| + d := cx.srv.cfg.MailDeployment |
| + if _, _, err := mail.EnsureDomain(ctx, cx.srv.claimMailStore(), cx.srv.cfg.MailProvisioner, cx.srv.cfg.DNSWriter, d, "bab", "titogo", domain, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + uncertain := errors.New("lost disassociation response") |
| + p := &ambiguousDisassociate{Provisioner: cx.srv.cfg.MailProvisioner, err: uncertain} |
| + _, err := mail.RemoveDomain(ctx, cx.srv.claimMailStore(), p, cx.srv.cfg.DNSWriter, d, "bab", "titogo", domain) |
| + if p.disassociations == 0 || p.deletes == 0 { |
| + t.Fatalf("did not reach failing and successful backend calls: %+v err=%v", p, err) |
| + } |
| + if rec, _, e := mail.GetDomain(ctx, cx.rel, "bab", domain); e != nil || rec != nil { |
| + t.Fatalf("later removal did not succeed: %v %v", rec, e) |
| + } |
| + a, e := hostops.ReadCanonicalAdmission(ctx, cx.rel, "bab", "titogo") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if a.Value.Op == nil { |
| + t.Error("app admission released after ambiguous disassociation") |
| + } |
| + held := false |
| + for _, effect := range a.Value.Effects { |
| + if effect.State == "uncertain" { |
| + held = true |
| + } |
| + } |
| + if !held { |
| + t.Errorf("no uncertain effect: %+v", a.Value.Effects) |
| + } |
| + raw, _, e := cx.rel.Get(ctx, hostops.MailGateKey) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + var gate struct { |
| + State string `json:"state"` |
| + } |
| + if e = json.Unmarshal(raw, &gate); e != nil { |
| + t.Fatal(e) |
| + } |
| + if gate.State != "held" { |
| + t.Errorf("mail gate released: %s", raw) |
| + } |
| +} |
| + |
| +func TestClaimPurgeMailRecordRefusesBeforeEffects(t *testing.T) { |
| + for _, tc := range []struct{ name, key, body string }{ |
| + {"sender schema", "email/senders/app.json", `{"schema":2,"id":"app","scope":"app","source":"domain","provisioned":{"iam_user":"carlos-mail-bab-titogo"}}`}, |
| + {"sender identity", "email/senders/app.json", `{"schema":1,"id":"other","scope":"app","source":"domain","provisioned":{"iam_user":"carlos-mail-bab-titogo"}}`}, |
| + {"legacy schema", "email.json", `{"schema":2,"app":"titogo","provisioned":{"iam_user":"carlos-mail-bab-titogo"}}`}, |
| + {"legacy identity", "email.json", `{"schema":1,"app":"other","provisioned":{"iam_user":"carlos-mail-bab-titogo"}}`}, |
| + } { |
| + for _, stage := range []string{"readiness", "request", "teardown", "mail teardown"} { |
| + t.Run(tc.name+"/"+stage, func(t *testing.T) { |
| + cx := newClaimCase(t) |
| + cx.purgeWorld(t) |
| + p := cx.srv.cfg.ClaimPurge |
| + ctx := t.Context() |
| + const id = "strict-mail-purge" |
| + appID := cx.appIDs[claimAppRef{Account: "bab", App: "titogo"}] |
| + var h *hostops.OperationHandle |
| + if stage == "teardown" { |
| + got, err := p.Request(ctx, "bab", "titogo", id, "owner", claimFxOwner) |
| + if got != id || err != nil && !errors.Is(err, hostops.ErrPending) { |
| + t.Fatalf("request: %s %v", got, err) |
| + } |
| + var err2 error |
| + h, err2 = p.Authority.Control.ResumeExclusive(ctx, "bab", "titogo", appID, id, hostops.OpPurging, p.Owner) |
| + if err2 != nil { |
| + t.Fatal(err2) |
| + } |
| + j, _, err := hostops.ReadPurgeJournal(ctx, cx.rel, "bab", "titogo", id, appID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + j.Phase = "claimed" // Stage fixture: host barriers are tested separately. |
| + key, _ := hostops.PurgeKey("bab", "titogo") |
| + putMailJSON(t, cx.rel, key, j) |
| + } |
| + key := release.AppPrefix("bab", "titogo") + tc.key |
| + if _, err := cx.rel.Put(ctx, key, []byte(tc.body), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + legacy, _, err := mail.Get(ctx, cx.rel, "bab", "titogo") |
| + if err != nil || legacy == nil || legacy.Schema != mail.Schema || legacy.App != "titogo" { |
| + t.Fatalf("flag-off conversion changed: %+v %v", legacy, err) |
| + } |
| + mark := cx.rec.mark() |
| + if stage == "mail teardown" { |
| + err = mail.Teardown(ctx, cx.srv.claimMailStore(), cx.srv.cfg.MailProvisioner, cx.srv.cfg.DNSWriter, cx.srv.cfg.MailDeployment, "bab", "titogo") |
| + } else if stage == "readiness" { |
| + err = p.mailReady(ctx, "bab", "titogo") |
| + } else if stage == "request" { |
| + _, err = p.Request(ctx, "bab", "titogo", id, "owner", claimFxOwner) |
| + journal, _ := hostops.PurgeKey("bab", "titogo") |
| + if _, _, e := cx.rel.Get(ctx, journal); !errors.Is(e, os.ErrNotExist) { |
| + t.Errorf("invalid inventory reserved purge: %v", e) |
| + } |
| + } else { |
| + err = p.stage(ctx, h, "teardown", func(ctx context.Context) error { |
| + scope := mail.ClaimMailScope{Account: "bab", App: "titogo", AppID: appID, Operation: id} |
| + return mail.WithClaimMailResource(mail.WithClaimMailScope(ctx, scope), cx.srv.claimMailStore(), func(held context.Context) error { |
| + return p.teardown(held, h, "bab", "titogo", id, appID) |
| + }) |
| + }) |
| + } |
| + if err == nil || errors.Is(err, hostops.ErrPending) { |
| + t.Errorf("invalid inventory was not refused: %v", err) |
| + } |
| + for _, e := range cx.rec.since(mark) { |
| + if !e.Control && (e.Unscoped || len(e.Scopes) > 0) { |
| + t.Errorf("effect before validation: %v", e) |
| + } |
| + } |
| + if _, _, e := cx.rel.Get(ctx, key); e != nil { |
| + t.Errorf("invalid estate record removed: %v", e) |
| + } |
| + }) |
| + } |
| + } |
| +} |
diff --git a/internal/console/claim_route_writes.go b/internal/console/claim_route_writes.go| index c796dbe09..6672552d9 100644 |
| --- a/internal/console/claim_route_writes.go |
| +++ b/internal/console/claim_route_writes.go |
| @@ -70,9 +70,14 @@ func (s *Server) serviceCredentialWrite(ctx context.Context, acct *Account, h se |
| if s.store.gate == nil { |
| return fn(s.store) |
| } |
| - row, _ := s.findServiceCredential(acct.ID, h) |
| - if row.App == "" { |
| - return fn(s.store) |
| + rows, err := s.store.ServicePrincipals(acct.ID) |
| + if err != nil { |
| + return err |
| + } |
| + for _, row := range rows { |
| + if (h.name != "" && row.Name == h.name) || (h.id != "" && row.ID == h.id) { |
| + return s.store.withAutoAppSQL(ctx, acct.ID, row.App, action, fn) |
| + } |
| } |
| - return s.store.withAutoAppSQL(ctx, acct.ID, row.App, action, fn) |
| + return fn(s.store) |
| } |
diff --git a/internal/console/controlplane.go b/internal/console/controlplane.go| index 4c03dd488..52a3f7faa 100644 |
| --- a/internal/console/controlplane.go |
| +++ b/internal/console/controlplane.go |
| @@ -239,9 +239,7 @@ func (s *Server) handleAppControlPlaneClear(w http.ResponseWriter, r *http.Reque |
| return |
| } |
| if err := s.claimAppMutation(r.Context(), acct, app, "protection", func(ctx context.Context) error { |
| - return s.store.SetAppControlPlaneContext(ctx, |
| - |
| - acct.ID, app, false) |
| + return s.store.SetAppControlPlaneContext(ctx, acct.ID, app, false) |
| }); err != nil { |
| log.Printf("console: control-plane clear %s/%s: %v", acct.Sqid, app, err) |
| back("err", "Could not clear the control-plane flag — nothing was changed. Try again.") |
| @@ -297,9 +295,7 @@ func (s *Server) handleAppControlPlaneSet(w http.ResponseWriter, r *http.Request |
| return |
| } |
| if err := s.claimAppMutation(r.Context(), acct, app, "protection", func(ctx context.Context) error { |
| - return s.store.SetAppControlPlaneContext(ctx, |
| - |
| - acct.ID, app, true) |
| + return s.store.SetAppControlPlaneContext(ctx, acct.ID, app, true) |
| }); err != nil { |
| if errors.Is(err, ErrTemporaryApp) { |
| // A keep cannot race it: under the lock above, nothing can turn a |
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go| index 348affeec..36769f333 100644 |
| --- a/internal/console/domainattach.go |
| +++ b/internal/console/domainattach.go |
| @@ -53,7 +53,6 @@ package console |
| import ( |
| "context" |
| "errors" |
| - "github.com/carlosframework/platform/internal/hostops" |
| "log" |
| "net/http" |
| "os" |
| @@ -62,6 +61,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
diff --git a/internal/console/doorkeys_store.go b/internal/console/doorkeys_store.go| index df5ba12b1..9d74cd35b 100644 |
| --- a/internal/console/doorkeys_store.go |
| +++ b/internal/console/doorkeys_store.go |
| @@ -1,7 +1,5 @@ |
| package console |
| -import |
| - |
| // Door keys, storage half (#175 P7). The console's database is the |
| // authority for which door keys an app has; the bucket only ever holds a |
| // signed projection of these rows (apidoorkeys.go). Only a key's sha256 is |
| @@ -12,9 +10,9 @@ import |
| // one key set. DoorKeyAdvanceRevision moves it forward past a revision the |
| // bucket already carries, which keeps revisions monotonic across a restored |
| // database. |
| -"context" |
| import ( |
| + "context" |
| "database/sql" |
| "errors" |
| "fmt" |
diff --git a/internal/console/expirypass.go b/internal/console/expirypass.go| index 6661096b1..2f58f1817 100644 |
| --- a/internal/console/expirypass.go |
| +++ b/internal/console/expirypass.go |
| @@ -169,9 +169,7 @@ func (s *Server) expireOne(ctx context.Context, r AppRow, now time.Time, ev evid |
| log.Printf("console: expiry pass: re-read marker %s/%s: %v", r.Sqid, r.App, err) |
| return |
| } |
| - if ok, err := s.store.ExpireAppRowContext(ctx, |
| - |
| - r.AccountID, r.App, tomb.DeletedAt); err != nil || !ok { |
| + if ok, err := s.store.ExpireAppRowContext(ctx, r.AccountID, r.App, tomb.DeletedAt); err != nil || !ok { |
| // The marker stands and the row is live. The next door or pass to |
| // touch the app settles it, with the temporary window (spec §5). |
| log.Printf("console: expiry pass: mark %s/%s trashed: changed=%v err=%v", r.Sqid, r.App, ok, err) |
diff --git a/internal/console/fleets.go b/internal/console/fleets.go| index fb7715f79..472c8ab20 100644 |
| --- a/internal/console/fleets.go |
| +++ b/internal/console/fleets.go |
| @@ -27,7 +27,6 @@ import ( |
| "crypto/subtle" |
| "encoding/hex" |
| "errors" |
| - "github.com/carlosframework/platform/internal/hostops" |
| "log" |
| "net/http" |
| "net/url" |
| @@ -37,6 +36,7 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -635,9 +635,7 @@ func (s *Server) placeAppTarget(ctx context.Context, acct *Account, app, target |
| } |
| return http.StatusAccepted, id |
| } |
| - if err := s.store.SetAppPlacementContext(ctx, |
| - |
| - acct.ID, app, target); err != nil { |
| + if err := s.store.SetAppPlacementContext(ctx, acct.ID, app, target); err != nil { |
| log.Printf("console: place %s/%s: set placement: %v", acct.Sqid, app, err) |
| return http.StatusInternalServerError, "could not record placement — try again" |
| } |
diff --git a/internal/console/hostreports.go b/internal/console/hostreports.go| index 20a3e6706..a9c48f7a1 100644 |
| --- a/internal/console/hostreports.go |
| +++ b/internal/console/hostreports.go |
| @@ -5,16 +5,17 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| - "github.com/carlosframework/platform/internal/hostactors" |
| - "github.com/carlosframework/platform/internal/hostfloor" |
| - "github.com/carlosframework/platform/internal/release" |
| - "github.com/carlosframework/platform/internal/version" |
| "log" |
| "os" |
| "path/filepath" |
| "runtime" |
| "strings" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/version" |
| ) |
| // hostRunningImage is the census's seam for what this process is running. It |
diff --git a/internal/console/materialize.go b/internal/console/materialize.go| index 37e562e11..295a052e2 100644 |
| --- a/internal/console/materialize.go |
| +++ b/internal/console/materialize.go |
| @@ -378,9 +378,7 @@ func MaterializeApps(ctx context.Context, s release.Store, st *Store, claimEnabl |
| // A restore that crashed before deleting the marker: release |
| // its cap slot; the app stays in the Trash (spec §6). |
| if r.Restoring { |
| - if _, err := st.SetRestoringContext(ctx, |
| - |
| - r.AccountID, r.App, false); err != nil { |
| + if _, err := st.SetRestoringContext(ctx, r.AccountID, r.App, false); err != nil { |
| log.Printf("console: clear restoring on %s/%s: %v", r.Sqid, r.App, err) |
| } |
| } |
| @@ -388,9 +386,7 @@ func MaterializeApps(ctx context.Context, s release.Store, st *Store, claimEnabl |
| } |
| // The clock comes from the MARKER. TrashAppRow also sets the |
| // 3-day window when the row is temporary (spec §5 "Materialize"). |
| - if err := st.TrashAppRowContext(ctx, |
| - |
| - r.AccountID, r.App, tomb.DeletedAt); err != nil { |
| + if err := st.TrashAppRowContext(ctx, r.AccountID, r.App, tomb.DeletedAt); err != nil { |
| log.Printf("console: mark %s/%s trashed: %v", r.Sqid, r.App, err) |
| continue |
| } |
diff --git a/internal/console/ownership.go b/internal/console/ownership.go| index 112e5451a..ea940a012 100644 |
| --- a/internal/console/ownership.go |
| +++ b/internal/console/ownership.go |
| @@ -16,7 +16,6 @@ import ( |
| "database/sql" |
| "errors" |
| "fmt" |
| - "github.com/carlosframework/platform/internal/hostops" |
| "log" |
| "net/http" |
| "net/url" |
| @@ -25,6 +24,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/release" |
diff --git a/internal/console/pending.go b/internal/console/pending.go| index ca9094bda..1061842ba 100644 |
| --- a/internal/console/pending.go |
| +++ b/internal/console/pending.go |
| @@ -1,7 +1,5 @@ |
| package console |
| -import |
| - |
| // Pending changes: multi-party quorum for the actions release-pipelines-v2 |
| // gates (spec §4) — promote, policy, fasttrack. A change is proposed once, |
| // with its payload pinned at propose time (canonical JSON — Task 8 re-checks |
| @@ -16,9 +14,9 @@ import |
| // quorum of 1 needs a SEPARATE, passkey-backed confirmation before it is |
| // met, including one made by the same identity that proposed it over the |
| // CLI. |
| -"context" |
| import ( |
| + "context" |
| "crypto/rand" |
| "database/sql" |
| "encoding/hex" |
diff --git a/internal/console/pendinghandlers.go b/internal/console/pendinghandlers.go| index 4192eb408..a28a1247a 100644 |
| --- a/internal/console/pendinghandlers.go |
| +++ b/internal/console/pendinghandlers.go |
| @@ -239,9 +239,7 @@ func (s *Server) proposeThenExecute(ctx context.Context, acct *Account, app, kin |
| log.Printf("console: propose %s %s/%s: marshal: %v", kind, acct.Sqid, app, err) |
| return "err", "Could not record that proposal. Please try again." |
| } |
| - pc, err := s.store.ProposeChangeContext(ctx, |
| - |
| - acct.ID, app, kind, raw, quorum, proposer, true, time.Now()) |
| + pc, err := s.store.ProposeChangeContext(ctx, acct.ID, app, kind, raw, quorum, proposer, true, time.Now()) |
| if err != nil { |
| log.Printf("console: propose %s %s/%s: %v", kind, acct.Sqid, app, err) |
| return "err", "Could not record that proposal. Please try again." |
| @@ -803,9 +801,7 @@ func (s *Server) handleAPIPending(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - list, err := s.store.PendingChangesForContext(r.Context(), |
| - |
| - acct.ID, app, time.Now()) |
| + list, err := s.store.PendingChangesForContext(r.Context(), acct.ID, app, time.Now()) |
| if err != nil { |
| log.Printf("console: api pending %s/%s: %v", acct.Sqid, app, err) |
| jsonError(w, http.StatusInternalServerError, "could not read pending changes") |
diff --git a/internal/console/restore.go b/internal/console/restore.go| index 344399038..5887d32d5 100644 |
| --- a/internal/console/restore.go |
| +++ b/internal/console/restore.go |
| @@ -91,21 +91,15 @@ func (s *Server) restoreApp(ctx context.Context, acct *Account, app string, now |
| } |
| return &restoreFailure{Marker: true, Err: err} |
| } |
| - if _, err := s.store.BumpTrashedExpiryContext(ctx, |
| - |
| - acct.ID, app, now); err != nil { |
| + if _, err := s.store.BumpTrashedExpiryContext(ctx, acct.ID, app, now); err != nil { |
| return &restoreFailure{Marker: true, Err: err} |
| } |
| } |
| - if _, err := s.store.SetRestoringContext(ctx, |
| - |
| - acct.ID, app, true); err != nil { |
| + if _, err := s.store.SetRestoringContext(ctx, acct.ID, app, true); err != nil { |
| return &restoreFailure{Marker: true, Err: err} |
| } |
| if err := release.RestoreApp(ctx, s.cfg.Releases, acct.Sqid, app); err != nil { |
| - if _, cerr := s.store.SetRestoringContext(ctx, |
| - |
| - acct.ID, app, false); cerr != nil { |
| + if _, cerr := s.store.SetRestoringContext(ctx, acct.ID, app, false); cerr != nil { |
| log.Printf("console: restore %s/%s: release restoring after a failed marker delete: %v", acct.Sqid, app, cerr) |
| } |
| return &restoreFailure{Marker: true, Err: err} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 8242ebcfc..78e3fab02 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -14,8 +14,6 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| - "github.com/carlosframework/platform/internal/hostops" |
| - "github.com/carlosframework/platform/internal/lease" |
| "html/template" |
| "log" |
| "math" |
| @@ -33,7 +31,6 @@ import ( |
| "time" |
| "github.com/aws/aws-sdk-go-v2/service/costexplorer" |
| - |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/applog" |
| "github.com/carlosframework/platform/internal/boxid" |
| @@ -42,7 +39,9 @@ import ( |
| "github.com/carlosframework/platform/internal/economics" |
| "github.com/carlosframework/platform/internal/edgesfile" |
| "github.com/carlosframework/platform/internal/geo" |
| + "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/infraview" |
| + "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/objstore" |
| @@ -3183,9 +3182,7 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { |
| } else { |
| p.Events = events |
| } |
| - if pcs, err := s.store.PendingChangesForContext(r.Context(), |
| - |
| - acct.ID, app, time.Now()); err != nil { |
| + if pcs, err := s.store.PendingChangesForContext(r.Context(), acct.ID, app, time.Now()); err != nil { |
| log.Printf("console: pending changes %s/%s: %v", acct.Sqid, app, err) |
| } else if len(pcs) > 0 { |
| // One pipeline read for the whole card: the rows show the quorum |
| @@ -3208,9 +3205,7 @@ func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) { |
| } |
| if strings.HasSuffix(r.URL.Path, "/deployments") { |
| p.DeploymentTargets = assembleDeploymentTargets(p.Detail, acct.Sqid) |
| - if pcs, err := s.store.PendingChangesForContext(r.Context(), |
| - |
| - acct.ID, p.AppName, time.Now()); err != nil { |
| + if pcs, err := s.store.PendingChangesForContext(r.Context(), acct.ID, p.AppName, time.Now()); err != nil { |
| log.Printf("console: pending changes %s/%s: %v", acct.Sqid, p.AppName, err) |
| } else if len(pcs) > 0 { |
| p.Pending = s.pendingChangeViews(pcs, s.pendingPipeline(r.Context(), acct, p.AppName)) |
| @@ -3977,9 +3972,7 @@ func (s *Server) claimAppWith(ctx context.Context, acct *Account, name, addr, pl |
| // plan-R6 1). Only reached once the row is ours. |
| log.Printf("console: app region %s/%s: %s (%s)", acct.Sqid, name, s.homeRegionOut(region), source) |
| if placement != "" { |
| - if err := s.store.SetAppPlacementContext(ctx, |
| - |
| - acct.ID, name, placement); err != nil { |
| + if err := s.store.SetAppPlacementContext(ctx, acct.ID, name, placement); err != nil { |
| // Best-effort, like ensureAccountManifest below: the claim itself |
| // already succeeded and must not be undone by this — see this |
| // function's doc comment for why. `placed` stays "" so the |
diff --git a/internal/console/settle.go b/internal/console/settle.go| index 46f67a15d..611722f0d 100644 |
| --- a/internal/console/settle.go |
| +++ b/internal/console/settle.go |
| @@ -88,16 +88,12 @@ func (s *Server) settleApp(ctx context.Context, accountID int64, sqid, app strin |
| if tomb != nil { |
| switch { |
| case !life.Trashed(): |
| - if err := s.store.TrashAppRowContext(ctx, |
| - |
| - accountID, app, tomb.DeletedAt); err != nil { |
| + if err := s.store.TrashAppRowContext(ctx, accountID, app, tomb.DeletedAt); err != nil { |
| return err |
| } |
| log.Printf("console: %s/%s is in the trash in the bucket; marking it trashed here", sqid, app) |
| case life.Restoring: |
| - if _, err := s.store.SetRestoringContext(ctx, |
| - |
| - accountID, app, false); err != nil { |
| + if _, err := s.store.SetRestoringContext(ctx, accountID, app, false); err != nil { |
| return err |
| } |
| log.Printf("console: %s/%s: a restore did not remove the trash marker; it stays in the trash", sqid, app) |
diff --git a/internal/console/svcpage.go b/internal/console/svcpage.go| index 48ef24beb..39fc121df 100644 |
| --- a/internal/console/svcpage.go |
| +++ b/internal/console/svcpage.go |
| @@ -334,9 +334,7 @@ func (s *Server) handleServiceCreatePage(w http.ResponseWriter, r *http.Request) |
| if !s.requireFreshAuth(w, r, addr, raw) { |
| return |
| } |
| - secret, err := s.store.NewServicePrincipalScopedContext(r.Context(), |
| - |
| - acct.ID, name, app, role, r.Form["rungs"], addr, time.Now()) |
| + secret, err := s.store.NewServicePrincipalScopedContext(r.Context(), acct.ID, name, app, role, r.Form["rungs"], addr, time.Now()) |
| if err != nil { |
| // The sentinel, not a string match: a name collision is the owner's |
| // to fix and a busy database is not. |
diff --git a/internal/console/svcprincipal.go b/internal/console/svcprincipal.go| index c36ddfb14..cdd8940c9 100644 |
| --- a/internal/console/svcprincipal.go |
| +++ b/internal/console/svcprincipal.go |
| @@ -18,9 +18,8 @@ |
| // Spec: docs/superpowers/specs/2026-08-18-service-principals-design.md §2. |
| package console |
| -import "context" |
| - |
| import ( |
| + "context" |
| "crypto/subtle" |
| "errors" |
| "fmt" |
diff --git a/internal/console/tempstore.go b/internal/console/tempstore.go| index 0dd409952..d357fed09 100644 |
| --- a/internal/console/tempstore.go |
| +++ b/internal/console/tempstore.go |
| @@ -1,8 +1,7 @@ |
| package console |
| -import "context" |
| - |
| import ( |
| + "context" |
| "database/sql" |
| "errors" |
| "time" |
diff --git a/internal/final-review-report.txt b/internal/final-review-report.txt| deleted file mode 100644 |
| index 8d8f59c1d..000000000 |
| --- a/internal/final-review-report.txt |
| +++ /dev/null |
| @@ -1,152 +0,0 @@ |
| -Final read-only Opus review: Plan5 rev5 remaining authority scope (overlay) |
| -========================================================================== |
| -Static read only. Nothing compiled, run, edited, committed or delegated. |
| -Classifier files and the Moves coordinator were excluded as briefed. |
| - |
| -FINDINGS |
| --------- |
| -P1 An unbound pre-retirement SQL row is adopted into a recreated incarnation |
| - internal/console/claim_app_admission.go:122-132 (bindExistingClaimAppAdmission) |
| - - Setup: console Y holds an app row with app_id='' (legacy row, never bound |
| - because Y was flag-off, offline, or its bind kept hitting ErrPending |
| - while incarnation A was closed). A is purged elsewhere. While A is |
| - completed, Y's sweep correctly returns ErrPending. materializeClaimApp |
| - cannot trash or replay the row because ClaimAppID returns ErrIdentity, |
| - so the row stays live with deleted_at=0 and A-era cli_tokens, door keys |
| - and mail rows. |
| - - Trigger: the name is recreated as B, from any console (on Y itself, |
| - createClaimAppRow enrolls B, the insert returns created=false, and B is |
| - left open). |
| - - Result: Y's next SweepClaimAppIdentities reaches bindExisting, which |
| - sees bound=="" and calls bindClaimAppIdentity(B). |
| - claimEnrollmentContext accepts '', RunAppMutation admits B, and the |
| - UPDATE ... (app_id='' OR ...) binds A's row to B. From then on, |
| - mutationAppID returns B == canonical, so A-era legacy app tokens on Y |
| - pass RunAppMutation and mail admission and mutate B. This is the stale |
| - console P1 again, and A's local state reopens under B. |
| - - Why the row cannot belong to B: under claim mode a successor's row is |
| - only ever created by createClaimAppRow, which inserts and binds in one |
| - permitted callback. So an unbound row while canonical last_purge != nil |
| - always predates the retirement. |
| - - Fix: in bindExistingClaimAppAdmission, when bound=="" and |
| - a.Value.LastPurge != nil, return hostops.ErrPending (log it) instead of |
| - binding. Leave first-incarnation backfill binding unchanged. |
| - - Test: two consoles; B's row stays unbound (seed app_id=''); purge and |
| - recreate on A. Assert that SweepClaimAdmissions on B leaves app_id='' |
| - and that B's legacy app token gets ErrIdentity on config PUT and |
| - mail.Declare, with the canonical ETag unchanged. |
| - |
| -P2 An uncertain purge stage permanently wedges the purge; joined writes are mislabelled not-applied |
| - internal/console/claim_purge.go:39-54 (stage) |
| - - Wedge: |
| - - Any non-ErrPending error from a stage settles that stage's effect as |
| - "uncertain". This includes pure read failures before any write: a |
| - transient ListVersions or Get error in PreparePurgeSweep, |
| - VerifyPurgeSweep, RetirePurgeHosts or retireDomains, or a journal |
| - read error. |
| - - Every retry uses the same Resource "purge-stage:<name>", so |
| - prepareEffect (hostops/admission.go:276-280) refuses it with |
| - ErrPending forever. |
| - - CompletePurge and finish also need effectsSettled. |
| - - Nothing reconciles an uncertain purge-stage effect: only |
| - mail_reconcile handles uncertain effects. The admission stays closed |
| - for good and the name can never be recreated. |
| - - Mislabel: |
| - - The reverse case also exists. RetirePurgeHosts |
| - (hostops/purge_hosts.go:233-236, 260-263) returns ErrPending right |
| - after a successful c.Free or c.Retire record delete. |
| - - DeletePurgeSweep can return ErrPending after DeleteVersion calls. |
| - - The stage maps both to ErrNotApplied, so effects that reached a sink |
| - are recorded "not-applied". The calls had joined, so no late effect |
| - is possible and nothing reads the label today. It is still a false |
| - settlement record. |
| - - Fix: |
| - - Run the stage fn under an issued mark, as RunAppMutation and |
| - runRecordEffect do (MarkAppMutationIssued is already called by the |
| - boundary, versioned boundary and lifecycle record sinks). |
| - - If fn errs and nothing was marked, settle with ErrNotApplied whatever |
| - the error. |
| - - If something was marked and the error is ErrPending (synchronous, |
| - joined), settle complete with result "joined-pending". |
| - - Only a marked, non-pending error stays uncertain. That residue needs |
| - a journal-verified reconciler before a retry (open item below). |
| - |
| -P3 The purging console's app-row stage can wedge after the tombstone is gone |
| - internal/console/claim_purge.go:359-366 |
| - - PurgeClaimAppRow requires deleted_at!=0. The automatic path |
| - (SweepRequests -> Request) never checks that the local row is trashed; |
| - only the owner HTTP door does, through recheckUnderLock. |
| - - Once the purge closes admission, materialize cannot project the trash. |
| - The tombstone stage (:342-358) has already deleted the marker. If every |
| - console driving the purge has an untrashed row, the app-row stage |
| - returns ErrPending forever. |
| - - Fix: inside the app-row callback, call st.MarkClaimAppRetired(acct.ID, |
| - app, appID, now) before PurgeClaimAppRow, as the purge-receipt replay |
| - does (claim_materialize.go:95-103). The effect permit allows it. |
| - |
| -P3 The probe SQL tripwire watches only names that have an apps row |
| - internal/console/claim_probe_all_apps_test.go:10-21, claim_invariant_probes_test.go:143-199 |
| - - The watched set is AppRows() before and after the probe (apps JOIN live |
| - accounts). App-scoped rows of an (account, app) with no apps row are not |
| - digested: orphan cli_tokens, door_keys or mail rows, or rows a route |
| - inserts for a name that has no row. |
| - - The production trigger still bounds such rows. This is a detector gap |
| - only. |
| - - Fix: build the watched set from SELECT DISTINCT over every app-scoped |
| - table in claimSQLTables (sqid/account and app columns), plus AppRows(). |
| - |
| -CHECKED, NO FINDINGS |
| --------------------- |
| -- No reopen of a closed canonical incarnation: |
| - - nextAdmission's reopen requires before.Op != nil, so a completed |
| - (op-less, closed) admission cannot reopen through finish, |
| - abandonUnissued or FinishMutation. |
| - - exclusiveAdmission refuses non-open. EnrollBackfillApp refuses completed. |
| - - EnrollNewApp recreates only with a new app_id, epoch+1, last_purge |
| - carried, and the bucket name free. |
| - - last_purge changes only via purgeCompletion. |
| -- Local-SQL settlement: |
| - - withAutoAppSQL, placement sqlEffect and purge localSQLStage all settle |
| - "joined" only after WithAppMutation returns. |
| - - The SQL error is returned to the caller, and every phase transition |
| - (SetPhase, FinishPlacement, SavePurgeJournal Cleanup.AppRow, CompletePurge) |
| - is skipped on error. |
| - - BindClaimAppOperation, SetClaimAppPlacement and PurgeClaimAppRow are |
| - bound to app_id and claim_op. |
| - - The purge receipt replay grant is keyed on the retired app_id plus |
| - last_purge, and requires the journal to be absent. |
| -- Retirement: |
| - - retireClaimHostRecord uses the local app_id with exact ReadAdmission. |
| - - A foreign or other-host op is refused (409) before any effect. |
| - - A lost-response BeginExclusive is resumed by the sweep as this host's |
| - OpDeleting. |
| - - The record delete runs inside an effect. |
| - - Markers are deleted only with an identity match and DeleteIfMatch. |
| - - FinishDelete requires no registrations and settled effects. |
| -- Rev5 census fixes: |
| - - The ungated DELETE witness now lands a real DELETE through withClaimSQL |
| - and asserts the foreign-run, never-registered and cli_tokens SQL |
| - problems. The trigger-refusal variant is kept as a separate subtest. |
| - - The probe watched set includes apps created by setup or by the request. |
| - - TestClaimProbeDetectsSQLMutationOutsideDefaultApps exercises a third app. |
| -- Worker probes: each has a world witness (moves-console record, |
| - alert_seen, census day plus bill, orphan blob removed, console report, |
| - inventory). PublishMovesConsole turns MovesEnabled on in setup as the |
| - earlier review asked. |
| -- Flag-off: createClaimAppRow, withAutoAppSQL (gate==nil) and |
| - deleteLegacySidecarWake are pass-through. Purge, placement and retire are |
| - claim-only. |
| - |
| -LIMITS |
| ------- |
| -- Read probe table: markers and setups spot-checked only, not each entry. |
| -- Not traced: |
| - - ResumeExclusive owner semantics (BeginPurge resumes an existing op for |
| - any owner). |
| - - Placement projection staleness on non-finishing consoles (not |
| - authority). |
| - - hostops Retire, Free and RefreshDeleteRecord internals. |
| -- Open item for Paul: purge stages that fail after a marked write stay |
| - "uncertain" with no reconciler (same class as moves report 5b). A |
| - journal-verified reconciler, or a supersede-on-verified-rerun rule, is |
| - needed so a single ambiguous S3 error cannot retire a name permanently. |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 0139046bc..e7267d0e1 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -2186,7 +2186,7 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu |
| // same reason: one silent read per served sidecar at arming so the |
| // FIRST wake request after a boot is acted on rather than swallowed as |
| // the baseline (sidecarWakeSeed's own doc comment). |
| - sidecarWakeMem := newSidecarWakeMemory() |
| + sidecarWakeMem := newSidecarWakeMemory(writes.Enabled) |
| sidecarWakeSeed(ctx, reg, store, sidecarWakeMem) |
| sidecarWake := wakeFn(svc) |
| // The schedule converge's own seed, on restartSeed's precedent and for the |
diff --git a/internal/host/sidecarwake.go b/internal/host/sidecarwake.go| index 47570e5c9..5893a6953 100644 |
| --- a/internal/host/sidecarwake.go |
| +++ b/internal/host/sidecarwake.go |
| @@ -31,7 +31,6 @@ import ( |
| "fmt" |
| "log" |
| "os" |
| - "strings" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -67,8 +66,9 @@ func sidecarsServed(routes []registry.Route) map[[3]string]registry.Route { |
| // and a bare map cannot tell them apart, which is exactly how the |
| // create-then-wake flow came to swallow its own first wake. |
| type sidecarWakeMemory struct { |
| - identities map[string]release.SidecarWake |
| - etags map[[3]string]string |
| + claimsEnabled bool |
| + identities map[string]release.SidecarWake |
| + etags map[[3]string]string |
| // seeded is set by the first completed pass (the arming one in |
| // production, sidecarWakeSeed; the first leg pass when arming's route |
| // list could not be read). Before it, first sight is the baseline; after |
| @@ -76,8 +76,8 @@ type sidecarWakeMemory struct { |
| seeded bool |
| } |
| -func newSidecarWakeMemory() *sidecarWakeMemory { |
| - return &sidecarWakeMemory{identities: map[string]release.SidecarWake{}, etags: map[[3]string]string{}} |
| +func newSidecarWakeMemory(claimsEnabled bool) *sidecarWakeMemory { |
| + return &sidecarWakeMemory{claimsEnabled: claimsEnabled, identities: map[string]release.SidecarWake{}, etags: map[[3]string]string{}} |
| } |
| // sidecarWakeChanges is sidecar-wake's counterpart to restartGenPass: read |
| @@ -141,7 +141,7 @@ func sidecarWakeChanges(ctx context.Context, routes []registry.Route, store rele |
| readErrs = append(readErrs, fmt.Errorf("%s/%s/%s: %w", k[0], k[1], k[2], err)) |
| continue // cache entry untouched: unknown this tick, not changed |
| } |
| - if strings.TrimSpace(os.Getenv("CARLOS_HOST_CLAIM")) != "" { |
| + if mem.claimsEnabled { |
| var signal release.SidecarWake |
| if etag == genAbsent || json.Unmarshal(raw, &signal) != nil { |
| continue |
| @@ -225,7 +225,7 @@ func sidecarWakeLeg(ctx context.Context, routes []registry.Route, store release. |
| if len(moved) == 0 { |
| return moved, readErrs |
| } |
| - if strings.TrimSpace(os.Getenv("CARLOS_HOST_CLAIM")) != "" { |
| + if mem.claimsEnabled { |
| captured := make(map[string]release.SidecarWake, len(moved)) |
| for _, rt := range moved { |
| captured[rt.Host] = mem.identities[rt.Host] |
diff --git a/internal/host/sidecarwake_identity_test.go b/internal/host/sidecarwake_identity_test.go| index 386d55972..cb420ba56 100644 |
| --- a/internal/host/sidecarwake_identity_test.go |
| +++ b/internal/host/sidecarwake_identity_test.go |
| @@ -15,7 +15,7 @@ import ( |
| ) |
| func TestSidecarWakeOldIdentityCannotWakeReplacement(t *testing.T) { |
| - t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + t.Setenv("CARLOS_HOST_CLAIM", "") |
| ctx := context.Background() |
| s := release.NewDirStore(t.TempDir()) |
| host := provision.SidecarHost("bot", "app", "acct") |
| @@ -43,7 +43,7 @@ func TestSidecarWakeOldIdentityCannotWakeReplacement(t *testing.T) { |
| t.Fatal(err) |
| } |
| rt := registry.Route{Host: host, Kind: "sidecar", Slug: "bot", App: "app", Account: "acct"} |
| - mem := newSidecarWakeMemory() |
| + mem := newSidecarWakeMemory(true) |
| sidecarWakeChanges(ctx, nil, s, mem) |
| if err = release.TouchSidecarWake(ctx, s, "acct", "app", "bot", "operator", "old", release.SidecarWakeIdentity{AppID: "old-app", Incarnation: inc, RequestID: "old-request"}); err != nil { |
| t.Fatal(err) |
| @@ -75,7 +75,7 @@ func TestSidecarWakeOldIdentityCannotWakeReplacement(t *testing.T) { |
| if cleaned, err := hostops.ReadAdmission(ctx, s, "acct", "app", "new-app"); err != nil || len(cleaned.Value.Registrations) != 0 { |
| t.Fatalf("registration not settled: %v %+v", err, cleaned.Value.Registrations) |
| } |
| - delayed := newSidecarWakeMemory() |
| + delayed := newSidecarWakeMemory(true) |
| sidecarWakeChanges(ctx, nil, s, delayed) |
| moved, errs = sidecarWakeChanges(ctx, []registry.Route{rt}, s, delayed) |
| if len(errs) != 0 || len(moved) != 1 { |
| @@ -98,3 +98,28 @@ func TestSidecarWakeOldIdentityCannotWakeReplacement(t *testing.T) { |
| t.Fatal("queued request survived replacement before dispatch") |
| } |
| } |
| + |
| +func TestSidecarWakeFlagOffIgnoresEnvironment(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + ctx := t.Context() |
| + s := release.NewDirStore(t.TempDir()) |
| + mem := newSidecarWakeMemory(false) |
| + sidecarWakeChanges(ctx, nil, s, mem) |
| + rt := registry.Route{Host: provision.SidecarHost("bot", "app", "acct"), Kind: "sidecar", Slug: "bot", App: "app", Account: "acct"} |
| + if err := release.TouchSidecarWake(ctx, s, "acct", "app", "bot", "operator", "wake"); err != nil { |
| + t.Fatal(err) |
| + } |
| + woke := make(chan registry.Route, 1) |
| + moved, errs := sidecarWakeLeg(ctx, []registry.Route{rt}, s, mem, func(r registry.Route) error { woke <- r; return nil }) |
| + if len(moved) != 1 || len(errs) != 0 { |
| + t.Fatalf("flag-off legacy wake: moved=%v errors=%v", moved, errs) |
| + } |
| + select { |
| + case got := <-woke: |
| + if got.Host != rt.Host || got.WakeIncarnation != "" { |
| + t.Fatalf("legacy wake changed: %+v", got) |
| + } |
| + case <-time.After(time.Second): |
| + t.Fatal("legacy dispatch did not wake") |
| + } |
| +} |
diff --git a/internal/host/sidecarwake_test.go b/internal/host/sidecarwake_test.go| index f4fff70f4..082b2d3de 100644 |
| --- a/internal/host/sidecarwake_test.go |
| +++ b/internal/host/sidecarwake_test.go |
| @@ -43,7 +43,7 @@ func TestSidecarWakeChangesEtagMoveReportsExactlyThatSidecar(t *testing.T) { |
| wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot"), |
| wakeSidecarRoute("other.eleven.sidecar.internal", "bdm", "eleven", "other"), |
| } |
| - mem := newSidecarWakeMemory() |
| + mem := newSidecarWakeMemory(false) |
| sidecarWakeChanges(ctx, routes, s, mem) // seed: first sight of both, silently |
| touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") |
| @@ -60,7 +60,7 @@ func TestSidecarWakeChangesUnchangedEtagReportsNothing(t *testing.T) { |
| ctx, s := genKit(t) |
| routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")} |
| touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") |
| - mem := newSidecarWakeMemory() |
| + mem := newSidecarWakeMemory(false) |
| sidecarWakeChanges(ctx, routes, s, mem) // seed |
| got, errs := sidecarWakeChanges(ctx, routes, s, mem) |
| @@ -85,7 +85,7 @@ func TestSidecarWakeChangesBaselinePassDoesNotReplay(t *testing.T) { |
| // long-lived wake request or a box that just (re)started sees. |
| touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") |
| routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")} |
| - mem := newSidecarWakeMemory() |
| + mem := newSidecarWakeMemory(false) |
| got, errs := sidecarWakeChanges(ctx, routes, s, mem) |
| if len(errs) != 0 { |
| @@ -120,7 +120,7 @@ func TestSidecarWakeChangesBaselinePassDoesNotReplay(t *testing.T) { |
| func TestSidecarWakeChangesRouteMintedAfterTheBaselineIsWoken(t *testing.T) { |
| ctx, s := genKit(t) |
| existing := wakeSidecarRoute("old.eleven.sidecar.internal", "bdm", "eleven", "old") |
| - mem := newSidecarWakeMemory() |
| + mem := newSidecarWakeMemory(false) |
| sidecarWakeChanges(ctx, []registry.Route{existing}, s, mem) // arming: this box's baseline |
| // The console creates a sidecar and wakes it; this box's next tick mints |
| @@ -142,7 +142,7 @@ func TestSidecarWakeChangesRouteMintedAfterTheBaselineIsWoken(t *testing.T) { |
| // wakes a hibernating sidecar nobody asked to run. |
| func TestSidecarWakeChangesRouteMintedWithNoWakeObjectIsSilent(t *testing.T) { |
| ctx, s := genKit(t) |
| - mem := newSidecarWakeMemory() |
| + mem := newSidecarWakeMemory(false) |
| sidecarWakeChanges(ctx, nil, s, mem) // arming on a box with no sidecars |
| minted := wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot") |
| @@ -171,7 +171,7 @@ func TestSidecarWakeSeedListFailureLeavesTheBaselineToTheFirstPass(t *testing.T) |
| ctx, s := genKit(t) |
| touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") // history, from before this agent |
| routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")} |
| - mem := newSidecarWakeMemory() // arming never completed: no List, nothing seeded |
| + mem := newSidecarWakeMemory(false) // arming never completed: no List, nothing seeded |
| got, errs := sidecarWakeChanges(ctx, routes, s, mem) |
| if len(errs) != 0 { |
| @@ -200,7 +200,7 @@ func TestSidecarWakeChangesReadErrorDoesNotSuppressOthers(t *testing.T) { |
| wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot"), |
| wakeSidecarRoute("worker.titogo.sidecar.internal", "bdm", "titogo", "worker"), |
| } |
| - mem := newSidecarWakeMemory() |
| + mem := newSidecarWakeMemory(false) |
| sidecarWakeChanges(ctx, routes, s0, mem) // seed both, absent |
| touchSidecarWake(t, ctx, s0, "bdm", "eleven", "bot") |
| @@ -223,7 +223,7 @@ func TestSidecarWakeChangesIgnoresUnservedSidecar(t *testing.T) { |
| ctx, s := genKit(t) |
| touchSidecarWake(t, ctx, s, "bdm", "eleven", "ghost") // no route for "ghost" at all |
| routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")} |
| - mem := newSidecarWakeMemory() |
| + mem := newSidecarWakeMemory(false) |
| sidecarWakeChanges(ctx, routes, s, mem) // seed |
| touchSidecarWake(t, ctx, s, "bdm", "eleven", "ghost") // "ghost" moves again |
| @@ -251,7 +251,7 @@ func TestSidecarWakeChangesIgnoresNonSidecarRouteWithMatchingSlug(t *testing.T) |
| notSidecar := execRoute("app.example.com", "bdm", "eleven") |
| notSidecar.Slug = "bot" // same name a sidecar could carry, wrong Kind |
| routes := []registry.Route{notSidecar} |
| - mem := newSidecarWakeMemory() |
| + mem := newSidecarWakeMemory(false) |
| got, errs := sidecarWakeChanges(ctx, routes, s, mem) |
| if len(errs) != 0 { |
| @@ -417,7 +417,7 @@ func TestSidecarWakeLegActsOnBumpedGeneration(t *testing.T) { |
| t.Fatalf("upsert %s: %v", rt.Host, err) |
| } |
| } |
| - mem := newSidecarWakeMemory() |
| + mem := newSidecarWakeMemory(false) |
| sidecarWakeSeed(ctx, reg, s, mem) |
| touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") |
| @@ -450,7 +450,7 @@ func TestSidecarWakeLegActsOnBumpedGeneration(t *testing.T) { |
| func TestSidecarWakeLegCreateThenWakeInOneTickStillWakesIt(t *testing.T) { |
| ctx, s := genKit(t) |
| reg := restartTestRegistry(t) |
| - mem := newSidecarWakeMemory() |
| + mem := newSidecarWakeMemory(false) |
| sidecarWakeSeed(ctx, reg, s, mem) // arming: no sidecar routes yet |
| // create + wake, both before this box's next tick. |
| @@ -499,7 +499,7 @@ func TestSidecarWakeLegAfterAnAgentRestartReplaysNothing(t *testing.T) { |
| touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") // history |
| touchSidecarWake(t, ctx, s, "bdm", "eleven", "other") // history |
| - mem := newSidecarWakeMemory() // a fresh process |
| + mem := newSidecarWakeMemory(false) // a fresh process |
| sidecarWakeSeed(ctx, reg, s, mem) |
| if len(mem.etags) != 2 { |
| t.Fatalf("the arming pass recorded %d etags, want 2 — a baseline that records nothing is no baseline at all", len(mem.etags)) |
| @@ -534,7 +534,7 @@ func TestSidecarWakeLegAfterAnAgentRestartReplaysNothing(t *testing.T) { |
| func TestSidecarWakeLegReturnsBeforeASlowWakeCompletes(t *testing.T) { |
| ctx, s := genKit(t) |
| routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")} |
| - mem := newSidecarWakeMemory() |
| + mem := newSidecarWakeMemory(false) |
| sidecarWakeChanges(ctx, routes, s, mem) // seed |
| touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") |
diff --git a/internal/hostactors/roster.go b/internal/hostactors/roster.go| index 44e6af316..f2fe48e9c 100644 |
| --- a/internal/hostactors/roster.go |
| +++ b/internal/hostactors/roster.go |
| @@ -5,15 +5,16 @@ import ( |
| "crypto/ed25519" |
| "encoding/json" |
| "errors" |
| - "github.com/carlosframework/platform/internal/boxid" |
| - "github.com/carlosframework/platform/internal/hostquarantine" |
| - "github.com/carlosframework/platform/internal/release" |
| "math" |
| "reflect" |
| "regexp" |
| "slices" |
| "strings" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/boxid" |
| + "github.com/carlosframework/platform/internal/hostquarantine" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| const Key = "control/host-actors.json" |
diff --git a/internal/hostops/app_mutation_runner_test.go b/internal/hostops/app_mutation_runner_test.go| index 0c4421377..2027d8ae9 100644 |
| --- a/internal/hostops/app_mutation_runner_test.go |
| +++ b/internal/hostops/app_mutation_runner_test.go |
| @@ -3,8 +3,9 @@ package hostops |
| import ( |
| "context" |
| "errors" |
| - "github.com/carlosframework/platform/internal/release" |
| "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| func TestAppMutationRunnerOwnsTheWholeCall(t *testing.T) { |
diff --git a/internal/hostops/evidence_discovery_test.go b/internal/hostops/evidence_discovery_test.go| index 9c539af53..723970ccd 100644 |
| --- a/internal/hostops/evidence_discovery_test.go |
| +++ b/internal/hostops/evidence_discovery_test.go |
| @@ -7,6 +7,10 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| "github.com/carlosframework/platform/internal/hostactors" |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| @@ -15,9 +19,6 @@ import ( |
| "github.com/carlosframework/platform/internal/lease/leasetest" |
| "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/release" |
| - "strings" |
| - "testing" |
| - "time" |
| ) |
| func TestAdoptedHostDiscoveryThroughProductionRegionalWorker(t *testing.T) { |
diff --git a/internal/hostops/mutation_boundary_test.go b/internal/hostops/mutation_boundary_test.go| index 01cc9fb95..a6d87e848 100644 |
| --- a/internal/hostops/mutation_boundary_test.go |
| +++ b/internal/hostops/mutation_boundary_test.go |
| @@ -3,8 +3,9 @@ package hostops |
| import ( |
| "context" |
| "errors" |
| - "github.com/carlosframework/platform/internal/release" |
| "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| func TestMutationBoundaryRefusesEveryWriteWithoutAuthority(t *testing.T) { |
diff --git a/internal/hostops/production_held_start.go b/internal/hostops/production_held_start.go| index fc00f1e0c..b667a92de 100644 |
| --- a/internal/hostops/production_held_start.go |
| +++ b/internal/hostops/production_held_start.go |
| @@ -4,9 +4,10 @@ import ( |
| "context" |
| "crypto/sha256" |
| "fmt" |
| + "sort" |
| + |
| "github.com/carlosframework/platform/internal/hostactors" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| - "sort" |
| ) |
| // Each exact saved host observation owns a distinct lifecycle reservation. |
diff --git a/internal/hostops/purge_retirement_test.go b/internal/hostops/purge_retirement_test.go| index c9bdda8fc..1d6d29652 100644 |
| --- a/internal/hostops/purge_retirement_test.go |
| +++ b/internal/hostops/purge_retirement_test.go |
| @@ -2,8 +2,9 @@ package hostops |
| import ( |
| "errors" |
| - "github.com/carlosframework/platform/internal/hostclaim" |
| "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| ) |
| func TestPurgeRetirementRequiresFree(t *testing.T) { |
diff --git a/internal/mail/claim_gate.go b/internal/mail/claim_gate.go| index 454d5e51d..f9394201c 100644 |
| --- a/internal/mail/claim_gate.go |
| +++ b/internal/mail/claim_gate.go |
| @@ -166,12 +166,13 @@ type claimMailHeld struct { |
| type claimMailAttempts struct { |
| mu sync.Mutex |
| list []hostops.MailAttempt |
| + err error |
| } |
| -func (a *claimMailAttempts) snapshot() []hostops.MailAttempt { |
| +func (a *claimMailAttempts) snapshot() ([]hostops.MailAttempt, error) { |
| a.mu.Lock() |
| defer a.mu.Unlock() |
| - return slices.Clone(a.list) |
| + return slices.Clone(a.list), a.err |
| } |
| // markClaimMailDirty records an attempt on an unnamed surface (kept for the |
| @@ -191,6 +192,7 @@ func markClaimMailEffect(ctx context.Context, at hostops.MailAttempt, err error) |
| if rec, ok := ctx.Value(claimMailAttemptsKey{}).(*claimMailAttempts); ok { |
| rec.mu.Lock() |
| rec.list = append(rec.list, at) |
| + rec.err = errors.Join(rec.err, err) |
| rec.mu.Unlock() |
| return |
| } |
| @@ -413,7 +415,8 @@ func claimMailAdmitted(ctx context.Context, s release.Store, acct string, plan c |
| rec := &claimMailAttempts{} |
| admitted := context.WithValue(context.WithValue(admission.Context(), claimMailCapsKey{}, caps), claimMailAttemptsKey{}, rec) |
| result := call(admitted) |
| - attempts := rec.snapshot() |
| + attempts, effectErr := rec.snapshot() |
| + result = errors.Join(result, effectErr) |
| if err := admission.Settle(ctx, result, attempts); err != nil { |
| // An unsettled effect retains both the app authority and the gate. |
| held.Retain.Store(true) |
diff --git a/internal/mail/claim_gate_domain_test.go b/internal/mail/claim_gate_domain_test.go| index d2879270b..348bbbf54 100644 |
| --- a/internal/mail/claim_gate_domain_test.go |
| +++ b/internal/mail/claim_gate_domain_test.go |
| @@ -5,13 +5,13 @@ import ( |
| "context" |
| "errors" |
| "fmt" |
| - "github.com/carlosframework/platform/internal/hostops" |
| "strings" |
| "sync" |
| "testing" |
| "time" |
| "github.com/carlosframework/platform/internal/dnsrec" |
| + "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
diff --git a/internal/mail/mail.go b/internal/mail/mail.go| index 114e103bc..b3919b835 100644 |
| --- a/internal/mail/mail.go |
| +++ b/internal/mail/mail.go |
| @@ -201,6 +201,19 @@ func claimUnserializedPut(ctx context.Context, s release.Store, acct, app string |
| // GetSender reads one sender. The app sender falls back to LegacyKey with an |
| // empty etag; any other sender has no legacy form and is simply absent. |
| func GetSender(ctx context.Context, s release.Store, acct, app, id string) (*Sender, string, error) { |
| + _, strict := s.(ClaimMailStore) |
| + return getSender(ctx, s, acct, app, id, strict) |
| +} |
| + |
| +func StrictGet(ctx context.Context, s release.Store, acct, app string) (*Record, string, error) { |
| + sn, etag, err := getSender(ctx, s, acct, app, SenderAppID, true) |
| + if err != nil || sn == nil { |
| + return nil, "", err |
| + } |
| + return sn.record(app), etag, nil |
| +} |
| + |
| +func getSender(ctx context.Context, s release.Store, acct, app, id string, strict bool) (*Sender, string, error) { |
| if !release.ValidAccount(acct) { |
| return nil, "", fmt.Errorf("mail: invalid account %q", acct) |
| } |
| @@ -216,6 +229,11 @@ func GetSender(ctx context.Context, s release.Store, acct, app, id string) (*Sen |
| if err := json.Unmarshal(raw, &sn); err != nil { |
| return nil, "", fmt.Errorf("mail: %s: %w", SenderKey(acct, app, id), err) |
| } |
| + if strict && (sn.Schema < 0 || sn.Schema > Schema || sn.ID != id || |
| + (id == SenderAppID && (sn.Scope != ScopeApp || sn.Host != "" || sn.Source != SourceDomain)) || |
| + (id != SenderAppID && sn.Scope != ScopeInstance)) { |
| + return nil, "", ErrDomainInventory |
| + } |
| return &sn, etag, nil |
| } |
| if !errors.Is(err, os.ErrNotExist) { |
| @@ -235,6 +253,9 @@ func GetSender(ctx context.Context, s release.Store, acct, app, id string) (*Sen |
| if err := json.Unmarshal(raw, &rec); err != nil { |
| return nil, "", fmt.Errorf("mail: %s: %w", LegacyKey(acct, app), err) |
| } |
| + if strict && (rec.Schema < 0 || rec.Schema > Schema || rec.App != app) { |
| + return nil, "", ErrDomainInventory |
| + } |
| // The empty etag is the whole of the migration's safety: it makes the |
| // caller's Put a create, so two consoles racing the first sender write |
| // cannot both win. |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index 196264df9..64ea58068 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -13,7 +13,6 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| - "github.com/carlosframework/platform/internal/controlname" |
| "log" |
| "net" |
| "os" |
| @@ -24,11 +23,11 @@ import ( |
| "strings" |
| "time" |
| - "golang.org/x/crypto/bcrypt" |
| - |
| + "github.com/carlosframework/platform/internal/controlname" |
| "github.com/carlosframework/platform/internal/controlsock" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| + "golang.org/x/crypto/bcrypt" |
| ) |
| // Record is the wire shape at apps/<sqid>/<app>/instances/<host>.json — one |
diff --git a/internal/release/release.go b/internal/release/release.go| index 4000a04dd..4f73d15e6 100644 |
| --- a/internal/release/release.go |
| +++ b/internal/release/release.go |
| @@ -31,13 +31,13 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| - "github.com/carlosframework/platform/internal/controlname" |
| "os" |
| "regexp" |
| "strings" |
| "time" |
| "github.com/carlosframework/platform/internal/caps" |
| + "github.com/carlosframework/platform/internal/controlname" |
| ) |
| // Manifest describes one shipped release of one app. It is content-addressed |