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.
284 lines
9.9 KiB
Go
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)
|
|
}
|