package console import ( "context" "encoding/json" "net/http" "testing" "time" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) // J17 rev2: the typed door binds the fresh observation to the move and record // its operator gate read. A store wrapper runs a replacement between the two // reads, on the real door. // j17RaceStore runs hook once, just before the door's first read of the // host claim (the fresh ObserveConsoleMove, after the gate's record read). type j17RaceStore struct { release.Store key string hook func() } func (s *j17RaceStore) Get(ctx context.Context, key string) ([]byte, string, error) { if key == s.key && s.hook != nil { h := s.hook s.hook = nil h() } return s.Store.Get(ctx, key) } func j17Arm(fx *consoleFixture, hook func()) { fx.srv.cfg.Releases = &j17RaceStore{Store: fx.srv.cfg.Releases, key: "control/hosts/acme.go.tito.io.json", hook: hook} } // j17Replace swaps the claim's op, the record's move and the registration to // a successor move id, as a finished move and a new request would. func j17Replace(t *testing.T, fx *consoleFixture, oldID string) { t.Helper() ctx := context.Background() id := "mv_abcdefghijklmnopqrstuvwxyz" if id == oldID { id = "mv_zyxwvutsrqponmlkjihgfedcba" } obs, err := hostclaim.Read(ctx, fx.rel, "acme.go.tito.io") if err != nil { t.Fatal(err) } if _, err := hostclaim.Update(ctx, fx.rel, obs, func(c *hostclaim.Claim) error { c.Op.ID, c.Op.Intent["admission_id"] = id, id return nil }); err != nil { t.Fatal(err) } j17SetRecord(t, fx, func(r *provision.Record) { r.Move.ID = id }) a, err := hostops.ReadAdmission(ctx, fx.rel, "bab", "titogo", claimMoveAppID) if err != nil { t.Fatal(err) } a.Value.Registrations[id] = a.Value.Registrations[oldID] b, _ := json.Marshal(a.Value) if _, err := fx.rel.Put(ctx, a.Key, b, a.ETag); err != nil { t.Fatal(err) } } func j17RecordBytes(t *testing.T, fx *consoleFixture) string { t.Helper() b, _, err := fx.rel.Get(context.Background(), provision.RecordKey("bab", "titogo", "acme.go.tito.io")) if err != nil { t.Fatal(err) } return string(b) } func j17Serial(t *testing.T, fx *consoleFixture) int64 { t.Helper() d, _, err := release.ReadSerial(context.Background(), fx.rel) if err != nil { t.Fatal(err) } return d.Serial } // j17Raced fires the request with the hook between the gate and the // observation and requires the identity 409 with nothing written. func j17Raced(t *testing.T, fx *consoleFixture, method, url, token, body string, hook func()) { t.Helper() var claim, rec string j17Arm(fx, func() { hook() claim, rec = claimBytes(t, fx, "acme.go.tito.io"), j17RecordBytes(t, fx) }) serial := j17Serial(t, fx) code, _, out := rawReq(t, method, url, token, body) if claim == "" { t.Fatal("the race hook never ran") } if code != http.StatusConflict || errorText(out) != moveChangedSentence { t.Fatalf("raced door = %d %s", code, out) } if claimBytes(t, fx, "acme.go.tito.io") != claim { t.Fatal("the raced door wrote the claim") } if j17RecordBytes(t, fx) != rec { t.Fatal("the raced door mutated the successor record") } if j17Serial(t, fx) != serial { t.Fatal("the raced door bumped the serial") } } func j17RetryReady(t *testing.T, fx *consoleFixture) { t.Helper() ctx := context.Background() obs, err := hostclaim.Read(ctx, fx.rel, "acme.go.tito.io") if err != nil { t.Fatal(err) } rb := "rb_abcdefghijklmnopqrstuvwxyz" if _, err := hostclaim.Update(ctx, fx.rel, obs, func(c *hostclaim.Claim) error { c.Op.Kind, c.Op.Phase = hostclaim.KindRestoring, hostclaim.PhaseProbing c.Op.Intent["rollback"] = rb return nil }); err != nil { t.Fatal(err) } j17SetRecord(t, fx, func(r *provision.Record) { r.Move.State, r.Move.StuckReason = provision.MoveStuck, provision.StuckRecoveryProbeAttempts r.Move.Rollback = &provision.MoveRollback{ID: rb, TargetPhase: provision.RbTargetReleased, SourcePhase: provision.RbSourceProbing, StuckAt: provision.RbSourceProbing} }) } func TestJ17DoorCutRefusesReplacedMove(t *testing.T) { fx, token, moveURL, id := j17StartedMove(t, "code-j17-r2-cut") j17SetRecord(t, fx, j17Ready) j17Raced(t, fx, http.MethodPost, moveURL+"/cut", token, "", func() { j17Replace(t, fx, id) }) } func TestJ17DoorAbortRefusesReplacedMove(t *testing.T) { fx, token, moveURL, id := j17StartedMove(t, "code-j17-r2-abort") j17Raced(t, fx, http.MethodDelete, moveURL, token, "", func() { j17Replace(t, fx, id) }) } func TestJ17DoorRetryRefusesReplacedMove(t *testing.T) { fx, token, moveURL, id := j17StartedMove(t, "code-j17-r2-retry") j17RetryReady(t, fx) j17Raced(t, fx, http.MethodPost, moveURL+"/resolve", token, `{"retry":true}`, func() { j17Replace(t, fx, id) }) } // The same move and incarnation, the record advanced: the fresh observation // carries a newer record_seq than the one the gate read. func TestJ17DoorRefusesAdvancedRecordSeq(t *testing.T) { advance := func(t *testing.T, fx *consoleFixture) func() { return func() { before := j17RecordBytes(t, fx) j17SetRecord(t, fx, func(r *provision.Record) {}) if j17RecordBytes(t, fx) == before { t.Fatal("the record did not advance") } } } t.Run("cut", func(t *testing.T) { fx, token, moveURL, _ := j17StartedMove(t, "code-j17-r2-seq-cut") j17SetRecord(t, fx, j17Ready) j17Raced(t, fx, http.MethodPost, moveURL+"/cut", token, "", advance(t, fx)) }) t.Run("abort", func(t *testing.T) { fx, token, moveURL, _ := j17StartedMove(t, "code-j17-r2-seq-abort") j17Raced(t, fx, http.MethodDelete, moveURL, token, "", advance(t, fx)) }) t.Run("retry", func(t *testing.T) { fx, token, moveURL, _ := j17StartedMove(t, "code-j17-r2-seq-retry") j17RetryReady(t, fx) j17Raced(t, fx, http.MethodPost, moveURL+"/resolve", token, `{"retry":true}`, advance(t, fx)) }) } func TestJ17DoorRefusesReplacedIncarnation(t *testing.T) { for _, action := range []string{"cut", "abort", "retry"} { t.Run(action, func(t *testing.T) { fx, token, url, id := j17StartedMove(t, "code-j17-r2-inc-"+action) method, body := http.MethodDelete, "" switch action { case "cut": j17SetRecord(t, fx, j17Ready) method, url = http.MethodPost, url+"/cut" case "retry": j17RetryReady(t, fx) method, url, body = http.MethodPost, url+"/resolve", `{"retry":true}` } j17Raced(t, fx, method, url, token, body, func() { ctx := context.Background() r, tag, err := provision.GetRecordETag(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io") if err != nil { t.Fatal(err) } r.CreatedAt = r.CreatedAt.Add(time.Second) inc, err := hostclaim.Incarnation(r.Account, r.App, r.Host, r.CreatedAt) if err != nil { t.Fatal(err) } r.Move.Incarnation = inc b, _ := json.Marshal(r) if _, err := fx.rel.Put(ctx, provision.RecordKey(r.Account, r.App, r.Host), b, tag); err != nil { t.Fatal(err) } o, err := hostclaim.Read(ctx, fx.rel, r.Host) if err != nil { t.Fatal(err) } if _, err := hostclaim.Update(ctx, fx.rel, o, func(c *hostclaim.Claim) error { c.Holder.Incarnation = inc c.Holder.CreatedAt = r.CreatedAt return nil }); err != nil { t.Fatal(err) } a, err := hostops.ReadAdmission(ctx, fx.rel, "bab", "titogo", claimMoveAppID) if err != nil { t.Fatal(err) } reg := a.Value.Registrations[id] reg.Incarnation = inc a.Value.Registrations[id] = reg b, _ = json.Marshal(a.Value) if _, err := fx.rel.Put(ctx, a.Key, b, a.ETag); err != nil { t.Fatal(err) } }) }) } }