carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Name claims: one store per bucket name and per IAM user name (#218).
2//
3// MintedBucketIn and UserNameIn join account, app and environment with "-",
4// and app and environment names may both contain "-" — so app "shop" with
5// environment "eu" and app "shop-eu" resolve to ONE bucket and ONE user.
6// Nothing downstream notices: EnsureBucket takes BucketAlreadyOwnedByYou as
7// success, EnsureUser adopts an existing user, the grant walk's reap deletes
8// the other store's live key, and a later Teardown empties and deletes the
9// other store's bucket. An operator adopting a bucket by name reaches the
10// same place by a typo.
11//
12// The names themselves cannot change — production buckets and users carry
13// them — so the fix is to make a name belong to one store. Same discipline as
14// internal/mail/domainclaim.go: a create-only bucket key per name, GLOBAL
15// rather than per account (an adopted bucket name is not scoped to the
16// account that adopts it), whose write is the uniqueness check. Two walks
17// racing for one name cannot both win, in one console or in two, whichever
18// store was declared first.
19//
20// Stores granted before claims existed hold none. The walk backfills a claim
21// on its next run, and until then two checks stand in for it: the stores in
22// the same account whose names collide by construction are few and
23// computable (collidingStores), so they are read directly; and every first
24// grant scans every store record, which is the only way to find a name an
25// unclaimed record holds in some other account (an operator's adopt can be
26// shaped exactly like another account's minted name).
27package objstore
28
29import (
30 "context"
31 "encoding/json"
32 "errors"
33 "fmt"
34 "os"
35 "path"
36 "strings"
37 "time"
38
39 "github.com/carlosframework/platform/internal/appconfig"
40 "github.com/carlosframework/platform/internal/release"
41)
42
43// ErrStoreNameTaken refuses a grant whose bucket or IAM user name already
44// belongs to another store. It is raised before anything is created,
45// recorded or reaped.
46var ErrStoreNameTaken = errors.New("objstore: bucket or IAM user name belongs to another store")
47
48const nameClaimsPrefix = "objstore/name-claims/"
49
50func bucketClaimKey(bucket string) string { return nameClaimsPrefix + "buckets/" + bucket + ".json" }
51func userClaimKey(user string) string { return nameClaimsPrefix + "users/" + user + ".json" }
52
53// NameClaim is the wire shape at a claim key: which store holds the name.
54type NameClaim struct {
55 Schema int `json:"schema"` // 1
56 Name string `json:"name"`
57 Account string `json:"account"`
58 App string `json:"app"`
59 Environment string `json:"environment,omitempty"`
60 CreatedAt time.Time `json:"created_at"`
61}
62
63func (c *NameClaim) heldBy(acct, app, env string) bool {
64 return c.Account == acct && c.App == app && c.Environment == env
65}
66
67// getNameClaim reads a claim; absent is (nil, nil).
68func getNameClaim(ctx context.Context, s release.Store, key string) (*NameClaim, error) {
69 raw, _, err := s.Get(ctx, key)
70 if errors.Is(err, os.ErrNotExist) {
71 return nil, nil
72 }
73 if err != nil {
74 return nil, err
75 }
76 var c NameClaim
77 if err := json.Unmarshal(raw, &c); err != nil {
78 return nil, fmt.Errorf("objstore: name claim %s: %w", key, err)
79 }
80 if c.Schema > 1 {
81 return nil, fmt.Errorf("objstore: name claim %s: schema %d is newer than this binary understands — upgrade carlos", key, c.Schema)
82 }
83 return &c, nil
84}
85
86// claimName takes key for the store, or confirms the store already holds it.
87// The create-only Put is the check; a lost create race re-reads and judges
88// the winner, as claimDomain does.
89func claimName(ctx context.Context, s release.Store, key, name, acct, app, env string, now time.Time) error {
90 body, err := json.Marshal(NameClaim{Schema: 1, Name: name, Account: acct, App: app, Environment: env, CreatedAt: now})
91 if err != nil {
92 return err
93 }
94 for attempt := 0; ; attempt++ {
95 cur, err := getNameClaim(ctx, s, key)
96 if err != nil {
97 return err
98 }
99 if cur != nil {
100 if !cur.heldBy(acct, app, env) {
101 return fmt.Errorf("%w: %s", ErrStoreNameTaken, name)
102 }
103 return nil
104 }
105 _, err = s.Put(ctx, key, body, "")
106 if err == nil {
107 return nil
108 }
109 if !errors.Is(err, release.ErrCASConflict) || attempt > 0 {
110 return err
111 }
112 }
113}
114
115// releaseName deletes key only when this store holds it. A claim held by
116// another store is theirs, and an absent one is already released.
117func releaseName(ctx context.Context, s release.Store, key, acct, app, env string) error {
118 cur, err := getNameClaim(ctx, s, key)
119 if err != nil {
120 return err
121 }
122 if cur == nil || !cur.heldBy(acct, app, env) {
123 return nil
124 }
125 return s.Delete(ctx, key)
126}
127
128type storeRef struct{ app, env string }
129
130// collidingStores lists every OTHER store in the account whose minted bucket
131// and IAM user names are the same as this one's: every way of splitting
132// app[-env] at a hyphen into a valid app and an optional valid environment.
133func collidingStores(app, env string) []storeRef {
134 joined := app
135 if env != "" {
136 joined += "-" + env
137 }
138 var out []storeRef
139 for i := 0; i <= len(joined); i++ {
140 var ref storeRef
141 switch {
142 case i == len(joined):
143 ref = storeRef{joined, ""}
144 case joined[i] == '-':
145 ref = storeRef{joined[:i], joined[i+1:]}
146 default:
147 continue
148 }
149 if ref.app == app && ref.env == env {
150 continue
151 }
152 if !release.ValidApp(ref.app) || (ref.env != "" && !appconfig.ValidEnvironmentName(ref.env)) {
153 continue
154 }
155 out = append(out, ref)
156 }
157 return out
158}
159
160// grantedColliders reads the records of collidingStores and returns those a
161// grant has acted on — the ones holding the shared names, claimed or not.
162func grantedColliders(ctx context.Context, s release.Store, acct, app, env string) ([]*Record, error) {
163 var out []*Record
164 for _, ref := range collidingStores(app, env) {
165 rec, _, err := GetIn(ctx, s, acct, ref.app, ref.env)
166 if err != nil {
167 return nil, err
168 }
169 if rec != nil && (rec.Grant != nil || rec.Provisioned != nil) {
170 rec.App, rec.Environment = ref.app, ref.env // named by the key read, not the body
171 out = append(out, rec)
172 }
173 }
174 return out, nil
175}
176
177// recordNames is the bucket and user a record's estate is named by, read the
178// way Teardown reads them.
179func recordNames(acct string, rec *Record) (bucket, user string) {
180 user = UserNameIn(acct, rec.App, rec.Environment)
181 if rec.Grant != nil {
182 bucket = rec.Grant.Bucket
183 }
184 if rec.Provisioned != nil {
185 bucket, user = rec.Provisioned.Bucket, rec.Provisioned.IAMUser
186 }
187 return bucket, user
188}
189
190// storeNamingBucket scans every store record in the deployment for one other
191// than (acct, app, env) that names bucket. Every FIRST grant pays for it, not
192// only an operator's adopt: an adopt made before claims existed can hold a
193// name shaped exactly like another account's minted one (#191), and nothing
194// but the record says so. It walks with ListDirs, as mail.SweepLegacyRecords
195// does, so it never lists a release artifact; a re-run skips it, because by
196// then the walk holds the claim.
197func storeNamingBucket(ctx context.Context, s release.Store, bucket, acct, app, env string) (bool, error) {
198 accts, err := s.ListDirs(ctx, "apps/")
199 if err != nil {
200 return false, err
201 }
202 for _, acctPrefix := range accts {
203 a := path.Base(strings.TrimSuffix(acctPrefix, "/"))
204 if !release.ValidAccount(a) {
205 continue
206 }
207 apps, err := s.ListDirs(ctx, acctPrefix)
208 if err != nil {
209 return false, err
210 }
211 for _, appPrefix := range apps {
212 p := path.Base(strings.TrimSuffix(appPrefix, "/"))
213 if !release.ValidApp(p) {
214 continue
215 }
216 envs, err := StoreEnvironments(ctx, s, a, p)
217 if err != nil {
218 return false, err
219 }
220 for _, e := range append([]string{""}, envs...) {
221 if a == acct && p == app && e == env {
222 continue
223 }
224 rec, _, err := GetIn(ctx, s, a, p, e)
225 if err != nil {
226 return false, err
227 }
228 if rec == nil {
229 continue
230 }
231 if b, _ := recordNames(a, rec); b == bucket {
232 return true, nil
233 }
234 }
235 }
236 }
237 return false, nil
238}
239
240// claimStoreNames is the grant walk's gate, run before the grant block is
241// written and before any backend call. firstGrant is a grant with no block
242// yet — the only run that can be choosing a name another record holds.
243func claimStoreNames(ctx context.Context, s release.Store, acct, app, env, bucket, user string, firstGrant bool, now time.Time) error {
244 others, err := grantedColliders(ctx, s, acct, app, env)
245 if err != nil {
246 return err
247 }
248 if len(others) > 0 {
249 return fmt.Errorf("%w: %s is already the IAM user of %s/%s environment %q",
250 ErrStoreNameTaken, user, acct, others[0].App, others[0].Environment)
251 }
252 if firstGrant {
253 taken, err := storeNamingBucket(ctx, s, bucket, acct, app, env)
254 if err != nil {
255 return err
256 }
257 if taken {
258 return fmt.Errorf("%w: %s", ErrStoreNameTaken, bucket)
259 }
260 }
261 // Both are read before either is written, so a refusal on the second
262 // does not leave the first behind.
263 for _, key := range []string{userClaimKey(user), bucketClaimKey(bucket)} {
264 cur, err := getNameClaim(ctx, s, key)
265 if err != nil {
266 return err
267 }
268 if cur != nil && !cur.heldBy(acct, app, env) {
269 return fmt.Errorf("%w: %s", ErrStoreNameTaken, cur.Name)
270 }
271 }
272 if err := claimName(ctx, s, userClaimKey(user), user, acct, app, env, now); err != nil {
273 return err
274 }
275 return claimName(ctx, s, bucketClaimKey(bucket), bucket, acct, app, env, now)
276}
277
278// sharedNames reports, for Teardown, whether the bucket or the user it is
279// about to delete belongs to another store: by that store's claim, or — for
280// a pair granted before claims existed — by a colliding record that names it.
281// Either way the other store's estate is not this teardown's to destroy.
282func sharedNames(ctx context.Context, s release.Store, acct, app, env, bucket, user string) (bucketShared, userShared bool, err error) {
283 if bucket != "" {
284 c, err := getNameClaim(ctx, s, bucketClaimKey(bucket))
285 if err != nil {
286 return false, false, err
287 }
288 bucketShared = c != nil && !c.heldBy(acct, app, env)
289 }
290 c, err := getNameClaim(ctx, s, userClaimKey(user))
291 if err != nil {
292 return false, false, err
293 }
294 userShared = c != nil && !c.heldBy(acct, app, env)
295 others, err := grantedColliders(ctx, s, acct, app, env)
296 if err != nil {
297 return false, false, err
298 }
299 for _, o := range others {
300 b, u := recordNames(acct, o)
301 bucketShared = bucketShared || (bucket != "" && b == bucket)
302 userShared = userShared || u == user
303 }
304 return bucketShared, userShared, nil
305}
306
307// claimedBucket finds the bucket claim this store holds, for the one teardown
308// that has no record of the name: a grant that took its claims and died
309// before writing its block. Only a declared-only teardown pays for the
310// listing. "" means none.
311func claimedBucket(ctx context.Context, s release.Store, acct, app, env string) (string, error) {
312 keys, err := s.List(ctx, nameClaimsPrefix+"buckets/")
313 if err != nil {
314 return "", err
315 }
316 for _, key := range keys {
317 c, err := getNameClaim(ctx, s, key)
318 if err != nil {
319 return "", err
320 }
321 if c != nil && c.heldBy(acct, app, env) {
322 return c.Name, nil
323 }
324 }
325 return "", nil
326}
327
328// releaseStoreNames gives back whatever claims this store holds on bucket and
329// user. Owner-gated, so a name another store holds is never touched.
330func releaseStoreNames(ctx context.Context, s release.Store, acct, app, env, bucket, user string) error {
331 keys := []string{userClaimKey(user)}
332 if bucket != "" {
333 keys = append(keys, bucketClaimKey(bucket))
334 }
335 for _, key := range keys {
336 if err := releaseName(ctx, s, key, acct, app, env); err != nil {
337 return fmt.Errorf("objstore: release name claim %s: %w", key, err)
338 }
339 }
340 return nil
341}
342