| 1 | // Sidecar records and manifests are the second record family this package |
| 2 | // owns, one directory over from the instance family in provision.go: a |
| 3 | // sidecar is a process the platform runs that serves no requests and that |
| 4 | // nothing can dial (design: sidecar members). The wire shapes, key |
| 5 | // conventions and store operations below mirror the instance family's |
| 6 | // verbatim — same CAS discipline, same identity-vs-path agreement check, |
| 7 | // same absence-means-disabled convention — because the two families are the |
| 8 | // same problem (an app's opt-in manifest governing a collection of |
| 9 | // create-only records) with different reachability rules. |
| 10 | package provision |
| 11 | |
| 12 | import ( |
| 13 | "context" |
| 14 | "encoding/json" |
| 15 | "errors" |
| 16 | "fmt" |
| 17 | "os" |
| 18 | "sort" |
| 19 | "strings" |
| 20 | "time" |
| 21 | |
| 22 | "github.com/carlosframework/platform/internal/controlname" |
| 23 | "github.com/carlosframework/platform/internal/registry" |
| 24 | "github.com/carlosframework/platform/internal/release" |
| 25 | ) |
| 26 | |
| 27 | // SidecarSuffix is the reserved, never-resolvable suffix every sidecar host |
| 28 | // carries. It is refused at every door where a name could become routable |
| 29 | // (see cli.go's addRail, the route door, and domain attach) — reservation |
| 30 | // is enforced, not documented. |
| 31 | const SidecarSuffix = ".sidecar.internal" |
| 32 | |
| 33 | // PlacementAny and PlacementNoInbound are the closed placement vocabulary a |
| 34 | // sidecar record's Placement field is checked against (ValidateSidecarShape). |
| 35 | const ( |
| 36 | PlacementAny = "any" |
| 37 | PlacementNoInbound = "no-inbound" |
| 38 | ) |
| 39 | |
| 40 | // SidecarManifest is the wire shape at apps/<sqid>/<app>/sidecars.json — the |
| 41 | // app's opt-in to sidecar provisioning and the policy every record is |
| 42 | // checked against, the same role Manifest plays for instances. Absence |
| 43 | // means the feature does not exist for that app (design §3.1's convention, |
| 44 | // carried over unchanged). |
| 45 | type SidecarManifest struct { |
| 46 | Schema int `json:"schema"` // 1 |
| 47 | App string `json:"app"` |
| 48 | Pool string `json:"pool,omitempty"` |
| 49 | Channel string `json:"channel,omitempty"` |
| 50 | Placement string `json:"placement,omitempty"` |
| 51 | Regions []string `json:"regions,omitempty"` |
| 52 | Args []string `json:"args,omitempty"` |
| 53 | // Hibernate is a POINTER for the same reason Record.Hibernate is: absent |
| 54 | // and false are different facts, and nil must resolve through |
| 55 | // HibernateOrDefault rather than read as an explicit false. |
| 56 | Hibernate *bool `json:"hibernate,omitempty"` |
| 57 | // IdleSeconds and MaxRunSeconds are integer SECONDS on the wire, not Go |
| 58 | // duration strings — the existing records use plain JSON scalars and a |
| 59 | // string would invite "5 minutes". |
| 60 | IdleSeconds int `json:"idle_seconds,omitempty"` |
| 61 | MaxRunSeconds int `json:"max_run_seconds,omitempty"` |
| 62 | } |
| 63 | |
| 64 | // SidecarRecord is the wire shape at |
| 65 | // apps/<sqid>/<app>/sidecars/<name>.json — one per provisioned sidecar, |
| 66 | // create-only so a lost race is a 409 and never a silent overwrite. The body |
| 67 | // must agree with its own key path (Name/App/Account vs the key's |
| 68 | // segments); GetSidecarRecord enforces that — a record that lies about |
| 69 | // identity is never guessed at, the same discipline GetRecord applies to |
| 70 | // instances. |
| 71 | type SidecarRecord struct { |
| 72 | // RecordSeq is advanced by every record writer once present; absent is legacy zero. |
| 73 | RecordSeq uint64 `json:"record_seq,omitempty"` |
| 74 | Acquisition string `json:"acq,omitempty"` |
| 75 | Schema int `json:"schema"` // 1 |
| 76 | Name string `json:"name"` |
| 77 | Host string `json:"host"` |
| 78 | App string `json:"app"` |
| 79 | Account string `json:"account"` |
| 80 | Pool string `json:"pool,omitempty"` |
| 81 | Region string `json:"region,omitempty"` |
| 82 | Channel string `json:"channel"` |
| 83 | Placement string `json:"placement,omitempty"` |
| 84 | Environment string `json:"environment,omitempty"` |
| 85 | CreatedAt time.Time `json:"created_at"` |
| 86 | CreatedBy string `json:"created_by,omitempty"` |
| 87 | } |
| 88 | |
| 89 | // SidecarHost builds a sidecar's synthetic host. It is the registry key, the |
| 90 | // bucket key segment and the on-disk path segment, which is why every |
| 91 | // component is validated before it is ever joined. |
| 92 | func SidecarHost(name, app, sqid string) string { |
| 93 | return name + "." + app + "." + sqid + SidecarSuffix |
| 94 | } |
| 95 | |
| 96 | // ValidSidecarName pins one DNS label exactly as ValidHost pins an |
| 97 | // instance's: the name becomes a bucket key segment and the <host>.db / |
| 98 | // cgroup path segments, so the label regex is what keeps a hostile name an |
| 99 | // opaque, safe path component. |
| 100 | func ValidSidecarName(name string) bool { return controlname.ValidLabel(name) } |
| 101 | |
| 102 | // ValidSidecarHost reports whether host is exactly SidecarHost(name, app, |
| 103 | // sqid) for some valid name — the mirror check a route or domain-attach door |
| 104 | // runs before treating a host as a sidecar's own. |
| 105 | func ValidSidecarHost(host, app, sqid string) bool { |
| 106 | rest, ok := strings.CutSuffix(host, SidecarSuffix) |
| 107 | if !ok { |
| 108 | return false |
| 109 | } |
| 110 | name, tail, ok := strings.Cut(rest, ".") |
| 111 | if !ok || tail != app+"."+sqid { |
| 112 | return false |
| 113 | } |
| 114 | return ValidSidecarName(name) |
| 115 | } |
| 116 | |
| 117 | // SidecarManifestKey, SidecarsPrefix and SidecarRecordKey are the bucket key |
| 118 | // conventions for a provisioned sidecar app — beside release.AppPrefix's |
| 119 | // channels/ and releases/, and beside the instance family's own |
| 120 | // instances.json/instances/, matching the same per-app-manifest-beside-its- |
| 121 | // collection precedent. |
| 122 | func SidecarManifestKey(acct, app string) string { |
| 123 | return release.AppPrefix(acct, app) + "sidecars.json" |
| 124 | } |
| 125 | func SidecarsPrefix(acct, app string) string { |
| 126 | return release.AppPrefix(acct, app) + "sidecars/" |
| 127 | } |
| 128 | func SidecarRecordKey(acct, app, name string) string { |
| 129 | return SidecarsPrefix(acct, app) + name + ".json" |
| 130 | } |
| 131 | |
| 132 | // ParseSidecarKey is ParseRecordKey with "instances/" swapped for |
| 133 | // "sidecars/": it reports whether key is an account-qualified sidecar |
| 134 | // record (apps/<sqid>/<app>/sidecars/<name>.json) known to belong to a |
| 135 | // materialized account, and if so its account, app and name. known guards |
| 136 | // against a key whose account has not materialized yet, and app is refused |
| 137 | // when it collides with the release plane's own words ("channels", |
| 138 | // "releases") — the same two-namespace collision ParseRecordKey documents. |
| 139 | // A key with anything but exactly one non-empty segment after "sidecars/" |
| 140 | // is not a record: the manifest (sidecars.json, no trailing segment) and |
| 141 | // any nested path both refuse. |
| 142 | func ParseSidecarKey(key string, known map[string]bool) (acct, app, name string, ok bool) { |
| 143 | rest, ok := strings.CutPrefix(key, "apps/") |
| 144 | if !ok { |
| 145 | return "", "", "", false |
| 146 | } |
| 147 | acct, rest, ok = strings.Cut(rest, "/") |
| 148 | if !ok || !known[acct] { |
| 149 | return "", "", "", false |
| 150 | } |
| 151 | app, rest, ok = strings.Cut(rest, "/") |
| 152 | if !ok || app == "" || app == "channels" || app == "releases" { |
| 153 | return "", "", "", false |
| 154 | } |
| 155 | seg, rest, ok := strings.Cut(rest, "/") |
| 156 | if !ok || seg != "sidecars" { |
| 157 | return "", "", "", false |
| 158 | } |
| 159 | if rest == "" || strings.Contains(rest, "/") { |
| 160 | return "", "", "", false |
| 161 | } |
| 162 | name, ok = strings.CutSuffix(rest, ".json") |
| 163 | if !ok || name == "" { |
| 164 | return "", "", "", false |
| 165 | } |
| 166 | return acct, app, name, true |
| 167 | } |
| 168 | |
| 169 | // IdleOrDefault resolves the manifest's idle timeout: IdleSeconds if |
| 170 | // positive, else 5 minutes. |
| 171 | func (m SidecarManifest) IdleOrDefault() time.Duration { |
| 172 | if m.IdleSeconds > 0 { |
| 173 | return time.Duration(m.IdleSeconds) * time.Second |
| 174 | } |
| 175 | return 5 * time.Minute |
| 176 | } |
| 177 | |
| 178 | // MaxRunOrDefault resolves the manifest's max run duration: MaxRunSeconds if |
| 179 | // positive, else 1 hour. |
| 180 | func (m SidecarManifest) MaxRunOrDefault() time.Duration { |
| 181 | if m.MaxRunSeconds > 0 { |
| 182 | return time.Duration(m.MaxRunSeconds) * time.Second |
| 183 | } |
| 184 | return time.Hour |
| 185 | } |
| 186 | |
| 187 | // HibernateOrDefault resolves the tri-state. An explicit value wins; absent |
| 188 | // defaults true — matching how instance records carry Hibernate, so an |
| 189 | // explicit false must win over the default. |
| 190 | func (m SidecarManifest) HibernateOrDefault() bool { |
| 191 | if m.Hibernate != nil { |
| 192 | return *m.Hibernate |
| 193 | } |
| 194 | return true |
| 195 | } |
| 196 | |
| 197 | // PlacementOrDefault resolves the manifest's placement policy: Placement if |
| 198 | // set, else PlacementAny. |
| 199 | func (m SidecarManifest) PlacementOrDefault() string { |
| 200 | if m.Placement == "" { |
| 201 | return PlacementAny |
| 202 | } |
| 203 | return m.Placement |
| 204 | } |
| 205 | |
| 206 | // ValidateSidecarShape checks a sidecar record's fields at the door, before |
| 207 | // it is written or materialized — the rails live at the door, exactly as |
| 208 | // ValidateShape's do for instance records. |
| 209 | func ValidateSidecarShape(rec SidecarRecord) error { |
| 210 | if rec.Schema > 1 { |
| 211 | return fmt.Errorf("schema %d is newer than this binary understands — upgrade carlos", rec.Schema) |
| 212 | } |
| 213 | if !ValidSidecarName(rec.Name) { |
| 214 | 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) |
| 215 | } |
| 216 | if rec.App == "" { |
| 217 | return errors.New("app: must not be empty") |
| 218 | } |
| 219 | if rec.Account == "" { |
| 220 | return errors.New("account: must not be empty") |
| 221 | } |
| 222 | switch rec.Placement { |
| 223 | case "", PlacementAny, PlacementNoInbound: |
| 224 | default: |
| 225 | return fmt.Errorf("placement %q: want %q or %q (empty means %q)", rec.Placement, PlacementAny, PlacementNoInbound, PlacementAny) |
| 226 | } |
| 227 | // A region is either a region CODE or a fleet placement target — the two |
| 228 | // grammars Route.Region carries, exactly as an instance record's does. |
| 229 | // registry.FleetPlacement is the one parser for the second, never a |
| 230 | // prefix test written out again here. |
| 231 | // |
| 232 | // This rail is not where user input is policed: the console's create door |
| 233 | // validates a REQUESTED region against ValidRegion and the manifest's |
| 234 | // allow-list, and a fleet-placed app never reaches that path at all |
| 235 | // because its region is the app's placement rather than anything the |
| 236 | // caller sent (apisidecars.go). What this rail is for is the other |
| 237 | // direction — the reconcile pass PROTECTS a record whose shape it cannot |
| 238 | // trust, so refusing the placement grammar here would make every |
| 239 | // fleet-placed sidecar permanently unmintable with no error anywhere. |
| 240 | if rec.Region != "" && !ValidRegion(rec.Region) { |
| 241 | if _, ok := registry.FleetPlacement(rec.Region); !ok { |
| 242 | return fmt.Errorf("region %q: want [a-z0-9-]{0,16} or a fleet placement target (fleet/<name>)", rec.Region) |
| 243 | } |
| 244 | } |
| 245 | if rec.Pool != "" && !ValidPool(rec.Pool) { |
| 246 | return fmt.Errorf("pool %q: want [a-z0-9-]{0,16}", rec.Pool) |
| 247 | } |
| 248 | return nil |
| 249 | } |
| 250 | |
| 251 | // GetSidecarManifest reads app's sidecars manifest. Not-found stays |
| 252 | // os.ErrNotExist-wrapped (absence IS the "sidecars disabled" answer, not an |
| 253 | // error condition the caller must special-case beyond errors.Is). A schema |
| 254 | // this binary doesn't understand is refused rather than silently truncated |
| 255 | // by json.Unmarshal. |
| 256 | func GetSidecarManifest(ctx context.Context, s release.Store, acct, app string) (*SidecarManifest, error) { |
| 257 | m, _, err := GetSidecarManifestETag(ctx, s, acct, app) |
| 258 | return m, err |
| 259 | } |
| 260 | |
| 261 | // GetSidecarManifestETag is GetSidecarManifest carrying the etag its bytes |
| 262 | // were read under — for a caller that must write back GUARDED against what |
| 263 | // it read (PutSidecarManifestIfMatch), not against whatever is there at |
| 264 | // write time. |
| 265 | func GetSidecarManifestETag(ctx context.Context, s release.Store, acct, app string) (*SidecarManifest, string, error) { |
| 266 | raw, etag, err := s.Get(ctx, SidecarManifestKey(acct, app)) |
| 267 | if err != nil { |
| 268 | return nil, "", err |
| 269 | } |
| 270 | var m SidecarManifest |
| 271 | if err := json.Unmarshal(raw, &m); err != nil { |
| 272 | return nil, "", fmt.Errorf("provision: sidecar manifest %s/%s: %w", acct, app, err) |
| 273 | } |
| 274 | if m.Schema > 1 { |
| 275 | return nil, "", fmt.Errorf("provision: sidecar manifest %s/%s: schema %d is newer than this binary understands — upgrade carlos", acct, app, m.Schema) |
| 276 | } |
| 277 | return &m, etag, nil |
| 278 | } |
| 279 | |
| 280 | // PutSidecarManifest publishes app's sidecars manifest — a CAS upsert (read |
| 281 | // etag, put, one retry on a lost race), the PutManifest shape, because |
| 282 | // unlike a record this object is meant to be overwritten as an operator |
| 283 | // adjusts policy, not created once. |
| 284 | func claimRawPutSidecarManifest(ctx context.Context, s release.Store, acct, app string, m SidecarManifest) error { |
| 285 | key := SidecarManifestKey(acct, app) |
| 286 | body, err := json.Marshal(m) |
| 287 | if err != nil { |
| 288 | return err |
| 289 | } |
| 290 | put := func() error { |
| 291 | etag := "" |
| 292 | if _, cur, err := s.Get(ctx, key); err == nil { |
| 293 | etag = cur |
| 294 | } else if !errors.Is(err, os.ErrNotExist) { |
| 295 | return err |
| 296 | } |
| 297 | _, err := s.Put(ctx, key, body, etag) |
| 298 | return err |
| 299 | } |
| 300 | if err := put(); errors.Is(err, release.ErrCASConflict) { |
| 301 | return put() // one retry on a lost race; a second loss surfaces |
| 302 | } else if err != nil { |
| 303 | return err |
| 304 | } |
| 305 | return nil |
| 306 | } |
| 307 | |
| 308 | // PutSidecarManifestIfMatch writes app's sidecars manifest guarded by the |
| 309 | // etag the caller READ it under — "" meaning it read nothing, which makes |
| 310 | // the write create-only. Unlike PutSidecarManifest's upsert (which re-reads |
| 311 | // inside and retries, so the last writer always wins) a lost race here |
| 312 | // surfaces as release.ErrCASConflict for the caller to answer. |
| 313 | func claimRawPutSidecarManifestIfMatch(ctx context.Context, s release.Store, acct, app string, m SidecarManifest, etag string) error { |
| 314 | body, err := json.Marshal(m) |
| 315 | if err != nil { |
| 316 | return err |
| 317 | } |
| 318 | _, err = s.Put(ctx, SidecarManifestKey(acct, app), body, etag) |
| 319 | return err |
| 320 | } |
| 321 | |
| 322 | // CreateSidecarRecord writes a new sidecar record, create-only: a lost race |
| 323 | // against a second create for the same name comes back wrapping |
| 324 | // release.ErrCASConflict, never a silent overwrite of the winner's record. |
| 325 | func CreateSidecarRecord(ctx context.Context, s release.Store, rec SidecarRecord) error { |
| 326 | body, err := json.Marshal(rec) |
| 327 | if err != nil { |
| 328 | return err |
| 329 | } |
| 330 | _, _, err = WriteRecord(ctx, s, SidecarRecordKey(rec.Account, rec.App, rec.Name), []byte(`{}`), body, "") |
| 331 | return err |
| 332 | } |
| 333 | |
| 334 | // GetSidecarRecord reads and validates one sidecar record. A body whose own |
| 335 | // Name/App/Account disagree with the key path it was read from is refused |
| 336 | // rather than trusted — the same "never guessed at" discipline GetRecord |
| 337 | // applies to instance records. |
| 338 | func GetSidecarRecord(ctx context.Context, s release.Store, acct, app, name string) (*SidecarRecord, error) { |
| 339 | raw, _, err := s.Get(ctx, SidecarRecordKey(acct, app, name)) |
| 340 | if err != nil { |
| 341 | return nil, err |
| 342 | } |
| 343 | var rec SidecarRecord |
| 344 | if err := json.Unmarshal(raw, &rec); err != nil { |
| 345 | return nil, fmt.Errorf("provision: sidecar record %s/%s/%s: %w", acct, app, name, err) |
| 346 | } |
| 347 | if rec.Account != acct || rec.App != app || rec.Name != name { |
| 348 | return nil, fmt.Errorf("provision: sidecar record %s/%s/%s: record disagrees with its own path", acct, app, name) |
| 349 | } |
| 350 | return &rec, nil |
| 351 | } |
| 352 | |
| 353 | // ListSidecarRecords lists an app's sidecar records, sorted by name, capped |
| 354 | // at max with truncated reporting whether more existed. The cap is decided |
| 355 | // on the KEY count — how many sidecars/<name>.json objects exist under the |
| 356 | // prefix — not on how many bodies successfully parse, the same rule |
| 357 | // ListRecords applies. A malformed or path-disagreeing body (GetSidecarRecord's |
| 358 | // own checks) is skipped, not fatal. |
| 359 | // |
| 360 | // A negative max is clamped to 0 rather than treated as "no cap", the same |
| 361 | // safe-degrade ListRecords applies. |
| 362 | func ListSidecarRecords(ctx context.Context, s release.Store, acct, app string, max int) (recs []SidecarRecord, truncated bool, err error) { |
| 363 | if max < 0 { |
| 364 | max = 0 |
| 365 | } |
| 366 | prefix := SidecarsPrefix(acct, app) |
| 367 | keys, err := s.List(ctx, prefix) |
| 368 | if err != nil { |
| 369 | return nil, false, err |
| 370 | } |
| 371 | var names []string |
| 372 | for _, key := range keys { |
| 373 | rest, ok := strings.CutPrefix(key, prefix) |
| 374 | if !ok || rest == "" || strings.Contains(rest, "/") { |
| 375 | continue // not a record at this level (e.g. a nested key never written here) |
| 376 | } |
| 377 | name, ok := strings.CutSuffix(rest, ".json") |
| 378 | if !ok || name == "" { |
| 379 | continue |
| 380 | } |
| 381 | names = append(names, name) |
| 382 | } |
| 383 | sort.Strings(names) |
| 384 | if len(names) > max { |
| 385 | truncated = true |
| 386 | names = names[:max] |
| 387 | } |
| 388 | for _, name := range names { |
| 389 | rec, err := GetSidecarRecord(ctx, s, acct, app, name) |
| 390 | if err != nil { |
| 391 | continue |
| 392 | } |
| 393 | recs = append(recs, *rec) |
| 394 | } |
| 395 | return recs, truncated, nil |
| 396 | } |
| 397 | |
| 398 | // DeleteSidecarRecord retires a sidecar's provisioning authorization. Object |
| 399 | // deletion only; Store's Delete is idempotent, so retiring an already-gone |
| 400 | // record is not an error. |
| 401 | func DeleteSidecarRecord(ctx context.Context, s release.Store, acct, app, name string) error { |
| 402 | return s.Delete(ctx, SidecarRecordKey(acct, app, name)) |
| 403 | } |
| 404 | func PutSidecarManifest(ctx context.Context, s release.Store, acct, app string, m SidecarManifest) (claimResult0 error) { |
| 405 | claimResult0 = release.WithAppMutation(ctx, s, acct, app, "provision.PutSidecarManifest", func(claimContext context.Context) error { |
| 406 | claimResult0 = claimRawPutSidecarManifest(claimContext, s, acct, app, m) |
| 407 | return claimResult0 |
| 408 | }) |
| 409 | return |
| 410 | } |
| 411 | func PutSidecarManifestIfMatch(ctx context.Context, s release.Store, acct, app string, m SidecarManifest, etag string) (claimResult0 error) { |
| 412 | claimResult0 = release.WithAppMutation(ctx, s, acct, app, "provision.PutSidecarManifestIfMatch", func(claimContext context.Context) error { |
| 413 | claimResult0 = claimRawPutSidecarManifestIfMatch(claimContext, s, acct, app, m, etag) |
| 414 | return claimResult0 |
| 415 | }) |
| 416 | return |
| 417 | } |
| 418 | |