Просмотр исходного кода

feat(cli): add -syntaxcheck and -startuptrace

Co-authored-by: Cursor <cursoragent@cursor.com>
jamesread 3 недель назад
Родитель
Сommit
aa57538040

+ 9 - 2
docs/modules/ROOT/pages/advanced_configuration/logs.adoc

@@ -6,6 +6,14 @@ There are two different types of logs in OliveTin - xref:advanced_configuration/
 
 OliveTin supports a few different log levels. The default logLevel is `INFO`.
 
+Before configuration is loaded, OliveTin also logs at `INFO` by default. Pass `-startuptrace` if you need `TRACE` messages while OliveTin is searching for and loading `config.yaml` (for example to see which config paths were checked). After configuration loads, `logLevel` from `config.yaml` takes over as usual.
+
+.`Example: enable startup TRACE logging`
+[source,bash]
+----
+OliveTin -startuptrace -configdir /etc/OliveTin
+----
+
 You can set a `logLevel` in config.yaml like this;
 
 .`config.yaml`
@@ -19,6 +27,7 @@ actions:
 
 The supported log levels are;
 
+* `TRACE` - Extremely detailed messages (for example config decode hooks). Usually only useful with `-startuptrace`.
 * `DEBUG` - Every possible log message will be shown. This will use a lot of disk space and is not recommended unless you are a developer / like reading code.
 * `ERROR` - OliveTin rarely uses the `ERROR` log level.
 * `WARN` - Very few messages, only warnings are shown.
@@ -43,8 +52,6 @@ You can enable JSON log format by setting the `OLIVETIN_LOG_FORMAT` environment
 ----
 root@server: ./OliveTin
 {"commit":"nocommit","date":"nodate","level":"info","msg":"OliveTin initializing","time":"2025-10-30T10:09:55Z","version":"dev"}
-{"level":"debug","msg":"Value of -configdir flag","time":"2025-10-30T10:09:55Z","value":"."}
-{"level":"debug","msg":"servicehost nonwin","time":"2025-10-30T10:09:55Z"}
 {"level":"info","msg":"Setting log level to info","time":"2025-10-30T10:09:55Z"}
 {"level":"info","msg":"OliveTin initialization complete","time":"2025-10-30T10:09:55Z"}
 {"configDir":"/home/xconspirisist/sandbox/Development/OliveTin/OliveTin","level":"info","msg":"OliveTin started","time":"2025-10-30T10:09:55Z"}

+ 13 - 1
docs/modules/ROOT/pages/config.adoc

@@ -10,6 +10,18 @@ file in the following locations;
 
 For a complete annotated example, see the https://github.com/OliveTin/OliveTin/blob/main/config.yaml[`config.yaml` in the OliveTin repository].
 
+[#syntaxcheck]
+== Check configuration without starting the server
+
+Use `-syntaxcheck` to load configuration, print the files OliveTin found and read, list any configuration warnings or errors (the same Diagnostics issue list), and exit without starting the web UI.
+
+[source,bash]
+----
+OliveTin -configdir /etc/OliveTin -syntaxcheck
+----
+
+The process exits with `0` when no configuration issues are found, and `1` when one or more issues are found. A final line always reports how many issues were found, including `0 configuration issues found`. If configuration cannot be loaded (for example no `config.yaml` is found), OliveTin still prints the searched paths and a load-failure message, then exits with a non-zero status.
+
 The most simple `config.yaml` would be something like this;
 
 .The most simple `config.yaml` file.
@@ -107,7 +119,7 @@ All configuration options are covered in the solution sections
 |===
 | Option | Description | Default | Live Reloadable | Documentation
 
-| `logLevel` | The log level to use. `INFO`, `DEBUG`, `WARN` | `INFO` | Requires Restart | -
+| `logLevel` | The log level to use. `INFO`, `DEBUG`, `WARN`, `TRACE` | `INFO` | Requires Restart | xref:advanced_configuration/logs.adoc[Logging]
 | `logDebugOptions` | Enable various debug logs. | `-` | Requires Restart | xref:troubleshooting/advanced.adoc[Advanced Troubleshooting]
 | `Insecure*` | Various options to disable security features. | `false` | Restart recommended | xref:troubleshooting/advanced.adoc[Advanced Troubleshooting]
 |===

+ 5 - 1
service/internal/config/config_reloader.go

@@ -45,6 +45,8 @@ func AppendSource(cfg *Config, k *koanf.Koanf, configPath string) {
 	}).Info("Appending cfg source")
 
 	configissues.BeginConfigLoad()
