carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
10package provision
11
12import (
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.
31const SidecarSuffix = ".sidecar.internal"
32
33// PlacementAny and PlacementNoInbound are the closed placement vocabulary a
34// sidecar record's Placement field is checked against (ValidateSidecarShape).
35const (
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).
45type 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.
71type 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.
92func 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.
100func 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.
105func 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.
122func SidecarManifestKey(acct, app string) string {
123 return release.AppPrefix(acct, app) + "sidecars.json"
124}
125func SidecarsPrefix(acct, app string) string {
126 return release.AppPrefix(acct, app) + "sidecars/"
127}
128func 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.
142func 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.
171func (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.
180func (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.
190func (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.
199func (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.
209func 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.
256func 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.
265func 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.
284func 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.
313func 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.
325func 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.
338func 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.
362func 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.
401func DeleteSidecarRecord(ctx context.Context, s release.Store, acct, app, name string) error {
402 return s.Delete(ctx, SidecarRecordKey(acct, app, name))
403}
404func 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}
411func 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