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 "bytes"
5 "context"
6 "errors"
7 "github.com/carlosframework/platform/internal/hostwrite"
8 "log"
9 "net/http"
10 "net/http/httputil"
11 "os"
12 "path/filepath"
13 "strings"
14 "sync"
15 "time"
16
17 "github.com/carlosframework/platform/internal/activator"
18 "github.com/carlosframework/platform/internal/edge"
19 "github.com/carlosframework/platform/internal/provision"
20 "github.com/carlosframework/platform/internal/registry"
21 "github.com/carlosframework/platform/internal/release"
22 "github.com/carlosframework/platform/internal/schedule"
23 "github.com/carlosframework/platform/internal/version"
24)
25
26// Scheduled work, host side (spec §6.2, §6.3, §4.3).
27//
28// Two halves, one set of dependencies:
29//
30// - the CONVERGER turns apps/<acct>/<app>/schedules.json into timer rows in
31// this box's registry. It runs on every serial move (so a `carlos schedule
32// set` lands in seconds, like every other console-mediated write), hourly
33// underneath that (the floor a missed bump falls back to), and once at
34// agent start (a box that booted after the bump must not wait an hour to
35// learn its schedules).
36// - the RUNNER fires the rows that are due, every 15s, and reports what
37// happened back to the bucket so `carlos schedule status` can answer
38// without a box command.
39//
40// Both are built ONCE, in the agent's startup, and shared: the converger's
41// etag memory is what keeps an idle box at one GET per app per pass and makes
42// a run signal exactly-once, so a second instance in another loop would
43// re-seed that memory and either replay a run or swallow one.
44//
45// The two halves have different preconditions. The converger needs the deploy
46// store (schedules live in the bucket). The runner does not: once-timers are
47// registered by instances on their own control socket (spec §7.2) straight
48// into this box's registry, so a box with an armed activator and no store
49// still has rows to fire — conv is nil there, and nothing is reported.
50type scheduleDeps struct {
51 conv *schedule.Converger // nil when the box has no deploy store
52 runner *schedule.Runner
53}
54
55// newScheduleDeps assembles the converger and the runner around this agent's
56// registry, deploy store and activator.
57//
58// store may be nil (no deploy store on this box): no converger is built and
59// the runner reports nothing, but it still fires the once rows instances
60// register. svc may be nil (no hibernation armed on this box): delivery then
61// proxies straight to the instance's socket, which is exactly right — an
62// instance that is not activator-managed is either already running or not
63// this leg's problem. sink may be nil (no log store): the runner simply emits
64// no member events. Callers must pass a genuinely nil interface for sink, not
65// a nil *applog.Sink — see the trap agent.go documents where logSink is
66// threaded.
67func newScheduleDeps(reg *registry.Registry, store release.Store, svc *activator.Service, sink activator.EventSink, dd string) *scheduleDeps {
68 box, err := os.Hostname()
69 if err != nil || box == "" {
70 box = "unknown"
71 }
72 var event func(rt registry.Route, level, msg string)
73 if sink != nil {
74 event = func(rt registry.Route, level, msg string) {
75 sink.Event(release.RouteAccount(rt), rt.App, rt.Host, level, msg)
76 }
77 }
78 // Zero when no activator is armed on this box: nothing hibernates, so no
79 // schedule can hold anything awake, and holdsAwake reports false for all
80 // of them.
81 var idle time.Duration
82 if svc != nil {
83 idle = svc.IdleWindow()
84 }
85 sd := &scheduleDeps{
86 runner: &schedule.Runner{
87 Reg: reg,
88 Deliver: &activatorDeliverer{svc: svc},
89 Token: scheduleToken(dd, svc, reg),
90 Skip: scheduleSkip,
91 Event: event,
92 Box: box,
93 Build: version.Build,
94 Idle: idle,
95 },
96 }
97 if store == nil {
98 return sd
99 }
100 // The reporter is shared by both halves on purpose: a converge that adds a
101 // row and a tick that fires it write the SAME status object, so whichever
102 // ran last is what a member reads. Its own timeout, not the caller's — a
103 // slow bucket must not hold a converge pass or a tick goroutine open.
104 report := func(rt registry.Route, st schedule.Status) {
105 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
106 defer cancel()
107 if err := schedule.WriteStatus(ctx, store, release.RouteAccount(rt), rt.App, st); err != nil {
108 log.Printf("schedule: report %s: %v", rt.Host, err)
109 }
110 }
111 sd.conv = schedule.NewConverger(reg, store, box, report)
112 sd.conv.Idle = idle
113 sd.runner.Report = report
114 return sd
115}
116
117// armSchedule is the agent's arming switch for scheduled work, lifted out of
118// agent.go so the decision itself can be tested rather than only its parts.
119//
120// storeErr is openDeployStore's error, and the three arms are not
121// interchangeable:
122//
123// - a deploy store: both halves. Declarations converge into rows and every
124// fire reports back to the bucket.
125// - NO store but an armed activator: the RUNNER ALONE, no converger, no
126// reports. This is the arm a review found missing (I6). A box that mounts
127// the control socket's schedule verbs accepts once-timers from its own
128// instances (spec §7.2) straight into the registry — and a box that
129// accepts a timer must fire it. Gating the runner on the store would have
130// left those rows sitting in the table forever.
131// - neither: nothing at all, since nothing could write a row or fire one.
132//
133// It returns nil for that last arm, which every caller (and scheduleLegFor,
134// and converge) already tolerates.
135func armSchedule(reg *registry.Registry, store release.Store, storeErr error, svc *activator.Service, sink activator.EventSink, dd string) *scheduleDeps {
136 switch {
137 case storeErr == nil:
138 log.Printf("agent: scheduled work armed (converge on serial + hourly; runner every 15s)")
139 return newScheduleDeps(reg, store, svc, sink, dd)
140 case svc != nil:
141 log.Printf("agent: schedules from the bucket are off (%v); once-timers registered by instances still run (runner every 15s)", storeErr)
142 return newScheduleDeps(reg, nil, svc, sink, dd)
143 default:
144 log.Printf("agent: scheduled work disabled: %v", storeErr)
145 return nil
146 }
147}
148
149// scheduleSkip is the runner's Skip (harness Run 9, defect 8): a host whose
150// row this box holds, and a host whose offline move's record names another
151// region. After open the source keeps its row, held, until finishing, and
152// open has lifted the fence that used to refuse the token; so the source's
153// tick minted a fresh <host>.env (an admin token) inside the instance
154// directory the move had just parked, a stray secret for a host served
155// elsewhere, and recorded a delivery the activator refuses for a held row
156// anyway (ServeScheduled). The token is the only instance file the runner
157// writes; delivery wakes nothing for a held row, and the converger writes
158// registry rows and the bucket status only. Any hold counts, not only an
159// offline move's: no held row is ever served a tick, and a hold whose move
160// has just ended is still a hold until reconcile removes or releases the
161// row.
162func scheduleSkip(rt registry.Route) bool {
163 if registry.IsHold(rt.Status) {
164 return true
165 }
166 if v := armedOfflineView(); v != nil {
167 return v.elsewhere(rt.Host)
168 }
169 return false
170}
171
172// scheduleToken is the runner's Token: ensure-then-read on the activator's
173// own <dataDir>/<host>.env (spec §6.4).
174//
175// Ensure, not just read, because the token is otherwise minted only when the
176// activator spawns the instance — and the tick is what would spawn it. An
177// instance asleep since before this build rolled has no token; a read-only
178// Token would record "no instance token" five times and give up, and the
179// wake that mints it would never come. EnsureAdminToken is the same call
180// Start and Seed make, serialized with them, so a tick and a visitor racing
181// on a fresh host mint ONE token.
182//
183// The error is redacted: it reaches the row's last_status and from there the
184// bucket status object and `carlos schedule ls`, and a member must not learn
185// the box's filesystem layout from it.
186func scheduleToken(dd string, svc *activator.Service, reg *registry.Registry) func(host string) (string, error) {
187 paths := envPathFor(dd, reg)
188 read := schedule.TokenFromEnvPaths(paths)
189 prepare := func(host string) (string, error) {
190 if err := activator.EnsureAdminToken(paths(host)); err != nil {
191 return "", errors.New(activator.RedactPaths(err.Error()))
192 }
193 tok, err := read(host)
194 if err != nil {
195 return "", errors.New(activator.RedactPaths(err.Error()))
196 }
197 return tok, nil
198 }
199 return func(host string) (string, error) {
200 if svc == nil {
201 return prepare(host)
202 }
203 return svc.PrepareScheduledToken(host, func() (string, error) {
204 return prepare(host)
205 })
206 }
207}
208
209// envPathFor is scheduleToken's route to an instance's own env file: flat by
210// default, and the nested layout's own <host>.env (design §7) when this
211// box's registry classifies the host as a nested row. It is the SAME
212// classification the exec backend used to decide where it spawned the
213// instance in the first place (activator.Classify), so the runner and the
214// token-ensure step ask the file the backend actually wrote rather than
215// guessing the flat path for every host. reg nil (a test with no registry
216// in hand) is every host flat, byte-identical to before this existed.
217func envPathFor(dd string, reg *registry.Registry) func(host string) string {
218 flat := func(host string) string { return filepath.Join(dd, host+".env") }
219 if reg == nil {
220 return flat
221 }
222 return func(host string) string {
223 rt, err := reg.Get(host)
224 if err != nil || rt == nil {
225 return flat(host)
226 }
227 if layout, _ := activator.Classify(*rt, activator.LayoutInputs{DataDir: dd}); layout == activator.LayoutNested {
228 return filepath.Join(provision.InstanceDir(dd, host), host+".env")
229 }
230 return flat(host)
231 }
232}
233
234// convergePassTimeout bounds ONE converge pass. The agent's three call sites
235// (the seed and the serial leg in fastConvergeLoop, the hourly floor in
236// adoptLoop) all hand this context.Background() — an unbounded context — and
237// a pass is store-bound: a bucket that accepts a connection and then never
238// answers would park the pass, and with it every caller queued on
239// Converger's own mutex, for as long as the process lives. Generous on
240// purpose: a pass reads one object per app plus the run signal, so minutes
241// is far past any healthy bucket and still finite. The floors underneath
242// (hourly, and the next serial move) are what recover a pass cut short.
243//
244// It bounds QUEUE PLUS WORK, not work alone: Pass takes the converger's
245// mutex first, so a caller behind a parked pass can spend the budget waiting
246// and then run with an expired context. Harmless — every app read fails,
247// convergeApp is die-free and leaves its rows alone, no etag is poisoned —
248// and it is the property that matters here: the leg returns, whatever the
249// bucket is doing.
250const convergePassTimeout = 5 * time.Minute
251
252// converge runs one converger pass, or nothing on a box with no deploy
253// store. The agent's three call sites go through this so none of them can
254// reach into a nil conv — and so that all three get the timeout above
255// rather than each remembering to apply it.
256func (sd *scheduleDeps) converge(ctx context.Context) error {
257 if sd == nil || sd.conv == nil {
258 return nil
259 }
260 ctx, cancel := context.WithTimeout(ctx, convergePassTimeout)
261 defer cancel()
262 return sd.conv.Pass(ctx)
263}
264
265// scheduleLegFor is fastDeps.scheduleLeg for sd — a genuinely nil func when
266// there is nothing to converge (no scheduled work armed, or no deploy store
267// to read declarations from), so the tick skips the leg rather than calling
268// a closure over a nil converger.
269func scheduleLegFor(sd *scheduleDeps) func(ctx context.Context) {
270 if sd == nil || sd.conv == nil {
271 return nil
272 }
273 return func(ctx context.Context) {
274 if err := sd.converge(ctx); err != nil {
275 log.Printf("agent: schedule converge: %v", err)
276 }
277 }
278}
279
280// activatorDeliverer delivers one tick the way a member request reaches an
281// instance from inside this process — through the activator, then the
282// instance's unix socket — and never through the public edge (spec §6.3).
283// Two properties come from that and only that:
284//
285// - a hibernating instance WAKES for its tick, exactly as it would for a
286// visitor, and is held awake for the tick's duration because the
287// activator's in-flight count holds it open — but the tick earns no
288// activity credit, so it does not by itself keep the instance awake
289// afterwards. Separately, the sweeper's schedule guard (activator.go,
290// sweepOnce) holds a host resident whenever its next tick is due sooner
291// than the idle window, so a schedule firing more often than the idle
292// window stays resident by that guard's design rather than thrashing
293// through a wake/sleep pair on every tick. A schedule looser than the
294// idle window is not covered by the guard and does sleep between ticks,
295// waking again for the next one.
296// - the tick never leaves the box: no DNS, no TLS, no edge auth, nothing an
297// outsider could aim at the same path.
298type activatorDeliverer struct {
299 svc *activator.Service
300
301 // proxies caches one unix proxy — and therefore one http.Transport, and
302 // therefore one connection pool — per socket path. A fresh Transport per
303 // delivery meant a fresh connection for every tick and a pool thrown
304 // away unread; a schedule that fires every minute paid a dial (and the
305 // runtime a reader goroutine) each time for nothing.
306 // Keyed by socket path rather than host because the path is what the
307 // Transport actually dials: a route re-pointed at a new socket gets a new
308 // entry rather than a pool aimed at the old file.
309 //
310 // Bounded three ways, in the order they normally fire:
311 //
312 // - the runner's daily prune leg hands this the sockets still routed
313 // here (RetainSockets) and everything else goes. That is the one that
314 // reclaims a RETIRED route: it stops receiving deliveries, so it can
315 // never error its own way out.
316 // - an entry is dropped when its delivery comes back as an error — a
317 // refused wake or an expired deadline. (A socket that will not dial
318 // is NOT one of these: ReverseProxy answers that itself with a 502
319 // and no error. Nothing is pooled behind a failed dial anyway.)
320 // - the whole map is dropped if it ever passes maxProxyCache, a safety
321 // net rather than a working mechanism.
322 //
323 // Every one of those closes the dropped transports' idle connections
324 // rather than waiting out edge.UnixProxy's 90s IdleConnTimeout.
325 mu sync.Mutex
326 proxies map[string]http.Handler
327
328 // genPools is the delivery leg's own per-generation-socket pools, used
329 // instead of proxies for an activator-managed instance once generations
330 // are on (zero-downtime spec §7.1): a tick reaches the CURRENT
331 // generation, asked per connection. Made on first use and retired
332 // through the activator's hook, so a retired generation's pool closes
333 // with it rather than waiting for the daily prune. Nil with generations
334 // off, and nothing below reads it then.
335 genOnce sync.Once
336 genPools *edge.GenPools
337}
338
339// managedByGenerations reports whether rt's ticks go through the current
340// generation: generations on, and an activator-managed instance.
341func (d *activatorDeliverer) managedByGenerations(rt registry.Route) bool {
342 return d.svc != nil && d.svc.GenerationsOn() && rt.Kind == "instance" && rt.Hibernate
343}
344
345// genProxyFor is the AppSocket proxy for host over the leg's own pools.
346func (d *activatorDeliverer) genProxyFor(host string) http.Handler {
347 d.genOnce.Do(func() {
348 d.genPools = edge.NewGenPools()
349 d.svc.OnGenRetired(d.genPools.Retire)
350 })
351 return edge.AppSocketProxy(host, d.svc, d.genPools)
352}
353
354// maxProxyCache is that ceiling. Deliberately well above the number of
355// exec instances a box runs (the 1000-per-host timer cap is per host, not
356// per box) so the reset is a safety net, never a working mechanism.
357const maxProxyCache = 512
358
359// proxyFor is the cached unix proxy for socket.
360func (d *activatorDeliverer) proxyFor(socket string) http.Handler {
361 d.mu.Lock()
362 defer d.mu.Unlock()
363 if p, ok := d.proxies[socket]; ok {
364 return p
365 }
366 if d.proxies == nil {
367 d.proxies = map[string]http.Handler{}
368 } else if len(d.proxies) >= maxProxyCache {
369 for _, p := range d.proxies {
370 closeIdle(p)
371 }
372 clear(d.proxies)
373 }
374 p := edge.UnixProxy(socket)
375 d.proxies[socket] = p
376 return p
377}
378
379// dropProxy forgets socket's proxy and closes the connections it was holding.
380func (d *activatorDeliverer) dropProxy(socket string) {
381 d.mu.Lock()
382 defer d.mu.Unlock()
383 if p, ok := d.proxies[socket]; ok {
384 closeIdle(p)
385 delete(d.proxies, socket)
386 }
387}
388
389// RetainSockets keeps only the sockets that are still routed on this box and
390// drops the rest — schedule.SocketRetainer, called by the runner's prune leg
391// with the host list it has already read. Without it a retired route's entry
392// would sit here until the maxProxyCache reset, since a route nothing
393// delivers to can never error its way out.
394func (d *activatorDeliverer) RetainSockets(live map[string]bool) {
395 d.mu.Lock()
396 defer d.mu.Unlock()
397 for socket, p := range d.proxies {
398 if !live[socket] {
399 closeIdle(p)
400 delete(d.proxies, socket)
401 }
402 }
403}
404
405// closeIdle returns a dropped proxy's pooled connections now rather than at
406// the end of edge.UnixProxy's 90s IdleConnTimeout. Best-effort by design:
407// both assertions are guarded, so a change to the shape edge builds costs
408// the eager close and nothing else — the idle timeout is still the backstop.
409// (schedule_wake_test.go pins the shape, so such a change fails a test
410// rather than passing quietly.)
411func closeIdle(h http.Handler) {
412 if rp, ok := h.(*httputil.ReverseProxy); ok {
413 if tr, ok := rp.Transport.(*http.Transport); ok {
414 tr.CloseIdleConnections()
415 }
416 }
417}
418
419func (d *activatorDeliverer) Deliver(ctx context.Context, rt registry.Route, req *http.Request) (int, error) {
420 return d.deliverSelected(ctx, rt, req, nil)
421}
422func (d *activatorDeliverer) DeliverClaim(ctx context.Context, rt registry.Route, req *http.Request, admit func(context.Context) (context.Context, func(), error)) (int, error) {
423 return d.deliverSelected(ctx, rt, req, admit)
424}
425func (d *activatorDeliverer) deliverSelected(ctx context.Context, rt registry.Route, req *http.Request, admit func(context.Context) (context.Context, func(), error)) (int, error) {
426 if rt.Socket == "" {
427 // An addr-backed (or tunnel-backed) route has no socket to dial from
428 // here. Converge only ever writes rows for exec-backed instances, so
429 // this is a row that outlived its route's shape — an error, not a dial
430 // to an empty path (which would be a connect-refused every 15s).
431 return 0, errors.New("no socket for route " + rt.Host)
432 }
433 // Generations on: the proxy asks the activator for the current
434 // generation's socket per connection (spec §7.1). Off: the cached proxy
435 // on rt.Socket, exactly as before.
436 gen := d.managedByGenerations(rt)
437 var proxy http.Handler
438 if gen {
439 proxy = d.genProxyFor(rt.Host)
440 } else {
441 proxy = d.proxyFor(rt.Socket)
442 }
443 var admissionErr error
444 if admit != nil {
445 next := proxy
446 proxy = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
447 call, finish, err := admit(r.Context())
448 if err != nil {
449 admissionErr = errors.Join(hostwrite.ErrUnavailable, err)
450 http.Error(w, "instance timer authority changed", http.StatusServiceUnavailable)
451 return
452 }
453 defer finish()
454 next.ServeHTTP(w, r.WithContext(call))
455 })
456 }
457 h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
458 // ServeScheduled, not ServeHTTP: a tick wakes the instance and is
459 // held in flight for its duration, but must not reset the idle
460 // clock. Reports false for a route it does not manage (not an
461 // instance, or not hibernate-flagged); that route is proxied
462 // straight, the same call shape the edge itself uses.
463 if d.svc == nil || !d.svc.ServeScheduled(w, r, rt, proxy) {
464 proxy.ServeHTTP(w, r)
465 }
466 })
467 status, err := deliverVia(ctx, h, rt, req)
468 if admissionErr != nil {
469 return 0, admissionErr
470 }
471 if err != nil {
472 // Anything that did not come back as an HTTP status — a wake the
473 // activator refused, a delivery that ran past its deadline — retires
474 // this socket's pool; the next tick dials fresh. Not the case a
475 // reader expects here: a socket that will not dial is ReverseProxy's
476 // own 502 with no error, and it has nothing pooled to retire anyway.
477 // Retired ROUTES are reclaimed by RetainSockets, not from here. A
478 // generation's pool is retired by the activator's hook instead.
479 if !gen {
480 d.dropProxy(rt.Socket)
481 }
482 }
483 if err != nil && ctx.Err() == nil {
484 // Redacted for the same reason as the token error: this string is
485 // the row's last_status. The context's own error is left alone —
486 // the runner matches it with errors.Is to tell a timeout apart.
487 err = errors.New(activator.RedactPaths(err.Error()))
488 }
489 return status, err
490}
491
492// deliverVia is the pure core: run the handler against a recorder and turn
493// what it wrote into the runner's (status, error) pair.
494//
495// The mapping is the whole point. The activator answers its own refusals with
496// a 503 whose body names the instance ("instance is waking", "instance failed
497// to wake", "instance is waking elsewhere"), which is indistinguishable at the
498// status line from an app that answered 503 itself. Both retry — but the
499// member-facing status must say WHICH, so a wake refusal comes back as an
500// error (classified "503: instance is waking…") and an app's own 503 comes
501// back as the status 503. Anything else is returned as the app said it.
502func deliverVia(ctx context.Context, h http.Handler, rt registry.Route, req *http.Request) (int, error) {
503 rec := &statusRecorder{}
504 h.ServeHTTP(rec, req)
505 // Checked AFTER the handler returns, and before the status: a tick that
506 // ran past its deadline gets a 502 written under it by the proxy on the
507 // way out, and reporting that 502 would retry a job that may well have
508 // completed. The runner maps DeadlineExceeded to ErrTimeout, the one
509 // outcome that does not retry.
510 if err := ctx.Err(); err != nil {
511 return 0, err
512 }
513 line := rec.firstLine()
514 if rec.status == http.StatusServiceUnavailable && strings.HasPrefix(line, "instance") {
515 return 0, errors.New(line)
516 }
517 if rec.status == 0 {
518 // The handler returned without writing anything and the context is
519 // fine — nothing an instance can legitimately do (even a failed dial
520 // is a 502). Reporting status 0 would classify as "app refused" and
521 // never retry; this says what actually happened and retries.
522 return 0, errors.New("no response from " + rt.Host)
523 }
524 return rec.status, nil
525}
526
527// firstLineMax bounds what the recorder keeps of a body. A tick's response
528// body is never delivered anywhere — only the first line of a wake refusal is
529// ever read — so a chatty app cannot make this leg hold its output in memory.
530const firstLineMax = 256
531
532// statusRecorder is the ResponseWriter the activator and the proxy write
533// through: it keeps the status and the first line of the body and discards
534// everything else. Not httptest.ResponseRecorder — that one buffers the whole
535// body, and this runs in the agent on every tick.
536type statusRecorder struct {
537 header http.Header
538 status int
539 line []byte
540 lineEnd bool // newline seen or cap reached — later writes are discarded
541}
542
543func (r *statusRecorder) Header() http.Header {
544 if r.header == nil {
545 r.header = http.Header{}
546 }
547 return r.header
548}
549
550func (r *statusRecorder) WriteHeader(code int) {
551 if r.status == 0 {
552 r.status = code
553 }
554}
555
556func (r *statusRecorder) Write(b []byte) (int, error) {
557 r.WriteHeader(http.StatusOK) // implicit 200, as on a real writer
558 if !r.lineEnd {
559 if i := bytes.IndexByte(b, '\n'); i >= 0 {
560 r.line = append(r.line, b[:i]...)
561 r.lineEnd = true
562 } else {
563 r.line = append(r.line, b...)
564 }
565 if len(r.line) >= firstLineMax {
566 r.line = r.line[:firstLineMax]
567 r.lineEnd = true
568 }
569 }
570 return len(b), nil
571}
572
573// firstLine is the body's first line, trimmed — what the wake-refusal test
574// above matches on.
575func (r *statusRecorder) firstLine() string { return strings.TrimSpace(string(r.line)) }
576