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 "encoding/json"
6 "errors"
7 "log"
8 "os"
9 "path"
10 "time"
11
12 "github.com/carlosframework/platform/internal/registry"
13 "github.com/carlosframework/platform/internal/release"
14)
15
16// FleetBinding is the deployment-bucket object a fleet box publishes to say
17// "as of BoundAt, Box (the box's fleet_boxes.label — resolveFleetIdentity,
18// NEVER status.Label(machineID)) is the box currently serving Host". Fleet
19// boxes are outbound-only — they dial the platform edge over a reverse
20// tunnel and can never write the platform box's registry directly
21// (customer-fleets design spec, "Routing to N boxes: the lease stamp") — so
22// this bucket object is the ONLY channel a fleet box has to tell the
23// platform which of its boxes currently holds a given hostname's lease.
24// ReconcileFleetBindings is the platform-side reader that turns it back
25// into registry.Route.FleetBox.
26type FleetBinding struct {
27 Host string `json:"host"`
28 Fleet string `json:"fleet"`
29 Box string `json:"box"` // the holding box's fleet_boxes.label
30 // BoundAt is unix MILLISECONDS (not seconds) — a wire-shape choice made
31 // free at Task 6's fix round, before any binding object has ever shipped
32 // to production: two wakes racing on different boxes (or a wake racing
33 // the startup pass on the SAME box) can land inside the same second, and
34 // millisecond resolution is what lets bindingBlocked's logical-clock
35 // guard actually distinguish them instead of comparing equal.
36 BoundAt int64 `json:"bound_at"`
37}
38
39// fleetBindingKey is the one place the object's key is spelled, shared by
40// the writer and the reader below: fleets/<fleet>/bindings/<host>.json,
41// ABSOLUTE in the PLATFORM'S deployment bucket — a fleet box has TWO buckets
42// (its own fleet/data bucket the activator leases and restores instances
43// against, and the platform's shared deployment bucket every box, fleet or
44// not, adopts releases from and now publishes/reads bindings in) and this
45// key lives in the latter, never the former.
46//
47// Deliberately not routed through release.NewPrefixStore's
48// CARLOS_STATUS_PREFIX rebasing: that wrapper exists so a fleet box's own
49// status/metrics/alerts heartbeats land under its private
50// "fleets/<name>/" corner instead of the platform's shared paths — but a
51// binding is not a heartbeat, it is a cross-box MESSAGE the platform box
52// must read back, and it already carries its own fleet name (b.Fleet) in
53// the struct. Both WriteFleetBinding and ReconcileFleetBindings take an
54// UNPREFIXED release.Store and compute this absolute key themselves, so
55// there is exactly one key format to keep in sync, and a fleet box's own
56// CARLOS_STATUS_PREFIX (which does apply to its status/metrics stores) has
57// no bearing on where its bindings land.
58func fleetBindingKey(fleet, host string) string {
59 return path.Join("fleets", fleet, "bindings", host+".json")
60}
61
62// bindingBlocked reports whether writing b over the binding currently stored
63// at its key (existing, nil if none) must be skipped. Two independent
64// guards, either one enough to block:
65//
66// - Non-stealing: a non-takeover write (the startup pass,
67// publishResidentFleetBindings — a bare "this box has this route
68// resident" observation, not lease-backed) must never overwrite a
69// binding that already names a DIFFERENT box. Only a wake-success write
70// (takeover=true) may take over — it is backed by having just won the
71// per-host S3 lease, which is the actual arbiter of who may serve a
72// host, so it is allowed to restamp regardless of who was there before.
73// - Logical clock, SAME BOX ONLY: a binding already stored FROM THE SAME
74// BOX with a strictly greater BoundAt must never be overwritten by an
75// older write from that same box — this holds regardless of takeover,
76// and fixes a physical-write-race hazard: two concurrent Puts to the
77// same key from one box (a retried wake publish racing its own
78// predecessor, or any other overlap) must not let whichever one's HTTP
79// round-trip happens to land second win just because it arrived later.
80// Scoped to existing.Box == b.Box deliberately (fix round 2, Opus
81// review): BoundAt is each BOX's OWN wall clock, never a shared one —
82// comparing it across two DIFFERENT boxes compares clock skew, not
83// recency. Before this scoping, a dead box A's stale binding stamped
84// with a clock running ahead of surviving box B's would silently and
85// PERMANENTLY block B's lease-backed takeover write (the startup pass
86// can't help either — it's non-stealing by the guard above), stranding
87// the edge on its sorted-fallback channel instead of the real lease
88// holder for as long as the skew persists. A cross-box binding is
89// already fully governed by the non-stealing guard above (blocked
90// unless takeover=true); the clock only ever needs to arbitrate a BOX
91// against its own past self.
92func bindingBlocked(existing *FleetBinding, b FleetBinding, takeover bool) bool {
93 if existing == nil {
94 return false
95 }
96 if !takeover && existing.Box != b.Box {
97 return true
98 }
99 return existing.Box == b.Box && existing.BoundAt > b.BoundAt
100}
101
102// getExistingBinding reads key's current binding + etag for a replace-only
103// Put, folding "doesn't exist yet" into (nil, "", nil) — a create, with
104// neither guard in bindingBlocked applicable. A corrupt existing object is
105// treated as absent (logged) rather than failing the write outright: the
106// same "never fatal" spirit ReconcileFleetBindings' reader-side skip uses,
107// and it ensures a legitimate new write is never permanently stuck behind a
108// broken object at its own key.
109func getExistingBinding(ctx context.Context, s release.Store, key string) (*FleetBinding, string, error) {
110 body, etag, err := s.Get(ctx, key)
111 if err != nil {
112 if errors.Is(err, os.ErrNotExist) {
113 return nil, "", nil
114 }
115 return nil, "", err
116 }
117 var b FleetBinding
118 if err := json.Unmarshal(body, &b); err != nil {
119 log.Printf("host: fleet binding: existing object at %s is corrupt, treating as absent: %v", key, err)
120 return nil, etag, nil
121 }
122 return &b, etag, nil
123}
124
125// WriteFleetBinding publishes b to the PLATFORM'S deployment bucket,
126// replacing whatever is currently at its key — UNLESS bindingBlocked says
127// the existing binding must win (see its doc comment for the two guards).
128// s must be an UNPREFIXED store handle — see fleetBindingKey's doc comment.
129//
130// takeover distinguishes the two callers: fleetBindingHook (the activator's
131// OnWake, a lease-backed wake success) passes true and may claim a binding
132// away from any other box; publishResidentFleetBindings (the agent-startup
133// pass, a bare "this route is resident here" observation with no lease
134// behind it) passes false and must never steal a binding another box
135// currently holds.
136//
137// The read-etag-then-Put dance mirrors status.WriteBox, and for the same
138// reason: a box republishes to the SAME key on every wake and once more at
139// startup, so a bare create-only Put (ifMatch "") would fail every write
140// after the first with ErrCASConflict.
141func WriteFleetBinding(ctx context.Context, s release.Store, b FleetBinding, takeover bool) error {
142 body, err := json.Marshal(b)
143 if err != nil {
144 return err
145 }
146 key := fleetBindingKey(b.Fleet, b.Host)
147
148 existing, etag, err := getExistingBinding(ctx, s, key)
149 if err != nil {
150 return err
151 }
152 if bindingBlocked(existing, b, takeover) {
153 return nil
154 }
155
156 if _, err := s.Put(ctx, key, body, etag); err != nil {
157 if !errors.Is(err, release.ErrCASConflict) {
158 return err
159 }
160 // Lost the race against a concurrent write to the same key —
161 // re-read once, re-apply BOTH guards against the fresher state (it
162 // may now block a write that was fine against the stale read), and
163 // retry — the same single-retry contract status.WriteBox uses.
164 existing, etag, err = getExistingBinding(ctx, s, key)
165 if err != nil {
166 return err
167 }
168 if bindingBlocked(existing, b, takeover) {
169 return nil
170 }
171 if _, err := s.Put(ctx, key, body, etag); err != nil {
172 return err
173 }
174 }
175 return nil
176}
177
178// ReconcileFleetBindings is the platform-side half of fleet routing: for
179// every fleets/<fleet>/bindings/<host>.json object published under any
180// fleet, if the route registered for that host is actually PLACED on that
181// SAME fleet (registry.FleetPlacement(rt.Region) == the binding's own
182// Fleet) and its FleetBox doesn't already match, stamp it via SetFleetBox.
183//
184// The fleet-placement check is a v1 trust-boundary requirement, not a
185// nicety: fleet boxes are outbound-only, but nothing at the bucket layer
186// stops fleet A's box from publishing a binding that names a host actually
187// placed on fleet B (a same-named host collision, or a stale/rewritten
188// object) — without this check that binding could silently override fleet
189// B's own routing for a host fleet A's box has no business serving. There
190// is deliberately no "newest wins" tie-break for a cross-fleet collision:
191// two bindings for the exact same (fleet, host) pair cannot exist (the key
192// IS fleet+host), and a binding whose fleet doesn't match the route's own
193// placement is rejected outright, never preferred by timestamp — an earlier
194// version of this function used a cross-fleet "newest BoundAt" reduction
195// before this check existed; it's gone because the placement check makes it
196// both wrong (a malicious/stale binding could out-race a legitimate one by
197// BoundAt alone) and unnecessary (same-fleet duplicates are structurally
198// impossible).
199//
200// s must be an UNPREFIXED store handle (see fleetBindingKey) — this walks
201// the same absolute "fleets/" keys WriteFleetBinding writes, and is only
202// ever called on platform boxes, which never set CARLOS_STATUS_PREFIX in
203// the first place.
204//
205// Corrupt objects and per-key read failures are logged and skipped, never
206// fatal — the loadBoxes precedent (internal/status/fleet.go:197-216): one
207// bad binding must not blank out routing for every other fleet host. A
208// missing route, or one not placed on the binding's own fleet, is silently
209// skipped too — neither is this binding's to stamp.
210func ReconcileFleetBindings(ctx context.Context, s release.Store, reg *registry.Registry) error {
211 fleetDirs, err := s.ListDirs(ctx, "fleets/")
212 if err != nil {
213 return err
214 }
215 for _, dir := range fleetDirs {
216 keys, err := s.List(ctx, dir+"bindings/")
217 if err != nil {
218 log.Printf("host: fleet bindings: list %s: %v", dir, err)
219 continue
220 }
221 for _, key := range keys {
222 body, _, err := s.Get(ctx, key)
223 if err != nil {
224 log.Printf("host: fleet bindings: skipping %s: read failed: %v", key, err)
225 continue
226 }
227 var b FleetBinding
228 if err := json.Unmarshal(body, &b); err != nil {
229 log.Printf("host: fleet bindings: skipping %s: corrupt binding: %v", key, err)
230 continue
231 }
232 rt, err := reg.Get(b.Host)
233 if err != nil {
234 log.Printf("host: fleet bindings: read route %s: %v", b.Host, err)
235 continue
236 }
237 if rt == nil {
238 continue // no matching route on this platform box
239 }
240 // Never stamp a sidecar. SetFleetBox on a fleet-placed route is
241 // precisely what turns the edge's fleet dispatch arm into a live
242 // proxy, so this reader is the last gate on the chain and it
243 // refuses on kind rather than trusting its input.
244 //
245 // Redundant with residentBindingHosts on the publishing side, and
246 // deliberately so: the two halves run on DIFFERENT machines and
247 // upgrade independently. A fleet box still on an older binary
248 // keeps publishing sidecar bindings into the bucket for the whole
249 // of a mixed-version roll, and this platform box is what reads
250 // them. A binding is an untrusted claim written by another host;
251 // the route row is local truth.
252 if rt.IsSidecar() {
253 continue
254 }
255 if fleet, ok := registry.FleetPlacement(rt.Region); !ok || fleet != b.Fleet {
256 // The route isn't placed on THIS binding's fleet at all —
257 // v1 trust boundary: refuse to let it stamp (see doc
258 // comment above).
259 continue
260 }
261 if rt.FleetBox == b.Box {
262 continue
263 }
264 if err := reg.SetFleetBox(b.Host, b.Box); err != nil {
265 log.Printf("host: fleet bindings: stamp %s -> %s: %v", b.Host, b.Box, err)
266 }
267 }
268 }
269 return nil
270}
271
272// fleetBindingHook builds the activator's OnWake hook for a fleet box: on
273// every successful wake, publish a FleetBinding naming this box (identified
274// by fleet/box, resolveFleetIdentity's result — the channel label, never a
275// hashed pseudonym) as the current holder, with takeover=true since a
276// wake's lease acquisition is exactly the authority that may claim a
277// binding away from whoever held it before. activator.Service already
278// invokes OnWake in its own goroutine (see activator.go's wake), so this
279// function runs off the wake path entirely — a slow or failing bucket Put
280// can never delay or fail a request. Errors are logged only: a binding is
281// eventually-consistent by design (the design spec's failure sequence — box
282// goes dark, heartbeat ages, lease expires, the next request wakes a
283// surviving box, which restamps — is exactly the fallback that covers a
284// dropped write here).
285//
286// The hook takes the whole ROUTE, which is what the activator's wake path has
287// in hand anyway, and refuses on its Kind: a sidecar host must never have a
288// binding published for it, and this is the write path that carries TAKEOVER
289// semantics, so an errant one here does not merely appear — it claims the host
290// away from whoever legitimately held it. The filter lives here rather than
291// resting on "the activator never wakes a sidecar": that is true of the
292// activator TODAY, and it is a fact a later task in this same branch removes
293// (the sidecar worker goes through the same wake() that fires OnWake). A guard
294// that depends on a caller's current behaviour is not a guard.
295//
296// Taking the route also removes the registry read this refusal used to need.
297// That read could fail, and a security-relevant check whose input can fail
298// must then decide which way to fail: it published anyway (fail OPEN), because
299// a dropped binding is a routing outage and the fallback that covers it is
300// slow. The route the wake already holds is the same local truth the read was
301// fetching, so the refusal is now total and depends on no I/O at all.
302func fleetBindingHook(store release.Store, fleet, box string) func(rt registry.Route) {
303 return func(rt registry.Route) {
304 if rt.IsSidecar() {
305 return
306 }
307 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
308 defer cancel()
309 b := FleetBinding{Host: rt.Host, Fleet: fleet, Box: box, BoundAt: time.Now().UnixMilli()}
310 if err := WriteFleetBinding(ctx, store, b, true); err != nil {
311 log.Printf("agent: fleet binding for %s: %v", rt.Host, err)
312 }
313 }
314}
315
316// publishResidentFleetBindings runs once at fleet-box agent startup: every
317// route already present in this box's local registry gets a binding
318// written immediately, without waiting for an activator wake, with
319// takeover=false — this is a bare "this route is resident here" observation
320// with no lease behind it, so it must never steal a binding a wake-success
321// write already holds for a DIFFERENT box (see WriteFleetBinding/
322// bindingBlocked). Two gaps this closes that a wake-only publish would
323// miss:
324// - an agent restart discards activator.Service's in-memory entries, but
325// an already-running exec child (or a unit-backed tenant) keeps serving
326// — its host would otherwise have no fresh binding until its NEXT idle
327// wake, which may be a long time coming, or never for a host that is
328// always awake;
329// - a route that isn't activator-managed at all (Hibernate unset, or a
330// unit-backed Backing) never goes through activator.wake in the first
331// place, so it would never get a binding published any other way.
332//
333// Best-effort and non-fatal per host: a failed List aborts the whole pass
334// (logged; the next restart tries again), but one host's write failure does
335// not stop the rest of the pass from covering everyone else.
336func publishResidentFleetBindings(store release.Store, fleet, box string, reg *registry.Registry) {
337 routes, err := reg.List()
338 if err != nil {
339 log.Printf("agent: fleet bindings startup pass: list routes: %v", err)
340 return
341 }
342 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
343 defer cancel()
344 now := time.Now().UnixMilli()
345 for _, host := range residentBindingHosts(routes) {
346 b := FleetBinding{Host: host, Fleet: fleet, Box: box, BoundAt: now}
347 if err := WriteFleetBinding(ctx, store, b, false); err != nil {
348 log.Printf("agent: fleet binding for %s: %v", host, err)
349 }
350 }
351}
352
353// residentBindingHosts is the set of hosts this box advertises as served —
354// the pure host-selection half of publishResidentFleetBindings, split out so
355// the refusal below is directly testable.
356//
357// Sidecar routes (Kind "sidecar") are excluded. Nothing routes to a sidecar,
358// so a binding claiming a box serves it is a lie the platform side then acts
359// on: ReconcileFleetBindings stamps SetFleetBox from it, and a fleet-placed
360// route is exactly what the edge's fleet dispatch arm turns into a live
361// internet-facing proxy. Filtered at the publisher rather than at the
362// reconciler because this is the only place that decides what a box CLAIMS —
363// the claim is what must never exist.
364func residentBindingHosts(routes []registry.Route) []string {
365 var out []string
366 for _, rt := range routes {
367 if rt.Host == "" || rt.IsSidecar() {
368 continue
369 }
370 out = append(out, rt.Host)
371 }
372 return out
373}
374