package host import ( "context" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "github.com/carlosframework/platform/internal/activator" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" ) // A tick's outcome is read from the handler's own answer, so the mapping from // "what the activator wrote" to "what the policy sees" is the whole contract // of this leg: an app's 503 must retry as an app 503, while the activator's // own wake refusal must retry as a wake failure — same retry, different // member-facing status. func TestDeliverViaHandlerMapsWakeRefusals(t *testing.T) { cases := []struct { status int body string want int wantErr string }{ {204, "", 204, ""}, {503, "instance failed to wake\n", 0, "instance failed to wake"}, {503, "instance is waking; retry shortly\n", 0, "instance is waking; retry shortly"}, {503, "instance is waking elsewhere; retry shortly\n", 0, "instance is waking elsewhere"}, {503, "app says no", 503, ""}, {500, "instance blew up", 500, ""}, // only a 503 is a wake refusal } for _, c := range cases { h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(c.status) w.Write([]byte(c.body)) }) req := httptest.NewRequest("POST", "http://a.bab.oncarlos.com/jobs/x", nil) got, err := deliverVia(context.Background(), h, registry.Route{Host: "a.bab.oncarlos.com"}, req) if got != c.want || (err == nil) != (c.wantErr == "") || (err != nil && !strings.Contains(err.Error(), c.wantErr)) { t.Errorf("%d %q → %d %v; want %d %q", c.status, c.body, got, err, c.want, c.wantErr) } } } // A handler that writes a body without WriteHeader has answered 200 — the // recorder must say so, or every well-behaved app that just writes its // response would be recorded as "0 (app refused)". func TestDeliverViaImplicit200(t *testing.T) { h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.Write([]byte("ok")) }) req := httptest.NewRequest("POST", "http://a.bab.oncarlos.com/jobs/x", nil) got, err := deliverVia(context.Background(), h, registry.Route{Host: "a.bab.oncarlos.com"}, req) if got != 200 || err != nil { t.Fatalf("implicit 200 → %d %v", got, err) } } // A tick that ran past its deadline is the context's error, not the 502 the // proxy writes on the way out: the runner maps DeadlineExceeded to ErrTimeout, // which is the one outcome that does NOT retry (the job may still be running). func TestDeliverViaReportsContextError(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cancel() // the handler runs long; the deadline fires under it w.WriteHeader(http.StatusBadGateway) }) req := httptest.NewRequest("POST", "http://a.bab.oncarlos.com/jobs/x", nil) got, err := deliverVia(ctx, h, registry.Route{Host: "a.bab.oncarlos.com"}, req) if got != 0 || err != context.Canceled { t.Fatalf("cancelled ctx → %d %v; want 0 context.Canceled", got, err) } } // A tick has nowhere to go on an addr-backed route: the delivery leg dials the // instance's own unix socket on purpose (spec §6.3 — never the public edge), so // a route with no socket is an error, not a dial to an empty path. func TestActivatorDelivererRefusesSocketlessRoute(t *testing.T) { req := httptest.NewRequest("POST", "http://a.bab.oncarlos.com/jobs/x", nil) got, err := (&activatorDeliverer{}).Deliver(context.Background(), registry.Route{Host: "a.bab.oncarlos.com"}, req) if got != 0 || err == nil || !strings.Contains(err.Error(), "no socket") { t.Fatalf("socketless route → %d %v; want 0 and a no-socket error", got, err) } } // The recorder is a ResponseWriter the activator writes through, so it must // keep the headers the activator sets (Retry-After on a wake refusal) without // panicking on a nil map, and must keep only the FIRST line of a body of any // size — a tick's response body is never read, only classified. func TestStatusRecorderHeadersAndFirstLine(t *testing.T) { rec := &statusRecorder{} rec.Header().Set("Retry-After", "5") rec.WriteHeader(503) rec.Write([]byte("instance is waking; retry shortly\n")) rec.Write([]byte(strings.Repeat("x", 1<<20))) rec.WriteHeader(200) // a second WriteHeader is a no-op, as on a real writer if rec.status != 503 { t.Fatalf("status = %d, want 503", rec.status) } if got := rec.firstLine(); got != "instance is waking; retry shortly" { t.Fatalf("firstLine = %q", got) } if got := rec.Header().Get("Retry-After"); got != "5" { t.Fatalf("Retry-After = %q", got) } } // The runner's Token MINTS: an instance asleep since before the roll has no // CARLOS_ADMIN_TOKEN, and the wake that would mint one is the tick itself. So // a route with no env file gets a token from the runner's Token func, and the // spawn that follows (EnsureAdminToken, as Start and Seed call it) finds the // same one rather than appending a second. func TestScheduleTokenMintsForASleepingInstance(t *testing.T) { dd := t.TempDir() const host = "asleep.bab.oncarlos.com" envPath := filepath.Join(dd, host+".env") if _, err := os.Stat(envPath); !os.IsNotExist(err) { t.Fatalf("fixture: env file already exists (%v)", err) } tok, err := scheduleToken(dd, nil, nil)(host) if err != nil { t.Fatalf("Token on a host with no env file: %v", err) } if len(tok) != 64 { t.Fatalf("minted token = %q, want 32 random bytes as hex", tok) } if st, err := os.Stat(envPath); err != nil || st.Mode().Perm() != 0o600 { t.Fatalf("env file after mint: %v mode %v, want 0600", err, st.Mode().Perm()) } // The spawn's own ensure is a no-op now, and reads back the same token. if err := activator.EnsureAdminToken(envPath); err != nil { t.Fatal(err) } again, err := scheduleToken(dd, nil, nil)(host) if err != nil || again != tok { t.Fatalf("second read = %q (%v), want the first token %q", again, err, tok) } raw, _ := os.ReadFile(envPath) if strings.Count(string(raw), "CARLOS_ADMIN_TOKEN=") != 1 { t.Fatalf("env file carries %d tokens, want 1:\n%s", strings.Count(string(raw), "CARLOS_ADMIN_TOKEN="), raw) } } // TestScheduleTokenUsesTheNestedPathForANestedRoute pins envPathFor's nested // branch (schedule_wiring.go): a route this box's registry classifies as // LayoutNested (its DBPath and Socket are the nested layout's own defaults) // gets its token read and minted at the nested layout's own .env, // inside provision.InstanceDir, never at the flat /.env path. // A flat route, added to the very same registry, still resolves to the // flat path exactly as before this branch existed. func TestScheduleTokenUsesTheNestedPathForANestedRoute(t *testing.T) { dd := t.TempDir() reg, err := registry.Open(filepath.Join(dd, "r.db")) if err != nil { t.Fatal(err) } defer reg.Close() svc := &activator.Service{} const nestedHost = "nested.bab.oncarlos.com" if err := os.MkdirAll(provision.InstanceDir(dd, nestedHost), 0o755); err != nil { t.Fatal(err) } if err := reg.Upsert(registry.Route{ Host: nestedHost, Kind: "instance", Account: "bab", App: "app", Status: "ready", Hibernate: true, DBPath: provision.NestedDBPath(dd, nestedHost), Socket: provision.NestedSocket(nestedHost), }); err != nil { t.Fatal(err) } tok, err := scheduleToken(dd, svc, reg)(nestedHost) if err != nil { t.Fatalf("Token on a nested route: %v", err) } if len(tok) != 64 { t.Fatalf("minted token = %q, want 32 random bytes as hex", tok) } nestedPath := filepath.Join(provision.InstanceDir(dd, nestedHost), nestedHost+".env") nestedRaw, err := os.ReadFile(nestedPath) if err != nil { t.Fatalf("token was not minted at the nested path %s: %v", nestedPath, err) } if !strings.Contains(string(nestedRaw), "CARLOS_ADMIN_TOKEN="+tok) { t.Fatalf("nested env file = %q, want it to carry the minted token", nestedRaw) } flatPathForNestedHost := filepath.Join(dd, nestedHost+".env") if _, err := os.Stat(flatPathForNestedHost); !os.IsNotExist(err) { t.Fatalf("a nested route's token also landed at the flat path %s, stat err = %v", flatPathForNestedHost, err) } // The same registry, asked for a route whose DBPath and Socket are the // FLAT defaults: envPathFor must not have gone flat-blind because a // nested row now shares the box. const flatHost = "flat.bab.oncarlos.com" if err := reg.Upsert(registry.Route{ Host: flatHost, Kind: "instance", Account: "bab", App: "app", Status: "ready", Hibernate: true, DBPath: provision.DefaultDBPath(dd, flatHost), Socket: filepath.Join(provision.SocketDir, flatHost+".sock"), }); err != nil { t.Fatal(err) } flatTok, err := scheduleToken(dd, svc, reg)(flatHost) if err != nil { t.Fatalf("Token on a flat route: %v", err) } flatPath := filepath.Join(dd, flatHost+".env") flatRaw, err := os.ReadFile(flatPath) if err != nil { t.Fatalf("token was not minted at the flat path %s: %v", flatPath, err) } if !strings.Contains(string(flatRaw), "CARLOS_ADMIN_TOKEN="+flatTok) { t.Fatalf("flat env file = %q, want it to carry the minted token", flatRaw) } if _, err := os.Stat(filepath.Join(provision.InstanceDir(dd, flatHost), flatHost+".env")); !os.IsNotExist(err) { t.Fatalf("a flat route's token landed under its instance directory, stat err = %v", err) } } // A Token error becomes the row's last_status and from there the bucket // status object and `carlos schedule ls`: it must not carry the box's // filesystem layout. func TestScheduleTokenErrorCarriesNoAbsolutePath(t *testing.T) { dd := filepath.Join(t.TempDir(), "missing-data-dir") _, err := scheduleToken(dd, nil, nil)("x.bab.oncarlos.com") if err == nil { t.Fatal("Token succeeded with no data dir") } if strings.Contains(err.Error(), dd) || strings.Contains(err.Error(), "/") { t.Fatalf("error leaks a path: %q", err) } }