package relay import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "os" "time" "github.com/carlosframework/platform/internal/release" ) // RecordSchema is the current Record schema version. const RecordSchema = 1 // RecordKey is the per-app record's location in the deployment bucket. func RecordKey(acct, app string) string { return release.AppPrefix(acct, app) + "relay.json" } // RouteCutover is one member's ack for a TCP exec route (spec §5): the // address they're cutting to, the per-region suffixes they've picked up, // and who/when. type RouteCutover struct { Host string `json:"host"` Suffixes map[string]string `json:"suffixes"` // region -> suffix At time.Time `json:"at"` By string `json:"by"` } // Record is the per-app relay state (spec §2, §5): whether the relay is // enabled for the app, its current key incarnation, the regions it's live // in, the in-flight rotation op (if any), and the bookkeeping a rotation // needs to converge. type Record struct { Schema int `json:"schema"` Enabled bool `json:"enabled"` Incarnation string `json:"incarnation"` Regions []string `json:"regions"` Op int64 `json:"op"` Phase string `json:"phase"` // "config"|"aggregate"|"done" IssuedSuffixes map[string]string `json:"issued_suffixes"` // region -> username_suffix last issued // IssuedDigest is hex(sha256) of the sealed CARLOS_RELAY envelope the // console last WROTE into the app's config bundle. Additive (omitempty, // no schema bump): a record written before this field simply has none, // and the console's reconcile re-applies once to fill it in. // // It exists because the console cannot open the envelope it wrote — it // holds no decryption key — and two seals of identical plaintext differ // anyway. Without a digest the reconcile can only compare the record to // itself, and a bundle carrying a DIFFERENT envelope (a straggler write // from a superseded console, or a member's own `carlos secrets set // CARLOS_RELAY`) reads as converged forever. With it, "the bundle holds // exactly what we last wrote" is a fact the console can check. IssuedDigest string `json:"issued_digest,omitempty"` Cutovers []RouteCutover `json:"cutovers"` // member acks for TCP exec routes EnabledAt time.Time `json:"enabled_at"` DisabledAt time.Time `json:"disabled_at"` } // GetRecord reads the record. Absent is (nil, "", nil) — same idiom as // objstore.Get, because "no relay" is a state, not an error. func GetRecord(ctx context.Context, s release.Store, acct, app string) (*Record, string, error) { if !release.ValidAccount(acct) { return nil, "", fmt.Errorf("relay: invalid account %q", acct) } if !release.ValidApp(app) { return nil, "", fmt.Errorf("relay: invalid app %q", app) } raw, etag, err := s.Get(ctx, RecordKey(acct, app)) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, "", nil } return nil, "", err } var rec Record if err := json.Unmarshal(raw, &rec); err != nil { return nil, "", fmt.Errorf("relay: %s: %w", RecordKey(acct, app), err) } return &rec, etag, nil } // PutRecord writes the record with the caller's CAS position; etag "" is // create-only. Every writer is a read-modify-write, so a lost race is // release.ErrCASConflict for the caller to surface, never to absorb. func PutRecord(ctx context.Context, s release.Store, acct, app string, r *Record, etag string) (string, error) { r.Schema = RecordSchema body, err := json.MarshalIndent(r, "", " ") if err != nil { return "", err } return s.Put(ctx, RecordKey(acct, app), append(body, '\n'), etag) } // NewIncarnation is 16 lowercase hex characters from crypto/rand — a fresh // key incarnation, distinct from every prior one with overwhelming // probability. func NewIncarnation() string { var b [8]byte if _, err := rand.Read(b[:]); err != nil { panic("relay: crypto/rand: " + err.Error()) } return hex.EncodeToString(b[:]) }