// Deployment-wide settings records: system/.json. // // These are NOT features.json. That one is per-app and holds switches the // platform "stores and serves but never interprets" (apifeatures.go) — an // app's own vocabulary, passed through. These the platform reads and acts on, // so each gets a typed shape and a pinned schema. // // A typed record per feature rather than one generic settings map, for the // same reason: a map would be features.json by another name, uninterpreted and // impossible to schema-pin meaningfully. system/pdf.json later is a new type // and a new door, not a new magic string. package release import ( "context" "encoding/json" "errors" "fmt" "os" "time" ) // AntivirusSchema is pinned. An unknown schema refuses rather than guessing — // the rule objstore.Record and ScanSidecar already follow, and it matters more // here: this record decides whether the deployment reads tenant object bytes. const AntivirusSchema = 1 // SystemKey is where a deployment-wide settings record lives. func SystemKey(name string) string { return "system/" + name + ".json" } // AntivirusKey is the object this package's typed helpers read and write. const AntivirusName = "antivirus" // AntivirusSettings is whether object scanning is on for this deployment. // // Policy, not wiring. Where clamd lives stays in the unit file, because those // are coordinates a console must not guess; whether scanning is ON lives here, // so turning a compliance control on does not mean editing a systemd unit and // restarting the control plane. type AntivirusSettings struct { Schema int `json:"schema"` Enabled bool `json:"enabled"` Engine string `json:"engine"` At time.Time `json:"at"` By string `json:"by"` } // GetAntivirus reads the record. Absent is (nil, "", nil) — no record is a // state, not an error, the same idiom objstore.Get uses. // // Absent means NOT ENABLED. A deployment that has never run `carlos system // antivirus enable` does not scan, which is what makes the migration in // cmd/console worth its own seeding step rather than a default. func GetAntivirus(ctx context.Context, s Store) (*AntivirusSettings, string, error) { raw, etag, err := s.Get(ctx, SystemKey(AntivirusName)) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, "", nil } return nil, "", err } var av AntivirusSettings if err := json.Unmarshal(raw, &av); err != nil { return nil, "", fmt.Errorf("release: %s: %w", SystemKey(AntivirusName), err) } if av.Schema != AntivirusSchema { return nil, "", fmt.Errorf("release: %s: schema %d is not %d", SystemKey(AntivirusName), av.Schema, AntivirusSchema) } return &av, etag, nil } // PutAntivirus writes with the caller's CAS position; etag "" is create-only. // It stamps the schema so a caller cannot write a body it would then refuse to // read back. func PutAntivirus(ctx context.Context, s Store, av *AntivirusSettings, etag string) (string, error) { av.Schema = AntivirusSchema body, err := json.MarshalIndent(av, "", " ") if err != nil { return "", err } return s.Put(ctx, SystemKey(AntivirusName), append(body, '\n'), etag) } // AntivirusEnabled folds the read into the one question every caller actually // asks. A read error is NOT "disabled": it is an error, and a caller that // treated it as disabled would silently stop scanning on a transient bucket // failure. func AntivirusEnabled(ctx context.Context, s Store) (bool, error) { av, _, err := GetAntivirus(ctx, s) if err != nil { return false, err } return av != nil && av.Enabled, nil }