carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package hostbox
2
3import (
4 "context"
5 "crypto/rand"
6 "crypto/sha256"
7 "database/sql"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "os"
12 "path/filepath"
13 "sync"
14 "time"
15
16 "github.com/carlosframework/platform/internal/hostguard"
17 "github.com/carlosframework/platform/internal/rootstate"
18)
19
20var ErrLegacyJournalProof = errors.New("legacy journal requires root-signed stop evidence")
21
22// ErrJournalUncertain says nothing about whether a request committed.
23var ErrJournalUncertain = errors.New("hostbox: journal request outcome uncertain")
24
25// A mutation executes only within JournalRequestWindow of the caller's issue
26// time, so a receipt older than JournalReceiptRetention can never be needed:
27// its request either replayed already or can only ever answer uncertain.
28const (
29 JournalRequestWindow = 15 * time.Minute
30 JournalReceiptRetention = 24 * time.Hour
31 journalPruneBatch = 256
32)
33
34// journalPrepare fixes a request's identity once, before its first attempt.
35// Reads get a fresh random ID. Every mutation is v2: a per-call nonce and issue time
36// are hashed in, so two distinct calls with identical payloads never share a
37// receipt. Retries resend the same prepared request.
38func journalPrepare(req JournalRequest, now time.Time) (JournalRequest, error) {
39 req.Nonce, req.IssuedAt = "", 0
40 switch {
41 case journalRead(req.Op):
42 var id [16]byte
43 if _, err := rand.Read(id[:]); err != nil {
44 return req, err
45 }
46 req.V = 1
47 req.ID = hex.EncodeToString(id[:])
48 default:
49 var nonce [16]byte
50 if _, err := rand.Read(nonce[:]); err != nil {
51 return req, err
52 }
53 req.V = 2
54 if req.RecordKey != "" || req.RecordETag != "" {
55 req.V = 3
56 }
57 if req.ServingPark != nil {
58 req.V = 4
59 }
60 req.Nonce = hex.EncodeToString(nonce[:])
61 req.IssuedAt = now.UnixNano()
62 req.ID = journalRequestID(req)
63 }
64 return req, nil
65}
66
67// JournalRequest carries values, never SQL or caller-chosen filesystem paths.
68// ID is retained by the transport across retries. Journal-only effects and
69// their replies share a FULL-synchronous transaction. Parking records a pending
70// receipt before filesystem work and a completed reply afterwards.
71type JournalRequest struct {
72 Timer *TimerMutation `json:"timer,omitempty"`
73 ServingPark *ServingParkRequest `json:"serving_park,omitempty"`
74 RecordKey string `json:"record_key,omitempty"`
75 RecordETag string `json:"record_etag,omitempty"`
76 Revision uint64 `json:"inventory_revision"`
77 V int `json:"v"`
78 ID string `json:"request_id"`
79 Op string `json:"operation"`
80 Target hostguard.FenceTarget `json:"target"`
81 Launch LaunchInventory `json:"launch"`
82 Reservation LaunchReservation `json:"reservation"`
83 File LaunchFile `json:"file"`
84 Producer string `json:"producer"`
85 Previous ProducerCompletion `json:"previous"`
86 PID int `json:"pid"`
87 ProcessStart string `json:"process_start"`
88 Inventory StopInventory `json:"inventory"`
89 Nonce string `json:"nonce,omitempty"`
90 IssuedAt int64 `json:"issued_at,omitempty"`
91}
92type JournalResponse struct {
93 ServingPark *ServingParkResult `json:"serving_park,omitempty"`
94 FenceView *hostguard.FenceView `json:"fence_view,omitempty"`
95 Revision uint64 `json:"inventory_revision"`
96 LastRequest string `json:"last_request"`
97 V int `json:"v"`
98 ID string `json:"request_id"`
99 Launch LaunchInventory `json:"launch"`
100 Launches []LaunchInventory `json:"launches"`
101 Inventory StopInventory `json:"inventory"`
102 Completion ProducerCompletion `json:"completion"`
103 Found bool `json:"found"`
104 Error string `json:"error"`
105}
106type JournalCall func(context.Context, JournalRequest) (JournalResponse, error)
107
108// JournalUncertainError retains the exact request for explicit same-ID replay.
109// Read-back can inform recovery; it never proves that a pending write failed.
110type JournalUncertainError struct {
111 Request JournalRequest
112 Cause error
113}
114
115func (e *JournalUncertainError) Error() string { return "journal request outcome uncertain" }
116func (e *JournalUncertainError) Unwrap() error { return e.Cause }
117
118func RemoteJournals(call JournalCall) (*LaunchJournal, *StopInventoryDB) {
119 return &LaunchJournal{remote: call}, &StopInventoryDB{remote: call}
120}
121func journalCall(ctx context.Context, call JournalCall, req JournalRequest) (JournalResponse, error) {
122 req, err := journalPrepare(req, time.Now())
123 if err != nil {
124 return JournalResponse{}, err
125 }
126 return journalSend(ctx, call, req, false)
127}
128
129// ReplayUncertain resends the exact request an uncertain call retained. The
130// earlier attempt may have committed, so the replay stays uncertain on every
131// refusal, including after the receipt was pruned. Only a reply that names
132// this exact request with no error confirms it.
133func (j *LaunchJournal) ReplayUncertain(ctx context.Context, uncertain *JournalUncertainError) (JournalResponse, error) {
134 if j == nil || j.remote == nil || uncertain == nil || uncertain.Request.ID == "" {
135 return JournalResponse{}, ErrUnsupported
136 }
137 return journalSend(ctx, j.remote, uncertain.Request, true)
138}
139
140func journalSend(ctx context.Context, call JournalCall, req JournalRequest, replay bool) (JournalResponse, error) {
141 out, err := call(ctx, req)
142 if err != nil {
143 return out, &JournalUncertainError{Request: req, Cause: err}
144 }
145 if out.V != 1 || out.ID != req.ID {
146 return out, &JournalUncertainError{Request: req, Cause: ErrJournalUncertain}
147 }
148 switch out.Error {
149 case "":
150 return out, nil
151 }
152 if replay {
153 return out, &JournalUncertainError{Request: req, Cause: ErrJournalUncertain}
154 }
155 switch out.Error {
156 case "missing":
157 return out, sql.ErrNoRows
158 case "changed":
159 return out, ErrChanged
160 case "unsupported":
161 return out, ErrUnsupported
162 case "uncertain":
163 return out, &JournalUncertainError{Request: req, Cause: ErrJournalUncertain}
164 default:
165 return out, ErrTypedInventory
166 }
167}
168
169// JournalReadOrSettle identifies read-only operations and positive completion
170// of already issued work. It never includes creating a new producer or role.
171func JournalReadOrSettle(op string) bool {
172 return journalRead(op) || op == "close-writers" || op == "started" || op == "settled" || op == "complete"
173}
174
175func journalRead(op string) bool {
176 return op == "fence-view" || op == "read" || op == "session" || op == "has-host" || op == "has-effects" || op == "inventory-read" || op == "inventory-exact" || op == "inventory-version"
177}
178
179// journalRequestV1 freezes every field, including nested inventories, in an
180// ordered v1 tuple. Do not extend these tuples when the public structs grow:
181// a new hashed field requires a new protocol version and a separate encoder.
182func journalRequestV1(q JournalRequest) []byte {
183 target := func(t hostguard.FenceTarget) any {
184 return []any{t.Region, t.ClaimGen, t.ObligationID, t.FenceID, t.Host, t.Incarnation, t.Stamp, t.Session, t.Op, t.Phase, t.Box, t.Enrollment, t.BootID, t.ControllerID, t.ClaimETag, t.DirectoryRevision}
185 }
186 file := func(f LaunchFile) any { return []any{f.Device, f.Inode} }
187 group := func(g LaunchGroup) any { return []any{g.RelativePath, g.Device, g.Inode, g.InvocationID} }
188 reservation := func(r LaunchReservation) any {
189 return []any{r.ID, r.Role, r.InvocationID, r.Inputs, r.State, r.ProcessStart, target(r.Target), group(r.Group), group(r.Parent), file(r.Executable), r.PID}
190 }
191 groups := make([]any, len(q.Launch.Groups))
192 for i, g := range q.Launch.Groups {
193 groups[i] = group(g)
194 }
195 files := make([]any, len(q.Launch.Files))
196 for i, f := range q.Launch.Files {
197 files[i] = file(f)
198 }
199 reservations := make([]any, len(q.Launch.Reservations))
200 for i, r := range q.Launch.Reservations {
201 reservations[i] = reservation(r)
202 }
203 launch := []any{q.Launch.Completed.Count, q.Launch.Completed.Digest, q.Launch.ControllerPID, q.Launch.ProcessStart, target(q.Launch.Target), groups, files, append([]string{}, q.Launch.ProducerIDs...), q.Launch.NoState, reservations}
204 entries := make([]any, len(q.Inventory.Entries))
205 for i, e := range q.Inventory.Entries {
206 entries[i] = []any{e.Device, e.Inode, e.Type, e.Kind, e.ArtifactID, e.ManagedRoot, e.RelativePath, e.RowIdentity, e.Ownership, e.Materialization}
207 }
208 inventory := []any{q.Inventory.Complete, q.Inventory.Host, q.Inventory.Incarnation, q.Inventory.InstallationStamp, q.Inventory.Box, q.Inventory.Enrollment, q.Inventory.DirectoryRevision, entries}
209 raw, _ := json.Marshal([]any{"carlos-journal-request-v1", q.Revision, q.Op, target(q.Target), launch, reservation(q.Reservation), file(q.File), q.Producer, q.Previous.Count, q.Previous.Digest, q.PID, q.ProcessStart, inventory})
210 return raw
211}
212
213// journalRequestV2 is the frozen v1 tuple plus the caller's per-call nonce and
214// issue time. It is a separate encoder; v1 identities are unchanged.
215func journalRequestV2(q JournalRequest) []byte {
216 raw, _ := json.Marshal([]any{"carlos-journal-request-v2", string(journalRequestV1(q)), q.Nonce, q.IssuedAt})
217 return raw
218}
219func journalRequestHash(req JournalRequest) [sha256.Size]byte {
220 identities := make([]rootstate.FileIdentity, len(req.Inventory.Entries))
221 present := false
222 for i, e := range req.Inventory.Entries {
223 identities[i] = e.Identity
224 present = present || e.Identity != (rootstate.FileIdentity{})
225 }
226 if present {
227 base := req
228 base.Inventory.Entries = append([]StopInventoryEntry(nil), req.Inventory.Entries...)
229 for i := range base.Inventory.Entries {
230 base.Inventory.Entries[i].Identity = rootstate.FileIdentity{}
231 }
232 raw, _ := json.Marshal([]any{"carlos-inventory-identity-v1", journalRequestHash(base), identities})
233 return sha256.Sum256(raw)
234 }
235
236 if req.V == 4 {
237 raw, _ := json.Marshal([]any{"carlos-journal-request-v4", string(journalRequestV2(req)), journalServingParkV4(req.ServingPark)})
238 return sha256.Sum256(raw)
239 }
240 if req.V == 3 {
241 raw, _ := json.Marshal([]any{"carlos-journal-request-v3", string(journalRequestV2(req)), req.RecordKey, req.RecordETag})
242 return sha256.Sum256(raw)
243 }
244 if req.V == 2 {
245 return sha256.Sum256(journalRequestV2(req))
246 }
247 return sha256.Sum256(journalRequestV1(req))
248}
249func journalRequestID(req JournalRequest) string {
250 sum := journalRequestHash(req)
251 return hex.EncodeToString(sum[:16])
252}
253func journalRequestDigest(req JournalRequest) string {
254 sum := journalRequestHash(req)
255 return hex.EncodeToString(sum[:])
256}
257func validateJournalRequest(q JournalRequest) error {
258 if q.V == 4 {
259 return validateServingParkRequest(q)
260 }
261 if q.ServingPark != nil || ServingParkOperation(q.Op) {
262 return ErrTypedJournal
263 }
264 // The frozen request hash does not carry this root-only response field.
265 if q.Launch.WritersClosed || (q.V != 3 && (q.RecordKey != "" || q.RecordETag != "")) || (q.V == 3 && (q.Op != "inventory-write" || q.RecordKey == "" || q.RecordETag == "")) {
266 return ErrTypedJournal
267 }
268 id, err := hex.DecodeString(q.ID)
269 if err != nil || len(id) != 16 {
270 return ErrTypedJournal
271 }
272 switch {
273 case journalRead(q.Op):
274 if q.V != 1 || q.Nonce != "" || q.IssuedAt != 0 {
275 return ErrTypedJournal
276 }
277 return nil
278 default:
279 nonce, err := hex.DecodeString(q.Nonce)
280 if (q.V != 2 && q.V != 3) || err != nil || len(nonce) != 16 || q.IssuedAt <= 0 {
281 return ErrTypedJournal
282 }
283 }
284 if q.ID != journalRequestID(q) {
285 return ErrChanged
286 }
287 return nil
288}
289
290type journalRequestKey struct{}
291
292const inventoryVersionsSQL = "CREATE TABLE IF NOT EXISTS journal_inventory_versions(host TEXT NOT NULL, incarnation TEXT NOT NULL, stamp INTEGER NOT NULL, revision INTEGER NOT NULL CHECK(revision>0 AND typeof(revision)='integer'), request TEXT NOT NULL, PRIMARY KEY(host,incarnation,stamp))"
293
294type journalExecutor interface {
295 ExecContext(context.Context, string, ...any) (sql.Result, error)
296 QueryContext(context.Context, string, ...any) (*sql.Rows, error)
297 QueryRowContext(context.Context, string, ...any) *sql.Row
298}
299type journalTransactionKey struct{}
300
301func journalExecutorFor(ctx context.Context, db *sql.DB) journalExecutor {
302 if tx, ok := ctx.Value(journalTransactionKey{}).(*sql.Tx); ok {
303 return tx
304 }
305 return db
306}
307func journalTransaction(ctx context.Context, db *sql.DB) (*sql.Tx, func() error, func() error, error) {
308 if tx, ok := ctx.Value(journalTransactionKey{}).(*sql.Tx); ok {
309 return tx, func() error { return nil }, func() error { return nil }, nil
310 }
311 tx, err := db.BeginTx(ctx, nil)
312 if err != nil {
313 return nil, nil, nil, err
314 }
315 return tx, tx.Commit, tx.Rollback, nil
316}
317func journalIdentity(path string, original os.FileInfo) error {
318 if original == nil {
319 return nil
320 }
321 now, err := os.Lstat(path)
322 if err != nil {
323 return err
324 }
325 if !now.Mode().IsRegular() || !os.SameFile(original, now) {
326 return ErrChanged
327 }
328 return nil
329}
330
331// JournalService owns one protected database and serializes a request's replay
332// check, typed mutation and receipt. Local root recovery uses the same DB. A
333// replay never repeats an effect, even when the reply or root process was lost.
334// A v2 mutation executes only within JournalRequestWindow of its issue time,
335// so once a receipt is older than JournalReceiptRetention no request can
336// still need it: a replay inside the window found it, and anything later can
337// only answer uncertain. Mutations prune such settled receipts in bounded
338// batches. inventory-write needs no receipt to replay: its version row names
339// the last committed request.
340type JournalService struct {
341 parkGate chan struct{}
342 Launches *LaunchJournal
343 Inventory *StopInventoryDB
344 Now func() time.Time
345 mu sync.Mutex
346}
347
348func NewJournalService(j *LaunchJournal) (*JournalService, error) {
349 if j == nil || j.db == nil || j.remote != nil {
350 return nil, ErrUnsupported
351 }
352 for _, q := range []string{inventoryVersionsSQL,
353 "CREATE TABLE IF NOT EXISTS stop_inventory(host TEXT NOT NULL,incarnation TEXT NOT NULL,stamp INTEGER NOT NULL,body BLOB NOT NULL,PRIMARY KEY(host,incarnation,stamp))",
354 "CREATE TABLE IF NOT EXISTS journal_requests(id TEXT PRIMARY KEY, peer TEXT NOT NULL, digest TEXT NOT NULL, response BLOB NOT NULL, created INTEGER NOT NULL DEFAULT 0, issued INTEGER NOT NULL DEFAULT 0)",
355 "CREATE TABLE IF NOT EXISTS journal_receipt_retention(id INTEGER PRIMARY KEY CHECK(id=1), reject_before INTEGER NOT NULL)",
356 "INSERT INTO journal_receipt_retention(id,reject_before) VALUES(1,0) ON CONFLICT(id) DO NOTHING",
357 } {
358 if _, err := j.db.Exec(q); err != nil {
359 return nil, err
360 }
361 }
362 // Receipts written before retention existed carry created=0 and are pruned
363 // first; their v1 mutation IDs can no longer execute.
364 var created int
365 if err := j.db.QueryRow("SELECT count(*) FROM pragma_table_info('journal_requests') WHERE name='created'").Scan(&created); err != nil {
366 return nil, err
367 }
368 if created == 0 {
369 if _, err := j.db.Exec("ALTER TABLE journal_requests ADD COLUMN created INTEGER NOT NULL DEFAULT 0"); err != nil {
370 return nil, err
371 }
372 }
373 var issued int
374 if err := j.db.QueryRow("SELECT count(*) FROM pragma_table_info('journal_requests') WHERE name='issued'").Scan(&issued); err != nil {
375 return nil, err
376 }
377 if issued == 0 {
378 if _, err := j.db.Exec("ALTER TABLE journal_requests ADD COLUMN issued INTEGER NOT NULL DEFAULT 0"); err != nil {
379 return nil, err
380 }
381 }
382 if _, err := j.db.Exec("CREATE INDEX IF NOT EXISTS journal_requests_created ON journal_requests(created)"); err != nil {
383 return nil, err
384 }
385 dir, err := os.Open(filepath.Dir(j.path))
386 if err != nil {
387 return nil, err
388 }
389 err = dir.Sync()
390 closeErr := dir.Close()
391 if err != nil || closeErr != nil {
392 return nil, errors.Join(err, closeErr)
393 }
394 return &JournalService{parkGate: make(chan struct{}, 1), Launches: j, Inventory: &StopInventoryDB{db: j.db, shared: true}}, nil
395}
396
397// Replay returns only a byte-identical request by the same authenticated owner.
398func (s *JournalService) Replay(ctx context.Context, peer string, q JournalRequest) (JournalResponse, bool, error) {
399 out := JournalResponse{V: 1, ID: q.ID}
400 if err := validateJournalRequest(q); err != nil {
401 return out, false, err
402 }
403 if journalRead(q.Op) {
404 return out, false, nil
405 }
406 digestExpected := journalRequestDigest(q)
407 var priorPeer, digest string
408 var body []byte
409 err := s.Launches.db.QueryRowContext(ctx, "SELECT peer,digest,response FROM journal_requests WHERE id=?", q.ID).Scan(&priorPeer, &digest, &body)
410 if errors.Is(err, sql.ErrNoRows) {
411 return out, false, nil
412 }
413 if err != nil {
414 return out, false, err
415 }
416 if priorPeer != peer || digest != digestExpected {
417 return out, false, ErrChanged
418 }
419 err = json.Unmarshal(body, &out)
420 return out, true, err
421}
422
423func (s *JournalService) Execute(ctx context.Context, peer string, q JournalRequest) (JournalResponse, error) {
424 return s.execute(ctx, peer, q, false)
425}
426func (s *JournalService) CompletePendingInventory(ctx context.Context, peer string, q JournalRequest) (JournalResponse, error) {
427 if q.V != 3 || q.Op != "inventory-write" {
428 return JournalResponse{}, ErrTypedJournal
429 }
430 return s.execute(ctx, peer, q, true)
431}
432func (s *JournalService) ExecuteRootInventory(ctx context.Context, peer string, q JournalRequest, in StopInventory, pending bool) (JournalResponse, error) {
433 if q.V != 3 || q.Op != "inventory-write" || q.Inventory.Host != in.Host || q.Inventory.Incarnation != in.Incarnation || q.Inventory.InstallationStamp != in.InstallationStamp {
434 return JournalResponse{}, ErrTypedJournal
435 }
436 return s.executeWithInventory(ctx, peer, q, pending, &in)
437}
438
439func (s *JournalService) execute(ctx context.Context, peer string, q JournalRequest, pendingInventory bool) (JournalResponse, error) {
440 return s.executeWithInventory(ctx, peer, q, pendingInventory, nil)
441}
442func (s *JournalService) executeWithInventory(ctx context.Context, peer string, q JournalRequest, pendingInventory bool, inventory *StopInventory) (JournalResponse, error) {
443 out := JournalResponse{V: 1, ID: q.ID}
444 if err := validateJournalRequest(q); err != nil {
445 return out, err
446 }
447 if peer == "" {
448 return out, ErrTypedJournal
449 }
450 if journalRead(q.Op) {
451 return s.read(ctx, q)
452 }
453 digest := journalRequestDigest(q)
454 if inventory != nil {
455 q.Inventory = *inventory
456 }
457 s.mu.Lock()
458 defer s.mu.Unlock()
459 tx, err := s.Launches.db.BeginTx(ctx, nil)
460 if err != nil {
461 return out, err
462 }
463 defer tx.Rollback()
464 var priorPeer, priorDigest string
465 var prior []byte
466 err = tx.QueryRowContext(ctx, "SELECT peer,digest,response FROM journal_requests WHERE id=?", q.ID).Scan(&priorPeer, &priorDigest, &prior)
467 if err == nil {
468 if priorPeer != peer || priorDigest != digest {
469 return out, ErrChanged
470 }
471 err = json.Unmarshal(prior, &out)
472 return out, err
473 }
474 if !errors.Is(err, sql.ErrNoRows) {
475 return out, err
476 }
477 // Inventory's permanent revision row can prove this exact nonce committed
478 // even after the receipt expired. It never authorizes another execution.
479 ctx = context.WithValue(ctx, journalTransactionKey{}, tx)
480 if q.Op == "inventory-write" {
481 prior, e := s.Inventory.inventoryVersion(ctx, hostguard.FenceTarget{Host: q.Inventory.Host, Incarnation: q.Inventory.Incarnation, Stamp: q.Inventory.InstallationStamp})
482 if e != nil {
483 return out, e
484 }
485 if prior.Revision == q.Revision+1 && prior.LastRequest == q.ID {
486 out.Revision = prior.Revision
487 return out, nil
488 }
489 }
490 now := time.Now()
491 if s.Now != nil {
492 now = s.Now()
493 }
494 var rejectBefore int64
495 if err = tx.QueryRowContext(ctx, "SELECT reject_before FROM journal_receipt_retention WHERE id=1").Scan(&rejectBefore); err != nil {
496 return out, err
497 }
498 // The rejection boundary only ever rises to the issue time of a receipt
499 // this transaction deletes, so a pruned request can never execute again
500 // whatever the clock does, and a forward clock jump cannot raise it past
501 // real time: it covers only requests that actually committed.
502 if (q.V == 2 || q.V == 3) && !pendingInventory {
503 age := now.Sub(time.Unix(0, q.IssuedAt))
504 if q.IssuedAt <= rejectBefore || age > JournalRequestWindow || age < -JournalRequestWindow {
505 return out, ErrJournalUncertain
506 }
507 }
508 rejectBefore, err = journalPrune(ctx, tx, rejectBefore, now)
509 if err != nil {
510 return out, err
511 }
512 ctx = context.WithValue(ctx, journalTransactionKey{}, tx)
513 ctx = context.WithValue(ctx, journalRequestKey{}, q.ID)
514 switch q.Op {
515 case "close-writers":
516 err = s.Launches.CloseWriters(ctx, q.Target)
517 case "create":
518 err = s.Launches.Create(ctx, q.Launch)
519 case "prelaunch":
520 err = s.Launches.Prelaunch(ctx, q.Launch)
521 case "intend":
522 err = s.Launches.IntendRole(ctx, q.Reservation)
523 case "reserve":
524 err = s.Launches.Reserve(ctx, q.Reservation)
525 case "started":
526 err = s.Launches.Started(ctx, q.Reservation, q.PID, q.ProcessStart)
527 case "settled":
528 err = s.Launches.Settled(ctx, q.Reservation)
529 case "file":
530 err = s.Launches.RecordFile(ctx, q.Target, q.File)
531 case "producer":
532 err = s.Launches.RecordProducer(ctx, q.Target, q.Producer)
533 case "complete":
534 out.Completion, err = s.Launches.CompleteProducer(ctx, q.Target, q.Producer, q.Previous)
535 case "inventory-write":
536 prior, e := s.Inventory.inventoryVersion(ctx, hostguard.FenceTarget{Host: q.Inventory.Host, Incarnation: q.Inventory.Incarnation, Stamp: q.Inventory.InstallationStamp})
537 if e != nil {
538 return out, e
539 }
540 if prior.Revision != q.Revision {
541 return out, ErrChanged
542 }
543 err = s.Inventory.WriteStopInventory(ctx, q.Inventory)
544 out.Revision = prior.Revision + 1
545 default:
546 return out, ErrUnsupported
547 }
548 // A failed operation is rolled back, so no partial mutation acquires a receipt.
549 if err != nil {
550 return out, err
551 }
552 body, err := json.Marshal(out)
553 if err != nil {
554 return out, err
555 }
556 if _, err = tx.ExecContext(ctx, "INSERT INTO journal_requests(id,peer,digest,response,created,issued) VALUES(?,?,?,?,?,?)", q.ID, peer, digest, body, now.UnixNano(), q.IssuedAt); err != nil {
557 return out, err
558 }
559 if err = tx.Commit(); err != nil {
560 return out, err
561 }
562 return out, nil
563}
564
565// Reads neither take a write transaction nor allocate durable request receipts.
566// inventory-version is a single joined SELECT, so revision/body/last ID form
567// one SQLite snapshot even when a root writer commits concurrently.
568func (s *JournalService) read(ctx context.Context, q JournalRequest) (JournalResponse, error) {
569 out := JournalResponse{V: 1, ID: q.ID}
570 var err error
571 switch q.Op {
572 case "read":
573 out.Launch, err = s.Launches.Read(ctx, q.Target)
574 case "session":
575 out.Launches, err = s.Launches.SessionLaunches(ctx, q.Target.Host, q.Target.Incarnation, q.Target.Stamp, q.Target.Session, q.Target.Box, q.Target.Enrollment)
576 case "has-host":
577 out.Found, err = s.Launches.HasHost(ctx, q.Target.Host)
578 case "has-effects":
579 out.Found, err = s.Launches.HasInstallationEffects(ctx, q.Target.Host, q.Target.Incarnation, q.Target.Stamp)
580 case "inventory-version":
581 out, err = s.Inventory.inventoryVersion(ctx, q.Target)
582 out.V = 1
583 out.ID = q.ID
584 case "inventory-read":
585 out.Inventory, err = s.Inventory.ReadStopInventory(ctx, q.Target.Host)
586 case "inventory-exact":
587 out.Inventory, err = s.Inventory.ReadExactStopInventory(ctx, q.Target)
588 default:
589 return out, ErrUnsupported
590 }
591 return out, err
592}
593
594// journalPrune deletes one bounded batch of receipts past retention and raises
595// the persisted boundary to cover their requests. A request executed within
596// JournalRequestWindow of its issue time, so an unrecorded issue time is at
597// most that far past the receipt's creation.
598func journalPrune(ctx context.Context, tx *sql.Tx, rejectBefore int64, now time.Time) (int64, error) {
599 cutoff := max(rejectBefore-int64(JournalRequestWindow), now.Add(-JournalReceiptRetention).UnixNano())
600 rows, err := tx.QueryContext(ctx, "SELECT rowid,created,issued FROM journal_requests WHERE created < ? ORDER BY created LIMIT ?", cutoff, journalPruneBatch)
601 if err != nil {
602 return rejectBefore, err
603 }
604 var ids []int64
605 covered := rejectBefore
606 for rows.Next() {
607 var id, created, issued int64
608 if err = rows.Scan(&id, &created, &issued); err != nil {
609 rows.Close()
610 return rejectBefore, err
611 }
612 if issued <= 0 {
613 issued = created + int64(JournalRequestWindow)
614 }
615 ids = append(ids, id)
616 covered = max(covered, issued)
617 }
618 if err = errors.Join(rows.Err(), rows.Close()); err != nil {
619 return rejectBefore, err
620 }
621 if len(ids) == 0 {
622 return rejectBefore, nil
623 }
624 if _, err = tx.ExecContext(ctx, "UPDATE journal_receipt_retention SET reject_before=? WHERE id=1", covered); err != nil {
625 return rejectBefore, err
626 }
627 for _, id := range ids {
628 if _, err = tx.ExecContext(ctx, "DELETE FROM journal_requests WHERE rowid=?", id); err != nil {
629 return rejectBefore, err
630 }
631 }
632 return covered, nil
633}
634