From 21716e0a7e217489e9e5b1b5af49250b070eda8a Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 16 Jun 2026 00:33:49 +0100 Subject: [PATCH] feat: log file save path on windows #932 --- docs/modules/ROOT/pages/config.adoc | 1 + docs/modules/ROOT/pages/install/windows.adoc | 2 + .../ROOT/pages/install/windows_service.adoc | 4 +- .../pages/troubleshooting/service-logs.adoc | 21 +++++++++ .../install/windows_service_logs.adoc | 27 ++++++++++++ service/internal/config/config.go | 5 +++ service/internal/config/sanitize.go | 12 +++++ service/internal/config/sanitize_test.go | 22 ++++++++++ service/internal/servicehost/log_directory.go | 44 +++++++++++++++++++ .../servicehost/log_directory_test.go | 20 +++++++++ .../servicehost/servicehost_nonwin.go | 2 +- .../servicehost/servicehost_windows.go | 28 ++++++++---- service/main.go | 2 +- 13 files changed, 179 insertions(+), 11 deletions(-) create mode 100644 docs/modules/ROOT/partials/install/windows_service_logs.adoc create mode 100644 service/internal/servicehost/log_directory.go create mode 100644 service/internal/servicehost/log_directory_test.go diff --git a/docs/modules/ROOT/pages/config.adoc b/docs/modules/ROOT/pages/config.adoc index a0339a3..b48b4d4 100644 --- a/docs/modules/ROOT/pages/config.adoc +++ b/docs/modules/ROOT/pages/config.adoc @@ -117,6 +117,7 @@ All configuration options are covered in the solution sections | `WebUIDir` | The directory to serve the web UI from. | Calculated at runtime. | Requires Restart | - | `CronSupportForSeconds` | Whether or not to support seconds in cron expressions. | `false` | Requires Restart | xref:action_execution/oncron.adoc[Cron] | `SaveLogs` | Whether or not to save logs to disk. | `[]` | Requires Restart | xref:action_customization/savelogs.adoc[Save Logs] +| `ServiceLogs` | Windows process log directory (`serviceLogs.directory`). | `%ProgramData%\OliveTin\logs\` on Windows | Requires Restart | xref:install/windows_service.adoc#windows-service-logs[Windows service logs] | `Prometheus` | Prometheus configuration. | `-` | Requires Restart | xref:advanced_configuration/prometheus.adoc[Prometheus] |=== diff --git a/docs/modules/ROOT/pages/install/windows.adoc b/docs/modules/ROOT/pages/install/windows.adoc index 3b81c13..7b2eb31 100644 --- a/docs/modules/ROOT/pages/install/windows.adoc +++ b/docs/modules/ROOT/pages/install/windows.adoc @@ -9,4 +9,6 @@ You can instal install OliveTin as a Windows service, follow the instructions to 1. You can download the latest version of OliveTin here: link:https://github.com/OliveTin/OliveTin/releases/latest/download/OliveTin-windows-amd64.zip[`OliveTin-windows-amd64.zip`] 2. Unzip and run "OliveTin.exe" +include::partial$install/windows_service_logs.adoc[] + include::partial$install/post_generic.adoc[] diff --git a/docs/modules/ROOT/pages/install/windows_service.adoc b/docs/modules/ROOT/pages/install/windows_service.adoc index d4cf4cc..3365bdd 100644 --- a/docs/modules/ROOT/pages/install/windows_service.adoc +++ b/docs/modules/ROOT/pages/install/windows_service.adoc @@ -16,6 +16,8 @@ You can download the latest version of OliveTin here: link:https://github.com/Ol * Create c:/ProgramData/OliveTin/ ** Copy the **config.yaml** file into this directory. +include::partial$install/windows_service_logs.adoc[] + == Test OliveTin startup Open a command prompt and make suer you are in the c:/Program Files/OliveTin/ directory, then run: @@ -37,7 +39,7 @@ serviceHostMode: "winsvc-standard" logLevel: info -actions: +actions: ... ---- diff --git a/docs/modules/ROOT/pages/troubleshooting/service-logs.adoc b/docs/modules/ROOT/pages/troubleshooting/service-logs.adoc index 4c45d82..4a66ebc 100644 --- a/docs/modules/ROOT/pages/troubleshooting/service-logs.adoc +++ b/docs/modules/ROOT/pages/troubleshooting/service-logs.adoc @@ -16,6 +16,27 @@ How you read logs depends on how OliveTin is installed: * **Container (Docker / Podman):** logs are what the container runtime captured from OliveTin’s standard output. * **systemd service:** logs are stored by the system journal for the `OliveTin` unit. * **Manual / binary** (for example `./OliveTin` in a terminal): messages print to that terminal—copy them from there, or redirect output to a file when testing. +* **Windows (binary or service):** OliveTin writes process logs to a file under `%ProgramData%\OliveTin\logs\` by default. You can override this with xref:install/windows_service.adoc#windows-service-logs[`serviceLogs.directory`] in `config.yaml`. + +[#windows] +== Windows (binary or service) + +By default, OliveTin on Windows writes process logs to: + +[source] +---- +%ProgramData%\OliveTin\logs\OliveTin-service-.log +---- + +To use a custom directory (for portable installs), set in `config.yaml`: + +[source,yaml] +---- +serviceLogs: + directory: C:\Path\To\Logs\ +---- + +See xref:install/windows_service.adoc#windows-service-logs[Windows service logs directory] for details. This setting is ignored on non-Windows platforms. [#manual-binary] == Manual or binary run diff --git a/docs/modules/ROOT/partials/install/windows_service_logs.adoc b/docs/modules/ROOT/partials/install/windows_service_logs.adoc new file mode 100644 index 0000000..57b3e52 --- /dev/null +++ b/docs/modules/ROOT/partials/install/windows_service_logs.adoc @@ -0,0 +1,27 @@ +[#windows-service-logs] +== Process log directory + +On Windows, OliveTin writes its **process logs** (startup messages, configuration load, errors, and internal diagnostics) to a log file on disk. By default these files are stored under `%ProgramData%\OliveTin\logs\` as `OliveTin-service-.log`. + +This is separate from xref:action_customization/savelogs.adoc[action execution logs] (`saveLogs`), which persist command output from individual actions. + +Portable or self-contained installs often keep OliveTin, its configuration, and its logs together in one folder. Without a custom path, process logs always go to `%ProgramData%`, even when you run OliveTin from another location. + +Add a `serviceLogs` block to your `config.yaml`: + +[source,yaml] +---- +serviceLogs: + directory: ./logs/service/ +---- + +OliveTin creates the directory if it does not exist and writes a new timestamped log file there on each startup. + +If `serviceLogs.directory` is omitted, OliveTin uses the default location: `%ProgramData%\OliveTin\logs\`. + +Relative paths (for example `./logs/service/`) are resolved from the directory containing `OliveTin.exe`. This keeps portable installs self-contained when you colocate logs with the application. + +[NOTE] +==== +`serviceLogs.directory` is **Windows only**. If you set it on Linux, macOS, or in a container, OliveTin logs an error at startup and ignores the setting. On those platforms, use xref:troubleshooting/service-logs.adoc[service logs troubleshooting] for how to read process output (for example `journalctl` or container logs). +==== diff --git a/service/internal/config/config.go b/service/internal/config/config.go index b118afa..5d1fdc7 100644 --- a/service/internal/config/config.go +++ b/service/internal/config/config.go @@ -176,6 +176,7 @@ type Config struct { Prometheus PrometheusConfig `koanf:"prometheus"` Security SecurityConfig `koanf:"security"` SaveLogs SaveLogsConfig `koanf:"saveLogs"` + ServiceLogs ServiceLogsConfig `koanf:"serviceLogs"` DefaultIconForActions string `koanf:"defaultIconForActions"` DefaultIconForDirectories string `koanf:"defaultIconForDirectories"` DefaultIconForBack string `koanf:"defaultIconForBack"` @@ -230,6 +231,10 @@ type SaveLogsConfig struct { OutputDirectory string `koanf:"outputDirectory"` } +type ServiceLogsConfig struct { + Directory string `koanf:"directory"` +} + type LogDebugOptions struct { SingleFrontendRequests bool `koanf:"singleFrontendRequests"` SingleFrontendRequestHeaders bool `koanf:"singleFrontendRequestHeaders"` diff --git a/service/internal/config/sanitize.go b/service/internal/config/sanitize.go index d22cc90..d09140f 100644 --- a/service/internal/config/sanitize.go +++ b/service/internal/config/sanitize.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "runtime" "strings" "text/template" @@ -18,6 +19,7 @@ func (cfg *Config) Sanitize() { cfg.sanitizeLogHistoryPageSize() cfg.sanitizeLocalUsers() cfg.sanitizeSecurityHeaders() + cfg.sanitizeServiceLogs() // log.Infof("cfg %p", cfg) @@ -168,6 +170,16 @@ func (cfg *Config) sanitizeLogLevel() { } } +func (cfg *Config) sanitizeServiceLogs() { + if cfg.ServiceLogs.Directory == "" { + return + } + + if runtime.GOOS != "windows" { + log.Errorf("serviceLogs.directory is configured but this option is only supported on Windows") + } +} + func (action *Action) sanitize(cfg *Config) { if action.Timeout < 3 { action.Timeout = 3 diff --git a/service/internal/config/sanitize_test.go b/service/internal/config/sanitize_test.go index 036d710..b92d85b 100644 --- a/service/internal/config/sanitize_test.go +++ b/service/internal/config/sanitize_test.go @@ -1,8 +1,11 @@ package config import ( + "bytes" + "runtime" "testing" + "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -160,3 +163,22 @@ func TestValidateUniqueLocalUserAPIKeys(t *testing.T) { }) require.NoError(t, err) } + +func TestSanitizeServiceLogsUnsupportedPlatform(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("serviceLogs.directory platform check only applies on non-Windows") + } + + var logBuffer bytes.Buffer + previousOutput := logrus.StandardLogger().Out + logrus.SetOutput(&logBuffer) + t.Cleanup(func() { + logrus.SetOutput(previousOutput) + }) + + cfg := DefaultConfig() + cfg.ServiceLogs.Directory = "/var/log/OliveTin" + cfg.Sanitize() + + assert.Contains(t, logBuffer.String(), "serviceLogs.directory is configured but this option is only supported on Windows") +} diff --git a/service/internal/servicehost/log_directory.go b/service/internal/servicehost/log_directory.go new file mode 100644 index 0000000..1d52601 --- /dev/null +++ b/service/internal/servicehost/log_directory.go @@ -0,0 +1,44 @@ +package servicehost + +import ( + "os" + "path/filepath" +) + +func resolveLogDirectory(dir string, baseDir string) string { + if dir == "" { + return "" + } + + if filepath.IsAbs(dir) { + return dir + } + + if baseDir == "" { + return dir + } + + return filepath.Join(baseDir, dir) +} + +func executableDirectory() (string, error) { + ex, err := os.Executable() + if err != nil { + return "", err + } + + return filepath.Dir(ex), nil +} + +func configuredServiceLogDirectory(dir string) (string, error) { + if dir == "" { + return "", nil + } + + exeDir, err := executableDirectory() + if err != nil { + return "", err + } + + return resolveLogDirectory(dir, exeDir), nil +} diff --git a/service/internal/servicehost/log_directory_test.go b/service/internal/servicehost/log_directory_test.go new file mode 100644 index 0000000..0c3c16f --- /dev/null +++ b/service/internal/servicehost/log_directory_test.go @@ -0,0 +1,20 @@ +package servicehost + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResolveLogDirectory(t *testing.T) { + t.Parallel() + + baseDir := filepath.Join(t.TempDir(), "OliveTin") + absoluteDir := t.TempDir() + + assert.Equal(t, "", resolveLogDirectory("", baseDir)) + assert.Equal(t, absoluteDir, resolveLogDirectory(absoluteDir, baseDir)) + assert.Equal(t, filepath.Join(baseDir, "logs", "service"), resolveLogDirectory("./logs/service", baseDir)) + assert.Equal(t, "logs/service", resolveLogDirectory("logs/service", "")) +} diff --git a/service/internal/servicehost/servicehost_nonwin.go b/service/internal/servicehost/servicehost_nonwin.go index 8ecd407..d5fb296 100644 --- a/service/internal/servicehost/servicehost_nonwin.go +++ b/service/internal/servicehost/servicehost_nonwin.go @@ -7,7 +7,7 @@ import ( log "github.com/sirupsen/logrus" ) -func Start(mode string) { +func Start(_ string, _ string) { log.Debugf("servicehost nonwin") } diff --git a/service/internal/servicehost/servicehost_windows.go b/service/internal/servicehost/servicehost_windows.go index a653043..66a2deb 100644 --- a/service/internal/servicehost/servicehost_windows.go +++ b/service/internal/servicehost/servicehost_windows.go @@ -50,9 +50,20 @@ func (m *otWindowsService) Execute(args []string, r <-chan svc.ChangeRequest, st } } -func setupLogging() { - logsDir := path.Join(GetConfigFilePath(), "logs") +func setupLogging(serviceLogDirectory string) { + logsDir, err := configuredServiceLogDirectory(serviceLogDirectory) + if err != nil { + log.Warnf("Failed to resolve serviceLogs.directory relative to executable: %v", err) + } + if logsDir == "" { + logsDir = path.Join(GetConfigFilePath(), "logs") + } + + openServiceLogFile(logsDir) +} + +func openServiceLogFile(logsDir string) { os.MkdirAll(logsDir, 0755) timestamp := time.Now().Format("2006-01-02_15-04-05") @@ -65,11 +76,12 @@ func setupLogging() { if err != nil { log.Infof("Failed to open log file: %v", err) - } else { - log.Infof("Switching to log file: %v", f.Name()) - log.SetOutput(f) - log.Infof("Opened log file: %v", f.Name()) + return } + + log.Infof("Switching to log file: %v", f.Name()) + log.SetOutput(f) + log.Infof("Opened log file: %v", f.Name()) } func GetConfigFilePath() string { @@ -132,8 +144,8 @@ func startServiceHandler(mode string) { } -func Start(mode string) { - setupLogging() +func Start(mode string, serviceLogDirectory string) { + setupLogging(serviceLogDirectory) go startServiceHandler(mode) } diff --git a/service/main.go b/service/main.go index eeb2ca8..1f1e5b1 100644 --- a/service/main.go +++ b/service/main.go @@ -245,7 +245,7 @@ func warnIfPuidGuid() { } func main() { - servicehost.Start(cfg.ServiceHostMode) + servicehost.Start(cfg.ServiceHostMode, cfg.ServiceLogs.Directory) log.WithFields(log.Fields{ "configDir": cfg.GetDir(),