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.
200 lines
8.0 KiB
Go
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
|
|
}
|