| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/activator" |
| 13 | "github.com/carlosframework/platform/internal/provision" |
| 14 | "github.com/carlosframework/platform/internal/registry" |
| 15 | ) |
| 16 | |
| 17 | // A tick's outcome is read from the handler's own answer, so the mapping from |
| 18 | // "what the activator wrote" to "what the policy sees" is the whole contract |
| 19 | // of this leg: an app's 503 must retry as an app 503, while the activator's |
| 20 | // own wake refusal must retry as a wake failure — same retry, different |
| 21 | // member-facing status. |
| 22 | func TestDeliverViaHandlerMapsWakeRefusals(t *testing.T) { |
| 23 | cases := []struct { |
| 24 | status int |
| 25 | body string |
| 26 | want int |
| 27 | wantErr string |
| 28 | }{ |
| 29 | {204, "", 204, ""}, |
| 30 | {503, "instance failed to wake\n", 0, "instance failed to wake"}, |
| 31 | {503, "instance is waking; retry shortly\n", 0, "instance is waking; retry shortly"}, |
| 32 | {503, "instance is waking elsewhere; retry shortly\n", 0, "instance is waking elsewhere"}, |
| 33 | {503, "app says no", 503, ""}, |
| 34 | {500, "instance blew up", 500, ""}, // only a 503 is a wake refusal |
| 35 | } |
| 36 | for _, c := range cases { |
| 37 | h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 38 | w.WriteHeader(c.status) |
| 39 | w.Write([]byte(c.body)) |
| 40 | }) |
| 41 | req := httptest.NewRequest("POST", "http://a.bab.oncarlos.com/jobs/x", nil) |
| 42 | got, err := deliverVia(context.Background(), h, registry.Route{Host: "a.bab.oncarlos.com"}, req) |
| 43 | if got != c.want || (err == nil) != (c.wantErr == "") || (err != nil && !strings.Contains(err.Error(), c.wantErr)) { |
| 44 | t.Errorf("%d %q → %d %v; want %d %q", c.status, c.body, got, err, c.want, c.wantErr) |
| 45 | } |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | // A handler that writes a body without WriteHeader has answered 200 — the |
| 50 | // recorder must say so, or every well-behaved app that just writes its |
| 51 | // response would be recorded as "0 (app refused)". |
| 52 | func TestDeliverViaImplicit200(t *testing.T) { |
| 53 | h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 54 | w.Header().Set("Content-Type", "text/plain") |
| 55 | w.Write([]byte("ok")) |
| 56 | }) |
| 57 | req := httptest.NewRequest("POST", "http://a.bab.oncarlos.com/jobs/x", nil) |
| 58 | got, err := deliverVia(context.Background(), h, registry.Route{Host: "a.bab.oncarlos.com"}, req) |
| 59 | if got != 200 || err != nil { |
| 60 | t.Fatalf("implicit 200 → %d %v", got, err) |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | // A tick that ran past its deadline is the context's error, not the 502 the |
| 65 | // proxy writes on the way out: the runner maps DeadlineExceeded to ErrTimeout, |
| 66 | // which is the one outcome that does NOT retry (the job may still be running). |
| 67 | func TestDeliverViaReportsContextError(t *testing.T) { |
| 68 | ctx, cancel := context.WithCancel(context.Background()) |
| 69 | h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 70 | cancel() // the handler runs long; the deadline fires under it |
| 71 | w.WriteHeader(http.StatusBadGateway) |
| 72 | }) |
| 73 | req := httptest.NewRequest("POST", "http://a.bab.oncarlos.com/jobs/x", nil) |
| 74 | got, err := deliverVia(ctx, h, registry.Route{Host: "a.bab.oncarlos.com"}, req) |
| 75 | if got != 0 || err != context.Canceled { |
| 76 | t.Fatalf("cancelled ctx → %d %v; want 0 context.Canceled", got, err) |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | // A tick has nowhere to go on an addr-backed route: the delivery leg dials the |
| 81 | // instance's own unix socket on purpose (spec §6.3 — never the public edge), so |
| 82 | // a route with no socket is an error, not a dial to an empty path. |
| 83 | func TestActivatorDelivererRefusesSocketlessRoute(t *testing.T) { |
| 84 | req := httptest.NewRequest("POST", "http://a.bab.oncarlos.com/jobs/x", nil) |
| 85 | got, err := (&activatorDeliverer{}).Deliver(context.Background(), registry.Route{Host: "a.bab.oncarlos.com"}, req) |
| 86 | if got != 0 || err == nil || !strings.Contains(err.Error(), "no socket") { |
| 87 | t.Fatalf("socketless route → %d %v; want 0 and a no-socket error", got, err) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | // The recorder is a ResponseWriter the activator writes through, so it must |
| 92 | // keep the headers the activator sets (Retry-After on a wake refusal) without |
| 93 | // panicking on a nil map, and must keep only the FIRST line of a body of any |
| 94 | // size — a tick's response body is never read, only classified. |
| 95 | func TestStatusRecorderHeadersAndFirstLine(t *testing.T) { |
| 96 | rec := &statusRecorder{} |
| 97 | rec.Header().Set("Retry-After", "5") |
| 98 | rec.WriteHeader(503) |
| 99 | rec.Write([]byte("instance is waking; retry shortly\n")) |
| 100 | rec.Write([]byte(strings.Repeat("x", 1<<20))) |
| 101 | rec.WriteHeader(200) // a second WriteHeader is a no-op, as on a real writer |
| 102 | if rec.status != 503 { |
| 103 | t.Fatalf("status = %d, want 503", rec.status) |
| 104 | } |
| 105 | if got := rec.firstLine(); got != "instance is waking; retry shortly" { |
| 106 | t.Fatalf("firstLine = %q", got) |
| 107 | } |
| 108 | if got := rec.Header().Get("Retry-After"); got != "5" { |
| 109 | t.Fatalf("Retry-After = %q", got) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | // The runner's Token MINTS: an instance asleep since before the roll has no |
| 114 | // CARLOS_ADMIN_TOKEN, and the wake that would mint one is the tick itself. So |
| 115 | // a route with no env file gets a token from the runner's Token func, and the |
| 116 | // spawn that follows (EnsureAdminToken, as Start and Seed call it) finds the |
| 117 | // same one rather than appending a second. |
| 118 | func TestScheduleTokenMintsForASleepingInstance(t *testing.T) { |
| 119 | dd := t.TempDir() |
| 120 | const host = "asleep.bab.oncarlos.com" |
| 121 | envPath := filepath.Join(dd, host+".env") |
| 122 | if _, err := os.Stat(envPath); !os.IsNotExist(err) { |
| 123 | t.Fatalf("fixture: env file already exists (%v)", err) |
| 124 | } |
| 125 | tok, err := scheduleToken(dd, nil, nil)(host) |
| 126 | if err != nil { |
| 127 | t.Fatalf("Token on a host with no env file: %v", err) |
| 128 | } |
| 129 | if len(tok) != 64 { |
| 130 | t.Fatalf("minted token = %q, want 32 random bytes as hex", tok) |
| 131 | } |
| 132 | if st, err := os.Stat(envPath); err != nil || st.Mode().Perm() != 0o600 { |
| 133 | t.Fatalf("env file after mint: %v mode %v, want 0600", err, st.Mode().Perm()) |
| 134 | } |
| 135 | // The spawn's own ensure is a no-op now, and reads back the same token. |
| 136 | if err := activator.EnsureAdminToken(envPath); err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | again, err := scheduleToken(dd, nil, nil)(host) |
| 140 | if err != nil || again != tok { |
| 141 | t.Fatalf("second read = %q (%v), want the first token %q", again, err, tok) |
| 142 | } |
| 143 | raw, _ := os.ReadFile(envPath) |
| 144 | if strings.Count(string(raw), "CARLOS_ADMIN_TOKEN=") != 1 { |
| 145 | t.Fatalf("env file carries %d tokens, want 1:\n%s", strings.Count(string(raw), "CARLOS_ADMIN_TOKEN="), raw) |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | // TestScheduleTokenUsesTheNestedPathForANestedRoute pins envPathFor's nested |
| 150 | // branch (schedule_wiring.go): a route this box's registry classifies as |
| 151 | // LayoutNested (its DBPath and Socket are the nested layout's own defaults) |
| 152 | // gets its token read and minted at the nested layout's own <host>.env, |
| 153 | // inside provision.InstanceDir, never at the flat <dataDir>/<host>.env path. |
| 154 | // A flat route, added to the very same registry, still resolves to the |
| 155 | // flat path exactly as before this branch existed. |
| 156 | func TestScheduleTokenUsesTheNestedPathForANestedRoute(t *testing.T) { |
| 157 | dd := t.TempDir() |
| 158 | reg, err := registry.Open(filepath.Join(dd, "r.db")) |
| 159 | if err != nil { |
| 160 | t.Fatal(err) |
| 161 | } |
| 162 | defer reg.Close() |
| 163 | svc := &activator.Service{} |
| 164 | |
| 165 | const nestedHost = "nested.bab.oncarlos.com" |
| 166 | if err := os.MkdirAll(provision.InstanceDir(dd, nestedHost), 0o755); err != nil { |
| 167 | t.Fatal(err) |
| 168 | } |
| 169 | if err := reg.Upsert(registry.Route{ |
| 170 | Host: nestedHost, Kind: "instance", Account: "bab", App: "app", Status: "ready", |
| 171 | Hibernate: true, |
| 172 | DBPath: provision.NestedDBPath(dd, nestedHost), |
| 173 | Socket: provision.NestedSocket(nestedHost), |
| 174 | }); err != nil { |
| 175 | t.Fatal(err) |
| 176 | } |
| 177 | |
| 178 | tok, err := scheduleToken(dd, svc, reg)(nestedHost) |
| 179 | if err != nil { |
| 180 | t.Fatalf("Token on a nested route: %v", err) |
| 181 | } |
| 182 | if len(tok) != 64 { |
| 183 | t.Fatalf("minted token = %q, want 32 random bytes as hex", tok) |
| 184 | } |
| 185 | nestedPath := filepath.Join(provision.InstanceDir(dd, nestedHost), nestedHost+".env") |
| 186 | nestedRaw, err := os.ReadFile(nestedPath) |
| 187 | if err != nil { |
| 188 | t.Fatalf("token was not minted at the nested path %s: %v", nestedPath, err) |
| 189 | } |
| 190 | if !strings.Contains(string(nestedRaw), "CARLOS_ADMIN_TOKEN="+tok) { |
| 191 | t.Fatalf("nested env file = %q, want it to carry the minted token", nestedRaw) |
| 192 | } |
| 193 | flatPathForNestedHost := filepath.Join(dd, nestedHost+".env") |
| 194 | if _, err := os.Stat(flatPathForNestedHost); !os.IsNotExist(err) { |
| 195 | t.Fatalf("a nested route's token also landed at the flat path %s, stat err = %v", flatPathForNestedHost, err) |
| 196 | } |
| 197 | |
| 198 | // The same registry, asked for a route whose DBPath and Socket are the |
| 199 | // FLAT defaults: envPathFor must not have gone flat-blind because a |
| 200 | // nested row now shares the box. |
| 201 | const flatHost = "flat.bab.oncarlos.com" |
| 202 | if err := reg.Upsert(registry.Route{ |
| 203 | Host: flatHost, Kind: "instance", Account: "bab", App: "app", Status: "ready", |
| 204 | Hibernate: true, |
| 205 | DBPath: provision.DefaultDBPath(dd, flatHost), |
| 206 | Socket: filepath.Join(provision.SocketDir, flatHost+".sock"), |
| 207 | }); err != nil { |
| 208 | t.Fatal(err) |
| 209 | } |
| 210 | flatTok, err := scheduleToken(dd, svc, reg)(flatHost) |
| 211 | if err != nil { |
| 212 | t.Fatalf("Token on a flat route: %v", err) |
| 213 | } |
| 214 | flatPath := filepath.Join(dd, flatHost+".env") |
| 215 | flatRaw, err := os.ReadFile(flatPath) |
| 216 | if err != nil { |
| 217 | t.Fatalf("token was not minted at the flat path %s: %v", flatPath, err) |
| 218 | } |
| 219 | if !strings.Contains(string(flatRaw), "CARLOS_ADMIN_TOKEN="+flatTok) { |
| 220 | t.Fatalf("flat env file = %q, want it to carry the minted token", flatRaw) |
| 221 | } |
| 222 | if _, err := os.Stat(filepath.Join(provision.InstanceDir(dd, flatHost), flatHost+".env")); !os.IsNotExist(err) { |
| 223 | t.Fatalf("a flat route's token landed under its instance directory, stat err = %v", err) |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | // A Token error becomes the row's last_status and from there the bucket |
| 228 | // status object and `carlos schedule ls`: it must not carry the box's |
| 229 | // filesystem layout. |
| 230 | func TestScheduleTokenErrorCarriesNoAbsolutePath(t *testing.T) { |
| 231 | dd := filepath.Join(t.TempDir(), "missing-data-dir") |
| 232 | _, err := scheduleToken(dd, nil, nil)("x.bab.oncarlos.com") |
| 233 | if err == nil { |
| 234 | t.Fatal("Token succeeded with no data dir") |
| 235 | } |
| 236 | if strings.Contains(err.Error(), dd) || strings.Contains(err.Error(), "/") { |
| 237 | t.Fatalf("error leaks a path: %q", err) |
| 238 | } |
| 239 | } |
| 240 | |