| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "net/url" |
| 8 | "strings" |
| 9 | "sync/atomic" |
| 10 | "testing" |
| 11 | "time" |
| 12 | ) |
| 13 | |
| 14 | // instanceApp is a fake app serving the /.carlos/instances contract. Its |
| 15 | // httptest host doubles as the route host (the fetcher is pointed at scheme |
| 16 | // "http" in tests). |
| 17 | type instanceApp struct { |
| 18 | srv *httptest.Server |
| 19 | hits atomic.Int64 |
| 20 | body string |
| 21 | code int |
| 22 | wait time.Duration |
| 23 | } |
| 24 | |
| 25 | func newInstanceApp(t *testing.T, body string) *instanceApp { |
| 26 | t.Helper() |
| 27 | a := &instanceApp{body: body, code: http.StatusOK} |
| 28 | a.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 29 | if r.URL.Path != InstancesPath { |
| 30 | http.NotFound(w, r) |
| 31 | return |
| 32 | } |
| 33 | a.hits.Add(1) |
| 34 | if a.wait > 0 { |
| 35 | time.Sleep(a.wait) |
| 36 | } |
| 37 | w.WriteHeader(a.code) |
| 38 | w.Write([]byte(a.body)) |
| 39 | })) |
| 40 | t.Cleanup(a.srv.Close) |
| 41 | return a |
| 42 | } |
| 43 | |
| 44 | func (a *instanceApp) host(t *testing.T) string { |
| 45 | t.Helper() |
| 46 | u, err := url.Parse(a.srv.URL) |
| 47 | if err != nil { |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | return u.Host |
| 51 | } |
| 52 | |
| 53 | const kassReport = `[ |
| 54 | {"slug":"riders-2026","status":"ready","region":"eu-west-1"}, |
| 55 | {"slug":"spring-camp","status":"ready","region":"eu-west-1"}, |
| 56 | {"slug":"trial-run","status":"archived","detail":"read-only since June"} |
| 57 | ]` |
| 58 | |
| 59 | func readyRoute(host string) RouteView { |
| 60 | return RouteView{Host: host, Kind: "instance", Status: "ready"} |
| 61 | } |
| 62 | |
| 63 | func TestInstancesFetchAndCache(t *testing.T) { |
| 64 | app := newInstanceApp(t, kassReport) |
| 65 | f := newInstanceFetcher(nil, "http") |
| 66 | rep := f.Report(context.Background(), []RouteView{readyRoute(app.host(t))}) |
| 67 | if rep == nil || !rep.Reporting { |
| 68 | t.Fatalf("report = %+v, want reporting", rep) |
| 69 | } |
| 70 | if len(rep.Instances) != 3 { |
| 71 | t.Fatalf("instances = %+v, want 3", rep.Instances) |
| 72 | } |
| 73 | if in := rep.Instances[0]; in.Slug != "riders-2026" || in.Status != "ready" || in.Region != "eu-west-1" { |
| 74 | t.Fatalf("first instance = %+v", in) |
| 75 | } |
| 76 | if in := rep.Instances[2]; in.Detail != "read-only since June" { |
| 77 | t.Fatalf("third instance = %+v", in) |
| 78 | } |
| 79 | // A second report within the TTL is served from cache — one hit total. |
| 80 | f.Report(context.Background(), []RouteView{readyRoute(app.host(t))}) |
| 81 | if n := app.hits.Load(); n != 1 { |
| 82 | t.Fatalf("app got %d hits, want 1 (cached)", n) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | func TestInstancesNotReporting(t *testing.T) { |
| 87 | for name, app := range map[string]*instanceApp{ |
| 88 | "404": func() *instanceApp { a := newInstanceApp(t, "nope"); a.code = 404; return a }(), |
| 89 | "junk": newInstanceApp(t, "not json"), |
| 90 | "nonarray": newInstanceApp(t, `{"slug":"x"}`), |
| 91 | "oversized": newInstanceApp(t, `[{"slug":"`+strings.Repeat("x", instancesMaxBody)+`"}]`), |
| 92 | } { |
| 93 | f := newInstanceFetcher(nil, "http") |
| 94 | rep := f.Report(context.Background(), []RouteView{readyRoute(app.host(t))}) |
| 95 | if rep == nil || rep.Reporting { |
| 96 | t.Fatalf("%s: report = %+v, want polled but not reporting", name, rep) |
| 97 | } |
| 98 | } |
| 99 | // A host that isn't even listening: same graceful silence. |
| 100 | f := newInstanceFetcher(nil, "http") |
| 101 | rep := f.Report(context.Background(), []RouteView{readyRoute("127.0.0.1:1")}) |
| 102 | if rep == nil || rep.Reporting { |
| 103 | t.Fatalf("dead host: report = %+v, want not reporting", rep) |
| 104 | } |
| 105 | // And the failure is cached too — no re-poll inside the TTL. |
| 106 | app := newInstanceApp(t, "junk") |
| 107 | f2 := newInstanceFetcher(nil, "http") |
| 108 | f2.Report(context.Background(), []RouteView{readyRoute(app.host(t))}) |
| 109 | f2.Report(context.Background(), []RouteView{readyRoute(app.host(t))}) |
| 110 | if n := app.hits.Load(); n != 1 { |
| 111 | t.Fatalf("failing app got %d hits, want 1 (failure cached)", n) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | func TestInstancesSlugRequired(t *testing.T) { |
| 116 | app := newInstanceApp(t, `[{"slug":"good"},{"status":"ready"},{"slug":" "}]`) |
| 117 | f := newInstanceFetcher(nil, "http") |
| 118 | rep := f.Report(context.Background(), []RouteView{readyRoute(app.host(t))}) |
| 119 | if !rep.Reporting || len(rep.Instances) != 1 || rep.Instances[0].Slug != "good" { |
| 120 | t.Fatalf("report = %+v, want only the slugged row", rep) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | func TestInstancesTimeoutDoesNotHang(t *testing.T) { |
| 125 | app := newInstanceApp(t, kassReport) |
| 126 | app.wait = 500 * time.Millisecond |
| 127 | f := newInstanceFetcher(nil, "http") |
| 128 | f.timeout = 50 * time.Millisecond |
| 129 | start := time.Now() |
| 130 | rep := f.Report(context.Background(), []RouteView{readyRoute(app.host(t))}) |
| 131 | if rep == nil || rep.Reporting { |
| 132 | t.Fatalf("slow app report = %+v, want not reporting", rep) |
| 133 | } |
| 134 | if e := time.Since(start); e > 300*time.Millisecond { |
| 135 | t.Fatalf("report took %v — the page hung on a slow app", e) |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | func TestInstancesNeverPollsHibernatableOrStatic(t *testing.T) { |
| 140 | app := newInstanceApp(t, kassReport) |
| 141 | f := newInstanceFetcher(nil, "http") |
| 142 | routes := []RouteView{ |
| 143 | {Host: app.host(t), Kind: "instance", Status: "ready", Hibernate: true}, |
| 144 | {Host: app.host(t), Kind: "static", Status: "ready"}, |
| 145 | {Host: app.host(t), Kind: "instance", Status: "pending"}, |
| 146 | } |
| 147 | if rep := f.Report(context.Background(), routes); rep != nil { |
| 148 | t.Fatalf("report = %+v, want nil (nothing pollable)", rep) |
| 149 | } |
| 150 | if n := app.hits.Load(); n != 0 { |
| 151 | t.Fatalf("app got %d hits — a hibernatable/static/unready host was polled", n) |
| 152 | } |
| 153 | // The first pollable route wins; later routes are left alone. |
| 154 | other := newInstanceApp(t, `[]`) |
| 155 | routes = append(routes, readyRoute(app.host(t)), readyRoute(other.host(t))) |
| 156 | rep := f.Report(context.Background(), routes) |
| 157 | if rep == nil || rep.Host != app.host(t) { |
| 158 | t.Fatalf("report = %+v, want the first ready host", rep) |
| 159 | } |
| 160 | if n := other.hits.Load(); n != 0 { |
| 161 | t.Fatalf("second host got %d hits, want 0", n) |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | // TestInstancesNeverPollsSidecar: a sidecar row shares its app's routes |
| 166 | // slice (Assemble groups by app NAME, no kind filter) but its Host is a |
| 167 | // derived .sidecar.internal label nothing can dial — polling it wastes the |
| 168 | // timeout and, worse, makes a HEALTHY app read "not reporting" because the |
| 169 | // one route the picker chose was never routable to begin with. A ready, |
| 170 | // non-hibernating, non-static sidecar must still be skipped in favor of the |
| 171 | // next real instance route. |
| 172 | func TestInstancesNeverPollsSidecar(t *testing.T) { |
| 173 | sidecar := newInstanceApp(t, kassReport) |
| 174 | real := newInstanceApp(t, kassReport) |
| 175 | f := newInstanceFetcher(nil, "http") |
| 176 | routes := []RouteView{ |
| 177 | {Host: sidecar.host(t), Kind: "sidecar", Status: "ready"}, |
| 178 | readyRoute(real.host(t)), |
| 179 | } |
| 180 | rep := f.Report(context.Background(), routes) |
| 181 | if rep == nil || rep.Host != real.host(t) { |
| 182 | t.Fatalf("report = %+v, want the real instance host — the sidecar is unroutable", rep) |
| 183 | } |
| 184 | if n := sidecar.hits.Load(); n != 0 { |
| 185 | t.Fatalf("sidecar got %d hits — it must never be polled", n) |
| 186 | } |
| 187 | |
| 188 | // A sidecar-only route set: nothing pollable, same as hibernating/static. |
| 189 | f2 := newInstanceFetcher(nil, "http") |
| 190 | if rep := f2.Report(context.Background(), []RouteView{{Host: sidecar.host(t), Kind: "sidecar", Status: "ready"}}); rep != nil { |
| 191 | t.Fatalf("report = %+v, want nil — a sidecar-only app has nothing pollable", rep) |
| 192 | } |
| 193 | } |
| 194 | |