+	BeginLoadedSources()
+	RecordLoadedSource(configPath)
 	stampLoadedConfigSources(k, configPath)
 	loadIncludedConfigsFromDir(k, configPath)
 
@@ -244,6 +246,8 @@ func loadAndMergeIncludedFile(k *koanf.Koanf, includePath, filename string) {
 		return
 	}
 
+	RecordLoadedSource(filePath)
+
 	log.WithFields(log.Fields{
 		"filePath": filePath,
 	}).Info("Successfully loaded included config file")
@@ -350,7 +354,7 @@ func justificationDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (
 }
 
 func envDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error) {
-	log.Debugf("envDecodeHookFunc called: from=%v, to=%v, data=%v", from, to, data)
+	log.Tracef("envDecodeHookFunc called: from=%v, to=%v, data=%v", from, to, data)
 	if from.Kind() != reflect.String {
 		return data, nil
 	}

+ 43 - 0
service/internal/config/loaded_sources.go

@@ -0,0 +1,43 @@
+package config
+
+import "sync"
+
+var (
+	loadedSourcesMu sync.Mutex
+	loadedSources   []string
+)
+
+// BeginLoadedSources clears the list of config files recorded for the current load.
+func BeginLoadedSources() {
+	loadedSourcesMu.Lock()
+	defer loadedSourcesMu.Unlock()
+	loadedSources = nil
+}
+
+// RecordLoadedSource records a config file that was successfully read during parsing.
+func RecordLoadedSource(path string) {
+	if path == "" {
+		return
+	}
+
+	loadedSourcesMu.Lock()
+	defer loadedSourcesMu.Unlock()
+
+	for _, existing := range loadedSources {
+		if existing == path {
+			return
+		}
+	}
+
+	loadedSources = append(loadedSources, path)
+}
+
+// LoadedSources returns a copy of config files read during the latest config load.
+func LoadedSources() []string {
+	loadedSourcesMu.Lock()
+	defer loadedSourcesMu.Unlock()
+
+	out := make([]string, len(loadedSources))
+	copy(out, loadedSources)
+	return out
+}

+ 22 - 0
service/internal/config/loaded_sources_test.go

@@ -0,0 +1,22 @@
+package config
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestLoadedSourcesRecordsUniquePaths(t *testing.T) {
+	BeginLoadedSources()
+	assert.Empty(t, LoadedSources())
+
+	RecordLoadedSource("/tmp/config.yaml")
+	RecordLoadedSource("/tmp/config.d/a.yaml")
+	RecordLoadedSource("/tmp/config.yaml")
+	RecordLoadedSource("")
+
+	assert.Equal(t, []string{"/tmp/config.yaml", "/tmp/config.d/a.yaml"}, LoadedSources())
+
+	BeginLoadedSources()
+	assert.Empty(t, LoadedSources())
+}

+ 106 - 27
service/main.go

@@ -2,6 +2,7 @@ package main
 
 import (
 	"flag"
+	"fmt"
 	"path/filepath"
 	"strings"
 
@@ -33,15 +34,30 @@ import (
 
 var (
 	cfg     *config.Config
+	cli     cliOptions
 	version = "dev"
 	commit  = "nocommit"
 	date    = "nodate"
+
+	syntaxCheckSearchResults []string
+	syntaxCheckLoadError     error
 )
 
+type cliOptions struct {
+	configDir    string
+	syntaxCheck  bool
+	startupTrace bool
+	printVersion bool
+}
+
 func init() {
+	// Parse flags before initLog so -startuptrace can enable TRACE before config loads.
+	cli = parseCliFlags()
 	initLog()
+	handleEarlyCliFlags()
 
-	initConfig(initCliFlags())
+	initConfig(cli.configDir, !cli.syntaxCheck)
+	quietSyntaxCheckLogs()
 
 	initCheckEnvironment()
 
@@ -62,32 +78,64 @@ func initLog() {
 		})
 	}
 
-	// Use debug this early on to catch details about startup errors. The
-	// default config will raise the log level later, if not set.
-	log.SetLevel(log.DebugLevel) // Default to debug, to catch cfg issue
-}
+	if cli.startupTrace {
+		log.SetLevel(log.TraceLevel)
+		return
+	}
 
-func initCliFlags() string {
-	var configDir string
-	flag.StringVar(&configDir, "configdir", ".", "Config directory path")
+	log.SetLevel(log.InfoLevel)
+}
 
-	var printVersion bool
-	flag.BoolVar(&printVersion, "version", false, "Prints the version number and exits")
+func parseCliFlags() cliOptions {
+	options := cliOptions{}
+	flag.StringVar(&options.configDir, "configdir", ".", "Config directory path")
+	flag.BoolVar(&options.printVersion, "version", false, "Prints the version number and exits")
+	flag.BoolVar(&options.syntaxCheck, "syntaxcheck", false, "Check configuration for issues and exit")
+	flag.BoolVar(&options.startupTrace, "startuptrace", false, "Enable TRACE logging before configuration is loaded")
 	flag.Parse()
+	return options
+}
 
+func handleEarlyCliFlags() {
 	// This log message should be the first log message OliveTin prints.
-	if printVersion {
+	if cli.printVersion {
 		logStartupMessage("OliveTin is just printing the startup message")
 		os.Exit(1)
-	} else {
-		logStartupMessage("OliveTin initializing")
 	}
 
+	if cli.syntaxCheck {
+		// Suppress routine logs before any further startup output. Re-applied
+		// after config load because sanitize may raise the level again.
+		log.SetLevel(log.FatalLevel)
+		return
+	}
+
+	logStartupMessage("OliveTin initializing")
+
 	log.WithFields(log.Fields{
-		"value": configDir,
+		"value": cli.configDir,
 	}).Debugf("Value of -configdir flag")
+}
+
+// quietSyntaxCheckLogs keeps -syntaxcheck output limited to the report on
+// stdout. Load failures are returned to the syntax-check report instead of
+// exiting immediately via Fatalf.
+func quietSyntaxCheckLogs() {
+	if cli.syntaxCheck {
+		log.SetLevel(log.FatalLevel)
+	}
+}
+
+func failConfigLoad(format string, args ...any) {
+	err := fmt.Errorf(format, args...)
+	if cli.syntaxCheck {
+		if syntaxCheckLoadError == nil {
+			syntaxCheckLoadError = err
+		}
+		return
+	}
 
-	return configDir
+	log.Fatal(err)
 }
 
 func getBasePort() int {
@@ -178,7 +226,7 @@ func watchConfigFile(k *koanf.Koanf, f *file.File, configPath string) {
 	}
 }
 
-func loadAndWatchConfig(k *koanf.Koanf, configPath string) {
+func loadConfigFromPath(k *koanf.Koanf, configPath string, watch bool) bool {
 	log.WithFields(log.Fields{
 		"configPath": configPath,
 	}).Info("Loading config from path")
@@ -186,40 +234,66 @@ func loadAndWatchConfig(k *koanf.Koanf, configPath string) {
 	f := file.Provider(configPath)
 
 	if err := k.Load(f, yaml.Parser()); err != nil {
-		log.Fatalf("error loading config from %s: %v", configPath, err)
+		failConfigLoad("error loading config from %s: %v", configPath, err)
+		return false
+	}
+
+	if watch {
+		watchConfigFile(k, f, configPath)
 	}
 
-	watchConfigFile(k, f, configPath)
+	return true
 }
 
-func findAndLoadBaseConfig(k *koanf.Koanf, directories []string) string {
+func findAndLoadBaseConfig(k *koanf.Koanf, directories []string, watch bool) string {
 	for _, directory := range directories {
 		configPath := getConfigPath(directory)
-		if !configPathExists(configPath) {
+		found := configPathExists(configPath)
+		printConfigSearchResult(configPath, found)
+
+		if !found {
 			continue
 		}
 
-		loadAndWatchConfig(k, configPath)
+		if !loadConfigFromPath(k, configPath, watch) {
+			return ""
+		}
+
 		return configPath
 	}
 
 	return ""
 }
 
-func initConfig(configDir string) {
+func printConfigSearchResult(configPath string, found bool) {
+	if !cli.syntaxCheck {
+		return
+	}
+
+	if found {
+		syntaxCheckSearchResults = append(syntaxCheckSearchResults, "Found config file: "+configPath)
+		return
+	}
+
+	syntaxCheckSearchResults = append(syntaxCheckSearchResults, "Config file not found: "+configPath)
+}
+
+func initConfig(configDir string, watch bool) {
 	k := koanf.New(".")
 	err := k.Load(env.Provider(".", ".", nil), nil)
 	if err != nil {
-		log.WithFields(log.Fields{
-			"error": err,
-		}).Fatalf("Error loading environment variables")
+		failConfigLoad("Error loading environment variables: %v", err)
+		return
 	}
 
-	baseConfigPath := findAndLoadBaseConfig(k, configSearchDirectories(configDir))
+	baseConfigPath := findAndLoadBaseConfig(k, configSearchDirectories(configDir), watch)
 	cfg = config.DefaultConfigWithBasePort(getBasePort())
 
 	if baseConfigPath == "" {
-		log.Fatalf("No base config file found")
+		if syntaxCheckLoadError == nil {
+			failConfigLoad("No base config file found")
+		}
+		return
 	}
 
 	config.AppendSource(cfg, k, baseConfigPath)
@@ -251,6 +325,11 @@ func warnIfPuidGuid() {
 }
 
 func main() {
+	if cli.syntaxCheck {
+		runSyntaxCheck()
+		return
+	}
+
 	servicehost.Start(cfg.ServiceHostMode, cfg.ServiceLogs.Directory)
 
 	log.WithFields(log.Fields{

+ 80 - 0
service/syntaxcheck.go

@@ -0,0 +1,80 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/configissues"
+	"github.com/OliveTin/OliveTin/internal/executor"
+)
+
+func runSyntaxCheck() {
+	printSyntaxCheckSearchResults()
+	printLoadedConfigFiles()
+
+	if syntaxCheckLoadError != nil {
+		fmt.Printf("Configuration load failed: %v\n", syntaxCheckLoadError)
+		fmt.Println("0 configuration issues found")
+		os.Exit(1)
+	}
+
+	executor.DefaultExecutor(cfg).RebuildActionMap()
+	printConfigIssues(configissues.List())
+
+	issueCount := configissues.Count()
+	fmt.Printf("%d configuration issues found\n", issueCount)
+
+	if issueCount > 0 {
+		os.Exit(1)
+	}
+
+	os.Exit(0)
+}
+
+func printSyntaxCheckSearchResults() {
+	for _, line := range syntaxCheckSearchResults {
+		fmt.Println(line)
+	}
+}
+
+func printLoadedConfigFiles() {
+	files := config.LoadedSources()
+	if len(files) == 0 {
+		fmt.Println("No config files were read")
+		return
+	}
+
+	fmt.Println("Config files read:")
+	for _, path := range files {
+		fmt.Printf("  %s\n", path)
+	}
+}
+
+func printConfigIssues(issues []configissues.Issue) {
+	if len(issues) == 0 {
+		return
+	}
+
+	fmt.Println("Configuration issues:")
+	for _, issue := range issues {
+		fmt.Printf("  %s\n", formatConfigIssue(issue))
+	}
+}
+
+func formatConfigIssue(issue configissues.Issue) string {
+	parts := []string{issue.Severity, issue.Code}
+
+	if issue.ConfigFile != "" {
+		parts = append(parts, "file="+issue.ConfigFile)
+	}
+	if issue.ActionTitle != "" {
+		parts = append(parts, "action="+issue.ActionTitle)
+	}
+	if issue.ArgumentName != "" {
+		parts = append(parts, "argument="+issue.ArgumentName)
+	}
+
+	return strings.Join(parts, " ") + ": " + issue.Message
+}

+ 8 - 0
specs/config-issues.md

@@ -48,3 +48,11 @@ When Diagnostics is visible and at least one configuration issue exists that the
 ## Startup count
 
 When the web UI starts, users who may view Diagnostics receive the same filtered configuration issue count used for the Diagnostics list and navigation badge. For other users the count is zero.
+
+## Command-line syntax check
+
+Operators can validate configuration without starting the web server by running OliveTin with the `-syntaxcheck` flag (optionally with `-configdir`). OliveTin searches for and reads configuration files as usual, prints each candidate path that was found or not found, prints the list of configuration files that were successfully read (including include-directory files), prints each configuration issue, and always prints a final count of issues found (including zero). Routine server logs are suppressed so the report stays readable.
+
+If configuration cannot be loaded (for example no base config file is found, or a found file cannot be parsed), OliveTin still prints the search results and loaded-file list, prints a configuration load failure message, prints `0 configuration issues found`, and exits with a non-zero status.
+
+The process exits with status `0` when configuration loaded successfully and no configuration issues were found, and status `1` when one or more issues were found or configuration failed to load.