carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The wake signal for a hibernating sidecar: one tiny CAS-guarded object per
2// sidecar, watched by etag, exactly one door over from restartgen.go's
3// restart signal and appconfig's config-gen object — the same mechanism a
4// third time, because "tell a box to act, with no push channel and no box
5// access" is one problem no matter what the box is being told to do.
6//
7// Bucket layout addition:
8//
9// apps/<acct>/<app>/sidecar-wake/<name>.json CAS-guarded, schema-1
10//
11// It is a SIBLING prefix under the app, deliberately not a key under
12// sidecars/: provision.ParseSidecarKey treats anything but exactly one
13// non-empty segment after "sidecars/" as not-a-record, and a wake object
14// living inside that prefix would either have to be taught to that parser
15// (widening what it accepts) or silently misparse as a sidecar name of
16// "<name>.json" — either way the family's "exactly one segment" rule stops
17// meaning what it says. A sibling prefix costs nothing and keeps that rule
18// exactly as strict as it reads.
19package release
20
21import (
22 "context"
23 "encoding/json"
24 "errors"
25 "fmt"
26 "os"
27 "regexp"
28 "time"
29)
30
31// SidecarWake is the object a wake request touches. Change detection is by
32// ETAG, never by Generation: a monotonic counter that resets — a bucket
33// restored from backup, an object rewritten by a script that does not know
34// the current value — must not wedge a box's cache into believing every
35// future request lands on a value it has "already seen". Generation, At, By
36// and Reason exist so a human debugging "why did this sidecar wake?" has an
37// answer; a box reading this object for its own purposes reads only its
38// etag and never unmarshals the body at all.
39//
40// It carries no authority: the worst a forged touch can do is wake a
41// sidecar that was going to run under its own signed release anyway. Waking
42// is not deciding what runs, the same way a restart-generation touch isn't.
43type SidecarWakeIdentity struct {
44 AppID string `json:"app_id,omitempty"`
45 Incarnation string `json:"incarnation,omitempty"`
46 RequestID string `json:"request_id,omitempty"`
47}
48
49type SidecarWake struct {
50 SidecarWakeIdentity
51 Schema int `json:"schema"` // 1
52 Generation int `json:"generation"`
53 At string `json:"at"`
54 By string `json:"by"`
55 Reason string `json:"reason"`
56}
57
58// SidecarWakeKey is the bucket key of name's wake object within app, derived
59// from AppPrefix like every other per-app key.
60func SidecarWakeKey(acct, app, name string) string {
61 return AppPrefix(acct, app) + "sidecar-wake/" + name + ".json"
62}
63
64// sidecarWakeNameRe pins a sidecar's name to one DNS label — the same
65// pattern provision.ValidSidecarName (labelRe) checks, mirrored locally
66// rather than imported: provision already imports release for the bucket
67// key conventions, so release importing provision back would be a cycle.
68// The name becomes a bucket key segment here exactly as it does there, so
69// the same discipline applies: this is what keeps a hostile name an opaque,
70// safe path component rather than a traversal.
71var sidecarWakeNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
72
73func validSidecarWakeName(name string) bool { return sidecarWakeNameRe.MatchString(name) }
74
75// TouchSidecarWake bumps name's wake generation within app — the request
76// every box serving that sidecar converges on within seconds. by and reason
77// are carried verbatim onto the object for a human reading it during an
78// incident; neither is validated, because neither is ever parsed back into
79// a decision (see SidecarWake's own doc comment on carrying no authority).
80//
81// A CAS conflict is SUCCESS, not an error, for exactly TouchRestartGen's
82// reason: it means another writer touched the object between our read and
83// our write, so the signal this function exists to fire has already fired.
84// Retrying would only ask a sidecar that is already waking to wake again.
85//
86// os.ErrNotExist on the read means first touch (Generation starts at 1),
87// which is what makes the conflict carve-out safe here too: two first
88// touches race, one wins, both callers are correct — both asked for a wake,
89// and exactly one wake is what the box will do (WakeWorker is idempotent
90// regardless).
91func claimRawTouchSidecarWake(ctx context.Context, s Store, acct, app, name, by, reason string, identity ...SidecarWakeIdentity) error {
92 if !ValidAccount(acct) {
93 return fmt.Errorf("sidecar wake: invalid account %q", acct)
94 }
95 if !ValidApp(app) {
96 return fmt.Errorf("sidecar wake: invalid app %q", app)
97 }
98 if !validSidecarWakeName(name) {
99 return fmt.Errorf("sidecar wake: invalid sidecar name %q", name)
100 }
101 var binding SidecarWakeIdentity
102 if len(identity) > 1 {
103 return fmt.Errorf("sidecar wake: multiple identities")
104 }
105 if len(identity) == 1 {
106 binding = identity[0]
107 if binding.AppID == "" || binding.Incarnation == "" || binding.RequestID == "" {
108 return fmt.Errorf("sidecar wake: incomplete identity")
109 }
110 }
111 key := SidecarWakeKey(acct, app, name)
112 raw, etag, err := s.Get(ctx, key)
113 switch {
114 case errors.Is(err, os.ErrNotExist):
115 etag = ""
116 case err != nil:
117 return err
118 }
119 gen := 0
120 if err == nil {
121 var prev SidecarWake
122 if uerr := json.Unmarshal(raw, &prev); uerr == nil {
123 gen = prev.Generation
124 }
125 }
126 body, err := json.Marshal(SidecarWake{
127 SidecarWakeIdentity: binding,
128 Schema: 1,
129 Generation: gen + 1,
130 At: time.Now().UTC().Format(time.RFC3339),
131 By: by,
132 Reason: reason,
133 })
134 if err != nil {
135 return err
136 }
137 if _, err := s.Put(ctx, key, body, etag); err != nil {
138 if errors.Is(err, ErrCASConflict) && len(identity) == 0 {
139 return nil
140 }
141 return err
142 }
143 return nil
144}
145func TouchSidecarWake(ctx context.Context, s Store, acct, app, name, by, reason string, identity ...SidecarWakeIdentity) (claimResult0 error) {
146 claimResult0 = WithAppMutation(ctx, s, acct, app, "release.TouchSidecarWake", func(claimContext context.Context) error {
147 claimResult0 = claimRawTouchSidecarWake(claimContext, s, acct, app, name, by, reason, identity...)
148 return claimResult0
149 })
150 return
151}
152