carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package domains_test
2
3import (
4 "context"
5 "database/sql"
6 "path/filepath"
7 "reflect"
8 "testing"
9 "time"
10
11 "github.com/carlosframework/platform/internal/domains"
12 "github.com/carlosframework/platform/internal/registry"
13 "github.com/carlosframework/platform/internal/release"
14)
15
16// trashedImportScene is a trashed app "shop" with two routes on this box:
17// a.shop.example.com, swapped for an uncommitted import (so journaled),
18// and b.shop.example.com, an ordinary route.
19func trashedImportScene(t *testing.T) (*release.DirStore, *registry.Registry, string, registry.Route) {
20 t.Helper()
21 s := dirStore(t)
22 sqid := writeAccountManifest(t, s, 793481920157, "Shop")
23 putKey(t, s, "apps/"+sqid+"/shop/channels/stable.json")
24 path := filepath.Join(t.TempDir(), "registry.db")
25 reg, err := registry.Open(path)
26 if err != nil {
27 t.Fatal(err)
28 }
29 t.Cleanup(func() { reg.Close() })
30 legacy := registry.Route{Host: "a.shop.example.com", Kind: "service", Addr: "127.0.0.1:8401", App: "shop",
31 Account: sqid, Status: "ready", Channel: "stable", CreatedAt: 1700000000}
32 plain := legacy
33 plain.Host, plain.Addr = "b.shop.example.com", "127.0.0.1:8402"
34 for _, rt := range []registry.Route{legacy, plain} {
35 if err := reg.Upsert(rt); err != nil {
36 t.Fatal(err)
37 }
38 }
39 original := *mustGet(t, reg, legacy.Host)
40 const seedID = "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa"
41 next := registry.Route{Host: legacy.Host, Kind: "instance", Socket: "/run/carlos/instances/a/app.sock",
42 DBPath: "/data/instances/a/a.shop.example.com.db", App: "shop", Account: sqid, Status: "ready", Channel: "stable",
43 Hibernate: true, Provisioned: true, ImportGate: "abc123",
44 SeedSource: `{"id":"` + seedID + `","kind":"import","state":"swapped"}`}
45 if err := reg.SwapForImport(registry.ImportJournal{Host: legacy.Host, SeedID: seedID, Original: original,
46 RecordKey: "apps/" + sqid + "/shop/instances/a.shop.example.com.json", At: 1}, next); err != nil {
47 t.Fatal(err)
48 }
49 if err := release.TrashApp(ctx, s, sqid, "shop", "owner@example.com", time.Unix(1753900000, 0)); err != nil {
50 t.Fatal(err)
51 }
52 return s, reg, path, *mustGet(t, reg, legacy.Host)
53}
54
55// Task 5 (the Task 4 review's carry): the trash removal skips a host the
56// import journal holds, as the provision pass's removal does. The journal
57// owns the gated row until its rollback restores the original route and
58// lets go; every other route of the trashed app goes as before.
59func TestReconcileTrashLeavesAJournaledImportsRow(t *testing.T) {
60 s, reg, _, gated := trashedImportScene(t)
61 for pass := 1; pass <= 2; pass++ {
62 if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil {
63 t.Fatalf("pass %d: %v", pass, err)
64 }
65 if got := mustGet(t, reg, gated.Host); !reflect.DeepEqual(*got, gated) {
66 t.Fatalf("pass %d: the journaled row changed: %+v", pass, got)
67 }
68 if rt, _ := reg.Get("b.shop.example.com"); rt != nil {
69 t.Fatalf("pass %d: the trashed app's other route survived: %+v", pass, rt)
70 }
71 }
72 // Once the journal lets go, the trash removes the host like any other.
73 if err := reg.CloseImportGate(gated.Host, "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa", 1); err != nil {
74 t.Fatal(err)
75 }
76 if err := reg.RestoreImportRoute(gated.Host, "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa"); err != nil {
77 t.Fatal(err)
78 }
79 if err := reg.DeleteImportJournal(gated.Host, "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa"); err != nil {
80 t.Fatal(err)
81 }
82 if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil {
83 t.Fatal(err)
84 }
85 if rt, _ := reg.Get(gated.Host); rt != nil {
86 t.Fatalf("the trashed app's restored route survived: %+v", rt)
87 }
88}
89
90// A journal that cannot be read could hide any host: no route of a
91// trashed app is removed that pass.
92func TestReconcileTrashRemovesNothingWhenTheJournalCannotBeRead(t *testing.T) {
93 s, reg, path, gated := trashedImportScene(t)
94 db, err := sql.Open("sqlite", path)
95 if err != nil {
96 t.Fatal(err)
97 }
98 if _, err := db.Exec(`UPDATE import_journal SET original = '{' WHERE host = ?`, gated.Host); err != nil {
99 t.Fatal(err)
100 }
101 db.Close()
102 if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil {
103 t.Fatal(err)
104 }
105 mustGet(t, reg, gated.Host)
106 mustGet(t, reg, "b.shop.example.com")
107}
108
109// hookStore runs once, on the first read of one key, a change another
110// process makes in the middle of a domains pass.
111type hookStore struct {
112 release.Store
113 key string
114 once func()
115}
116
117func (h *hookStore) Get(ctx context.Context, key string) ([]byte, string, error) {
118 if key == h.key && h.once != nil {
119 f := h.once
120 h.once = nil
121 f()
122 }
123 return h.Store.Get(ctx, key)
124}
125
126// Fix round 1, I2: the domains pass lists the routes and sees the trashed
127// app's import row. Before its trash loop runs, the provision pass's
128// rollback restores the original row (the operator's, carrying a grant)
129// and retires the journal row. The trash delete must not remove that row
130// on the strength of the import row it listed.
131func TestReconcileTrashLeavesARowARollbackRestoredMidPass(t *testing.T) {
132 s := dirStore(t)
133 sqid := writeAccountManifest(t, s, 793481920157, "Shop")
134 putKey(t, s, "apps/"+sqid+"/shop/channels/stable.json")
135 reg := regWith(t, registry.Route{Host: "a.shop.example.com", Kind: "service", Addr: "127.0.0.1:8401",
136 App: "shop", Account: "bab", Status: "ready", Channel: "stable", Caps: "port:25", CreatedAt: 1700000000})
137 original := *mustGet(t, reg, "a.shop.example.com")
138 const seedID = "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa"
139 next := registry.Route{Host: original.Host, Kind: "instance", Socket: "/run/carlos/instances/a/app.sock",
140 DBPath: "/data/instances/a/a.shop.example.com.db", App: "shop", Account: sqid, Status: "ready", Channel: "stable",
141 Hibernate: true, Provisioned: true, ImportGate: "abc123",
142 SeedSource: `{"id":"` + seedID + `","kind":"import","state":"swapped"}`}
143 if err := reg.SwapForImport(registry.ImportJournal{Host: original.Host, SeedID: seedID, Original: original,
144 RecordKey: "apps/" + sqid + "/shop/instances/a.shop.example.com.json", At: 1}, next); err != nil {
145 t.Fatal(err)
146 }
147 if err := release.TrashApp(ctx, s, sqid, "shop", "owner@example.com", time.Unix(1753900000, 0)); err != nil {
148 t.Fatal(err)
149 }
150 ran := false
151 store := &hookStore{Store: s, key: release.AppTombstoneKey(sqid, "shop"), once: func() {
152 ran = true
153 for _, step := range []func() error{
154 func() error { return reg.CloseImportGate(original.Host, seedID, 1) },
155 func() error { return reg.RestoreImportRoute(original.Host, seedID) },
156 func() error { return reg.DeleteImportJournal(original.Host, seedID) },
157 } {
158 if err := step(); err != nil {
159 t.Fatal(err)
160 }
161 }
162 }}
163 if err := domains.Reconcile(ctx, store, reg, cfg(t)); err != nil {
164 t.Fatal(err)
165 }
166 if !ran {
167 t.Fatal("the rollback never ran between the list and the trash loop")
168 }
169 if got := mustGet(t, reg, original.Host); !reflect.DeepEqual(*got, original) {
170 t.Fatalf("the restored row changed: %+v, want %+v", got, original)
171 }
172}
173