| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "encoding/json" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/hostclaim" |
| 11 | "github.com/carlosframework/platform/internal/hostops" |
| 12 | "github.com/carlosframework/platform/internal/provision" |
| 13 | "github.com/carlosframework/platform/internal/registry" |
| 14 | "github.com/carlosframework/platform/internal/release" |
| 15 | ) |
| 16 | |
| 17 | func TestSidecarWakeOldIdentityCannotWakeReplacement(t *testing.T) { |
| 18 | t.Setenv("CARLOS_HOST_CLAIM", "") |
| 19 | ctx := context.Background() |
| 20 | s := release.NewDirStore(t.TempDir()) |
| 21 | host := provision.SidecarHost("bot", "app", "acct") |
| 22 | at := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC) |
| 23 | inc, err := hostclaim.Incarnation("acct", "app", host, at) |
| 24 | if err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | _, err = hostclaim.Update(ctx, s, hostclaim.Observed{Host: host, Absent: true}, func(c *hostclaim.Claim) error { |
| 28 | c.Holder = &hostclaim.Holder{Family: "sidecar", Key: provision.SidecarRecordKey("acct", "app", "bot"), Account: "acct", App: "app", Name: "bot", CreatedAt: at, Incarnation: inc, Location: "instances/" + host + "/" + inc, Stamp: 1, Session: 1, WriterBox: "box"} |
| 29 | c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"} |
| 30 | c.Touched.Complete = true |
| 31 | return nil |
| 32 | }) |
| 33 | if err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | owner := hostops.RegistrationOwner{Console: "console", Enrollment: "enrollment", Controller: "request"} |
| 37 | reg := hostops.Registration{Kind: "wake", Host: host, Owner: owner, AppID: "new-app", Epoch: 1, Incarnation: inc, CreatedAt: at} |
| 38 | key := release.SidecarWakeKey("acct", "app", "bot") |
| 39 | effect := hostops.Effect{ID: "new-request", OperationID: "new-request", AppID: "new-app", Resource: "control-object:" + key, Action: "wake", Owner: owner, State: "complete", ResultReference: "new-request", Inputs: map[string]string{"key": key, "incarnation": inc, "request_id": "new-request", "admission_epoch": "1", "created_at": at.Format(time.RFC3339Nano)}} |
| 40 | a := hostops.AppAdmission{V: 1, AppID: "new-app", Revision: 1, Epoch: 1, State: "open", Registrations: map[string]hostops.Registration{"new-request": reg}, Effects: map[string]hostops.Effect{"new-request": effect}} |
| 41 | raw, _ := json.Marshal(a) |
| 42 | if _, err = s.Put(ctx, "apps/acct/app/admission.json", raw, ""); err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | rt := registry.Route{Host: host, Kind: "sidecar", Slug: "bot", App: "app", Account: "acct"} |
| 46 | mem := newSidecarWakeMemory(true) |
| 47 | sidecarWakeChanges(ctx, nil, s, mem) |
| 48 | if err = release.TouchSidecarWake(ctx, s, "acct", "app", "bot", "operator", "old", release.SidecarWakeIdentity{AppID: "old-app", Incarnation: inc, RequestID: "old-request"}); err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | moved, _ := sidecarWakeChanges(ctx, []registry.Route{rt}, s, mem) |
| 52 | if len(moved) != 0 { |
| 53 | t.Fatal("delayed old app request woke replacement") |
| 54 | } |
| 55 | if err = release.TouchSidecarWake(ctx, s, "acct", "app", "bot", "operator", "new", release.SidecarWakeIdentity{AppID: "new-app", Incarnation: inc, RequestID: "new-request"}); err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | moved, errs := sidecarWakeChanges(ctx, []registry.Route{rt}, s, mem) |
| 59 | if len(errs) != 0 || len(moved) != 1 { |
| 60 | t.Fatalf("new request: moved=%d errors=%v", len(moved), errs) |
| 61 | } |
| 62 | captured := mem.identities[host] |
| 63 | pub, _, err := ed25519.GenerateKey(nil) |
| 64 | if err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | w, err := hostops.NewControlWriter(s, pub, time.Now) |
| 68 | if err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | // Registration cleanup is the writer's settle, not a raw admission edit. |
| 72 | if err = w.SettleRegistration(ctx, "acct", "app", "new-app", "new-request", time.Now()); err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | if cleaned, err := hostops.ReadAdmission(ctx, s, "acct", "app", "new-app"); err != nil || len(cleaned.Value.Registrations) != 0 { |
| 76 | t.Fatalf("registration not settled: %v %+v", err, cleaned.Value.Registrations) |
| 77 | } |
| 78 | delayed := newSidecarWakeMemory(true) |
| 79 | sidecarWakeChanges(ctx, nil, s, delayed) |
| 80 | moved, errs = sidecarWakeChanges(ctx, []registry.Route{rt}, s, delayed) |
| 81 | if len(errs) != 0 || len(moved) != 1 { |
| 82 | t.Fatalf("completed wake lost after registration cleanup: moved=%d errors=%v", len(moved), errs) |
| 83 | } |
| 84 | obs, err := hostclaim.Read(ctx, s, host) |
| 85 | if err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | _, err = hostclaim.Update(ctx, s, obs, func(c *hostclaim.Claim) error { |
| 89 | c.Holder.CreatedAt = c.Holder.CreatedAt.Add(time.Hour) |
| 90 | c.Holder.Incarnation, _ = hostclaim.Incarnation(c.Holder.Account, c.Holder.App, c.Host, c.Holder.CreatedAt) |
| 91 | c.Holder.Location = "instances/" + host + "/" + c.Holder.Incarnation |
| 92 | return nil |
| 93 | }) |
| 94 | if err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | if err = verifySidecarWake(ctx, s, rt, captured); err == nil { |
| 98 | t.Fatal("queued request survived replacement before dispatch") |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | func TestSidecarWakeFlagOffIgnoresEnvironment(t *testing.T) { |
| 103 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 104 | ctx := t.Context() |
| 105 | s := release.NewDirStore(t.TempDir()) |
| 106 | mem := newSidecarWakeMemory(false) |
| 107 | sidecarWakeChanges(ctx, nil, s, mem) |
| 108 | rt := registry.Route{Host: provision.SidecarHost("bot", "app", "acct"), Kind: "sidecar", Slug: "bot", App: "app", Account: "acct"} |
| 109 | if err := release.TouchSidecarWake(ctx, s, "acct", "app", "bot", "operator", "wake"); err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | woke := make(chan registry.Route, 1) |
| 113 | moved, errs := sidecarWakeLeg(ctx, []registry.Route{rt}, s, mem, func(r registry.Route) error { woke <- r; return nil }) |
| 114 | if len(moved) != 1 || len(errs) != 0 { |
| 115 | t.Fatalf("flag-off legacy wake: moved=%v errors=%v", moved, errs) |
| 116 | } |
| 117 | select { |
| 118 | case got := <-woke: |
| 119 | if got.Host != rt.Host || got.WakeIncarnation != "" { |
| 120 | t.Fatalf("legacy wake changed: %+v", got) |
| 121 | } |
| 122 | case <-time.After(time.Second): |
| 123 | t.Fatal("legacy dispatch did not wake") |
| 124 | } |
| 125 | } |
| 126 | |