carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package objstore is the object-storage primitive's control plane (issue
2// #55, spec 2026-08-10): the per-app store record, the Provisioner seam the
3// console drives, and the idempotent walks that provision, rotate, and tear
4// down a store. It never touches the data plane — apps talk to their bucket
5// directly with the delivered credentials.
6package objstore
7
8import (
9 "context"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "os"
14 "regexp"
15 "sort"
16 "strings"
17 "time"
18
19 "github.com/carlosframework/platform/internal/appconfig"
20 "github.com/carlosframework/platform/internal/release"
21)
22
23const Schema = 1
24
25// DefaultEnvPrefix names the delivered env set when a declaration doesn't
26// choose its own (§7 of the spec).
27const DefaultEnvPrefix = "CARLOS_STORE"
28
29type Declared struct {
30 EnvPrefix string `json:"env_prefix"`
31 At time.Time `json:"at"`
32 By string `json:"by"`
33 // Seed is the seed id that created this store (seeded-instances spec §4
34 // Storage, codex R5-1, R6-1): only a retry of that seed may use it, and
35 // only that seed's abort may tear it down. Empty for every other store.
36 Seed string `json:"seed,omitempty"`
37}
38
39// Grant sources (#381). Source records WHY a grant exists, mirroring
40// mail.GrantBlock.Source's vocabulary and its reason: an authority that can be
41// recomputed later must say what it rested on, or recomputing it silently
42// discards a deliberate decision.
43//
44// Not omitempty, matching mail's. A record written before this field existed
45// decodes "" — which reads correctly as "granted by the operator path, before
46// we recorded why" — and an empty string in a fresh write is a bug worth
47// seeing rather than a field worth hiding.
48const (
49 // GrantSourceOperator: a deployment operator ran `carlos store grant` for
50 // this one app. The original and still the override path.
51 GrantSourceOperator = "operator"
52 // GrantSourceAccountApproval: the app's ACCOUNT is approved for object
53 // storage, so the member's own declare provisioned it. By names the
54 // operator who approved the account — a real person who really did
55 // authorise this spend, one level up.
56 GrantSourceAccountApproval = "account-approval"
57)
58
59type GrantBlock struct {
60 Backend string `json:"backend"`
61 Bucket string `json:"bucket,omitempty"` // the resolved bucket (minted or adopted; the record does not distinguish)
62 Source string `json:"source"`
63 At time.Time `json:"at"`
64 By string `json:"by"`
65}
66
67type Provisioned struct {
68 Bucket string `json:"bucket"`
69 Region string `json:"region"`
70 Endpoint string `json:"endpoint,omitempty"`
71 IAMUser string `json:"iam_user"`
72 UserARN string `json:"user_arn"`
73 KeyID string `json:"key_id"`
74 PrevKeyID string `json:"prev_key_id,omitempty"`
75 At time.Time `json:"at"`
76}
77
78// ScanRequested is the MEMBER's half of scanning: they asked for it, and they
79// attested what kind of objects the store holds.
80//
81// Encrypted means CLIENT-SIDE encrypted — the objects reach the store as
82// ciphertext, so a scanner cannot read them, every object would scan clean
83// forever, and no coverage is claimed (spec §2.2). Server-side encryption
84// (SSE-S3, SSE-KMS, on by default for every AWS bucket) is NOT this: it is
85// transparent to a credentialled reader and scans perfectly well. The platform
86// cannot tell the two apart by looking, which is why this is an attestation
87// and not a detection.
88type ScanRequested struct {
89 At time.Time `json:"at"`
90 By string `json:"by"`
91 Encrypted bool `json:"encrypted"`
92}
93
94// ScanEnabled is the OPERATOR's half. Enabling calls out to the deployment's
95// own cloud account and, on a metered engine, starts a bill — the same test
96// that puts the store grant behind CARLOS_STORE_OPERATORS.
97type ScanEnabled struct {
98 Engine string `json:"engine"` // "clamav" | "guardduty"
99 At time.Time `json:"at"`
100 By string `json:"by"`
101}
102
103// ScanBlock is the scanning half of the record — the same three-author shape
104// the store itself has.
105//
106// It holds NO sweep rollup. Counts live in the scan.json sidecar, whose single
107// writer is the sweep, so a walk writing once per page over minutes never
108// contends with the doors that write this object.
109//
110// Enforce holds the DenyTag ITSELF rather than a bool. Grant takes a
111// Provisioner and has no Scanner to ask for the tag, so the record must carry
112// everything the policy writer needs — which is also what lets `scan enforce
113// off` clear a deny on a deployment with no engine built. A bool here would
114// mean unsetting CARLOS_STORE_SCANNER left every enforcing store unrepairable.
115type ScanBlock struct {
116 Requested *ScanRequested `json:"requested,omitempty"`
117 Enabled *ScanEnabled `json:"enabled,omitempty"`
118 Enforce *DenyTag `json:"enforce,omitempty"`
119 WebhookKeyID string `json:"webhook_key_id,omitempty"`
120 WebhookGen int `json:"webhook_gen,omitempty"`
121 WebhookConfigHash string `json:"webhook_config_hash,omitempty"`
122}
123
124// Record is apps/<acct>/<app>/store.json — three blocks for three actors
125// (member declares, operator grants, console provisions). The secret key is
126// never here; it exists only sealed inside config.json.
127//
128// PARSER LIST: this package is not the only reader of this shape.
129// release.PurgeApp parses `grant` and `provisioned` out of these bytes with
130// its own local struct — release cannot import objstore, because objstore
131// imports release — and REFUSES the box-side auto-purge when either block is
132// present. Rename or restructure either key here and that fleet-wide guard
133// silently stops seeing backend estate; the mirror test in walk_test.go
134// (TestPurgeGuardSeesOurRecord) exists to fail loudly if that ever happens.
135// Additive changes are safe; these two names are not free to move.
136//
137// `scanning` is such an additive change: release's local struct ignores keys
138// it has never heard of, which TestPurgeGuardIgnoresScanningBlock asserts
139// directly. That freedom does NOT extend to `grant` and `provisioned`.
140type Record struct {
141 Schema int `json:"schema"`
142 App string `json:"app"`
143 // Environment names the config bundle this store belongs to and delivers
144 // into. "" is the app's own store — the only shape that existed before
145 // 2026-09-19 — and is omitted, so every record written before this field
146 // existed parses as the app store it is.
147 //
148 // Stamped by Put from the key it is written to and never trusted from a
149 // caller, exactly as App is: a record that disagrees with its own key is
150 // refused rather than guessed at (GetIn).
151 Environment string `json:"environment,omitempty"`
152 Declared *Declared `json:"declared,omitempty"`
153 Grant *GrantBlock `json:"grant,omitempty"`
154 Provisioned *Provisioned `json:"provisioned,omitempty"`
155 Scanning *ScanBlock `json:"scanning,omitempty"`
156}
157
158// Key is the app's own store — KeyIn(acct, app, "") by another name, kept
159// because it is what every caller that has no environment in hand asks for.
160func Key(acct, app string) string { return release.AppPrefix(acct, app) + "store.json" }
161
162// StoresPrefix lists an app's ENVIRONMENT stores. The app's own store is NOT
163// under it: store.json predates this collection and does not move (spec §4),
164// so a reader after every store of an app reads Key AND lists this prefix —
165// which is exactly what release.PurgeApp's guard does.
166func StoresPrefix(acct, app string) string { return release.AppPrefix(acct, app) + "stores/" }
167
168// KeyIn is the record key for one store of an app: the app's own when env is
169// "", else the store belonging to that named config environment. A sibling
170// collection, not a key nested under an instance — nothing in the bucket
171// nests under an instance, and apps/<acct>/<app>/sidecars/<name>.json is the
172// shape this follows.
173func KeyIn(acct, app, env string) string {
174 if env == "" {
175 return Key(acct, app)
176 }
177 return StoresPrefix(acct, app) + env + ".json"
178}
179
180// StoreEnvironments names every ENVIRONMENT this app has a store for, sorted,
181// from a prefix listing — the app's own store is not among them. An app with
182// none lists empty, not an error, the same convention as appconfig's
183// ListEnvironments and for the same reason: "none" is a state a status page
184// has to print either way.
185//
186// A key that is not <env>.json is skipped rather than guessed at: this
187// prefix is ours alone, so anything else under it is somebody's hand edit,
188// and a malformed name must not become a bucket coordinate.
189func StoreEnvironments(ctx context.Context, s release.Store, acct, app string) ([]string, error) {
190 if _, enabled := s.(release.AppMutationRunner); enabled {
191 return StrictStoreEnvironments(ctx, s, acct, app)
192 }
193 if !release.ValidAccount(acct) {
194 return nil, fmt.Errorf("objstore: invalid account %q", acct)
195 }
196 if !release.ValidApp(app) {
197 return nil, fmt.Errorf("objstore: invalid app %q", app)
198 }
199 prefix := StoresPrefix(acct, app)
200 keys, err := s.List(ctx, prefix)
201 if err != nil {
202 return nil, err
203 }
204 out := make([]string, 0, len(keys))
205 for _, key := range keys {
206 name, ok := strings.CutPrefix(key, prefix)
207 if !ok {
208 continue
209 }
210 name, ok = strings.CutSuffix(name, ".json")
211 if !ok || !appconfig.ValidEnvironmentName(name) {
212 continue
213 }
214 out = append(out, name)
215 }
216 sort.Strings(out)
217 return out, nil
218}
219
220var envPrefixRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,31}$`)
221
222func ValidEnvPrefix(p string) bool { return envPrefixRe.MatchString(p) }
223
224// EnvNames renders the delivered variable names for a prefix (§7).
225func EnvNames(prefix string) (bucket, region, endpoint, accessKey, secretKey string) {
226 return prefix + "_BUCKET", prefix + "_REGION", prefix + "_ENDPOINT",
227 prefix + "_ACCESS_KEY", prefix + "_SECRET_KEY"
228}
229
230// Get reads the record. Absent is (nil, "", nil) — same idiom as
231// appconfig.getIfExists, because "no store" is a state, not an error.
232func Get(ctx context.Context, s release.Store, acct, app string) (*Record, string, error) {
233 return GetIn(ctx, s, acct, app, "")
234}
235
236// GetIn reads one store of an app: the app's own when env is "", else the
237// store belonging to that named config environment. Absent is (nil, "", nil),
238// as in Get — "no store" is a state, not an error, for either shape.
239//
240// A record whose Environment disagrees with the key it was read from is
241// REFUSED, not corrected: the field is stamped from the key by PutIn, so a
242// disagreement means bucket data was hand-edited, and guessing which half is
243// right would deliver one environment's credentials under another's name.
244// Same rule provision.GetRecord applies to a record that lies about its own
245// identity.
246func GetIn(ctx context.Context, s release.Store, acct, app, env string) (*Record, string, error) {
247 _, strict := s.(release.AppMutationRunner)
248 return getIn(ctx, s, acct, app, env, strict)
249}
250
251// StrictGetIn validates the record before it can supply teardown coordinates.
252func StrictGetIn(ctx context.Context, s release.Store, acct, app, env string) (*Record, string, error) {
253 return getIn(ctx, s, acct, app, env, true)
254}
255
256func getIn(ctx context.Context, s release.Store, acct, app, env string, strict bool) (*Record, string, error) {
257 if !release.ValidAccount(acct) {
258 return nil, "", fmt.Errorf("objstore: invalid account %q", acct)
259 }
260 if !release.ValidApp(app) {
261 return nil, "", fmt.Errorf("objstore: invalid app %q", app)
262 }
263 if env != "" && !appconfig.ValidEnvironmentName(env) {
264 return nil, "", fmt.Errorf("objstore: invalid environment %q", env)
265 }
266 key := KeyIn(acct, app, env)
267 raw, etag, err := s.Get(ctx, key)
268 if err != nil {
269 if errors.Is(err, os.ErrNotExist) {
270 return nil, "", nil
271 }
272 return nil, "", err
273 }
274 var rec Record
275 if err := json.Unmarshal(raw, &rec); err != nil {
276 return nil, "", fmt.Errorf("objstore: %s: %w", key, err)
277 }
278 if strict && (rec.Schema < 0 || rec.Schema > Schema || rec.App != app) {
279 return nil, "", fmt.Errorf("%w: %s", ErrStoreInventory, key)
280 }
281 if rec.Environment != env {
282 return nil, "", fmt.Errorf("objstore: %s names environment %q, not %q — refusing to read a record that disagrees with its own key",
283 key, rec.Environment, env)
284 }
285 return &rec, etag, nil
286}
287
288// Put writes the record with the caller's CAS position; etag "" is
289// create-only. Every writer is a read-modify-write, so a lost race is
290// release.ErrCASConflict for the caller to surface, never to absorb.
291func claimRawPut(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (string, error) {
292 return PutIn(ctx, s, acct, app, "", rec, etag)
293}
294
295// PutIn writes one store of an app, stamping App and Environment from the key
296// it is writing to — neither is trusted from the caller, so a record can never
297// disagree with where it lives (GetIn refuses one that does).
298func claimRawPutIn(ctx context.Context, s release.Store, acct, app, env string, rec *Record, etag string) (string, error) {
299 if env != "" && !appconfig.ValidEnvironmentName(env) {
300 return "", fmt.Errorf("objstore: invalid environment %q", env)
301 }
302 rec.Schema = Schema
303 rec.App = app
304 rec.Environment = env
305 body, err := json.MarshalIndent(rec, "", " ")
306 if err != nil {
307 return "", err
308 }
309 return s.Put(ctx, KeyIn(acct, app, env), append(body, '\n'), etag)
310}
311func Put(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (claimResult0 string, claimResult1 error) {
312 claimResult1 = release.WithAppMutation(ctx, s, acct, app, "objstore.Put", func(claimContext context.Context) error {
313 claimResult0, claimResult1 = claimRawPut(claimContext, s, acct, app, rec, etag)
314 return claimResult1
315 })
316 return
317}
318func PutIn(ctx context.Context, s release.Store, acct, app, env string, rec *Record, etag string) (claimResult0 string, claimResult1 error) {
319 claimResult1 = release.WithAppMutation(ctx, s, acct, app, "objstore.PutIn", func(claimContext context.Context) error {
320 claimResult0, claimResult1 = claimRawPutIn(claimContext, s, acct, app, env, rec, etag)
321 return claimResult1
322 })
323 return
324}
325
326// RevertIn puts one store record back the way it was before a write the
327// caller made, which left the record at etag: prev's exact bytes, or no
328// record at all when prev is nil. Both are conditional on etag: a record
329// written since, by a grant or by another writer (the declare door takes
330// no store lock), is not the caller's to undo, and RevertIn leaves it and
331// answers release.ErrCASConflict. A record already gone counts as deleted.
332func RevertIn(ctx context.Context, s release.Store, acct, app, env string, prev []byte, etag string) error {
333 return release.WithAppMutation(ctx, s, acct, app, "objstore.RevertIn", func(claimContext context.Context) error {
334 return claimRawRevertIn(claimContext, s, acct, app, env, prev, etag)
335 })
336}
337
338func claimRawRevertIn(ctx context.Context, s release.Store, acct, app, env string, prev []byte, etag string) error {
339 if env != "" && !appconfig.ValidEnvironmentName(env) {
340 return fmt.Errorf("objstore: invalid environment %q", env)
341 }
342 key := KeyIn(acct, app, env)
343 if prev == nil {
344 return release.DeleteIfMatch(ctx, s, key, etag)
345 }
346 _, err := s.Put(ctx, key, prev, etag)
347 return err
348}
349
350// ErrStoreInventory means a store listing or body cannot establish its estate.
351var ErrStoreInventory = errors.New("objstore: invalid store inventory")
352
353// StrictStoreEnvironments validates every listed key and body. A listed
354// record that cannot be read is not evidence of an empty environment.
355func StrictStoreEnvironments(ctx context.Context, s release.Store, acct, app string) ([]string, error) {
356 if !release.ValidAccount(acct) || !release.ValidApp(app) {
357 return nil, ErrStoreInventory
358 }
359 prefix := StoresPrefix(acct, app)
360 keys, err := s.List(ctx, prefix)
361 if err != nil {
362 return nil, err
363 }
364 out := make([]string, 0, len(keys))
365 seen := map[string]bool{}
366 for _, key := range keys {
367 name, ok := strings.CutPrefix(key, prefix)
368 if !ok {
369 return nil, fmt.Errorf("%w: %s", ErrStoreInventory, key)
370 }
371 name, ok = strings.CutSuffix(name, ".json")
372 if !ok || !appconfig.ValidEnvironmentName(name) || key != KeyIn(acct, app, name) {
373 return nil, fmt.Errorf("%w: %s", ErrStoreInventory, key)
374 }
375 if seen[key] {
376 continue
377 }
378 seen[key] = true
379 rec, _, err := StrictGetIn(ctx, s, acct, app, name)
380 if err != nil {
381 return nil, err
382 }
383 if rec == nil {
384 return nil, fmt.Errorf("%w: listed record %s is absent", ErrStoreInventory, key)
385 }
386 out = append(out, name)
387 }
388 sort.Strings(out)
389 return out, nil
390}
391