carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package schedule
2
3import (
4 "crypto/sha256"
5 "crypto/subtle"
6 "encoding/json"
7 "errors"
8 "net/http"
9 "os"
10 "time"
11
12 "github.com/carlosframework/platform/internal/registry"
13)
14
15// TimerStore is narrowed to the five methods Mount actually calls, not the
16// whole *registry.Registry: *registry.Registry satisfies it directly
17// (internal/host/agent.go), and internal/activator — the other side of the
18// package boundary this crosses (Backend.Timers) — stubs it with an
19// in-memory fake so its wiring tests never touch a real database.
20type TimerStore interface {
21 UpsertTimer(registry.Timer) error
22 DeleteTimer(host, name string) error
23 GetTimer(host, name string) (*registry.Timer, error)
24 TimersFor(host string) ([]registry.Timer, error)
25 CountTimers(host, kind string) (int, error)
26}
27
28// RetiredWriteMessage is the 409 body a retiring generation's schedule
29// writes get once the switch has happened (zero-downtime spec §7.10). App
30// developers read it in their logs; it is copy-reviewed.
31const RetiredWriteMessage = "this process has been replaced; the new process owns the schedule"
32
33// WriteFence serializes one generation's schedule writes against the
34// switch that retires it (zero-downtime spec §7.10, final review F8). It
35// runs write, the final retirement decision and the mutation together,
36// unless the generation has been retired, and reports whether it ran.
37// While write runs, the switch that would retire this generation waits;
38// once it has retired it, every later call answers false, including one
39// for a request that began before the switch. A request's body is parsed
40// and validated before the fence is asked, so a slow client never holds
41// the switch.
42type WriteFence func(write func()) bool
43
44// Mount is MountGated with no gate.
45func Mount(mux *http.ServeMux, host string, store TimerStore, token func() (string, error), now func() time.Time) {
46 MountGated(mux, host, store, token, now, nil)
47}
48
49// MountGated adds the once-timer verbs to an instance's control socket (spec
50// §7.2). host is fixed here and never read from a request — the
51// confused-deputy guarantee edge.ListenControl gives — and every verb needs
52// the host's own CARLOS_ADMIN_TOKEN as a bearer. Be precise about what that
53// bearer buys (internal/edge/purge.go has the long form): it closes the
54// channel against OTHER UNIX users on the box and against any
55// unauthenticated dialer — reaching the socket is no longer enough. It does
56// NOT close the same-uid sibling gap: every exec child shares the agent's
57// uid, the token file is 0600 owner-only, so a sibling can read another
58// host's token and present it here. That residual is bounded — a sibling
59// with host B's token could already dial B's own socket with it; these
60// verbs add reach (a platform wake plus a POST at a chosen time and path),
61// not privilege — and per-instance uids are the real fix.
62//
63// fence gates the two writing verbs: every store access a PUT or DELETE
64// makes runs inside it, and once it refuses, the verb answers 409 and
65// changes nothing. A generation the activator has switched away from must
66// not rewrite timers the successor now owns, which is what its
67// reassert-at-shutdown would do (zero-downtime spec §7.10). Reads stay
68// open, the bearer is checked first, and the request is validated before
69// the fence is asked. A nil fence is Mount exactly.
70func MountGated(mux *http.ServeMux, host string, store TimerStore, token func() (string, error), now func() time.Time, fence WriteFence) {
71 // fenced runs write inside the fence, and reports false (the caller
72 // answers 409) when the generation is retired.
73 fenced := func(write func()) bool {
74 if fence == nil {
75 write()
76 return true
77 }
78 return fence(write)
79 }
80 authed := func(fn http.HandlerFunc) http.HandlerFunc {
81 return func(w http.ResponseWriter, r *http.Request) {
82 want, err := token()
83 if err != nil || want == "" {
84 http.Error(w, "unauthorized", http.StatusUnauthorized)
85 return
86 }
87 got := sha256.Sum256([]byte(r.Header.Get("Authorization")))
88 exp := sha256.Sum256([]byte("Bearer " + want))
89 if subtle.ConstantTimeCompare(got[:], exp[:]) != 1 {
90 http.Error(w, "unauthorized", http.StatusUnauthorized)
91 return
92 }
93 fn(w, r)
94 }
95 }
96
97 mux.HandleFunc("GET /schedule-capabilities", authed(func(w http.ResponseWriter, r *http.Request) {
98 w.Header().Set("Content-Type", "application/json")
99 json.NewEncoder(w).Encode(struct {
100 RegistrationFence bool `json:"registration_fence"`
101 }{RegistrationFence: true})
102 }))
103
104 mux.HandleFunc("PUT /schedule/{name}", authed(func(w http.ResponseWriter, r *http.Request) {
105 name := r.PathValue("name")
106 if !ValidName(name) {
107 http.Error(w, "name: want [a-z0-9][a-z0-9-]{0,31}", http.StatusBadRequest)
108 return
109 }
110 var body struct {
111 At string `json:"at"`
112 Path string `json:"path"`
113 }
114 dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
115 if err := dec.Decode(&body); err != nil {
116 http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
117 return
118 }
119 // Decode stops at the first JSON value and silently ignores
120 // whatever follows it; a client that appended a second object (or
121 // stray bytes) meant something by that, so refuse rather than act
122 // on the first value alone.
123 if dec.More() {
124 http.Error(w, "bad request: trailing data after the JSON body", http.StatusBadRequest)
125 return
126 }
127 atT, err := time.Parse(time.RFC3339, body.At)
128 if err != nil {
129 http.Error(w, "at: want RFC3339, e.g. 2026-09-01T08:00:00Z", http.StatusBadRequest)
130 return
131 }
132 if atT.After(now().Add(MaxOnceAhead)) {
133 http.Error(w, "at: more than 400 days ahead", http.StatusBadRequest)
134 return
135 }
136 if err := ValidPath(body.Path); err != nil {
137 http.Error(w, err.Error(), http.StatusBadRequest)
138 return
139 }
140 // The store's checks and the write run inside the fence, so the
141 // retirement decision and the mutation are one step against the
142 // switch. The response is written after it.
143 var (
144 fail string
145 code int
146 row *registry.Timer
147 )
148 ran := fenced(func() {
149 existing, err := store.GetTimer(host, name)
150 switch {
151 case err == nil && existing.Kind != KindOnce:
152 fail, code = "name is a declared schedule", http.StatusConflict
153 return
154 case err != nil && !errors.Is(err, os.ErrNotExist):
155 fail, code = "registry error", http.StatusInternalServerError
156 return
157 case err != nil:
158 n, cerr := store.CountTimers(host, KindOnce)
159 if cerr != nil {
160 fail, code = "registry error", http.StatusInternalServerError
161 return
162 }
163 if n >= MaxOncePerHost {
164 fail, code = "too many one-shot timers (limit 1000) — delete finished ones or use a schedule", http.StatusTooManyRequests
165 return
166 }
167 }
168 sp := Spec{Kind: KindOnce, At: atT.UTC()}
169 next := atT.Unix()
170 if next <= 0 {
171 next = max(int64(1), now().Unix())
172 }
173 t := registry.Timer{Host: host, Name: name, Kind: KindOnce, Spec: sp.String(), Path: body.Path, NextAt: next, DueAt: atT.Unix(), CreatedAt: now().Unix()}
174 if err := store.UpsertTimer(t); err != nil {
175 fail, code = "registry error", http.StatusInternalServerError
176 return
177 }
178 if row, err = store.GetTimer(host, name); err != nil {
179 fail, code = "registry error", http.StatusInternalServerError
180 }
181 })
182 switch {
183 case !ran:
184 http.Error(w, RetiredWriteMessage, http.StatusConflict)
185 case fail != "":
186 http.Error(w, fail, code)
187 default:
188 writeTimer(w, *row, now())
189 }
190 }))
191
192 mux.HandleFunc("DELETE /schedule/{name}", authed(func(w http.ResponseWriter, r *http.Request) {
193 name := r.PathValue("name")
194 if !ValidName(name) {
195 http.Error(w, "bad name", http.StatusBadRequest)
196 return
197 }
198 var (
199 fail string
200 code int
201 )
202 ran := fenced(func() {
203 switch row, err := store.GetTimer(host, name); {
204 case err == nil && row.Kind != KindOnce:
205 fail, code = "name is a declared schedule — remove it with carlos schedule rm", http.StatusConflict
206 return
207 case err != nil && !errors.Is(err, os.ErrNotExist):
208 fail, code = "registry error", http.StatusInternalServerError
209 return
210 }
211 if err := store.DeleteTimer(host, name); err != nil {
212 fail, code = "registry error", http.StatusInternalServerError
213 }
214 })
215 switch {
216 case !ran:
217 http.Error(w, RetiredWriteMessage, http.StatusConflict)
218 case fail != "":
219 http.Error(w, fail, code)
220 default:
221 w.WriteHeader(http.StatusNoContent)
222 }
223 }))
224
225 mux.HandleFunc("GET /schedule", authed(func(w http.ResponseWriter, r *http.Request) {
226 rows, err := store.TimersFor(host)
227 if err != nil {
228 http.Error(w, "registry error", http.StatusInternalServerError)
229 return
230 }
231 out := struct {
232 Timers []TimerStatus `json:"timers"`
233 }{Timers: []TimerStatus{}}
234 for _, row := range rows {
235 // The control socket has no view of the activator's idle window
236 // (it is reached from inside the exec child, not the box's
237 // hibernation wiring), so it makes no holds_awake claim — same
238 // as any box that predates the field.
239 out.Timers = append(out.Timers, toStatus(row, 0, now()))
240 }
241 w.Header().Set("Content-Type", "application/json")
242 json.NewEncoder(w).Encode(out)
243 }))
244}
245
246// writeTimer writes one registry row as its wire TimerStatus (toStatus is
247// converge.go's — one row-to-status mapping for the whole package). idle is
248// 0 here too, for the reason the /schedule GET handler above documents: the
249// control socket cannot see the activator's window.
250func writeTimer(w http.ResponseWriter, r registry.Timer, now time.Time) {
251 w.Header().Set("Content-Type", "application/json")
252 json.NewEncoder(w).Encode(toStatus(r, 0, now))
253}
254