package console import ( "errors" "fmt" "log" "net/http" "os" "path/filepath" "strings" "sync" ) // seedScratch is the console's one scratch reservation pool, shared by // --from restores, import spools (seeded-instances spec §4 step 5, §5.2) // and rootfs uploads (rootfs apps Plan 1): an in-process counter over // /scratch/seeds/, and the per-app, per-account and // deployment limits on how many run at once. type seedScratch struct { root string free func(string) (int64, error) mu sync.Mutex capacity int64 used int64 running int perApp map[string]bool perAccount map[string]bool // an account with a reservation } const ( seedScratchSlack = 1 << 30 // capacity is free space less this seedScratchAllApps = 2 // reservations across the deployment; one per app, one per account // seedScratchPrefix starts the name of every entry the pool's users // create directly inside the scratch directory. Start-up cleanup removes // entries with this prefix and nothing else. seedScratchPrefix = "from-" ) func newSeedScratch(root string, free func(string) (int64, error)) *seedScratch { p := &seedScratch{root: root, free: free, perApp: map[string]bool{}, perAccount: map[string]bool{}} p.readCapacity() return p } // startSeedScratch is what NewServer calls: it clears the pool's leftovers // and builds the pool. An empty directory, or one the cleanup refuses, // leaves a pool with no capacity, so every reservation is a 507. func startSeedScratch(dir string) *seedScratch { usable := dir != "" if usable { if err := clearSeedScratch(dir); err != nil { log.Printf("console: seed scratch %s: %v (the seed doors will refuse)", dir, err) usable = false } } return newSeedScratch(dir, func(d string) (int64, error) { if !usable { return 0, errors.New("no usable seed scratch directory") } return scratchFree(d) }) } func (p *seedScratch) dir() string { return p.root } // readCapacity is called with p.mu held, or before the pool is shared. func (p *seedScratch) readCapacity() { n, err := p.free(p.root) if err != nil { p.capacity = -1 // unavailable: below zero, so even a zero-byte reservation is refused return } p.capacity = max(n-seedScratchSlack, 0) } // reserve takes bytes for appKey (account/app), or answers why not: the // app already has a reservation, the account already holds one (for // another app: the pool has two slots, and one account holding both would // turn every other account away for as long as its uploads run), the // deployment's slots are taken, or there is no room. The per-account // refusal names no app: an app-scoped credential must not learn that a // sibling app exists (see apiAccountAppSaying). // The returned release is safe to call more than once. func (p *seedScratch) reserve(appKey string, bytes int64) (func(), *doorRefusal) { p.mu.Lock() defer p.mu.Unlock() acct, app, _ := strings.Cut(appKey, "/") if p.perApp[appKey] { return nil, &doorRefusal{http.StatusTooManyRequests, fmt.Sprintf("another copy or upload is running for %s. Try again when it finishes.", app)} } if p.perAccount[acct] { return nil, &doorRefusal{http.StatusTooManyRequests, "another copy or upload is running in this account. try again when it finishes"} } if p.running >= seedScratchAllApps { return nil, &doorRefusal{http.StatusTooManyRequests, "the console is already running two copies or uploads. Try again in a few minutes."} } if p.used == 0 { p.readCapacity() } if bytes < 0 || bytes > p.capacity-p.used { return nil, seedScratchFull() } p.used += bytes p.running++ p.perApp[appKey] = true p.perAccount[acct] = true var once sync.Once return func() { once.Do(func() { p.mu.Lock() defer p.mu.Unlock() p.used -= bytes p.running-- delete(p.perApp, appKey) delete(p.perAccount, acct) }) }, nil } // seedScratchFull is the 507 the pool answers, and what a door answers // when it cannot make its directory in the scratch. func seedScratchFull() *doorRefusal { return &doorRefusal{http.StatusInsufficientStorage, "the console has no room for this right now. Try again later."} } // clearSeedScratch removes what a crashed restore or spool left at console // start (spec §4 "Cleanup", §5.2): the entries named seedScratchPrefix* that // sit directly inside dir. Anything else in dir stays, and dir itself is // never removed. A relative path or "/" is refused; an empty dir does // nothing. A symlink entry is removed as a link, never followed. func clearSeedScratch(dir string) error { if dir == "" { return nil } if !filepath.IsAbs(dir) || filepath.Clean(dir) == string(filepath.Separator) { return fmt.Errorf("refusing to clear %q: it must be an absolute path other than /", dir) } dir = filepath.Clean(dir) if err := os.MkdirAll(dir, 0o700); err != nil { return err } // The directory holds copies of customer databases: narrow one that // already existed with wider permissions. if err := os.Chmod(dir, 0o700); err != nil { return err } ents, err := os.ReadDir(dir) if err != nil { return err } var errs []error for _, e := range ents { if !strings.HasPrefix(e.Name(), seedScratchPrefix) { continue } if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil { errs = append(errs, err) } } return errors.Join(errs...) }