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
3import (
4 "context"
5 "log"
6 "sort"
7 "sync"
8 "time"
9
10 "github.com/carlosframework/platform/internal/edgesfile"
11 "github.com/carlosframework/platform/internal/release"
12)
13
14// livenessSaid throttles the console's own incomplete-evidence diagnostic
15// to once per process (the liveness join's are edgesfile's).
16var livenessSaid sync.Map
17
18// edgesFilePrefix is where every box publishes what it serves — one object
19// per box, control/edges/<box>.json, written by internal/host's edges-file
20// pass and read by every edge's home map. This is the third reader of that
21// prefix (internal/edge, internal/steering) and, like them, declares its own
22// view of the wire shape rather than sharing a type: the writer's schema
23// grows additively and each reader names only the fields it needs.
24const edgesFilePrefix = "control/edges/"
25
26// servedBy is one box that serves a host, as the instances API reports it
27// (issue #337). Region is the box's AWS region as the edges file carries it
28// (e.g. "eu-west-1") — the vocabulary a consumer fleet surface already shows
29// from its own IMDS read — and omitted when the file predates the field.
30type servedBy struct {
31 Box string `json:"box"`
32 Region string `json:"region,omitempty"`
33}
34
35// edgesFilePlacementView is the edges-file placement view, shared with
36// the box agent's relocation chooser through internal/edgesfile (region HA
37// stage B Task 10): one read for every automatic-eligibility caller.
38type edgesFilePlacementView = edgesfile.PlacementView
39
40// loadEdgesJoin folds every readable edges file into host → the boxes that
41// serve it, box-sorted so two consoles answer identically. Best-effort in
42// the same spirit as loadBoxJoin: a failed list, an unreadable object, a
43// parse failure or an unversioned file each cost only the answer that object
44// would have contributed — the instances themselves are the record store's
45// story and must never fail to list because a placement hint is missing.
46// Keys that are not <box>.json directly under the prefix are not read at
47// all, the same shape filter the other readers apply.
48func loadEdgesJoin(ctx context.Context, s release.Store) map[string][]servedBy {
49 out := map[string][]servedBy{}
50 for _, f := range readEdgesFiles(ctx, s) {
51 for host := range f.Hosts {
52 out[host] = append(out[host], servedBy{Box: f.Box, Region: f.Region})
53 }
54 }
55 for host := range out {
56 sort.Slice(out[host], func(i, j int) bool { return out[host][i].Box < out[host][j].Box })
57 }
58 return out
59}
60
61// readEdgesFiles is the shared best-effort read under both consumers:
62// list, shape-filter, parse, skip anything unreadable or unversioned. A
63// failed list is an empty answer, logged — the callers each degrade to
64// their pre-#337/#329 behaviour rather than failing the request. It is
65// readEdgesSnapshot with the listing error folded into that empty answer.
66func readEdgesFiles(ctx context.Context, s release.Store) []edgesFilePlacementView {
67 if s == nil {
68 return nil
69 }
70 snap, err := readEdgesSnapshot(ctx, s)
71 if err != nil {
72 log.Printf("console: api instances: could not list %s for the placement join: %v", edgesFilePrefix, err)
73 return nil
74 }
75 return snap.Files
76}
77
78// edgesSnapshot is one read of control/edges/ (edgesfile.Snapshot).
79type edgesSnapshot = edgesfile.Snapshot
80
81// readEdgesSnapshot is edgesfile.ReadSnapshot.
82func readEdgesSnapshot(ctx context.Context, s release.Store) (edgesSnapshot, error) {
83 return edgesfile.ReadSnapshot(ctx, s)
84}
85
86// readLiveEdgesSnapshot is edgesfile.ReadLiveSnapshot: the edges files
87// joined with box liveness, for automatic eligibility.
88func readLiveEdgesSnapshot(ctx context.Context, s release.Store, now time.Time) (edgesSnapshot, error) {
89 return edgesfile.ReadLiveSnapshot(ctx, s, now)
90}
91
92// placementCapability is what one placement code's boxes can do, folded
93// across every box publishing that code. Three separate facts, because the
94// three doors that consult them are asking three different questions, and
95// conflating those is how a static site came to be refused for want of an
96// activator it never needed.
97type placementCapability struct {
98 // Reachable: some box publishing this code has a dial address. A box
99 // nothing can reach is not a landing target for anything.
100 Reachable bool
101 // Hosting: some reachable box publishing this code has its hibernation
102 // activator configured. Only a SOCKET-BACKED INSTANCE needs this — a
103 // static route is served from bucket bytes by every edge, and an
104 // addr-backed service proxies to a process the platform never spawns.
105 Hosting bool
106 // Staging: some reachable box publishing this code publishes actual
107 // data-bucket COORDINATES, which is the move door's requirement and is
108 // strictly stronger than Hosting. internal/host's moveTargets drops any
109 // target whose file carries no data_bucket, and runMoveRound then
110 // reports "target region %s publishes no data bucket yet — waiting" for
111 // as long as the record lives. A door admitting such a move promises
112 // something the worker refuses forever.
113 Staging bool
114}
115
116// placements folds the fleet's edges files into each published placement
117// code's capability (issue #329, discussion #110). The second return reports
118// whether ANY file carried a placement code at all: false means the fleet
119// predates the field (or the read failed) and the create gate stays DORMANT,
120// accepting what it always accepted, so rolling boxes and console in either
121// order is safe.
122//
123// The hosting question is answered PER BOX, from that box's own
124// hibernation_configured field, and is never inferred across boxes. That
125// distinction is what discussion #110's review paid for. An earlier version
126// keyed hosting on data_bucket alone — and because an absent data_bucket is
127// exactly what an OLD WRITER and a bucket-less new box both produce, it had
128// to decide when silence meant "no". It decided by asking whether any OTHER
129// box had spoken, so the first box in a fleet to adopt a bucket-publishing
130// build withdrew every region that had not yet rolled, all of them healthy:
131// minutes for an uninterrupted fleet-wide roll, hours at one box an hour,
132// unbounded if a roll stalled. One writer's upgrade cannot establish another
133// writer's capabilities.
134//
135// So each file answers only for itself, and UNKNOWN is not NO:
136//
137// hibernation_configured present → that box's own answer, believed
138// absent → a writer predating the field: unknown,
139// and it keeps the eligibility it had
140// before the field existed
141//
142// That last line is what makes a box roll safe in either direction, and it
143// is deliberately generous. A genuinely unconfigured box on an old build
144// stays eligible until it rolls and says so, so a create into it can still
145// strand — but that is the bug we already had, whereas refusing creates into
146// regions that host perfectly well would be a new one, caused BY the fix.
147//
148// None of these three is a promise that a wake will SUCCEED. A box whose IAM
149// role cannot reach the bucket it names, or whose CARLOS_HIBERNATE_REGION is
150// wrong, publishes hibernation_configured true and fails every lease. This
151// is configuration, which is all an edges file can honestly carry.
152func placements(ctx context.Context, s release.Store) (caps map[string]placementCapability, published bool) {
153 caps = map[string]placementCapability{}
154 for _, f := range readEdgesFiles(ctx, s) {
155 if f.Placement == "" {
156 continue
157 }
158 published = true
159 caps[f.Placement] = foldCapability(caps[f.Placement], f)
160 }
161 return caps, published
162}
163
164// foldCapability adds one box's edges file to its code's capability.
165func foldCapability(c placementCapability, f edgesFilePlacementView) placementCapability {
166 // Every capability below is gated on reachability: a box with no dial
167 // address contributes nothing, however well configured it is. The code
168 // is still RECORDED, so an unreachable region reads as known-and-unarmed
169 // rather than as absent.
170 if f.EIP == "" {
171 return c
172 }
173 // Unknown is not no. An absent hibernation_configured with no
174 // data_bucket is a writer that predates the field, and it keeps the
175 // eligibility it had before the field existed.
176 hosting := true
177 if f.HibernationConfigured != nil {
178 hosting = *f.HibernationConfigured
179 }
180 c.Reachable = true
181 c.Hosting = c.Hosting || hosting
182 c.Staging = c.Staging || f.DataBucket != ""
183 return c
184}
185
186// movePlacements is the move door's reading of the same files: placements
187// plus the home, folded under the record's "" from every edges file that
188// carries no placement (a home box sets no CARLOS_REGION). The rules are
189// placements' own: no dial address, no capability, and staging is a
190// published data bucket. homeNamed is the sorted boxes whose file names
191// home as its placement (only when home is named): the console reads such a
192// box as home (homeRegionIn), but the box does not, so it cannot take a
193// move into home (MV-N511). published keeps placements' meaning, so a fleet
194// whose only boxes are the home's still publishes no armed regions.
195//
196// A separate reader, not a flag on placements (Review Focus 4):
197// placements feeds the create gate's published flag and armedPlacements,
198// and a home folded in there would change create.
199func movePlacements(ctx context.Context, s release.Store, home string) (caps map[string]placementCapability, homeNamed []string, published bool) {
200 caps = map[string]placementCapability{}
201 for _, f := range readEdgesFiles(ctx, s) {
202 if f.Placement != "" {
203 published = true
204 if homePlacementCode(home, f.Placement) == "" {
205 homeNamed = append(homeNamed, f.Box)
206 }
207 }
208 caps[f.Placement] = foldCapability(caps[f.Placement], f)
209 }
210 sort.Strings(homeNamed)
211 return caps, homeNamed, published
212}
213
214// armedPlacements is the picker answer in the shape its callers have always
215// taken: code -> "can take an instance right now", which is Reachable AND
216// Hosting.
217func armedPlacements(ctx context.Context, s release.Store) (set map[string]bool, published bool) {
218 caps, published := placements(ctx, s)
219 return hostingSet(caps), published
220}
221
222// hostingSet and reachableSet project a capability map onto the one axis a
223// particular refusal is about, so the list a caller is handed names regions
224// that would actually satisfy the bar they just failed.
225func hostingSet(caps map[string]placementCapability) map[string]bool {
226 set := make(map[string]bool, len(caps))
227 for code, c := range caps {
228 set[code] = c.Reachable && c.Hosting
229 }
230 return set
231}
232
233func reachableSet(caps map[string]placementCapability) map[string]bool {
234 set := make(map[string]bool, len(caps))
235 for code, c := range caps {
236 set[code] = c.Reachable
237 }
238 return set
239}
240