// The wake signal for a hibernating sidecar: one tiny CAS-guarded object per // sidecar, watched by etag, exactly one door over from restartgen.go's // restart signal and appconfig's config-gen object — the same mechanism a // third time, because "tell a box to act, with no push channel and no box // access" is one problem no matter what the box is being told to do. // // Bucket layout addition: // // apps///sidecar-wake/.json CAS-guarded, schema-1 // // It is a SIBLING prefix under the app, deliberately not a key under // sidecars/: provision.ParseSidecarKey treats anything but exactly one // non-empty segment after "sidecars/" as not-a-record, and a wake object // living inside that prefix would either have to be taught to that parser // (widening what it accepts) or silently misparse as a sidecar name of // ".json" — either way the family's "exactly one segment" rule stops // meaning what it says. A sibling prefix costs nothing and keeps that rule // exactly as strict as it reads. package release import ( "context" "encoding/json" "errors" "fmt" "os" "regexp" "time" ) // SidecarWake is the object a wake request touches. Change detection is by // ETAG, never by Generation: a monotonic counter that resets — a bucket // restored from backup, an object rewritten by a script that does not know // the current value — must not wedge a box's cache into believing every // future request lands on a value it has "already seen". Generation, At, By // and Reason exist so a human debugging "why did this sidecar wake?" has an // answer; a box reading this object for its own purposes reads only its // etag and never unmarshals the body at all. // // It carries no authority: the worst a forged touch can do is wake a // sidecar that was going to run under its own signed release anyway. Waking // is not deciding what runs, the same way a restart-generation touch isn't. type SidecarWakeIdentity struct { AppID string `json:"app_id,omitempty"` Incarnation string `json:"incarnation,omitempty"` RequestID string `json:"request_id,omitempty"` } type SidecarWake struct { SidecarWakeIdentity Schema int `json:"schema"` // 1 Generation int `json:"generation"` At string `json:"at"` By string `json:"by"` Reason string `json:"reason"` } // SidecarWakeKey is the bucket key of name's wake object within app, derived // from AppPrefix like every other per-app key. func SidecarWakeKey(acct, app, name string) string { return AppPrefix(acct, app) + "sidecar-wake/" + name + ".json" } // sidecarWakeNameRe pins a sidecar's name to one DNS label — the same // pattern provision.ValidSidecarName (labelRe) checks, mirrored locally // rather than imported: provision already imports release for the bucket // key conventions, so release importing provision back would be a cycle. // The name becomes a bucket key segment here exactly as it does there, so // the same discipline applies: this is what keeps a hostile name an opaque, // safe path component rather than a traversal. var sidecarWakeNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) func validSidecarWakeName(name string) bool { return sidecarWakeNameRe.MatchString(name) } // TouchSidecarWake bumps name's wake generation within app — the request // every box serving that sidecar converges on within seconds. by and reason // are carried verbatim onto the object for a human reading it during an // incident; neither is validated, because neither is ever parsed back into // a decision (see SidecarWake's own doc comment on carrying no authority). // // A CAS conflict is SUCCESS, not an error, for exactly TouchRestartGen's // reason: it means another writer touched the object between our read and // our write, so the signal this function exists to fire has already fired. // Retrying would only ask a sidecar that is already waking to wake again. // // os.ErrNotExist on the read means first touch (Generation starts at 1), // which is what makes the conflict carve-out safe here too: two first // touches race, one wins, both callers are correct — both asked for a wake, // and exactly one wake is what the box will do (WakeWorker is idempotent // regardless). func claimRawTouchSidecarWake(ctx context.Context, s Store, acct, app, name, by, reason string, identity ...SidecarWakeIdentity) error { if !ValidAccount(acct) { return fmt.Errorf("sidecar wake: invalid account %q", acct) } if !ValidApp(app) { return fmt.Errorf("sidecar wake: invalid app %q", app) } if !validSidecarWakeName(name) { return fmt.Errorf("sidecar wake: invalid sidecar name %q", name) } var binding SidecarWakeIdentity if len(identity) > 1 { return fmt.Errorf("sidecar wake: multiple identities") } if len(identity) == 1 { binding = identity[0] if binding.AppID == "" || binding.Incarnation == "" || binding.RequestID == "" { return fmt.Errorf("sidecar wake: incomplete identity") } } key := SidecarWakeKey(acct, app, name) raw, etag, err := s.Get(ctx, key) switch { case errors.Is(err, os.ErrNotExist): etag = "" case err != nil: return err } gen := 0 if err == nil { var prev SidecarWake if uerr := json.Unmarshal(raw, &prev); uerr == nil { gen = prev.Generation } } body, err := json.Marshal(SidecarWake{ SidecarWakeIdentity: binding, Schema: 1, Generation: gen + 1, At: time.Now().UTC().Format(time.RFC3339), By: by, Reason: reason, }) if err != nil { return err } if _, err := s.Put(ctx, key, body, etag); err != nil { if errors.Is(err, ErrCASConflict) && len(identity) == 0 { return nil } return err } return nil } func TouchSidecarWake(ctx context.Context, s Store, acct, app, name, by, reason string, identity ...SidecarWakeIdentity) (claimResult0 error) { claimResult0 = WithAppMutation(ctx, s, acct, app, "release.TouchSidecarWake", func(claimContext context.Context) error { claimResult0 = claimRawTouchSidecarWake(claimContext, s, acct, app, name, by, reason, identity...) return claimResult0 }) return }