// The scan pass (spec §7.1): the goroutine that drives objstore.RunSweep over // every store that has opted in. // // Its own ticker, deliberately. Draft 2 wanted to ride storagePass; that was // wrong — storagePass returns once the day's usage object exists // (economicspass.go:137-140), so it runs once per UTC day, and a sweep that // advances one page a day would never finish a bucket of any size. It is also // not folded into economicsPass, whose single goroutine already runs census, // statements and billing and already worries about a slow step delaying the // others. package console import ( "context" "errors" "log" "strings" "time" "github.com/carlosframework/platform/internal/objstore" "github.com/carlosframework/platform/internal/release" ) // scanPassInterval is how often the pass ticks. The re-sweep FLOOR is a // separate number (Config.ScanInterval): ticking hourly is what lets a // part-finished sweep make progress every hour, while a finished one is left // alone until it is genuinely stale. const scanPassInterval = time.Hour const ( defaultScanBudget = time.Minute defaultScanInterval = 24 * time.Hour ) // StartScanPass runs scanPass immediately and then hourly until ctx is done — // StartInfraPass's shape (infrapass.go:49). func (s *Server) StartScanPass(ctx context.Context) { if s.cfg.Releases == nil || s.cfg.StoreScanner == nil { return // no bucket, or nobody opted in: nothing to tick for } t := time.NewTicker(scanPassInterval) defer t.Stop() for { if err := s.scanPass(ctx); err != nil { log.Printf("console: scan pass: %v", err) } select { case <-ctx.Done(): return case <-t.C: } } } // antivirusEnabled reads the deployment record, seeding it once when the // retired CARLOS_STORE_SCANNER supplied this deployment's wiring. // // The seed carries an intent the operator already expressed. Refusing to seed // would not withhold consent — it would silently withdraw consent already // given, which is the failure cmd/console refuses to permit elsewhere: "a typo // that silently disabled scanning would leave an operator believing a // compliance control is on when nothing is running." // // A read error is neither "enabled" nor "disabled": it is an error. Seeding on // a failed read would enable scanning on a deployment that had disabled it. func (s *Server) antivirusEnabled(ctx context.Context) (bool, error) { av, _, err := release.GetAntivirus(ctx, s.cfg.Releases) if err != nil { return false, err } if av != nil { return av.Enabled, nil } if !s.cfg.ScanSeedFromDeprecatedEnv { // Wired with no record: nothing scans until an operator enables it. // Said once per tick rather than silently, because the difference // between "off" and "nobody told it to be on" is the whole point. log.Printf("console: a clamd address is configured but object scanning is not enabled — run `carlos system antivirus enable`") return false, nil } // Create-only: two consoles, or a restart loop, converge on one seed, and // an operator who later runs `disable` is never overridden on the next // restart. _, err = release.PutAntivirus(ctx, s.cfg.Releases, &release.AntivirusSettings{ Enabled: true, Engine: "clamav", At: time.Now().UTC(), By: "migration from CARLOS_STORE_SCANNER", }, "") if errors.Is(err, release.ErrCASConflict) { // Somebody else got there first — and NOT NECESSARILY WITH A SEED. // The race that matters is an operator running `disable` in the same // instant: the read above saw no record, the operator's write landed, // and answering "enabled" here would run a whole pass over tenant // bytes against the switch the operator had just turned off. So read // the winner rather than assuming it agrees with us. av, _, rerr := release.GetAntivirus(ctx, s.cfg.Releases) if rerr != nil { return false, rerr } if av == nil { // Created and gone again between the two calls. Neither on nor // off: say off and let the next tick decide. return false, nil } return av.Enabled, nil } if err != nil { return false, err } return true, nil } func (s *Server) scanBudget() time.Duration { if s.cfg.ScanBudget > 0 { return s.cfg.ScanBudget } return defaultScanBudget } func (s *Server) scanInterval() time.Duration { if s.cfg.ScanInterval > 0 { return s.cfg.ScanInterval } return defaultScanInterval } // scanPass gives every enabled store one budget of scanning. // // Enumeration is storagePass's (economicspass.go:150-181): ListDirs("apps/"), // per-account ListDirs, per-app objstore.Get. Hourly, that is 24x that pass's // listing cost — accepted for v1 and named here so it is a known cost rather // than a surprise. An index is the fix if it bites. // // One app's failure does not stop the others. A store whose engine is // unreachable, whose record names a different engine, or whose bucket has gone // is logged and skipped: the pass exists to make progress on what it can. func (s *Server) scanPass(ctx context.Context) error { if s.cfg.Releases == nil || s.cfg.StoreScanner == nil { return nil } // Policy is read every tick, so `carlos system antivirus enable` takes // effect without a restart. Disabled means this tick does nothing — not // that there is no goroutine, which is what would make enabling need one. enabled, err := s.antivirusEnabled(ctx) if err != nil { return err } if !enabled { return nil } acctPrefixes, err := s.cfg.Releases.ListDirs(ctx, "apps/") if err != nil { return err } budget := s.scanBudget() interval := s.scanInterval() for _, acctPrefix := range acctPrefixes { acct := strings.TrimSuffix(strings.TrimPrefix(acctPrefix, "apps/"), "/") appPrefixes, err := s.cfg.Releases.ListDirs(ctx, acctPrefix) if err != nil { return err } for _, appPrefix := range appPrefixes { if err := ctx.Err(); err != nil { return err } app := strings.TrimSuffix(strings.TrimPrefix(appPrefix, acctPrefix), "/") if err := s.scanOneApp(ctx, acct, app, budget, interval); err != nil { log.Printf("console: scan pass: %s/%s: %v", acct, app, err) } } } return nil } // scanOneApp decides whether this app is due and, if so, sweeps it. func (s *Server) scanOneApp(ctx context.Context, acct, app string, budget, interval time.Duration) error { rec, _, err := objstore.Get(ctx, s.cfg.Releases, acct, app) if err != nil { return err } // Absent is (nil, "", nil) — objstore.Get's own idiom — so this is "no // store", not a failure. if rec == nil || rec.Provisioned == nil || rec.Scanning == nil || rec.Scanning.Enabled == nil { return nil } due, err := s.storeIsDue(ctx, rec, acct, app, interval) if err != nil { return err } if !due { return nil } lister, err := s.storeLister(ctx, rec.Provisioned) if err != nil { return err } _, err = objstore.RunSweep(ctx, s.cfg.Releases, lister, s.cfg.StoreScanner, acct, app, budget) return err } // storeIsDue answers whether this store wants work now. // // A sweep already in flight is always due — that is how a bucket bigger than // one budget ever finishes. Only a COMPLETED sweep waits out the interval, so // LastComplete stays stable long enough to be quoted in an audit answer // instead of being rebuilt from scratch every hour. func (s *Server) storeIsDue(ctx context.Context, rec *objstore.Record, acct, app string, interval time.Duration) (bool, error) { side, _, err := objstore.GetSidecar(ctx, s.cfg.Releases, acct, app) if err != nil { // A sidecar we cannot read is not a reason to re-sweep blindly: it is // a schema or ownership refusal that wants an operator, and sweeping // anyway would overwrite whatever it was trying to protect. return false, err } if side == nil { return true, nil // never swept } if side.Current != nil { return true, nil // mid-sweep: keep going } if side.NotApplicable { // Client-encrypted — but the RECORD decides that, not the sidecar. // // A member can re-attest from --client-encrypted to --plaintext, which // rewrites store.json and leaves the stale not_applicable sidecar // behind. Reading only the sidecar here meant that store was never // swept again by any tick, for as long as it existed, while // `store status` cheerfully reported "scanning: clamav". Only the // on-demand sweep verb could unstick it, and nothing told anyone to // run it. return recordSaysEncrypted(rec) == false, nil } if side.LastComplete == nil { return true, nil } return time.Since(side.LastComplete.At) >= interval, nil } // storeLister routes through the test seam or builds a real S3 lister, the // way sizeStore already does for the storage sweep. func (s *Server) storeLister(ctx context.Context, p *objstore.Provisioned) (objstore.ObjectLister, error) { if s.storeListerFor != nil { return s.storeListerFor(ctx, p) } client, err := objstore.S3ClientFor(ctx, p.Region, p.Endpoint) if err != nil { return nil, err } return objstore.NewObjectLister(client), nil } // errNoScanner is what the doors return when scanning was never configured. var errNoScanner = errors.New("console: no scanning engine is configured on this deployment") // errNoStore is a sweep asked for on an app that has no provisioned store. var errNoStore = errors.New("console: this app has no object store") // sweepNow drives one sweep on demand, for the operator verb. It shares // everything with the ticker except the schedule — spec §7.1's guarantee is // "at most one driver RECORDS", not that two never run: the sidecar's CAS // guard makes the loser stand down. func (s *Server) sweepNow(ctx context.Context, acct, app string) (*objstore.ScanSidecar, error) { if s.cfg.StoreScanner == nil { return nil, errNoScanner } rec, _, err := objstore.Get(ctx, s.cfg.Releases, acct, app) if err != nil { return nil, err } if rec == nil || rec.Provisioned == nil { return nil, errNoStore } lister, err := s.storeLister(ctx, rec.Provisioned) if err != nil { return nil, err } return objstore.RunSweep(ctx, s.cfg.Releases, lister, s.cfg.StoreScanner, acct, app, s.scanBudget()) } // recordSaysEncrypted is the single reading of the member's attestation. The // record is the authority; the sidecar is a cache of a decision made from it. func recordSaysEncrypted(rec *objstore.Record) bool { return rec != nil && rec.Scanning != nil && rec.Scanning.Requested != nil && rec.Scanning.Requested.Encrypted }