carlos / platform Public

provision: sidecar manifest and record shapes

Paul Campbell pushed by paul@keymail.dev 8a3af0852af9a754ff4e81d64d5da8c8c6dcbfa0 parent 92d782e
2 files changed, +520 −0
  • internal/provision/sidecar.go +383 −0
  • internal/provision/sidecar_test.go +137 −0
diff --git a/internal/provision/sidecar.go b/internal/provision/sidecar.go
new file mode 100644
index 000000000..474764e9a
--- /dev/null
+++ b/internal/provision/sidecar.go
@@ -0,0 +1,383 @@
+// Sidecar records and manifests are the second record family this package
+// owns, one directory over from the instance family in provision.go: a
+// sidecar is a process the platform runs that serves no requests and that
+// nothing can dial (design: sidecar members). The wire shapes, key
+// conventions and store operations below mirror the instance family's
+// verbatim — same CAS discipline, same identity-vs-path agreement check,
+// same absence-means-disabled convention — because the two families are the
+// same problem (an app's opt-in manifest governing a collection of
+// create-only records) with different reachability rules.
+package provision
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// SidecarSuffix is the reserved, never-resolvable suffix every sidecar host
+// carries. It is refused at every door where a name could become routable
+// (see cli.go's addRail, the route door, and domain attach) — reservation
+// is enforced, not documented.
+const SidecarSuffix = ".sidecar.internal"
+
+// PlacementAny and PlacementNoInbound are the closed placement vocabulary a
+// sidecar record's Placement field is checked against (ValidateSidecarShape).
+const (
+ PlacementAny = "any"
+ PlacementNoInbound = "no-inbound"
+)
+
+// SidecarManifest is the wire shape at apps/<sqid>/<app>/sidecars.json — the
+// app's opt-in to sidecar provisioning and the policy every record is
+// checked against, the same role Manifest plays for instances. Absence
+// means the feature does not exist for that app (design §3.1's convention,
+// carried over unchanged).
+type SidecarManifest struct {
+ Schema int `json:"schema"` // 1
+ App string `json:"app"`
+ Pool string `json:"pool,omitempty"`
+ Channel string `json:"channel,omitempty"`
+ Placement string `json:"placement,omitempty"`
+ Regions []string `json:"regions,omitempty"`
+ Args []string `json:"args,omitempty"`
+ // Hibernate is a POINTER for the same reason Record.Hibernate is: absent
+ // and false are different facts, and nil must resolve through
+ // HibernateOrDefault rather than read as an explicit false.
+ Hibernate *bool `json:"hibernate,omitempty"`
+ // IdleSeconds and MaxRunSeconds are integer SECONDS on the wire, not Go
+ // duration strings — the existing records use plain JSON scalars and a
+ // string would invite "5 minutes".
+ IdleSeconds int `json:"idle_seconds,omitempty"`
+ MaxRunSeconds int `json:"max_run_seconds,omitempty"`
+}
+
+// SidecarRecord is the wire shape at
+// apps/<sqid>/<app>/sidecars/<name>.json — one per provisioned sidecar,
+// create-only so a lost race is a 409 and never a silent overwrite. The body
+// must agree with its own key path (Name/App/Account vs the key's
+// segments); GetSidecarRecord enforces that — a record that lies about
+// identity is never guessed at, the same discipline GetRecord applies to
+// instances.
+type SidecarRecord struct {
+ Schema int `json:"schema"` // 1
+ Name string `json:"name"`
+ Host string `json:"host"`
+ App string `json:"app"`
+ Account string `json:"account"`
+ Pool string `json:"pool,omitempty"`
+ Region string `json:"region,omitempty"`
+ Channel string `json:"channel"`
+ Placement string `json:"placement,omitempty"`
+ Environment string `json:"environment,omitempty"`
+ CreatedAt string `json:"created_at,omitempty"`
+ CreatedBy string `json:"created_by,omitempty"`
+}
+
+// SidecarHost builds a sidecar's synthetic host. It is the registry key, the
+// bucket key segment and the on-disk path segment, which is why every
+// component is validated before it is ever joined.
+func SidecarHost(name, app, sqid string) string {
+ return name + "." + app + "." + sqid + SidecarSuffix
+}
+
+// ValidSidecarName pins one DNS label exactly as ValidHost pins an
+// instance's: the name becomes a bucket key segment and the <host>.db /
+// cgroup path segments, so the label regex is what keeps a hostile name an
+// opaque, safe path component.
+func ValidSidecarName(name string) bool { return labelRe.MatchString(name) }
+
+// ValidSidecarHost reports whether host is exactly SidecarHost(name, app,
+// sqid) for some valid name — the mirror check a route or domain-attach door
+// runs before treating a host as a sidecar's own.
+func ValidSidecarHost(host, app, sqid string) bool {
+ rest, ok := strings.CutSuffix(host, SidecarSuffix)
+ if !ok {
+ return false
+ }
+ name, tail, ok := strings.Cut(rest, ".")
+ if !ok || tail != app+"."+sqid {
+ return false
+ }
+ return ValidSidecarName(name)
+}
+
+// SidecarManifestKey, SidecarsPrefix and SidecarRecordKey are the bucket key
+// conventions for a provisioned sidecar app — beside release.AppPrefix's
+// channels/ and releases/, and beside the instance family's own
+// instances.json/instances/, matching the same per-app-manifest-beside-its-
+// collection precedent.
+func SidecarManifestKey(acct, app string) string {
+ return release.AppPrefix(acct, app) + "sidecars.json"
+}
+func SidecarsPrefix(acct, app string) string {
+ return release.AppPrefix(acct, app) + "sidecars/"
+}
+func SidecarRecordKey(acct, app, name string) string {
+ return SidecarsPrefix(acct, app) + name + ".json"
+}
+
+// ParseSidecarKey is ParseRecordKey with "instances/" swapped for
+// "sidecars/": it reports whether key is an account-qualified sidecar
+// record (apps/<sqid>/<app>/sidecars/<name>.json) known to belong to a
+// materialized account, and if so its account, app and name. known guards
+// against a key whose account has not materialized yet, and app is refused
+// when it collides with the release plane's own words ("channels",
+// "releases") — the same two-namespace collision ParseRecordKey documents.
+// A key with anything but exactly one non-empty segment after "sidecars/"
+// is not a record: the manifest (sidecars.json, no trailing segment) and
+// any nested path both refuse.
+func ParseSidecarKey(key string, known map[string]bool) (acct, app, name string, ok bool) {
+ rest, ok := strings.CutPrefix(key, "apps/")
+ if !ok {
+ return "", "", "", false
+ }
+ acct, rest, ok = strings.Cut(rest, "/")
+ if !ok || !known[acct] {
+ return "", "", "", false
+ }
+ app, rest, ok = strings.Cut(rest, "/")
+ if !ok || app == "" || app == "channels" || app == "releases" {
+ return "", "", "", false
+ }
+ seg, rest, ok := strings.Cut(rest, "/")
+ if !ok || seg != "sidecars" {
+ return "", "", "", false
+ }
+ if rest == "" || strings.Contains(rest, "/") {
+ return "", "", "", false
+ }
+ name, ok = strings.CutSuffix(rest, ".json")
+ if !ok || name == "" {
+ return "", "", "", false
+ }
+ return acct, app, name, true
+}
+
+// IdleOrDefault resolves the manifest's idle timeout: IdleSeconds if
+// positive, else 5 minutes.
+func (m SidecarManifest) IdleOrDefault() time.Duration {
+ if m.IdleSeconds > 0 {
+ return time.Duration(m.IdleSeconds) * time.Second
+ }
+ return 5 * time.Minute
+}
+
+// MaxRunOrDefault resolves the manifest's max run duration: MaxRunSeconds if
+// positive, else 1 hour.
+func (m SidecarManifest) MaxRunOrDefault() time.Duration {
+ if m.MaxRunSeconds > 0 {
+ return time.Duration(m.MaxRunSeconds) * time.Second
+ }
+ return time.Hour
+}
+
+// HibernateOrDefault resolves the tri-state. An explicit value wins; absent
+// defaults true — matching how instance records carry Hibernate, so an
+// explicit false must win over the default.
+func (m SidecarManifest) HibernateOrDefault() bool {
+ if m.Hibernate != nil {
+ return *m.Hibernate
+ }
+ return true
+}
+
+// PlacementOrDefault resolves the manifest's placement policy: Placement if
+// set, else PlacementAny.
+func (m SidecarManifest) PlacementOrDefault() string {
+ if m.Placement == "" {
+ return PlacementAny
+ }
+ return m.Placement
+}
+
+// ValidateSidecarShape checks a sidecar record's fields at the door, before
+// it is written or materialized — the rails live at the door, exactly as
+// ValidateShape's do for instance records.
+func ValidateSidecarShape(rec SidecarRecord) error {
+ if rec.Schema > 1 {
+ return fmt.Errorf("schema %d is newer than this binary understands — upgrade carlos", rec.Schema)
+ }
+ if !ValidSidecarName(rec.Name) {
+ return fmt.Errorf("name %q: want a single DNS label (lowercase a-z, 0-9, hyphen, 1-63 chars, no leading/trailing hyphen)", rec.Name)
+ }
+ if rec.App == "" {
+ return errors.New("app: must not be empty")
+ }
+ if rec.Account == "" {
+ return errors.New("account: must not be empty")
+ }
+ switch rec.Placement {
+ case "", PlacementAny, PlacementNoInbound:
+ default:
+ return fmt.Errorf("placement %q: want %q or %q (empty means %q)", rec.Placement, PlacementAny, PlacementNoInbound, PlacementAny)
+ }
+ if rec.Region != "" && !ValidRegion(rec.Region) {
+ return fmt.Errorf("region %q: want [a-z0-9-]{0,16}", rec.Region)
+ }
+ if rec.Pool != "" && !ValidPool(rec.Pool) {
+ return fmt.Errorf("pool %q: want [a-z0-9-]{0,16}", rec.Pool)
+ }
+ return nil
+}
+
+// GetSidecarManifest reads app's sidecars manifest. Not-found stays
+// os.ErrNotExist-wrapped (absence IS the "sidecars disabled" answer, not an
+// error condition the caller must special-case beyond errors.Is). A schema
+// this binary doesn't understand is refused rather than silently truncated
+// by json.Unmarshal.
+func GetSidecarManifest(ctx context.Context, s release.Store, acct, app string) (*SidecarManifest, error) {
+ m, _, err := GetSidecarManifestETag(ctx, s, acct, app)
+ return m, err
+}
+
+// GetSidecarManifestETag is GetSidecarManifest carrying the etag its bytes
+// were read under — for a caller that must write back GUARDED against what
+// it read (PutSidecarManifestIfMatch), not against whatever is there at
+// write time.
+func GetSidecarManifestETag(ctx context.Context, s release.Store, acct, app string) (*SidecarManifest, string, error) {
+ raw, etag, err := s.Get(ctx, SidecarManifestKey(acct, app))
+ if err != nil {
+ return nil, "", err
+ }
+ var m SidecarManifest
+ if err := json.Unmarshal(raw, &m); err != nil {
+ return nil, "", fmt.Errorf("provision: sidecar manifest %s/%s: %w", acct, app, err)
+ }
+ if m.Schema > 1 {
+ return nil, "", fmt.Errorf("provision: sidecar manifest %s/%s: schema %d is newer than this binary understands — upgrade carlos", acct, app, m.Schema)
+ }
+ return &m, etag, nil
+}
+
+// PutSidecarManifest publishes app's sidecars manifest — a CAS upsert (read
+// etag, put, one retry on a lost race), the PutManifest shape, because
+// unlike a record this object is meant to be overwritten as an operator
+// adjusts policy, not created once.
+func PutSidecarManifest(ctx context.Context, s release.Store, acct, app string, m SidecarManifest) error {
+ key := SidecarManifestKey(acct, app)
+ body, err := json.Marshal(m)
+ if err != nil {
+ return err
+ }
+ put := func() error {
+ etag := ""
+ if _, cur, err := s.Get(ctx, key); err == nil {
+ etag = cur
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ _, err := s.Put(ctx, key, body, etag)
+ return err
+ }
+ if err := put(); errors.Is(err, release.ErrCASConflict) {
+ return put() // one retry on a lost race; a second loss surfaces
+ } else if err != nil {
+ return err
+ }
+ return nil
+}
+
+// PutSidecarManifestIfMatch writes app's sidecars manifest guarded by the
+// etag the caller READ it under — "" meaning it read nothing, which makes
+// the write create-only. Unlike PutSidecarManifest's upsert (which re-reads
+// inside and retries, so the last writer always wins) a lost race here
+// surfaces as release.ErrCASConflict for the caller to answer.
+func PutSidecarManifestIfMatch(ctx context.Context, s release.Store, acct, app string, m SidecarManifest, etag string) error {
+ body, err := json.Marshal(m)
+ if err != nil {
+ return err
+ }
+ _, err = s.Put(ctx, SidecarManifestKey(acct, app), body, etag)
+ return err
+}
+
+// CreateSidecarRecord writes a new sidecar record, create-only: a lost race
+// against a second create for the same name comes back wrapping
+// release.ErrCASConflict, never a silent overwrite of the winner's record.
+func CreateSidecarRecord(ctx context.Context, s release.Store, rec SidecarRecord) error {
+ body, err := json.Marshal(rec)
+ if err != nil {
+ return err
+ }
+ _, err = s.Put(ctx, SidecarRecordKey(rec.Account, rec.App, rec.Name), body, "")
+ return err
+}
+
+// GetSidecarRecord reads and validates one sidecar record. A body whose own
+// Name/App/Account disagree with the key path it was read from is refused
+// rather than trusted — the same "never guessed at" discipline GetRecord
+// applies to instance records.
+func GetSidecarRecord(ctx context.Context, s release.Store, acct, app, name string) (*SidecarRecord, error) {
+ raw, _, err := s.Get(ctx, SidecarRecordKey(acct, app, name))
+ if err != nil {
+ return nil, err
+ }
+ var rec SidecarRecord
+ if err := json.Unmarshal(raw, &rec); err != nil {
+ return nil, fmt.Errorf("provision: sidecar record %s/%s/%s: %w", acct, app, name, err)
+ }
+ if rec.Account != acct || rec.App != app || rec.Name != name {
+ return nil, fmt.Errorf("provision: sidecar record %s/%s/%s: record disagrees with its own path", acct, app, name)
+ }
+ return &rec, nil
+}
+
+// ListSidecarRecords lists an app's sidecar records, sorted by name, capped
+// at max with truncated reporting whether more existed. The cap is decided
+// on the KEY count — how many sidecars/<name>.json objects exist under the
+// prefix — not on how many bodies successfully parse, the same rule
+// ListRecords applies. A malformed or path-disagreeing body (GetSidecarRecord's
+// own checks) is skipped, not fatal.
+//
+// A negative max is clamped to 0 rather than treated as "no cap", the same
+// safe-degrade ListRecords applies.
+func ListSidecarRecords(ctx context.Context, s release.Store, acct, app string, max int) (recs []SidecarRecord, truncated bool, err error) {
+ if max < 0 {
+ max = 0
+ }
+ prefix := SidecarsPrefix(acct, app)
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return nil, false, err
+ }
+ var names []string
+ for _, key := range keys {
+ rest, ok := strings.CutPrefix(key, prefix)
+ if !ok || rest == "" || strings.Contains(rest, "/") {
+ continue // not a record at this level (e.g. a nested key never written here)
+ }
+ name, ok := strings.CutSuffix(rest, ".json")
+ if !ok || name == "" {
+ continue
+ }
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ if len(names) > max {
+ truncated = true
+ names = names[:max]
+ }
+ for _, name := range names {
+ rec, err := GetSidecarRecord(ctx, s, acct, app, name)
+ if err != nil {
+ continue
+ }
+ recs = append(recs, *rec)
+ }
+ return recs, truncated, nil
+}
+
+// DeleteSidecarRecord retires a sidecar's provisioning authorization. Object
+// deletion only; Store's Delete is idempotent, so retiring an already-gone
+// record is not an error.
+func DeleteSidecarRecord(ctx context.Context, s release.Store, acct, app, name string) error {
+ return s.Delete(ctx, SidecarRecordKey(acct, app, name))
+}
diff --git a/internal/provision/sidecar_test.go b/internal/provision/sidecar_test.go
new file mode 100644
index 000000000..813e6df0c
--- /dev/null
+++ b/internal/provision/sidecar_test.go
@@ -0,0 +1,137 @@
+package provision
+
+import (
+ "context"
+ "errors"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestSidecarHostAndValidation(t *testing.T) {
+ got := SidecarHost("bot-acme", "eleven", "bdm")
+ if want := "bot-acme.eleven.bdm.sidecar.internal"; got != want {
+ t.Fatalf("SidecarHost = %q, want %q", got, want)
+ }
+ if !ValidSidecarHost(got, "eleven", "bdm") {
+ t.Fatal("its own SidecarHost output must validate")
+ }
+ for _, bad := range []string{
+ "bot-acme.other.bdm.sidecar.internal", // wrong app
+ "bot-acme.eleven.bad.sidecar.internal", // wrong sqid
+ "bot-acme.eleven.bdm.example.com", // wrong suffix
+ "a.b.bot.eleven.bdm.sidecar.internal", // more than one label
+ } {
+ if ValidSidecarHost(bad, "eleven", "bdm") {
+ t.Fatalf("ValidSidecarHost(%q) = true, want false", bad)
+ }
+ }
+ for _, bad := range []string{"", "-lead", "trail-", "UPPER", "has.dot", "has/slash", "..", strings.Repeat("a", 64)} {
+ if ValidSidecarName(bad) {
+ t.Fatalf("ValidSidecarName(%q) = true — the name becomes a bucket key AND a filesystem path segment", bad)
+ }
+ }
+}
+
+func TestParseSidecarKey(t *testing.T) {
+ known := map[string]bool{"bdm": true}
+ acct, app, name, ok := ParseSidecarKey("apps/bdm/eleven/sidecars/bot-acme.json", known)
+ if !ok || acct != "bdm" || app != "eleven" || name != "bot-acme" {
+ t.Fatalf("ParseSidecarKey = %q/%q/%q ok=%v", acct, app, name, ok)
+ }
+ for _, bad := range []string{
+ "apps/bdm/eleven/sidecars.json", // the manifest, not a record
+ "apps/bdm/eleven/sidecars/nested/x.json", // nested
+ "apps/bdm/eleven/sidecars/.json", // empty name
+ "apps/unknown/eleven/sidecars/bot.json", // account not materialized
+ "apps/bdm/channels/sidecars/bot.json", // release-plane collision
+ "apps/bdm/eleven/instances/bot.json", // the other family
+ } {
+ if _, _, _, ok := ParseSidecarKey(bad, known); ok {
+ t.Fatalf("ParseSidecarKey(%q) parsed, want refusal", bad)
+ }
+ }
+}
+
+func TestCreateSidecarRecordIsCreateOnly(t *testing.T) {
+ ctx := context.Background()
+ s := dirStore(t) // the helper reconcile_test.go already uses
+ rec := SidecarRecord{
+ Schema: 1, Name: "bot-acme", Host: SidecarHost("bot-acme", "eleven", "bdm"),
+ App: "eleven", Account: "bdm", Channel: "stable",
+ }
+ if err := CreateSidecarRecord(ctx, s, rec); err != nil {
+ t.Fatalf("first create: %v", err)
+ }
+ err := CreateSidecarRecord(ctx, s, rec)
+ if !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("second create err = %v, want ErrCASConflict — a lost race must never silently overwrite", err)
+ }
+}
+
+func TestGetSidecarRecordRefusesRecordThatLiesAboutItsPath(t *testing.T) {
+ ctx := context.Background()
+ s := dirStore(t)
+ body := []byte(`{"schema":1,"name":"bot-acme","app":"OTHER","account":"bdm"}`)
+ if _, err := s.Put(ctx, SidecarRecordKey("bdm", "eleven", "bot-acme"), body, ""); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ if _, err := GetSidecarRecord(ctx, s, "bdm", "eleven", "bot-acme"); err == nil {
+ t.Fatal("a record disagreeing with its own key path must be refused, never guessed at")
+ }
+}
+
+func TestSidecarManifestDefaults(t *testing.T) {
+ var m SidecarManifest
+ if m.IdleOrDefault() != 5*time.Minute {
+ t.Fatalf("idle default = %v, want 5m", m.IdleOrDefault())
+ }
+ if m.MaxRunOrDefault() != time.Hour {
+ t.Fatalf("max_run default = %v, want 1h", m.MaxRunOrDefault())
+ }
+ if !m.HibernateOrDefault() {
+ t.Fatal("hibernate must default true")
+ }
+ if m.PlacementOrDefault() != "any" {
+ t.Fatalf("placement default = %q, want any", m.PlacementOrDefault())
+ }
+ no := false
+ m2 := SidecarManifest{Hibernate: &no}
+ if m2.HibernateOrDefault() {
+ t.Fatal("an explicit false must win — tri-state, like instances' Hibernate")
+ }
+}
+
+func TestSidecarManifestSchemaNewerIsRefused(t *testing.T) {
+ ctx := context.Background()
+ s := dirStore(t)
+ if _, err := s.Put(ctx, SidecarManifestKey("bdm", "eleven"), []byte(`{"schema":2}`), ""); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ if _, err := GetSidecarManifest(ctx, s, "bdm", "eleven"); err == nil {
+ t.Fatal("a schema newer than this binary understands must be refused, not silently truncated")
+ }
+}
+
+func TestSidecarManifestAbsenceIsNotExist(t *testing.T) {
+ _, err := GetSidecarManifest(context.Background(), dirStore(t), "bdm", "eleven")
+ if !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("absence err = %v, want os.ErrNotExist — absence means the feature does not exist for that app", err)
+ }
+}
+
+func TestValidateSidecarShape(t *testing.T) {
+ ok := SidecarRecord{Schema: 1, Name: "bot", Host: SidecarHost("bot", "eleven", "bdm"),
+ App: "eleven", Account: "bdm", Channel: "stable", Placement: "no-inbound"}
+ if err := ValidateSidecarShape(ok); err != nil {
+ t.Fatalf("valid record refused: %v", err)
+ }
+ bad := ok
+ bad.Placement = "sideways"
+ if err := ValidateSidecarShape(bad); err == nil {
+ t.Fatal("placement vocabulary must be closed")
+ }
+}