Files
member-console/internal/integration/settings.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

200 lines
8.0 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package integration
import (
"context"
"database/sql"
"fmt"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
)
// SettingsService is the one seam onto core.integration_config_overrides
// (design D11, typed-config-keys): the operator settings page's save,
// Clear and orphan-delete handlers and `member-console config` both call
// it instead of the generated store directly, so the two surfaces cannot
// behave differently. It lives here, in internal/integration (the package
// that already owns the generated ConfigOverride store), rather than in
// internal/config or internal/server: internal/config declares the parser
// and the boot overlay and must stay free of any *sql.DB dependency (it is
// imported by cmd before a database connection exists, in CheckSpecs and
// ValidateStart), and internal/server cannot be imported by cmd/settings.go
// without pulling in the whole HTTP surface. internal/integration already
// imports internal/config for nothing today, and the reverse import
// (internal/config importing internal/integration) does not exist and must
// not start: this file's only new dependency edge is internal/integration
// -> internal/config, which both cmd and internal/server can already reach.
type SettingsService struct {
q *Queries
}
// NewSettingsService builds a SettingsService backed by db, the same way
// New builds the generated Queries it wraps.
func NewSettingsService(db DBTX) *SettingsService {
return &SettingsService{q: New(db)}
}
// SettingsRow is one key's state as List reports it: enough for both the
// operator settings page's own rendering (which this service does not
// produce, since GetIntegrationSettingsPage keeps reading
// ListConfigOverrides directly and layering config.BootEffective, the
// boot-time snapshot no separate CLI process has) and `member-console
// config list`, which has no boot-effective snapshot to read and
// instead states what this process's own environment/flag/config-file/
// default resolution gives.
type SettingsRow struct {
Key string
// Declared is false when Key matches no passed-in ConfigKey: a stored
// override left behind by an integration that has since been
// uninstalled, or renamed its key (the orphan case the operator
// Integrations page already lists; design D11 requires the CLI list it
// too, "unrecognized rows listed as such").
Declared bool
Secret bool
// Environment is the value this process's environment, flags, config
// file, or declared default gives Key, computed with
// config.EffectiveString the same way ApplyOverlay's pre-overlay pass
// does: never the boot-effective value a running server already
// layered an override over. Empty for a secret key (never echoed) and
// for an unrecognized row (nothing declares it, so nothing resolves
// it).
Environment string
// SecretSet reports, for a Secret key only, whether the environment
// currently resolves it to a non-blank value, without ever exposing
// that value (mirrors the operator page's own SettingRow.SecretSet).
SecretSet bool
// HasOverride and Override report the stored row, if any, in its raw
// (transport-encoded) form. A secret key can carry a stored row only if
// one was written before this design forbade it (Set below refuses
// one going forward); ApplyOverlay refuses to boot on it either way, so
// Override is reported here without ever being echoed unmasked to a
// terminal (callers mask it, exactly like Environment).
HasOverride bool
Override string
// Winner is the source that wins at the next boot: SourceOverride when
// HasOverride is true, otherwise SourceEnvironment or SourceDefault the
// way ApplyOverlay's own source attribution decides (design D11). A
// secret key's stored override never actually wins, since ApplyOverlay
// refuses to boot on it, so its Winner is always SourceEnvironment,
// which is also the only source a secret key's value can ever come
// from. Meaningless (left "") when Declared is false: ApplyOverlay
// skips an unrecognized row outright, so it never wins anything.
Winner config.Source
}
// List reports one SettingsRow per key in specs, in declaration order,
// followed by one row for every stored override whose key matches no spec
// (declaration order is not meaningful for those, so they are appended in
// the order ListConfigOverrides returns them: by key). specs is the
// caller's full aggregate of installed integrations' ConfigSpec; passing a
// narrower set would misreport another integration's stored override as
// unrecognized, so a caller that wants only one integration's rows (the
// CLI's `list <integration>` filter) filters List's result instead of
// narrowing specs.
func (s *SettingsService) List(ctx context.Context, specs []config.ConfigKey) ([]SettingsRow, error) {
stored, err := s.q.ListConfigOverrides(ctx)
if err != nil {
return nil, err
}
overrides := make(map[string]string, len(stored))
for _, row := range stored {
overrides[row.Key] = row.Value
}
seen := make(map[string]bool, len(specs))
rows := make([]SettingsRow, 0, len(specs))
for _, spec := range specs {
seen[spec.Name] = true
row := SettingsRow{Key: spec.Name, Declared: true, Secret: spec.Secret}
if val, has := overrides[spec.Name]; has {
row.HasOverride = true
row.Override = val
}
if spec.Secret {
row.SecretSet = config.EffectiveString(spec) != ""
row.Winner = config.SourceEnvironment
rows = append(rows, row)
continue
}
row.Environment = config.EffectiveString(spec)
switch {
case row.HasOverride:
row.Winner = config.SourceOverride
case row.Environment != config.DefaultString(spec):
row.Winner = config.SourceEnvironment
default:
row.Winner = config.SourceDefault
}
rows = append(rows, row)
}
for _, stored := range stored {
if seen[stored.Key] {
continue
}
rows = append(rows, SettingsRow{
Key: stored.Key,
Declared: false,
HasOverride: true,
Override: stored.Value,
// Winner left unset: nothing declares this key, so ApplyOverlay
// skips the row at boot rather than applying it.
})
}
return rows, nil
}
// Set writes key's override after refusing an unknown key, a secret key, or
// a value that fails to parse against the key's declaration, through the
// same config.Parse the boot overlay and the operator page's save path
// apply, so a value that saves here is a value that boots (design D2,
// D11). raw is the transport encoding (the same one the environment and
// the settings page's control use); updatedBy is stored as the acting
// operator's identity, or "" for the CLI, which has none to record
// (mirrors internal/instance.Store's own convention for an unattributed
// write).
func (s *SettingsService) Set(ctx context.Context, specs []config.ConfigKey, key, raw, updatedBy string) error {
spec, found := findSpec(specs, key)
if !found {
return fmt.Errorf("unknown key %s", key)
}
if spec.Secret {
return fmt.Errorf("%s is a secret key and is set in the environment", key)
}
if _, err := config.Parse(spec, raw); err != nil {
return err
}
by := sql.NullString{}
if updatedBy != "" {
by = sql.NullString{String: updatedBy, Valid: true}
}
return s.q.UpsertConfigOverride(ctx, UpsertConfigOverrideParams{
Key: key, Value: raw, UpdatedBy: by,
})
}
// Clear deletes key's stored override, declared or not (an unrecognized
// row is removable through this the same way a declared one is, design
// D11: "clear ... an unrecognized row included"), and reports whether a
// row actually existed to delete.
func (s *SettingsService) Clear(ctx context.Context, key string) (found bool, err error) {
rows, err := s.q.DeleteConfigOverride(ctx, key)
if err != nil {
return false, err
}
return rows > 0, nil
}
// findSpec returns the ConfigKey named key from specs, and whether one was
// found.
func findSpec(specs []config.ConfigKey, key string) (config.ConfigKey, bool) {
for _, spec := range specs {
if spec.Name == key {
return spec, true
}
}
return config.ConfigKey{}, false
}