// 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/controlname" "github.com/carlosframework/platform/internal/registry" "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///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///sidecars/.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 { // RecordSeq is advanced by every record writer once present; absent is legacy zero. RecordSeq uint64 `json:"record_seq,omitempty"` Acquisition string `json:"acq,omitempty"` 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 time.Time `json:"created_at"` 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 .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 controlname.ValidLabel(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///sidecars/.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) } // A region is either a region CODE or a fleet placement target — the two // grammars Route.Region carries, exactly as an instance record's does. // registry.FleetPlacement is the one parser for the second, never a // prefix test written out again here. // // This rail is not where user input is policed: the console's create door // validates a REQUESTED region against ValidRegion and the manifest's // allow-list, and a fleet-placed app never reaches that path at all // because its region is the app's placement rather than anything the // caller sent (apisidecars.go). What this rail is for is the other // direction — the reconcile pass PROTECTS a record whose shape it cannot // trust, so refusing the placement grammar here would make every // fleet-placed sidecar permanently unmintable with no error anywhere. if rec.Region != "" && !ValidRegion(rec.Region) { if _, ok := registry.FleetPlacement(rec.Region); !ok { return fmt.Errorf("region %q: want [a-z0-9-]{0,16} or a fleet placement target (fleet/)", 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 claimRawPutSidecarManifest(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 claimRawPutSidecarManifestIfMatch(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 = WriteRecord(ctx, s, SidecarRecordKey(rec.Account, rec.App, rec.Name), []byte(`{}`), 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/.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 } // ListSidecarRecordsStrict lists every one of an app's sidecar records, // with no cap, and fails on any record it cannot read or parse. A record // deleted between the list and its read is skipped. It is for decisions // that must see every worker: plans 3b grants always-on on an app's own // fleet only once each sidecar is there, and a skipped record would be a // worker nobody checked (Astra plans-3b round 2 finding 1). func ListSidecarRecordsStrict(ctx context.Context, s release.Store, acct, app string) ([]SidecarRecord, error) { prefix := SidecarsPrefix(acct, app) keys, err := s.List(ctx, prefix) if err != nil { return nil, err } var recs []SidecarRecord for _, key := range keys { rest, ok := strings.CutPrefix(key, prefix) if !ok || rest == "" || strings.Contains(rest, "/") { continue } name, ok := strings.CutSuffix(rest, ".json") if !ok || name == "" { continue } rec, err := GetSidecarRecord(ctx, s, acct, app, name) if errors.Is(err, os.ErrNotExist) { continue } if err != nil { return nil, err } recs = append(recs, *rec) } return recs, 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)) } func PutSidecarManifest(ctx context.Context, s release.Store, acct, app string, m SidecarManifest) (claimResult0 error) { claimResult0 = release.WithAppMutation(ctx, s, acct, app, "provision.PutSidecarManifest", func(claimContext context.Context) error { claimResult0 = claimRawPutSidecarManifest(claimContext, s, acct, app, m) return claimResult0 }) return } func PutSidecarManifestIfMatch(ctx context.Context, s release.Store, acct, app string, m SidecarManifest, etag string) (claimResult0 error) { claimResult0 = release.WithAppMutation(ctx, s, acct, app, "provision.PutSidecarManifestIfMatch", func(claimContext context.Context) error { claimResult0 = claimRawPutSidecarManifestIfMatch(claimContext, s, acct, app, m, etag) return claimResult0 }) return }