carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The --from seed door (seeded-instances spec §4): `carlos instances create
2// --from <host>` creates an instance whose database starts as a copy of a
3// sibling's replica. handleAPIInstanceCreate runs every ordinary check,
4// then hands over here. The door answers 202 once the record is reserved,
5// and the copy finishes on its own (createFrom).
6
7package console
8
9import (
10 "bytes"
11 "context"
12 "crypto/sha256"
13 "encoding/hex"
14 "encoding/json"
15 "errors"
16 "fmt"
17 "io"
18 "io/fs"
19 "log"
20 "math"
21 "net/http"
22 "os"
23 "path/filepath"
24 "runtime/debug"
25 "strconv"
26 "strings"
27 "sync/atomic"
28 "time"
29
30 "github.com/carlosframework/platform/internal/dbdoor"
31 "github.com/carlosframework/platform/internal/objstore"
32 "github.com/carlosframework/platform/internal/provision"
33 "github.com/carlosframework/platform/internal/registry"
34 "github.com/carlosframework/platform/internal/release"
35 "github.com/carlosframework/platform/internal/seeds"
36)
37
38// Seams for the tests.
39var (
40 seedMaxBytes = seeds.MaxBytes
41 planRestore = dbdoor.PlanRestore
42 restoreToTarget = dbdoor.RestoreToTarget
43 // seedRestoreDeadline bounds the restore and its check (spec §4 step 5).
44 seedRestoreDeadline = 30 * time.Minute
45 // restoreWatchEvery is how often the size watcher looks (spec §4 step 5).
46 restoreWatchEvery = time.Second
47)
48
49// seedUploadDeadline bounds the snapshot's one PUT, after the restore's own
50// deadline: up to 4 GiB to the region's own bucket. The record is reserving
51// for the whole restore, store walk and upload, and the sweep aborts a
52// --from that has been reserving for an hour (spec §3.1), so the three
53// together stay well under that.
54const seedUploadDeadline = 20 * time.Minute
55
56// seedFromReq is the create body's "from" (spec §4). TXID 0 is the latest
57// copy; the CLI refuses a non-positive --at.
58type seedFromReq struct {
59 Host string `json:"host"`
60 TXID uint64 `json:"txid,omitempty"`
61}
62
63// seedCreate is what handleAPIInstanceCreate has decided by the time it
64// hands a seed door over: every ordinary check has passed. siblingLabel is
65// the create door's own (set when the host sits at another label of the
66// account's platform namespace), for its re-check before publishing.
67type seedCreate struct {
68 acct *Account
69 app, principal, message string
70 m *provision.Manifest
71 rec provision.Record
72 placed, regionFrom string
73 siblingLabel string
74}
75
76// seedCommonRefusal is both seed doors' shape rule: an ordinary instance.
77func seedCommonRefusal(kind string, addrs []string, role string, replicas []string) *doorRefusal {
78 if (kind != "" && kind != "instance") || len(addrs) > 0 || role != "" || len(replicas) > 0 {
79 return seedOrdinaryRefusal()
80 }
81 return nil
82}
83
84// seedOrdinaryRefusal is SD-N36, the shape rule's answer.
85func seedOrdinaryRefusal() *doorRefusal {
86 return &doorRefusal{http.StatusBadRequest, "a seeded instance is an ordinary instance. Drop --kind, --addr and --role."}
87}
88
89// seedNoDataRefusal is both doors' answer on a console with no data bucket
90// for seeds.
91func seedNoDataRefusal() *doorRefusal {
92 return &doorRefusal{http.StatusServiceUnavailable, "this console has no data bucket set up, so it cannot create a seeded instance."}
93}
94
95// seedPlacementRefusal is both seed doors' placement rule: the platform
96// keeps a replica only of an instance it hosts itself.
97func seedPlacementRefusal(c seedCreate) *doorRefusal {
98 if c.placed != "" || c.m.Pool != "" {
99 return &doorRefusal{http.StatusConflict, fmt.Sprintf("%s places its instances in a pool or on a fleet, so it cannot take a seeded instance.", c.app)}
100 }
101 return nil
102}
103
104// seedOwnEnvironment is SD-N33, the rule a seeded instance's environment
105// keeps (spec §4 Storage).
106const seedOwnEnvironment = "a seeded instance gets its own environment and an empty store"
107
108// refuseSeededEnvironment is both seed doors' answer to an explicit
109// --environment on an app with private storage (spec §4 Storage).
110func (s *Server) refuseSeededEnvironment(w http.ResponseWriter) {
111 jsonError(w, http.StatusConflict, seedOwnEnvironment)
112}
113
114// seedStoreTakenRefusal is the refusal of a seeded create whose environment
115// is not new: SD-N34 naming the store's bucket, or, when the environment
116// has no store with a bucket and is only named by another instance (final
117// review F6), the sentence that says so and what to do.
118func seedStoreTakenRefusal(env, app string, store *objstore.Record) *doorRefusal {
119 if bucket := storeBucketName(store); bucket != "" {
120 return &doorRefusal{http.StatusConflict, fmt.Sprintf("environment %s already has a store (%s). A seeded instance needs a new, empty one.", env, bucket)}
121 }
122 return &doorRefusal{http.StatusConflict, fmt.Sprintf("environment %s is already used by another instance of %s. A seeded instance gets a new environment named after its host, so choose another host.", env, app)}
123}
124
125// seedSiblingApexReason is the sentence both seed doors speak when an app
126// created under the host's label while the instance was being written has
127// made the host its platform apex. Main's create door keeps its own.
128func seedSiblingApexReason(host, label string) string {
129 return fmt.Sprintf("%s is app %s's platform apex. That app was created while this instance was being written, so the instance was not kept.", host, label)
130}
131
132// errSeedEnvironmentUnread is instanceStoreProvisionFor's answer when it
133// cannot tell whether another instance names a seed's environment. It
134// answers nothing on w: the seed door speaks its own retry sentence.
135var errSeedEnvironmentUnread = errors.New("console: the instances naming the environment could not be read")
136
137// seedEnvironmentUser names an instance of app, other than seed seedID's
138// own, whose record names env, or "" when none does (final review F6). A
139// seeded instance's derived environment must be unused: its store is new
140// and empty and its seed's finish may tear it down, which another
141// instance's environment must never be. It reads strictly
142// (provision.ListRecordsStrict): a listing it cannot complete, too many
143// records, or a record that will not decode is an error, since any of them
144// may name env.
145func (s *Server) seedEnvironmentUser(ctx context.Context, sqid, app, env, seedID string) (string, error) {
146 recs, unreadable, err := provision.ListRecordsStrict(ctx, s.cfg.Releases, sqid, app, instanceListMax)
147 if err != nil {
148 return "", err
149 }
150 if len(unreadable) > 0 {
151 return "", fmt.Errorf("%d records of %s/%s could not be read, so one may name environment %q", len(unreadable), sqid, app, env)
152 }
153 for _, r := range recs {
154 if r.Environment == env && (r.Seed == nil || r.Seed.ID != seedID) {
155 return r.Host, nil
156 }
157 }
158 return "", nil
159}
160
161// seedStoreRefusal is spec §4 Storage's existing-store rule for both doors,
162// checked before the record is reserved. instanceStoreProvisionFor checks
163// it again under the store lock. failed is the door's own retry sentence,
164// answered with a 502 when the store record cannot be read.
165func (s *Server) seedStoreRefusal(ctx context.Context, c seedCreate, seedID, failed string) *doorRefusal {
166 if !c.m.PrivateStorage {
167 return nil
168 }
169 rec, _, err := objstore.GetIn(ctx, s.cfg.Releases, c.acct.Sqid, c.app, c.rec.Environment)
170 if err != nil {
171 log.Printf("console: seed store check %s/%s/%s: %v", c.acct.Sqid, c.app, c.rec.Environment, err)
172 return &doorRefusal{http.StatusBadGateway, failed}
173 }
174 if !seedOwnsStore(rec, seedID) {
175 return seedStoreTakenRefusal(c.rec.Environment, c.app, rec)
176 }
177 user, err := s.seedEnvironmentUser(ctx, c.acct.Sqid, c.app, c.rec.Environment, seedID)
178 if err != nil {
179 log.Printf("console: seed store check %s/%s/%s: who names the environment: %v", c.acct.Sqid, c.app, c.rec.Environment, err)
180 return &doorRefusal{http.StatusBadGateway, failed}
181 }
182 if user != "" {
183 log.Printf("console: seed store check %s/%s/%s: instance %s already names the environment", c.acct.Sqid, c.app, c.rec.Environment, user)
184 return seedStoreTakenRefusal(c.rec.Environment, c.app, rec)
185 }
186 return nil
187}
188
189// seedGiB is SD-N26's size, rounded up to a tenth so a size over the limit
190// never prints as the limit itself.
191func seedGiB(n int64) string {
192 return fmt.Sprintf("%.1f GiB", math.Ceil(float64(n)/(1<<30)*10)/10)
193}
194
195// fromCopy is a --from copy whose record is reserved: everything runFrom
196// needs once the request has been answered. It owns the scratch
197// reservation (release) from the moment beginFrom hands it over.
198type fromCopy struct {
199 ctx context.Context // the request's values, never its cancellation
200 c seedCreate
201 from seedFromReq
202 source string
203 id string
204 plan dbdoor.RestorePlan
205 client *dbdoor.Client
206 release func()
207}
208
209// createFrom is --from (spec §4). Every refusal up to the record's
210// reservation is answered here, synchronously, after the scratch
211// reservation is released. Once the record is reserved the door answers
212// 202 with the reserving record and the copy runs on, detached from the
213// request (runFrom): a copy can take most of an hour, and the edge's
214// backhaul, the CLI's client and idle NATs all cut a response that keeps
215// its headers longer than 30 seconds (Plan 5 ruling R2, a deviation from
216// spec §4 for Paul). The caller follows the record until its seed is ready
217// or aborted, with the reason in Seed.Reason.
218func (s *Server) createFrom(w http.ResponseWriter, r *http.Request, c seedCreate, from seedFromReq) {
219 held := &heldResponse{header: w.Header()}
220 job := s.beginFrom(held, r, c, from)
221 if job != nil {
222 s.seedCopies.Add(1)
223 go s.runFrom(job)
224 }
225 held.flush(w, fmt.Sprintf("could not copy %s. Nothing was created. Try again.", strings.TrimSpace(from.Host)))
226}
227
228// beginFrom is the synchronous half: every check, the plan and its
229// admission, the store gates that write nothing, and the record's
230// reservation. It returns the copy to run, or nil when it has answered a
231// refusal on w.
232func (s *Server) beginFrom(w http.ResponseWriter, r *http.Request, c seedCreate, from seedFromReq) *fromCopy {
233 ctx := r.Context()
234 acct, app, host := c.acct, c.app, c.rec.Host
235 source := strings.TrimSpace(from.Host)
236 failed := fmt.Sprintf("could not copy %s. Nothing was created. Try again.", source)
237 if ref := seedPlacementRefusal(c); ref != nil {
238 jsonError(w, ref.Status, ref.Msg)
239 return nil
240 }
241 if s.cfg.SeedData == nil || s.cfg.HibernateBucket == "" {
242 ref := seedNoDataRefusal()
243 jsonError(w, ref.Status, ref.Msg)
244 return nil
245 }
246 checks, cancel := context.WithTimeout(ctx, instanceStoreTimeout)
247 defer cancel()
248 if ref := s.seedRegionReady(checks, c.rec.Region, false); ref != nil {
249 jsonError(w, ref.Status, ref.Msg)
250 return nil
251 }
252 src, err := s.resolveDBSource(checks, acct, c.principal, app, source)
253 if ref := s.fromSourceRefusal(src, source, app, err); ref != nil {
254 jsonError(w, ref.Status, ref.Msg)
255 return nil
256 }
257 if src.rec.Region != c.rec.Region {
258 jsonError(w, http.StatusConflict, "a copy stays in its source's region. To run it elsewhere, create it here and then run carlos move.")
259 return nil
260 }
261 rd, err := s.dbReplica(ctx, src.prefix)
262 if err != nil {
263 log.Printf("console: from %s: replica client: %v", source, err)
264 jsonError(w, http.StatusBadGateway, failed)
265 return nil
266 }
267 client := dbdoor.NewClient(replicaDoer(rd))
268 planCtx, planCancel := context.WithTimeout(ctx, instanceStoreTimeout)
269 plan, err := planRestore(planCtx, client, from.TXID)
270 planCancel()
271 switch {
272 case from.TXID != 0 && (errors.Is(err, dbdoor.ErrHistoryGone) || errors.Is(err, dbdoor.ErrNothingReplicated)):
273 jsonError(w, http.StatusConflict, fmt.Sprintf("history before %d is no longer kept", from.TXID))
274 return nil
275 case errors.Is(err, dbdoor.ErrNothingReplicated):
276 jsonError(w, http.StatusConflict, fmt.Sprintf("%s has not replicated anything yet. Wake it once, then try again.", source))
277 return nil
278 case err != nil:
279 // A file that vanished under the plan, a header that does not
280 // describe its file, a bucket read: a retry plans again.
281 log.Printf("console: from %s: plan: %v", source, err)
282 jsonError(w, http.StatusBadGateway, failed)
283 return nil
284 case from.TXID > plan.TXID:
285 // The plan clamps an --at past the latest transaction to the
286 // latest copy. A caller who named a point later than anything the
287 // source has replicated is told so, and nothing is reserved.
288 jsonError(w, http.StatusConflict, fmt.Sprintf("%s has replicated up to txid %d, so there is no txid %d to copy yet. Leave out --at to copy the latest.", source, plan.TXID, from.TXID))
289 return nil
290 }
291 if plan.RestoredBytes > seedMaxBytes {
292 jsonError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("a copy of %s would be %s, over the 4 GiB limit.", source, seedGiB(plan.RestoredBytes)))
293 return nil
294 }
295 releaseScratch, ref := s.seedScratch.reserve(acct.Sqid+"/"+app, plan.RestoredBytes+plan.LTXBytes)
296 if ref != nil {
297 jsonError(w, ref.Status, ref.Msg)
298 return nil
299 }
300 handedOver := false
301 defer func() {
302 if !handedOver {
303 releaseScratch()
304 }
305 }()
306 id, err := seeds.NewID()
307 if err != nil {
308 log.Printf("console: from %s: seed id: %v", source, err)
309 jsonError(w, http.StatusBadGateway, failed)
310 return nil
311 }
312 if c.m.PrivateStorage {
313 // The store gates that write nothing run before the reservation,
314 // so their refusals leave no aborted record holding the host
315 // (ruling R3). The walk runs them again, after it.
316 if _, ok := s.instanceStorePreflight(ctx, w, acct, app); !ok {
317 return nil
318 }
319 storeCtx, storeCancel := context.WithTimeout(ctx, instanceStoreTimeout)
320 ref = s.seedStoreRefusal(storeCtx, c, id, failed)
321 storeCancel()
322 if ref != nil {
323 jsonError(w, ref.Status, ref.Msg)
324 return nil
325 }
326 }
327
328 // Reserve the record (spec §4 step 7.1). It is reserved here, after the
329 // size, scratch and store checks rather than straight after resolving
330 // the source: a refusal before this point leaves nothing behind, where
331 // an earlier record would turn each into an aborted record holding the
332 // host until it is finished (a recorded deviation, Plan 5 M10).
333 rec := c.rec
334 rec.Seed = &provision.SeedSource{ID: id, Kind: seeds.KindReplica, From: source, State: seeds.StateReserving,
335 By: c.principal, At: time.Now().UTC()}
336 // Not the request's cancellation: a client leaving now must not turn a
337 // write that landed into an error, which would answer 502 and start no
338 // copy while the reserving record held the host.
339 writeCtx, writeCancel := context.WithTimeout(context.WithoutCancel(ctx), instanceStoreTimeout)
340 defer writeCancel()
341 if err := provision.CreateRecord(writeCtx, s.cfg.Releases, rec); err != nil {
342 if errors.Is(err, release.ErrCASConflict) {
343 jsonError(w, http.StatusConflict, "instance "+host+" already exists")
344 return nil
345 }
346 log.Printf("console: from %s: reserve %s: %v", source, host, err)
347 jsonError(w, http.StatusBadGateway, failed)
348 return nil
349 }
350 out := recordView(rec)
351 out.RegionFrom = c.regionFrom
352 out.RegionName = s.placeOf(rec.Region, s.candsOrHome(writeCtx)) // never fleet-placed: refused above
353 writeJSON(w, http.StatusAccepted, out)
354 handedOver = true
355 return &fromCopy{ctx: context.WithoutCancel(ctx), c: c, from: from, source: source,
356 id: id, plan: plan, client: client, release: releaseScratch}
357}
358
359// runFrom is the detached half: the seed's store, the restore, the upload
360// and the publish. It never depends on the request, which has been
361// answered. Every failure moves the record to aborted with the sentence
362// the synchronous door would have answered in Seed.Reason, for the box
363// and the console's finish to clean up (spec §4 step 7). On every exit it
364// removes its scratch and then releases the reservation.
365//
366// The console has no shutdown hook to cancel a running copy: a restart
367// leaves the record reserving, its scratch is cleared at start, and the
368// sweep aborts it (Plan 8).
369func (s *Server) runFrom(j *fromCopy) {
370 defer s.seedCopies.Done()
371 defer j.release()
372 ctx := j.ctx
373 c := j.c
374 acct, app, host, source := c.acct, c.app, c.rec.Host, j.source
375 // The reason a copy that failed after the answer leaves on its record.
376 // The aborted record holds the host until the box and the next sweep
377 // finish it, so a retry before then finds the host taken.
378 failed := fmt.Sprintf("could not copy %s, so no instance was created. %s is free again within about 5 minutes. Try again then.", source, host)
379 abort := func(reason, why string) {
380 bg, cancel := context.WithTimeout(ctx, instanceStoreTimeout)
381 defer cancel()
382 if _, err := provision.SetRecordSeed(bg, s.cfg.Releases, acct.Sqid, app, host, provision.SeedAbortBecause(j.id, reason)); err != nil {
383 log.Printf("console: from %s: abort %s (seed %s) after %s: %v", source, host, j.id, why, err)
384 }
385 s.bumpSerial(bg)
386 }
387 // Nothing above this copy contains a panic any more: net/http's
388 // handler recover covered the synchronous door, and a panic here (in
389 // the restore's decode of replica data, the check, the store walk, the
390 // upload) would end the whole console. It aborts this seed instead.
391 // Registered after release and Done, so it runs before them; scratch
392 // removal, registered later, has already run.
393 defer func() {
394 if p := recover(); p != nil {
395 log.Printf("console: from %s: panic in the copy of %s (seed %s): %v\n%s", source, host, j.id, p, debug.Stack())
396 abort(failed, "a panic")
397 }
398 }()
399
400 if c.m.PrivateStorage {
401 // Its refusals are written to a held answer nobody reads but this:
402 // the sentence becomes the seed's reason.
403 held := &heldResponse{header: http.Header{}}
404 storeRec, ok, err := s.instanceStoreProvisionFor(ctx, held, acct, app, c.rec.Environment, c.principal, j.id)
405 if !ok {
406 reason := held.message(failed)
407 switch {
408 case errors.Is(err, errSeedStoreTaken):
409 reason = seedStoreTakenRefusal(c.rec.Environment, c.app, storeRec).Msg
410 case errors.Is(err, errSeedEnvironmentUnread):
411 reason = failed
412 }
413 abort(reason, "store provisioning")
414 return
415 }
416 }
417
418 dir, err := os.MkdirTemp(s.seedScratch.dir(), seedScratchPrefix)
419 if err != nil {
420 log.Printf("console: from %s: scratch: %v", source, err)
421 abort(seedScratchFull().Msg, "scratch")
422 return
423 }
424 defer os.RemoveAll(dir)
425 // Pinned at the planned TXID, never "latest" again: only that restore
426 // produces the size that was admitted, however far the source has moved
427 // on since it was planned.
428 plan := j.plan
429 rctx, rcancel := context.WithTimeout(ctx, seedRestoreDeadline)
430 defer rcancel()
431 grew := restoreWatch(rctx, rcancel, dir, plan.RestoredBytes, plan.RestoredBytes+plan.LTXBytes)
432 restored, err := restoreToTarget(rctx, j.client, dir, plan.TXID)
433 timedOut := errors.Is(rctx.Err(), context.DeadlineExceeded)
434 rcancel() // stops the watcher
435 overgrown := grew()
436 if err == nil && !overgrown {
437 // The watcher's tick can come after a fast restore has finished.
438 st, serr := os.Stat(restored.Path)
439 switch {
440 case serr != nil:
441 err = serr
442 case st.Size() > plan.RestoredBytes:
443 overgrown = true
444 }
445 }
446 if err != nil || overgrown {
447 reason := failed
448 switch {
449 case overgrown:
450 reason = fmt.Sprintf("the copy of %s grew past its planned size, so it was stopped and no instance was created.", source)
451 case timedOut:
452 reason = fmt.Sprintf("copying %s took longer than 30 minutes, so no instance was created.", source)
453 case errors.Is(err, dbdoor.ErrCheckFailed):
454 reason = fmt.Sprintf("the copy of %s failed its integrity check, so no instance was created.", source)
455 case errors.Is(err, dbdoor.ErrHistoryGone) && plan.Target != 0:
456 reason = fmt.Sprintf("history before %d is no longer kept", j.from.TXID)
457 default:
458 // Including history lost under a latest copy between its plan
459 // and its restore: a retry plans again.
460 log.Printf("console: from %s: restore: %v", source, err)
461 }
462 abort(reason, "restore")
463 return
464 }
465
466 uctx, ucancel := context.WithTimeout(ctx, seedUploadDeadline)
467 sha, size, err := uploadSnapshot(uctx, s.cfg.SeedData, restored.Path, seeds.SnapshotKey(host, j.id))
468 ucancel()
469 if err != nil {
470 log.Printf("console: from %s: upload %s: %v", source, host, err)
471 abort(failed, "upload")
472 return
473 }
474 // The create door's re-check after its write (apiinstances.go), made
475 // here before the publish: a reserving record mints nothing, so the
476 // publish is the point of no return.
477 if c.siblingLabel != "" {
478 if live, trashed, err := s.store.AppNameState(acct.ID, c.siblingLabel); err == nil && (live || trashed) {
479 abort(seedSiblingApexReason(host, c.siblingLabel), "a sibling app took the label")
480 return
481 }
482 }
483 pctx, pcancel := context.WithTimeout(ctx, instanceStoreTimeout)
484 defer pcancel()
485 if _, err := provision.SetRecordSeed(pctx, s.cfg.Releases, acct.Sqid, app, host,
486 provision.SeedPublishReplica(j.id, strconv.FormatUint(restored.TXID, 10), sha, size)); err != nil {
487 log.Printf("console: from %s: publish %s: %v", source, host, err)
488 abort(failed, "publish")
489 return
490 }
491 s.auditActivity(acct, app, c.principal, Activity{
492 Action: "instance-seed", TargetType: "instance", Target: host, Message: c.message,
493 Detail: fmt.Sprintf("seeded %s from %s at txid %d", host, source, restored.TXID),
494 })
495 s.bumpSerial(pctx)
496}
497
498// fromSourceRefusal maps resolveDBSource's answer to --from's sentences.
499func (s *Server) fromSourceRefusal(src dbSource, source, app string, err error) *doorRefusal {
500 switch {
501 case err == nil:
502 return nil
503 case errors.Is(err, os.ErrNotExist), errors.Is(err, ErrBadHost):
504 return &doorRefusal{http.StatusConflict, fmt.Sprintf("no instance %s in %s. A copy can only come from an instance of the same app in the same account.", source, app)}
505 case errors.Is(err, errSourceClass):
506 return &doorRefusal{http.StatusConflict, fmt.Sprintf("%s is not an instance the platform keeps a replica of, so it cannot be copied.", source)}
507 case errors.Is(err, ErrPooled):
508 return &doorRefusal{http.StatusConflict, fmt.Sprintf("%s runs in the pool %s, so the platform keeps no replica of it to copy.", source, src.rec.Pool)}
509 case errors.Is(err, ErrResidency):
510 if fleet, ok := registry.FleetPlacement(src.rec.Region); ok {
511 return &doorRefusal{http.StatusConflict, fmt.Sprintf("%s runs on your fleet %s, so the platform keeps no replica of it to copy.", source, fleet)}
512 }
513 return &doorRefusal{http.StatusConflict, s.dbResidencyRefusal(src.rec)}
514 case errors.Is(err, errSourceClaim):
515 // Unreachable while seedRegionReady refuses a claim console first.
516 return seedClaimConsoleRefusal()
517 }
518 log.Printf("console: from %s: resolve: %v", source, err)
519 return &doorRefusal{http.StatusBadGateway, fmt.Sprintf("could not copy %s. Nothing was created. Try again.", source)}
520}
521
522// uploadSnapshot hashes path and writes it to key with one create-only
523// PutFrom (spec §4 step 7.2: seekable, under S3's 5 GB single-PUT limit).
524func uploadSnapshot(ctx context.Context, data release.Store, path, key string) (sha string, size int64, err error) {
525 f, err := os.Open(path)
526 if err != nil {
527 return "", 0, err
528 }
529 defer f.Close()
530 h := sha256.New()
531 if size, err = io.Copy(h, f); err != nil {
532 return "", 0, err
533 }
534 if _, err := f.Seek(0, io.SeekStart); err != nil {
535 return "", 0, err
536 }
537 if _, err := release.PutObjectFrom(ctx, data, key, f, size, ""); err != nil {
538 return "", 0, err
539 }
540 return hex.EncodeToString(h.Sum(nil)), size, nil
541}
542
543// restoreWatch is the size watcher (spec §4 step 5). Every
544// restoreWatchEvery it measures the restore under dir and cancels it when
545// the database (litestream's replica.db.tmp while it decodes, replica.db
546// after its rename) grows past dbLimit, or everything the restore holds
547// (the database and its downloaded LTX files) grows past totalLimit, the
548// reservation. grew reports whether it fired.
549func restoreWatch(ctx context.Context, cancel context.CancelFunc, dir string, dbLimit, totalLimit int64) (grew func() bool) {
550 var fired atomic.Bool
551 every := restoreWatchEvery
552 go func() {
553 t := time.NewTicker(every)
554 defer t.Stop()
555 for {
556 select {
557 case <-ctx.Done():
558 return
559 case <-t.C:
560 }
561 if db, total := restoreScratchUse(dir); db > dbLimit || total > totalLimit {
562 fired.Store(true)
563 cancel()
564 return
565 }
566 }
567 }()
568 return fired.Load
569}
570
571// restoreScratchUse measures a restore under dir: the largest database file
572// (replica.db or replica.db.tmp, dbdoor's names) and every regular file
573// together. SQLite's shared-memory index, which the read-only check opens
574// beside the copy, is not the restore's and is not counted.
575func restoreScratchUse(dir string) (db, total int64) {
576 _ = filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error {
577 if err != nil || !d.Type().IsRegular() || strings.HasSuffix(d.Name(), "-shm") {
578 return nil // a file the restore removed between listing and stat is not there
579 }
580 info, err := d.Info()
581 if err != nil {
582 return nil
583 }
584 total += info.Size()
585 if n := d.Name(); n == "replica.db" || n == "replica.db.tmp" {
586 db = max(db, info.Size())
587 }
588 return nil
589 })
590 return db, total
591}
592
593// heldResponse holds a door's answer until its cleanup has run.
594type heldResponse struct {
595 header http.Header
596 status int
597 body bytes.Buffer
598}
599
600func (h *heldResponse) Header() http.Header { return h.header }
601
602func (h *heldResponse) WriteHeader(code int) {
603 if h.status == 0 {
604 h.status = code
605 }
606}
607
608func (h *heldResponse) Write(b []byte) (int, error) {
609 h.WriteHeader(http.StatusOK)
610 return h.body.Write(b)
611}
612
613// flush writes what was held. A door that held nothing answers failed
614// with a 502, never an empty 200.
615func (h *heldResponse) flush(w http.ResponseWriter, failed string) {
616 if h.status == 0 {
617 jsonError(w, http.StatusBadGateway, failed)
618 return
619 }
620 w.WriteHeader(h.status)
621 _, _ = w.Write(h.body.Bytes())
622}
623
624// message is the held refusal's sentence, or fallback when nothing usable
625// was held.
626func (h *heldResponse) message(fallback string) string {
627 var out struct {
628 Error string `json:"error"`
629 }
630 if err := json.Unmarshal(h.body.Bytes(), &out); err != nil || out.Error == "" {
631 return fallback
632 }
633 return out.Error
634}
635