carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package provision
2
3import (
4 "context"
5 "errors"
6 "reflect"
7 "slices"
8 "sort"
9 "strings"
10 "sync"
11 "testing"
12
13 "github.com/carlosframework/platform/internal/release"
14)
15
16// bucketShaped is a store that promises bucket-shaped ListDirs, as the S3
17// store does, and records every listing asked of it. DirStore's own ListDirs
18// has that shape; it just does not advertise it.
19type bucketShaped struct {
20 release.Store
21 mu sync.Mutex
22 lists []string
23 dirs []string
24 // failDirs and failList fail one prefix each.
25 failDirs, failList string
26}
27
28func (s *bucketShaped) BucketShapedListDirs() bool { return true }
29
30func (s *bucketShaped) List(ctx context.Context, prefix string) ([]string, error) {
31 s.mu.Lock()
32 s.lists = append(s.lists, prefix)
33 s.mu.Unlock()
34 if prefix == s.failList {
35 return nil, errors.New("list refused")
36 }
37 return s.Store.List(ctx, prefix)
38}
39
40func (s *bucketShaped) ListDirs(ctx context.Context, prefix string) ([]string, error) {
41 s.mu.Lock()
42 s.dirs = append(s.dirs, prefix)
43 s.mu.Unlock()
44 if prefix == s.failDirs {
45 return nil, errors.New("listdirs refused")
46 }
47 return s.Store.ListDirs(ctx, prefix)
48}
49
50func (s *bucketShaped) listedAllOfApps() bool {
51 s.mu.Lock()
52 defer s.mu.Unlock()
53 for _, p := range s.lists {
54 if p == "apps/" {
55 return true
56 }
57 }
58 return false
59}
60
61// fleetShaped answers ListDirs the way the fleet door does: scoped to one
62// account, ListDirs("apps/") is that account's APP directories. It makes no
63// bucket-shape promise, which is the point.
64type fleetShaped struct {
65 release.Store
66 acct string
67}
68
69func (s fleetShaped) ListDirs(ctx context.Context, prefix string) ([]string, error) {
70 if prefix == "apps/" {
71 return s.Store.ListDirs(ctx, "apps/"+s.acct+"/")
72 }
73 return s.Store.ListDirs(ctx, prefix)
74}
75
76// A bucket with everything a production one holds under apps/: records of
77// both kinds, the logs and release artifacts that dwarf them, an account that
78// has not materialized, a legacy account-less app, and apps named like the
79// release plane's own words.
80func seedAppListingBucket(t *testing.T) release.Store {
81 t.Helper()
82 s := release.NewDirStore(t.TempDir())
83 for _, k := range []string{
84 "apps/bab/shop/instances/a.example.com.json",
85 "apps/bab/shop/instances/b.example.com.json",
86 "apps/bab/shop/instances.json",
87 "apps/bab/shop/sidecars/bot.json",
88 "apps/bab/shop/logs/2026/10/05/chunk-1.jsonl",
89 "apps/bab/shop/logs/2026/10/05/chunk-2.jsonl",
90 "apps/bab/shop/releases/v1/manifest.json",
91 "apps/bab/shop/channels/edge.json",
92 "apps/bab/blog/instances/c.example.com.json",
93 "apps/bab/blog/instances/nested/too/deep.json",
94 "apps/bad/site/sidecars/w.json",
95 "apps/bad/site/instances/d.example.com.json",
96 "apps/zzz/ghost/instances/e.example.com.json", // account not materialized
97 "apps/legacyapp/channels/edge.json", // account-less
98 "apps/bab/channels/instances/f.example.com.json",
99 "apps/bab/releases/instances/g.example.com.json",
100 } {
101 if _, err := s.Put(context.Background(), k, []byte(`{}`), ""); err != nil {
102 t.Fatalf("seed %s: %v", k, err)
103 }
104 }
105 return s
106}
107
108func parsedRecords(keys []string, parse func(string) bool) []string {
109 var out []string
110 for _, k := range keys {
111 if parse(k) {
112 out = append(out, k)
113 }
114 }
115 sort.Strings(out)
116 return out
117}
118
119// On a bucket-shaped store the walk finds exactly the records the full
120// listing did, for both record kinds, and never lists the whole of apps/.
121func TestAppSubtreeWalkMatchesTheFullListing(t *testing.T) {
122 inner := seedAppListingBucket(t)
123 known := map[string]bool{"bab": true, "bad": true}
124 all, err := inner.List(context.Background(), "apps/")
125 if err != nil {
126 t.Fatal(err)
127 }
128 for _, c := range []struct {
129 sub string
130 parse func(string) bool
131 }{
132 {"instances/", func(k string) bool { _, _, _, ok := ParseRecordKey(k, known); return ok }},
133 {"sidecars/", func(k string) bool { _, _, _, ok := ParseSidecarKey(k, known); return ok }},
134 } {
135 s := &bucketShaped{Store: inner}
136 got, partial, err := listAppSubtree(context.Background(), s, known, c.sub)
137 if err != nil || partial {
138 t.Fatalf("%s: partial=%v err=%v", c.sub, partial, err)
139 }
140 if g, w := parsedRecords(got, c.parse), parsedRecords(all, c.parse); !reflect.DeepEqual(g, w) {
141 t.Fatalf("%s: the walk found %v, the full listing %v", c.sub, g, w)
142 }
143 for _, p := range s.lists {
144 if !strings.HasSuffix(p, "/"+c.sub) {
145 t.Fatalf("%s: listed %q — the walk lists only <app>/%s prefixes", c.sub, p, c.sub)
146 }
147 }
148 }
149}
150
151// A store that makes no bucket-shape promise is listed the old way. The fleet
152// door's ListDirs("apps/") is its account's app directories; walked as if it
153// were account directories, it yields no records at all, which reconcile
154// would read as every route retired.
155func TestAppSubtreeListsTheOldWayWithoutTheBucketShape(t *testing.T) {
156 fleet := fleetShaped{Store: seedAppListingBucket(t), acct: "bab"}
157 known := map[string]bool{"bab": true}
158 parse := func(k string) bool { _, _, _, ok := ParseRecordKey(k, known); return ok }
159 got, partial, err := listAppSubtree(context.Background(), fleet, known, "instances/")
160 if err != nil || partial {
161 t.Fatalf("partial=%v err=%v", partial, err)
162 }
163 want := []string{
164 "apps/bab/blog/instances/c.example.com.json",
165 "apps/bab/shop/instances/a.example.com.json",
166 "apps/bab/shop/instances/b.example.com.json",
167 }
168 if g := parsedRecords(got, parse); !reflect.DeepEqual(g, want) {
169 t.Fatalf("records on a fleet-shaped store = %v, want %v", g, want)
170 }
171 if release.HasBucketListDirs(release.NewDirStore(t.TempDir())) {
172 t.Fatal("DirStore promises bucket-shaped ListDirs; it walks its whole root per List, so it must be listed the old way")
173 }
174}
175
176// One account's or one app's listing failing is partial, not fatal: the rest
177// still come back, and the caller is told some were not seen.
178func TestAppSubtreeOneListingFailingIsPartial(t *testing.T) {
179 known := map[string]bool{"bab": true, "bad": true}
180 parse := func(k string) bool { _, _, _, ok := ParseRecordKey(k, known); return ok }
181 for _, c := range []struct {
182 name string
183 s *bucketShaped
184 want []string
185 }{
186 {"an app", &bucketShaped{failList: "apps/bab/blog/instances/"}, []string{
187 "apps/bab/shop/instances/a.example.com.json",
188 "apps/bab/shop/instances/b.example.com.json",
189 "apps/bad/site/instances/d.example.com.json",
190 }},
191 {"an account", &bucketShaped{failDirs: "apps/bab/"}, []string{
192 "apps/bad/site/instances/d.example.com.json",
193 }},
194 } {
195 c.s.Store = seedAppListingBucket(t)
196 got, partial, err := listAppSubtree(context.Background(), c.s, known, "instances/")
197 if err != nil {
198 t.Fatalf("%s: one failed listing failed the walk: %v", c.name, err)
199 }
200 if !partial {
201 t.Fatalf("%s: a failed listing was not reported as partial", c.name)
202 }
203 if g := parsedRecords(got, parse); !reflect.DeepEqual(g, c.want) {
204 t.Fatalf("%s: records = %v, want %v", c.name, g, c.want)
205 }
206 }
207}
208
209// All three record readers, on a bucket-shaped store with real records: they
210// find the records, mint the same route they mint off a plain store, and
211// never list the whole of apps/.
212func TestRecordReadersWalkABucketShapedStore(t *testing.T) {
213 inner, sqid := setup(t)
214 if err := CreateRecord(ctx, inner, acmeRecord(sqid)); err != nil {
215 t.Fatal(err)
216 }
217 cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"}
218
219 plainReg := regWith(t)
220 if err := Reconcile(ctx, inner, plainReg, cfg); err != nil {
221 t.Fatal(err)
222 }
223 s := &bucketShaped{Store: inner}
224 reg := regWith(t)
225 if err := Reconcile(ctx, s, reg, cfg); err != nil {
226 t.Fatal(err)
227 }
228 want, got := *mustGet(t, plainReg, "acme.go.tito.io"), *mustGet(t, reg, "acme.go.tito.io")
229 want.CreatedAt, got.CreatedAt = 0, 0
230 if got != want {
231 t.Fatalf("minted over the walk = %+v, over the full listing %+v", got, want)
232 }
233
234 recs, err := ListAllRecords(ctx, s)
235 if err != nil {
236 t.Fatal(err)
237 }
238 if len(recs) != 1 || recs[0].Host != "acme.go.tito.io" {
239 t.Fatalf("ListAllRecords = %+v, want the one acme record", recs)
240 }
241 if err := ReconcileSidecars(ctx, s, reg, cfg); err != nil {
242 t.Fatal(err)
243 }
244 if s.listedAllOfApps() {
245 t.Fatalf("a record reader listed the whole of apps/: %v", s.lists)
246 }
247}
248
249// Reconcile treats a partial walk as it treats an unreadable record: the
250// routes it could not see are not retired.
251func TestReconcileKeepsRoutesItCouldNotList(t *testing.T) {
252 inner, sqid := setup(t)
253 if err := CreateRecord(ctx, inner, acmeRecord(sqid)); err != nil {
254 t.Fatal(err)
255 }
256 cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"}
257 reg := regWith(t)
258 if err := Reconcile(ctx, inner, reg, cfg); err != nil {
259 t.Fatal(err)
260 }
261 s := &bucketShaped{Store: inner, failList: RecordsPrefix(sqid, "tito")}
262 if err := Reconcile(ctx, s, reg, cfg); err != nil {
263 t.Fatal(err)
264 }
265 if rt, err := reg.Get("acme.go.tito.io"); err != nil || rt == nil {
266 t.Fatalf("a route whose records could not be listed was removed: %+v, %v", rt, err)
267 }
268}
269
270// ListAllRecords refuses a partial walk rather than report a short record
271// set: the moves and steering passes act on what it returns, and a missing
272// record reads to them as an instance that no longer exists.
273func TestListAllRecordsRefusesAPartialWalk(t *testing.T) {
274 inner, sqid := setup(t)
275 if err := CreateRecord(ctx, inner, acmeRecord(sqid)); err != nil {
276 t.Fatal(err)
277 }
278 s := &bucketShaped{Store: inner, failList: RecordsPrefix(sqid, "tito")}
279 if recs, err := ListAllRecords(ctx, s); err == nil {
280 t.Fatalf("ListAllRecords over a failed app listing = %+v, nil; want an error", recs)
281 }
282}
283
284// ListAllRecordsTolerant reports what it could read and says it is
285// incomplete, where ListAllRecords refuses: an app it could not list, a
286// record it could not Get.
287func TestListAllRecordsTolerantSkipsWhatItCannotRead(t *testing.T) {
288 inner, sqid := setup(t)
289 first := acmeRecord(sqid)
290 first.Host, first.Slug = "one.example.com", "one"
291 second := acmeRecord(sqid)
292 second.Host, second.Slug = "two.example.com", "two"
293 for _, rec := range []Record{first, second} {
294 if err := CreateRecord(ctx, inner, rec); err != nil {
295 t.Fatal(err)
296 }
297 }
298 if recs, complete, err := ListAllRecordsTolerant(ctx, inner); err != nil || !complete || len(recs) != 2 {
299 t.Fatalf("a clean read = %d records, complete %v, %v", len(recs), complete, err)
300 }
301 s := &countingGets{Store: inner, failKey: RecordKey(sqid, first.App, first.Host)}
302 recs, complete, err := ListAllRecordsTolerant(ctx, s)
303 if err != nil || complete || len(recs) != 1 || recs[0].Host != second.Host {
304 t.Fatalf("one record unreadable = %+v, complete %v, %v", recs, complete, err)
305 }
306 if _, err := ListAllRecords(ctx, s); err == nil {
307 t.Fatal("ListAllRecords read past an unreadable record")
308 }
309 walk := &bucketShaped{Store: inner, failList: RecordsPrefix(sqid, first.App)}
310 if recs, complete, err := ListAllRecordsTolerant(ctx, walk); err != nil || complete || len(recs) != 0 {
311 t.Fatalf("an app that will not list = %+v, complete %v, %v", recs, complete, err)
312 }
313}
314
315// The sidecar reader over the walk mints the sidecar it mints over the full
316// listing, and keeps it on the next pass. Returning no sidecar keys at all
317// would otherwise pass every other test here.
318func TestSidecarReconcileOverTheWalkMintsAndKeeps(t *testing.T) {
319 f := sidecarSetup(t)
320 f.putManifest(t, "eleven", SidecarManifest{
321 Schema: 1, App: "eleven", Args: []string{"sidecar", "run"}, Channel: "stable"})
322 f.putRecord(t, SidecarRecord{Schema: 1, Name: "bot", App: "eleven", Channel: "stable"})
323 s := &bucketShaped{Store: f.store}
324 cfg := ReconcileConfig{DataDir: f.dir}
325 for pass := 1; pass <= 2; pass++ {
326 if err := ReconcileSidecars(ctx, s, f.reg, cfg); err != nil {
327 t.Fatalf("pass %d: %v", pass, err)
328 }
329 hasRoute(t, f.reg, f.host("bot", "eleven"), "the walk lost the sidecar")
330 }
331 if s.listedAllOfApps() {
332 t.Fatalf("the sidecar reader listed the whole of apps/: %v", s.lists)
333 }
334}
335
336// reversedList answers List in reverse order and makes no bucket-shape
337// promise: Store.List does not promise an order.
338type reversedList struct{ release.Store }
339
340func (s reversedList) List(ctx context.Context, prefix string) ([]string, error) {
341 keys, err := s.Store.List(ctx, prefix)
342 slices.Reverse(keys)
343 return keys, err
344}
345
346// ListAllRecords returns records in a stable order whatever order the store
347// lists in, on the fallback path as on the walk.
348func TestListAllRecordsIsSortedOnTheFallback(t *testing.T) {
349 inner, sqid := setup(t)
350 for _, slug := range []string{"acme", "zeta", "beta"} {
351 rec := acmeRecord(sqid)
352 rec.Host, rec.Slug = slug+".go.tito.io", slug
353 if err := CreateRecord(ctx, inner, rec); err != nil {
354 t.Fatal(err)
355 }
356 }
357 recs, err := ListAllRecords(ctx, reversedList{inner})
358 if err != nil {
359 t.Fatal(err)
360 }
361 var hosts []string
362 for _, r := range recs {
363 hosts = append(hosts, r.Host)
364 }
365 if !slices.IsSorted(hosts) {
366 t.Fatalf("ListAllRecords over a reverse-listing store = %v, want sorted", hosts)
367 }
368}
369