carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package relay
2
3import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "os"
11 "time"
12
13 "github.com/carlosframework/platform/internal/release"
14)
15
16// RecordSchema is the current Record schema version.
17const RecordSchema = 1
18
19// RecordKey is the per-app record's location in the deployment bucket.
20func RecordKey(acct, app string) string {
21 return release.AppPrefix(acct, app) + "relay.json"
22}
23
24// RouteCutover is one member's ack for a TCP exec route (spec §5): the
25// address they're cutting to, the per-region suffixes they've picked up,
26// and who/when.
27type RouteCutover struct {
28 Host string `json:"host"`
29 Suffixes map[string]string `json:"suffixes"` // region -> suffix
30 At time.Time `json:"at"`
31 By string `json:"by"`
32}
33
34// Record is the per-app relay state (spec §2, §5): whether the relay is
35// enabled for the app, its current key incarnation, the regions it's live
36// in, the in-flight rotation op (if any), and the bookkeeping a rotation
37// needs to converge.
38type Record struct {
39 Schema int `json:"schema"`
40 Enabled bool `json:"enabled"`
41 Incarnation string `json:"incarnation"`
42 Regions []string `json:"regions"`
43 Op int64 `json:"op"`
44 Phase string `json:"phase"` // "config"|"aggregate"|"done"
45 IssuedSuffixes map[string]string `json:"issued_suffixes"` // region -> username_suffix last issued
46 // IssuedDigest is hex(sha256) of the sealed CARLOS_RELAY envelope the
47 // console last WROTE into the app's config bundle. Additive (omitempty,
48 // no schema bump): a record written before this field simply has none,
49 // and the console's reconcile re-applies once to fill it in.
50 //
51 // It exists because the console cannot open the envelope it wrote — it
52 // holds no decryption key — and two seals of identical plaintext differ
53 // anyway. Without a digest the reconcile can only compare the record to
54 // itself, and a bundle carrying a DIFFERENT envelope (a straggler write
55 // from a superseded console, or a member's own `carlos secrets set
56 // CARLOS_RELAY`) reads as converged forever. With it, "the bundle holds
57 // exactly what we last wrote" is a fact the console can check.
58 IssuedDigest string `json:"issued_digest,omitempty"`
59 Cutovers []RouteCutover `json:"cutovers"` // member acks for TCP exec routes
60 EnabledAt time.Time `json:"enabled_at"`
61 DisabledAt time.Time `json:"disabled_at"`
62}
63
64// GetRecord reads the record. Absent is (nil, "", nil) — same idiom as
65// objstore.Get, because "no relay" is a state, not an error.
66func GetRecord(ctx context.Context, s release.Store, acct, app string) (*Record, string, error) {
67 if !release.ValidAccount(acct) {
68 return nil, "", fmt.Errorf("relay: invalid account %q", acct)
69 }
70 if !release.ValidApp(app) {
71 return nil, "", fmt.Errorf("relay: invalid app %q", app)
72 }
73 raw, etag, err := s.Get(ctx, RecordKey(acct, app))
74 if err != nil {
75 if errors.Is(err, os.ErrNotExist) {
76 return nil, "", nil
77 }
78 return nil, "", err
79 }
80 var rec Record
81 if err := json.Unmarshal(raw, &rec); err != nil {
82 return nil, "", fmt.Errorf("relay: %s: %w", RecordKey(acct, app), err)
83 }
84 return &rec, etag, nil
85}
86
87// PutRecord writes the record with the caller's CAS position; etag "" is
88// create-only. Every writer is a read-modify-write, so a lost race is
89// release.ErrCASConflict for the caller to surface, never to absorb.
90func PutRecord(ctx context.Context, s release.Store, acct, app string, r *Record, etag string) (string, error) {
91 r.Schema = RecordSchema
92 body, err := json.MarshalIndent(r, "", " ")
93 if err != nil {
94 return "", err
95 }
96 return s.Put(ctx, RecordKey(acct, app), append(body, '\n'), etag)
97}
98
99// NewIncarnation is 16 lowercase hex characters from crypto/rand — a fresh
100// key incarnation, distinct from every prior one with overwhelming
101// probability.
102func NewIncarnation() string {
103 var b [8]byte
104 if _, err := rand.Read(b[:]); err != nil {
105 panic("relay: crypto/rand: " + err.Error())
106 }
107 return hex.EncodeToString(b[:])
108}
109