Skip to content

Add functionality to upload log-file #488

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
]
}
1 change: 1 addition & 0 deletions config/global.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/"`
Expand Down
70 changes: 64 additions & 6 deletions logger.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package main

import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"slices"
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Comment on lines +105 to +136
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Verify potential race conditions with log file uploading.

While the code correctly closes the original log handler before uploading, there could be a risk of uploading an incomplete log file if other processes are still writing to it.

Consider checking if there are any safeguards in place to ensure the log file is fully written before upload. You may want to add a small delay or use file locking if available in your target environments.


🏁 Script executed:

#!/bin/bash
# Check if there are any file locking or synchronization mechanisms elsewhere in the codebase
rg -A 3 -B 3 "file.*lock|flock|sync.*file" --ignore-case

Length of output: 56016


Action: Address potential race condition in log file upload

After reviewing the code and repository, it appears that while the log file is closed via w.LogCloser.Close() before being re-opened for upload, there is no explicit safeguard—such as file locking or a delay—to ensure that no concurrent writes occur during or immediately after this close. In our search, no synchronisation mechanism was identified in the logger code to guarantee that the logfile is fully flushed and complete before being uploaded.

  • File locking or delay: Consider implementing file locking on the log file during the upload process or adding a short delay to ensure that any pending writes have finished.
  • Concurrent writes risk: Ensure that no other process or goroutine writes to the log file once the original handler is closed, or introduce additional safeguards if such concurrent writes are possible.

Please review your logging and file upload strategy to mitigate any risk of uploading incomplete logs.


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() {
Expand All @@ -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):]
Expand All @@ -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() {
Expand Down
39 changes: 38 additions & 1 deletion logger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ package main

import (
"bufio"
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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())
}
10 changes: 8 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading