Files
member-console/internal/config/overlay.go
T
cgalo5758 88db730fcc Add dual licensing and SPDX headers
Introduce a commercial license option alongside AGPL-3.0-only, require a
CLA for contributors, and document the terms in COMMERCIAL.md and
NOTICE. Add a script to stamp SPDX headers on Go files and apply it
across the tree.
2026-09-06 02:29:42 -05:00

284 lines
9.9 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package config
import (
"fmt"
"net/url"
"slices"
"strconv"
"strings"
"time"
"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; clear it: member-console config clear %s",
o.Key, o.Key)
}
value, err := CoerceOverride(spec, o.Value)
if err != nil {
// The CLI clear is named first because it is the remediation
// that works while the console is down; the panel only helps
// before the restart, which is what config validate is for
// (design D5, D11, typed-config-keys).
return fmt.Errorf(
"integration config override %q: %w; clear it: member-console config clear %s (or fix it in the operator panel before restarting)",
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 Parse's TypeEnum case, so the
// operator-override save path and ValidateStart's env-value loop
// (validateTypes, validate.go) apply the same membership check and cannot
// diverge (design D8).
func enumMember(spec ConfigKey, raw string) bool {
return len(spec.Enum) == 0 || slices.Contains(spec.Enum, raw)
}
// Parse turns a raw string into spec's typed value: the one parser every
// path that reads a raw value shares (design D2): the operator's save
// (through CoerceOverride), the boot overlay of stored overrides
// (ApplyOverlay, through CoerceOverride), and boot validation of
// environment/flag/config-file values (validateTypes). Its error is the
// exact sentence every path reports (design D3): the settings surface puts
// it under the control, and boot prints it naming the key.
//
// A URL must be absolute with an http or https scheme and a host; a
// duration is a Go duration string that is not negative; an int is an
// integer; a bool is true or false as strconv.ParseBool reads them; a list
// is comma-separated and trimmed (never refused; SplitList accepts
// anything); an enum value must be a declared member; a string is itself.
// An int or duration key that declares Positive additionally refuses zero
// (design D6): its consumer replaces a zero with the default silently,
// which would make the settings page state a value the running process
// ignores.
func Parse(spec ConfigKey, raw string) (any, error) {
switch spec.TypeOf() {
case TypeEnum:
if !enumMember(spec, raw) {
return nil, fmt.Errorf("value %q is not one of %s", raw, strings.Join(spec.Enum, "|"))
}
return raw, nil
case TypeURL:
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, fmt.Errorf("value %q is not an absolute http or https URL", raw)
}
return raw, nil
case TypeDuration:
d, err := time.ParseDuration(raw)
if err != nil {
return nil, fmt.Errorf("value %q is not a duration; for example 30m or 1h30m", raw)
}
if d < 0 {
return nil, fmt.Errorf("value %q is a negative duration", raw)
}
if d == 0 && spec.Positive {
return nil, fmt.Errorf("value %q must be greater than zero", raw)
}
return d, nil
case TypeInt:
n, err := strconv.Atoi(raw)
if err != nil {
return nil, fmt.Errorf("value %q is not an integer", raw)
}
if n == 0 && spec.Positive {
return nil, fmt.Errorf("value %q must be greater than zero", raw)
}
return n, nil
case TypeBool:
b, err := strconv.ParseBool(raw)
if err != nil {
return nil, fmt.Errorf("value %q is not a boolean", raw)
}
return b, nil
case TypeList:
return SplitList(raw), nil
default: // TypeString
return raw, nil
}
}
// CoerceOverride validates a raw override value against its key's
// declaration and converts it to its declared type, through Parse (design
// D2). 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) {
return Parse(spec, raw)
}
// 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)
}
// EffectiveString is effectiveString, exported for the settings service
// (internal/integration, design D11, typed-config-keys): the value this
// process's own environment/flag/config-file/default resolution gives a
// key, in the same display form ApplyOverlay's pre-overlay pass computes,
// with no override layered in. `member-console config list` calls it
// directly, in a process that never runs ApplyOverlay at all.
func EffectiveString(spec ConfigKey) string {
return effectiveString(spec)
}
// 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 ""
}
}
// DefaultString is defaultString, exported for the settings service
// (internal/integration, design D11): the same declared-default rendering
// ApplyOverlay's source attribution compares effectiveString against.
func DefaultString(spec ConfigKey) string {
return defaultString(spec)
}