Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/dependabot-automerge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:

steps:
- name: Set up Node.js 24
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # pinned from actions/setup-node@v4
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # pinned from actions/setup-node@v6.4.0
with:
node-version: '24'

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd

- name: Dependency Review
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # pinned from actions/dependency-review-action@v5.0.0
with:
# Blocca solo severity critical (zero-touch per gli altri)
fail-on-severity: critical
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/security-ultimate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ jobs:
# UPLOAD SARIF
########################################
- name: Upload GoSec SARIF
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba
with:
sarif_file: gosec.sarif

Expand All @@ -104,7 +104,7 @@ jobs:
# CODEQL
########################################
- name: Initialize CodeQL
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba
with:
languages: go

Expand All @@ -114,4 +114,4 @@ jobs:
go build ./...

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba
4 changes: 2 additions & 2 deletions cmd/proxsave/base_dir_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ func forceDetectedBaseDirForTest(t *testing.T, baseDir string) {
origOnce := execInfoOnce

execPath := filepath.Join(baseDir, "build", "proxsave")
execInfo = ExecInfo{
execInfo = &ExecInfo{
ExecPath: execPath,
ExecDir: filepath.Dir(execPath),
BaseDir: baseDir,
HasBase: true,
}
execInfoOnce = sync.Once{}
execInfoOnce = &sync.Once{}
execInfoOnce.Do(func() {})

t.Cleanup(func() {
Expand Down
1 change: 0 additions & 1 deletion cmd/proxsave/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,6 @@ func runConfigWizardCLI(ctx context.Context, reader *bufio.Reader, configPath, t
if skipConfigWizard {
return installConfigResult{SkipConfigWizard: true}, nil
}
template = config.RemoveRuntimeDerivedEnvKeys(template)

logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "configuring secondary storage")
if template, err = configureSecondaryStorage(ctx, reader, template); err != nil {
Expand Down
9 changes: 5 additions & 4 deletions cmd/proxsave/runtime_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,16 @@ type ExecInfo struct {
}

var (
execInfo ExecInfo
execInfoOnce sync.Once
execInfo = &ExecInfo{}
execInfoOnce = &sync.Once{}
)

func getExecInfo() ExecInfo {
execInfoOnce.Do(func() {
execInfo = detectExecInfo()
info := detectExecInfo()
execInfo = &info
})
return execInfo
return *execInfo
}

func detectExecInfo() ExecInfo {
Expand Down
2 changes: 1 addition & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -865,7 +865,7 @@ If `EMAIL_ENABLED` is omitted, the default remains `false`. The legacy alias `EM
| `sendmail` | The node already has a local MTA such as Postfix, Exim, or Sendmail. | In the local MTA. ProxSave calls `/usr/sbin/sendmail`. |
| `pmf` | You explicitly want Proxmox Notifications via `proxmox-mail-forward`. | In Proxmox (`Datacenter -> Notifications` on PVE, or the PBS notification UI/config). ProxSave does not ask for SMTP host/port/user/password. |

`pmf` may also be written as `proxmox`, `proxmox-notifications`, or `proxmox-mail-forward`; ProxSave normalizes those aliases to `pmf`.
The value `pmf` may also be written as `proxmox`, `proxmox-notifications`, or `proxmox-mail-forward`; ProxSave normalizes those aliases to `pmf`.

**Notes**:
- Allowed values for `EMAIL_DELIVERY_METHOD` are: `pmf`, `relay`, `sendmail` (invalid values will skip Email with a warning).
Expand Down
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ require (
filippo.io/age v1.3.1
github.com/gdamore/tcell/v2 v2.13.9
github.com/rivo/tview v0.42.0
golang.org/x/crypto v0.50.0
golang.org/x/term v0.42.0
golang.org/x/text v0.36.0
golang.org/x/crypto v0.51.0
golang.org/x/term v0.43.0
golang.org/x/text v0.37.0
)

require (
Expand All @@ -17,5 +17,5 @@ require (
github.com/gdamore/encoding v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/sys v0.44.0 // indirect
)
16 changes: 8 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
Expand All @@ -36,20 +36,20 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
Expand Down
2 changes: 1 addition & 1 deletion internal/backup/archiver.go
Original file line number Diff line number Diff line change
Expand Up @@ -726,7 +726,7 @@ func (a *Archiver) attachStderrLogger(cmd *exec.Cmd, algo string) error {

func drainTarWriterAfterCompressorStartFailure(pw *io.PipeWriter, errChan <-chan error, startErr error) {
_ = pw.CloseWithError(startErr)
_ = <-errChan
<-errChan
}

func (a *Archiver) pipeTarThroughCommand(ctx context.Context, sourceDir, outputPath string, cmd *exec.Cmd, algo string) (err error) {
Expand Down
8 changes: 4 additions & 4 deletions internal/notify/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,8 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti
detectedRecipient, err := e.detectRecipient(ctx)
if err != nil {
e.logger.Warning("WARNING: Failed to detect email recipient: %v", err)
switch {
case e.config.DeliveryMethod == EmailDeliveryPMF:
switch e.config.DeliveryMethod {
case EmailDeliveryPMF:
e.logger.Info(" Proceeding anyway because EMAIL_DELIVERY_METHOD=pmf routes via Proxmox Notifications; recipient is only used for the To: header")
default:
e.logger.Warning("WARNING: Email notification skipped because no valid recipient is available")
Expand All @@ -218,8 +218,8 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti

recipient = strings.TrimSpace(recipient)
if recipient == "" {
switch {
case e.config.DeliveryMethod == EmailDeliveryRelay || e.config.DeliveryMethod == EmailDeliverySendmail:
switch e.config.DeliveryMethod {
case EmailDeliveryRelay, EmailDeliverySendmail:
e.logger.Warning("WARNING: Email recipient is empty after configuration/detection")
e.logger.Info(" Configure EMAIL_RECIPIENT or set an email address for root@pam inside Proxmox")
result.Success = false
Expand Down
100 changes: 98 additions & 2 deletions internal/orchestrator/additional_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -597,8 +597,14 @@ func TestFinalizeAndCloseLogWithoutLogFile(t *testing.T) {
}

type stubNotifierChannel struct {
name string
called bool
name string
called bool
logger *logging.Logger
warnOnNotify bool
errorCount int
warningCount int
categoryCount int
exitCode int
}

func (s *stubNotifierChannel) Name() string {
Expand All @@ -607,6 +613,26 @@ func (s *stubNotifierChannel) Name() string {

func (s *stubNotifierChannel) Notify(ctx context.Context, stats *BackupStats) error {
s.called = true
if stats != nil {
s.errorCount = stats.ErrorCount
s.warningCount = stats.WarningCount
s.categoryCount = len(stats.LogCategories)
s.exitCode = stats.ExitCode
}
if s.warnOnNotify && s.logger != nil {
s.logger.Warning("%s notification warning after snapshot", s.name)
}
return nil
}

type warningStorageTarget struct {
logger *logging.Logger
}

func (w *warningStorageTarget) Sync(ctx context.Context, stats *BackupStats) error {
if w.logger != nil {
w.logger.Warning("storage warning before notification snapshot")
}
return nil
}

Expand Down Expand Up @@ -721,6 +747,76 @@ func TestDispatchNotificationsUsesNameMappingNotRegistrationOrder(t *testing.T)
}
}

func TestDispatchPostBackupSnapshotsIssuesImmediatelyBeforeNotifications(t *testing.T) {
logger := logging.New(types.LogLevelInfo, false)
var buf bytes.Buffer
logger.SetOutput(&buf)

logPath := filepath.Join(t.TempDir(), "run.log")
if err := logger.OpenLogFile(logPath); err != nil {
t.Fatalf("OpenLogFile: %v", err)
}
t.Cleanup(func() {
_ = logger.CloseLogFile()
})

email := &stubNotifierChannel{name: "Email", logger: logger, warnOnNotify: true}
telegram := &stubNotifierChannel{name: "Telegram"}
o := &Orchestrator{
logger: logger,
cfg: &config.Config{
EmailEnabled: true,
TelegramEnabled: true,
GotifyEnabled: false,
WebhookEnabled: false,
},
storageTargets: []StorageTarget{
&warningStorageTarget{logger: logger},
},
notificationChannels: []NotificationChannel{
email,
telegram,
},
}
stats := &BackupStats{
LogFilePath: logPath,
SecondaryEnabled: true,
SecondaryStatus: "ok",
CloudStatus: "disabled",
EmailStatus: "unknown",
TelegramStatus: "unknown",
}

if err := o.dispatchPostBackup(context.Background(), stats); err != nil {
t.Fatalf("dispatchPostBackup returned error: %v", err)
}

if !email.called || !telegram.called {
t.Fatalf("expected both notifiers to be called (email=%v telegram=%v)", email.called, telegram.called)
}
if email.warningCount != 1 || telegram.warningCount != 1 {
t.Fatalf("notifiers should see the same pre-notification warning snapshot, got email=%d telegram=%d", email.warningCount, telegram.warningCount)
}
if email.errorCount != 0 || telegram.errorCount != 0 {
t.Fatalf("notifiers should not see errors, got email=%d telegram=%d", email.errorCount, telegram.errorCount)
}
if email.categoryCount == 0 || telegram.categoryCount == 0 || len(stats.LogCategories) == 0 {
t.Fatalf("expected pre-notification log categories to be snapshotted")
}
if stats.WarningCount != 1 {
t.Fatalf("stats.WarningCount should remain at the pre-notification snapshot, got %d", stats.WarningCount)
}
if stats.ExitCode != types.ExitGenericError.Int() {
t.Fatalf("stats.ExitCode should reflect pre-notification warnings, got %d", stats.ExitCode)
}
if email.exitCode != types.ExitGenericError.Int() || telegram.exitCode != types.ExitGenericError.Int() {
t.Fatalf("notifiers should see warning exit code, got email=%d telegram=%d", email.exitCode, telegram.exitCode)
}
if got := logger.WarningCount(); got != 2 {
t.Fatalf("final logger warning count should include storage and notification warnings, got %d", got)
}
}

func TestLogRetentionPolicyDetailsSimpleVsGFS(t *testing.T) {
logger := logging.New(types.LogLevelDebug, false)
var buf bytes.Buffer
Expand Down
8 changes: 3 additions & 5 deletions internal/orchestrator/backup_run_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,9 @@ func (o *Orchestrator) parseFailedBackupLogCounts(stats *BackupStats) {
}

o.logger.Debug("Parsing log file for error/warning counts after failure: %s", stats.LogFilePath)
_, errorCount, warningCount := ParseLogCounts(stats.LogFilePath, 0)
stats.ErrorCount = errorCount
stats.WarningCount = warningCount
if errorCount > 0 || warningCount > 0 {
o.logger.Debug("Found %d errors and %d warnings in log file (failure path)", errorCount, warningCount)
o.refreshLogIssuesFromFile(stats, false)
if stats.ErrorCount > 0 || stats.WarningCount > 0 {
o.logger.Debug("Found %d errors and %d warnings in log file (failure path)", stats.ErrorCount, stats.WarningCount)
}
}

Expand Down
31 changes: 15 additions & 16 deletions internal/orchestrator/backup_run_phases.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,26 +339,25 @@ func (o *Orchestrator) finalizeBackupStats(run *backupRunContext) {
stats := run.stats
stats.Duration = stats.EndTime.Sub(stats.StartTime)

if stats.LogFilePath != "" {
o.logger.Debug("Parsing log file for error/warning counts: %s", stats.LogFilePath)
_, errorCount, warningCount := ParseLogCounts(stats.LogFilePath, 0)
stats.ErrorCount = errorCount
stats.WarningCount = warningCount
if errorCount > 0 || warningCount > 0 {
o.logger.Debug("Found %d errors and %d warnings in log file", errorCount, warningCount)
}
} else {
if o.dryRun {
o.finalizeDryRunIssueStats(stats)
}
}

func (o *Orchestrator) finalizeDryRunIssueStats(stats *BackupStats) {
if stats.LogFilePath == "" {
o.logger.Debug("No log file path specified, error/warning counts will be 0")
applyIssueExitCode(stats)
o.logger.Debug("Aggregated exit code based on log analysis: %d", stats.ExitCode)
return
}

switch {
case stats.ErrorCount > 0:
stats.ExitCode = types.ExitBackupError.Int()
case stats.WarningCount > 0:
stats.ExitCode = types.ExitGenericError.Int()
default:
stats.ExitCode = types.ExitSuccess.Int()
o.logger.Debug("Parsing log file for error/warning counts: %s", stats.LogFilePath)
o.refreshLogIssuesFromFile(stats, false)
if stats.ErrorCount > 0 || stats.WarningCount > 0 {
o.logger.Debug("Found %d errors and %d warnings in log file", stats.ErrorCount, stats.WarningCount)
}
applyIssueExitCode(stats)
o.logger.Debug("Aggregated exit code based on log analysis: %d", stats.ExitCode)
}

Expand Down
12 changes: 10 additions & 2 deletions internal/orchestrator/encryption_exported_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,11 @@ func TestRunAgeSetupWizard_ExitReturnsAborted(t *testing.T) {
if err != nil {
t.Fatalf("open stdin: %v", err)
}
defer f.Close()
t.Cleanup(func() {
if err := f.Close(); err != nil {
t.Errorf("close stdin fixture: %v", err)
}
})

origIn := os.Stdin
t.Cleanup(func() { os.Stdin = origIn })
Expand Down Expand Up @@ -279,7 +283,11 @@ func TestRunAgeSetupWizard_Option1WritesFile(t *testing.T) {
if err != nil {
t.Fatalf("open stdin: %v", err)
}
defer f.Close()
t.Cleanup(func() {
if err := f.Close(); err != nil {
t.Errorf("close stdin fixture: %v", err)
}
})

origIn := os.Stdin
t.Cleanup(func() { os.Stdin = origIn })
Expand Down
Loading
Loading