carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package status
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "log"
8 "time"
9
10 "github.com/carlosframework/platform/internal/registry"
11 "github.com/carlosframework/platform/internal/release"
12)
13
14// FleetApp is one opted-in app's public row — name, count, health, and the
15// release it's running. Nothing here exists for an app that hasn't set
16// StatusNamed; see BuildFleet's apps grouping.
17type FleetApp struct {
18 Name string `json:"name"`
19 Instances int `json:"instances"`
20 Healthy bool `json:"healthy"`
21 Channel string `json:"channel"`
22 Version string `json:"version"` // short sha as stored on the route
23}
24
25// FleetBox is a Box's public row inside the merged feed: everything a Box
26// carries except its own Replication (which only ever votes into the
27// fleet-wide Replication, never appears per-box) and its own Capacity
28// (same — it only ever votes into Fleet.Capacity), plus Stale, which only
29// exists at merge time since a lone Box has no "how long ago" to compare
30// against.
31type FleetBox struct {
32 Label string `json:"label"`
33 Region string `json:"region,omitempty"`
34 Load *string `json:"load"`
35 Stale bool `json:"stale"`
36 Routes RouteCounts `json:"routes"`
37 // AdoptionRunning DOES appear per-box, unlike Replication and Capacity
38 // above, because it is the one reading whose whole value is knowing WHICH
39 // box has it wrong: "something in the fleet isn't adopting" sends an
40 // operator to ssh into every box in turn, which is barely better than the
41 // silence this field exists to end. It carries nothing about a tenant —
42 // only whether the platform's own timer is armed on an opaquely-labelled
43 // machine.
44 AdoptionRunning *bool `json:"adoption_running"`
45 // ConfigPathRunning appears per-box for exactly the reason
46 // AdoptionRunning does: the value of the reading is knowing WHICH box
47 // lost its config-sync trigger, since "somewhere in the fleet config is
48 // an hour late" is a search, not an answer. Deliberately NOT rolled up
49 // into a fleet-wide field: worst-of aggregates exist here only where the
50 // page shows a tile for them, and this one is a per-box operational
51 // fact, not a headline about the fleet.
52 ConfigPathRunning *bool `json:"config_path_running"`
53 // RestartPathRunning appears per-box for exactly the reason
54 // ConfigPathRunning does, and is not rolled up for the same reason: it is a
55 // per-box operational fact ("this box's restart trigger is not watching"),
56 // not a headline about the fleet.
57 RestartPathRunning *bool `json:"restart_path_running"`
58 // ExpiredCanaries appears per-box for the same reason AdoptionRunning
59 // does: the fleet-wide sum says something is expired somewhere, this
60 // says on which box, without naming which route. Published in the FEED
61 // only for now — the rendered boxes table deliberately has no column
62 // for it yet (2026-08-03 review, MINOR 4: this comment used to imply
63 // the table showed it; it does not, and adding the column is deferred
64 // until an expired canary has actually been hunted via the feed once).
65 ExpiredCanaries *int `json:"expired_canaries"`
66}
67
68// Fleet is the merged feed: status/fleet.json. Every field here is either a
69// fleet-wide aggregate, a band word, or behind an app's own opt-in — see the
70// design spec's "rule for new fields".
71type Fleet struct {
72 GeneratedAt time.Time `json:"generated_at"`
73 Boxes []FleetBox `json:"boxes"`
74 Instances map[string]*int `json:"instances"` // total/static/resident/hibernating/awake/asleep
75 Capacity struct {
76 Level *string `json:"level"`
77 } `json:"capacity"`
78 Apps []FleetApp `json:"apps"`
79 AppsUnlisted int `json:"apps_unlisted"`
80 Services []struct {
81 Name string `json:"name"`
82 Up bool `json:"up"`
83 } `json:"services"`
84 Replication Replication `json:"replication"`
85 AdoptionRunning *bool `json:"adoption_running"`
86 // ExpiredCanaries is the fleet-wide sum of every box's own count — see
87 // sumExpiredCanaries. Null-propagating: one box that couldn't check
88 // nulls the whole aggregate, the same rule sumInstances applies to
89 // Awake/Asleep, because a partial sum would read as more certain than
90 // it is. The status page shows this only when it is a known, positive
91 // count — see assets/index.html.
92 ExpiredCanaries *int `json:"expired_canaries"`
93}
94
95// capacityRank orders the capacity band words worst-last, so "worst of a
96// set of boxes" is just "the box with the highest rank" — built off
97// snapshot.go's capacityWords so the two never drift apart into disagreeing
98// about which word beats which.
99var capacityRank = map[string]int{
100 capacityWords[0]: 0,
101 capacityWords[1]: 1,
102 capacityWords[2]: 2,
103}
104
105// intPtr is the one-line "give me a pointer to this value" a lot of the
106// Instances map construction below needs — Go has no address-of-literal
107// operator, so every *int field needs a local to take the address of
108// without this helper.
109func intPtr(v int) *int { return &v }
110
111// SnapshotPeriod is how often every box republishes its own
112// status/boxes/<label>.json (the agent's status loop tick, 5 minutes +
113// jitter). It lives here rather than at each reader because it is the unit
114// BoxStale measures staleness in: the console's private host detail and
115// `carlos status -live` both have to answer "is this box dark?" without an
116// agent's local const to borrow, and a reader that guessed a different period
117// would call a box unreachable at a different moment than the published
118// boxes table does.
119const SnapshotPeriod = 5 * time.Minute
120
121// staleFactor is how many missed snapshot periods make a box stale. Three
122// consecutive misses: past any single hiccup (a slow S3 write, one skipped
123// tick), and the point at which a box has plainly stopped self-reporting.
124// One constant, one rule — BuildFleet's published FleetBox.Stale and every
125// private reader go through BoxStale below, so nothing can drift into calling
126// the same box fresh on one page and dark on another.
127const staleFactor = 3
128
129// BoxStale is the fleet's one staleness rule: a snapshot older than
130// staleFactor×period means the box stopped reporting. Every caller — the
131// public merge, the console's private host table, the CLI — asks this
132// function, never its own subtraction.
133//
134// Callers that render must treat true as "this box's numbers are history":
135// per the design spec an unreachable host shows how long it has been dark and
136// NOTHING else, never its last happy readings.
137func BoxStale(b Box, now time.Time, period time.Duration) bool {
138 return now.Sub(b.GeneratedAt) > staleFactor*period
139}
140
141// LoadBoxes reads every per-box snapshot in the bucket — the same read
142// BuildFleet does, exported for the private readers (the console's operator
143// fleet page, `carlos status -live`) so there is exactly ONE parser of the
144// status/boxes/ shape and one skip-corrupt rule, rather than a second loader
145// that could disagree about which snapshots count.
146//
147// Unlike the merged feed, what comes back here still carries Box.Metrics: raw
148// numbers that must reach a private surface only (bucket credentials or a
149// session-gated operator page), never fleet.json. See the package comment.
150func LoadBoxes(ctx context.Context, s release.Store) ([]Box, error) {
151 return loadBoxes(ctx, s)
152}
153
154// BuildFleet merges every status/boxes/*.json (stale = GeneratedAt older
155// than staleFactor×period — see BoxStale), folds the local registry's routes
156// into apps/services, and never invents a number a snapshot didn't carry.
157func BuildFleet(ctx context.Context, s release.Store, routes []registry.Route, now time.Time, period time.Duration) (*Fleet, error) {
158 boxes, err := loadBoxes(ctx, s)
159 if err != nil {
160 return nil, err
161 }
162
163 f := &Fleet{GeneratedAt: now}
164
165 // stale tracks each box's staleness in lockstep with boxes — computed
166 // once here and reused by both the per-box Stale field and the
167 // worst-of/replication aggregates below, which must vote only using
168 // fresh boxes.
169 stale := make([]bool, len(boxes))
170 for i, b := range boxes {
171 stale[i] = BoxStale(b, now, period)
172 f.Boxes = append(f.Boxes, FleetBox{
173 Label: b.Label,
174 Region: b.Region,
175 Load: b.Load,
176 Stale: stale[i],
177 Routes: b.Routes,
178 AdoptionRunning: b.AdoptionRunning,
179 ConfigPathRunning: b.ConfigPathRunning,
180 RestartPathRunning: b.RestartPathRunning,
181 ExpiredCanaries: b.ExpiredCanaries,
182 })
183 }
184
185 f.Instances = sumInstances(boxes)
186 f.Capacity.Level = worstCapacity(boxes)
187 f.Replication = worstReplication(boxes, stale)
188 f.AdoptionRunning = worstAdoption(boxes, stale)
189 f.ExpiredCanaries = sumExpiredCanaries(boxes)
190
191 apps, unlisted := buildApps(routes)
192 f.Apps = apps
193 f.AppsUnlisted = unlisted
194 f.Services = buildServices(routes)
195
196 return f, nil
197}
198
199// loadBoxes reads every status/boxes/*.json snapshot it can. A snapshot
200// that fails to Get or fails to unmarshal is logged and skipped — one box's
201// bad write must never take the whole fleet page down, per the plan's
202// "corrupt snapshot = logged skip, never fatal" rule.
203// maxFleetBoxKeys bounds how many objects loadBoxes will ever process from
204// one prefix listing, and maxFleetBoxBodyBytes bounds how large any single
205// one of those objects may be before its body is read. Both exist for the
206// SAME reason: the prefix loadBoxes reads (status/boxes/, and — via
207// release.NewPrefixStore — a customer fleet's own fleets/<name>/status/boxes/)
208// is written to by boxes this operator does not control in the fleet case.
209// A hostile or badly-misbehaving box could park tens of thousands of keys,
210// or one huge body, under its own prefix; without a cap this function would
211// list/fetch/unmarshal all of it on every call, feeding both the operator
212// page (platform prefix) and every fleet's own page (customer prefix) —
213// unbounded memory from data this process does not trust.
214const (
215 maxFleetBoxKeys = 200
216 maxFleetBoxBodyBytes = 1 << 20 // 1MB
217)
218
219func loadBoxes(ctx context.Context, s release.Store) ([]Box, error) {
220 boxes, _, err := loadBoxesReport(ctx, s)
221 return boxes, err
222}
223
224// LoadBoxesComplete is LoadBoxes that also reports whether EVERY listed
225// snapshot was read: complete is false when any object's read failed, any
226// body was skipped as oversize or corrupt, or the listing was capped. The
227// boxes returned are exactly what LoadBoxes would return, and the skips are
228// logged the same way; the only addition is the accounting. It exists for
229// a reader that must fail CLOSED on partial evidence (the console's
230// automatic region placement: a box whose snapshot could not be read has
231// UNKNOWN liveness, which is not the same as stale), where LoadBoxes'
232// "logged skip, never fatal" posture would quietly drop the one box that
233// mattered.
234func LoadBoxesComplete(ctx context.Context, s release.Store) (boxes []Box, complete bool, err error) {
235 return loadBoxesReport(ctx, s)
236}
237
238// statusListingHorizon is how long ago a report's object may last have
239// been written and still be listed as a box (region HA plan 2b). Each box
240// rewrites its own report every SnapshotPeriod, so an object older than a
241// day is a box that has gone: with automatic replacement such reports pile
242// up, one per replaced box, and could fill the key cap below, hiding the
243// boxes that are running. They are dropped from the listing before any
244// read and before the cap, so the cap bounds current reports only. This is
245// an optimisation, never authority: every reader still judges each
246// report's own GeneratedAt as before (BoxStale, the console's
247// VerifyReport), and a day keeps every box an operator would still look
248// for as stale. A store that cannot say when an object was written is
249// listed whole, as before: one that is not a release.ModLister, and one
250// that is but whose ListMod fails. The second is the claim's boundary
251// stores (hostops' mutation boundary and conditional record store, the
252// mail claim gate): they always have a ListMod method and answer it with
253// their own refusal when the store they wrap has none, while their List
254// still reads. Because the horizon is only an optimisation, a failed
255// ListMod never fails the read: List answers, with main's errors.
256const statusListingHorizon = 24 * time.Hour
257
258// listBoxKeys lists status/boxes/, without the reports the horizon drops.
259func listBoxKeys(ctx context.Context, s release.Store) ([]string, error) {
260 ml, ok := s.(release.ModLister)
261 if !ok {
262 return s.List(ctx, "status/boxes/")
263 }
264 mods, err := ml.ListMod(ctx, "status/boxes/")
265 if err != nil {
266 return s.List(ctx, "status/boxes/")
267 }
268 cut := time.Now().Add(-statusListingHorizon)
269 keys := make([]string, 0, len(mods))
270 for _, m := range mods {
271 if m.Mod.IsZero() || !m.Mod.Before(cut) {
272 keys = append(keys, m.Key)
273 }
274 }
275 return keys, nil
276}
277
278func loadBoxesReport(ctx context.Context, s release.Store) (boxes []Box, complete bool, err error) {
279 keys, err := listBoxKeys(ctx, s)
280 if err != nil {
281 return nil, false, err
282 }
283 complete = true
284 if len(keys) > maxFleetBoxKeys {
285 log.Printf("status: fleet: %d keys under status/boxes/, capping at %d", len(keys), maxFleetBoxKeys)
286 keys = keys[:maxFleetBoxKeys]
287 complete = false
288 }
289 for _, key := range keys {
290 body, _, err := s.Get(ctx, key)
291 if err != nil {
292 log.Printf("status: fleet: skipping %s: read failed: %v", key, err)
293 complete = false
294 continue
295 }
296 if len(body) > maxFleetBoxBodyBytes {
297 log.Printf("status: fleet: skipping %s: body %d bytes exceeds %d byte cap", key, len(body), maxFleetBoxBodyBytes)
298 complete = false
299 continue
300 }
301 var b Box
302 if err := json.Unmarshal(body, &b); err != nil {
303 log.Printf("status: fleet: skipping %s: corrupt snapshot: %v", key, err)
304 complete = false
305 continue
306 }
307 boxes = append(boxes, b)
308 }
309 return boxes, complete, nil
310}
311
312// sumInstances adds each RouteCounts component across every box — stale
313// boxes included, since staleness is a "trust this less" signal rendered
314// loudly per-box, not a reason to silently drop a box's last-known counts
315// from the fleet total. Static/Resident/Hibernating are plain ints on Box
316// (always known), so their sums are always known too. Awake/Asleep are
317// *int on Box (null when a box's socket table was unreadable), so per the
318// plan's null-propagation rule, ANY box contributing null nulls the whole
319// summed field — a partial sum would read as more certain than it is.
320func sumInstances(boxes []Box) map[string]*int {
321 var totalStatic, totalResident, totalHibernating, awakeSum, asleepSum int
322 awakeKnown, asleepKnown := true, true
323 for _, b := range boxes {
324 totalStatic += b.Routes.Static
325 totalResident += b.Routes.Resident
326 totalHibernating += b.Routes.Hibernating
327 if b.Routes.Awake == nil {
328 awakeKnown = false
329 } else {
330 awakeSum += *b.Routes.Awake
331 }
332 if b.Routes.Asleep == nil {
333 asleepKnown = false
334 } else {
335 asleepSum += *b.Routes.Asleep
336 }
337 }
338 instances := map[string]*int{
339 "total": intPtr(totalStatic + totalResident + totalHibernating),
340 "static": intPtr(totalStatic),
341 "resident": intPtr(totalResident),
342 "hibernating": intPtr(totalHibernating),
343 }
344 if awakeKnown {
345 instances["awake"] = intPtr(awakeSum)
346 } else {
347 instances["awake"] = nil
348 }
349 if asleepKnown {
350 instances["asleep"] = intPtr(asleepSum)
351 } else {
352 instances["asleep"] = nil
353 }
354 return instances
355}
356
357// sumExpiredCanaries adds each box's own ExpiredCanaries count — stale boxes
358// included, same reasoning as sumInstances: a box's last-known count is a
359// "trust this less" signal rendered loudly per-box (FleetBox.Stale), not a
360// reason to silently drop it from the fleet total. Per the null-propagation
361// rule every *int aggregate here follows, ANY box contributing nil (it
362// couldn't check) nils the whole sum — a partial count would read as more
363// certain than it is. No boxes at all sums to 0, not nil: there is nothing
364// unknown about an empty fleet.
365func sumExpiredCanaries(boxes []Box) *int {
366 sum := 0
367 for _, b := range boxes {
368 if b.ExpiredCanaries == nil {
369 return nil
370 }
371 sum += *b.ExpiredCanaries
372 }
373 return intPtr(sum)
374}
375
376// worstCapacity picks the worst capacity band among ALL boxes, stale ones
377// included: a box that stopped reporting still had a last-known fill level,
378// and dropping it from the vote just because its snapshot aged out would
379// hide a real "getting full" or "at capacity" reading behind the newer,
380// possibly-rosier boxes — exactly the silent-omission failure the plan's
381// privacy rules refuse to allow, just pointed at capacity instead of a
382// hostname. (Contrast worstReplication below, which the plan explicitly
383// scopes to fresh boxes — staleness there means "we don't know", not "we
384// know it was bad".) A box with an unknown (nil) band doesn't vote — nil
385// never invents a word. All-nil input yields nil.
386func worstCapacity(boxes []Box) *string {
387 var worst *string
388 worstRank := -1
389 for _, b := range boxes {
390 if b.Capacity == nil {
391 continue
392 }
393 rank, ok := capacityRank[*b.Capacity]
394 if !ok {
395 continue // defensive: an unrecognized band word doesn't vote
396 }
397 if rank > worstRank {
398 worstRank = rank
399 v := *b.Capacity
400 worst = &v
401 }
402 }
403 return worst
404}
405
406// worstReplication picks the worst replication reading among non-stale
407// boxes: unhealthy beats healthy beats unknown, and ties within a band
408// break toward the larger lag (still the worse reading). Nothing fresh
409// with a known reading yields the zero Replication (both fields nil) —
410// unknown, not a guessed "healthy".
411func worstReplication(boxes []Box, stale []bool) Replication {
412 var worst Replication
413 haveWorst := false
414 for i, b := range boxes {
415 if stale[i] || b.Replication.Healthy == nil {
416 continue
417 }
418 switch {
419 case !haveWorst:
420 worst = b.Replication
421 haveWorst = true
422 case !*b.Replication.Healthy && *worst.Healthy:
423 // This box is unhealthy and the current worst isn't — a real
424 // outage always outranks "just some lag".
425 worst = b.Replication
426 case *b.Replication.Healthy == *worst.Healthy &&
427 b.Replication.LagSecs != nil && worst.LagSecs != nil &&
428 *b.Replication.LagSecs > *worst.LagSecs:
429 // Same health band; the larger lag is the worse reading.
430 worst = b.Replication
431 }
432 }
433 return worst
434}
435
436// worstAdoption folds the boxes' adoption readings into one fleet-wide
437// answer, worst-first: a single box that is KNOWN not to be adopting makes the
438// fleet false, because a real defect must never be averaged away by the
439// healthy boxes around it. Failing that, an unknown box nulls the aggregate —
440// "all the boxes we could ask said yes" is not the same claim as "the fleet is
441// adopting", and this package does not make the stronger claim from the weaker
442// evidence. Only when every voting box is known-true is the fleet true.
443//
444// Stale boxes don't vote at all (same rule as worstReplication): a snapshot
445// that stopped arriving means "we no longer know what that box is doing", and
446// a reading from twenty minutes ago is not evidence about now. That is a real
447// blind spot rather than a solved problem — but staleness is itself rendered
448// loudly per-box, so it is a visible blind spot, which is the whole point of
449// this field.
450// The order of the checks matters and is not the order the boxes happen to
451// arrive in: a known false is decided BEFORE any unknown is allowed to null
452// the result, so one unreachable box can never mask a box that reported a dead
453// timer.
454func worstAdoption(boxes []Box, stale []bool) *bool {
455 anyUnknown, anyTrue := false, false
456 for i, b := range boxes {
457 if stale[i] {
458 continue
459 }
460 switch {
461 case b.AdoptionRunning == nil:
462 anyUnknown = true
463 case !*b.AdoptionRunning:
464 no := false
465 return &no
466 default:
467 anyTrue = true
468 }
469 }
470 if anyUnknown || !anyTrue {
471 return nil
472 }
473 yes := true
474 return &yes
475}
476
477// routeGroup collects one App name's routes in the order they appeared in
478// the input slice — used for both the apps and services groupings below,
479// which differ only in which Kind they filter on and how they reduce a
480// group down to one published row.
481type routeGroup struct {
482 name string
483 routes []registry.Route
484}
485
486// groupRoutesByApp buckets routes matching kind into per-app groups,
487// preserving first-seen order so output is deterministic without needing a
488// separate sort pass.
489//
490// The group key is (ACCOUNT, app), not app: app names are unique within an
491// account, never globally, so two tenants can both run a "blog". Keying on
492// the name alone would merge them — publishing one tenant's outage under the
493// other's name, and letting either tenant's StatusNamed opt-in decide whether
494// the other appears at all. The published NAME stays the app name; the
495// account is only ever the grouping key, never anything the public page
496// reveals.
497func groupRoutesByApp(routes []registry.Route, kind string) []routeGroup {
498 type key struct{ acct, app string }
499 index := map[key]int{}
500 var groups []routeGroup
501 for _, r := range routes {
502 if r.Kind != kind {
503 continue
504 }
505 k := key{release.RouteAccount(r), r.App}
506 i, ok := index[k]
507 if !ok {
508 i = len(groups)
509 index[k] = i
510 groups = append(groups, routeGroup{name: r.App})
511 }
512 groups[i].routes = append(groups[i].routes, r)
513 }
514 return groups
515}
516
517// buildApps groups the registry's "instance" routes by App and publishes
518// only groups where EVERY route opted in via StatusNamed — one un-opted
519// route keeps its whole app dark, never a partial reveal. Everything else
520// folds into the unlisted count so the aggregate is still honest about how
521// many apps exist without naming the ones that didn't ask to be named.
522func buildApps(routes []registry.Route) ([]FleetApp, int) {
523 groups := groupRoutesByApp(routes, "instance")
524
525 var apps []FleetApp
526 for _, g := range groups {
527 allNamed := true
528 healthy := true
529 for _, r := range g.routes {
530 if !r.StatusNamed {
531 allNamed = false
532 }
533 if r.Status != "ready" {
534 healthy = false
535 }
536 }
537 if !allNamed {
538 continue
539 }
540 first := g.routes[0]
541 for _, r := range g.routes[1:] {
542 if r.Channel != first.Channel || r.Version != first.Version {
543 log.Printf("status: fleet: app %q routes disagree on channel/version (using first route's %q/%q)",
544 g.name, first.Channel, first.Version)
545 break
546 }
547 }
548 apps = append(apps, FleetApp{
549 Name: g.name,
550 Instances: len(g.routes),
551 Healthy: healthy,
552 Channel: first.Channel,
553 Version: first.Version,
554 })
555 }
556 return apps, len(groups) - len(apps)
557}
558
559// buildServices groups the registry's "service" routes by App and reports
560// each as up only if every route in the group is ready. Service names are
561// platform-reserved (svc/*), not tenant hostnames, so — unlike apps —
562// there's no opt-in gate here.
563func buildServices(routes []registry.Route) []struct {
564 Name string `json:"name"`
565 Up bool `json:"up"`
566} {
567 groups := groupRoutesByApp(routes, "service")
568
569 var services []struct {
570 Name string `json:"name"`
571 Up bool `json:"up"`
572 }
573 for _, g := range groups {
574 up := true
575 for _, r := range g.routes {
576 if r.Status != "ready" {
577 up = false
578 }
579 }
580 services = append(services, struct {
581 Name string `json:"name"`
582 Up bool `json:"up"`
583 }{Name: g.name, Up: up})
584 }
585 return services
586}
587
588// pageAssets is the fixed publish order for assetsFS's contents: css before
589// html, both before fleet.json (see PublishFleet). It is NOT fs.ReadDir
590// order — "index.html" sorts alphabetically before "status.css", which
591// would flip the required css-before-html order — so the sequence is
592// spelled out explicitly rather than derived from a directory walk.
593var pageAssets = []string{"status.css", "index.html"}
594
595// PublishFleet writes the whole viewer: the embedded page assets first,
596// then fleet.json last. Order mirrors Eleven's upload-styles-first rule —
597// fleet.json is the object readers poll most often, so a reader who fetches
598// it the instant after a publish must never be able to load a page that
599// references a stylesheet or markup that hasn't landed yet. Every object
600// goes through putOverwrite: PublishFleet republishes all three keys on
601// every pass (every 5 minutes, per the plan), so a create-only Put would
602// fail from the second pass onward.
603func PublishFleet(ctx context.Context, s release.Store, f *Fleet) error {
604 for _, name := range pageAssets {
605 body, err := assetsFS.ReadFile("assets/" + name)
606 if err != nil {
607 return err
608 }
609 if err := putOverwrite(ctx, s, "status/"+name, body); err != nil {
610 return err
611 }
612 }
613
614 body, err := json.MarshalIndent(f, "", " ")
615 if err != nil {
616 return err
617 }
618 return putOverwrite(ctx, s, "status/fleet.json", body)
619}
620
621// putOverwrite lands body at key with last-writer-wins semantics — the same
622// read-etag-then-Put dance WriteBox uses, for the same reason: every
623// status/ object PublishFleet writes gets republished on the same key each
624// pass (fleet.json every 5 minutes, alongside the boxes), and
625// release.Store's Put is CAS-only, so a bare create-only Put would fail
626// every tick after the first with ErrCASConflict. One retry on a lost race;
627// a second conflict is surfaced rather than retried again, since the status
628// lease is supposed to make PublishFleet the only writer touching these
629// keys — two losses in a row past the lease means something else is wrong.
630func putOverwrite(ctx context.Context, s release.Store, key string, body []byte) error {
631 etag, err := currentEtag(ctx, s, key)
632 if err != nil {
633 return err
634 }
635 if _, err := s.Put(ctx, key, body, etag); err != nil {
636 if !errors.Is(err, release.ErrCASConflict) {
637 return err
638 }
639 etag, err = currentEtag(ctx, s, key)
640 if err != nil {
641 return err
642 }
643 if _, err := s.Put(ctx, key, body, etag); err != nil {
644 return err
645 }
646 }
647 return nil
648}
649