From c02b3d21dbf68d804f52efe75158d0e327da4d8b Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 15 Oct 2026 13:06:30 -0400 Subject: [PATCH 1/2] fix(docker): scope init cleanup to compose project (D-0687) --- internal/docker/cleanup.go | 13 +++--- internal/docker/cleanup_init_test.go | 59 +++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/internal/docker/cleanup.go b/internal/docker/cleanup.go index 75cd4cd2c..8bfcc9702 100644 --- a/internal/docker/cleanup.go +++ b/internal/docker/cleanup.go @@ -12,22 +12,23 @@ import ( ) // initContainerListArgs is the `docker ps` argument list that selects init -// containers safe to remove: labelled nself.type=init-container, exited, with +// containers safe to remove in the given project: labelled nself.type=init-container, exited, with // exit code 0. Running ones (a start in progress, or a service that uses the // label) and failed ones (kept for `docker logs`) are left alone. -func initContainerListArgs() []string { +func initContainerListArgs(projectName string) []string { return []string{"ps", "-aq", "--filter", "label=nself.type=init-container", + "--filter", "label=com.docker.compose.project=" + projectName, "--filter", "status=exited", "--filter", "exited=0", } } // cleanupInitContainers removes finished init containers (minio-init, -// meilisearch-init, seaweedfs-init, ...): the ones that exited with code 0. +// meilisearch-init, seaweedfs-init, ...) in the given project that exited with code 0. // Running and failed init containers are kept. -func cleanupInitContainers(ctx context.Context) error { - out, err := exec.CommandContext(ctx, "docker", initContainerListArgs()...).Output() +func cleanupInitContainers(ctx context.Context, projectName string) error { + out, err := exec.CommandContext(ctx, "docker", initContainerListArgs(projectName)...).Output() if err != nil { return fmt.Errorf("finding init containers: %w", err) } @@ -114,7 +115,7 @@ func RunPreStartCleanup(ctx context.Context, projectName string) error { func RunPostStartCleanup(ctx context.Context, projectName string) error { // Run init container cleanup 3 times with delays to handle race conditions. for i := 0; i < 3; i++ { - if err := cleanupInitContainers(ctx); err != nil { + if err := cleanupInitContainers(ctx, projectName); err != nil { // Non-fatal: continue with remaining passes. slog.Debug("cleanup init containers (non-fatal)", "pass", i+1, "err", err) } diff --git a/internal/docker/cleanup_init_test.go b/internal/docker/cleanup_init_test.go index 61e0236b9..993ba5622 100644 --- a/internal/docker/cleanup_init_test.go +++ b/internal/docker/cleanup_init_test.go @@ -5,17 +5,74 @@ package docker // (kept for `docker logs`) must never be selected. import ( + "context" + "os" + "path/filepath" "reflect" + "runtime" + "strings" "testing" ) +func TestPostStartCleanupScopesInitContainersToProject(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake docker is a shell script") + } + dir := t.TempDir() + log := filepath.Join(dir, "calls") + t.Setenv("CLEANUP_CALLS", log) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + // Model two projects: an unscoped init query selects both; a scoped + // query selects only project-a. Other cleanup queries return no IDs. + script := `#!/bin/sh +case " $* " in + *" label=nself.type=init-container "*) + printf 'init %s\n' "$*" >> "$CLEANUP_CALLS" + printf 'project-a-init\n' + case " $* " in + *" label=com.docker.compose.project=project-a "*) ;; + *) printf 'project-b-init\n' ;; + esac ;; + " rm -f "*) printf '%s\n' "$*" >> "$CLEANUP_CALLS" ;; +esac +` + if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + if err := RunPostStartCleanup(context.Background(), "project-a"); err != nil { + t.Fatal(err) + } + out, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + initQueries, removals := 0, 0 + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if strings.HasPrefix(line, "init ") { + initQueries++ + if !strings.Contains(line, "--filter label=com.docker.compose.project=project-a") { + t.Errorf("init query missing project filter: %s", line) + } + } else { + removals++ + if line != "rm -f project-a-init" { + t.Errorf("removed another project's container: %s", line) + } + } + } + if initQueries != 3 || removals != 3 { + t.Errorf("got %d init queries and %d removals, want 3 each", initQueries, removals) + } +} + func TestInitContainerListArgsSelectExitedZeroOnly(t *testing.T) { want := []string{"ps", "-aq", "--filter", "label=nself.type=init-container", + "--filter", "label=com.docker.compose.project=project-a", "--filter", "status=exited", "--filter", "exited=0", } - if got := initContainerListArgs(); !reflect.DeepEqual(got, want) { + if got := initContainerListArgs("project-a"); !reflect.DeepEqual(got, want) { t.Fatalf("initContainerListArgs() = %v, want %v", got, want) } } From b7c3bfb153701f4d42faff81d9114f1dda828f69 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 15 Oct 2026 13:45:38 -0400 Subject: [PATCH 2/2] fix(importer): handle Appwrite paging limits and file signatures (D-0784) --- .github/wiki/Come-Home-Appwrite.md | 8 +- internal/importer/appwrite/client.go | 34 ++++- internal/importer/appwrite/count.go | 22 ++++ internal/importer/appwrite/limits_test.go | 151 ++++++++++++++++++++++ internal/importer/appwrite/source.go | 8 +- internal/importer/appwrite/storage.go | 17 ++- scripts/e2e/import-appwrite.sh | 4 +- test/fixtures/import/appwrite/seed.sh | 4 +- 8 files changed, 227 insertions(+), 21 deletions(-) create mode 100644 internal/importer/appwrite/count.go create mode 100644 internal/importer/appwrite/limits_test.go diff --git a/.github/wiki/Come-Home-Appwrite.md b/.github/wiki/Come-Home-Appwrite.md index c7c7b6eb0..3bb51f64d 100644 --- a/.github/wiki/Come-Home-Appwrite.md +++ b/.github/wiki/Come-Home-Appwrite.md @@ -66,7 +66,13 @@ column is NULL and the report lists the attribute and the number of documents un `exemptions` (id ending `#missing:`); check those before you trust the data. Buckets are created in nSelf only for the objects they hold, so an empty bucket is listed in the report instead. A file name containing `/` or `\` has it replaced by `_`, and the file is -listed in the report. Each downloaded file must match Appwrite's `sizeOriginal`. +listed in the report. Each downloaded file must match Appwrite's `sizeOriginal` +and its MD5 `signature` when the server supplies one. + +List pages start at 100 items and shrink on responses exceeding 64 MiB, down to +one item. A single item larger than this limit stops the import. The plan uses a +one-user count query; when its total reaches Appwrite's 5000-user cap, it walks +the user list to count users, so planning large projects requires more requests. ## Passwords diff --git a/internal/importer/appwrite/client.go b/internal/importer/appwrite/client.go index 70eee47e5..7965f3ebb 100644 --- a/internal/importer/appwrite/client.go +++ b/internal/importer/appwrite/client.go @@ -8,6 +8,7 @@ package appwrite import ( "context" "encoding/json" + "errors" "fmt" "io" "net" @@ -25,6 +26,8 @@ const ( maxPageBytes = 64 << 20 ) +var errPageOverflow = errs.New("E517", "source schema lookup failed") + // client is a minimal Appwrite REST client. The key is held only in the request header. type client struct { base, project, key string @@ -92,7 +95,14 @@ func (c *client) getJSON(ctx context.Context, path string, q url.Values, out any return err } defer func() { _ = resp.Body.Close() }() - if json.NewDecoder(io.LimitReader(resp.Body, maxPageBytes)).Decode(out) != nil { + b, err := io.ReadAll(io.LimitReader(resp.Body, maxPageBytes+1)) + if err != nil { + return errs.New("E517", "source schema lookup failed") + } + if len(b) > maxPageBytes { + return errPageOverflow + } + if json.Unmarshal(b, out) != nil { return errs.New("E517", "source schema lookup failed") } return nil @@ -108,13 +118,21 @@ func queryJSON(method string, values ...any) string { type page struct { Total int64 Items []json.RawMessage + Limit int } // listPage fetches one page of the collection under key. A non-empty after is a $id cursor; // a positive offset pages by position instead (attribute documents carry no $id). func (c *client) listPage(ctx context.Context, path, key, after string, offset int) (page, error) { + return c.listPageLimit(ctx, path, key, after, offset, pageSize) +} + +// listPageLimit retries oversized responses at the same position, down to one item. +// Inputs: list location and requested limit. Outputs: items and the effective limit. +// Constraints: only byte-cap overflow is retried; other source failures are returned. +func (c *client) listPageLimit(ctx context.Context, path, key, after string, offset, limit int) (page, error) { q := url.Values{} - q.Add("queries[]", queryJSON("limit", pageSize)) + q.Add("queries[]", queryJSON("limit", limit)) if after != "" { q.Add("queries[]", queryJSON("cursorAfter", after)) } @@ -123,9 +141,12 @@ func (c *client) listPage(ctx context.Context, path, key, after string, offset i } var raw map[string]json.RawMessage if err := c.getJSON(ctx, path, q, &raw); err != nil { + if errors.Is(err, errPageOverflow) && limit > 1 { + return c.listPageLimit(ctx, path, key, after, offset, limit/2) + } return page{}, err } - var p page + p := page{Limit: limit} if t, ok := raw["total"]; ok && json.Unmarshal(t, &p.Total) != nil { return page{}, errs.New("E517", "source schema lookup failed") } @@ -143,19 +164,20 @@ func (c *client) each(ctx context.Context, path, key string, fn func(json.RawMes // walk is each with a choice of cursor ($id) or offset paging. func (c *client) walk(ctx context.Context, path, key string, cursor bool, fn func(json.RawMessage) error) (int64, error) { - after, total, seen := "", int64(0), 0 + after, total, seen, limit := "", int64(0), 0, pageSize for first := true; ; first = false { offset, prev := 0, after if !cursor { offset = seen } - p, err := c.listPage(ctx, path, key, after, offset) + p, err := c.listPageLimit(ctx, path, key, after, offset, limit) if err != nil { return 0, err } if first { total = p.Total } + limit = p.Limit for _, item := range p.Items { if cursor { var id struct { @@ -178,7 +200,7 @@ func (c *client) walk(ctx context.Context, path, key string, cursor bool, fn fun if !cursor && int64(seen) > total+pageSize { return 0, errs.New("E517", "source schema lookup failed") } - if len(p.Items) < pageSize { + if len(p.Items) < limit { return total, nil } } diff --git a/internal/importer/appwrite/count.go b/internal/importer/appwrite/count.go new file mode 100644 index 000000000..ee08c1563 --- /dev/null +++ b/internal/importer/appwrite/count.go @@ -0,0 +1,22 @@ +package appwrite + +import ( + "context" + "encoding/json" +) + +// userCount reads a cheap one-user query for Detect, then walks the list if its +// total reaches Appwrite's count cap. Inputs: source client. Outputs: exact user +// count. Constraints: capped totals cannot be trusted; no usage scope is required. +func (c *client) userCount(ctx context.Context) (int64, error) { + p, err := c.listPageLimit(ctx, "/users", "users", "", 0, 1) + if err != nil || p.Total < totalCap { + return p.Total, err + } + var count int64 + _, err = c.each(ctx, "/users", "users", func(json.RawMessage) error { + count++ + return nil + }) + return count, err +} diff --git a/internal/importer/appwrite/limits_test.go b/internal/importer/appwrite/limits_test.go new file mode 100644 index 000000000..b8b8c2669 --- /dev/null +++ b/internal/importer/appwrite/limits_test.go @@ -0,0 +1,151 @@ +package appwrite + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/nself-org/cli/internal/importer" + "github.com/nself-org/cli/internal/portable" +) + +func TestOverflowShrinksPageWithoutLosingRows(t *testing.T) { + for _, cursor := range []bool{true, false} { + t.Run(fmt.Sprint(cursor), func(t *testing.T) { + f := &fake{docs: 120, t: t} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + limit, after, offset := cursorLimit(r) + if limit > 50 { + _, _ = io.CopyN(w, strings.NewReader(strings.Repeat(" ", maxPageBytes+1)), maxPageBytes+1) + return + } + if !cursor && offset > 0 { + after = fmt.Sprintf("n%d", offset-1) + } + f.documents(w, limit, after) + })) + defer srv.Close() + c, err := newClient(srv.URL, "proj1", fakeKey) + if err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + total, err := c.walk(context.Background(), "/documents", "documents", cursor, func(raw json.RawMessage) error { + var item struct { + ID string `json:"$id"` + } + if err := json.Unmarshal(raw, &item); err != nil { + return err + } + if seen[item.ID] { + t.Errorf("duplicate %s", item.ID) + } + seen[item.ID] = true + return nil + }) + if err != nil || total != 120 || len(seen) != 120 { + t.Fatalf("total=%d rows=%d err=%v", total, len(seen), err) + } + }) + } +} + +func TestDetectCountsBeyondUserTotalCap(t *testing.T) { + f := &fake{t: t} + firstLimit := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/users" { + f.handler(w, r) + return + } + limit, after, _ := cursorLimit(r) + if firstLimit == 0 { + firstLimit = limit + } + start := 0 + if after != "" { + _, _ = fmt.Sscanf(after, "u%d", &start) + start++ + } + items := []map[string]any{} + for i := start; i < 5001 && len(items) < limit; i++ { + items = append(items, map[string]any{"$id": fmt.Sprintf("u%d", i)}) + } + writeList(w, "users", 5000, items) + })) + defer srv.Close() + t.Setenv("APPWRITE_TEST_KEY", fakeKey) + d, err := (Source{}).Detect(context.Background(), importer.Input{Values: map[string]string{ + "endpoint": srv.URL, "project": "proj1", "api-key-env": "APPWRITE_TEST_KEY", + }}) + if err != nil || d.SourceUsers != 5001 || firstLimit != 1 { + t.Fatalf("users=%d first limit=%d err=%v", d.SourceUsers, firstLimit, err) + } +} + +func TestStorageChecksServerSignature(t *testing.T) { + for _, signature := range []string{"", "900150983cd24fb0d6963f7d28e17f72", "900150983CD24FB0D6963F7D28E17F72", "00000000000000000000000000000000", "invalid"} { + t.Run(signature, func(t *testing.T) { + f := &fake{t: t} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/storage/buckets/Files_One/files": + writeList(w, "files", 1, []map[string]any{{"$id": "f1", "name": "a.txt", "sizeOriginal": 3, "signature": signature}}) + case "/v1/storage/buckets/Files_One/files/f1/download": + _, _ = io.WriteString(w, "abc") + default: + f.handler(w, r) + } + })) + defer srv.Close() + c, err := newClient(srv.URL, "proj1", fakeKey) + if err != nil { + t.Fatal(err) + } + w, err := portable.NewWriter(filepath.Join(t.TempDir(), "bundle")) + if err != nil { + t.Fatal(err) + } + _, err = c.exportStorage(context.Background(), w) + wantError := signature == "invalid" || strings.HasPrefix(signature, "0000") + if (err != nil) != wantError { + t.Fatalf("signature check: %v", err) + } + }) + } +} + +func TestPagingStopsOnMalformedOrSingleItemOverflow(t *testing.T) { + for _, oversized := range []bool{false, true} { + t.Run(fmt.Sprint(oversized), func(t *testing.T) { + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if oversized { + _, _ = io.WriteString(w, strings.Repeat(" ", maxPageBytes+1)) + } else { + _, _ = io.WriteString(w, "{invalid") + } + })) + defer srv.Close() + c, err := newClient(srv.URL, "proj1", fakeKey) + if err != nil { + t.Fatal(err) + } + _, err = c.listPage(context.Background(), "/documents", "documents", "", 0) + want := 1 + if oversized { + want = 7 + } // 100, 50, 25, 12, 6, 3, 1 + if err == nil || requests != want { + t.Fatalf("requests=%d want=%d err=%v", requests, want, err) + } + }) + } +} diff --git a/internal/importer/appwrite/source.go b/internal/importer/appwrite/source.go index 6f22a28a3..7a65f306e 100644 --- a/internal/importer/appwrite/source.go +++ b/internal/importer/appwrite/source.go @@ -68,15 +68,15 @@ func (Source) Detect(ctx context.Context, in importer.Input) (importer.Detection if err != nil { return d, err } - var users page - if users, err = c.listPage(ctx, "/users", "users", "", 0); err != nil { + users, err := c.userCount(ctx) + if err != nil { return d, errs.New("E517", "source auth lookup failed") } - d.SourceUsers = int(users.Total) + d.SourceUsers = int(users) if d.Objects, err = c.listObjects(ctx); err != nil { return d, err } - d.Notes = append(d.Notes, fmt.Sprintf("%d users; document permissions are reported, not mapped", users.Total)) + d.Notes = append(d.Notes, fmt.Sprintf("%d users; document permissions are reported, not mapped", users)) return d, nil } diff --git a/internal/importer/appwrite/storage.go b/internal/importer/appwrite/storage.go index 0dd72773e..ad2bfff1a 100644 --- a/internal/importer/appwrite/storage.go +++ b/internal/importer/appwrite/storage.go @@ -6,8 +6,10 @@ package appwrite // be valid S3 names after lowercasing and a collision is an error, never a silent merge. import ( "context" + "crypto/md5" // Appwrite's file signature is MD5; used for compatibility, not authentication. "encoding/json" "fmt" + "io" "net/url" "regexp" "strings" @@ -78,10 +80,11 @@ func (c *client) exportStorage(ctx context.Context, w *portable.Writer) (storage var filePerms int64 total, e := c.each(ctx, "/storage/buckets/"+url.PathEscape(b.ID)+"/files", "files", func(raw json.RawMessage) error { var f struct { - ID string `json:"$id"` - Name string `json:"name"` - Size int64 `json:"sizeOriginal"` - Perms []string `json:"$permissions"` + ID string `json:"$id"` + Name string `json:"name"` + Size int64 `json:"sizeOriginal"` + Signature string `json:"signature"` + Perms []string `json:"$permissions"` } if json.Unmarshal(raw, &f) != nil || f.ID == "" { return errs.New("E520", "source tables unavailable") @@ -101,13 +104,15 @@ func (c *client) exportStorage(ctx context.Context, w *portable.Writer) (storage if e != nil { return errs.New("E520", "source tables unavailable") } - file, e := w.WriteFile(member, body) + signature := md5.New() + file, e := w.WriteFile(member, io.TeeReader(body, signature)) _ = body.Close() if e != nil { return errs.New("E520", "dump member write failed") } obj, e := portable.NewObject(name, key, file) - if e != nil || (f.Size > 0 && f.Size != file.Bytes) { + if e != nil || (f.Size > 0 && f.Size != file.Bytes) || + (f.Signature != "" && !strings.EqualFold(f.Signature, fmt.Sprintf("%x", signature.Sum(nil)))) { return errs.New("E520", "dump member write failed") } res.Storage.Objects = append(res.Storage.Objects, obj) diff --git a/scripts/e2e/import-appwrite.sh b/scripts/e2e/import-appwrite.sh index 24bbf0432..e62a81346 100755 --- a/scripts/e2e/import-appwrite.sh +++ b/scripts/e2e/import-appwrite.sh @@ -46,7 +46,7 @@ jq -e '.data.result=="pass" and .data.detection.kind=="appwrite" and .data.detec test ! -d .nself/import "${IMPORT[@]}" --json > "$W/import.json" jq -e '.data.result=="pass" and (.data.mismatches|length)==0 and (.data.tables|length)==2 and ([.data.tables[].equal]|all)' "$W/import.json" -jq -e '([.data.tables[]|select(.name=="books")]|.[0].source_rows)==25 and ([.data.tables[]|select(.name=="authors")]|.[0].source_rows)==5' "$W/import.json" +jq -e '([.data.tables[]|select(.name=="books")]|.[0].source_rows)==250 and ([.data.tables[]|select(.name=="authors")]|.[0].source_rows)==5' "$W/import.json" jq -e '.data.users.source==4 and .data.users.target==4 and .data.users.equal==true' "$W/import.json" # The two argon2 users keep no password and are listed with the reset path; bcrypt users keep theirs. jq -e '(.data.users.reset_required|length)==2 and ([.data.users.reset_required[].email]|sort)==["argon1@fixture.invalid","argon2@fixture.invalid"]' "$W/import.json" @@ -59,7 +59,7 @@ jq -e '[.data.exemptions[]|select(.reason|test("permissions"))]|length>=1' "$W/i jq -e '.data.hasura.equal==true and (.data.hasura.diffs|length)==0' "$W/import.json" SECRET=$(sed -n 's/^HASURA_GRAPHQL_ADMIN_SECRET=//p' .env | tail -1) curl -fsS -H "x-hasura-admin-secret: $SECRET" -H 'Content-Type: application/json' -d '{"type":"export_metadata","args":{}}' "http://127.0.0.1:$TARGET_HASURA_PORT/v1/metadata" | jq -e '[.sources[].tables[]|select(.table.name=="books")|.object_relationships[]?|select(.name=="author_rel")]|length==1' -test "$(docker compose exec -T postgres psql -U postgres -d nself -Atc "SELECT count(*) FROM main.books b JOIN main.authors a ON a.id=b.author")" = 25 +test "$(docker compose exec -T postgres psql -U postgres -d nself -Atc "SELECT count(*) FROM main.books b JOIN main.authors a ON a.id=b.author")" = 250 # A wrong key is refused with a fixed message and no echo. export BAD_KEY=wrong-fixture-key if nself db import appwrite --endpoint "$ENDPOINT" --project proj1 --api-key-env BAD_KEY --plan --json > "$W/badkey.json" 2> "$W/badkey.err"; then echo 'FAIL: bad key accepted'; exit 1; fi diff --git a/test/fixtures/import/appwrite/seed.sh b/test/fixtures/import/appwrite/seed.sh index 609c609ab..d6685fca9 100755 --- a/test/fixtures/import/appwrite/seed.sh +++ b/test/fixtures/import/appwrite/seed.sh @@ -47,7 +47,7 @@ done for i in 1 2 3 4 5; do api POST /databases/main/collections/authors/documents "{\"documentId\":\"a$i\",\"data\":{\"name\":\"Author $i\",\"bio\":\"Bio of \\\"author\\\" $i, with a comma\"},\"permissions\":[]}" >/dev/null done -for i in $(seq 1 25); do +for i in $(seq 1 250); do perms='[]' [[ $i == 1 ]] && perms='["read(\"any\")"]' api POST /databases/main/collections/books/documents "{\"documentId\":\"b$i\",\"data\":{\"title\":\"Book $i\",\"pages\":$((100 + i)),\"rating\":4.$((i % 10)),\"in_stock\":$([[ $((i % 2)) == 0 ]] && echo true || echo false),\"released\":\"2025-01-$(printf '%02d' $((i % 28 + 1)))T00:00:00.000+00:00\",\"contact\":\"book$i@fixture.invalid\",\"author\":\"a$(((i % 5) + 1))\"},\"permissions\":$perms}" >/dev/null @@ -64,4 +64,4 @@ for i in 1 2 3; do printf 'fixture file %s\n' "$i" > "$OUT/file$i.txt" curl -fsS -X POST "$ENDPOINT/storage/buckets/files/files" -H "X-Appwrite-Project: proj1" -H "X-Appwrite-Key: $wkey" -F "fileId=f$i" -F "file=@$OUT/file$i.txt;filename=file$i.txt" >/dev/null done -echo '{"databases":1,"collections":2,"documents":30,"users":4,"buckets":1,"files":3}' > "$OUT/seed.json" +echo '{"databases":1,"collections":2,"documents":255,"users":4,"buckets":1,"files":3}' > "$OUT/seed.json"