| 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)) |
| +} |