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 "errors"
6 "fmt"
7 "log"
8 "net/http"
9 "strings"
10 "sync"
11 "time"
12
13 "github.com/carlosframework/platform/internal/alerting"
14 "github.com/carlosframework/platform/internal/hostclaim"
15 "github.com/carlosframework/platform/internal/hostops"
16)
17
18// No box has room (region HA spec 2 §7, ruling H27). Every door that
19// chooses a box (instance create, domain attach's first route, standalone
20// sidecar create, and the move door's destination) refuses with a 503 when
21// the placement policy finds no eligible box, and the operator hears of it
22// at most once per region an hour per console process: a restart may send
23// one extra alert, which H27 accepts rather than persist the limit. A
24// create door chooses before it registers anything (chooseExecutorFirst),
25// so a refusal leaves nothing registered, nothing written to the claim and
26// nothing for recovery to settle. Only a complete placement read with no
27// placeable box is "no room": an incomplete one (hostops.ErrEvidenceIncomplete)
28// is answered as temporarily unavailable and pages nobody.
29
30// noCapacityAlertEvery is how often one region's alert may repeat.
31const noCapacityAlertEvery = time.Hour
32
33// noCapacityRetryAfter is the Retry-After (seconds) a no-room 503 carries:
34// RHA-C17 tells the caller to try again in a few minutes. Every other
35// claim refusal keeps its own.
36const noCapacityRetryAfter = "60"
37
38func noCapacityRefusal(region string) string {
39 return fmt.Sprintf("No box in %s has room right now. Try again in a few minutes.", region)
40}
41
42func noCapacityAlert(region string) (subject, body string) {
43 return fmt.Sprintf("No box in %s has room", region),
44 fmt.Sprintf("A request in %s was refused because no box there can take it. Every box is full, stale or excluded by its role. Add a box, or free memory or disk on one, and check the eligible-capacity alarm for %s.", region, region)
45}
46
47// noCapacityError is a request refused because no box in Region (its
48// display name) can take it. It is hostops.ErrNoEligibleBox, and
49// claimResponseRefusal answers it with noCapacityRefusal.
50type noCapacityError struct{ Region string }
51
52func (e *noCapacityError) Error() string {
53 return "no eligible box in " + e.Region + ": " + hostops.ErrNoEligibleBox.Error()
54}
55
56func (e *noCapacityError) Is(target error) bool { return target == hostops.ErrNoEligibleBox }
57
58// pageNoCapacity sends the alert; tests replace it.
59var pageNoCapacity = alerting.Page
60
61// noCapacityAlerts remembers when each region's alert was last sent, in
62// this console process. An alert counts as sent only once its send
63// succeeds: due reserves the region's slot so concurrent refusals do not
64// page twice while one send is in flight, and failed gives the slot back,
65// so the next refusal tries again rather than the region going quiet for
66// an hour. The zero value is ready.
67type noCapacityAlerts struct {
68 mu sync.Mutex
69 last map[string]time.Time
70}
71
72// due reports whether region's alert should be sent at now, and if so
73// records it as sent.
74func (a *noCapacityAlerts) due(region string, now time.Time) bool {
75 a.mu.Lock()
76 defer a.mu.Unlock()
77 if at, ok := a.last[region]; ok && now.Sub(at) < noCapacityAlertEvery {
78 return false
79 }
80 if a.last == nil {
81 a.last = map[string]time.Time{}
82 }
83 a.last[region] = now
84 return true
85}
86
87// failed gives back the slot due reserved at for region, because that send
88// failed. A later reservation is left alone.
89func (a *noCapacityAlerts) failed(region string, at time.Time) {
90 a.mu.Lock()
91 defer a.mu.Unlock()
92 if last, ok := a.last[region]; ok && last.Equal(at) {
93 delete(a.last, region)
94 }
95}
96
97// isNoCapacityRefusal reports whether ref is the no-room refusal
98// (claimResponseRefusal's answer to a noCapacityError), which the attach
99// doors carry as a doorRefusal.
100func isNoCapacityRefusal(ref *doorRefusal) bool {
101 if ref == nil || ref.Status != http.StatusServiceUnavailable {
102 return false
103 }
104 before, after, _ := strings.Cut(noCapacityRefusal("\x00"), "\x00")
105 region, ok := strings.CutPrefix(ref.Msg, before)
106 if !ok {
107 return false
108 }
109 region, ok = strings.CutSuffix(region, after)
110 return ok && region != ""
111}
112
113// setNoCapacityRetryAfter stamps noCapacityRetryAfter on w when ref is the
114// no-room refusal, and leaves every other refusal's headers as its door
115// writes them.
116func setNoCapacityRetryAfter(w http.ResponseWriter, ref *doorRefusal) {
117 if isNoCapacityRefusal(ref) {
118 w.Header().Set("Retry-After", noCapacityRetryAfter)
119 }
120}
121
122// noRoom is the refusal for region (record vocabulary, "" for the home):
123// it logs, sends the operator alert when one is due, and returns the
124// error the door answers with.
125func (s *Server) noRoom(ctx context.Context, region string) error {
126 name := s.homeRegionOut(region)
127 log.Printf("console: refused: no eligible box in %s", name)
128 if now := time.Now(); s.noCapacity.due(name, now) {
129 subject, body := noCapacityAlert(name)
130 go func() {
131 ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
132 defer cancel()
133 if err := pageNoCapacity(ctx, alerting.LevelWarning, subject, body); err != nil {
134 // Not sent: the next refusal in name tries again.
135 s.noCapacity.failed(name, now)
136 log.Printf("console: no-capacity alert for %s: %v", name, err)
137 }
138 }()
139 }
140 return &noCapacityError{Region: name}
141}
142
143// placementChooser is the create door's chooser scoped as
144// ProductionAuthority.SelectExecutor scopes it, for pool ("" the shared
145// pool), or nil with region HA off or no claim runtime.
146func (s *Server) placementChooser(pool string) *hostops.ExecutorChooser {
147 a := s.cfg.ClaimAuthority
148 if !s.cfg.RegionHA || a == nil || a.Executors == nil {
149 return nil
150 }
151 x := *a.Executors
152 x.Namespace, x.Pool = a.Namespace, pool
153 return &x
154}
155
156// roomIn is H27's question about region (record vocabulary) for pool, asked
157// with region HA on before a door's own placement checks (Astra 2a round 2
158// finding 1): a region the roster places boxes in, whose placeable set is
159// empty (no edges file, no public address, stale, full or excluded by
160// role), is the no-room refusal, never a 400. It only refuses: it chooses
161// nothing and changes nothing the door goes on to do. It answers nil with
162// region HA off, for a region the roster does not place (the door's own
163// checks answer that), and when a box has room. An incomplete read is
164// hostops.ErrEvidenceIncomplete, which the door answers as temporarily
165// unavailable, with no alert.
166func (s *Server) roomIn(ctx context.Context, region, pool string) error {
167 x := s.placementChooser(pool)
168 if x == nil {
169 return nil
170 }
171 boxes, err := x.PlaceableBoxes(ctx, s.cfg.claimPlacementRegion(region))
172 switch {
173 case errors.Is(err, hostops.ErrContract):
174 return nil
175 case err != nil:
176 return err
177 case len(boxes) == 0:
178 return s.noRoom(ctx, region)
179 }
180 return nil
181}
182
183// errChoiceStale is an early choice whose claim observation is no longer
184// the claim: a write landed between the choice and beginClaimCreate's own
185// read. It wraps hostclaim.ErrConflict, so errors.Is(errChoiceStale,
186// hostclaim.ErrConflict) holds on the sentinel itself and the door answers
187// main's retryable busy refusal; nothing was registered or written.
188var errChoiceStale = fmt.Errorf("%w: the host's claim changed after its executor was chosen", hostclaim.ErrConflict)
189
190// chosenOnKey carries the claim observation an early choice was decided
191// on, beside the choice itself (hostops.WithChosenExecutor).
192type chosenOnKey struct{}
193
194type chosenOn struct {
195 host, etag string
196 absent bool
197}
198
199// chooseExecutorFirst is beginClaimCreate's first step with region HA on,
200// given beginClaimCreate's own read of the claim (before). It chooses the
201// executor for the record build would make, before anything is
202// registered, and carries the choice to the create
203// (hostops.WithChosenExecutor). It takes no lease and writes nothing: the
204// choice is SelectExecutor's read of the roster and the edges files. No
205// eligible box on a complete read is noRoom's refusal; an incomplete read
206// (hostops.ErrEvidenceIncomplete) is returned as it is. A choice ctx already carries for host
207// (chooseCreateFirst's) is kept only when before is the claim observation
208// it was decided on (census ruling region-ha-2a, item 5): otherwise the
209// create is refused with errChoiceStale, and the old choice is never used
210// and nothing is chosen again. With region HA off it returns ctx
211// unchanged, and the create is main's.
212func (s *Server) chooseExecutorFirst(ctx context.Context, host string, before hostclaim.Observed, build func(time.Time, string) (hostops.Record, error)) (context.Context, error) {
213 a := s.cfg.ClaimAuthority
214 if !s.cfg.RegionHA || a == nil || a.Executors == nil {
215 return ctx, nil
216 }
217 if _, ok := hostops.ChosenExecutor(ctx, host); ok {
218 on, ok := ctx.Value(chosenOnKey{}).(chosenOn)
219 if !ok || on.host != host || on.etag != before.ETag || on.absent != before.Absent {
220 return ctx, fmt.Errorf("%w: %s", errChoiceStale, host)
221 }
222 return ctx, nil
223 }
224 probe, err := build(time.Now().UTC(), "")
225 if err != nil {
226 return ctx, err
227 }
228 sel, chosen, err := a.SelectExecutor(ctx, before, probe)
229 if errors.Is(err, hostops.ErrNoEligibleBox) {
230 return ctx, s.noRoom(ctx, probe.Holder.RecordRegion)
231 }
232 if err != nil || !chosen {
233 return ctx, err
234 }
235 return hostops.WithChosenExecutor(ctx, host, sel), nil
236}
237
238// chooseCreateFirst is the instance create door's choice, made before the
239// door's first write (Astra 2a round 2 finding 2): the environment binding
240// (bindEnvironmentCreate) can move a seed's store to the new instance, so
241// a create refused for want of room must be refused before it. It reads
242// host's claim and chooses as chooseExecutorFirst does, and returns the
243// choice together with the claim observation it decided on (its ETag, or
244// its absence). beginClaimCreate keeps the choice only if its own read is
245// that same observation. With region HA off it returns ctx unchanged and
246// reads nothing.
247func (s *Server) chooseCreateFirst(ctx context.Context, host string, build func(time.Time, string) (hostops.Record, error)) (context.Context, error) {
248 a := s.cfg.ClaimAuthority
249 if !s.cfg.RegionHA || a == nil || a.Executors == nil {
250 return ctx, nil
251 }
252 before, err := hostclaim.Read(ctx, s.cfg.Releases, host)
253 if err != nil {
254 return ctx, err
255 }
256 chosen, err := s.chooseExecutorFirst(ctx, host, before, build)
257 if err != nil {
258 return ctx, err
259 }
260 if _, ok := hostops.ChosenExecutor(chosen, host); !ok {
261 return chosen, nil
262 }
263 return context.WithValue(chosen, chosenOnKey{}, chosenOn{host: host, etag: before.ETag, absent: before.Absent}), nil
264}
265