Enforce 10j's verified gaps (schema-hardening change): - Migration 00010: partial unique indexes for one default pool and one primary assignment per workspace, plus CHECKs pinning pool/provider/subscription vocabularies and provider lifecycle timestamps. - Workspace creation shares a transactional provisioning function; extension validates its target pool; last-tier deletion of a defaulted ladder is guarded; signup completes plan-less on a broken ladder. - Boot asserts integration slug parity and validates declared config enums; Stripe invoice amounts are range-checked; domain cancellation runs a final evidence probe; rule authoring is additive-only.
213 lines
7.0 KiB
Go
213 lines
7.0 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
// Override is one operator-set row from core.integration_config_overrides,
|
|
// as loaded by the composition root. Only key and value matter to the
|
|
// overlay; the audit columns stay in the store.
|
|
type Override struct {
|
|
Key string
|
|
Value string
|
|
}
|
|
|
|
// Source identifies the layer a key's boot-effective value came from.
|
|
type Source string
|
|
|
|
const (
|
|
// SourceOverride: an operator-set row in core.integration_config_overrides.
|
|
SourceOverride Source = "override"
|
|
// SourceEnvironment: any non-default boot source — environment
|
|
// variable, config file, or changed flag. (Viper cannot cheaply
|
|
// distinguish these from each other, and the settings surface does
|
|
// not need to.)
|
|
SourceEnvironment Source = "environment"
|
|
// SourceDefault: the declared ConfigKey.Default. A key explicitly set
|
|
// to exactly its default value is also reported as default — the
|
|
// label describes the effective value, not the write history.
|
|
SourceDefault Source = "default"
|
|
)
|
|
|
|
// Effective is a key's resolved value at boot, in display-string form
|
|
// (list values comma-joined), with the layer that won. Secret keys are
|
|
// never recorded.
|
|
type Effective struct {
|
|
Value string
|
|
Source Source
|
|
}
|
|
|
|
// bootEffective is the snapshot ApplyOverlay records for the operator
|
|
// settings surface: declared non-secret key → effective value + winning
|
|
// source. Written once during single-threaded boot, read-only afterwards —
|
|
// which is also why saving an override cannot move it: changes apply on
|
|
// restart, and the surface derives its "pending restart" indicator from
|
|
// exactly this staleness.
|
|
var bootEffective = map[string]Effective{}
|
|
|
|
// BootEffective returns a copy of the boot-time effective-value snapshot.
|
|
// Empty until ApplyOverlay has run.
|
|
func BootEffective() map[string]Effective {
|
|
out := make(map[string]Effective, len(bootEffective))
|
|
for k, v := range bootEffective {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ApplyOverlay layers operator-set overrides over the environment. Each
|
|
// override is validated against its key's declaration — secret keys are
|
|
// never overridable, enum keys must hold a member — coerced to the key's
|
|
// declared default type (list keys comma-split, bool/int keys parsed), and
|
|
// set into viper at its highest-precedence layer, so override →
|
|
// environment → default resolution falls out of viper's own layering for
|
|
// every existing read path.
|
|
//
|
|
// It must run exactly once, during single-threaded boot, after migrations
|
|
// and before any route or worker registration: viper's internals are not
|
|
// synchronized for concurrent Set/Get, and every integration captures its
|
|
// config at registration time. Nothing re-reads overrides at runtime;
|
|
// changes apply on restart.
|
|
//
|
|
// An override whose key matches no declaration is skipped — the operator
|
|
// surface lists such rows as unrecognized. An override that fails
|
|
// validation or coercion aborts startup with the offending key and its
|
|
// remediation: save-time validation makes that state reachable only by
|
|
// hand-written SQL, and a silently ignored (or half-applied) config is
|
|
// worse than a named refusal to boot.
|
|
func ApplyOverlay(specs []ConfigKey, overrides []Override) error {
|
|
byName := make(map[string]ConfigKey, len(specs))
|
|
for _, s := range specs {
|
|
byName[s.Name] = s
|
|
}
|
|
|
|
// Pre-overlay pass: what each key resolves to from environment/flag/
|
|
// config-file/default alone, for source attribution below.
|
|
pre := make(map[string]string, len(specs))
|
|
for _, s := range specs {
|
|
if !s.Secret {
|
|
pre[s.Name] = effectiveString(s)
|
|
}
|
|
}
|
|
|
|
applied := make(map[string]bool, len(overrides))
|
|
for _, o := range overrides {
|
|
spec, ok := byName[o.Key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if spec.Secret {
|
|
return fmt.Errorf(
|
|
"integration config override %q: key is declared secret and cannot be overridden; remove the row: DELETE FROM core.integration_config_overrides WHERE key = '%s'",
|
|
o.Key, o.Key)
|
|
}
|
|
value, err := CoerceOverride(spec, o.Value)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"integration config override %q: %w; fix it in the operator panel or remove the row: DELETE FROM core.integration_config_overrides WHERE key = '%s'",
|
|
o.Key, err, o.Key)
|
|
}
|
|
viper.Set(o.Key, value)
|
|
applied[o.Key] = true
|
|
}
|
|
|
|
for _, s := range specs {
|
|
if s.Secret {
|
|
continue
|
|
}
|
|
eff := Effective{Value: effectiveString(s)}
|
|
switch {
|
|
case applied[s.Name]:
|
|
eff.Source = SourceOverride
|
|
case pre[s.Name] != defaultString(s):
|
|
eff.Source = SourceEnvironment
|
|
default:
|
|
eff.Source = SourceDefault
|
|
}
|
|
bootEffective[s.Name] = eff
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// enumMember reports whether raw is a member of spec's declared Enum set. A
|
|
// key with no declared Enum (len(spec.Enum) == 0) imposes no constraint, so
|
|
// every value is a member — callers that also want to reject empty/unset
|
|
// values must check that separately. Shared by CoerceOverride (the operator-
|
|
// override save path) and ValidateStart's env-enum loop (validateEnums,
|
|
// validate.go), so the two membership checks cannot diverge (design D8).
|
|
func enumMember(spec ConfigKey, raw string) bool {
|
|
return len(spec.Enum) == 0 || slices.Contains(spec.Enum, raw)
|
|
}
|
|
|
|
// CoerceOverride validates a raw override value against its key's
|
|
// declaration and converts it to the key's declared default type. The raw
|
|
// form is the transport encoding — the same one the environment uses.
|
|
// Shared by the boot overlay and the operator surface's save path, so a
|
|
// value that saves is a value that boots.
|
|
func CoerceOverride(spec ConfigKey, raw string) (any, error) {
|
|
if !enumMember(spec, raw) {
|
|
return nil, fmt.Errorf("value %q is not one of %s", raw, strings.Join(spec.Enum, "|"))
|
|
}
|
|
switch spec.Default.(type) {
|
|
case []string:
|
|
return SplitList(raw), nil
|
|
case bool:
|
|
b, err := strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("value %q is not a boolean", raw)
|
|
}
|
|
return b, nil
|
|
case int:
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("value %q is not an integer", raw)
|
|
}
|
|
return n, nil
|
|
default:
|
|
return raw, nil
|
|
}
|
|
}
|
|
|
|
// SplitList parses the transport encoding for list-typed keys:
|
|
// comma-separated, whitespace-trimmed, empty elements dropped.
|
|
func SplitList(raw string) []string {
|
|
parts := strings.Split(raw, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if p = strings.TrimSpace(p); p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// effectiveString renders a key's current viper resolution in display/
|
|
// transport form (list values comma-joined).
|
|
func effectiveString(spec ConfigKey) string {
|
|
if _, ok := spec.Default.([]string); ok {
|
|
return strings.Join(viper.GetStringSlice(spec.Name), ",")
|
|
}
|
|
return viper.GetString(spec.Name)
|
|
}
|
|
|
|
// defaultString renders a key's declared default in the same form.
|
|
func defaultString(spec ConfigKey) string {
|
|
switch d := spec.Default.(type) {
|
|
case []string:
|
|
return strings.Join(d, ",")
|
|
case bool:
|
|
return strconv.FormatBool(d)
|
|
case int:
|
|
return strconv.Itoa(d)
|
|
case string:
|
|
return d
|
|
default:
|
|
return ""
|
|
}
|
|
}
|