carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3// The operator doors for platform GeoIP (geoip spec §4.2, §5.5):
4//
5// PUT /api/cli/system/geoip stage an upload, answer 202 + a job id
6// GET /api/cli/system/geoip the pointer, every box, the rollout verdict
7// DELETE /api/cli/system/geoip off: delete the pointer, sweep the blobs
8//
9// All three open with apiSystemRoller (an ops owner or CARLOS_SYSTEM_OPERATORS,
10// a person, never a token) and are registered with the zero svcrole.Set.
11
12import (
13 "context"
14 "errors"
15 "fmt"
16 "io"
17 "log"
18 "net/http"
19 "os"
20 "sort"
21 "strings"
22 "time"
23
24 "github.com/carlosframework/platform/internal/geoip"
25 "github.com/carlosframework/platform/internal/release"
26 "github.com/carlosframework/platform/internal/status"
27)
28
29// geoipCommitDelay is a test seam: a job sleeps this long before Commit, so
30// a test can prove the 202 arrives before verification finishes.
31var geoipCommitDelay time.Duration
32
33// geoipRetiredAfter is how old a heartbeat may be, for a box with no edges
34// record and no fleet registration, before it is left out as retired.
35const geoipRetiredAfter = 7 * 24 * time.Hour
36
37func (s *Server) handleAPISystemGeoipPut(w http.ResponseWriter, r *http.Request) {
38 addr, _, ok := s.apiSystemRoller(w, r)
39 if !ok {
40 return
41 }
42 if !s.systemBucket(w) {
43 return
44 }
45 // The server-wide ReadTimeout is 5 minutes (cmd/console/main.go); a
46 // 1 GiB upload gets its own deadline, the way the ship door does it.
47 rc := http.NewResponseController(w)
48 _ = rc.SetReadDeadline(time.Now().Add(15 * time.Minute))
49 defer rc.SetReadDeadline(time.Time{})
50 body := http.MaxBytesReader(w, r.Body, geoip.MaxBlobBytes)
51 // Stage takes the one publish slot BEFORE reading any of the body
52 // (codex R5-1): a second upload is refused here, unread.
53 st, err := s.geoip.Stage(r.Context(), body)
54 var mbe *http.MaxBytesError
55 switch {
56 case errors.Is(err, errGeoipBusy):
57 jsonError(w, http.StatusConflict, "another geoip publish is in progress. try again when it finishes")
58 return
59 case errors.As(err, &mbe):
60 jsonError(w, http.StatusRequestEntityTooLarge, "the database is larger than 1 GiB")
61 return
62 case errors.Is(err, errGeoipRefused):
63 jsonError(w, http.StatusUnprocessableEntity, err.Error())
64 return
65 case err != nil:
66 log.Printf("console: geoip upload: %v", err)
67 jsonError(w, http.StatusBadGateway, "could not receive the database")
68 return
69 }
70 opts := geoipPublishOpts{Origin: "manual", Force: r.URL.Query().Get("force") == "1"}
71 id, job := s.newSystemJob()
72 job.step("received %d bytes (sha256 %s)", st.size, st.sha[:8])
73 // The job owns the slot and the staged directory from here; Commit
74 // releases both on every path. A fresh context: the request's dies with
75 // the 202, and a backhauled request has only 30s after its body to see
76 // headers (codex R4-2).
77 go func() {
78 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
79 defer cancel()
80 if geoipCommitDelay > 0 {
81 time.Sleep(geoipCommitDelay)
82 }
83 job.step("verifying the database")
84 ptr, err := s.geoip.Commit(ctx, st, opts)
85 if err == nil {
86 job.setResult(ptr)
87 job.step("published %s: %s, built %s", ptr.SHA256[:8], ptr.DatabaseType, ptr.Build().Format("2006-01-02"))
88 s.auditSystem(addr, "geoip publish", ptr.SHA256[:8]+" "+ptr.DatabaseType)
89 } else if errors.Is(err, errGeoipRefused) {
90 log.Printf("console: geoip publish refused: %v", err)
91 } else {
92 log.Printf("console: geoip publish: %v", err)
93 }
94 job.finish(err)
95 }()
96 writeJSON(w, http.StatusAccepted, map[string]string{"job": id})
97}
98
99func (s *Server) handleAPISystemGeoipDelete(w http.ResponseWriter, r *http.Request) {
100 addr, _, ok := s.apiSystemRoller(w, r)
101 if !ok {
102 return
103 }
104 if !s.systemBucket(w) {
105 return
106 }
107 if err := s.geoip.Off(r.Context()); errors.Is(err, errGeoipBusy) {
108 jsonError(w, http.StatusConflict, "a geoip publish is in progress. run off again when it finishes")
109 return
110 } else if err != nil {
111 log.Printf("console: geoip off: %v", err)
112 jsonError(w, http.StatusBadGateway, "could not turn geoip off")
113 return
114 }
115 s.auditSystem(addr, "geoip off", "")
116 writeJSON(w, http.StatusOK, map[string]any{"off": true, "feed_set": os.Getenv("CONSOLE_GEOIP_FEED") != ""})
117}
118
119// geoipBoxRow is one box in the status answer.
120type geoipBoxRow struct {
121 Label string `json:"label"`
122 Fleet string `json:"fleet,omitempty"`
123 State string `json:"state"` // ok | stale | old binary | no heartbeat | other sha | tunnel | none
124 LastSeen *time.Time `json:"last_seen,omitempty"`
125 GeoIP *status.GeoIPState `json:"geoip,omitempty"`
126 Expected bool `json:"expected"`
127 Blocking string `json:"blocking,omitempty"`
128}
129
130func (s *Server) handleAPISystemGeoipGet(w http.ResponseWriter, r *http.Request) {
131 if _, _, ok := s.apiSystemRoller(w, r); !ok {
132 return
133 }
134 if !s.systemBucket(w) {
135 return
136 }
137 ctx := r.Context()
138 cur, _, present, perr := s.geoip.Current(ctx)
139 out := map[string]any{"pointer": nil}
140 want := geoipWant{state: geoipOff}
141 switch {
142 case perr != nil:
143 out["pointer_error"] = "the published pointer is unreadable"
144 want.state = geoipUnknown
145 case present && (cur.Expired || geoip.Expired(cur.DatabaseType, cur.Build(), time.Now())):
146 // Expired, marked or not yet marked by the daily job: every edge
147 // drops it by the clock, so every mirror must be empty (codex R10
148 // on R9-1).
149 want.state = geoipOff
150 case present:
151 want = geoipWant{state: geoipLive, sha: cur.SHA256}
152 }
153 if perr == nil && present {
154 p := map[string]any{"sha256": cur.SHA256, "database_type": cur.DatabaseType, "build": cur.Build(),
155 "published": cur.Published, "origin": cur.Origin, "edition": cur.Edition, "size": cur.Size, "expired": cur.Expired}
156 if at, ok := geoip.ExpiresAt(cur.DatabaseType, cur.Build()); ok {
157 p["expires_at"] = at
158 }
159 if a, ok := geoip.AttributionFor(cur.DatabaseType); ok {
160 p["attribution"] = a.Text
161 }
162 out["pointer"] = p
163 }
164 inv, err := s.geoipInventory(ctx)
165 if err != nil {
166 log.Printf("console: geoip status inventory: %v", err)
167 jsonError(w, http.StatusBadGateway, "box heartbeats unreadable")
168 return
169 }
170 ready, rows, blocking := geoipVerdict(want, inv, time.Now())
171 out["boxes"], out["ready"], out["blocking"] = rows, ready, blocking
172 out["feed_set"] = os.Getenv("CONSOLE_GEOIP_FEED") != ""
173 if raw, _, err := release.OpenObject(ctx, s.cfg.Releases, geoip.FeedKey); err == nil {
174 b, rerr := io.ReadAll(io.LimitReader(raw, geoip.MaxPointerBytes+1))
175 raw.Close()
176 if rerr == nil && len(b) <= geoip.MaxPointerBytes {
177 if fs, err := geoip.ParseFeedStatus(b); err == nil {
178 out["feed"] = map[string]any{"name": fs.Feed, "last_check": fs.LastCheck, "last_success": fs.LastSuccess,
179 "edition": fs.LastEdition, "failures": fs.ConsecutiveFailures, "not_found": fs.ConsecutiveNotFound,
180 "last_failure": fs.LastFailure, "failing": fs.Failing()}
181 }
182 }
183 }
184 writeJSON(w, http.StatusOK, out)
185}
186
187// geoipBoxKey is a box's identity: a label is unique only within its
188// namespace, the platform ("") or one fleet (codex R8-1). Two fleets may
189// both have node-1, and a fleet label may equal a platform label.
190type geoipBoxKey struct{ Fleet, Label string }
191
192func (k geoipBoxKey) String() string {
193 if k.Fleet == "" {
194 return k.Label
195 }
196 return k.Fleet + "/" + k.Label
197}
198
199// geoipInventoryRead is everything the verdict reconciles (spec §5.5).
200// expectedComplete is false when the list of expected boxes itself could
201// not be read (a fleet registry or fleet listing failed): that always
202// blocks. heartbeatsComplete is false when some heartbeat record could not
203// be read: informational only, because an expected box whose record was
204// unreadable is missing from boxes and blocks as "no heartbeat" on its own
205// (codex R9 on R1-16).
206//
207// fleetsListed is true only once the fleet registry was opened and its
208// fleets listed; fleetsUnread says why not, for the operator log. The GeoIP
209// verdict never reads it: it treats an unset or absent registry as a
210// platform-only deployment, as it always has. The update settings gate
211// (updatesEnforced) does read it, because a fleet list it never saw is not
212// evidence that there are no fleet boxes to wait for (spec §4.4).
213type geoipInventoryRead struct {
214 expected map[geoipBoxKey]bool
215 boxes map[geoipBoxKey]status.Box
216 expectedComplete bool
217 heartbeatsComplete bool
218 fleetsListed bool
219 fleetsUnread string
220}
221
222// geoipWantState is what the published pointer asks of every mirror.
223type geoipWantState int
224
225const (
226 geoipUnknown geoipWantState = iota // the pointer could not be read: never ready
227 geoipOff // no pointer, or an expired one: mirrors must hold nothing
228 geoipLive // mirrors must hold sha
229)
230
231type geoipWant struct {
232 state geoipWantState
233 sha string
234}
235
236// geoipInventory reads the expected boxes (platform edges records, active
237// fleet registrations) and every heartbeat prefix with LoadBoxesComplete,
238// never LoadBoxes, which skips unreadable records silently.
239func (s *Server) geoipInventory(ctx context.Context) (geoipInventoryRead, error) {
240 inv := geoipInventoryRead{expected: map[geoipBoxKey]bool{}, boxes: map[geoipBoxKey]status.Box{}, expectedComplete: true, heartbeatsComplete: true}
241 keys, err := s.cfg.Releases.List(ctx, "control/edges/")
242 if err != nil {
243 return inv, fmt.Errorf("edges records: %w", err)
244 }
245 for _, k := range keys {
246 label := strings.TrimSuffix(strings.TrimPrefix(k, "control/edges/"), ".json")
247 if label != "" && !strings.Contains(label, "/") && strings.HasSuffix(k, ".json") {
248 inv.expected[geoipBoxKey{Label: label}] = true
249 }
250 }
251 add := func(store release.Store, fleet string) error {
252 boxes, complete, err := status.LoadBoxesComplete(ctx, store)
253 if err != nil {
254 return err
255 }
256 if !complete {
257 inv.heartbeatsComplete = false
258 }
259 for _, b := range boxes {
260 inv.boxes[geoipBoxKey{fleet, b.Label}] = b
261 }
262 return nil
263 }
264 if err := add(s.cfg.Releases, ""); err != nil {
265 return inv, err
266 }
267 if s.cfg.Registry == "" {
268 inv.fleetsUnread = "no registry path is configured"
269 return inv, nil
270 }
271 reg, err := s.openPassRegistry()
272 switch {
273 case errors.Is(err, os.ErrNotExist):
274 inv.fleetsUnread = err.Error()
275 return inv, nil
276 case err != nil:
277 inv.expectedComplete = false
278 inv.fleetsUnread = err.Error()
279 return inv, nil
280 }
281 fleets, err := reg.ListFleets()
282 if err != nil {
283 inv.expectedComplete = false
284 inv.fleetsUnread = "listing fleets: " + err.Error()
285 return inv, nil
286 }
287 inv.fleetsListed = true
288 for _, f := range fleets {
289 fbs, err := reg.ListFleetBoxes(f.Name)
290 if err != nil {
291 inv.expectedComplete = false
292 continue
293 }
294 for _, fb := range fbs {
295 if fb.Status == "active" {
296 inv.expected[geoipBoxKey{f.Name, fb.Label}] = true
297 }
298 }
299 if err := add(release.NewPrefixStore(s.cfg.Releases, "fleets/"+f.Name+"/"), f.Name); err != nil {
300 // That fleet's heartbeats are unknown: its expected boxes are
301 // missing from boxes and block as "no heartbeat".
302 inv.heartbeatsComplete = false
303 }
304 }
305 return inv, nil
306}
307
308// geoipVerdict is spec §5.5's reconciliation. ready only when every listed
309// box is fresh, readable and GeoIP-aware, every mirroring box holds the
310// live pointer's sha, and every read was complete.
311func geoipVerdict(want geoipWant, inv geoipInventoryRead, now time.Time) (bool, []geoipBoxRow, []string) {
312 var rows []geoipBoxRow
313 var blocking []string
314 keys := map[geoipBoxKey]bool{}
315 for k := range inv.expected {
316 keys[k] = true
317 }
318 for k, b := range inv.boxes {
319 if inv.expected[k] || now.Sub(b.GeneratedAt) <= geoipRetiredAfter {
320 keys[k] = true
321 }
322 }
323 sorted := make([]geoipBoxKey, 0, len(keys))
324 for k := range keys {
325 sorted = append(sorted, k)
326 }
327 sort.Slice(sorted, func(i, j int) bool { return sorted[i].String() < sorted[j].String() })
328 for _, k := range sorted {
329 name, expected := k.String(), inv.expected[k]
330 row := geoipBoxRow{Label: k.Label, Fleet: k.Fleet, Expected: expected}
331 b, seen := inv.boxes[k]
332 retire := ". if this box is retired, delete its edges record (control/edges/" + k.Label + ".json)"
333 if k.Fleet != "" {
334 retire = ". if this box is retired, detach it from fleet " + k.Fleet
335 }
336 switch {
337 case !seen:
338 row.State = "no heartbeat"
339 row.Blocking = name + ": no heartbeat" + retire
340 case status.BoxStale(b, now, status.SnapshotPeriod):
341 t := b.GeneratedAt
342 row.LastSeen = &t
343 row.State = "stale"
344 row.Blocking = name + ": no heartbeat since " + t.UTC().Format(time.RFC3339) + retire
345 case b.Metrics == nil || b.Metrics.GeoIP == nil:
346 t := b.GeneratedAt
347 row.LastSeen = &t
348 row.State = "old binary"
349 row.Blocking = name + ": runs a binary that does not strip forged geo headers"
350 default:
351 t := b.GeneratedAt
352 row.LastSeen, row.GeoIP = &t, b.Metrics.GeoIP
353 switch g := b.Metrics.GeoIP; {
354 case g.Mode == "tunnel", g.Mode == "none":
355 row.State = g.Mode
356 case want.state == geoipLive && g.SHA256 != want.sha:
357 row.State = "other sha"
358 row.Blocking = name + ": has " + short8(g.SHA256) + ", not the published " + short8(want.sha)
359 case want.state == geoipOff && g.SHA256 != "":
360 // Off or expired: a mirror still serving a generation has
361 // not caught up (codex R9-1).
362 row.State = "still loaded"
363 row.Blocking = name + ": still has " + short8(g.SHA256) + " loaded"
364 default:
365 row.State = "ok"
366 }
367 }
368 // Only an EXPECTED box (an edges record, an active fleet
369 // registration) can hold the verdict up: deleting a retired box's
370 // edges record unblocks at once, though its last heartbeat stays
371 // listed for information (codex R8 on R1-16).
372 if !expected {
373 row.Blocking = ""
374 }
375 if row.Blocking != "" {
376 blocking = append(blocking, row.Blocking)
377 }
378 rows = append(rows, row)
379 }
380 if want.state == geoipUnknown {
381 blocking = append(blocking, "database: the published pointer could not be read")
382 }
383 if !inv.expectedComplete {
384 blocking = append(blocking, "boxes: the list of fleet boxes could not be read")
385 }
386 if rows == nil {
387 rows = []geoipBoxRow{}
388 }
389 if blocking == nil {
390 blocking = []string{}
391 }
392 return len(blocking) == 0, rows, blocking
393}
394
395func short8(sha string) string {
396 if sha == "" {
397 return "nothing"
398 }
399 if len(sha) > 8 {
400 return sha[:8]
401 }
402 return sha
403}
404