carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
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.
22func 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)".
52func 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).
67func 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.
83func 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.
95func 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.
118func 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.
156func 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.
230func 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