package main // Billing → replication class (the policy half of the durability ladder; // registry.go has the mechanism). The cloud provisioner maps each space's // billing state to a replication class and pushes it to controld, which // records it on the registry route — the same event-driven-plus-sweep shape // as storage caps (meter.go): pushed at creation (born classed), on every // Stripe entitlement move, and reconciled by the meter sweep as the // self-healing backstop. Classes are config (LCHAT_CLOUD_REPLICATION_CLASSES); // no config = no pushes anywhere, and operator-run instances outside the // ledger are never touched. import ( "bytes" "encoding/json" "fmt" "log" "net/http" "strings" ) // parseReplicationClasses reads LCHAT_CLOUD_REPLICATION_CLASSES: // "free=daily,family=daily,community=batched,business=stream" — keys are tier // keys from the price book, plus "free" for unpaid spaces (the same key // convention as LCHAT_CLOUD_STORAGE_CAPS). func parseReplicationClasses(v string) (map[string]string, error) { if strings.TrimSpace(v) == "" { return nil, nil } classes := map[string]string{} for _, pair := range strings.Split(v, ",") { k, val, ok := strings.Cut(strings.TrimSpace(pair), "=") if !ok || strings.TrimSpace(k) == "" { return nil, fmt.Errorf("bad entry %q (want key=class)", pair) } class := strings.ToLower(strings.TrimSpace(val)) if class == "" || !validReplication(class) { return nil, fmt.Errorf("class %q: want %s, %s or %s", val, replicationStream, replicationBatched, replicationDaily) } classes[strings.TrimSpace(k)] = class } return classes, nil } // replicationFor maps a space's billing state to its replication class. // "" = nothing to push (classes unconfigured, or free with no "free" entry — // the host then keeps whatever it has, which for an unclassed route is // stream). A PAID tier missing from the map gets stream, never a downgrade: // a misspelled tier key in config must fail toward durability, not away // from what the member paid for. func (s *cloudServer) replicationFor(entitlement, tier string) string { if len(s.replicationClasses) == 0 { return "" } if entitlement == entDemo { return "" // demos never replicate — they're never activator-managed (demo.go) } if entitlement == entPaid { if class, ok := s.replicationClasses[tier]; ok { return class } return replicationStream } return s.replicationClasses["free"] } // controlSetReplication pushes one instance's replication class to controld. func (s *cloudServer) controlSetReplication(host, class string) error { payload, _ := json.Marshal(map[string]any{"class": class}) req, err := http.NewRequest("POST", "http://control/instances/"+host+"/replication", bytes.NewReader(payload)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+s.controlToken) req.Header.Set("Content-Type", "application/json") resp, err := s.control.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("control replication %s: HTTP %d", host, resp.StatusCode) } return nil } // pushReplication applies a space's tier class to its instance. Best-effort // like pushQuota: a failed push logs and stands down — the meter sweep // reconciles it on the next pass. Unlike quota there is no ledger record to // compare against; controld no-ops (and skips the router reload) when the // class already matches, so re-pushing every sweep is a cheap localhost POST. func (s *cloudServer) pushReplication(host, entitlement, tier string) { class := s.replicationFor(entitlement, tier) if class == "" { return } if err := s.controlSetReplication(host, class); err != nil { log.Printf("cloud: replication push %s: %v (sweep will retry)", host, err) return } } // pushReplicationByID looks a space's billing state up and applies its class — // the webhook-side entry point, beside pushQuotaByID. func (s *cloudServer) pushReplicationByID(id int64) { if len(s.replicationClasses) == 0 { return } m, err := s.db.spaceBillingByID(id) if err != nil || m.Host == "" { return // pending create or gone — the sweep reconciles survivors } s.pushReplication(m.Host, m.Entitlement, m.Tier) }