carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package registry
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "reflect"
11 "strings"
12)
13
14// The import journal (seeded-instances spec §5.7): one row per host this
15// box has swapped for an import, holding the complete original route, so
16// the box always knows what it replaced, whatever happened to the record.
17const (
18 JournalSwapped = "swapped"
19 JournalRollingBack = "rolling-back"
20 JournalCleanupPending = "cleanup-pending"
21)
22
23// ImportGateClosed is the import gate a rollback installs: it matches no
24// token's hash (seeds.ClosedGate holds the same value).
25const ImportGateClosed = "closed"
26
27// ErrImportJournal is a journal transaction whose precondition did not
28// hold: no row or another seed's, a row already there, a changed route.
29var ErrImportJournal = errors.New("registry: the import journal does not allow this")
30
31// What the host's own env file (<host>.env, at the nested path the swap
32// gives the import's row) held when the swap ran, before any launch of the
33// import: ImportJournal.EnvBefore. The platform mints the instance key into
34// that file at the import's first launch, create-only, and a key there
35// outranks the app's configuration, so a rollback has to know whether the
36// key it finds is the import's own (it retires it) or was there before (it
37// never touches it). "" is a journal row written before this was recorded:
38// nothing is known, so nothing is retired.
39const (
40 EnvBeforeAbsent = "absent" // no env file: every byte in it later is the import's
41 EnvBeforeNoKey = "no-key" // an env file with no instance key: a key in it later is the import's
42 EnvBeforeKey = "key" // an env file that already carried a key: the import minted none
43)
44
45// ImportJournal is one import_journal row. Original is the route the swap
46// replaced, exactly as it was stored. EnvBefore is one of the EnvBefore
47// values, or "" for unknown.
48type ImportJournal struct {
49 Host string
50 SeedID string
51 Original Route
52 RecordKey string
53 State string
54 FenceGen uint64
55 At int64
56 EnvBefore string
57}
58
59func (r *Registry) migrateImportJournal() error {
60 if _, err := r.sql.Exec(`CREATE TABLE IF NOT EXISTS import_journal (
61 host TEXT PRIMARY KEY,
62 seed_id TEXT NOT NULL,
63 original TEXT NOT NULL,
64 record_key TEXT NOT NULL DEFAULT '',
65 state TEXT NOT NULL,
66 fence_gen INTEGER NOT NULL DEFAULT 0,
67 at INTEGER NOT NULL DEFAULT 0,
68 env_before TEXT NOT NULL DEFAULT ''
69);`); err != nil {
70 return err
71 }
72 // A journal table from before env_before gains it; its rows read "".
73 if _, err := r.sql.Exec(`ALTER TABLE import_journal ADD COLUMN env_before TEXT NOT NULL DEFAULT ''`); err != nil &&
74 !strings.Contains(err.Error(), "duplicate column") {
75 return err
76 }
77 return nil
78}
79
80type queryer interface {
81 QueryRow(query string, args ...any) *sql.Row
82}
83
84type txer interface {
85 queryer
86 Exec(query string, args ...any) (sql.Result, error)
87}
88
89const journalCols = `host, seed_id, original, record_key, state, fence_gen, at, env_before`
90
91func scanJournalRow(scan func(...any) error) (ImportJournal, error) {
92 var j ImportJournal
93 var orig string
94 if err := scan(&j.Host, &j.SeedID, &orig, &j.RecordKey, &j.State, &j.FenceGen, &j.At, &j.EnvBefore); err != nil {
95 return j, err
96 }
97 if err := json.Unmarshal([]byte(orig), &j.Original); err != nil {
98 return j, fmt.Errorf("registry: import journal %s: original row: %w", j.Host, err)
99 }
100 return j, nil
101}
102
103func scanJournal(q queryer, host string) (*ImportJournal, error) {
104 j, err := scanJournalRow(q.QueryRow(`SELECT `+journalCols+` FROM import_journal WHERE host = ?`, host).Scan)
105 if errors.Is(err, sql.ErrNoRows) {
106 return nil, nil
107 }
108 if err != nil {
109 return nil, err
110 }
111 return &j, nil
112}
113
114// ImportJournal returns host's journal row, or nil when there is none.
115func (r *Registry) ImportJournal(host string) (*ImportJournal, error) {
116 if r.remote != nil {
117 return nil, errRemoteMethod
118 }
119 return scanJournal(r.sql, host)
120}
121
122// ImportJournals returns every journal row, ordered by host.
123func (r *Registry) ImportJournals() ([]ImportJournal, error) {
124 if r.remote != nil {
125 return nil, errRemoteMethod
126 }
127 rows, err := r.sql.Query(`SELECT ` + journalCols + ` FROM import_journal ORDER BY host`)
128 if err != nil {
129 return nil, err
130 }
131 defer rows.Close()
132 var out []ImportJournal
133 for rows.Next() {
134 j, err := scanJournalRow(rows.Scan)
135 if err != nil {
136 return nil, err
137 }
138 out = append(out, j)
139 }
140 return out, rows.Err()
141}
142
143// ImportJournalHosts returns the hosts the journal holds a row for, in any
144// state, ordered. It is what a box says about its imports in the claim
145// activation census (hostops.CollectCensusSeeds). Unlike the journal's
146// other methods it answers on a handle that proxies to root's registry
147// child too, so a collector that runs as root can ask.
148func (r *Registry) ImportJournalHosts() ([]string, error) {
149 if r.remote != nil {
150 out, err := r.remote.call(nil, remoteRequest{Method: "ImportJournalHosts"}, nil)
151 if err != nil {
152 return nil, err
153 }
154 // The transport drops an empty list; "none" is not "did not say".
155 return append([]string{}, out.Hosts...), nil
156 }
157 rows, err := r.sql.Query(`SELECT host FROM import_journal ORDER BY host`)
158 if err != nil {
159 return nil, err
160 }
161 defer rows.Close()
162 out := []string{}
163 for rows.Next() {
164 var h string
165 if err := rows.Scan(&h); err != nil {
166 return nil, err
167 }
168 out = append(out, h)
169 }
170 return out, rows.Err()
171}
172
173// hashImportJournal folds the journal into SnapshotFingerprint's digest:
174// the journal is box-local state nothing in the bucket can rebuild, and a
175// snapshot that kept a deleted cleanup-pending row would, restored cold,
176// resume a finished rollback's bucket cleanup over a later instance on the
177// same host. Every column, in host order, under its own section marker. An
178// empty journal writes nothing, so a box with no import in flight digests
179// exactly as it did before the journal existed.
180func hashImportJournal(db *sql.DB, h io.Writer) error {
181 rows, err := db.Query(`SELECT ` + journalCols + ` FROM import_journal ORDER BY host`)
182 if err != nil {
183 return err
184 }
185 defer rows.Close()
186 first := true
187 for rows.Next() {
188 var host, seedID, orig, recordKey, state, envBefore string
189 var fenceGen uint64
190 var at int64
191 if err := rows.Scan(&host, &seedID, &orig, &recordKey, &state, &fenceGen, &at, &envBefore); err != nil {
192 return err
193 }
194 if first {
195 fmt.Fprint(h, "\x1dimport_journal\x1e")
196 first = false
197 }
198 fmt.Fprintf(h, "%q\x1f%q\x1f%q\x1f%q\x1f%q\x1f%d\x1f%d", host, seedID, orig, recordKey, state, fenceGen, at)
199 // env_before is hashed only when set, tagged, so a row written
200 // before the column existed digests as it did.
201 if envBefore != "" {
202 fmt.Fprintf(h, "\x1fenv_before=%q", envBefore)
203 }
204 fmt.Fprint(h, "\x1e")
205 }
206 return rows.Err()
207}
208
209// inTx runs fn in one BEGIN IMMEDIATE transaction at synchronous=FULL (the
210// claim transactions' form). IMMEDIATE takes the write lock before the first
211// read, so another process's write waits on busy_timeout instead of failing
212// the upgrade; FULL makes the commit durable before the caller acts on it
213// (the swap's record CAS follows it). The registry's pool holds one
214// connection (Open), so in-process writers serialise on it.
215//
216// The import journal, the fence generation source and the seed column are
217// the agent's own: the agent opens the registry itself (Open), and no root
218// pass reads or writes them. A handle that proxies to root's registry child
219// (OpenRemote) has no dispatch for them, so each refuses there
220// (errRemoteMethod) rather than touch a database it does not hold.
221func (r *Registry) inTx(fn func(tx txer) error) error {
222 if r.remote != nil {
223 return errRemoteMethod
224 }
225 tx, err := r.beginClaimTransaction(context.Background(), true)
226 if err != nil {
227 return err
228 }
229 defer tx.Rollback()
230 if err := fn(tx); err != nil {
231 return err
232 }
233 return tx.Commit()
234}
235
236func getRouteTx(tx txer, host string) (*Route, error) {
237 t, err := scanRoute(tx.QueryRow(`SELECT `+routeCols+` FROM routes WHERE host = ?`, host).Scan)
238 if errors.Is(err, sql.ErrNoRows) {
239 return nil, nil
240 }
241 if err != nil {
242 return nil, err
243 }
244 return &t, nil
245}
246
247// insertRouteTx inserts t exactly as given (no Upsert defaults).
248func insertRouteTx(tx txer, t Route) error {
249 _, err := tx.Exec(`INSERT INTO routes (`+routeCols+`) VALUES (`+placeholders(routeColCount)+`)`, routeArgs(t)...)
250 return err
251}
252
253// IsImportRow reports whether rt is the row j's swap put in place: the
254// journal's host, an instance row a record provisioned, still gated (by
255// the verify hash, or the closed sentinel once a rollback began), and
256// whose seed column names j's seed. The rollback acts only on this row
257// (spec §5.6): any other row at the host belongs to someone else, such as
258// a later instance when a stale journal row came back from a snapshot.
259func IsImportRow(rt Route, j ImportJournal) bool {
260 return j.SeedID != "" && rt.Host == j.Host && rt.Kind == "instance" && rt.Provisioned &&
261 rt.ImportGate != "" && seedColumnID(rt.SeedSource) == j.SeedID
262}
263
264// seedColumnID is the seed id a seed_source column names, "" for none or
265// one that does not parse. The column is seeds.RowSeed's JSON; the
266// registry reads only its id, since seeds imports this package.
267func seedColumnID(col string) string {
268 if col == "" {
269 return ""
270 }
271 var v struct {
272 ID string `json:"id"`
273 }
274 if err := json.Unmarshal([]byte(col), &v); err != nil {
275 return ""
276 }
277 return v.ID
278}
279
280// SwapForImport is the swap's one transaction (spec §5.4, codex 14): with
281// no journal row for the host and the stored route still exactly
282// j.Original, it replaces the route with next (Upsert's defaults applied)
283// and writes the journal row at swapped. next must be the import's row as
284// IsImportRow reads it (the same host, gated, naming the journal's seed),
285// and the journal must name its record: a journal row with no record key
286// can never be decided, so it is never written. The original must be one
287// the journal can put back: not claim-owned (claims are off for imports,
288// spec §3.4), not held by an operation, not itself gated, and unchanged by
289// its JSON round trip. j.EnvBefore is stored as given: one of the EnvBefore
290// values, or "" when the caller could not say.
291func (r *Registry) SwapForImport(j ImportJournal, next Route) error {
292 if j.Host == "" || j.SeedID == "" || j.RecordKey == "" || j.Original.Host != j.Host || !IsImportRow(normalizeRoute(next), j) {
293 return fmt.Errorf("%w: the swap for %s is not well formed", ErrImportJournal, j.Host)
294 }
295 switch j.EnvBefore {
296 case "", EnvBeforeAbsent, EnvBeforeNoKey, EnvBeforeKey:
297 default:
298 return fmt.Errorf("%w: the swap for %s is not well formed", ErrImportJournal, j.Host)
299 }
300 if j.Original.ClaimOwnership != (ClaimOwnership{}) || j.Original.HoldOp != "" || j.Original.ImportGate != "" {
301 return fmt.Errorf("%w: the route at %s is not one an import replaces", ErrImportJournal, j.Host)
302 }
303 orig, err := json.Marshal(j.Original)
304 if err != nil {
305 return err
306 }
307 var back Route
308 if err := json.Unmarshal(orig, &back); err != nil || !reflect.DeepEqual(back, j.Original) {
309 return fmt.Errorf("%w: the route at %s would not restore exactly", ErrImportJournal, j.Host)
310 }
311 return r.inTx(func(tx txer) error {
312 if cur, err := scanJournal(tx, j.Host); err != nil {
313 return err
314 } else if cur != nil {
315 return fmt.Errorf("%w: %s already has a journal row for seed %s", ErrImportJournal, j.Host, cur.SeedID)
316 }
317 stored, err := getRouteTx(tx, j.Host)
318 if err != nil {
319 return err
320 }
321 if stored == nil || !reflect.DeepEqual(*stored, j.Original) {
322 return fmt.Errorf("%w: the route at %s changed since it was checked", ErrImportJournal, j.Host)
323 }
324 if _, err := tx.Exec(`DELETE FROM routes WHERE host = ?`, j.Host); err != nil {
325 return err
326 }
327 if err := insertRouteTx(tx, normalizeRoute(next)); err != nil {
328 return err
329 }
330 _, err = tx.Exec(`INSERT INTO import_journal (`+journalCols+`) VALUES (?,?,?,?,?,0,?,?)`,
331 j.Host, j.SeedID, string(orig), j.RecordKey, JournalSwapped, j.At, j.EnvBefore)
332 return err
333 })
334}
335
336// journalFor is host's journal row, which must name seedID and be in one of
337// states.
338func journalFor(tx txer, host, seedID string, states ...string) (*ImportJournal, error) {
339 j, err := scanJournal(tx, host)
340 if err != nil {
341 return nil, err
342 }
343 if j == nil || j.SeedID != seedID {
344 return nil, fmt.Errorf("%w: no journal row for %s seed %s", ErrImportJournal, host, seedID)
345 }
346 for _, s := range states {
347 if j.State == s {
348 return j, nil
349 }
350 }
351 return nil, fmt.Errorf("%w: %s's journal is %s", ErrImportJournal, host, j.State)
352}
353
354// importRowTx is host's row when it is j's import row, nil when the host
355// has no row, and ErrImportJournal when the row there is anyone else's.
356func importRowTx(tx txer, j *ImportJournal) (*Route, error) {
357 cur, err := getRouteTx(tx, j.Host)
358 if err != nil {
359 return nil, err
360 }
361 if cur != nil && !IsImportRow(*cur, *j) {
362 return nil, fmt.Errorf("%w: the route at %s is not import %s's", ErrImportJournal, j.Host, j.SeedID)
363 }
364 return cur, nil
365}
366
367// CloseImportGate is the rollback's step 1 (spec §5.6): the journal says
368// rolling-back with the fence generation, and the gate refuses even verify
369// requests, in one transaction. Run again on a row already rolling-back, it
370// re-closes the gate and keeps the stored generation (fenceGen is ignored):
371// step 2 recognises this rollback's own fence by that generation, so the
372// caller reads FenceGen back from the journal rather than trusting its own.
373//
374// It refuses when the host's row is not the import's (IsImportRow), so it
375// never gates a row the import does not own. A host with no row is closed
376// all the same: the journal moves on, and there is no gate to set.
377func (r *Registry) CloseImportGate(host, seedID string, fenceGen uint64) error {
378 return r.inTx(func(tx txer) error {
379 j, err := journalFor(tx, host, seedID, JournalSwapped, JournalRollingBack)
380 if err != nil {
381 return err
382 }
383 if _, err := importRowTx(tx, j); err != nil {
384 return err
385 }
386 if j.State == JournalSwapped {
387 if _, err := tx.Exec(`UPDATE import_journal SET state = ?, fence_gen = ? WHERE host = ?`, JournalRollingBack, fenceGen, host); err != nil {
388 return err
389 }
390 }
391 _, err = tx.Exec(`UPDATE routes SET import_gate = ? WHERE host = ?`, ImportGateClosed, host)
392 return err
393 })
394}
395
396// RestoreImportRoute is the rollback's step 4 (spec §5.6, codex R3-1): the
397// original route back exactly as it was stored, and the journal at
398// cleanup-pending.
399//
400// Only the import's own row is replaced (IsImportRow). Any other row at
401// the host is refused, and nothing changes: it is not the import's to
402// remove. A host with no row at all (something removed the import's row)
403// gets no route back, since whatever removed it decided the host should
404// not be served; the journal still moves to cleanup-pending, so the
405// rollback carries on with its cleanup.
406func (r *Registry) RestoreImportRoute(host, seedID string) error {
407 return r.inTx(func(tx txer) error {
408 j, err := journalFor(tx, host, seedID, JournalRollingBack)
409 if err != nil {
410 return err
411 }
412 cur, err := importRowTx(tx, j)
413 if err != nil {
414 return err
415 }
416 if cur != nil {
417 if _, err := tx.Exec(`DELETE FROM routes WHERE host = ?`, host); err != nil {
418 return err
419 }
420 if err := insertRouteTx(tx, j.Original); err != nil {
421 return err
422 }
423 }
424 _, err = tx.Exec(`UPDATE import_journal SET state = ? WHERE host = ?`, JournalCleanupPending, host)
425 return err
426 })
427}
428
429// CommitImport clears the gate and retires the journal in one transaction
430// (spec §5.4 "Commit").
431func (r *Registry) CommitImport(host, seedID string) error {
432 return r.inTx(func(tx txer) error {
433 if _, err := journalFor(tx, host, seedID, JournalSwapped); err != nil {
434 return err
435 }
436 if _, err := tx.Exec(`UPDATE routes SET import_gate = '' WHERE host = ?`, host); err != nil {
437 return err
438 }
439 _, err := tx.Exec(`DELETE FROM import_journal WHERE host = ?`, host)
440 return err
441 })
442}
443
444// DeleteImportJournal is the rollback's step 7.
445func (r *Registry) DeleteImportJournal(host, seedID string) error {
446 return r.inTx(func(tx txer) error {
447 if _, err := journalFor(tx, host, seedID, JournalCleanupPending); err != nil {
448 return err
449 }
450 _, err := tx.Exec(`DELETE FROM import_journal WHERE host = ?`, host)
451 return err
452 })
453}
454