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 "errors"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "path/filepath"
11 "sync/atomic"
12 "testing"
13 "time"
14
15 "github.com/carlosframework/platform/internal/activator"
16 "github.com/carlosframework/platform/internal/edge"
17 "github.com/carlosframework/platform/internal/instancectl"
18 "github.com/carlosframework/platform/internal/layout"
19 "github.com/carlosframework/platform/internal/registry"
20)
21
22type startupBackend struct {
23 fakeWakeBackend
24 recovered *atomic.Bool
25 started *atomic.Bool
26 attempts atomic.Int32
27 gate func() error
28}
29
30func (b *startupBackend) Acquire(ctx context.Context, rt registry.Route) (activator.Lease, error) {
31 b.attempts.Add(1)
32 if !b.recovered.Load() || !b.started.Load() {
33 return nil, errors.New("wake preceded recovery or Start")
34 }
35 if b.gate != nil {
36 if err := b.gate(); err != nil {
37 return nil, err
38 }
39 }
40 return b.fakeWakeBackend.Acquire(ctx, rt)
41}
42
43type observingActivator struct {
44 *activator.Service
45 entered chan struct{}
46}
47
48func (a observingActivator) ServeHTTP(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool {
49 a.entered <- struct{}{}
50 return a.Service.ServeHTTP(w, r, rt, proxy)
51}
52
53func TestStartupServesWhileSweepAndRecoveryWait(t *testing.T) {
54 t.Setenv("CARLOS_STEERING_ZONE_ID", "Z1234567890")
55 t.Setenv("PLATFORM_APPS_DOMAIN", "apps.example.com")
56 for _, fail := range []bool{false, true} {
57 t.Run(map[bool]string{false: "slow sweep and recovery", true: "unreachable instance control"}[fail], func(t *testing.T) {
58 dd, err := os.MkdirTemp(os.TempDir(), "startup-")
59 if err != nil {
60 t.Fatal(err)
61 }
62 t.Cleanup(func() { os.RemoveAll(dd) })
63 reg, err := registry.Open(filepath.Join(dd, "registry.db"))
64 if err != nil {
65 t.Fatal(err)
66 }
67 defer reg.Close()
68 inst := &fakeInstance{socket: filepath.Join(dd, "app.sock"), handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "awake") })}
69 rt := registry.Route{Host: "shop.example.com", Kind: "instance", Hibernate: true, Status: "ready", Socket: inst.socket, DBPath: filepath.Join(dd, "app.db")}
70 if err := reg.Upsert(rt); err != nil {
71 t.Fatal(err)
72 }
73 console := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }))
74 defer console.Close()
75 if err := reg.Upsert(registry.Route{Host: "console.example.com", Addr: console.Listener.Addr().String()}); err != nil {
76 t.Fatal(err)
77 }
78 var recovered, startedService atomic.Bool
79 backend := &startupBackend{fakeWakeBackend: fakeWakeBackend{inst: inst}, recovered: &recovered, started: &startedService}
80 control := instancectl.NewClient(filepath.Join(dd, "unreachable.sock"))
81 if fail {
82 backend.gate = nestedGate(func() error { return nil }, func(string) (bool, error) { return true, nil }, control.Ready)
83 }
84 svc := activator.New(activator.Config{Bucket: "unused", Idle: time.Hour}, backend, reg)
85 ready := make(chan struct{})
86 svc.Startup = ready
87 ctx, cancel := context.WithCancel(t.Context())
88 defer cancel()
89 defer func() { svc.Shutdown(context.Background()) }()
90 sweepEntered, recoverEntered := make(chan struct{}), make(chan struct{})
91 sweepRelease, recoverRelease := make(chan struct{}), make(chan struct{})
92 done := activatorStart{
93 Sweep: func(ctx context.Context) error {
94 close(sweepEntered)
95 select {
96 case <-sweepRelease:
97 case <-ctx.Done():
98 return ctx.Err()
99 }
100 if fail {
101 _, err := control.Sweep(ctx)
102 return err
103 }
104 return nil
105 },
106 Recover: func(ctx context.Context, swept bool) *layout.Pass {
107 if swept == fail {
108 t.Error("recovery received the wrong sweep result")
109 }
110 close(recoverEntered)
111 select {
112 case <-recoverRelease:
113 case <-ctx.Done():
114 return nil
115 }
116 recovered.Store(true)
117 return nil
118 },
119 Publish: func(*layout.Pass) {}, Start: func() { svc.Start(); startedService.Store(true) }, Ready: func() { close(ready) },
120 Routes: func() ([]registry.Route, error) { return nil, nil },
121 }.Run(ctx)
122 defer func() { cancel(); <-done }()
123 <-sweepEntered
124 entered := make(chan struct{}, 1)
125 e, err := edge.New(reg, observingActivator{svc, entered})
126 if err != nil {
127 t.Fatal(err)
128 }
129 server := httptest.NewServer(e)
130 defer server.Close()
131 client := &http.Client{Timeout: time.Second}
132 get := func(host string) (int, error) {
133 r, _ := http.NewRequest(http.MethodGet, server.URL+"/", nil)
134 r.Host = host
135 resp, err := client.Do(r)
136 if err != nil {
137 return 0, err
138 }
139 io.Copy(io.Discard, resp.Body)
140 resp.Body.Close()
141 return resp.StatusCode, nil
142 }
143 for host, want := range map[string]int{"127.0.0.1": http.StatusNotFound, "console.example.com": http.StatusNoContent, "edge.apps.example.com": http.StatusOK} {
144 began := time.Now()
145 if got, err := get(host); err != nil || got != want {
146 t.Fatalf("%s during sweep: %d, %v", host, got, err)
147 }
148 t.Logf("%s answered in %s while sweep was blocked", host, time.Since(began))
149 }
150 answer := make(chan int, 1)
151 go func() { status, _ := get(rt.Host); answer <- status }()
152 select {
153 case <-entered:
154 case <-time.After(time.Second):
155 t.Fatal("request never reached the activator")
156 }
157 assertHeld := func() {
158 t.Helper()
159 select {
160 case status := <-answer:
161 t.Fatalf("request answered before startup: %d", status)
162 case <-time.After(25 * time.Millisecond):
163 }
164 if backend.attempts.Load() != 0 {
165 t.Fatal("wake attempted before sweep and recovery finished")
166 }
167 }
168 assertHeld()
169 close(sweepRelease)
170 <-recoverEntered
171 assertHeld()
172 if got, err := get("console.example.com"); err != nil || got != http.StatusNoContent {
173 t.Fatalf("console during recovery: %d, %v", got, err)
174 }
175 close(recoverRelease)
176 want := http.StatusOK
177 if fail {
178 want = http.StatusServiceUnavailable
179 }
180 select {
181 case got := <-answer:
182 if got != want {
183 t.Fatalf("instance after Start: %d, want %d", got, want)
184 }
185 case <-time.After(time.Second):
186 t.Fatal("request did not finish after Start")
187 }
188 if backend.attempts.Load() != 1 || inst.running() == fail {
189 t.Fatal("unexpected wake outcome")
190 }
191 })
192 }
193}
194
195func TestStartupCancellationAndBoundedJoin(t *testing.T) {
196 t.Parallel()
197 ctx, cancel := context.WithCancel(context.Background())
198 entered := make(chan struct{})
199 var started atomic.Bool
200 done := activatorStart{
201 Sweep: func(ctx context.Context) error { close(entered); <-ctx.Done(); return ctx.Err() },
202 Recover: func(context.Context, bool) *layout.Pass { t.Error("recovery after cancellation"); return nil },
203 Start: func() { started.Store(true) },
204 }.Run(ctx)
205 <-entered
206 cancel()
207 join, stop := context.WithTimeout(t.Context(), time.Second)
208 defer stop()
209 if !joinAgentStartup(join, done) || started.Load() {
210 t.Fatal("cancelled startup was not joined, or started")
211 }
212 blocked := make(chan struct{})
213 expired, expire := context.WithTimeout(t.Context(), 20*time.Millisecond)
214 defer expire()
215 if joinAgentStartup(expired, blocked) {
216 t.Fatal("join ignored its bound")
217 }
218}
219
220func TestStartupJoinsKeyPassOnAgentCancellation(t *testing.T) {
221 ctx, cancel := context.WithCancel(t.Context())
222 defer cancel()
223 entered := make(chan struct{})
224 rt := registry.Route{Host: "shop.example.com", Kind: "instance", Socket: "/unused.sock", Status: "ready"}
225 done := activatorStart{
226 Sweep: func(context.Context) error { return nil },
227 Recover: func(context.Context, bool) *layout.Pass { return nil },
228 Publish: func(*layout.Pass) {}, Start: func() {},
229 Routes: func() ([]registry.Route, error) { return []registry.Route{rt}, nil },
230 Route: routeOf(rt), Slot: openSlot,
231 Keys: keyBackfillFunc(func(ctx context.Context, _ registry.Route) (activator.InstanceKeyOutcome, error) {
232 close(entered)
233 <-ctx.Done()
234 return activator.InstanceKeyFailed, ctx.Err()
235 }),
236 }.Run(ctx)
237 select {
238 case <-entered:
239 case <-time.After(time.Second):
240 t.Fatal("key pass did not start")
241 }
242 cancel()
243 join, stop := context.WithTimeout(t.Context(), time.Second)
244 defer stop()
245 if !joinAgentStartup(join, done) {
246 t.Fatal("key pass outlived agent cancellation")
247 }
248}
249
250func TestStartupCancellationDuringRecoveryPreservesAgentLifetime(t *testing.T) {
251 agentCtx, cancelAgent := context.WithCancel(t.Context())
252 defer cancelAgent()
253 startupCtx, cancelStartup := context.WithCancel(agentCtx)
254 defer cancelStartup()
255 recovering := make(chan struct{})
256 svc := activator.New(activator.Config{}, &fakeWakeBackend{}, nil)
257 ready := make(chan struct{})
258 svc.Startup = ready
259 done := activatorStart{
260 Configure: func(context.Context) {
261 if agentCtx.Err() != nil {
262 t.Error("agent lifetime ended before configuration")
263 }
264 },
265 Sweep: func(context.Context) error { return nil },
266 Recover: func(ctx context.Context, _ bool) *layout.Pass {
267 close(recovering)
268 <-ctx.Done()
269 return nil
270 },
271 Publish: func(*layout.Pass) {},
272 Start: func() { t.Error("Start after recovery cancellation") },
273 Ready: func() { close(ready) },
274 }.Run(startupCtx)
275 <-recovering
276 cancelStartup()
277 join, stop := context.WithTimeout(t.Context(), time.Second)
278 defer stop()
279 if !joinAgentStartup(join, done) {
280 t.Fatal("recovery did not join")
281 }
282 svc.Shutdown(join)
283 if agentCtx.Err() != nil {
284 t.Fatal("startup cancellation stopped rollout's agent lifetime before children drained")
285 }
286 select {
287 case <-ready:
288 t.Fatal("cancelled recovery released admission")
289 default:
290 }
291}
292