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.
235 lines
7.2 KiB
Go
235 lines
7.2 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"strings"
|
|
"text/tabwriter"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/logging"
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
// settingsCmd is the console's only supported way to manage stored config
|
|
// override rows from the command line (design D11, typed-config-keys): the
|
|
// same three verbs the operator settings page offers, and no more. Its
|
|
// subcommands call internal/integration.SettingsService, the exact seam
|
|
// the page's save, Clear and orphan-delete handlers call, so the CLI and
|
|
// the page cannot behave differently.
|
|
//
|
|
// It is a separate process from the running server, so it never claims to
|
|
// know what that server is doing: it reports this process's own
|
|
// environment/flag/config-file resolution and the stored override, and
|
|
// states once that a change takes effect on the app's next restart. It
|
|
// never states what the running app currently has in force or whether a
|
|
// restart is pending: the operator settings page owns that, from its own
|
|
// boot-effective snapshot.
|
|
var settingsCmd = &cobra.Command{
|
|
Use: "config",
|
|
Short: "List, set, clear and check the deployment's configuration",
|
|
}
|
|
|
|
var settingsListCmd = &cobra.Command{
|
|
Use: "list [integration]",
|
|
Short: "List every declared key's environment value, stored override, and next-boot winner",
|
|
Args: cobra.MaximumNArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
svc, database, specs := connectSettings()
|
|
defer database.Close()
|
|
|
|
rows, err := svc.List(context.Background(), specs)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "config: "+err.Error())
|
|
os.Exit(1)
|
|
}
|
|
if len(args) == 1 {
|
|
names, ok := integrationKeyNames(args[0])
|
|
if !ok {
|
|
fmt.Fprintf(os.Stderr, "unknown integration %s\n", args[0])
|
|
os.Exit(1)
|
|
}
|
|
rows = filterSettingsRows(rows, names)
|
|
}
|
|
fmt.Print(renderSettingsList(rows))
|
|
},
|
|
}
|
|
|
|
var settingsSetCmd = &cobra.Command{
|
|
Use: "set <key> <value>",
|
|
Short: "Store a value as an override, parsed against its key's declared type",
|
|
Args: cobra.ExactArgs(2),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
svc, database, specs := connectSettings()
|
|
defer database.Close()
|
|
|
|
key, raw := args[0], args[1]
|
|
if err := svc.Set(context.Background(), specs, key, raw, ""); err != nil {
|
|
fmt.Fprintln(os.Stderr, err.Error())
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("%s set. Applies on restart.\n", key)
|
|
},
|
|
}
|
|
|
|
var settingsClearCmd = &cobra.Command{
|
|
Use: "clear <key>",
|
|
Short: "Delete a stored override",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
svc, database, _ := connectSettings()
|
|
defer database.Close()
|
|
|
|
key := args[0]
|
|
found, err := svc.Clear(context.Background(), key)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "config: "+err.Error())
|
|
os.Exit(1)
|
|
}
|
|
if !found {
|
|
fmt.Fprintf(os.Stderr, "no override stored for %s\n", key)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("%s cleared. Applies on restart.\n", key)
|
|
},
|
|
}
|
|
|
|
// connectSettings loads every installed integration's ConfigSpec and opens
|
|
// a plain database connection, the same way config validate does (design
|
|
// D10): no migrations, no server, just enough to read and write
|
|
// core.integration_config_overrides through the settings service.
|
|
func connectSettings() (*integration.SettingsService, *sql.DB, []config.ConfigKey) {
|
|
var specs []config.ConfigKey
|
|
for _, integ := range integrations.All() {
|
|
if cp, ok := integ.(config.ConfigProvider); ok {
|
|
specs = append(specs, cp.ConfigSpec()...)
|
|
}
|
|
}
|
|
|
|
logger := slog.Default()
|
|
if logging.AppLogger != nil {
|
|
logger = logging.AppLogger
|
|
}
|
|
dbConfig := db.DefaultDBConfig(viper.GetString("db-dsn"))
|
|
database, err := db.ConnectPlain(context.Background(), logger, dbConfig)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "config: connecting to the database: "+err.Error())
|
|
os.Exit(1)
|
|
}
|
|
return integration.NewSettingsService(database), database, specs
|
|
}
|
|
|
|
// integrationKeyNames returns the ConfigSpec names declared by the
|
|
// installed integration whose Key() equals integrationKey, and whether
|
|
// that integration is installed at all.
|
|
func integrationKeyNames(integrationKey string) (map[string]bool, bool) {
|
|
for _, integ := range integrations.All() {
|
|
if integ.Key() != integrationKey {
|
|
continue
|
|
}
|
|
cp, ok := integ.(config.ConfigProvider)
|
|
if !ok {
|
|
return map[string]bool{}, true
|
|
}
|
|
names := make(map[string]bool, len(cp.ConfigSpec()))
|
|
for _, spec := range cp.ConfigSpec() {
|
|
names[spec.Name] = true
|
|
}
|
|
return names, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// filterSettingsRows keeps only the rows whose key is in names, the
|
|
// `list <integration>` filter. An unrecognized row belongs to no
|
|
// integration by definition, so it never survives a filtered listing; the
|
|
// bare `list` is the only form that shows one.
|
|
func filterSettingsRows(rows []integration.SettingsRow, names map[string]bool) []integration.SettingsRow {
|
|
out := make([]integration.SettingsRow, 0, len(rows))
|
|
for _, row := range rows {
|
|
if names[row.Key] {
|
|
out = append(out, row)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// renderSettingsList formats rows as a plain aligned table: the key, the
|
|
// value this process's environment or default gives it, the stored
|
|
// override (blank when none), and which one wins at the next boot. A
|
|
// secret key's value and stored override are masked to "set"/"not set"
|
|
// rather than ever printed; an unrecognized row (design D11, "unrecognized
|
|
// rows listed as such") carries no environment value and names itself in
|
|
// the winner column instead of one. The closing line states the one fact
|
|
// this process can state about a running app it is not: that it applies
|
|
// whatever is stored here on its next restart.
|
|
func renderSettingsList(rows []integration.SettingsRow) string {
|
|
var buf strings.Builder
|
|
tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0)
|
|
fmt.Fprintln(tw, "KEY\tENVIRONMENT/DEFAULT\tOVERRIDE\tNEXT BOOT")
|
|
for _, row := range rows {
|
|
env := settingsEnvironmentCell(row)
|
|
override := settingsOverrideCell(row)
|
|
winner := settingsWinnerCell(row)
|
|
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", row.Key, env, override, winner)
|
|
}
|
|
tw.Flush()
|
|
buf.WriteString("The running app applies changes on restart.\n")
|
|
return buf.String()
|
|
}
|
|
|
|
func settingsEnvironmentCell(row integration.SettingsRow) string {
|
|
if !row.Declared {
|
|
return ""
|
|
}
|
|
if row.Secret {
|
|
if row.SecretSet {
|
|
return "set"
|
|
}
|
|
return "not set"
|
|
}
|
|
if row.Environment == "" {
|
|
return "not set"
|
|
}
|
|
return row.Environment
|
|
}
|
|
|
|
func settingsOverrideCell(row integration.SettingsRow) string {
|
|
if !row.HasOverride {
|
|
return ""
|
|
}
|
|
if row.Secret {
|
|
return "set"
|
|
}
|
|
return row.Override
|
|
}
|
|
|
|
func settingsWinnerCell(row integration.SettingsRow) string {
|
|
if !row.Declared {
|
|
return "(unrecognized)"
|
|
}
|
|
switch row.Winner {
|
|
case config.SourceOverride:
|
|
return "Override"
|
|
case config.SourceEnvironment:
|
|
return "Environment"
|
|
default:
|
|
return "Default"
|
|
}
|
|
}
|
|
|
|
func init() {
|
|
settingsCmd.AddCommand(settingsListCmd, settingsSetCmd, settingsClearCmd)
|
|
rootCmd.AddCommand(settingsCmd)
|
|
}
|