carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The NARROWED restart signal (discussion #16): a request to cycle ONE route,
2// or the routes reading ONE config environment, rather than every instance an
3// app owns.
4//
5// Why a second object rather than a field on restart-gen.json. A box acts on
6// that object's ETAG MOVING — it never reads the content — so a selector
7// written INTO it would be invisible to every box running today's binary, and
8// those boxes would read the movement the only way they know how: restart the
9// whole app. For titogo that is nineteen production routes cycled to deliver
10// one credential to one of them. The rule this repo already has for wire
11// shapes ("anything written before your change must still parse") has a twin
12// that this file is built on: an old reader that cannot understand a NARROWED
13// request must do NOTHING, never something wider than was asked for.
14//
15// A sibling key gets that for free. Nothing that exists today reads
16// restart-scope.json, so:
17//
18// - OLD box, NEW (narrowed) request — never looks at the key, sees no
19// movement on restart-gen.json, cycles nothing. The request simply does
20// not reach it. The console says which routes it addressed and on which
21// box's registry it resolved them, so an operator whose fleet still has
22// an old box can see that it is one box's answer.
23// - NEW box, OLD (app-wide) request — restart-gen.json moves exactly as it
24// always did and this file takes no part.
25// - NEW box, FUTURE request kind — an entry whose `kind` this binary does
26// not know is SKIPPED, not widened (see RestartScope.Selects). A selector
27// nobody here understands addresses nothing.
28//
29// Bucket layout addition:
30//
31// apps/<acct>/<app>/restart-scope.json CAS-guarded, schema-1 request log
32//
33// It is a LOG rather than a single generation, which is the one place this
34// shape costs more than restart-gen.json's. Movement is enough to carry "some
35// restart was asked for"; it is not enough to carry WHICH routes, because two
36// requests inside one 2s poll window collapse into one etag move and the
37// earlier selector would be lost — silently, having told its caller
38// "requested". Recording each request with an id, and remembering the last id
39// acted on, makes a coalesced pair two requests again.
40//
41// The content is TRUSTED here in a way restart-gen.json's is not, so it is
42// worth saying exactly how far that goes: an entry can only ever NARROW. The
43// box matches entries against routes it already serves for that account and
44// app — the account, the app and the route set come from the box's own
45// registry, never from this object — so the worst a forged or corrupted entry
46// can do is name a selector matching every route of one app, which is
47// precisely what the unsigned app-wide object next to it already permits.
48// Nothing about what runs, or about which account owns what, is decided here.
49package release
50
51import (
52 "context"
53 "crypto/rand"
54 "encoding/hex"
55 "encoding/json"
56 "errors"
57 "fmt"
58 "io"
59 "os"
60 "strings"
61 "time"
62
63 "github.com/carlosframework/platform/internal/registry"
64)
65
66// The selector kinds. Only Host and Environment are ever written into the
67// object; App names an app-wide request, which is restart-gen.json's job and
68// travels no further than the HTTP door and the CLI that talks to it. It
69// lives here so the three of them spell it the same way.
70const (
71 RestartScopeHost = "host"
72 RestartScopeEnvironment = "environment"
73 RestartScopeApp = "app"
74
75 // RestartScopeRetry is `carlos instances retry <host>` (zero-downtime spec
76 // §8.4): Value is the host whose failure hold to clear. It is NOT a
77 // restart. Selects does not understand it, so a box built before it
78 // steps over it, and a box built after it hands it to the rollout manager
79 // instead of cycling anything (internal/host/restartscope.go). Unlike a
80 // restart, a retry lifts a restriction, so the file header's narrowing
81 // argument does not cover it: it acts only on a root-signed grant in
82 // Signed (HostRetryGrant, spec §11), and a box drops one without.
83 //
84 // It is the kind of every entry in the host-retry log (AppendHostRetry,
85 // hostretrylog.go), and no longer written into this one
86 // (ValidRestartScope): a console built before Signed strips it from every
87 // entry when it re-marshals this log. A box still verifies and applies a
88 // signed retry an earlier console left here, with the same checks.
89 RestartScopeRetry = "retry"
90)
91
92// restartScopeKeep bounds the log. Entries older than the last few dozen are
93// dropped on the next append: a box that has been away long enough to miss
94// them re-baselines (see PendingSince) rather than replaying a week of
95// requests, and the object stays a small, cheap read.
96const restartScopeKeep = 32
97
98// RestartScopeNone is the mark a box records for "I have looked and there is
99// nothing here" — the object is absent, or present with an empty log. The two
100// collapse deliberately: an object that appears carrying its first request
101// moves the mark from this sentinel to that request's id, which is movement,
102// which is exactly what it is.
103//
104// The NUL byte is what makes it safe as a sentinel — no request id contains
105// one, since ids are hex — and it is the same trick the box's own absent
106// marker uses for etags.
107const RestartScopeNone = "\x00none"
108
109// RestartScope is one selector: what to narrow a restart to.
110//
111// Kind is compared, never interpreted — an unknown one addresses nothing.
112// Value is matched against a registry column and never becomes a bucket key,
113// a filesystem path or a shell word; the strict shape checks belong at the
114// door that accepts it from a person, where a typo can still be answered with
115// what actually exists.
116type RestartScope struct {
117 Kind string `json:"kind"`
118 Value string `json:"value"`
119 // Signed is a retry's root-signed grant (HostRetryGrant, spec §11): a
120 // box clears a hold only on a grant that verifies against this entry's
121 // account, app and host, so a box that can write this log cannot forge
122 // a retry (Astra impl-6 finding 5). Only a retry carries it, and only in
123 // the host-retry log is one written now (AppendHostRetry); a legacy
124 // entry in the restart-scope log may still carry one. It is additive:
125 // an older box's lax parse steps over it, and an unsigned retry is
126 // dropped by every box that checks.
127 Signed string `json:"signed,omitempty"`
128}
129
130// RestartRequest is one recorded request. ID is what a box remembers; nothing
131// is derived from it beyond "have I acted on this one".
132type RestartRequest struct {
133 ID string `json:"id"`
134 RequestedAt string `json:"requested_at"`
135 Scope RestartScope `json:"scope"`
136}
137
138// ScopedRestartGeneration is the object: apps/<acct>/<app>/restart-scope.json.
139// Requests are OLDEST FIRST, so the newest is the last element and a box's
140// "what have I not acted on" is a suffix.
141type ScopedRestartGeneration struct {
142 Schema int `json:"schema"` // 1
143 Requests []RestartRequest `json:"requests"`
144}
145
146// RestartScopeKey is the bucket key of app's narrowed-request log, derived
147// from AppPrefix like every other per-app key.
148func RestartScopeKey(acct, app string) string { return AppPrefix(acct, app) + "restart-scope.json" }
149
150// Selects reports whether rt is addressed by this scope, and whether this
151// binary understands the scope's kind at all.
152//
153// The second return is the forward-compatibility hinge, and the reason this
154// is two bools rather than one. A selector kind added after this binary was
155// built must address NOTHING here — not everything — so a caller that gets
156// understood=false skips the whole request rather than falling back to the
157// app. Widening on ignorance is the failure this whole file exists to avoid.
158//
159// The caller has already narrowed to the right account and app: this decides
160// only which of THOSE routes the selector names.
161func (sc RestartScope) Selects(rt registry.Route) (selected, understood bool) {
162 switch sc.Kind {
163 case RestartScopeHost:
164 return rt.Host == sc.Value, true
165 case RestartScopeEnvironment:
166 // An environment selector never matches the unbound routes: "" is not
167 // an environment name, it is the absence of one, and `--environment ""`
168 // cannot be typed. A request to cycle what reads the DEFAULT bundle is
169 // an app-wide request, and has the app-wide object.
170 return sc.Value != "" && rt.Environment == sc.Value, true
171 }
172 return false, false
173}
174
175// ValidRestartScope refuses a selector this package would write but no box
176// could act on. It is the last gate before the bucket, not the first: the
177// door that took the value from a person has already checked its shape and
178// answered a typo with what exists.
179//
180// A retry, and any signature, is refused: retries ride the host-retry log
181// (AppendHostRetry), where no console older than it can strip a grant's
182// signature by re-marshalling the log.
183func ValidRestartScope(sc RestartScope) error {
184 switch sc.Kind {
185 case RestartScopeHost, RestartScopeEnvironment:
186 case RestartScopeRetry:
187 return fmt.Errorf("restart scope: a retry is written to the host-retry log, not this one")
188 default:
189 return fmt.Errorf("restart scope: unknown kind %q", sc.Kind)
190 }
191 if err := validScopeValue(sc.Kind, sc.Value); err != nil {
192 return err
193 }
194 if sc.Signed != "" {
195 return fmt.Errorf("restart scope: %s carries a signature, which only a retry may", sc.Kind)
196 }
197 return nil
198}
199
200// validScopeValue is the shape check every entry's Value passes: present,
201// at most a hostname's length, and no NUL or line break.
202func validScopeValue(kind, value string) error {
203 if value == "" {
204 return fmt.Errorf("restart scope: %s has no value", kind)
205 }
206 if len(value) > 253 || strings.ContainsAny(value, "\x00\n\r") {
207 return fmt.Errorf("restart scope: %s value is not usable", kind)
208 }
209 return nil
210}
211
212// LoadRestartScopes reads the log. found=false means the object is absent,
213// which is the ordinary state of almost every app and is legal forever.
214//
215// A newer schema is an ERROR rather than a best-effort parse: a box that
216// cannot be sure what a request means must do nothing, and its caller records
217// nothing, so the request is picked up whenever the box is upgraded rather
218// than dropped or guessed at.
219func LoadRestartScopes(ctx context.Context, s Store, acct, app string) (*ScopedRestartGeneration, bool, error) {
220 key := RestartScopeKey(acct, app)
221 raw, _, err := s.Get(ctx, key)
222 if errors.Is(err, os.ErrNotExist) {
223 return &ScopedRestartGeneration{Schema: 1}, false, nil
224 }
225 if err != nil {
226 return nil, false, err
227 }
228 g, err := parseRestartScopes(key, raw)
229 if err != nil {
230 return nil, false, err
231 }
232 return g, true, nil
233}
234
235func parseRestartScopes(key string, raw []byte) (*ScopedRestartGeneration, error) {
236 var g ScopedRestartGeneration
237 if err := json.Unmarshal(raw, &g); err != nil {
238 return nil, fmt.Errorf("restart scopes at %s: %w", key, err)
239 }
240 if g.Schema > 1 {
241 return nil, fmt.Errorf("restart scopes at %s: schema %d is newer than this binary understands — upgrade carlos", key, g.Schema)
242 }
243 return &g, nil
244}
245
246// Newest is the id of the last request in the log, or RestartScopeNone for an
247// empty one — the value a box records when it has nothing to act on.
248func (g *ScopedRestartGeneration) Newest() string {
249 if g == nil || len(g.Requests) == 0 {
250 return RestartScopeNone
251 }
252 return g.Requests[len(g.Requests)-1].ID
253}
254
255// PendingSince returns the requests recorded after mark — what a box that has
256// already acted up to mark still owes — and whether mark could be located at
257// all.
258//
259// found=false means the mark is an id that is no longer in the log: the box
260// has been away long enough for its position to roll off the end. There is no
261// honest answer to "what did I miss" then, and the two candidate guesses are
262// not equal — replaying the whole log would cycle routes for requests served
263// days ago, while re-baselining drops requests that are recoverable by typing
264// the verb again. Callers re-baseline; see the box's restart pass, which makes
265// the same choice everywhere else it is offered.
266//
267// RestartScopeNone as the mark is NOT an unknown position: it is a box that
268// looked and found nothing, so everything now in the log is new to it.
269func (g *ScopedRestartGeneration) PendingSince(mark string) (pending []RestartRequest, found bool) {
270 if g == nil {
271 return nil, true
272 }
273 if mark == RestartScopeNone {
274 return g.Requests, true
275 }
276 for i, r := range g.Requests {
277 if r.ID == mark {
278 return g.Requests[i+1:], true
279 }
280 }
281 if len(g.Requests) == 0 {
282 // The log emptied under a mark that named a request — only possible if
283 // the object was replaced wholesale. Unknown position, same answer.
284 return nil, false
285 }
286 return nil, false
287}
288
289// AppendRestartScope records one narrowed request and returns it.
290//
291// A CAS conflict is RETRIED here, which is the opposite of TouchRestartGen's
292// treatment of the same error, and the difference is the whole point of this
293// object. There, any touch means "restart this app", so a racing writer has
294// already fired the exact signal this call exists to fire and a retry would
295// only manufacture a second restart. Here the other writer's request names a
296// DIFFERENT set of routes: dropping ours would answer "requested" for a
297// restart that will never happen. So we re-read and append again.
298//
299// The retry budget is small on purpose. This is a human-paced verb; a
300// contended object means something is wrong that another lap will not fix,
301// and the caller gets an error rather than a lie.
302func claimRawAppendRestartScope(ctx context.Context, s Store, acct, app string, scope RestartScope) (RestartRequest, error) {
303 if !ValidAccount(acct) {
304 return RestartRequest{}, fmt.Errorf("restart: invalid account %q", acct)
305 }
306 if !ValidApp(app) {
307 return RestartRequest{}, fmt.Errorf("restart: invalid app %q", app)
308 }
309 if err := ValidRestartScope(scope); err != nil {
310 return RestartRequest{}, err
311 }
312 return claimRawAppendRequestLog(ctx, s, RestartScopeKey(acct, app), scope, "restart")
313}
314
315// claimRawAppendRequestLog is the CAS append both request logs share (the
316// restart-scope log and the host-retry log): a fresh random id, the log
317// re-read and appended to on a conflict, at most restartScopeKeep entries
318// kept, and a log this binary cannot parse refused rather than
319// overwritten. verb names the request in the conflict error. The caller
320// has validated the account, the app and the scope.
321func claimRawAppendRequestLog(ctx context.Context, s Store, key string, scope RestartScope, verb string) (RestartRequest, error) {
322 id := make([]byte, 16)
323 if _, err := io.ReadFull(rand.Reader, id); err != nil {
324 return RestartRequest{}, err
325 }
326 req := RestartRequest{
327 ID: hex.EncodeToString(id),
328 RequestedAt: time.Now().UTC().Format(time.RFC3339),
329 Scope: scope,
330 }
331 var lastErr error
332 for attempt := 0; attempt < 3; attempt++ {
333 raw, etag, err := s.Get(ctx, key)
334 if err != nil && !errors.Is(err, os.ErrNotExist) {
335 return RestartRequest{}, err
336 }
337 g := &ScopedRestartGeneration{Schema: 1}
338 if err == nil {
339 if g, err = parseRestartScopes(key, raw); err != nil {
340 // Refusing beats overwriting: a log this binary cannot read is
341 // one whose entries it would silently drop on the next write.
342 return RestartRequest{}, err
343 }
344 } else {
345 etag = "" // create-only
346 }
347 g.Schema = 1
348 g.Requests = append(g.Requests, req)
349 if len(g.Requests) > restartScopeKeep {
350 g.Requests = g.Requests[len(g.Requests)-restartScopeKeep:]
351 }
352 body, err := json.Marshal(g)
353 if err != nil {
354 return RestartRequest{}, err
355 }
356 if _, err := s.Put(ctx, key, body, etag); err != nil {
357 if errors.Is(err, ErrCASConflict) {
358 lastErr = err
359 continue
360 }
361 return RestartRequest{}, err
362 }
363 return req, nil
364 }
365 return RestartRequest{}, fmt.Errorf("%s: %s is being written by someone else — nothing was requested; try again: %w", verb, key, lastErr)
366}
367func AppendRestartScope(ctx context.Context, s Store, acct, app string, scope RestartScope) (claimResult0 RestartRequest, claimResult1 error) {
368 claimResult1 = WithAppMutation(ctx, s, acct, app, "release.AppendRestartScope", func(claimContext context.Context) error {
369 claimResult0, claimResult1 = claimRawAppendRestartScope(claimContext, s, acct, app, scope)
370 return claimResult1
371 })
372 return
373}
374