| 1 | package console |
| 2 | |
| 3 | import ( |
| 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). |
| 16 | var 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. |
| 24 | const 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. |
| 30 | type 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. |
| 38 | type 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. |
| 48 | func 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. |
| 66 | func 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). |
| 79 | type edgesSnapshot = edgesfile.Snapshot |
| 80 | |
| 81 | // readEdgesSnapshot is edgesfile.ReadSnapshot. |
| 82 | func 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. |
| 88 | func 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. |
| 97 | type 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. |
| 152 | func 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. |
| 165 | func 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. |
| 199 | func 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. |
| 217 | func 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. |
| 225 | func 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 | |
| 233 | func 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 | |