carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/hostops"
13 "github.com/carlosframework/platform/internal/release"
14)
15
16func init() {
17 // Package tests run on hosts without root's enrollment file.
18 sqlInstanceIdentity = func() (string, error) { return "i-console-test", nil }
19}
20
21// Fix round 1, item 2: a public transaction the batch callback leaves open
22// must abort the batch, not vanish into the forced cleanup rollback.
23func TestPlan9Fix1BatchRefusesTransactionLeftOpen(t *testing.T) {
24 s := openGatedStore(t)
25 seedGateApp(t, s)
26 r := receiptFixture(t, s)
27 err := preparedBatch(t, s, &r, func(v *Store) error {
28 tx, err := v.db.BeginTx(context.Background(), nil)
29 if err != nil {
30 return err
31 }
32 _, err = tx.Exec(`UPDATE audit SET action='left-open' WHERE account_id=7 AND app='blog'`)
33 return err
34 })
35 if err == nil {
36 t.Fatal("batch reported success after losing an uncommitted transaction")
37 }
38 var n int
39 if qerr := s.rawBoot.QueryRow(`SELECT count(*) FROM claim_sql_receipts WHERE effect=? AND state='committed'`, r.Effect).Scan(&n); qerr != nil || n != 0 {
40 t.Fatalf("receipt committed for an aborted batch: n=%d err=%v", n, qerr)
41 }
42 if countWhere(t, s, "audit", "action='left-open'") != 0 {
43 t.Fatal("uncommitted write landed")
44 }
45 // A transaction the callback finishes itself still commits normally.
46 r2 := r
47 r2.Effect = "ingest-2"
48 if err = preparedBatch(t, s, &r2, func(v *Store) error {
49 tx, err := v.db.Begin()
50 if err != nil {
51 return err
52 }
53 if _, err = tx.Exec(`UPDATE audit SET action='closed' WHERE account_id=7 AND app='blog'`); err != nil {
54 tx.Rollback()
55 return err
56 }
57 return tx.Commit()
58 }); err != nil {
59 t.Fatal(err)
60 }
61}
62
63// I-3: a cloned console (same disk image, different EC2 instance) must not
64// reuse the original's SQL receipt database identity. Since fix round 2
65// (I2-2) the clone opens under an identity of its own rather than refusing.
66func TestPlan9Fix1SQLIdentityRefusesClonedInstance(t *testing.T) {
67 path := filepath.Join(t.TempDir(), "console.db")
68 restore := UseSQLInstanceIdentityForTest("i-original")
69 s, err := OpenGated(path)
70 if err != nil {
71 restore()
72 t.Fatal(err)
73 }
74 id, err := s.sqlDatabaseID()
75 s.Close()
76 restore()
77 if err != nil {
78 t.Fatal(err)
79 }
80 if !strings.Contains(id, "i-original") {
81 t.Fatalf("database identity %q does not bind the instance", id)
82 }
83 restore = UseSQLInstanceIdentityForTest("i-original")
84 again, err := OpenGated(path)
85 restore()
86 if err != nil {
87 t.Fatalf("original instance refused its own database: %v", err)
88 }
89 id2, err := again.sqlDatabaseID()
90 again.Close()
91 if err != nil || id2 != id {
92 t.Fatalf("identity changed across reopen: %q %q %v", id, id2, err)
93 }
94 restore = UseSQLInstanceIdentityForTest("i-clone")
95 defer restore()
96 clone, err := OpenGated(path)
97 if err != nil {
98 t.Fatalf("clone: %v", err)
99 }
100 defer clone.Close()
101 if cid, err := clone.sqlDatabaseID(); err != nil || cid == id {
102 t.Fatalf("cloned instance reused the original's receipt database identity: %q %v", cid, err)
103 }
104}
105
106func TestPlan9Fix1SQLIdentityRefusesMissingInstance(t *testing.T) {
107 restore := useSQLInstanceIdentity(func() (string, error) { return "", errors.New("no host-actor.json") })
108 defer restore()
109 if s, err := OpenGated(filepath.Join(t.TempDir(), "console.db")); err == nil {
110 s.Close()
111 t.Fatal("gated DB opened without a per-instance identity")
112 }
113}
114
115// I-4: SQL effects bound to a replaced receipt database surface as errors and
116// an operator alert once past sql_recover_after; they are never settled.
117func TestPlan9Fix1ReplacedDatabaseEffectsAlert(t *testing.T) {
118 raw := release.NewDirStore(t.TempDir())
119 w := claimTestWriter(t, raw)
120 reg := hostops.Registration{Kind: hostops.RegistrationIngest, Owner: claimTestOwner, AppID: "app", Epoch: 1, Expires: time.Now().Add(time.Hour)}
121 past := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339Nano)
122 future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano)
123 effect := func(id, after string) hostops.Effect {
124 return hostops.Effect{ID: id, OperationID: id, AppID: "app", Resource: "app-sql:" + id, Action: "sql-ingest", Owner: claimTestOwner, State: "uncertain", Inputs: map[string]string{"sql_database": "restored-away", "sql_recover_after": after}}
125 }
126 seedClaimAdmission(t, raw, "bab", "demo", hostops.AppAdmission{V: 1, AppID: "app", Revision: 1, Epoch: 1, State: "open",
127 Registrations: map[string]hostops.Registration{"old": reg, "young": reg},
128 Effects: map[string]hostops.Effect{"old": effect("old", past), "young": effect("young", future)}})
129 st := openGatedStore(t)
130 s := &Server{store: st, cfg: Config{HostClaimOps: &hostops.Controller{Store: raw, Control: w, Now: time.Now}}}
131 err := s.sweepOneClaimAdmission(t.Context(), hostops.App{Account: "bab", Name: "demo"})
132 if !errors.Is(err, ErrSQLDatabaseReplaced) {
133 t.Fatalf("replaced-database effect skipped silently: %v", err)
134 }
135 if strings.Contains(err.Error(), "young") {
136 t.Fatalf("effect before sql_recover_after reported: %v", err)
137 }
138 firing, known, aerr := st.AlertSeen(sqlReceiptAlertBox, sqlReplacedRule("bab", "demo", "old"), noMailboxAddress)
139 if aerr != nil || !known || !firing {
140 t.Fatalf("no operator alert recorded: firing=%v known=%v err=%v", firing, known, aerr)
141 }
142 if _, known, _ = st.AlertSeen(sqlReceiptAlertBox, sqlReplacedRule("bab", "demo", "young"), noMailboxAddress); known {
143 t.Fatal("alert raised before sql_recover_after")
144 }
145 a, err := hostops.ReadAdmission(t.Context(), raw, "bab", "demo", "app")
146 if err != nil {
147 t.Fatal(err)
148 }
149 if a.Value.Effects["old"].State != "uncertain" {
150 t.Fatal("replaced-database effect was settled by inference")
151 }
152}
153
154// M13: rows from a statement prepared on the bound view are refused, so a
155// retained result set can never hold the console's only connection.
156func TestPlan9Fix1RetainedPreparedRowsCannotPinConnection(t *testing.T) {
157 for _, batch := range []bool{false, true} {
158 s := openGatedStore(t)
159 seedGateApp(t, s)
160 r := receiptFixture(t, s)
161 var receipts []hostops.SQLReceiptScope
162 if batch {
163 receipts = []hostops.SQLReceiptScope{r}
164 }
165 retained := make(chan *sql.Rows, 1)
166 done := make(chan error, 1)
167 go func() {
168 done <- s.withPreparedClaimSQL(t.Context(), []claimSQLScope{gateScope}, allowAllClaimSQL, allowAllClaimSQL, receipts, batch, func(v *Store) error {
169 st, err := v.db.Prepare(`SELECT action FROM audit`)
170 if err != nil {
171 return err
172 }
173 rows, err := st.QueryContext(context.Background())
174 if err == nil {
175 retained <- rows
176 }
177 return nil
178 })
179 }()
180 select {
181 case <-done:
182 case <-time.After(3 * time.Second):
183 select {
184 case rows := <-retained:
185 rows.Close()
186 default:
187 }
188 <-done
189 t.Fatalf("batch=%v: retained prepared rows blocked revoke", batch)
190 }
191 if _, err := s.db.Exec(`SELECT 1`); err != nil {
192 t.Fatalf("batch=%v: pool unusable after scope: %v", batch, err)
193 }
194 }
195}
196
197// M13: a nested WithAppMutation is refused before any remote preparation.
198func TestPlan9Fix1NestedAppMutationRefusedBeforeRemote(t *testing.T) {
199 s := openGatedStore(t)
200 seedGateApp(t, s)
201 calls := 0
202 g := sqlGrant{scope: gateScope, kind: grantEffect, check: func(context.Context) error { calls++; return nil }}
203 err := s.withClaimSQL(t.Context(), []claimSQLScope{gateScope}, allowAllClaimSQL, func(v *Store) error {
204 return v.WithAppMutation(t.Context(), []sqlGrant{g}, func(*Store) error { return nil })
205 })
206 if !errors.Is(err, ErrClaimSQLScope) {
207 t.Fatalf("nested mutation: %v", err)
208 }
209 if calls != 0 {
210 t.Fatalf("nested mutation ran %d remote checks before refusing", calls)
211 }
212}
213
214func sqlIngestFixture(t *testing.T) (*Store, release.Store, release.Store) {
215 t.Helper()
216 s := openGatedStore(t)
217 seedGateApp(t, s)
218 if _, err := s.db.Exec(`UPDATE accounts SET sqid='bab' WHERE id=7`); err != nil {
219 t.Fatal(err)
220 }
221 raw := release.NewDirStore(t.TempDir())
222 w := claimTestWriter(t, raw)
223 seedClaimAdmission(t, raw, "bab", "blog", hostops.AppAdmission{V: 1, AppID: gateScope.AppID, Revision: 1, Epoch: 1, State: "open", Registrations: map[string]hostops.Registration{}, Effects: map[string]hostops.Effect{}})
224 bucket := claimTestBoundary(t, raw, w)
225 if err := s.bindClaimSQLMutations(bucket); err != nil {
226 t.Fatal(err)
227 }
228 return s, raw, bucket
229}
230
231// M13: the callback's error appears once in withAutoAppSQL's result.
232func TestPlan9Fix1AutoSQLErrorNotDuplicated(t *testing.T) {
233 s, _, _ := sqlIngestFixture(t)
234 marker := errors.New("unique-callback-failure")
235 err := s.withAutoAppSQL(t.Context(), gateAcct, "blog", "test", func(*Store) error { return marker })
236 if !errors.Is(err, marker) {
237 t.Fatalf("callback error lost: %v", err)
238 }
239 if n := strings.Count(err.Error(), marker.Error()); n != 1 {
240 t.Fatalf("callback error appears %d times: %v", n, err)
241 }
242}
243
244// M13: a refusal before the SQL connection is bound is proven by the local
245// fence at once, so the ingest effect does not wait out its registration.
246func TestPlan9Fix1PreBindRefusalSettlesIngest(t *testing.T) {
247 s, raw, bucket := sqlIngestFixture(t)
248 runner := bucket.(hostops.SQLIngestRunner)
249 database, err := s.sqlDatabaseID()
250 if err != nil {
251 t.Fatal(err)
252 }
253 refused := errors.New("second grant refused")
254 called := false
255 err = runner.RunSQLIngest(t.Context(), "bab", "blog", "test", database, func(pctx context.Context) error {
256 bad := sqlGrant{scope: gateScope, kind: grantEffect, check: func(context.Context) error { return refused }}
257 return s.WithAppMutation(pctx, []sqlGrant{permitGrant(pctx, bucket, "bab", gateScope), bad}, func(*Store) error {
258 called = true
259 return nil
260 })
261 })
262 if !errors.Is(err, refused) || called {
263 t.Fatalf("pre-bind refusal: called=%v err=%v", called, err)
264 }
265 a, err := hostops.ReadCanonicalAdmission(t.Context(), raw, "bab", "blog")
266 if err != nil {
267 t.Fatal(err)
268 }
269 if len(a.Value.Registrations) != 0 {
270 t.Fatalf("ingest registration left to expire: %+v", a.Value.Registrations)
271 }
272 for id, e := range a.Value.Effects {
273 if e.State != "complete" {
274 t.Fatalf("effect %s left %s after a proven refusal", id, e.State)
275 }
276 }
277}
278
279// M13: recovery that finds the original commit records it as committed.
280func TestPlan9Fix1FenceReportsCommittedReceipt(t *testing.T) {
281 s := openGatedStore(t)
282 seedGateApp(t, s)
283 r := receiptFixture(t, s)
284 if err := preparedBatch(t, s, &r, func(v *Store) error {
285 _, err := v.db.Exec(`UPDATE audit SET action='done' WHERE account_id=7 AND app='blog'`)
286 return err
287 }); err != nil {
288 t.Fatal(err)
289 }
290 committed, err := s.FenceSQLOutcome(t.Context(), r)
291 if err != nil || !committed {
292 t.Fatalf("committed receipt: committed=%v err=%v", committed, err)
293 }
294 other := r
295 other.Effect = "never-ran"
296 committed, err = s.FenceSQLOutcome(t.Context(), other)
297 if err != nil || committed {
298 t.Fatalf("fresh fence: committed=%v err=%v", committed, err)
299 }
300}
301