diff --git a/.vscode/launch.json b/.vscode/launch.json index f1eeafa01..2d3fcc50c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,6 +11,14 @@ "mode": "auto", "program": "${fileDirname}", "args": ["-c", "examples/include.conf"] + }, + { + "name": "Private profiles.toml", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceFolder}", + "args": ["-c", "examples/private/profiles.toml"] } ] } \ No newline at end of file diff --git a/config/global.go b/config/global.go index cd19e7ca6..be26134cf 100644 --- a/config/global.go +++ b/config/global.go @@ -27,6 +27,7 @@ type Global struct { Scheduler string `mapstructure:"scheduler" default:"auto" examples:"auto;launchd;systemd;taskscheduler;crond;crond:/usr/bin/crontab;crontab:*:/etc/cron.d/resticprofile" description:"Selects the scheduler. Blank or \"auto\" uses the default scheduler of your operating system: \"launchd\", \"systemd\", \"taskscheduler\" or \"crond\" (as fallback). Alternatively you can set \"crond\" for cron compatible schedulers supporting the crontab executable API or \"crontab:[user:]file\" to write into a crontab file directly. The need for a user is detected if missing and can be set to a name, \"-\" (no user) or \"*\" (current user)."` ScheduleDefaults *ScheduleBaseConfig `mapstructure:"schedule-defaults" default:"" description:"Sets defaults for all schedules"` Log string `mapstructure:"log" default:"" examples:"/resticprofile.log;syslog-tcp://syslog-server:514;syslog:server;syslog:" description:"Sets the default log destination to be used if not specified in \"--log\" or \"schedule-log\" - see https://creativeprojects.github.io/resticprofile/configuration/logs/"` + LogUploadUrl string `mapstructure:"log-upload-url" description:"A URL to where to log-file will be uploaded after the job ran"` CommandOutput string `mapstructure:"command-output" default:"auto" enum:"auto;log;console;all" description:"Sets the destination for command output (stderr/stdout). \"log\" sends output to the log file (if specified), \"console\" sends it to the console instead. \"auto\" sends it to \"both\" if console is a terminal otherwise to \"log\" only - see https://creativeprojects.github.io/resticprofile/configuration/logs/"` LegacyArguments bool `mapstructure:"legacy-arguments" default:"false" deprecated:"0.20.0" description:"Legacy, broken arguments mode of resticprofile before version 0.15"` SystemdUnitTemplate string `mapstructure:"systemd-unit-template" default:"" description:"File containing the go template to generate a systemd unit - see https://creativeprojects.github.io/resticprofile/schedules/systemd/"` diff --git a/logger.go b/logger.go index 9bb26c9ab..13b76b3ad 100644 --- a/logger.go +++ b/logger.go @@ -1,9 +1,11 @@ package main import ( + "context" "fmt" "io" "log" + "net/http" "os" "path/filepath" "slices" @@ -50,18 +52,20 @@ func setupRemoteLogger(flags commandLineFlags, client *remote.Client) { clog.SetDefaultLogger(logger) } -func setupTargetLogger(flags commandLineFlags, logTarget, commandOutput string) (io.Closer, error) { +func setupTargetLogger(flags commandLineFlags, logTarget, logUploadTarget, commandOutput string) (io.Closer, error) { var ( - handler LogCloser - file io.Writer - err error + handler LogCloser + file io.Writer + filepath string + err error ) if scheme, hostPort, isURL := dial.GetAddr(logTarget); isURL { handler, file, err = getSyslogHandler(scheme, hostPort) } else if dial.IsURL(logTarget) { err = fmt.Errorf("unsupported URL: %s", logTarget) } else { - handler, file, err = getFileHandler(logTarget) + filepath = getLogfilePath(logTarget) + handler, file, err = getFileHandler(filepath) } if err != nil { return nil, err @@ -79,11 +83,62 @@ func setupTargetLogger(flags commandLineFlags, logTarget, commandOutput string) } else if toLog { term.SetAllOutput(file) } + if logUploadTarget != "" && filepath != "" { + if !dial.IsURL(logUploadTarget) { + return nil, fmt.Errorf("log-upload: No valid URL %v", logUploadTarget) + } + handler = createLogUploadingLogHandler(handler, filepath, logUploadTarget) + } } // and return the handler (so we can close it at the end) return handler, nil } +type logUploadingLogCloser struct { + LogCloser + logfilePath string + logUploadTarget string +} + +// Try to close the original handler +// Also upload the log to the configured log-upload-url +func (w logUploadingLogCloser) Close() error { + err := w.LogCloser.Close() + if err != nil { + return err + } + // Open logfile for reading + logData, err := os.Open(w.logfilePath) + if err != nil { + return err + } + // Upload logfile to server + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.logUploadTarget, logData) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/octet-stream") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + // HTTP-Status-Codes 200-299 signal success, return an error for everything else + if resp.StatusCode < 200 || resp.StatusCode > 299 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("log-upload: Got invalid http status %v: %v", resp.StatusCode, string(respBody)) + } + return nil +} + +func createLogUploadingLogHandler(handler LogCloser, logfilePath string, logUploadTarget string) LogCloser { + return logUploadingLogCloser{LogCloser: handler, logfilePath: logfilePath, logUploadTarget: logUploadTarget} +} + func parseCommandOutput(commandOutput string) (all, log bool) { if strings.TrimSpace(commandOutput) == "auto" { if term.OsStdoutIsTerminal() { @@ -98,7 +153,7 @@ func parseCommandOutput(commandOutput string) (all, log bool) { return } -func getFileHandler(logfile string) (*clog.StandardLogHandler, io.Writer, error) { +func getLogfilePath(logfile string) string { if strings.HasPrefix(logfile, constants.TemporaryDirMarker) { if tempDir, err := util.TempDir(); err == nil { logfile = logfile[len(constants.TemporaryDirMarker):] @@ -109,7 +164,10 @@ func getFileHandler(logfile string) (*clog.StandardLogHandler, io.Writer, error) _ = os.MkdirAll(filepath.Dir(logfile), 0755) } } + return logfile +} +func getFileHandler(logfile string) (*clog.StandardLogHandler, io.Writer, error) { // create a platform aware log file appender keepOpen, appender := true, appendFunc(nil) if platform.IsWindows() { diff --git a/logger_test.go b/logger_test.go index 32fb6fe8b..f20498c52 100644 --- a/logger_test.go +++ b/logger_test.go @@ -2,9 +2,14 @@ package main import ( "bufio" + "bytes" "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" + "strconv" + "strings" "testing" "time" @@ -38,7 +43,8 @@ func TestFileHandlerWithTemporaryDirMarker(t *testing.T) { logFile := filepath.Join(util.MustGetTempDir(), "sub", "file.log") assert.NoFileExists(t, logFile) - handler, _, err := getFileHandler(filepath.Join(constants.TemporaryDirMarker, "sub", "file.log")) + filepath := getLogfilePath(filepath.Join(constants.TemporaryDirMarker, "sub", "file.log")) + handler, _, err := getFileHandler(filepath) require.NoError(t, err) assert.FileExists(t, logFile) @@ -142,3 +148,34 @@ func TestCloseFileHandler(t *testing.T) { handler.Close() assert.Error(t, handler.LogEntry(clog.LogEntry{Level: clog.LevelInfo, Format: "log-line-2"})) } + +func TestLogUploadFailed(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body.Close() + w.WriteHeader(http.StatusInternalServerError) + }) + server := httptest.NewServer(handler) + defer server.Close() + closer, err := setupTargetLogger(commandLineFlags{}, filepath.Join(constants.TemporaryDirMarker, "file.log"), server.URL, "log") + assert.NoError(t, err) + assert.ErrorContains(t, closer.Close(), "log-upload: Got invalid http status "+strconv.Itoa(http.StatusInternalServerError)) +} + +func TestLogUpload(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buffer := bytes.Buffer{} + _, err := buffer.ReadFrom(r.Body) + assert.NoError(t, err) + r.Body.Close() + + w.WriteHeader(http.StatusOK) + assert.Equal(t, strings.Trim(buffer.String(), "\r\n"), "TestLogLine") + }) + server := httptest.NewServer(handler) + defer server.Close() + closer, err := setupTargetLogger(commandLineFlags{}, filepath.Join(constants.TemporaryDirMarker, "file.log"), server.URL, "log") + assert.NoError(t, err) + _, err = term.Println("TestLogLine") + assert.NoError(t, err) + assert.NoError(t, closer.Close()) +} diff --git a/main.go b/main.go index 6a3d02c38..e77e996a5 100644 --- a/main.go +++ b/main.go @@ -118,8 +118,14 @@ func main() { term.PrintToError = flags.stderr } if logTarget != "" && logTarget != "-" { - if closer, err := setupTargetLogger(flags, logTarget, commandOutput); err == nil { - logCloser = func() { _ = closer.Close() } + if closer, err := setupTargetLogger(flags, logTarget, ctx.global.LogUploadUrl, commandOutput); err == nil { + logCloser = func() { + err := closer.Close() + if err != nil { + // Log is already closed. Write to stderr as last resort + fmt.Fprintf(os.Stderr, "Error closing logfile: %v\n", err) + } + } } else { // fallback to a console logger setupConsoleLogger(flags)