// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package cmd import ( "context" "fmt" "log/slog" "os" "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" ) // validateConfigCmd is the pre-flight check design D10 // (typed-config-keys) names: every source in that design's research puts a // test between an edit and the process that applies it (consul validate, // nginx -t, systemd-analyze verify), so a deployer runs this before an // upgrade's restart and a bad value surfaces in their terminal instead of // the service manager's restart loop. It reuses the exact boot path (the // same CheckSpecs, resolveSecretFiles, ValidateStart and ApplyOverlay // start.go calls) and adds no rule of its own; it never starts the server. // It is a subcommand of config, beside list, set and clear: the terminal // is the deployer's door and its convention is config (git config, kubectl // config, npm config), while the product's pages say settings // (docs/settings-and-configuration.md). var validateConfigCmd = &cobra.Command{ Use: "validate", Short: "Check the configuration the next boot will read, without starting the server", Long: `config validate runs the checks member-console start runs before it initializes any service, in the same order: every installed integration's own declarations, the resolved environment, flag and config-file values, and every stored override, each parsed against its key's declared type, through the same parser the settings page and the boot overlay use. It connects to the database to read the stored overrides and nothing else: it never runs migrations and never starts the server. Exits 0 with one line when everything parses. Exits 1 naming the first failure and its remediation otherwise.`, Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { var specs []config.ConfigKey for _, integ := range integrations.All() { if cp, ok := integ.(config.ConfigProvider); ok { specs = append(specs, cp.ConfigSpec()...) } } ok, message := runValidateConfig(context.Background(), specs) if !ok { fmt.Fprintln(os.Stderr, message) os.Exit(1) } fmt.Println(message) }, } // runValidateConfig performs every check validate-config runs and returns // the message to print and whether every check passed, so a test can // assert on the outcome without exercising os.Exit. specs is the caller's // installed-integration ConfigSpec aggregate (production: integrations.All() // filtered to config.ConfigProvider; tests: a fixture spec, the same // substitution ValidateStart's and CheckSpecs' own tests make). func runValidateConfig(ctx context.Context, specs []config.ConfigKey) (ok bool, message string) { if err := config.CheckSpecs(specs); err != nil { return false, fmt.Sprintf("config validate: %v", err) } if err := resolveSecretFiles(specs); err != nil { return false, fmt.Sprintf("config validate: %v", err) } if err := config.ValidateStart(specs); err != nil { return false, fmt.Sprintf("config validate: %v", err) } logger := slog.Default() if logging.AppLogger != nil { logger = logging.AppLogger } dbConfig := db.DefaultDBConfig(viper.GetString("db-dsn")) database, err := db.ConnectPlain(ctx, logger, dbConfig) if err != nil { return false, fmt.Sprintf("config validate: connecting to the database: %v", err) } defer database.Close() overrideRows, err := integration.New(database).ListConfigOverrides(ctx) if err != nil { return false, fmt.Sprintf("config validate: loading stored overrides: %v", err) } overrides := make([]config.Override, 0, len(overrideRows)) for _, row := range overrideRows { overrides = append(overrides, config.Override{Key: row.Key, Value: row.Value}) } // ApplyOverlay is the exact boot-time overlay (design D10, "adds no rule // of its own"): a secret-key override, or a stored value that fails to // parse, fails here with the same key and remediation start.go's own // call would produce. if err := config.ApplyOverlay(specs, overrides); err != nil { return false, fmt.Sprintf("config validate: %v", err) } return true, "config validate: ok" } func init() { settingsCmd.AddCommand(validateConfigCmd) }