package host import ( "context" "net" "net/http" "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/carlosframework/platform/internal/activator" "github.com/carlosframework/platform/internal/hostwrite" "github.com/carlosframework/platform/internal/lease" "github.com/carlosframework/platform/internal/moves" "github.com/carlosframework/platform/internal/registry" ) // instanceForTest serves h on a unix socket, the way an exec instance // listens, and returns the route that names it. func instanceForTest(t *testing.T, h http.HandlerFunc) registry.Route { t.Helper() dir, err := os.MkdirTemp("", "s1") if err != nil { t.Fatal(err) } t.Cleanup(func() { os.RemoveAll(dir) }) sock := filepath.Join(dir, "i.sock") ln, err := net.Listen("unix", sock) if err != nil { t.Fatal(err) } srv := &http.Server{Handler: h} go srv.Serve(ln) t.Cleanup(func() { srv.Close() }) return registry.Route{Host: p1Host, Kind: "instance", Hibernate: true, Status: "ready", Socket: sock} } // wakeThenProxy is an activator that wakes and hands the request to the // probe's proxy, as Service.ServeScheduled does for an awake instance. func wakeThenProxy(w http.ResponseWriter, r *http.Request, _ registry.Route, proxy http.Handler) bool { proxy.ServeHTTP(w, r) return true } // S1 must prove the instance itself answered, in full, with a 2xx, within // its deadline (Task 14 review finding 4). func TestS1ProbeNeedsTheInstanceItself(t *testing.T) { t.Parallel() ctx := context.Background() answer := func(code int) registry.Route { return instanceForTest(t, func(w http.ResponseWriter, r *http.Request) { if r.Host != p1Host || r.URL.Path != "/ready" { w.WriteHeader(http.StatusTeapot) return } w.WriteHeader(code) w.Write([]byte("ok")) }) } if c := probeInstance(ctx, wakeThenProxy, answer(200), "/ready"); !c.OK { t.Fatalf("a 200 from the instance: %+v", c) } if c := probeInstance(ctx, wakeThenProxy, answer(204), "/ready"); !c.OK { t.Fatalf("a 204 from the instance: %+v", c) } for name, code := range map[string]int{"redirect": 302, "password gate": 401, "server error": 503, "not found": 404} { if c := probeInstance(ctx, wakeThenProxy, answer(code), "/ready"); c.OK || c.Reason != s1NoAnswer(p1Host) { t.Errorf("%s (%d) from the instance passed S1: %+v", name, code, c) } } // Answers that never reached the instance: the edge's unknown-host // fallback redirect, a gate's 401 and even a 200 written by the // activator itself (a fenced or refused wake) all fail. rt := answer(200) for name, wake := range map[string]func(http.ResponseWriter, *http.Request, registry.Route, http.Handler) bool{ "fallback redirect": func(w http.ResponseWriter, r *http.Request, _ registry.Route, _ http.Handler) bool { http.Redirect(w, r, "https://example.com/", http.StatusFound) return true }, "password gate": func(w http.ResponseWriter, _ *http.Request, _ registry.Route, _ http.Handler) bool { w.WriteHeader(http.StatusUnauthorized) return true }, "a 200 not from the instance": func(w http.ResponseWriter, _ *http.Request, _ registry.Route, _ http.Handler) bool { w.WriteHeader(http.StatusOK) return true }, } { if c := probeInstance(ctx, wake, rt, "/ready"); c.OK { t.Errorf("%s passed S1", name) } } // 200 headers, part of a body, then nothing until the deadline. slow := instanceForTest(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Length", "100") w.WriteHeader(http.StatusOK) w.Write([]byte("partial")) w.(http.Flusher).Flush() select { case <-r.Context().Done(): case <-time.After(5 * time.Second): } }) short, cancel := context.WithTimeout(ctx, 300*time.Millisecond) defer cancel() if c := probeInstance(short, wakeThenProxy, slow, "/ready"); c.OK { t.Fatal("a partial 200 that ran out its deadline passed S1") } // The instance answered in time, but the wake held the probe past its // deadline: the probe expired, so S1 fails. late, cancelLate := context.WithTimeout(ctx, 200*time.Millisecond) defer cancelLate() overrun := func(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool { proxy.ServeHTTP(w, r) <-r.Context().Done() return true } if c := probeInstance(late, overrun, answer(200), "/ready"); c.OK { t.Fatal("a probe that ran out its deadline passed S1") } // A route the activator does not manage is asked directly. notManaged := func(http.ResponseWriter, *http.Request, registry.Route, http.Handler) bool { return false } if c := probeInstance(ctx, notManaged, answer(200), "/ready"); !c.OK { t.Fatalf("an always-on instance: %+v", c) } } // The declared readiness path reaches the instance as declared: an escaped // slash stays escaped (Task 14 re-review), so an app that tells // /ready%2Fcheck from /ready/check is asked the one it declared. func TestS1ProbeSendsTheDeclaredPathVerbatim(t *testing.T) { var got []string rt := instanceForTest(t, func(w http.ResponseWriter, r *http.Request) { got = append(got, r.RequestURI) w.WriteHeader(http.StatusOK) }) for _, path := range []string{"/ready%2Fcheck", "/ready?probe=a%2Fb", "/plain"} { got = nil if c := probeInstance(context.Background(), wakeThenProxy, rt, path); !c.OK { t.Fatalf("%s: %+v", path, c) } if len(got) != 1 || got[0] != path { t.Fatalf("declared %q, the instance received %q", path, got) } } } // armedProbe is the production S1 (armPhase1's ReadyProbe) over a real // activator and one sleeping instance whose row carries gate as its import // gate. The socket directory is short, as a unix socket path must be. func armedProbe(t *testing.T, gate string) (func(context.Context, string, string) moves.Check, *countingWakeBackend) { t.Helper() dir, err := os.MkdirTemp("", "s1") if err != nil { t.Fatal(err) } t.Cleanup(func() { os.RemoveAll(dir) }) reg, err := registry.Open(filepath.Join(dir, "r.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { reg.Close() }) inst := &fakeInstance{socket: filepath.Join(dir, "i.sock"), handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })} if err := reg.Upsert(registry.Route{Host: p1Host, Kind: "instance", Account: "bab", App: "app", Status: "ready", Hibernate: true, Socket: inst.socket, DBPath: filepath.Join(dir, "app.db"), ImportGate: gate}); err != nil { t.Fatal(err) } b := &countingWakeBackend{fakeWakeBackend: &fakeWakeBackend{inst: inst}} svc := activator.New(activator.Config{Bucket: "test-bucket", Idle: time.Hour}, b, reg) svc.Start() t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() svc.Shutdown(ctx) }) var cfg moveCfg hib := activator.Config{Bucket: "data-ie", Region: "eu-west-1", Prefix: "instances"} armPhase1(&cfg, &movesWorker{mirrors: map[string]lease.MirrorStore{}}, hib, dir, reg, svc, nil, hostwrite.Config{}, func(string) string { return "" }) if cfg.ReadyProbe == nil { t.Fatal("armPhase1 wired no S1 probe") } return cfg.ReadyProbe, b } // countingWakeBackend counts every wake the activator begins (its lease // acquire, the wake's first step), whether or not the spawn then succeeds. type countingWakeBackend struct { *fakeWakeBackend mu sync.Mutex acquires int } func (b *countingWakeBackend) Acquire(ctx context.Context, rt registry.Route) (activator.Lease, error) { b.mu.Lock() b.acquires++ b.mu.Unlock() return b.fakeWakeBackend.Acquire(ctx, rt) } func (b *countingWakeBackend) wakes() int { b.mu.Lock() defer b.mu.Unlock() return b.acquires } // The moves probe never wakes a gated import (seeded-instances spec §5.4: // only the app's own verify requests reach it; §5.8: it moves only once // committed). ServeScheduled does not read the gate, so the probe must: // whatever the move's state, S1 fails without a wake. func TestS1ProbeNeverWakesAGatedImport(t *testing.T) { t.Parallel() for _, gate := range []string{strings.Repeat("a", 64), registry.ImportGateClosed} { probe, b := armedProbe(t, gate) got := probe(context.Background(), p1Host, "/") if got.OK || got.Reason != s1NoAnswer(p1Host) { t.Fatalf("gate %q: S1 = %+v, want %q", gate, got, s1NoAnswer(p1Host)) } if n := b.wakes(); n != 0 { t.Fatalf("gate %q: the probe began %d wakes of a gated import", gate, n) } if reqs := b.inst.served(); len(reqs) != 0 { t.Fatalf("gate %q: the gated instance served %d requests", gate, len(reqs)) } } // The control: the same row with no gate is woken and answers (this // half listens on a unix socket, so it needs a run outside a sandbox // that forbids one). probe, b := armedProbe(t, "") if got := probe(context.Background(), p1Host, "/"); !got.OK { t.Fatalf("an ungated instance: S1 = %+v", got) } if b.wakes() != 1 || b.inst.startCount() != 1 { t.Fatalf("an ungated instance: %d wakes, %d starts", b.wakes(), b.inst.startCount()) } }