release: system/<name>.json, a typed deployment settings record
Deployment-wide policy the platform reads and acts on, as against features.json, which is per-app and holds switches the platform deliberately never interprets. Different thing, different object. A typed record per feature rather than one generic settings map. 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. The schema is pinned and an unknown one refuses, the rule objstore.Record and ScanSidecar already follow. It matters more here: this record decides whether the deployment reads tenant object bytes, so a shape we cannot read is not one to guess at. Two things the tests pin because getting them wrong fails quietly. Absent means NOT ENABLED — a deployment that never ran enable does not scan — which is why the upgrade needs a real seeding step rather than a default. And AntivirusEnabled does not turn an error into "disabled": a caller acting on false would silently stop scanning on a transient bucket failure or a schema it could not read. Create-only conflicts against an existing key, which is what will make the boot seed idempotent across two consoles and a restart loop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 files changed,
+195
−0
internal/release/system.go+95 −0internal/release/system_test.go+100 −0
diff --git a/internal/release/system.go b/internal/release/system.go| new file mode 100644 |
| index 000000000..8491a3a2a |
| --- /dev/null |
| +++ b/internal/release/system.go |
| @@ -0,0 +1,95 @@ |
| +// Deployment-wide settings records: system/<name>.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 |
| +} |
diff --git a/internal/release/system_test.go b/internal/release/system_test.go| new file mode 100644 |
| index 000000000..9d29e1a48 |
| --- /dev/null |
| +++ b/internal/release/system_test.go |
| @@ -0,0 +1,100 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestSystemKeyShape(t *testing.T) { |
| + if got := SystemKey("antivirus"); got != "system/antivirus.json" { |
| + t.Fatalf("SystemKey = %q", got) |
| + } |
| +} |
| + |
| +// Absent is a state, not an error — and it means NOT ENABLED. A deployment |
| +// that has never enabled scanning does not scan. |
| +func TestGetAntivirusAbsentIsNotEnabled(t *testing.T) { |
| + ctx := context.Background() |
| + s := NewDirStore(t.TempDir()) |
| + |
| + av, etag, err := GetAntivirus(ctx, s) |
| + if err != nil || av != nil || etag != "" { |
| + t.Fatalf("absent record = %+v, %q, %v", av, etag, err) |
| + } |
| + on, err := AntivirusEnabled(ctx, s) |
| + if err != nil || on { |
| + t.Fatalf("AntivirusEnabled with no record = %v, %v", on, err) |
| + } |
| +} |
| + |
| +// An unknown schema refuses rather than guessing. A future schema 2 could mean |
| +// anything, and the thing this record drives is whether the deployment reads |
| +// tenant object bytes. |
| +func TestAntivirusSchemaRefusesUnknown(t *testing.T) { |
| + ctx := context.Background() |
| + s := NewDirStore(t.TempDir()) |
| + |
| + body, _ := json.Marshal(map[string]any{"schema": 2, "enabled": true}) |
| + if _, err := s.Put(ctx, SystemKey(AntivirusName), body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if _, _, err := GetAntivirus(ctx, s); err == nil { |
| + t.Fatal("an unknown schema was read as if it were understood") |
| + } |
| + // And the folded helper must not turn that refusal into "disabled" — a |
| + // caller acting on false would silently stop scanning. |
| + if _, err := AntivirusEnabled(ctx, s); err == nil { |
| + t.Fatal("AntivirusEnabled swallowed a schema refusal as disabled") |
| + } |
| +} |
| + |
| +// A read error is not "disabled" either, for the same reason. |
| +func TestAntivirusEnabledDoesNotSwallowErrors(t *testing.T) { |
| + ctx := context.Background() |
| + s := NewDirStore(t.TempDir()) |
| + |
| + body, _ := json.Marshal(map[string]any{"schema": 1, "enabled": true}) |
| + if _, err := s.Put(ctx, SystemKey(AntivirusName), []byte(string(body)[:len(body)-3]), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := AntivirusEnabled(ctx, s); err == nil { |
| + t.Fatal("malformed JSON was read as disabled rather than as an error") |
| + } |
| +} |
| + |
| +func TestPutAntivirusCreateOnlyThenReplace(t *testing.T) { |
| + ctx := context.Background() |
| + s := NewDirStore(t.TempDir()) |
| + now := time.Date(2026, 9, 2, 8, 0, 0, 0, time.UTC) |
| + |
| + etag, err := PutAntivirus(ctx, s, &AntivirusSettings{ |
| + Enabled: true, Engine: "clamav", At: now, By: "op@example.com", |
| + }, "") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Create-only against an existing key conflicts — this is what makes the |
| + // boot seed idempotent across two consoles and a restart loop. |
| + if _, err := PutAntivirus(ctx, s, &AntivirusSettings{Enabled: false}, ""); !errors.Is(err, ErrCASConflict) { |
| + t.Fatalf("second create-only write = %v, want ErrCASConflict", err) |
| + } |
| + |
| + if _, err := PutAntivirus(ctx, s, &AntivirusSettings{ |
| + Enabled: false, Engine: "clamav", At: now, By: "op@example.com", |
| + }, etag); err != nil { |
| + t.Fatalf("replace with the current etag: %v", err) |
| + } |
| + |
| + av, _, err := GetAntivirus(ctx, s) |
| + if err != nil || av == nil || av.Enabled { |
| + t.Fatalf("after disable: %+v, %v", av, err) |
| + } |
| + if av.Schema != AntivirusSchema { |
| + t.Fatalf("schema not stamped on write: %+v", av) |
| + } |
| +} |