carlos / platform Public

e2e: the operator's doors unchanged on the bridge with the switch off, an operator return after a self-serve move, and the claims-off box's published instance id

TestSelfServeOperatorDoorsUnchangedOnTheBridge replays the console's
answers from main (operator-door-answers.golden) for the bridge's world and
checks the sentinel reservation and the legacy move door;
TestSelfServeThenOperatorReturn runs a self-serve forward and then the
operator's return through every operator door to home;
TestClaimsOffInstanceIDIsPublished checks the status block's box id and
instance id, read on each tick, and the participants pinned by them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev ff36030a3d8c20b3fb5b4181cd6d3ff73e671b7f parent d396142
1 file changed, +358 −0
  • internal/e2e/selfserve/selfserve_operator_test.go +358 −0
diff --git a/internal/e2e/selfserve/selfserve_operator_test.go b/internal/e2e/selfserve/selfserve_operator_test.go
new file mode 100644
index 000000000..8a3297d93
--- /dev/null
+++ b/internal/e2e/selfserve/selfserve_operator_test.go
@@ -0,0 +1,358 @@
+//go:build e2e
+
+package selfserve
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/host"
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The operator's doors beside the self-serve move, and the box's identity
+// in its status (self-serve moves plan 2 Task 10).
+
+// operatorLine renders an operator door's answer as the console's replay
+// from main keeps it (operatorAnswer in
+// internal/console/apiinstancemove_offline_snapshot_test.go): the status
+// and the error sentence, or for a 200 or 202 the view's stable fields.
+func operatorLine(code int, out string) string {
+ if code == http.StatusAccepted || code == http.StatusOK {
+ var v struct {
+ State string `json:"state"`
+ From string `json:"from"`
+ To string `json:"to"`
+ Stager string `json:"stager"`
+ Next string `json:"next"`
+ Waiting string `json:"waiting"`
+ CanAbort bool `json:"can_abort"`
+ NeverOffline bool `json:"never_offline"`
+ }
+ if err := json.Unmarshal([]byte(out), &v); err != nil {
+ return fmt.Sprintf("%d unparsed %q", code, out)
+ }
+ return fmt.Sprintf("%d state=%s from=%s to=%s stager=%s next=%s waiting=%q can_abort=%v never_offline=%v",
+ code, v.State, v.From, v.To, v.Stager, v.Next, v.Waiting, v.CanAbort, v.NeverOffline)
+ }
+ return fmt.Sprintf("%d %q", code, errorOf(out))
+}
+
+// goldenOperatorLines is the console's replay from main, by "case | what",
+// with its host named as the bridge names its instance.
+func goldenOperatorLines(t *testing.T) map[string]string {
+ t.Helper()
+ raw, err := os.ReadFile("../../console/testdata/operator-door-answers.golden")
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := map[string]string{}
+ for _, line := range strings.Split(strings.ReplaceAll(string(raw), "acme.shop.example.com", host.E2EHost), "\n") {
+ parts := strings.SplitN(line, " | ", 3)
+ if len(parts) == 3 {
+ out[parts[0]+" | "+parts[1]] = parts[2]
+ }
+ }
+ return out
+}
+
+// legacyMoveDoor calls the move door itself (POST .../move, the request
+// door that self-serve mode makes the member's) with the operator's token.
+func (w *jobWorld) legacyMoveDoor(body string) (int, string) {
+ w.t.Helper()
+ tok, base := w.operator()
+ req, err := http.NewRequest(http.MethodPost, strings.TrimSuffix(base, "/offline"), strings.NewReader(body))
+ if err != nil {
+ w.t.Fatal(err)
+ }
+ req.Header.Set("Authorization", "Bearer "+tok)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ w.t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ out, _ := io.ReadAll(resp.Body)
+ return resp.StatusCode, string(out)
+}
+
+// TestSelfServeOperatorDoorsUnchangedOnTheBridge: on the bridge's real
+// boxes, with CARLOS_SELF_SERVE_MOVES off, the operator's start and stage
+// doors answer every line the console's replay from main keeps for this
+// world (the good body, a malformed one, the current region and an invalid
+// one, each door's view after it, and a staged move's status, restage and
+// start), byte for byte; the operator's reservation is the sentinel "*"
+// and nothing else; and the move door itself is the legacy door (its own
+// refusals), where in self-serve mode it is the member's door, which
+// refuses the operator's token with the family's 404.
+func TestSelfServeOperatorDoorsUnchangedOnTheBridge(t *testing.T) {
+ golden := goldenOperatorLines(t)
+ switchOff := func(t *testing.T) *jobWorld {
+ w := newJobWorld(t)
+ w.switchOff = true
+ w.restart()
+ return w
+ }
+ check := func(t *testing.T, key string, code int, out string) {
+ t.Helper()
+ want, ok := golden[key]
+ if !ok {
+ t.Fatalf("the replay has no line %q", key)
+ }
+ if got := operatorLine(code, out); got != want {
+ t.Errorf("%s:\n got %s\nwant %s", key, got, want)
+ }
+ }
+ sentinel := func(t *testing.T, w *jobWorld) {
+ t.Helper()
+ table := w.reservations()
+ if _, ok := table[moves.OfflineSentinel]; !ok || len(table) != 1 {
+ t.Fatalf("the operator's reservation %v, want the sentinel alone", table)
+ }
+ }
+ t.Run("the refusals", func(t *testing.T) {
+ w := switchOff(t)
+ for _, door := range []struct{ name, path string }{{"start", ""}, {"stage", "/stage"}} {
+ for _, b := range []struct{ name, body string }{{"malformed", `{"region":`}, {"current", `{"region":"ie"}`}, {"invalid", `{"region":"UK!"}`}} {
+ code, out := w.door(http.MethodPost, door.path, b.body)
+ check(t, "ok | "+door.name+" "+b.name, code, out)
+ }
+ }
+ if rec := w.record(); rec.Move != nil || len(w.reservations()) != 0 {
+ t.Fatalf("a refusal wrote: %+v, %v", rec.Move, w.reservations())
+ }
+ // The move door: the legacy door's refusals with the switch off.
+ for body, want := range map[string]string{
+ `{"region":"UK!"}`: `400 "invalid region"`,
+ `{"region":"ie"}`: `400 "moves to the home region are not supported yet — the home publishes no staging lane"`,
+ } {
+ if got := operatorLine(w.legacyMoveDoor(body)); got != want {
+ t.Errorf("the move door with %s: %s, want %s", body, got, want)
+ }
+ }
+ // In self-serve mode the move door is the member's: the operator's
+ // token is not a move-capable token, and gets the 404.
+ w.switchOff = false
+ w.restart()
+ for _, body := range []string{`{"region":"UK!"}`, `{"region":"uk"}`} {
+ if code, out := w.legacyMoveDoor(body); code != http.StatusNotFound || out != "404 page not found\n" {
+ t.Errorf("the move door in self-serve mode with %s: %d %q", body, code, out)
+ }
+ }
+ })
+ t.Run("start", func(t *testing.T) {
+ w := switchOff(t)
+ code, out := w.door(http.MethodPost, "", `{"region":"uk"}`)
+ check(t, "ok | start good", code, out)
+ code, out = w.door(http.MethodGet, "", "")
+ check(t, "ok | start good then status", code, out)
+ sentinel(t, w)
+ })
+ t.Run("stage, then the staged move", func(t *testing.T) {
+ w := switchOff(t)
+ code, out := w.door(http.MethodPost, "/stage", `{"region":"uk"}`)
+ check(t, "ok | stage good", code, out)
+ code, out = w.door(http.MethodGet, "", "")
+ check(t, "ok | stage good then status", code, out)
+ sentinel(t, w)
+ code, out = w.door(http.MethodGet, "", "")
+ check(t, "staged | status", code, out)
+ code, out = w.door(http.MethodPost, "/stage", `{"region":"uk"}`)
+ check(t, "staged | restage", code, out)
+ code, out = w.door(http.MethodPost, "", `{"region":"uk"}`)
+ check(t, "staged | start", code, out)
+ sentinel(t, w)
+ })
+}
+
+// operatorRun drives the operator's offline doors from the record's state
+// until the move is cleared: seal once stopping, flip once sealed, open
+// once landed, finish once open and while finishing; the boxes and the
+// console's pass run between the doors, and the source's held config is
+// rendered as config sync would. It fails after max rounds.
+func (w *jobWorld) operatorRun(max int, source string) *provision.Record {
+ w.t.Helper()
+ rendered := false
+ for i := 0; i < max; i++ {
+ rec := w.record()
+ if rec.Move == nil {
+ return rec
+ }
+ w.boxes()
+ if !rendered {
+ ok, err := w.e.Box(source).RenderHeldConfig(w.ctx, rec.Move.ID)
+ if err != nil {
+ w.t.Logf("render: %v", err)
+ }
+ rendered = ok
+ }
+ w.tick()
+ verb := map[string]string{provision.OfflineStopping: "/seal", provision.OfflineSealed: "/flip",
+ provision.OfflineLanded: "/open", provision.OfflineOpen: "/finish", provision.OfflineFinishing: "/finish"}[w.record().Move.State]
+ if verb != "" {
+ if code, out := w.door(http.MethodPost, verb, ""); code != http.StatusAccepted {
+ w.t.Logf("%s: %d %s", verb, code, errorOf(out))
+ }
+ }
+ }
+ w.t.Fatalf("after %d rounds the operator's move is %+v", max, w.record().Move)
+ return nil
+}
+
+// TestSelfServeThenOperatorReturn: the member's self-serve move to uk is
+// done; the operator then returns the instance to ie through the operator
+// doors: the return request (its box report of the leftovers), the stage
+// and the start with return_of (the staged return names the forward), the
+// seal, the flip, the open and both finishes. The record is home with no
+// move and last_move done for the return; nothing stays reserved; and the
+// member's poll reads no move in flight, its last self-serve move still
+// the forward. The handshake's refusals and restarts are
+// TestOperatorReturnHandshake.
+func TestSelfServeThenOperatorReturn(t *testing.T) {
+ w := newMemberWorld(t)
+ build := w.liveBuild(host.E2ESourceBox)
+ fwd := w.cutByDoor()
+ if r := w.done(fwd); r.Outcome != "done" {
+ t.Fatalf("the forward finished %s", describe(r))
+ }
+ w.wakeTarget(build)
+ // The forward leaves the source's timer rows (not among the leftovers
+ // a return accepts): the operator clears them, as the runbook does.
+ w.clearTimers(host.E2ESourceBox)
+ key := w.returnRequest(fwd)
+ w.boxes()
+ if r, ok := w.returnReport(fwd, w.e.Box(host.E2ESourceBox).BoxID(), key); !ok || r.Ready == nil || r.Ready.Leftovers == nil {
+ t.Fatalf("no return report: %v %+v", ok, r)
+ }
+ if code, out := w.door(http.MethodPost, "/stage", `{"region":"ie","return_of":"`+fwd+`"}`); code != http.StatusAccepted {
+ t.Fatalf("the return's stage = %d %s", code, out)
+ }
+ ret := w.record().Move.ID
+ if m := w.record().Move; m.ReturnOf != fwd || m.State != provision.OfflineStaging {
+ t.Fatalf("the staged return %+v", m)
+ }
+ w.until(60, "the return staged", func() bool {
+ st, ok := w.report(ret, moves.OfflineRoleStager, host.E2ETargetBox)
+ return ok && st.Phase == moves.OfflinePhaseStaged
+ })
+ if code, out := w.door(http.MethodPost, "", `{"region":"ie","return_of":"`+fwd+`"}`); code != http.StatusAccepted {
+ t.Fatalf("the return's start = %d %s", code, out)
+ }
+ rec := w.operatorRun(200, host.E2ETargetBox)
+ switch {
+ case rec.Region != "" || rec.LastMove == nil || rec.LastMove.ID != ret || rec.LastMove.Outcome != provision.OfflineOutcomeDone:
+ t.Fatalf("after the return: region %q last %+v", rec.Region, rec.LastMove)
+ case len(w.reservations()) != 0:
+ t.Fatalf("reservations left: %v", w.reservations())
+ }
+ v := w.view()
+ if v.State != "none" || v.ID != "" || v.Last == nil || v.Last.ID != fwd || v.Last.Outcome != "done" {
+ t.Fatalf("the member's poll after the return: %+v %+v", v, v.Last)
+ }
+ if open, err := w.cst.OpenSelfServeMoves(); err != nil || len(open) != 0 {
+ t.Fatalf("self-serve rows open: %v %v", open, err)
+ }
+}
+
+// TestClaimsOffInstanceIDIsPublished: on a claims-off box the agent's
+// status tick publishes the box's id and its cloud instance id in its
+// status block, the instance id read when the tick runs (an instance id
+// the box learns later is published by the next tick, not held at the one
+// the worker was armed with), and the console pins a self-serve move's
+// participants by them.
+func TestClaimsOffInstanceIDIsPublished(t *testing.T) {
+ w := newMemberWorld(t)
+ src, tgt := w.e.Box(host.E2ESourceBox), w.e.Box(host.E2ETargetBox)
+ published := func(label string) (boxID, instanceID string) {
+ t.Helper()
+ _, metrics, _ := loadBoxes(t, w.raw)
+ m := metrics[label]
+ if m == nil {
+ t.Fatalf("no status block for %s", label)
+ }
+ return m.BoxID, m.InstanceID
+ }
+ if os.Getenv("CARLOS_HOST_CLAIM") != "" {
+ t.Fatal("the bridge's boxes are not claims-off")
+ }
+ for _, b := range []struct {
+ label string
+ h interface{ BoxID() string }
+ }{{src.Label(), src}, {tgt.Label(), tgt}} {
+ if id, inst := published(b.label); id == "" || id != b.h.BoxID() || inst != "i-e2e-"+b.label {
+ t.Fatalf("%s publishes box %q instance %q", b.label, id, inst)
+ }
+ }
+ // The source learns a new instance id (a stop and start of its VM):
+ // the next status tick publishes it.
+ src.SetInstanceID("i-e2e-ie-box-2")
+ if err := w.e.Status(w.ctx); err != nil {
+ t.Fatal(err)
+ }
+ if id, inst := published(src.Label()); id != src.BoxID() || inst != "i-e2e-ie-box-2" {
+ t.Fatalf("after the new instance id: box %q instance %q", id, inst)
+ }
+ // The member's move pins both boxes by what they publish.
+ id := w.prepare()
+ r := w.row(id)
+ if len(r.Participants.Source) != 1 || r.Participants.Source[0].BoxID != src.BoxID() || r.Participants.Source[0].InstanceID != "i-e2e-ie-box-2" ||
+ r.Participants.Target.BoxID != tgt.BoxID() || r.Participants.Target.InstanceID != "i-e2e-uk-box" {
+ t.Fatalf("the move pinned %+v", r.Participants)
+ }
+}
+
+// liveBuild is the build live for the instance on box label: the version
+// directory its live link names (<opt>/versions/<build>/<acct>/<app>).
+func (w *jobWorld) liveBuild(label string) string {
+ w.t.Helper()
+ opt := w.e.Box(label).OptDir()
+ target, err := os.Readlink(release.LiveLink(opt, host.E2EHost))
+ if err != nil {
+ w.t.Fatalf("no live build on %s: %v", label, err)
+ }
+ rel, err := filepath.Rel(filepath.Join(opt, "versions"), target)
+ if err != nil {
+ w.t.Fatal(err)
+ }
+ return strings.SplitN(rel, string(filepath.Separator), 2)[0]
+}
+
+// wakeTarget stands in for what the target box does after the open that
+// the bridge does not run: the build adoption puts the moved build live on
+// the box, and the first wake promotes the staged replica (moves/<host>/)
+// over the instance's replica (instances/<host>) in the box's own bucket
+// (the activator's promoteStagedReplica), which the served instance
+// replicates to from then on.
+func (w *jobWorld) wakeTarget(build string) {
+ w.t.Helper()
+ opt := w.e.Box(host.E2ETargetBox).OptDir()
+ versions := filepath.Join(opt, "versions", build, w.acct, host.E2EApp)
+ link := release.LiveLink(opt, host.E2EHost)
+ for _, dir := range []string{versions, filepath.Dir(link)} {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ w.t.Fatal(err)
+ }
+ }
+ if err := os.Symlink(versions, link); err != nil && !os.IsExist(err) {
+ w.t.Fatal(err)
+ }
+ bucket := w.e.Box(host.E2ETargetBox).DataBucket()
+ m, err := lease.NewS3Mirror(w.ctx, "eu-west-2", "eu-west-2")
+ if err != nil {
+ w.t.Fatal(err)
+ }
+ round, err := lease.MirrorPrefix(w.ctx, m, bucket, activator.MovesPrefix(host.E2EHost), bucket, activator.HibernatePrefix("instances", host.E2EHost), lease.Receipts{})
+ if err != nil || round.Deferred > 0 {
+ w.t.Fatalf("the promotion: %+v %v", round, err)
+ }
+}