| 1 | package hostclaim |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "errors" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/release" |
| 11 | ) |
| 12 | |
| 13 | func newHolder() NewHolder { |
| 14 | return NewHolder{Family: "instance", Key: "apps/bab/jam/instances/" + testHost + ".json", |
| 15 | Account: "bab", App: "jam", Host: testHost, Region: "ie", RecordRegion: "ie", |
| 16 | CreatedAt: time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC)} |
| 17 | } |
| 18 | |
| 19 | var record = []byte(`{"schema":1,"host":"shop.bab.oncarlos.com","account":"bab","app":"jam","region":"ie","created_at":"2026-10-04T01:00:00Z"}`) |
| 20 | |
| 21 | func TestCreateLifecycle(t *testing.T) { |
| 22 | ctx := context.Background() |
| 23 | s := release.NewDirStore(t.TempDir()) |
| 24 | now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC) |
| 25 | reg, _ := Read(ctx, s, testHost) |
| 26 | o, err := Update(ctx, s, reg, BeginCreate(newHolder(), record, "op_c1", "console", now)) |
| 27 | if err != nil || o.Claim.Op.Kind != KindCreating || o.Claim.Op.Intent["record"] != string(record) { |
| 28 | t.Fatalf("begin = %+v, %v", o, err) |
| 29 | } |
| 30 | if o.Claim.Holder.Location != "instances/"+testHost+"/"+o.Claim.Holder.Incarnation { |
| 31 | t.Fatalf("location = %q", o.Claim.Holder.Location) |
| 32 | } |
| 33 | o, err = Update(ctx, s, o, CompleteCreate("op_c1", "adi-knitting")) |
| 34 | if err != nil || o.Claim.Op.Kind != KindServing || o.Claim.Op.Exec != "adi-knitting" || o.Claim.Holder.Stamp != o.Claim.Gen { |
| 35 | t.Fatalf("complete = %+v, %v", o.Claim, err) |
| 36 | } |
| 37 | if len(o.Claim.Touched.Boxes) != 1 || o.Claim.Touched.Boxes[0] != "adi-knitting" { |
| 38 | t.Fatalf("executor not recorded as touched: %+v", o.Claim.Touched) |
| 39 | } |
| 40 | if _, err := Update(ctx, s, o, CompleteCreate("op_c1", "adi-knitting")); !errors.Is(err, ErrStaleOp) { |
| 41 | t.Fatalf("replay = %v, want ErrStaleOp", err) |
| 42 | } |
| 43 | if _, err := Update(ctx, s, reg, BeginCreate(newHolder(), record, "op_c2", "console", now)); !errors.Is(err, ErrConflict) { |
| 44 | t.Fatalf("stale create = %v, want ErrConflict", err) |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | func TestBeginCreateRefusesAnotherHostOrKey(t *testing.T) { |
| 49 | ctx := context.Background() |
| 50 | s := release.NewDirStore(t.TempDir()) |
| 51 | now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC) |
| 52 | reg, _ := Read(ctx, s, testHost) |
| 53 | other := newHolder() |
| 54 | other.Host = "other.bab.oncarlos.com" |
| 55 | if _, err := Update(ctx, s, reg, BeginCreate(other, record, "op_c1", "console", now)); !errors.Is(err, ErrWrongState) { |
| 56 | t.Fatalf("another host's create on this claim = %v", err) |
| 57 | } |
| 58 | badKey := newHolder() |
| 59 | badKey.Key = "apps/zzz/jam/instances/" + testHost + ".json" |
| 60 | if _, err := Update(ctx, s, reg, BeginCreate(badKey, record, "op_c1", "console", now)); !errors.Is(err, ErrWrongState) { |
| 61 | t.Fatalf("mismatched record key = %v", err) |
| 62 | } |
| 63 | other2 := []byte(`{"schema":1,"host":"shop.bab.oncarlos.com","account":"bab","app":"jam","region":"ie","created_at":"2026-10-04T09:00:00Z"}`) |
| 64 | if _, err := Update(ctx, s, reg, BeginCreate(newHolder(), other2, "op_c1", "console", now)); !errors.Is(err, ErrWrongState) { |
| 65 | t.Fatalf("record body with another CreatedAt = %v", err) |
| 66 | } |
| 67 | if _, err := Update(ctx, s, reg, BeginCreate(newHolder(), []byte("not json"), "op_c1", "console", now)); !errors.Is(err, ErrWrongState) { |
| 68 | t.Fatalf("record body not JSON = %v", err) |
| 69 | } |
| 70 | wrongRegion := []byte(`{"schema":1,"host":"shop.bab.oncarlos.com","account":"bab","app":"jam","region":"uk","created_at":"2026-10-04T01:00:00Z"}`) |
| 71 | if _, err := Update(ctx, s, reg, BeginCreate(newHolder(), wrongRegion, "op_c1", "console", now)); !errors.Is(err, ErrWrongState) { |
| 72 | t.Fatalf("record body in another region = %v", err) |
| 73 | } |
| 74 | // A sidecar key cannot claim an instance-shaped host (P1, plan review round 2). |
| 75 | sc := NewHolder{Family: "sidecar", Key: "apps/bab/jam/sidecars/shop.json", Account: "bab", App: "jam", Name: "shop", |
| 76 | Host: testHost, Region: "ie", RecordRegion: "ie", CreatedAt: newHolder().CreatedAt} |
| 77 | if _, err := Update(ctx, s, reg, BeginCreate(sc, record, "op_c1", "console", now)); !errors.Is(err, ErrWrongState) { |
| 78 | t.Fatalf("sidecar key on a non-sidecar host = %v", err) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | func TestBeginCreateSidecar(t *testing.T) { |
| 83 | ctx := context.Background() |
| 84 | s := release.NewDirStore(t.TempDir()) |
| 85 | now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC) |
| 86 | host := "bot.jam.bab.sidecar.internal" |
| 87 | sc := NewHolder{Family: "sidecar", Key: "apps/bab/jam/sidecars/bot.json", Account: "bab", App: "jam", Name: "bot", |
| 88 | Host: host, Region: "ie", RecordRegion: "ie", CreatedAt: time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC)} |
| 89 | body := []byte(`{"name":"bot","host":"` + host + `","account":"bab","app":"jam","region":"ie","created_at":"2026-10-04T01:00:00Z"}`) |
| 90 | reg, _ := Read(ctx, s, host) |
| 91 | if _, err := Update(ctx, s, reg, BeginCreate(sc, body, "op_s1", "console", now)); err != nil { |
| 92 | t.Fatalf("sidecar create = %v", err) |
| 93 | } |
| 94 | s2 := release.NewDirStore(t.TempDir()) |
| 95 | reg2, _ := Read(ctx, s2, host) |
| 96 | wrongName := []byte(`{"name":"other","host":"` + host + `","account":"bab","app":"jam","region":"ie","created_at":"2026-10-04T01:00:00Z"}`) |
| 97 | if _, err := Update(ctx, s2, reg2, BeginCreate(sc, wrongName, "op_s1", "console", now)); !errors.Is(err, ErrWrongState) { |
| 98 | t.Fatalf("sidecar body with another name = %v", err) |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | func TestCancelRetiresAndRefusesReuse(t *testing.T) { |
| 103 | ctx := context.Background() |
| 104 | s := release.NewDirStore(t.TempDir()) |
| 105 | now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC) |
| 106 | reg, _ := Read(ctx, s, testHost) |
| 107 | o, _ := Update(ctx, s, reg, BeginCreate(newHolder(), record, "op_c1", "console", now)) |
| 108 | inc := o.Claim.Holder.Incarnation |
| 109 | o, err := Update(ctx, s, o, CancelCreate("op_c1", now)) |
| 110 | if err != nil || !o.Claim.Free() || len(o.Claim.CancelledCreates) != 1 || o.Claim.RetiredIncarnations[0] != inc { |
| 111 | t.Fatalf("cancel = %+v, %v", o.Claim, err) |
| 112 | } |
| 113 | if _, err := Update(ctx, s, o, BeginCreate(newHolder(), record, "op_c3", "console", now)); !errors.Is(err, ErrRetiredIncarnation) { |
| 114 | t.Fatalf("reuse = %v, want ErrRetiredIncarnation", err) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | func TestCancelCreateRefusesExecutionAuthority(t *testing.T) { |
| 119 | for _, tc := range []struct { |
| 120 | name string |
| 121 | exec string |
| 122 | participants []string |
| 123 | stamp uint64 |
| 124 | }{ |
| 125 | {name: "executor", exec: "adi-knitting"}, |
| 126 | {name: "participants", participants: []string{"adi-knitting"}}, |
| 127 | {name: "stamp", stamp: 1}, |
| 128 | {name: "all", exec: "adi-knitting", participants: []string{"adi-knitting"}, stamp: 1}, |
| 129 | } { |
| 130 | t.Run(tc.name, func(t *testing.T) { |
| 131 | ctx := context.Background() |
| 132 | s := release.NewDirStore(t.TempDir()) |
| 133 | now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC) |
| 134 | reg, err := Read(ctx, s, testHost) |
| 135 | if err != nil { |
| 136 | t.Fatal(err) |
| 137 | } |
| 138 | o, err := Update(ctx, s, reg, func(c *Claim) error { |
| 139 | if err := BeginCreate(newHolder(), record, "op_c1", "console", now)(c); err != nil { |
| 140 | return err |
| 141 | } |
| 142 | c.Op.Exec = tc.exec |
| 143 | c.Op.Participants = tc.participants |
| 144 | c.Holder.Stamp = tc.stamp |
| 145 | return nil |
| 146 | }) |
| 147 | if err != nil { |
| 148 | t.Fatalf("store creating claim with authority: %v", err) |
| 149 | } |
| 150 | key, err := Key(testHost) |
| 151 | if err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | before, beforeETag, err := s.Get(ctx, key) |
| 155 | if err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | if _, err := Update(ctx, s, o, CancelCreate("op_c1", now)); !errors.Is(err, ErrWrongState) { |
| 159 | t.Errorf("cancel with authority = %v, want ErrWrongState", err) |
| 160 | } |
| 161 | after, afterETag, err := s.Get(ctx, key) |
| 162 | if err != nil { |
| 163 | t.Fatal(err) |
| 164 | } |
| 165 | if !bytes.Equal(before, after) || beforeETag != afterETag { |
| 166 | t.Errorf("cancel changed stored claim: before ETag %q, after ETag %q", beforeETag, afterETag) |
| 167 | } |
| 168 | }) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | func TestCreateHomeRegionParksEffectiveRegion(t *testing.T) { |
| 173 | ctx := context.Background() |
| 174 | s := release.NewDirStore(t.TempDir()) |
| 175 | nh := newHolder() |
| 176 | nh.RecordRegion = "" |
| 177 | body := bytes.Replace(record, []byte(`"region":"ie"`), []byte(`"region":""`), 1) |
| 178 | obs, err := Read(ctx, s, testHost) |
| 179 | if err != nil { |
| 180 | t.Fatal(err) |
| 181 | } |
| 182 | o, err := Update(ctx, s, obs, BeginCreate(nh, body, "op_c", "console", time.Now())) |
| 183 | if err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | if len(o.Claim.Touched.Regions) != 1 || o.Claim.Touched.Regions[0] != "ie" || o.Claim.Op.Intent["record"] != string(body) { |
| 187 | t.Fatalf("home-region create = %+v", o.Claim) |
| 188 | } |
| 189 | o, err = Update(ctx, s, o, CompleteCreate("op_c", "B0")) |
| 190 | if err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), nil, nil, "")) |
| 194 | if err != nil { |
| 195 | t.Fatal(err) |
| 196 | } |
| 197 | o, err = Update(ctx, s, o, ToParking("op_d", nil, nil, counter())) |
| 198 | if err != nil { |
| 199 | t.Fatal(err) |
| 200 | } |
| 201 | assertParkingTargets(t, o.Claim.Obligations, []Obligation{ |
| 202 | {ParkTarget: "instances/" + testHost + "/" + o.Claim.Holder.Incarnation, Op: "op_d", Kind: "park-replica", Region: "ie", Owner: "region:ie"}, |
| 203 | {Op: "op_d", Kind: "park-local", Box: "B0", Owner: "box:B0"}, |
| 204 | }) |
| 205 | } |
| 206 | |
| 207 | func TestBeginCreateRefusesEmptyEffectiveRegion(t *testing.T) { |
| 208 | ctx := context.Background() |
| 209 | s := release.NewDirStore(t.TempDir()) |
| 210 | nh := newHolder() |
| 211 | nh.Region, nh.RecordRegion = "", "" |
| 212 | body := bytes.Replace(record, []byte(`"region":"ie"`), []byte(`"region":""`), 1) |
| 213 | obs, err := Read(ctx, s, testHost) |
| 214 | if err != nil { |
| 215 | t.Fatal(err) |
| 216 | } |
| 217 | if _, err := Update(ctx, s, obs, BeginCreate(nh, body, "op_c", "console", time.Now())); !errors.Is(err, ErrWrongState) { |
| 218 | t.Errorf("empty effective region = %v, want ErrWrongState", err) |
| 219 | } |
| 220 | after, err := Read(ctx, s, testHost) |
| 221 | if err != nil || !after.Absent { |
| 222 | t.Fatalf("read after refused create = %+v, %v; want absent", after, err) |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | func TestCompleteCreateRefusesExecutionAuthority(t *testing.T) { |
| 227 | for _, tc := range []struct { |
| 228 | name string |
| 229 | exec string |
| 230 | participants []string |
| 231 | stamp uint64 |
| 232 | }{ |
| 233 | {name: "executor", exec: "adi-knitting"}, |
| 234 | {name: "participants", participants: []string{"adi-knitting"}}, |
| 235 | {name: "stamp", stamp: 1}, |
| 236 | {name: "all", exec: "adi-knitting", participants: []string{"adi-knitting"}, stamp: 1}, |
| 237 | } { |
| 238 | t.Run(tc.name, func(t *testing.T) { |
| 239 | ctx := context.Background() |
| 240 | s := release.NewDirStore(t.TempDir()) |
| 241 | now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC) |
| 242 | reg, err := Read(ctx, s, testHost) |
| 243 | if err != nil { |
| 244 | t.Fatal(err) |
| 245 | } |
| 246 | o, err := Update(ctx, s, reg, func(c *Claim) error { |
| 247 | if err := BeginCreate(newHolder(), record, "op_c1", "console", now)(c); err != nil { |
| 248 | return err |
| 249 | } |
| 250 | c.Op.Exec = tc.exec |
| 251 | c.Op.Participants = tc.participants |
| 252 | c.Holder.Stamp = tc.stamp |
| 253 | return nil |
| 254 | }) |
| 255 | if err != nil { |
| 256 | t.Fatalf("store creating claim with authority: %v", err) |
| 257 | } |
| 258 | key, err := Key(testHost) |
| 259 | if err != nil { |
| 260 | t.Fatal(err) |
| 261 | } |
| 262 | before, beforeETag, err := s.Get(ctx, key) |
| 263 | if err != nil { |
| 264 | t.Fatal(err) |
| 265 | } |
| 266 | if _, err := Update(ctx, s, o, CompleteCreate("op_c1", "new-executor")); !errors.Is(err, ErrWrongState) { |
| 267 | t.Errorf("complete with authority = %v, want ErrWrongState", err) |
| 268 | } |
| 269 | after, afterETag, err := s.Get(ctx, key) |
| 270 | if err != nil { |
| 271 | t.Fatal(err) |
| 272 | } |
| 273 | if !bytes.Equal(before, after) || beforeETag != afterETag { |
| 274 | t.Errorf("complete changed stored claim: before ETag %q, after ETag %q", beforeETag, afterETag) |
| 275 | } |
| 276 | }) |
| 277 | } |
| 278 | } |
| 279 | |
| 280 | func TestCreateCompletesOnlyOnTheSelectedExecutor(t *testing.T) { |
| 281 | ctx := context.Background() |
| 282 | s := release.NewDirStore(t.TempDir()) |
| 283 | now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC) |
| 284 | reg, _ := Read(ctx, s, testHost) |
| 285 | sel := ExecutorSelection{Box: "bri-knitting", Enrollment: "enrollment-b"} |
| 286 | if _, err := Update(ctx, s, reg, BeginCreateOn(newHolder(), record, "op_c1", "console", now, ExecutorSelection{})); !errors.Is(err, ErrMalformed) { |
| 287 | t.Fatalf("create with no selection: %v", err) |
| 288 | } |
| 289 | o, err := Update(ctx, s, reg, BeginCreateOn(newHolder(), record, "op_c1", "console", now, sel)) |
| 290 | if err != nil { |
| 291 | t.Fatal(err) |
| 292 | } |
| 293 | if o.Claim.Op.Intent["selected_executor"] != sel.Box || o.Claim.Op.Intent["executor_enrollment"] != sel.Enrollment || o.Claim.Op.Exec != "" { |
| 294 | t.Fatalf("intent: %+v", o.Claim.Op) |
| 295 | } |
| 296 | if _, err := Update(ctx, s, o, CompleteCreate("op_c1", "adi-knitting")); !errors.Is(err, ErrWrongState) { |
| 297 | t.Fatalf("completion substituted a box: %v", err) |
| 298 | } |
| 299 | if o, err = Update(ctx, s, o, CompleteCreate("op_c1", sel.Box)); err != nil || o.Claim.Op.Exec != sel.Box { |
| 300 | t.Fatalf("complete: %v %+v", err, o.Claim.Op) |
| 301 | } |
| 302 | } |
| 303 | |