diff --git a/.github/instructions/codacy.instructions.md b/.github/instructions/codacy.instructions.md index 9e27a080..a5391f1f 100644 --- a/.github/instructions/codacy.instructions.md +++ b/.github/instructions/codacy.instructions.md @@ -7,9 +7,9 @@ Configuration for AI behavior when interacting with Codacy's MCP Server ## CRITICAL: After ANY successful `edit_file` or `reapply` operation - YOU MUST IMMEDIATELY run the `codacy_cli_analyze` tool from Codacy's MCP Server for each file that was edited, with: - - `rootPath`: set to the workspace path - - `file`: set to the path of the edited file - - `tool`: leave empty or unset +- `rootPath`: set to the workspace path +- `file`: set to the path of the edited file +- `tool`: leave empty or unset - If any issues are found in the new edits, propose and apply fixes for them. - > NOTE: Failure to follow this rule is considered a critical error. @@ -24,8 +24,8 @@ Configuration for AI behavior when interacting with Codacy's MCP Server ## When there are no Codacy MCP Server tools available, or the MCP Server is not reachable - Suggest the user the following troubleshooting steps: - - Try to reset the MCP on the extension - - If the user is using VSCode, suggest them to review their Copilot > MCP settings in Github, under their organization or personal account. Refer them to Settings > Copilot > Enable MCP servers in Copilot. Suggested URL (https://github.com/settings/copilot/features) or https://github.com/organizations/{organization-name}/settings/copilot/features (This can only be done by their organization admins / owners) +- Try to reset the MCP on the extension +- If the user is using VSCode, suggest them to review their Copilot > MCP settings in Github, under their organization or personal account. Refer them to Settings > Copilot > Enable MCP servers in Copilot. Suggested URL (https://github.com/settings/copilot/features) or https://github.com/organizations/{organization-name}/settings/copilot/features (This can only be done by their organization admins / owners) - If none of the above steps work, suggest the user to contact Codacy support ## Trying to call a tool that needs a rootPath as a parameter @@ -33,24 +33,24 @@ Configuration for AI behavior when interacting with Codacy's MCP Server ## CRITICAL: Dependencies and Security Checks - IMMEDIATELY after ANY of these actions: - - Running npm/yarn/pnpm install - - Adding dependencies to package.json - - Adding requirements to requirements.txt - - Adding dependencies to pom.xml - - Adding dependencies to build.gradle - - Any other package manager operations +- Running npm/yarn/pnpm install +- Adding dependencies to package.json +- Adding requirements to requirements.txt +- Adding dependencies to pom.xml +- Adding dependencies to build.gradle +- Any other package manager operations - You MUST run the `codacy_cli_analyze` tool with: - - `rootPath`: set to the workspace path - - `tool`: set to "trivy" - - `file`: leave empty or unset +- `rootPath`: set to the workspace path +- `tool`: set to "trivy" +- `file`: leave empty or unset - If any vulnerabilities are found because of the newly added packages: - - Stop all other operations - - Propose and apply fixes for the security issues - - Only continue with the original task after security issues are resolved +- Stop all other operations +- Propose and apply fixes for the security issues +- Only continue with the original task after security issues are resolved - EXAMPLE: - - After: npm install react-markdown - - Do: Run codacy_cli_analyze with trivy - - Before: Continuing with any other tasks +- After: npm install react-markdown +- Do: Run codacy_cli_analyze with trivy +- Before: Continuing with any other tasks ## General - Repeat the relevant steps for each modified file. diff --git a/.github/workflows/autotag.yml b/.github/workflows/autotag.yml index 29407b1c..654aee19 100644 --- a/.github/workflows/autotag.yml +++ b/.github/workflows/autotag.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 # necessario per leggere commit + tag diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index ccac49ca..f9abf810 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -11,10 +11,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Setup Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c with: go-version-file: 'go.mod' @@ -34,7 +34,7 @@ jobs: go test $(go list ./... | grep -v -E '/cmd/|/pbs$|/bech32$|^github.com/tis24dev/proxsave$') -coverprofile=coverage.out - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.out diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 2301c6f5..3a2f2f4b 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -18,10 +18,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Dependency Review - uses: actions/dependency-review-action@v4 + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 with: # Blocca solo severity critical (zero-touch per gli altri) fail-on-severity: critical diff --git a/.github/workflows/race.yml b/.github/workflows/race.yml new file mode 100644 index 00000000..4ad0e9bb --- /dev/null +++ b/.github/workflows/race.yml @@ -0,0 +1,57 @@ +name: Race Detector + +run-name: Race detector - ${{ github.ref_name }} + +"on": + push: + branches: + - main + - dev + pull_request: {} + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + race: + name: Go race detector + runs-on: ubuntu-latest + timeout-minutes: 30 + + env: + CGO_ENABLED: "1" + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c + with: + go-version-file: go.mod + cache: true + cache-dependency-path: | + go.sum + **/go.sum + + - name: Show Go environment + run: | + go version + go env GOTOOLCHAIN CGO_ENABLED GOOS GOARCH + + - name: Download dependencies + run: go mod download + + - name: Run race detector + run: go test -race -count=1 ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4c15779..72ade9ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: # CHECKOUT (fetch-depth 0 per changelog e GoReleaser) ######################################## - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 @@ -45,7 +45,7 @@ jobs: # SETUP GO ######################################## - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c with: go-version-file: 'go.mod' @@ -62,7 +62,7 @@ jobs: # INSTALL SYFT (per SBOM CycloneDX via GoReleaser) ######################################## - name: Install Syft (for SBOM generation) - uses: anchore/sbom-action/download-syft@v0 + uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0 with: syft-version: v1.19.0 @@ -70,7 +70,7 @@ jobs: # GORELEASER ######################################## - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v7 + uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7 with: version: latest workdir: ${{ github.workspace }} @@ -82,6 +82,6 @@ jobs: # ATTESTAZIONE PROVENIENZA BUILD ######################################## - name: Attest Build Provenance - uses: actions/attest-build-provenance@v4 + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 with: subject-path: build/proxsave_* diff --git a/.github/workflows/security-ultimate.yml b/.github/workflows/security-ultimate.yml index 45023a66..18fe733c 100644 --- a/.github/workflows/security-ultimate.yml +++ b/.github/workflows/security-ultimate.yml @@ -21,13 +21,13 @@ jobs: # CHECKOUT ######################################## - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd ######################################## # GO 1.25 — MAIN TOOLCHAIN ######################################## - name: Set up Go (from go.mod) - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c with: go-version-file: 'go.mod' @@ -56,7 +56,7 @@ jobs: # GOSEC — RUN USING GO 1.21 (NO DOCKER) ######################################## - name: Set up Go 1.21 for GoSec - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c with: go-version: "1.21" @@ -88,7 +88,7 @@ jobs: # UPLOAD SARIF ######################################## - name: Upload GoSec SARIF - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e with: sarif_file: gosec.sarif @@ -96,7 +96,7 @@ jobs: # RESTORE GO 1.25 FOR CODEQL ######################################## - name: Restore Go 1.25 for CodeQL - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c with: go-version-file: 'go.mod' @@ -104,7 +104,7 @@ jobs: # CODEQL ######################################## - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e with: languages: go @@ -114,4 +114,4 @@ jobs: go build ./... - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e diff --git a/.github/workflows/sync-dev.yml b/.github/workflows/sync-dev.yml index 223a39cc..6c33326f 100644 --- a/.github/workflows/sync-dev.yml +++ b/.github/workflows/sync-dev.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 diff --git a/cmd/proxsave/main.go b/cmd/proxsave/main.go index a5c3f2cd..fdec4d60 100644 --- a/cmd/proxsave/main.go +++ b/cmd/proxsave/main.go @@ -10,7 +10,7 @@ import ( const ( defaultLegacyEnvPath = "/opt/proxsave/env/backup.env" legacyEnvFallbackPath = "/opt/proxmox-backup/env/backup.env" - goRuntimeMinVersion = "1.25.5" + goRuntimeMinVersion = "1.25.10" networkPreflightTimeout = 2 * time.Second bytesPerMegabyte int64 = 1024 * 1024 defaultDirPerm = 0o755 diff --git a/cmd/proxsave/main_defers.go b/cmd/proxsave/main_defers.go index e138610a..2df746b8 100644 --- a/cmd/proxsave/main_defers.go +++ b/cmd/proxsave/main_defers.go @@ -85,7 +85,11 @@ func closeRunProfiling(rt *appRuntime) { logging.Warning("Failed to create heap profile file: %v", err) return } - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + logging.Warning("Failed to close heap profile file: %v", err) + } + }() if err := pprof.WriteHeapProfile(f); err != nil { logging.Warning("Failed to write heap profile: %v", err) } diff --git a/cmd/proxsave/main_runtime.go b/cmd/proxsave/main_runtime.go index f1a174d8..96dc9b8b 100644 --- a/cmd/proxsave/main_runtime.go +++ b/cmd/proxsave/main_runtime.go @@ -265,10 +265,10 @@ func buildHeapProfilePath(rt *appRuntime) string { // checkGoRuntimeVersion ensures the running binary was built with at least the specified Go version (semver: major.minor.patch). func checkGoRuntimeVersion(minimum string) error { - rt := runtime.Version() // e.g., "go1.25.4" + rt := runtime.Version() // e.g., "go1.25.10" // Normalize versions to x.y.z parse := func(v string) (int, int, int) { - // Accept forms: go1.25.4, go1.25, 1.25.4, 1.25 + // Accept forms: go1.25.10, go1.25, 1.25.10, 1.25 v = strings.TrimPrefix(v, "go") parts := strings.Split(v, ".") toInt := func(s string) int { n, _ := strconv.Atoi(s); return n } diff --git a/cmd/proxsave/runtime_helpers.go b/cmd/proxsave/runtime_helpers.go index 7a778d66..2de51d52 100644 --- a/cmd/proxsave/runtime_helpers.go +++ b/cmd/proxsave/runtime_helpers.go @@ -84,10 +84,7 @@ func detectExecInfo() ExecInfo { originalDir := dir baseDir := "" - for { - if dir == "" || dir == "." || dir == string(filepath.Separator) { - break - } + for dir != "" && dir != "." { if info, err := os.Stat(filepath.Join(dir, "env")); err == nil && info.IsDir() { baseDir = dir break @@ -1085,7 +1082,7 @@ func executableHash() string { if err != nil { return "" } - defer f.Close() + defer func() { _ = f.Close() }() h := sha256.New() if _, err := io.Copy(h, f); err != nil { return "" diff --git a/cmd/proxsave/upgrade.go b/cmd/proxsave/upgrade.go index b1bab7f6..dbfcefb3 100644 --- a/cmd/proxsave/upgrade.go +++ b/cmd/proxsave/upgrade.go @@ -227,7 +227,11 @@ func downloadAndInstallLatest(ctx context.Context, execPath string, bootstrap *l if err != nil { return "", fmt.Errorf("cannot create temp dir: %w", err) } - defer os.RemoveAll(tmpDir) + defer func() { + if removeErr := os.RemoveAll(tmpDir); removeErr != nil { + bootstrap.Debug("Failed to remove temporary upgrade directory %s: %v", tmpDir, removeErr) + } + }() logging.DebugStepBootstrap(bootstrap, "upgrade download/install", "temp dir=%s", tmpDir) archivePath := filepath.Join(tmpDir, filename) @@ -288,7 +292,7 @@ func fetchLatestRelease(ctx context.Context) (string, string, error) { if err != nil { return "", "", fmt.Errorf("failed to fetch latest release: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4*1024)) @@ -381,7 +385,7 @@ func downloadFile(ctx context.Context, url, dest string, bootstrap *logging.Boot if err != nil { return fmt.Errorf("download failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() logging.DebugStepBootstrap(bootstrap, "upgrade download", "status=%s", resp.Status) if resp.StatusCode != http.StatusOK { @@ -393,7 +397,7 @@ func downloadFile(ctx context.Context, url, dest string, bootstrap *logging.Boot if err != nil { return fmt.Errorf("cannot create file %s: %w", dest, err) } - defer out.Close() + defer closeIntoErr(&err, out, "close downloaded file") written, err := io.Copy(out, resp.Body) if err != nil { @@ -436,7 +440,7 @@ func verifyChecksum(archivePath, checksumPath, filename string, bootstrap *loggi if err != nil { return fmt.Errorf("cannot open archive for checksum: %w", err) } - defer f.Close() + defer closeIntoErr(&err, f, "close archive for checksum") hasher := sha256.New() if _, err := io.Copy(hasher, f); err != nil { @@ -459,13 +463,13 @@ func extractBinaryFromTar(archivePath, targetName, destPath string, bootstrap *l if err != nil { return fmt.Errorf("cannot open archive: %w", err) } - defer f.Close() + defer closeIntoErr(&err, f, "close release archive") gzr, err := gzip.NewReader(f) if err != nil { return fmt.Errorf("cannot create gzip reader: %w", err) } - defer gzr.Close() + defer closeIntoErr(&err, gzr, "close release gzip reader") tr := tar.NewReader(gzr) for { @@ -489,7 +493,7 @@ func extractBinaryFromTar(archivePath, targetName, destPath string, bootstrap *l return fmt.Errorf("cannot create extracted binary: %w", err) } if _, err := io.Copy(tmpFile, tr); err != nil { - tmpFile.Close() + _ = tmpFile.Close() return fmt.Errorf("cannot write extracted binary: %w", err) } if err := tmpFile.Close(); err != nil { @@ -513,7 +517,7 @@ func installBinary(srcPath, destPath string, bootstrap *logging.BootstrapLogger) if err != nil { return fmt.Errorf("cannot open extracted binary: %w", err) } - defer src.Close() + defer closeIntoErr(&err, src, "close extracted binary") dst, err := os.OpenFile(tmpDest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) if err != nil { @@ -521,7 +525,7 @@ func installBinary(srcPath, destPath string, bootstrap *logging.BootstrapLogger) } if _, err := io.Copy(dst, src); err != nil { - dst.Close() + _ = dst.Close() return fmt.Errorf("cannot copy binary to temp target: %w", err) } if err := dst.Close(); err != nil { @@ -534,6 +538,15 @@ func installBinary(srcPath, destPath string, bootstrap *logging.BootstrapLogger) return nil } +func closeIntoErr(errp *error, closer io.Closer, operation string) { + if errp == nil || closer == nil { + return + } + if closeErr := closer.Close(); closeErr != nil && *errp == nil { + *errp = fmt.Errorf("%s: %w", operation, closeErr) + } +} + func printUpgradeFooter(upgradeErr error, version, configPath, baseDir, telegramCode, permStatus, permMessage string, cfgUpgradeResult *config.UpgradeResult, cfgUpgradeErr error) { colorReset := "\033[0m" diff --git a/docs/INSTALL.md b/docs/INSTALL.md index e09b6b79..86a0ff10 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -154,8 +154,8 @@ For more details, see [CLI Reference - Binary Upgrade](CLI_REFERENCE.md#binary-u ```bash # Install Go (if building from source) -wget https://go.dev/dl/go1.25.4.linux-amd64.tar.gz -tar -C /usr/local -xzf go1.25.4.linux-amd64.tar.gz +wget https://go.dev/dl/go1.25.10.linux-amd64.tar.gz +tar -C /usr/local -xzf go1.25.10.linux-amd64.tar.gz export PATH=$PATH:/usr/local/go/bin # Install rclone (for cloud storage) @@ -168,7 +168,7 @@ apt update && apt install -y git apt update && apt install -y make # Verify installations -go version # Should show go1.25+ +go version # Should show go1.25.10+ rclone version # Should show rclone v1.50+ git --version # Should show git 2.47.3+ make --version # Should show make 4.4.1+ diff --git a/docs/RESTORE_GUIDE.md b/docs/RESTORE_GUIDE.md index 05bc7172..b514c52e 100644 --- a/docs/RESTORE_GUIDE.md +++ b/docs/RESTORE_GUIDE.md @@ -92,6 +92,9 @@ Examples: - `dual` backup on `pbs` host: restore `PBS + Common` - `pve` backup on `dual` host: restore `PVE + Common` +When compatibility is partial, ProxSave automatically filters selectable +restore categories to the roles supported by the current host. + `unknown` hosts can still use export-oriented or common-only workflows, but ProxSave warns because role-specific compatibility cannot be verified. @@ -2499,9 +2502,10 @@ systemctl restart proxmox-backup proxmox-backup-proxy **Q: Can I restore PVE backup to PBS system (or vice versa)?** -A: Direct cross-role restore is still not recommended. PVE and PBS have -different role-specific configurations. However, ProxSave now evaluates -compatibility by **role overlap**: +A: Pure cross-role restore (no role overlap) is not recommended; however +ProxSave supports restores when roles overlap. PVE and PBS have different +role-specific configurations. ProxSave now evaluates compatibility by +**role overlap**: - `pve` ↔ `pbs`: only common categories are sensible - `dual` → `pve`: PVE + Common can be restored diff --git a/embed.go b/embed.go index b493a4c1..22dce604 100644 --- a/embed.go +++ b/embed.go @@ -1,3 +1,4 @@ +// Package proxmoxbackup embeds installable project documentation. package proxmoxbackup import ( @@ -34,7 +35,9 @@ var installableDocs = func() []DocAsset { }() // InstallableDocs returns the list of documentation files embedded in the -// binary that should be written to the installation root. +// binary that should be written to the installation root. The returned DocAsset +// slice is copied, but DocAsset.Data shares the embedded installableDocs backing +// data; callers must treat Data as read-only or copy it before mutation. func InstallableDocs() []DocAsset { out := make([]DocAsset, len(installableDocs)) copy(out, installableDocs) diff --git a/go.mod b/go.mod index 8bc70ffe..3016aaff 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/tis24dev/proxsave -go 1.25.0 - -toolchain go1.25.9 +go 1.25.10 require ( filippo.io/age v1.3.1 diff --git a/internal/backup/archiver.go b/internal/backup/archiver.go index b63d7826..5a99c3ef 100644 --- a/internal/backup/archiver.go +++ b/internal/backup/archiver.go @@ -15,6 +15,7 @@ import ( "time" "filippo.io/age" + "github.com/tis24dev/proxsave/internal/closeerr" "github.com/tis24dev/proxsave/internal/logging" "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/internal/types" @@ -22,6 +23,8 @@ import ( var lookPath = exec.LookPath +var closeIntoErr = closeerr.CloseIntoErr + // ArchiverDeps groups external dependencies used by Archiver. type ArchiverDeps struct { LookPath func(string) (string, error) @@ -443,7 +446,7 @@ func (a *Archiver) createGzipArchive(ctx context.Context, sourceDir, outputPath if err != nil { return fmt.Errorf("failed to create output file: %w", err) } - defer outFile.Close() + defer closeIntoErr(&err, outFile, "close output archive") writer, finalizeEncryption, err := a.wrapEncryptionWriter(outFile) if err != nil { @@ -464,7 +467,7 @@ func (a *Archiver) createGzipArchive(ctx context.Context, sourceDir, outputPath if err != nil { return fmt.Errorf("failed to create gzip writer: %w", err) } - defer gzWriter.Close() + defer closeIntoErr(&err, gzWriter, "close gzip writer") // Stream tar content into gzip writer if err := a.writeTar(ctx, sourceDir, gzWriter); err != nil { @@ -493,7 +496,7 @@ func (a *Archiver) createTarArchive(ctx context.Context, sourceDir, outputPath s if err != nil { return fmt.Errorf("failed to create output file: %w", err) } - defer outFile.Close() + defer closeIntoErr(&err, outFile, "close output archive") writer, finalizeEncryption, err := a.wrapEncryptionWriter(outFile) if err != nil { @@ -576,7 +579,7 @@ func (a *Archiver) createXZArchive(ctx context.Context, sourceDir, outputPath st if err != nil { return fmt.Errorf("failed to create output file: %w", err) } - defer outFile.Close() + defer closeIntoErr(&err, outFile, "close output archive") pr, pw := io.Pipe() cmd.Stdin = pr @@ -600,15 +603,15 @@ func (a *Archiver) createXZArchive(ctx context.Context, sourceDir, outputPath st defer close(errChan) err := a.writeTar(ctx, sourceDir, pw) if err != nil { - pw.CloseWithError(err) + _ = pw.CloseWithError(err) } else { - pw.Close() + err = pw.Close() } errChan <- err }() if err := cmd.Start(); err != nil { - pw.Close() + _ = pw.Close() if startErr := <-errChan; startErr != nil { return startErr } @@ -649,7 +652,7 @@ func (a *Archiver) createZstdArchive(ctx context.Context, sourceDir, outputPath if err != nil { return fmt.Errorf("failed to create output file: %w", err) } - defer outFile.Close() + defer closeIntoErr(&err, outFile, "close output archive") pr, pw := io.Pipe() cmd.Stdin = pr @@ -673,15 +676,15 @@ func (a *Archiver) createZstdArchive(ctx context.Context, sourceDir, outputPath defer close(errChan) err := a.writeTar(ctx, sourceDir, pw) if err != nil { - pw.CloseWithError(err) + _ = pw.CloseWithError(err) } else { - pw.Close() + err = pw.Close() } errChan <- err }() if err := cmd.Start(); err != nil { - pw.Close() + _ = pw.Close() if startErr := <-errChan; startErr != nil { return startErr } @@ -730,7 +733,7 @@ func (a *Archiver) pipeTarThroughCommand(ctx context.Context, sourceDir, outputP if err != nil { return fmt.Errorf("failed to create output file: %w", err) } - defer outFile.Close() + defer closeIntoErr(&err, outFile, "close output archive") pr, pw := io.Pipe() cmd.Stdin = pr @@ -756,16 +759,15 @@ func (a *Archiver) pipeTarThroughCommand(ctx context.Context, sourceDir, outputP go func() { defer close(errChan) if err := a.writeTar(ctx, sourceDir, pw); err != nil { - pw.CloseWithError(err) + _ = pw.CloseWithError(err) errChan <- err return } - pw.Close() - errChan <- nil + errChan <- pw.Close() }() if err := cmd.Start(); err != nil { - pw.Close() + _ = pw.Close() if startErr := <-errChan; startErr != nil { return startErr } @@ -889,12 +891,16 @@ func (a *Archiver) addToTar(ctx context.Context, tarWriter *tar.Writer, sourceDi a.logger.Warning("Failed to open file %s: %v", path, err) return nil } - defer file.Close() if _, err := io.Copy(tarWriter, file); err != nil { + _ = file.Close() a.logger.Warning("Failed to write file %s to archive: %v", path, err) return nil } + if err := file.Close(); err != nil { + a.logger.Warning("Failed to close file %s after archiving: %v", path, err) + return nil + } a.logger.Debug("Added file to archive: %s", archivePath) } else if linkInfo.Mode()&os.ModeSymlink != 0 { diff --git a/internal/backup/archiver_test.go b/internal/backup/archiver_test.go index 6a29842f..fb09de31 100644 --- a/internal/backup/archiver_test.go +++ b/internal/backup/archiver_test.go @@ -83,9 +83,15 @@ func TestCreateTarArchive(t *testing.T) { // Create test files testDir := filepath.Join(tempDir, "source") - os.MkdirAll(filepath.Join(testDir, "subdir"), 0755) - os.WriteFile(filepath.Join(testDir, "file1.txt"), []byte("content1"), 0644) - os.WriteFile(filepath.Join(testDir, "subdir", "file2.txt"), []byte("content2"), 0644) + if err := os.MkdirAll(filepath.Join(testDir, "subdir"), 0755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + if err := os.WriteFile(filepath.Join(testDir, "file1.txt"), []byte("content1"), 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + if err := os.WriteFile(filepath.Join(testDir, "subdir", "file2.txt"), []byte("content2"), 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } // Create archive outputPath := filepath.Join(tempDir, "test.tar") @@ -138,7 +144,7 @@ func TestCreateTarArchiveRespectsExcludePatterns(t *testing.T) { if err != nil { t.Fatalf("open archive: %v", err) } - defer f.Close() + defer func() { _ = f.Close() }() found := map[string]bool{} tr := tar.NewReader(f) @@ -178,8 +184,12 @@ func TestCreateGzipArchive(t *testing.T) { // Create test files testDir := filepath.Join(tempDir, "source") - os.MkdirAll(testDir, 0755) - os.WriteFile(filepath.Join(testDir, "file.txt"), []byte("test content"), 0644) + if err := os.MkdirAll(testDir, 0755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + if err := os.WriteFile(filepath.Join(testDir, "file.txt"), []byte("test content"), 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } // Create archive outputPath := filepath.Join(tempDir, "test.tar.gz") @@ -199,7 +209,7 @@ func TestCreateGzipArchive(t *testing.T) { if err != nil { t.Fatalf("Failed to open archive: %v", err) } - defer f.Close() + defer func() { _ = f.Close() }() _, err = gzip.NewReader(f) if err != nil { @@ -364,13 +374,19 @@ func TestVerifyArchive(t *testing.T) { // Create a test archive testDir := filepath.Join(tempDir, "source") - os.MkdirAll(testDir, 0755) - os.WriteFile(filepath.Join(testDir, "file.txt"), []byte("test"), 0644) + if err := os.MkdirAll(testDir, 0755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + if err := os.WriteFile(filepath.Join(testDir, "file.txt"), []byte("test"), 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } outputPath := filepath.Join(tempDir, "test.tar") ctx := context.Background() - archiver.CreateArchive(ctx, testDir, outputPath) + if err := archiver.CreateArchive(ctx, testDir, outputPath); err != nil { + t.Fatalf("CreateArchive failed: %v", err) + } // Verify it if err := archiver.VerifyArchive(ctx, outputPath); err != nil { @@ -405,14 +421,20 @@ func TestGetArchiveSize(t *testing.T) { // Create a test archive testDir := filepath.Join(tempDir, "source") - os.MkdirAll(testDir, 0755) + if err := os.MkdirAll(testDir, 0755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } content := []byte("test content with some length") - os.WriteFile(filepath.Join(testDir, "file.txt"), content, 0644) + if err := os.WriteFile(filepath.Join(testDir, "file.txt"), content, 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } outputPath := filepath.Join(tempDir, "test.tar") ctx := context.Background() - archiver.CreateArchive(ctx, testDir, outputPath) + if err := archiver.CreateArchive(ctx, testDir, outputPath); err != nil { + t.Fatalf("CreateArchive failed: %v", err) + } // Get size size, err := archiver.GetArchiveSize(outputPath) @@ -437,8 +459,12 @@ func TestDryRunMode(t *testing.T) { // Create test files testDir := filepath.Join(tempDir, "source") - os.MkdirAll(testDir, 0755) - os.WriteFile(filepath.Join(testDir, "file.txt"), []byte("test"), 0644) + if err := os.MkdirAll(testDir, 0755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + if err := os.WriteFile(filepath.Join(testDir, "file.txt"), []byte("test"), 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } // Try to create archive in dry-run mode outputPath := filepath.Join(tempDir, "test.tar") @@ -539,9 +565,13 @@ func TestContextCancellation(t *testing.T) { // Create a large test directory to ensure cancellation can happen testDir := filepath.Join(tempDir, "source") - os.MkdirAll(testDir, 0755) + if err := os.MkdirAll(testDir, 0755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } for i := 0; i < 100; i++ { - os.WriteFile(filepath.Join(testDir, fmt.Sprintf("file%d.txt", i)), []byte("test content"), 0644) + if err := os.WriteFile(filepath.Join(testDir, fmt.Sprintf("file%d.txt", i)), []byte("test content"), 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } } // Create a context that we'll cancel immediately @@ -564,7 +594,7 @@ func verifyTarContent(tarPath string, expectedFiles []string) error { if err != nil { return err } - defer f.Close() + defer func() { _ = f.Close() }() tr := tar.NewReader(f) found := make(map[string]bool) @@ -677,12 +707,12 @@ func TestEncryptedArchiveRejectsWrongIdentity(t *testing.T) { } } -func decryptArchiveForTest(src, dst string, identity age.Identity) error { +func decryptArchiveForTest(src, dst string, identity age.Identity) (err error) { in, err := os.Open(src) if err != nil { return err } - defer in.Close() + defer closeIntoErr(&err, in, "close encrypted test archive") reader, err := age.Decrypt(in, identity) if err != nil { @@ -693,7 +723,7 @@ func decryptArchiveForTest(src, dst string, identity age.Identity) error { if err != nil { return err } - defer out.Close() + defer closeIntoErr(&err, out, "close decrypted test archive") if _, err := io.Copy(out, reader); err != nil { return err diff --git a/internal/backup/archiver_verification_test.go b/internal/backup/archiver_verification_test.go index c1536995..223822b6 100644 --- a/internal/backup/archiver_verification_test.go +++ b/internal/backup/archiver_verification_test.go @@ -246,14 +246,16 @@ func TestVerifyGzipArchive_CorruptedTar(t *testing.T) { if err != nil { t.Fatal(err) } - defer file.Close() + defer func() { _ = file.Close() }() gzipWriter := gzip.NewWriter(file) _, err = gzipWriter.Write([]byte("corrupted tar content")) if err != nil { t.Fatal(err) } - gzipWriter.Close() + if err := gzipWriter.Close(); err != nil { + t.Fatal(err) + } logger := logging.New(types.LogLevelInfo, false) archiver := &Archiver{logger: logger} @@ -352,7 +354,7 @@ func TestVerifyGzipArchive_ValidTarContent(t *testing.T) { if err != nil { t.Fatal(err) } - defer file.Close() + defer func() { _ = file.Close() }() gzipWriter := gzip.NewWriter(file) tarWriter := tar.NewWriter(gzipWriter) @@ -370,8 +372,12 @@ func TestVerifyGzipArchive_ValidTarContent(t *testing.T) { t.Fatal(err) } - tarWriter.Close() - gzipWriter.Close() + if err := tarWriter.Close(); err != nil { + t.Fatal(err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatal(err) + } logger := logging.New(types.LogLevelInfo, false) archiver := &Archiver{logger: logger} diff --git a/internal/backup/checksum.go b/internal/backup/checksum.go index 9b3ac7f2..c318dd8e 100644 --- a/internal/backup/checksum.go +++ b/internal/backup/checksum.go @@ -62,14 +62,14 @@ func ParseChecksumData(data []byte) (string, error) { } // GenerateChecksum calculates SHA256 checksum of a file -func GenerateChecksum(ctx context.Context, logger *logging.Logger, filePath string) (string, error) { +func GenerateChecksum(ctx context.Context, logger *logging.Logger, filePath string) (checksum string, err error) { logger.Debug("Generating SHA256 checksum for: %s", filePath) file, err := os.Open(filePath) if err != nil { return "", fmt.Errorf("failed to open file: %w", err) } - defer file.Close() + defer closeIntoErr(&err, file, "close checksum source file") hash := sha256.New() @@ -98,7 +98,7 @@ func GenerateChecksum(ctx context.Context, logger *logging.Logger, filePath stri } } - checksum := hex.EncodeToString(hash.Sum(nil)) + checksum = hex.EncodeToString(hash.Sum(nil)) logger.Debug("Generated checksum: %s", checksum) return checksum, nil } diff --git a/internal/backup/collector.go b/internal/backup/collector.go index b87366a3..aa3e6ddd 100644 --- a/internal/backup/collector.go +++ b/internal/backup/collector.go @@ -854,7 +854,7 @@ func (c *Collector) removeExistingSymlinkDestination(dest string) error { return nil } -func (c *Collector) copyRegularFile(src, dest, description string, info os.FileInfo) error { +func (c *Collector) copyRegularFile(src, dest, description string, info os.FileInfo) (err error) { if err := c.prepareCopyDestination(src, dest); err != nil { c.incFilesFailed() return err @@ -865,7 +865,7 @@ func (c *Collector) copyRegularFile(src, dest, description string, info os.FileI c.incFilesFailed() return fmt.Errorf("failed to open %s: %w", src, err) } - defer srcFile.Close() + defer closeIntoErr(&err, srcFile, "close source file") written, err := copyRegularFileContents(srcFile, src, dest) if err != nil { @@ -990,6 +990,7 @@ type commandRunOptions struct { critical bool logCollection bool handleSystemctlStatus bool + debugNonCritical bool } type commandRunResult struct { @@ -1047,6 +1048,25 @@ func (c *Collector) runAndClassifyCommand(ctx context.Context, spec CommandSpec, out, err := c.depRunCommand(runCtx, spec.Name, spec.Args...) result.output = out if err != nil { + if isContextCancellationError(runCtx, err) { + if isNonCriticalPveshDeadline(ctx, runCtx, spec, opts.critical) { + result.classification = commandRunNonCriticalFailure + result.outputSummary = summarizeCommandOutputText(string(out)) + timeoutSeconds := 0 + if c.config != nil { + timeoutSeconds = c.config.PveshTimeoutSeconds + } + if opts.debugNonCritical { + c.logger.Debug("Skipping %s: command `%s` timed out after %d seconds. Non-critical; backup continues. Output: %s", + opts.description, cmdString, timeoutSeconds, result.outputSummary) + } else { + c.logger.Warning("Skipping %s: command `%s` timed out after %d seconds. Non-critical; backup continues. Output: %s", + opts.description, cmdString, timeoutSeconds, result.outputSummary) + } + return result, nil + } + return result, err + } result.outputSummary = summarizeCommandOutputText(string(out)) if opts.critical { c.incFilesFailed() @@ -1123,6 +1143,13 @@ func (c *Collector) runAndClassifyCommand(ctx context.Context, spec CommandSpec, err, result.outputSummary, ) + } else if opts.debugNonCritical { + c.logger.Debug("Skipping %s: command `%s` failed (%v). Non-critical; backup continues. Output: %s", + opts.description, + cmdString, + err, + result.outputSummary, + ) } else { c.logger.Warning("Skipping %s: command `%s` failed (%v). Non-critical; backup continues. Ensure the required CLI is available and has proper permissions. Output: %s", opts.description, @@ -1138,6 +1165,16 @@ func (c *Collector) runAndClassifyCommand(ctx context.Context, spec CommandSpec, return result, nil } +func isNonCriticalPveshDeadline(parentCtx, runCtx context.Context, spec CommandSpec, critical bool) bool { + if parentCtx == nil || runCtx == nil { + return false + } + return spec.Name == "pvesh" && + !critical && + parentCtx.Err() == nil && + errors.Is(runCtx.Err(), context.DeadlineExceeded) +} + func (c *Collector) safeCmdOutput(ctx context.Context, spec CommandSpec, output, description string, critical bool) error { result, err := c.runAndClassifyCommand(ctx, spec, commandRunOptions{ output: output, @@ -1161,6 +1198,29 @@ func (c *Collector) safeCmdOutput(ctx context.Context, spec CommandSpec, output, return nil } +func (c *Collector) safeCmdOutputBestEffort(ctx context.Context, spec CommandSpec, output, description string) error { + result, err := c.runAndClassifyCommand(ctx, spec, commandRunOptions{ + output: output, + description: description, + caller: "safeCmdOutputBestEffort", + logCollection: true, + debugNonCritical: true, + }) + if err != nil { + return err + } + if result.classification != commandRunSucceeded { + return nil + } + + if err := c.writeReportFile(output, result.output); err != nil { + return err + } + + c.logger.Debug("Successfully collected %s via command: %s", description, spec.String()) + return nil +} + // safeCmdOutputWithPBSAuth executes a command with PBS authentication environment variables // This enables automatic authentication for proxmox-backup-client commands func (c *Collector) safeCmdOutputWithPBSAuth(ctx context.Context, spec CommandSpec, output, description string, critical bool) error { diff --git a/internal/backup/collector_bricks_pve.go b/internal/backup/collector_bricks_pve.go index 4218cd0c..00482356 100644 --- a/internal/backup/collector_bricks_pve.go +++ b/internal/backup/collector_bricks_pve.go @@ -124,8 +124,7 @@ func newPVERuntimeBricks() []collectionBrick { if err != nil { return err } - state.collector.collectPVEACLRuntime(ctx, commandsDir) - return nil + return state.collector.collectPVEACLRuntime(ctx, commandsDir) }, }, { @@ -136,8 +135,7 @@ func newPVERuntimeBricks() []collectionBrick { if err != nil { return err } - state.collector.collectPVEClusterRuntime(ctx, commandsDir, state.pve.clustered) - return nil + return state.collector.collectPVEClusterRuntime(ctx, commandsDir, state.pve.clustered) }, }, { diff --git a/internal/backup/collector_pbs.go b/internal/backup/collector_pbs.go index 44cd9553..5e7bfc41 100644 --- a/internal/backup/collector_pbs.go +++ b/internal/backup/collector_pbs.go @@ -284,7 +284,7 @@ func (c *Collector) collectPBSCoreRuntime(ctx context.Context, commandsDir strin func (c *Collector) collectPBSNodeRuntime(ctx context.Context, commandsDir string) error { if c.config.BackupPBSNodeConfig { - c.safeCmdOutput(ctx, + return c.safeCmdOutput(ctx, commandSpec("proxmox-backup-manager", "node", "show", "--output-format=json"), filepath.Join(commandsDir, "node_config.json"), "Node configuration", @@ -295,7 +295,7 @@ func (c *Collector) collectPBSNodeRuntime(ctx context.Context, commandsDir strin func (c *Collector) collectPBSNetworkRuntime(ctx context.Context, commandsDir string) error { if c.config.BackupPBSNetworkConfig { - c.safeCmdOutput(ctx, + return c.safeCmdOutput(ctx, commandSpec("proxmox-backup-manager", "network", "list", "--output-format=json"), filepath.Join(commandsDir, "network_list.json"), "Network configuration", @@ -327,11 +327,13 @@ func (c *Collector) collectPBSDatastoreStatusRuntime(ctx context.Context, comman continue } dsKey := ds.pathKey() - c.safeCmdOutput(ctx, + if err := c.safeCmdOutput(ctx, commandSpec("proxmox-backup-manager", "datastore", "show", cliName, "--output-format=json"), filepath.Join(commandsDir, fmt.Sprintf("datastore_%s_status.json", dsKey)), fmt.Sprintf("Datastore %s status", ds.Name), - false) + false); err != nil { + return err + } } return nil } @@ -577,45 +579,41 @@ func (c *Collector) collectPBSTapeDrivesRuntime(ctx context.Context, commandsDir if !enabled { return nil } - c.safeCmdOutput(ctx, + return c.safeCmdOutput(ctx, commandSpec("proxmox-tape", "drive", "list", "--output-format=json"), filepath.Join(commandsDir, "tape_drives.json"), "Tape drives", false) - return nil } func (c *Collector) collectPBSTapeChangersRuntime(ctx context.Context, commandsDir string, enabled bool) error { if !enabled { return nil } - c.safeCmdOutput(ctx, + return c.safeCmdOutput(ctx, commandSpec("proxmox-tape", "changer", "list", "--output-format=json"), filepath.Join(commandsDir, "tape_changers.json"), "Tape changers", false) - return nil } func (c *Collector) collectPBSTapePoolsRuntime(ctx context.Context, commandsDir string, enabled bool) error { if !enabled { return nil } - c.safeCmdOutput(ctx, + return c.safeCmdOutput(ctx, commandSpec("proxmox-tape", "pool", "list", "--output-format=json"), filepath.Join(commandsDir, "tape_pools.json"), "Tape pools", false) - return nil } func (c *Collector) collectPBSDisksRuntime(ctx context.Context, commandsDir string) error { - c.safeCmdOutput(ctx, + return c.safeCmdOutput(ctx, commandSpec("proxmox-backup-manager", "disk", "list", "--output-format=json"), filepath.Join(commandsDir, "disk_list.json"), "Disk list", false) - return nil } func (c *Collector) collectPBSCertInfoRuntime(ctx context.Context, commandsDir string) error { @@ -630,25 +628,23 @@ func (c *Collector) collectPBSTrafficControlRuntime(ctx context.Context, command if !c.config.BackupPBSTrafficControl { return nil } - c.safeCmdOutput(ctx, + return c.safeCmdOutput(ctx, commandSpec("proxmox-backup-manager", "traffic-control", "list", "--output-format=json"), filepath.Join(commandsDir, "traffic_control.json"), "Traffic control rules", false) - return nil } func (c *Collector) collectPBSRecentTasksRuntime(ctx context.Context, commandsDir string) error { - c.safeCmdOutput(ctx, + return c.safeCmdOutput(ctx, commandSpec("proxmox-backup-manager", "task", "list", "--limit", "50", "--output-format=json"), filepath.Join(commandsDir, "recent_tasks.json"), "Recent tasks", false) - return nil } func (c *Collector) collectPBSS3EndpointsRuntime(ctx context.Context, commandsDir string) ([]string, error) { - if !(c.config.BackupDatastoreConfigs && c.config.BackupPBSS3Endpoints) { + if !c.config.BackupDatastoreConfigs || !c.config.BackupPBSS3Endpoints { return nil, nil } raw, err := c.captureCommandOutput(ctx, @@ -667,7 +663,7 @@ func (c *Collector) collectPBSS3EndpointsRuntime(ctx context.Context, commandsDi } func (c *Collector) collectPBSS3EndpointBucketsRuntime(ctx context.Context, commandsDir string, endpointIDs []string) error { - if !(c.config.BackupDatastoreConfigs && c.config.BackupPBSS3Endpoints) { + if !c.config.BackupDatastoreConfigs || !c.config.BackupPBSS3Endpoints { return nil } for _, id := range uniqueSortedStrings(endpointIDs) { diff --git a/internal/backup/collector_pbs_datastore.go b/internal/backup/collector_pbs_datastore.go index da5715e1..858c0281 100644 --- a/internal/backup/collector_pbs_datastore.go +++ b/internal/backup/collector_pbs_datastore.go @@ -343,11 +343,13 @@ func (c *Collector) collectPBSDatastoreCLIConfigs(ctx context.Context, state *pb for _, ds := range state.datastores { dsKey := ds.pathKey() if cliName := ds.cliName(); cliName != "" && !ds.isOverride() { - c.safeCmdOutput(ctx, + if err := c.safeCmdOutput(ctx, commandSpec("proxmox-backup-manager", "datastore", "show", cliName, "--output-format=json"), filepath.Join(state.datastoreDir, fmt.Sprintf("%s_config.json", dsKey)), fmt.Sprintf("Datastore %s configuration", ds.Name), - false) + false); err != nil { + return err + } continue } c.logger.Debug("Skipping datastore CLI config for %s (path=%s): no PBS datastore identity", ds.Name, ds.Path) @@ -548,10 +550,6 @@ func (c *Collector) runPBSPXARStep(ctx context.Context, state *pbsPxarState, fn dsWorkers = 1 } - parentCtx := ctx - ctx, cancel := context.WithCancel(parentCtx) - defer cancel() - var ( wg sync.WaitGroup sem = make(chan struct{}, dsWorkers) @@ -578,7 +576,6 @@ func (c *Collector) runPBSPXARStep(ctx context.Context, state *pbsPxarState, fn errMu.Lock() if firstErr == nil { firstErr = err - cancel() } errMu.Unlock() } @@ -590,7 +587,7 @@ func (c *Collector) runPBSPXARStep(ctx context.Context, state *pbsPxarState, fn if firstErr != nil { return firstErr } - if err := parentCtx.Err(); err != nil { + if err := ctx.Err(); err != nil { return err } return nil @@ -665,15 +662,15 @@ func (c *Collector) collectPBSPXARMetadataForDatastore(ctx context.Context, ds p func (c *Collector) writePxarSubdirReport(ctx context.Context, target string, ds pbsDatastore, ioTimeout time.Duration) error { c.logger.Debug("Writing PXAR subdirectory report for datastore %s", ds.Name) var builder strings.Builder - builder.WriteString(fmt.Sprintf("# Datastore subdirectories in %s generated on %s\n", ds.Path, time.Now().Format(time.RFC1123))) - builder.WriteString(fmt.Sprintf("# Datastore: %s\n", ds.Name)) + fmt.Fprintf(&builder, "# Datastore subdirectories in %s generated on %s\n", ds.Path, time.Now().Format(time.RFC1123)) + fmt.Fprintf(&builder, "# Datastore: %s\n", ds.Name) entries, err := safefs.ReadDir(ctx, ds.Path, ioTimeout) if err != nil { if errors.Is(err, safefs.ErrTimeout) { return err } - builder.WriteString(fmt.Sprintf("# Unable to read datastore path: %v\n", err)) + fmt.Fprintf(&builder, "# Unable to read datastore path: %v\n", err) return c.writeReportFile(target, []byte(builder.String())) } @@ -702,8 +699,8 @@ func (c *Collector) writePxarListReport(ctx context.Context, target string, ds p basePath := filepath.Join(ds.Path, subDir) var builder strings.Builder - builder.WriteString(fmt.Sprintf("# List of .pxar files in %s generated on %s\n", basePath, time.Now().Format(time.RFC1123))) - builder.WriteString(fmt.Sprintf("# Datastore: %s, Subdirectory: %s\n", ds.Name, subDir)) + fmt.Fprintf(&builder, "# List of .pxar files in %s generated on %s\n", basePath, time.Now().Format(time.RFC1123)) + fmt.Fprintf(&builder, "# Datastore: %s, Subdirectory: %s\n", ds.Name, subDir) builder.WriteString("# Format: permissions size date name\n") entries, err := safefs.ReadDir(ctx, basePath, ioTimeout) @@ -711,7 +708,7 @@ func (c *Collector) writePxarListReport(ctx context.Context, target string, ds p if errors.Is(err, safefs.ErrTimeout) { return err } - builder.WriteString(fmt.Sprintf("# Unable to read directory: %v\n", err)) + fmt.Fprintf(&builder, "# Unable to read directory: %v\n", err) if writeErr := c.writeReportFile(target, []byte(builder.String())); writeErr != nil { return writeErr } @@ -756,11 +753,11 @@ func (c *Collector) writePxarListReport(ctx context.Context, target string, ds p builder.WriteString("# No .pxar files found\n") } else { for _, file := range files { - builder.WriteString(fmt.Sprintf("%s %d %s %s\n", + fmt.Fprintf(&builder, "%s %d %s %s\n", file.mode.String(), file.size, file.time.Format("2006-01-02 15:04:05"), - file.name)) + file.name) } } diff --git a/internal/backup/collector_pve.go b/internal/backup/collector_pve.go index 18313254..6353ea44 100644 --- a/internal/backup/collector_pve.go +++ b/internal/backup/collector_pve.go @@ -463,17 +463,21 @@ func (c *Collector) collectPVECoreRuntime(ctx context.Context, commandsDir strin return fmt.Errorf("failed to get PVE version (critical): %w", err) } - c.safeCmdOutput(ctx, + if err := c.safeCmdOutput(ctx, commandSpec("pvenode", "config", "get"), filepath.Join(commandsDir, "node_config.txt"), "Node configuration", - false) + false); err != nil { + return err + } - c.safeCmdOutput(ctx, + if err := c.safeCmdOutput(ctx, commandSpec("pvesh", "get", "/version", "--output-format=json"), filepath.Join(commandsDir, "api_version.json"), "API version", - false) + false); err != nil { + return err + } if nodeData, err := c.captureCommandOutput(ctx, commandSpec("pvesh", "get", "/nodes", "--output-format=json"), @@ -499,68 +503,90 @@ func (c *Collector) collectPVECoreRuntime(ctx context.Context, commandsDir strin return nil } -func (c *Collector) collectPVEACLRuntime(ctx context.Context, commandsDir string) { +func (c *Collector) collectPVEACLRuntime(ctx context.Context, commandsDir string) error { if !c.config.BackupPVEACL { - return + return nil } - c.safeCmdOutput(ctx, + if err := c.safeCmdOutput(ctx, commandSpec("pveum", "user", "list", "--output-format=json"), filepath.Join(commandsDir, "pve_users.json"), "PVE users", - false) - c.safeCmdOutput(ctx, + false); err != nil { + return err + } + if err := c.safeCmdOutput(ctx, commandSpec("pveum", "group", "list", "--output-format=json"), filepath.Join(commandsDir, "pve_groups.json"), "PVE groups", - false) - c.safeCmdOutput(ctx, + false); err != nil { + return err + } + if err := c.safeCmdOutput(ctx, commandSpec("pveum", "role", "list", "--output-format=json"), filepath.Join(commandsDir, "pve_roles.json"), "PVE roles", - false) - c.safeCmdOutput(ctx, + false); err != nil { + return err + } + if err := c.safeCmdOutput(ctx, commandSpec("pveum", "pool", "list", "--output-format=json"), filepath.Join(commandsDir, "pools.json"), "PVE resource pools", - false) + false); err != nil { + return err + } + return nil } -func (c *Collector) collectPVEClusterRuntime(ctx context.Context, commandsDir string, clustered bool) { +func (c *Collector) collectPVEClusterRuntime(ctx context.Context, commandsDir string, clustered bool) error { if clustered && c.config.BackupClusterConfig { - c.safeCmdOutput(ctx, + if err := c.safeCmdOutput(ctx, commandSpec("pvecm", "status"), filepath.Join(commandsDir, "cluster_status.txt"), "Cluster status", - false) - c.safeCmdOutput(ctx, + false); err != nil { + return err + } + if err := c.safeCmdOutput(ctx, commandSpec("pvecm", "nodes"), filepath.Join(commandsDir, "cluster_nodes.txt"), "Cluster nodes", - false) - c.safeCmdOutput(ctx, + false); err != nil { + return err + } + if err := c.safeCmdOutput(ctx, commandSpec("pvesh", "get", "/cluster/ha/status", "--output-format=json"), filepath.Join(commandsDir, "ha_status.json"), "HA status", - false) - c.safeCmdOutput(ctx, + false); err != nil { + return err + } + if err := c.safeCmdOutput(ctx, commandSpec("pvesh", "get", "/cluster/mapping/pci", "--output-format=json"), filepath.Join(commandsDir, "mapping_pci.json"), "PCI resource mappings", - false) - c.safeCmdOutput(ctx, + false); err != nil { + return err + } + if err := c.safeCmdOutput(ctx, commandSpec("pvesh", "get", "/cluster/mapping/usb", "--output-format=json"), filepath.Join(commandsDir, "mapping_usb.json"), "USB resource mappings", - false) - c.safeCmdOutput(ctx, + false); err != nil { + return err + } + if err := c.safeCmdOutput(ctx, commandSpec("pvesh", "get", "/cluster/mapping/dir", "--output-format=json"), filepath.Join(commandsDir, "mapping_dir.json"), "Directory resource mappings", - false) + false); err != nil { + return err + } } else if clustered && !c.config.BackupClusterConfig { c.logger.Debug("Skipping cluster runtime commands: BACKUP_CLUSTER_CONFIG=false (clustered=%v)", clustered) } + return nil } func (c *Collector) collectPVEStorageRuntime(ctx context.Context, commandsDir string, info *pveRuntimeInfo) error { @@ -570,11 +596,13 @@ func (c *Collector) collectPVEStorageRuntime(ctx context.Context, commandsDir st nodeName = hostname } - c.safeCmdOutput(ctx, + if err := c.safeCmdOutput(ctx, commandSpec("pvesh", "get", fmt.Sprintf("/nodes/%s/disks/list", nodeName), "--output-format=json"), filepath.Join(commandsDir, "disks_list.json"), "Disks list", - false) + false); err != nil { + return err + } storageJSONPath := filepath.Join(commandsDir, "storage_status.json") if storageData, err := c.captureCommandOutput(ctx, @@ -595,11 +623,13 @@ func (c *Collector) collectPVEStorageRuntime(ctx context.Context, commandsDir st } } - c.safeCmdOutput(ctx, + if err := c.safeCmdOutput(ctx, commandSpec("pvesm", "status"), filepath.Join(commandsDir, "pvesm_status.txt"), "Storage manager status", - false) + false); err != nil { + return err + } return nil } @@ -729,17 +759,21 @@ func (c *Collector) collectPVEGuestInventory(ctx context.Context) error { nodeName = hostname } - c.safeCmdOutput(ctx, + if err := c.safeCmdOutput(ctx, commandSpec("pvesh", "get", fmt.Sprintf("/nodes/%s/qemu", nodeName), "--output-format=json"), filepath.Join(commandsDir, "qemu_vms.json"), "QEMU VMs list", - false) + false); err != nil { + return err + } - c.safeCmdOutput(ctx, + if err := c.safeCmdOutput(ctx, commandSpec("pvesh", "get", fmt.Sprintf("/nodes/%s/lxc", nodeName), "--output-format=json"), filepath.Join(commandsDir, "lxc_containers.json"), "LXC containers list", - false) + false); err != nil { + return err + } return nil } @@ -1298,13 +1332,13 @@ func (c *Collector) writePVEStorageSummary(ctx context.Context, storages []pveSt summary.WriteString("\n# Format: TYPE|NAME|PATH|CONTENT\n\n") for _, storage := range storages { - summary.WriteString(fmt.Sprintf("%s|%s|%s|%s\n", + fmt.Fprintf(&summary, "%s|%s|%s|%s\n", storage.Type, storage.Name, storage.Path, - storage.Content)) + storage.Content) } - summary.WriteString(fmt.Sprintf("\n# Total datastores processed: %d\n", len(storages))) + fmt.Fprintf(&summary, "\n# Total datastores processed: %d\n", len(storages)) return c.writeReportFile(filepath.Join(c.pveDatastoresBaseDir(), "detected_datastores.txt"), []byte(summary.String())) } @@ -1519,7 +1553,7 @@ func newPatternWriter(storageName, storagePath, analysisDir, pattern string, dry time.Now().Format(time.RFC3339), ) if _, err := writer.WriteString(header); err != nil { - file.Close() + _ = file.Close() return nil, err } return &patternWriter{ @@ -1611,7 +1645,7 @@ func (c *Collector) copyBackupSample(ctx context.Context, src, destDir, descript return c.safeCopyFile(ctx, src, dest, description) } -func (c *Collector) writePatternSummary(storage pveStorageEntry, analysisDir string, writers []*patternWriter, totalFiles, totalSize int64) error { +func (c *Collector) writePatternSummary(storage pveStorageEntry, analysisDir string, writers []*patternWriter, totalFiles, totalSize int64) (err error) { // Skip file creation in dry-run mode if c.dryRun { c.logger.Debug("[DRY RUN] Would write backup summary for datastore: %s", storage.Name) @@ -1623,35 +1657,65 @@ func (c *Collector) writePatternSummary(storage pveStorageEntry, analysisDir str if err != nil { return err } - defer file.Close() + defer closeIntoErr(&err, file, "close PVE backup summary") writer := bufio.NewWriter(file) - fmt.Fprintf(writer, "# PVE Backup Files Summary for datastore: %s\n", storage.Name) - fmt.Fprintf(writer, "# Path: %s\n", storage.Path) - fmt.Fprintf(writer, "# Generated on: %s\n\n", time.Now().Format(time.RFC3339)) + writeSummaryf := func(format string, args ...any) error { + _, err := fmt.Fprintf(writer, format, args...) + return err + } + if err := writeSummaryf("# PVE Backup Files Summary for datastore: %s\n", storage.Name); err != nil { + return err + } + if err := writeSummaryf("# Path: %s\n", storage.Path); err != nil { + return err + } + if err := writeSummaryf("# Generated on: %s\n\n", time.Now().Format(time.RFC3339)); err != nil { + return err + } for _, w := range writers { - fmt.Fprintf(writer, "## Files matching pattern: %s\n", w.pattern) + if err := writeSummaryf("## Files matching pattern: %s\n", w.pattern); err != nil { + return err + } if w.count == 0 { - fmt.Fprintln(writer, " No files found") - fmt.Fprintln(writer) + if _, err := fmt.Fprintln(writer, " No files found"); err != nil { + return err + } + if _, err := fmt.Fprintln(writer); err != nil { + return err + } continue } - fmt.Fprintf(writer, " Files: %d\n", w.count) + if err := writeSummaryf(" Files: %d\n", w.count); err != nil { + return err + } if w.errorCount > 0 { - fmt.Fprintf(writer, " Successfully analyzed: %d\n", w.count-w.errorCount) - fmt.Fprintf(writer, " Files with errors: %d\n", w.errorCount) + if err := writeSummaryf(" Successfully analyzed: %d\n", w.count-w.errorCount); err != nil { + return err + } + if err := writeSummaryf(" Files with errors: %d\n", w.errorCount); err != nil { + return err + } + } + if err := writeSummaryf(" Total size: %s\n\n", FormatBytes(w.totalSize)); err != nil { + return err } - fmt.Fprintf(writer, " Total size: %s\n\n", FormatBytes(w.totalSize)) } - fmt.Fprintln(writer, "## Overall Summary") - fmt.Fprintf(writer, "Total backup files: %d\n", totalFiles) - fmt.Fprintf(writer, "Total backup size: %s\n", FormatBytes(totalSize)) + if _, err := fmt.Fprintln(writer, "## Overall Summary"); err != nil { + return err + } + if err := writeSummaryf("Total backup files: %d\n", totalFiles); err != nil { + return err + } + if err := writeSummaryf("Total backup size: %s\n", FormatBytes(totalSize)); err != nil { + return err + } if err := writer.Flush(); err != nil { return err } - return file.Close() + return nil } func (c *Collector) collectPVECephConfigSnapshot(ctx context.Context) error { @@ -1712,11 +1776,13 @@ func (c *Collector) collectPVECephRuntime(ctx context.Context) error { } for _, command := range commands { - c.captureCommandOutput(ctx, + if _, err := c.captureCommandOutput(ctx, command.cmd, filepath.Join(cephDir, command.file), command.desc, - false) + false); err != nil { + return err + } } return nil @@ -1971,7 +2037,7 @@ func (c *Collector) parseStorageConfigEntries() []pveStorageEntry { if err != nil { return nil } - defer file.Close() + defer func() { _ = file.Close() }() scanner := bufio.NewScanner(file) var ( diff --git a/internal/backup/collector_pve_additional_test.go b/internal/backup/collector_pve_additional_test.go index 8c4e915c..70d8cfc8 100644 --- a/internal/backup/collector_pve_additional_test.go +++ b/internal/backup/collector_pve_additional_test.go @@ -21,7 +21,7 @@ func TestPatternWriterWrite_DryRunCountsOnly(t *testing.T) { if err != nil { t.Fatalf("CreateTemp: %v", err) } - defer f.Close() + defer func() { _ = f.Close() }() if _, err := f.WriteString("payload"); err != nil { t.Fatalf("WriteString: %v", err) diff --git a/internal/backup/collector_pve_util_test.go b/internal/backup/collector_pve_util_test.go index b6451873..a3c6305a 100644 --- a/internal/backup/collector_pve_util_test.go +++ b/internal/backup/collector_pve_util_test.go @@ -719,6 +719,9 @@ func TestIsClusteredPVE(t *testing.T) { cfg.PVEConfigPath = pveDir cfg.CorosyncConfigPath = "" collector := NewCollector(logger, cfg, tmpDir, "pve", false) + collector.deps.LookPath = func(string) (string, error) { + return "", os.ErrNotExist + } clustered, err := collector.isClusteredPVE(context.Background()) if err != nil { diff --git a/internal/backup/collector_system.go b/internal/backup/collector_system.go index 13c4ab93..78158f3b 100644 --- a/internal/backup/collector_system.go +++ b/internal/backup/collector_system.go @@ -57,6 +57,79 @@ func (c *Collector) detectZFSUsage() (bool, string) { return true, strings.Join(indicators, ",") } +func (c *Collector) collectBestEffortProbe(ctx context.Context, spec CommandSpec, output, description string, available func() (bool, string)) error { + if err := ctx.Err(); err != nil { + return err + } + if _, err := c.depLookPath(spec.Name); err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + c.logger.Debug("Skipping %s: command %s not available: %v", description, spec.Name, err) + return nil + } + if available != nil { + ok, reason := available() + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + if !ok { + if reason == "" { + reason = "required capability not detected" + } + c.logger.Debug("Skipping %s: %s", description, reason) + return nil + } + } + if err := c.safeCmdOutputBestEffort(ctx, spec, output, description); err != nil { + if isContextCancellationError(ctx, err) { + return err + } + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + c.logger.Debug("Skipping %s: %v", description, err) + return nil + } + if err := ctx.Err(); err != nil { + return err + } + return nil +} + +func (c *Collector) systemctlProbeAvailable() (bool, string) { + if _, err := c.depStat(c.systemPath("/run/systemd/system")); err == nil { + return true, "" + } + if data, err := os.ReadFile(c.systemPath("/proc/1/comm")); err == nil && strings.TrimSpace(string(data)) == "systemd" { + return true, "" + } + if ctxInfo := c.depDetectUnprivilegedContainer(); ctxInfo.Detected { + return false, "systemd runtime not detected in container: " + ctxInfo.Details + } + return false, "systemd runtime not detected" +} + +func (c *Collector) dmidecodeProbeAvailable() (bool, string) { + if os.Geteuid() != 0 { + return false, fmt.Sprintf("dmidecode requires root privileges (euid=%d)", os.Geteuid()) + } + if _, err := c.depStat(c.systemPath("/sys/firmware/dmi/tables")); err == nil { + return true, "" + } + if _, err := c.depStat(c.systemPath("/dev/mem")); err == nil { + return true, "" + } + return false, "DMI tables not accessible" +} + +func (c *Collector) sensorsProbeAvailable() (bool, string) { + if _, err := c.depStat(c.systemPath("/sys/class/hwmon")); err == nil { + return true, "" + } + return false, "hardware sensor sysfs not detected" +} + // CollectSystemInfo collects common system information (both PVE and PBS) func (c *Collector) CollectSystemInfo(ctx context.Context) error { c.logger.Info("Collecting system information") @@ -526,11 +599,13 @@ func (c *Collector) collectSystemCoreRuntime(ctx context.Context, commandsDir st return fmt.Errorf("failed to get kernel version (critical): %w", err) } - c.safeCmdOutput(ctx, + if err := c.collectBestEffortProbe(ctx, commandSpec("hostname", "-f"), filepath.Join(commandsDir, "hostname.txt"), "Hostname", - false) + nil); err != nil { + return err + } return nil } @@ -608,16 +683,20 @@ func (c *Collector) collectSystemNetworkLinksRuntime(ctx context.Context, comman } func (c *Collector) collectSystemNetworkNeighborsRuntime(ctx context.Context, commandsDir string) error { - c.safeCmdOutput(ctx, + if err := c.safeCmdOutput(ctx, commandSpec("ip", "neigh", "show"), filepath.Join(commandsDir, "ip_neigh.txt"), "Neighbor table", - false) - c.safeCmdOutput(ctx, + false); err != nil { + return err + } + if err := c.safeCmdOutput(ctx, commandSpec("ip", "-6", "neigh", "show"), filepath.Join(commandsDir, "ip6_neigh.txt"), "Neighbor table (IPv6)", - false) + false); err != nil { + return err + } return nil } @@ -692,11 +771,13 @@ func (c *Collector) collectSystemStorageMountsRuntime(ctx context.Context, comma return err } - c.safeCmdOutput(ctx, + if err := c.safeCmdOutput(ctx, commandSpec("mount"), filepath.Join(commandsDir, "mount.txt"), "Mounted filesystems", - false) + false); err != nil { + return err + } return nil } @@ -752,11 +833,13 @@ func (c *Collector) collectSystemComputeBusInventoryRuntime(ctx context.Context, return err } - c.safeCmdOutput(ctx, + if err := c.collectBestEffortProbe(ctx, commandSpec("lsusb"), filepath.Join(commandsDir, "lsusb.txt"), "USB devices", - false) + nil); err != nil { + return err + } return nil } @@ -766,17 +849,19 @@ func (c *Collector) collectSystemServicesRuntime(ctx context.Context, commandsDi return nil } - if err := c.collectCommandMulti(ctx, + if err := c.collectBestEffortProbe(ctx, commandSpec("systemctl", "list-units", "--type=service", "--all"), filepath.Join(commandsDir, "systemctl_services.txt"), "Systemd services", - false); err != nil { + c.systemctlProbeAvailable); err != nil { return err } - c.safeCmdOutput(ctx, commandSpec("systemctl", "list-unit-files", "--type=service"), + if err := c.collectBestEffortProbe(ctx, commandSpec("systemctl", "list-unit-files", "--type=service"), filepath.Join(commandsDir, "systemctl_service_files.txt"), - "Systemd service files", false) + "Systemd service files", c.systemctlProbeAvailable); err != nil { + return err + } return nil } @@ -875,10 +960,13 @@ func (c *Collector) collectSystemFirewallUFWRuntime(ctx context.Context, command commandSpec("ufw", "status", "verbose"), filepath.Join(commandsDir, "ufw_status.txt"), "UFW status") - c.collectCommandOptional(ctx, + if err := c.collectBestEffortProbe(ctx, commandSpec("systemctl", "status", "--no-pager", "ufw"), filepath.Join(commandsDir, "systemctl_ufw.txt"), - "systemctl ufw") + "systemctl ufw", + c.systemctlProbeAvailable); err != nil { + return err + } return nil } @@ -896,10 +984,13 @@ func (c *Collector) collectSystemFirewallFirewalldRuntime(ctx context.Context, c commandSpec("firewall-cmd", "--list-all"), filepath.Join(commandsDir, "firewalld_list_all.txt"), "firewalld rules") - c.collectCommandOptional(ctx, + if err := c.collectBestEffortProbe(ctx, commandSpec("systemctl", "status", "--no-pager", "firewalld"), filepath.Join(commandsDir, "systemctl_firewalld.txt"), - "systemctl firewalld") + "systemctl firewalld", + c.systemctlProbeAvailable); err != nil { + return err + } return nil } @@ -909,11 +1000,13 @@ func (c *Collector) collectSystemKernelModulesRuntime(ctx context.Context, comma return nil } - c.safeCmdOutput(ctx, + if err := c.collectBestEffortProbe(ctx, commandSpec("lsmod"), filepath.Join(commandsDir, "lsmod.txt"), "Loaded kernel modules", - false) + nil); err != nil { + return err + } return nil } @@ -922,12 +1015,11 @@ func (c *Collector) collectSystemSysctlRuntime(ctx context.Context, commandsDir return nil } - c.safeCmdOutput(ctx, + return c.safeCmdOutput(ctx, commandSpec("sysctl", "-a"), filepath.Join(commandsDir, "sysctl.txt"), "Sysctl values", false) - return nil } func (c *Collector) collectSystemZFSRuntime(ctx context.Context, commandsDir string) error { @@ -980,25 +1072,31 @@ func (c *Collector) collectSystemLVMRuntime(ctx context.Context, commandsDir str return err } if _, err := c.depLookPath("pvs"); err == nil { - c.safeCmdOutput(ctx, + if err := c.safeCmdOutput(ctx, commandSpec("pvs"), filepath.Join(commandsDir, "lvm_pvs.txt"), "LVM physical volumes", - false) + false); err != nil { + return err + } } if _, err := c.depLookPath("vgs"); err == nil { - c.safeCmdOutput(ctx, + if err := c.safeCmdOutput(ctx, commandSpec("vgs"), filepath.Join(commandsDir, "lvm_vgs.txt"), "LVM volume groups", - false) + false); err != nil { + return err + } } if _, err := c.depLookPath("lvs"); err == nil { - c.safeCmdOutput(ctx, + if err := c.safeCmdOutput(ctx, commandSpec("lvs"), filepath.Join(commandsDir, "lvm_lvs.txt"), "LVM logical volumes", - false) + false); err != nil { + return err + } } return nil } @@ -1020,8 +1118,8 @@ func (c *Collector) buildNetworkReport(ctx context.Context, commandsDir string) now := time.Now().Format(time.RFC3339) hostname, _ := os.Hostname() b.WriteString("Proxsave Network Report\n") - b.WriteString(fmt.Sprintf("Timestamp: %s\n", now)) - b.WriteString(fmt.Sprintf("Hostname: %s\n", hostname)) + fmt.Fprintf(&b, "Timestamp: %s\n", now) + fmt.Fprintf(&b, "Hostname: %s\n", hostname) b.WriteString("\n") appendFile := func(title, path string) { @@ -1032,7 +1130,7 @@ func (c *Collector) buildNetworkReport(ctx context.Context, commandsDir string) if err != nil || len(data) == 0 { return } - b.WriteString(fmt.Sprintf("## %s (%s)\n", title, path)) + fmt.Fprintf(&b, "## %s (%s)\n", title, path) b.Write(data) if !strings.HasSuffix(string(data), "\n") { b.WriteString("\n") @@ -1160,18 +1258,22 @@ func (c *Collector) collectKernelInfo(ctx context.Context) error { c.logger.Debug("Collecting kernel information into %s", commandsDir) // Kernel command line - c.safeCmdOutput(ctx, + if err := c.safeCmdOutput(ctx, commandSpec("cat", c.systemPath("/proc/cmdline")), filepath.Join(commandsDir, "kernel_cmdline.txt"), "Kernel command line", - false) + false); err != nil { + return err + } // Kernel version details - c.safeCmdOutput(ctx, + if err := c.safeCmdOutput(ctx, commandSpec("cat", c.systemPath("/proc/version")), filepath.Join(commandsDir, "kernel_version.txt"), "Kernel version details", - false) + false); err != nil { + return err + } c.logger.Debug("Kernel information snapshot completed") return nil @@ -1182,30 +1284,31 @@ func (c *Collector) collectHardwareInfo(ctx context.Context) error { commandsDir := c.proxsaveCommandsDir("system") c.logger.Debug("Collecting hardware inventory into %s", commandsDir) - // DMI decode (requires root) - c.safeCmdOutput(ctx, + if err := c.collectBestEffortProbe(ctx, commandSpec("dmidecode"), filepath.Join(commandsDir, "dmidecode.txt"), "Hardware DMI information", - false) + c.dmidecodeProbeAvailable); err != nil { + return err + } - // Hardware sensors (if available) - if _, err := c.depStat(c.systemPath("/usr/bin/sensors")); err == nil { - c.safeCmdOutput(ctx, - commandSpec("sensors"), - filepath.Join(commandsDir, "sensors.txt"), - "Hardware sensors", - false) + if err := c.collectBestEffortProbe(ctx, + commandSpec("sensors"), + filepath.Join(commandsDir, "sensors.txt"), + "Hardware sensors", + c.sensorsProbeAvailable); err != nil { + return err } // SMART status for disks (if available) if _, err := c.depStat(c.systemPath("/usr/sbin/smartctl")); err == nil { - // Get list of disks - c.safeCmdOutput(ctx, + if err := c.collectBestEffortProbe(ctx, commandSpec("smartctl", "--scan"), filepath.Join(commandsDir, "smartctl_scan.txt"), "SMART scan", - false) + nil); err != nil { + return err + } } c.logger.Debug("Hardware information snapshot completed") diff --git a/internal/backup/collector_system_test.go b/internal/backup/collector_system_test.go index 2983a478..3da01a2f 100644 --- a/internal/backup/collector_system_test.go +++ b/internal/backup/collector_system_test.go @@ -1,6 +1,7 @@ package backup import ( + "bytes" "context" "errors" "os" @@ -28,6 +29,170 @@ func TestEnsureSystemPathAddsDefaults(t *testing.T) { } } +func TestCollectSystemKernelModulesRuntimeBestEffort(t *testing.T) { + var log bytes.Buffer + logger := logging.New(types.LogLevelDebug, false) + logger.SetOutput(&log) + + tempDir := t.TempDir() + config := GetDefaultCollectorConfig() + calls := 0 + collector := NewCollectorWithDeps(logger, config, tempDir, types.ProxmoxUnknown, false, CollectorDeps{ + LookPath: func(name string) (string, error) { + if name == "lsmod" { + return "/usr/sbin/lsmod", nil + } + return "", os.ErrNotExist + }, + RunCommand: func(ctx context.Context, name string, args ...string) ([]byte, error) { + if name != "lsmod" { + t.Fatalf("unexpected command %s", name) + } + calls++ + return []byte("lsmod failed"), errors.New("lsmod failed") + }, + DetectUnprivilegedContainer: func() (bool, string) { return false, "" }, + }) + + commandsDir := filepath.Join(tempDir, "commands") + if err := collector.collectSystemKernelModulesRuntime(context.Background(), commandsDir); err != nil { + t.Fatalf("collectSystemKernelModulesRuntime returned error: %v", err) + } + if calls != 1 { + t.Fatalf("lsmod calls=%d; want 1", calls) + } + if logger.WarningCount() != 0 { + t.Fatalf("expected lsmod failure to stay below warning level, warnings=%d log=%s", logger.WarningCount(), log.String()) + } + if _, err := os.Stat(filepath.Join(commandsDir, "lsmod.txt")); !os.IsNotExist(err) { + t.Fatalf("expected no lsmod output file on failure, stat err: %v", err) + } +} + +func TestCollectSystemKernelModulesRuntimePropagatesCommandCancellation(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + logger.SetOutput(&bytes.Buffer{}) + + tempDir := t.TempDir() + config := GetDefaultCollectorConfig() + collector := NewCollectorWithDeps(logger, config, tempDir, types.ProxmoxUnknown, false, CollectorDeps{ + LookPath: func(name string) (string, error) { + if name == "lsmod" { + return "/usr/sbin/lsmod", nil + } + return "", os.ErrNotExist + }, + RunCommand: func(ctx context.Context, name string, args ...string) ([]byte, error) { + if name != "lsmod" { + t.Fatalf("unexpected command %s", name) + } + return nil, context.Canceled + }, + DetectUnprivilegedContainer: func() (bool, string) { return false, "" }, + }) + + err := collector.collectSystemKernelModulesRuntime(context.Background(), filepath.Join(tempDir, "commands")) + if !errors.Is(err, context.Canceled) { + t.Fatalf("collectSystemKernelModulesRuntime error=%v; want %v", err, context.Canceled) + } +} + +func TestCollectSystemKernelModulesRuntimePropagatesCommandDeadline(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + logger.SetOutput(&bytes.Buffer{}) + + tempDir := t.TempDir() + config := GetDefaultCollectorConfig() + collector := NewCollectorWithDeps(logger, config, tempDir, types.ProxmoxUnknown, false, CollectorDeps{ + LookPath: func(name string) (string, error) { + if name == "lsmod" { + return "/usr/sbin/lsmod", nil + } + return "", os.ErrNotExist + }, + RunCommand: func(ctx context.Context, name string, args ...string) ([]byte, error) { + if name != "lsmod" { + t.Fatalf("unexpected command %s", name) + } + return nil, context.DeadlineExceeded + }, + DetectUnprivilegedContainer: func() (bool, string) { return false, "" }, + }) + + err := collector.collectSystemKernelModulesRuntime(context.Background(), filepath.Join(tempDir, "commands")) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("collectSystemKernelModulesRuntime error=%v; want %v", err, context.DeadlineExceeded) + } +} + +func TestCollectBestEffortProbePropagatesCanceledContext(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + logger.SetOutput(&bytes.Buffer{}) + + collector := NewCollector(logger, GetDefaultCollectorConfig(), t.TempDir(), types.ProxmoxUnknown, false) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := collector.collectBestEffortProbe(ctx, commandSpec("lsusb"), filepath.Join(t.TempDir(), "lsusb.txt"), "USB devices", nil) + if !errors.Is(err, context.Canceled) { + t.Fatalf("collectBestEffortProbe error=%v; want %v", err, context.Canceled) + } +} + +func TestCollectHardwareInfoSmartctlScanBestEffort(t *testing.T) { + var log bytes.Buffer + logger := logging.New(types.LogLevelDebug, false) + logger.SetOutput(&log) + + tempDir := t.TempDir() + smartctlMarker := filepath.Join(tempDir, "smartctl") + if err := os.WriteFile(smartctlMarker, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("write smartctl marker: %v", err) + } + smartctlInfo, err := os.Stat(smartctlMarker) + if err != nil { + t.Fatalf("stat smartctl marker: %v", err) + } + config := GetDefaultCollectorConfig() + calls := 0 + collector := NewCollectorWithDeps(logger, config, tempDir, types.ProxmoxUnknown, false, CollectorDeps{ + LookPath: func(name string) (string, error) { + if name == "smartctl" { + return "/usr/sbin/smartctl", nil + } + return "", os.ErrNotExist + }, + RunCommand: func(ctx context.Context, name string, args ...string) ([]byte, error) { + if name != "smartctl" || len(args) != 1 || args[0] != "--scan" { + t.Fatalf("unexpected command %s %v", name, args) + } + calls++ + return []byte("smartctl failed"), errors.New("smartctl failed") + }, + Stat: func(path string) (os.FileInfo, error) { + if strings.HasSuffix(path, "/usr/sbin/smartctl") { + return smartctlInfo, nil + } + return nil, os.ErrNotExist + }, + DetectUnprivilegedContainer: func() (bool, string) { return false, "" }, + }) + + if err := collector.collectHardwareInfo(context.Background()); err != nil { + t.Fatalf("collectHardwareInfo returned error: %v", err) + } + if calls != 1 { + t.Fatalf("smartctl calls=%d; want 1", calls) + } + if logger.WarningCount() != 0 { + t.Fatalf("expected smartctl failure to stay below warning level, warnings=%d log=%s", logger.WarningCount(), log.String()) + } + output := filepath.Join(collector.proxsaveCommandsDir("system"), "smartctl_scan.txt") + if _, err := os.Stat(output); !os.IsNotExist(err) { + t.Fatalf("expected no smartctl output file on failure, stat err: %v", err) + } +} + func TestEnsureSystemPathDeduplicates(t *testing.T) { t.Setenv("PATH", "/usr/bin:/usr/bin:/usr/sbin:/usr/sbin") diff --git a/internal/backup/collector_test.go b/internal/backup/collector_test.go index c4c92379..7086e14f 100644 --- a/internal/backup/collector_test.go +++ b/internal/backup/collector_test.go @@ -187,9 +187,15 @@ func TestCollectorSafeCopyDir(t *testing.T) { // Create test source directory with files srcDir := filepath.Join(tempDir, "source") - os.MkdirAll(filepath.Join(srcDir, "subdir"), 0755) - os.WriteFile(filepath.Join(srcDir, "file1.txt"), []byte("content1"), 0644) - os.WriteFile(filepath.Join(srcDir, "subdir", "file2.txt"), []byte("content2"), 0644) + if err := os.MkdirAll(filepath.Join(srcDir, "subdir"), 0755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + if err := os.WriteFile(filepath.Join(srcDir, "file1.txt"), []byte("content1"), 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + if err := os.WriteFile(filepath.Join(srcDir, "subdir", "file2.txt"), []byte("content2"), 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } if err := os.Chmod(srcDir, 0700); err != nil { t.Fatalf("Failed to chmod source dir: %v", err) } @@ -378,7 +384,9 @@ func TestCollectorDryRun(t *testing.T) { // Create a test file and try to copy it srcFile := filepath.Join(tempDir, "source.txt") - os.WriteFile(srcFile, []byte("test"), 0644) + if err := os.WriteFile(srcFile, []byte("test"), 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } destFile := filepath.Join(tempDir, "dryrun", "dest.txt") ctx := context.Background() @@ -486,7 +494,9 @@ func TestGetStats(t *testing.T) { // Perform an operation testDir := filepath.Join(tempDir, "test") - collector.ensureDir(testDir) + if err := collector.ensureDir(testDir); err != nil { + t.Fatalf("ensureDir failed: %v", err) + } // Check stats updated stats = collector.GetStats() @@ -1418,6 +1428,59 @@ func TestSafeCmdOutputHonorsContextCancellation(t *testing.T) { } } +func TestSafeCmdOutputSwallowsNonCriticalPveshDeadline(t *testing.T) { + logger := logging.New(types.LogLevelWarning, false) + cfg := GetDefaultCollectorConfig() + cfg.PveshTimeoutSeconds = 1 + tmp := t.TempDir() + deps := CollectorDeps{ + LookPath: func(string) (string, error) { return "/usr/bin/pvesh", nil }, + RunCommand: func(ctx context.Context, name string, args ...string) ([]byte, error) { + if name != "pvesh" { + t.Fatalf("unexpected command %s", name) + } + <-ctx.Done() + return []byte("timeout"), ctx.Err() + }, + } + c := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) + + output := filepath.Join(tmp, "pvesh.txt") + err := c.safeCmdOutput(context.Background(), commandSpec("pvesh", "get", "/nodes"), output, "pvesh nodes", false) + if err != nil { + t.Fatalf("expected non-critical pvesh timeout to be skipped, got %v", err) + } + if _, err := os.Stat(output); !os.IsNotExist(err) { + t.Fatalf("expected no output file on timeout, stat err=%v", err) + } + if logger.WarningCount() != 1 { + t.Fatalf("expected one warning for skipped pvesh timeout, got %d", logger.WarningCount()) + } +} + +func TestSafeCmdOutputPropagatesParentCancellationDuringPvesh(t *testing.T) { + logger := logging.New(types.LogLevelError, false) + cfg := GetDefaultCollectorConfig() + cfg.PveshTimeoutSeconds = 15 + tmp := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + deps := CollectorDeps{ + LookPath: func(string) (string, error) { return "/usr/bin/pvesh", nil }, + RunCommand: func(runCtx context.Context, name string, args ...string) ([]byte, error) { + cancel() + <-runCtx.Done() + return nil, runCtx.Err() + }, + } + c := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) + + err := c.safeCmdOutput(ctx, commandSpec("pvesh", "get", "/nodes"), filepath.Join(tmp, "pvesh.txt"), "pvesh nodes", false) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected parent context cancellation, got %v", err) + } +} + func TestSafeCmdOutputReturnsErrorOnEmptyCommand(t *testing.T) { logger := logging.New(types.LogLevelError, false) cfg := GetDefaultCollectorConfig() diff --git a/internal/backup/optimizations.go b/internal/backup/optimizations.go index f31943f8..90e5d0ea 100644 --- a/internal/backup/optimizations.go +++ b/internal/backup/optimizations.go @@ -150,12 +150,12 @@ func shouldSkipDedupPath(rel string) bool { } } -func hashFile(path string) (string, error) { +func hashFile(path string) (sum string, err error) { f, err := os.Open(path) if err != nil { return "", err } - defer f.Close() + defer closeIntoErr(&err, f, "close file for hash") hasher := sha256.New() if _, err := io.Copy(hasher, f); err != nil { @@ -243,7 +243,7 @@ func chunkLargeFiles(ctx context.Context, logger *logging.Logger, root string, c return nil } -func splitFile(path, destBase string, chunkSize int64) error { +func splitFile(path, destBase string, chunkSize int64) (err error) { if err := os.MkdirAll(filepath.Dir(destBase), defaultChunkDirPerm); err != nil { return err } @@ -252,7 +252,7 @@ func splitFile(path, destBase string, chunkSize int64) error { if err != nil { return err } - defer in.Close() + defer closeIntoErr(&err, in, "close source file") buf := make([]byte, chunkBufferSize) index := 0 @@ -270,22 +270,32 @@ func splitFile(path, destBase string, chunkSize int64) error { return nil } -func writeChunk(src *os.File, chunkPath string, buf []byte, limit int64) (bool, error) { - out, err := os.OpenFile(chunkPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, defaultChunkFilePerm) - if err != nil { - return false, err +func writeChunk(src *os.File, chunkPath string, buf []byte, limit int64) (done bool, err error) { + if limit <= 0 { + return true, nil } - defer out.Close() - + var out *os.File + defer func() { + if out != nil { + closeIntoErr(&err, out, "close chunk file") + } + }() var written int64 for written < limit { remaining := limit - written - if remaining < int64(len(buf)) { - buf = buf[:remaining] + readBuf := buf + if remaining < int64(len(readBuf)) { + readBuf = readBuf[:remaining] } - n, err := src.Read(buf) + n, err := src.Read(readBuf) if n > 0 { - if _, wErr := out.Write(buf[:n]); wErr != nil { + if out == nil { + out, err = os.OpenFile(chunkPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, defaultChunkFilePerm) + if err != nil { + return false, err + } + } + if _, wErr := out.Write(readBuf[:n]); wErr != nil { return false, wErr } written += int64(n) diff --git a/internal/backup/optimizations_bench_test.go b/internal/backup/optimizations_bench_test.go index 8cd04131..a26f74b7 100644 --- a/internal/backup/optimizations_bench_test.go +++ b/internal/backup/optimizations_bench_test.go @@ -83,7 +83,7 @@ func BenchmarkPrefilterFiles(b *testing.B) { } } -func writeFileOfSize(path string, size int64) error { +func writeFileOfSize(path string, size int64) (err error) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } @@ -91,7 +91,7 @@ func writeFileOfSize(path string, size int64) error { if err != nil { return err } - defer f.Close() + defer closeIntoErr(&err, f, "close benchmark file") chunk := bytes.Repeat([]byte("x"), 32*1024) var written int64 @@ -134,14 +134,18 @@ func copyDir(src, dst string) error { } dstFile, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640) if err != nil { - srcFile.Close() + _ = srcFile.Close() return err } - _, err = io.Copy(dstFile, srcFile) - srcFile.Close() - if err != nil { + _, copyErr := io.Copy(dstFile, srcFile) + closeSrcErr := srcFile.Close() + if copyErr != nil { _ = dstFile.Close() - return err + return copyErr + } + if closeSrcErr != nil { + _ = dstFile.Close() + return closeSrcErr } return dstFile.Close() }) diff --git a/internal/checks/checks.go b/internal/checks/checks.go index 1a0a59eb..f9caaa86 100644 --- a/internal/checks/checks.go +++ b/internal/checks/checks.go @@ -375,11 +375,14 @@ func (c *Checker) CheckLockFile() CheckResult { result.Message = result.Error.Error() return result } - defer f.Close() hostname, _ := os.Hostname() lockContent := fmt.Sprintf("pid=%d\nhost=%s\ntime=%s\n", os.Getpid(), hostname, time.Now().Format(time.RFC3339)) if _, err := f.WriteString(lockContent); err != nil { + if closeErr := f.Close(); closeErr != nil { + c.logger.Warning("Failed to close lock file %s: %v", lockPath, closeErr) + } + c.removePartialLockFile(lockPath) result.Error = fmt.Errorf("failed to write lock file: %w", err) result.Message = result.Error.Error() return result @@ -387,6 +390,13 @@ func (c *Checker) CheckLockFile() CheckResult { if err := syncFile(f); err != nil { c.logger.Warning("Failed to sync lock file %s: %v", lockPath, err) } + if err := f.Close(); err != nil { + c.logger.Warning("Failed to close lock file %s: %v", lockPath, err) + c.removePartialLockFile(lockPath) + result.Error = fmt.Errorf("failed to close lock file: %w", err) + result.Message = result.Error.Error() + return result + } } else { c.logger.Info("[DRY RUN] Would create lock file: %s", lockPath) } @@ -397,6 +407,12 @@ func (c *Checker) CheckLockFile() CheckResult { return result } +func (c *Checker) removePartialLockFile(lockPath string) { + if err := osRemove(lockPath); err != nil && !os.IsNotExist(err) { + c.logger.Warning("Failed to remove partial lock file %s: %v", lockPath, err) + } +} + // CheckPermissions verifies write permissions on required directories func (c *Checker) CheckPermissions() CheckResult { result := CheckResult{ @@ -425,8 +441,11 @@ func (c *Checker) CheckPermissions() CheckResult { for attempt := 1; attempt <= maxAttempts; attempt++ { f, err := createTestFile(testFile) if err == nil { - f.Close() - lastErr = nil + if closeErr := f.Close(); closeErr != nil { + lastErr = closeErr + } else { + lastErr = nil + } break } @@ -565,7 +584,7 @@ func (c *Checker) CheckTempDirectory() CheckResult { if err != nil { if !os.IsNotExist(err) { result.Code = "STAT_FAILED" - result.Error = fmt.Errorf("Temp directory check failed - path: %s: %w", tempRoot, err) + result.Error = fmt.Errorf("temp directory check failed - path: %s: %w", tempRoot, err) result.Message = result.Error.Error() return result } @@ -574,7 +593,7 @@ func (c *Checker) CheckTempDirectory() CheckResult { c.logger.Debug("Temp directory not found, creating: %s", tempRoot) if err := osMkdirAll(tempRoot, 0o755); err != nil { result.Code = "CREATE_FAILED" - result.Error = fmt.Errorf("Temp directory creation failed - path: %s: %w", tempRoot, err) + result.Error = fmt.Errorf("temp directory creation failed - path: %s: %w", tempRoot, err) result.Message = result.Error.Error() return result } @@ -583,7 +602,7 @@ func (c *Checker) CheckTempDirectory() CheckResult { info, err = osStat(tempRoot) if err != nil { result.Code = "VERIFY_FAILED" - result.Error = fmt.Errorf("Temp directory verification failed - path: %s: %w", tempRoot, err) + result.Error = fmt.Errorf("temp directory verification failed - path: %s: %w", tempRoot, err) result.Message = result.Error.Error() return result } @@ -593,7 +612,7 @@ func (c *Checker) CheckTempDirectory() CheckResult { if !info.IsDir() { result.Code = "NOT_DIRECTORY" - result.Error = fmt.Errorf("Temp path is not a directory - path: %s", tempRoot) + result.Error = fmt.Errorf("temp path is not a directory - path: %s", tempRoot) result.Message = result.Error.Error() return result } @@ -603,22 +622,24 @@ func (c *Checker) CheckTempDirectory() CheckResult { testFile := filepath.Join(tempRoot, ".proxsave-permission-test") if err := osWriteFile(testFile, []byte("test"), 0o600); err != nil { result.Code = "NOT_WRITABLE" - result.Error = fmt.Errorf("Temp directory not writable - path: %s: %w", tempRoot, err) + result.Error = fmt.Errorf("temp directory not writable - path: %s: %w", tempRoot, err) result.Message = result.Error.Error() return result } - defer osRemove(testFile) + defer func() { _ = osRemove(testFile) }() // Test symlink support c.logger.Debug("Testing symlink support: %s", tempRoot) testSymlink := filepath.Join(tempRoot, ".proxsave-symlink-test") if err := osSymlink(testFile, testSymlink); err != nil { result.Code = "NO_SYMLINK_SUPPORT" - result.Error = fmt.Errorf("Temp directory does not support symlinks - path: %s: %w", tempRoot, err) + result.Error = fmt.Errorf("temp directory does not support symlinks - path: %s: %w", tempRoot, err) result.Message = result.Error.Error() return result } - osRemove(testSymlink) + if err := osRemove(testSymlink); err != nil && !os.IsNotExist(err) { + c.logger.Warning("Failed to remove temp symlink test %s: %v", testSymlink, err) + } result.Passed = true result.Message = fmt.Sprintf("%s writable with symlink support", tempRoot) diff --git a/internal/checks/checks_test.go b/internal/checks/checks_test.go index 1dbc6ffd..f9283e64 100644 --- a/internal/checks/checks_test.go +++ b/internal/checks/checks_test.go @@ -109,7 +109,9 @@ func TestCheckLockFile(t *testing.T) { } // Clean up - checker.ReleaseLock() + if err := checker.ReleaseLock(); err != nil { + t.Fatalf("ReleaseLock failed: %v", err) + } } func TestCheckLockFileStaleLock(t *testing.T) { @@ -146,7 +148,9 @@ func TestCheckLockFileStaleLock(t *testing.T) { } // Clean up - checker.ReleaseLock() + if err := checker.ReleaseLock(); err != nil { + t.Fatalf("ReleaseLock failed: %v", err) + } } func TestCheckLockFile_RemovesLockWhenProcessIsGone(t *testing.T) { @@ -624,7 +628,9 @@ func TestRunAllChecks(t *testing.T) { } // Clean up - checker.ReleaseLock() + if err := checker.ReleaseLock(); err != nil { + t.Fatalf("ReleaseLock failed: %v", err) + } } func TestRunAllChecksSkipPermissionCheck(t *testing.T) { @@ -797,7 +803,9 @@ func TestCheckDiskSpaceForEstimate(t *testing.T) { func TestCheckTempDirectory_Success(t *testing.T) { // Ensure /tmp/proxsave exists for the test tempRoot := filepath.Join("/tmp", "proxsave") - os.MkdirAll(tempRoot, 0o755) + if err := os.MkdirAll(tempRoot, 0o755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } config := GetDefaultCheckerConfig(t.TempDir(), t.TempDir(), t.TempDir()) logger := logging.New(types.LogLevelDebug, false) @@ -859,7 +867,9 @@ func TestCheckTempDirectory_NotWritable(t *testing.T) { func TestCheckTempDirectory_SymlinkSupport(t *testing.T) { // Verify that the temp directory check includes symlink validation tempRoot := filepath.Join("/tmp", "proxsave") - os.MkdirAll(tempRoot, 0o755) + if err := os.MkdirAll(tempRoot, 0o755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } config := GetDefaultCheckerConfig(t.TempDir(), t.TempDir(), t.TempDir()) logger := logging.New(types.LogLevelDebug, false) @@ -883,7 +893,9 @@ func TestCheckTempDirectory_SymlinkSupport(t *testing.T) { func TestRunAllChecks_IncludesTempDirectory(t *testing.T) { // Ensure /tmp/proxsave exists - os.MkdirAll(filepath.Join("/tmp", "proxsave"), 0o755) + if err := os.MkdirAll(filepath.Join("/tmp", "proxsave"), 0o755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } backupPath := t.TempDir() logPath := t.TempDir() @@ -1114,10 +1126,6 @@ func TestCheckLockFile_RemoveStaleLockFails(t *testing.T) { } func TestCheckLockFile_WriteFails(t *testing.T) { - if _, err := os.Stat("/dev/full"); err != nil { - t.Skipf("/dev/full not available: %v", err) - } - logger := logging.New(types.LogLevelInfo, false) logger.SetOutput(io.Discard) @@ -1131,7 +1139,7 @@ func TestCheckLockFile_WriteFails(t *testing.T) { origOpen := osOpenFile t.Cleanup(func() { osOpenFile = origOpen }) osOpenFile = func(name string, flag int, perm os.FileMode) (*os.File, error) { - return os.OpenFile("/dev/full", os.O_WRONLY, 0) + return os.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_RDONLY, perm) } checker := NewChecker(logger, config) @@ -1142,6 +1150,9 @@ func TestCheckLockFile_WriteFails(t *testing.T) { if result.Error == nil || !strings.Contains(result.Error.Error(), "failed to write lock file") { t.Fatalf("expected write lock file error, got: %v", result.Error) } + if _, err := os.Stat(lockPath); !os.IsNotExist(err) { + t.Fatalf("expected partial lock file to be removed, stat err: %v", err) + } } func TestCheckLockFile_SyncWarningDoesNotFail(t *testing.T) { @@ -1166,6 +1177,36 @@ func TestCheckLockFile_SyncWarningDoesNotFail(t *testing.T) { } } +func TestCheckLockFile_CloseFailsRemovesPartialLock(t *testing.T) { + logger := logging.New(types.LogLevelInfo, false) + logger.SetOutput(io.Discard) + + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, ".backup.lock") + + config := GetDefaultCheckerConfig(tmpDir, tmpDir, tmpDir) + config.LockFilePath = lockPath + config.MaxLockAge = time.Hour + + origSync := syncFile + t.Cleanup(func() { syncFile = origSync }) + syncFile = func(f *os.File) error { + return f.Close() + } + + checker := NewChecker(logger, config) + result := checker.CheckLockFile() + if result.Passed { + t.Fatalf("expected CheckLockFile to fail, got passed") + } + if result.Error == nil || !strings.Contains(result.Error.Error(), "failed to close lock file") { + t.Fatalf("expected close lock file error, got: %v", result.Error) + } + if _, err := os.Stat(lockPath); !os.IsNotExist(err) { + t.Fatalf("expected partial lock file to be removed, stat err: %v", err) + } +} + func TestCheckLockFile_DefaultLockPath_DryRun(t *testing.T) { logger := logging.New(types.LogLevelInfo, false) logger.SetOutput(io.Discard) diff --git a/internal/cli/args.go b/internal/cli/args.go index 6919c964..3892ba58 100644 --- a/internal/cli/args.go +++ b/internal/cli/args.go @@ -210,26 +210,29 @@ func ShowVersion() { } func printHelp(w io.Writer, argv0 string) { - fmt.Fprintf(w, "Usage: %s [options]\n\n", argv0) - fmt.Fprintln(w, "ProxSave") - fmt.Fprintln(w, "") - fmt.Fprintln(w, "Options:") + _, _ = fmt.Fprintf(w, "Usage: %s [options]\n\n", argv0) + _, _ = fmt.Fprintln(w, "ProxSave") + _, _ = fmt.Fprintln(w, "") + _, _ = fmt.Fprintln(w, "Options:") + previousOutput := flag.CommandLine.Output() + flag.CommandLine.SetOutput(w) + defer flag.CommandLine.SetOutput(previousOutput) flag.PrintDefaults() - fmt.Fprintln(w, "") - fmt.Fprintln(w, "Examples:") - fmt.Fprintf(w, " %s -c /path/to/config.env\n", argv0) - fmt.Fprintf(w, " %s --dry-run --log-level debug\n", argv0) - fmt.Fprintf(w, " %s --version\n", argv0) + _, _ = fmt.Fprintln(w, "") + _, _ = fmt.Fprintln(w, "Examples:") + _, _ = fmt.Fprintf(w, " %s -c /path/to/config.env\n", argv0) + _, _ = fmt.Fprintf(w, " %s --dry-run --log-level debug\n", argv0) + _, _ = fmt.Fprintf(w, " %s --version\n", argv0) } func printVersion(w io.Writer) { - fmt.Fprintln(w, "ProxSave") + _, _ = fmt.Fprintln(w, "ProxSave") v := version.String() if strings.TrimSpace(v) == "" { v = "0.0.0-dev" } - fmt.Fprintf(w, "Version: %s\n", v) + _, _ = fmt.Fprintf(w, "Version: %s\n", v) build := "development" commit := strings.TrimSpace(version.Commit) @@ -242,9 +245,9 @@ func printVersion(w io.Writer) { case date != "": build = date } - fmt.Fprintf(w, "Build: %s\n", build) + _, _ = fmt.Fprintf(w, "Build: %s\n", build) - fmt.Fprintln(w, "Author: tis24dev") + _, _ = fmt.Fprintln(w, "Author: tis24dev") } type stringFlag struct { diff --git a/internal/closeerr/closeerr.go b/internal/closeerr/closeerr.go new file mode 100644 index 00000000..60920bfa --- /dev/null +++ b/internal/closeerr/closeerr.go @@ -0,0 +1,19 @@ +package closeerr + +import ( + "errors" + "fmt" + "io" + "os" +) + +// CloseIntoErr closes closer and stores the close failure in errp only when no +// earlier error is present. +func CloseIntoErr(errp *error, closer io.Closer, operation string) { + if errp == nil || closer == nil { + return + } + if closeErr := closer.Close(); closeErr != nil && !errors.Is(closeErr, os.ErrClosed) && *errp == nil { + *errp = fmt.Errorf("%s: %w", operation, closeErr) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 07532c9d..7e014cfe 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1419,7 +1419,7 @@ func isLocalPBSHost(host string) bool { hostShort = hostShort[:dot] } return hostShort == currentShort && - (strings.Index(host, ".") < 0 || strings.Index(currentHost, ".") < 0) + (!strings.Contains(host, ".") || !strings.Contains(currentHost, ".")) } func normalizePBSHost(host string) string { @@ -1498,14 +1498,18 @@ func (c *Config) BuildWebhookConfig() *WebhookConfig { } } -func parseEnvFile(path string) (map[string]string, error) { +func parseEnvFile(path string) (raw map[string]string, err error) { file, err := os.Open(path) if err != nil { return nil, fmt.Errorf("cannot open config file: %w", err) } - defer file.Close() + defer func() { + if closeErr := file.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("close config file: %w", closeErr) + } + }() - raw := make(map[string]string) + raw = make(map[string]string) scanner := bufio.NewScanner(file) for scanner.Scan() { diff --git a/internal/config/upgrade.go b/internal/config/upgrade.go index d319a4af..0a402f47 100644 --- a/internal/config/upgrade.go +++ b/internal/config/upgrade.go @@ -385,14 +385,12 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er ops := make([]insertOp, 0, len(missingEntries)) unanchored := make([]templateEntry, 0) for _, entry := range missingEntries { - insertIndex := appendIndex - if prev, ok := findPrevAnchor(entry.index); ok { - insertIndex = prev - } else { + prev, ok := findPrevAnchor(entry.index) + if !ok { unanchored = append(unanchored, entry) continue } - insertIndex = normalizeInsertIndex(insertIndex) + insertIndex := normalizeInsertIndex(prev) ops = append(ops, insertOp{ index: insertIndex, lines: entry.lines, diff --git a/internal/environment/detect.go b/internal/environment/detect.go index a6fd841d..73c66029 100644 --- a/internal/environment/detect.go +++ b/internal/environment/detect.go @@ -143,12 +143,6 @@ func detectEnvironmentInfo() (*EnvironmentInfo, error) { return info, fmt.Errorf("unable to detect Proxmox environment") } -// detectProxmox is retained as a compatibility wrapper for legacy call sites and tests. -func detectProxmox() (types.ProxmoxType, string, error) { - info, err := detectEnvironmentInfo() - return info.Type, info.Version, err -} - func resolveType(hasPVE, hasPBS bool) types.ProxmoxType { switch { case hasPVE && hasPBS: @@ -421,58 +415,58 @@ func writeDetectionDebug() string { path := filepath.Join(debugDir, fmt.Sprintf("proxmox_detection_debug_%d.log", now.Unix())) var builder strings.Builder - builder.WriteString(fmt.Sprintf("=== Proxmox Detection Failure Debug - %s ===\n", now.Format("2006-01-02 15:04:05"))) - builder.WriteString(fmt.Sprintf("Current PATH: %s\n", os.Getenv("PATH"))) + fmt.Fprintf(&builder, "=== Proxmox Detection Failure Debug - %s ===\n", now.Format("2006-01-02 15:04:05")) + fmt.Fprintf(&builder, "Current PATH: %s\n", os.Getenv("PATH")) if u, err := userCurrentFunc(); err == nil { - builder.WriteString(fmt.Sprintf("Current USER: %s\n", u.Username)) + fmt.Fprintf(&builder, "Current USER: %s\n", u.Username) } else { builder.WriteString("Current USER: unknown\n") } if cwd, err := getwdFunc(); err == nil { - builder.WriteString(fmt.Sprintf("Current PWD: %s\n", cwd)) + fmt.Fprintf(&builder, "Current PWD: %s\n", cwd) } - builder.WriteString(fmt.Sprintf("Shell: %s\n\n", os.Getenv("SHELL"))) + fmt.Fprintf(&builder, "Shell: %s\n\n", os.Getenv("SHELL")) builder.WriteString("=== Command availability check ===\n") - builder.WriteString(fmt.Sprintf("command -v pveversion: %s\n", lookPathOrNotFound("pveversion"))) - builder.WriteString(fmt.Sprintf("command -v proxmox-backup-manager: %s\n", lookPathOrNotFound("proxmox-backup-manager"))) + fmt.Fprintf(&builder, "command -v pveversion: %s\n", lookPathOrNotFound("pveversion")) + fmt.Fprintf(&builder, "command -v proxmox-backup-manager: %s\n", lookPathOrNotFound("proxmox-backup-manager")) builder.WriteString("\n") builder.WriteString("=== File existence check ===\n") - builder.WriteString(fmt.Sprintf("%s exists: %s\n", "/usr/bin/pveversion", boolToYes(fileExists("/usr/bin/pveversion")))) - builder.WriteString(fmt.Sprintf("%s executable: %s\n", "/usr/bin/pveversion", boolToYes(isExecutable("/usr/bin/pveversion")))) - builder.WriteString(fmt.Sprintf("%s exists: %s\n", "/usr/sbin/pveversion", boolToYes(fileExists("/usr/sbin/pveversion")))) - builder.WriteString(fmt.Sprintf("%s executable: %s\n", "/usr/sbin/pveversion", boolToYes(isExecutable("/usr/sbin/pveversion")))) - builder.WriteString(fmt.Sprintf("%s exists: %s\n", "/usr/bin/proxmox-backup-manager", boolToYes(fileExists("/usr/bin/proxmox-backup-manager")))) - builder.WriteString(fmt.Sprintf("%s executable: %s\n", "/usr/bin/proxmox-backup-manager", boolToYes(isExecutable("/usr/bin/proxmox-backup-manager")))) + fmt.Fprintf(&builder, "%s exists: %s\n", "/usr/bin/pveversion", boolToYes(fileExists("/usr/bin/pveversion"))) + fmt.Fprintf(&builder, "%s executable: %s\n", "/usr/bin/pveversion", boolToYes(isExecutable("/usr/bin/pveversion"))) + fmt.Fprintf(&builder, "%s exists: %s\n", "/usr/sbin/pveversion", boolToYes(fileExists("/usr/sbin/pveversion"))) + fmt.Fprintf(&builder, "%s executable: %s\n", "/usr/sbin/pveversion", boolToYes(isExecutable("/usr/sbin/pveversion"))) + fmt.Fprintf(&builder, "%s exists: %s\n", "/usr/bin/proxmox-backup-manager", boolToYes(fileExists("/usr/bin/proxmox-backup-manager"))) + fmt.Fprintf(&builder, "%s executable: %s\n", "/usr/bin/proxmox-backup-manager", boolToYes(isExecutable("/usr/bin/proxmox-backup-manager"))) builder.WriteString("\n") builder.WriteString("=== Directory existence check ===\n") for _, dir := range append(pveDirCandidates, pbsDirCandidates...) { - builder.WriteString(fmt.Sprintf("%s exists: %s\n", dir, boolToYes(dirExists(dir)))) + fmt.Fprintf(&builder, "%s exists: %s\n", dir, boolToYes(dirExists(dir))) } builder.WriteString("\n") builder.WriteString("=== Version file check ===\n") - builder.WriteString(fmt.Sprintf("%s exists: %s\n", pveLegacyFile, boolToYes(fileExists(pveLegacyFile)))) + fmt.Fprintf(&builder, "%s exists: %s\n", pveLegacyFile, boolToYes(fileExists(pveLegacyFile))) if content := readAndTrim(pveLegacyFile); content != "" { - builder.WriteString(fmt.Sprintf("%s content: %s\n", pveLegacyFile, content)) + fmt.Fprintf(&builder, "%s content: %s\n", pveLegacyFile, content) } - builder.WriteString(fmt.Sprintf("%s exists: %s\n", pveVersionFile, boolToYes(fileExists(pveVersionFile)))) + fmt.Fprintf(&builder, "%s exists: %s\n", pveVersionFile, boolToYes(fileExists(pveVersionFile))) if content := readAndTrim(pveVersionFile); content != "" { - builder.WriteString(fmt.Sprintf("%s content: %s\n", pveVersionFile, content)) + fmt.Fprintf(&builder, "%s content: %s\n", pveVersionFile, content) } - builder.WriteString(fmt.Sprintf("%s exists: %s\n", pbsVersionFile, boolToYes(fileExists(pbsVersionFile)))) + fmt.Fprintf(&builder, "%s exists: %s\n", pbsVersionFile, boolToYes(fileExists(pbsVersionFile))) if content := readAndTrim(pbsVersionFile); content != "" { - builder.WriteString(fmt.Sprintf("%s content: %s\n", pbsVersionFile, content)) + fmt.Fprintf(&builder, "%s content: %s\n", pbsVersionFile, content) } builder.WriteString("\n") builder.WriteString("=== APT source files check ===\n") for _, source := range append(pveSourceFiles, pbsSourceFiles...) { - builder.WriteString(fmt.Sprintf("%s exists: %s\n", source, boolToYes(fileExists(source)))) + fmt.Fprintf(&builder, "%s exists: %s\n", source, boolToYes(fileExists(source))) } builder.WriteString("\n") diff --git a/internal/environment/unprivileged.go b/internal/environment/unprivileged.go index ed27be80..fb840f70 100644 --- a/internal/environment/unprivileged.go +++ b/internal/environment/unprivileged.go @@ -335,23 +335,6 @@ func formatIDMapDetails(label string, info IDMapOutsideZeroInfo) string { } } -func formatFileValueDetails(label string, info FileValueInfo) string { - label = strings.TrimSpace(label) - if label == "" { - label = "value" - } - switch { - case info.OK && strings.TrimSpace(info.Value) != "": - return fmt.Sprintf("%s=%s", label, strings.TrimSpace(info.Value)) - case info.OK: - return fmt.Sprintf("%s=empty", label) - case info.ReadError != "": - return fmt.Sprintf("%s=unavailable(err=%s)", label, info.ReadError) - default: - return fmt.Sprintf("%s=unavailable", label) - } -} - func formatSimpleDetails(label, value, emptyValue string) string { label = strings.TrimSpace(label) if label == "" { diff --git a/internal/identity/identity.go b/internal/identity/identity.go index 755dc24b..22a9d546 100644 --- a/internal/identity/identity.go +++ b/internal/identity/identity.go @@ -474,7 +474,7 @@ func buildSystemData(macs []string, logger *logging.Logger) string { } if builder.Len() == 0 { - builder.WriteString(fmt.Sprintf("fallback-%d-%d", time.Now().Unix(), os.Getpid())) + fmt.Fprintf(&builder, "fallback-%d-%d", time.Now().Unix(), os.Getpid()) logDebug(logger, "Identity: buildSystemData: WARNING: used fallback seed (unexpected)") } @@ -494,10 +494,10 @@ func encodeProtectedServerIDWithMACs(serverID string, macs []string, primaryMAC var builder strings.Builder builder.WriteString("# ProxSave Backup System Configuration\n") - builder.WriteString(fmt.Sprintf("# Generated: %s\n", time.Now().Format(time.RFC3339))) + fmt.Fprintf(&builder, "# Generated: %s\n", time.Now().Format(time.RFC3339)) builder.WriteString("# DO NOT MODIFY THIS FILE MANUALLY\n") builder.WriteString("# Format: proxsave-identity-v2\n") - builder.WriteString(fmt.Sprintf("SYSTEM_CONFIG_DATA=\"%s\"\n", encoded)) + fmt.Fprintf(&builder, "SYSTEM_CONFIG_DATA=\"%s\"\n", encoded) builder.WriteString("# End of configuration\n") content := builder.String() diff --git a/internal/identity/identity_test.go b/internal/identity/identity_test.go index e0639abd..25bad342 100644 --- a/internal/identity/identity_test.go +++ b/internal/identity/identity_test.go @@ -1138,7 +1138,8 @@ func TestReadAddrAssignType(t *testing.T) { } func TestIsBridgeInterfaceByName(t *testing.T) { - // On non-Linux or without sysfs, falls back to name-based detection + // On non-Linux, detection falls back to interface names. On Linux, + // sysfs decides the result and these synthetic names may not exist. tests := []struct { name string want bool @@ -1155,16 +1156,21 @@ func TestIsBridgeInterfaceByName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // This will use name-based fallback if sysfs not available got := isBridgeInterface(tt.name) - // On Linux with sysfs, result may differ, so we just check it doesn't panic - _ = got + if runtime.GOOS == "linux" { + t.Logf("sysfs bridge detection for %q returned %v", tt.name, got) + return + } + if got != tt.want { + t.Fatalf("isBridgeInterface(%q)=%v; want %v", tt.name, got, tt.want) + } }) } } func TestIsWirelessInterfaceByName(t *testing.T) { - // On non-Linux or without sysfs, falls back to name-based detection + // On non-Linux, detection falls back to interface names. On Linux, + // sysfs decides the result and these synthetic names may not exist. tests := []struct { name string want bool @@ -1179,9 +1185,12 @@ func TestIsWirelessInterfaceByName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := isWirelessInterface(tt.name) - // Check name-based fallback behavior - if strings.HasPrefix(strings.ToLower(tt.name), "wl") && !got { - // May or may not work depending on sysfs + if runtime.GOOS == "linux" { + t.Logf("sysfs wireless detection for %q returned %v", tt.name, got) + return + } + if got != tt.want { + t.Fatalf("isWirelessInterface(%q)=%v; want %v", tt.name, got, tt.want) } }) } diff --git a/internal/input/input_test.go b/internal/input/input_test.go index 535f0482..f82af39d 100644 --- a/internal/input/input_test.go +++ b/internal/input/input_test.go @@ -218,7 +218,7 @@ func TestReadLineWithContext_ReturnsLine(t *testing.T) { func TestReadLineWithContext_NilContextWorks(t *testing.T) { reader := bufio.NewReader(strings.NewReader("hello\n")) - got, err := ReadLineWithContext(nil, reader) + got, err := ReadLineWithContext(nil, reader) //nolint:staticcheck // Verifies the documented nil context fallback. if err != nil { t.Fatalf("ReadLineWithContext error: %v", err) } @@ -229,8 +229,8 @@ func TestReadLineWithContext_NilContextWorks(t *testing.T) { func TestReadLineWithContext_CancelledReturnsAborted(t *testing.T) { pr, pw := io.Pipe() - defer pr.Close() - defer pw.Close() + defer func() { _ = pr.Close() }() + defer func() { _ = pw.Close() }() reader := bufio.NewReader(pr) ctx, cancel := context.WithCancel(context.Background()) @@ -258,8 +258,8 @@ func TestReadLineWithContext_CancelledReturnsAborted(t *testing.T) { func TestReadLineWithContext_DeadlineReturnsDeadlineExceeded(t *testing.T) { pr, pw := io.Pipe() - defer pr.Close() - defer pw.Close() + defer func() { _ = pr.Close() }() + defer func() { _ = pw.Close() }() reader := bufio.NewReader(pr) ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) @@ -364,7 +364,7 @@ func TestReadPasswordWithContext_NilContextWorks(t *testing.T) { readPassword := func(fd int) ([]byte, error) { return []byte("secret"), nil } - got, err := ReadPasswordWithContext(nil, readPassword, 0) + got, err := ReadPasswordWithContext(nil, readPassword, 0) //nolint:staticcheck // Verifies the documented nil context fallback. if err != nil { t.Fatalf("ReadPasswordWithContext error: %v", err) } diff --git a/internal/logging/logger.go b/internal/logging/logger.go index ebb80512..2e80b719 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -72,7 +72,10 @@ func (l *Logger) OpenLogFile(logPath string) error { defer l.mu.Unlock() // If a log file is already open, close it first. if l.logFile != nil { - l.logFile.Close() + if err := l.logFile.Close(); err != nil { + return fmt.Errorf("failed to close existing log file: %w", err) + } + l.logFile = nil } // Create the log file (O_CREATE|O_WRONLY|O_APPEND). @@ -197,11 +200,11 @@ func (l *Logger) logWithLabel(level types.LogLevel, label string, colorOverride } // Write to stdout with colors. - fmt.Fprint(l.output, outputStdout) + _, _ = fmt.Fprint(l.output, outputStdout) // If a log file is open, write there too (without colors). if l.logFile != nil { - fmt.Fprint(l.logFile, outputFile) + _, _ = fmt.Fprint(l.logFile, outputFile) } } @@ -333,7 +336,7 @@ func (l *Logger) AppendRaw(message string) { types.LogLevelInfo.String(), message, ) - fmt.Fprint(l.logFile, output) + _, _ = fmt.Fprint(l.logFile, output) } // Package-level default logger diff --git a/internal/logging/session_test.go b/internal/logging/session_test.go index 233c9264..03fa6c7a 100644 --- a/internal/logging/session_test.go +++ b/internal/logging/session_test.go @@ -42,7 +42,7 @@ func TestDetectHostname(t *testing.T) { for _, r := range host { isLower := r >= 'a' && r <= 'z' isDigit := r >= '0' && r <= '9' - if !(isLower || isDigit || r == '-') { + if !isLower && !isDigit && r != '-' { t.Fatalf("unexpected rune %q in hostname %q", r, host) } } diff --git a/internal/metrics/prometheus.go b/internal/metrics/prometheus.go index d06a8eb2..c1e55240 100644 --- a/internal/metrics/prometheus.go +++ b/internal/metrics/prometheus.go @@ -48,7 +48,7 @@ func NewPrometheusExporter(textfileDir string, logger *logging.Logger) *Promethe } // Export writes the given metrics snapshot to proxmox_backup.prom in textfileDir. -func (pe *PrometheusExporter) Export(m *BackupMetrics) error { +func (pe *PrometheusExporter) Export(m *BackupMetrics) (err error) { if pe == nil || m == nil { return nil } @@ -68,13 +68,48 @@ func (pe *PrometheusExporter) Export(m *BackupMetrics) error { if err != nil { return fmt.Errorf("create metrics file %s: %w", tmpPath, err) } - defer f.Close() + defer func() { + if f == nil { + return + } + if closeErr := f.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("close metrics file %s: %w", tmpPath, closeErr) + } + }() + + var writeErr error + wrap := func(err error) error { + if err == nil { + return nil + } + if writeErr == nil { + writeErr = fmt.Errorf("write metrics file %s: %w", tmpPath, err) + } + return writeErr + } + writef := func(format string, a ...any) error { + if writeErr != nil { + return writeErr + } + _, err := fmt.Fprintf(f, format, a...) + return wrap(err) + } // Helper to write a single metric with HELP/TYPE - writeMetric := func(name, mtype, help, value string) { - fmt.Fprintf(f, "# HELP %s %s\n", name, help) - fmt.Fprintf(f, "# TYPE %s %s\n", name, mtype) - fmt.Fprintln(f, value) + writeMetric := func(name, mtype, help, value string) error { + if writeErr != nil { + return writeErr + } + if err := writef("# HELP %s %s\n", name, help); err != nil { + return err + } + if err := writef("# TYPE %s %s\n", name, mtype); err != nil { + return err + } + if err := writef("%s\n", value); err != nil { + return err + } + return nil } // Timestamps @@ -93,105 +128,146 @@ func (pe *PrometheusExporter) Export(m *BackupMetrics) error { } // Core metrics - writeMetric( + if err := writeMetric( "proxmox_backup_start_time_seconds", "gauge", "Unix timestamp of backup start", fmt.Sprintf("proxmox_backup_start_time_seconds %.0f", startTs), - ) + ); err != nil { + return err + } - writeMetric( + if err := writeMetric( "proxmox_backup_end_time_seconds", "gauge", "Unix timestamp of backup end", fmt.Sprintf("proxmox_backup_end_time_seconds %.0f", endTs), - ) + ); err != nil { + return err + } - writeMetric( + if err := writeMetric( "proxmox_backup_duration_seconds", "gauge", "Duration of last backup in seconds", fmt.Sprintf("proxmox_backup_duration_seconds %.2f", m.Duration.Seconds()), - ) + ); err != nil { + return err + } - writeMetric( + if err := writeMetric( "proxmox_backup_exit_code", "gauge", "Exit code of last backup", fmt.Sprintf("proxmox_backup_exit_code %d", m.ExitCode), - ) + ); err != nil { + return err + } - writeMetric( + if err := writeMetric( "proxmox_backup_status", "gauge", "Status of last backup (0=success,1=warning,2=error)", fmt.Sprintf("proxmox_backup_status %d", status), - ) + ); err != nil { + return err + } - writeMetric( + if err := writeMetric( "proxmox_backup_errors_total", "gauge", "Total number of errors in last backup", fmt.Sprintf("proxmox_backup_errors_total %d", m.ErrorCount), - ) + ); err != nil { + return err + } - writeMetric( + if err := writeMetric( "proxmox_backup_warnings_total", "gauge", "Total number of warnings in last backup", fmt.Sprintf("proxmox_backup_warnings_total %d", m.WarningCount), - ) + ); err != nil { + return err + } - writeMetric( + if err := writeMetric( "proxmox_backup_bytes_collected", "gauge", "Total number of bytes collected during last backup", fmt.Sprintf("proxmox_backup_bytes_collected %d", m.BytesCollected), - ) + ); err != nil { + return err + } - writeMetric( + if err := writeMetric( "proxmox_backup_archive_size_bytes", "gauge", "Size of last backup archive in bytes", fmt.Sprintf("proxmox_backup_archive_size_bytes %d", m.ArchiveSize), - ) + ); err != nil { + return err + } - writeMetric( + if err := writeMetric( "proxmox_backup_files_collected_total", "gauge", "Total files successfully collected during last backup", fmt.Sprintf("proxmox_backup_files_collected_total %d", m.FilesCollected), - ) + ); err != nil { + return err + } - writeMetric( + if err := writeMetric( "proxmox_backup_files_failed_total", "gauge", "Total files that failed to collect during last backup", fmt.Sprintf("proxmox_backup_files_failed_total %d", m.FilesFailed), - ) + ); err != nil { + return err + } // Per-location backup counts - fmt.Fprintf(f, "# HELP proxmox_backup_backups_total Number of backups per location\n") - fmt.Fprintf(f, "# TYPE proxmox_backup_backups_total gauge\n") - fmt.Fprintf(f, "proxmox_backup_backups_total{location=\"local\"} %d\n", m.LocalBackups) - fmt.Fprintf(f, "proxmox_backup_backups_total{location=\"secondary\"} %d\n", m.SecBackups) - fmt.Fprintf(f, "proxmox_backup_backups_total{location=\"cloud\"} %d\n", m.CloudBackups) + if err := writef("# HELP proxmox_backup_backups_total Number of backups per location\n"); err != nil { + return err + } + if err := writef("# TYPE proxmox_backup_backups_total gauge\n"); err != nil { + return err + } + if err := writef("proxmox_backup_backups_total{location=\"local\"} %d\n", m.LocalBackups); err != nil { + return err + } + if err := writef("proxmox_backup_backups_total{location=\"secondary\"} %d\n", m.SecBackups); err != nil { + return err + } + if err := writef("proxmox_backup_backups_total{location=\"cloud\"} %d\n", m.CloudBackups); err != nil { + return err + } // Static info metric with labels - fmt.Fprintf(f, "# HELP proxmox_backup_info Static information about this backup instance\n") - fmt.Fprintf(f, "# TYPE proxmox_backup_info gauge\n") - fmt.Fprintf( - f, + if err := writef("# HELP proxmox_backup_info Static information about this backup instance\n"); err != nil { + return err + } + if err := writef("# TYPE proxmox_backup_info gauge\n"); err != nil { + return err + } + if err := writef( "proxmox_backup_info{hostname=%q,proxmox_type=%q,proxmox_version=%q,script_version=%q} 1\n", m.Hostname, m.ProxmoxType, m.ProxmoxVersion, m.ScriptVersion, - ) + ); err != nil { + return err + } if err := f.Sync(); err != nil { return fmt.Errorf("sync metrics file %s: %w", tmpPath, err) } + if err := f.Close(); err != nil { + return fmt.Errorf("close metrics file %s: %w", tmpPath, err) + } + f = nil if err := os.Rename(tmpPath, finalPath); err != nil { return fmt.Errorf("rename metrics file to %s: %w", finalPath, err) diff --git a/internal/notify/email.go b/internal/notify/email.go index da44380f..e04644da 100644 --- a/internal/notify/email.go +++ b/internal/notify/email.go @@ -1151,9 +1151,9 @@ func (e *EmailNotifier) buildEmailMessage(recipient, subject, htmlBody, textBody if toHeader == "" { toHeader = "root" } - email.WriteString(fmt.Sprintf("To: %s\n", toHeader)) - email.WriteString(fmt.Sprintf("From: %s\n", e.config.From)) - email.WriteString(fmt.Sprintf("Subject: =?UTF-8?B?%s?=\n", encodedSubject)) + fmt.Fprintf(&email, "To: %s\n", toHeader) + fmt.Fprintf(&email, "From: %s\n", e.config.From) + fmt.Fprintf(&email, "Subject: =?UTF-8?B?%s?=\n", encodedSubject) email.WriteString("MIME-Version: 1.0\n") // Decide whether to attach log file @@ -1170,16 +1170,16 @@ func (e *EmailNotifier) buildEmailMessage(recipient, subject, htmlBody, textBody mixedBoundary := "mixed_boundary_42" altBoundary := "alt_boundary_42" - email.WriteString(fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\n", mixedBoundary)) + fmt.Fprintf(&email, "Content-Type: multipart/mixed; boundary=\"%s\"\n", mixedBoundary) email.WriteString("\n") // First part: multipart/alternative with text and HTML bodies - email.WriteString(fmt.Sprintf("--%s\n", mixedBoundary)) - email.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\n", altBoundary)) + fmt.Fprintf(&email, "--%s\n", mixedBoundary) + fmt.Fprintf(&email, "Content-Type: multipart/alternative; boundary=\"%s\"\n", altBoundary) email.WriteString("\n") // Plain text part - email.WriteString(fmt.Sprintf("--%s\n", altBoundary)) + fmt.Fprintf(&email, "--%s\n", altBoundary) email.WriteString("Content-Type: text/plain; charset=UTF-8\n") email.WriteString("Content-Transfer-Encoding: quoted-printable\n") email.WriteString("\n") @@ -1187,14 +1187,14 @@ func (e *EmailNotifier) buildEmailMessage(recipient, subject, htmlBody, textBody email.WriteString("\n\n") // HTML part - email.WriteString(fmt.Sprintf("--%s\n", altBoundary)) + fmt.Fprintf(&email, "--%s\n", altBoundary) email.WriteString("Content-Type: text/html; charset=UTF-8\n") email.WriteString("Content-Transfer-Encoding: quoted-printable\n") email.WriteString("\n") email.WriteString(encodeQuotedPrintableBody(htmlBody)) email.WriteString("\n\n") - email.WriteString(fmt.Sprintf("--%s--\n", altBoundary)) + fmt.Fprintf(&email, "--%s--\n", altBoundary) email.WriteString("\n") // Second part: log file attachment (Base64 encoded) @@ -1203,9 +1203,9 @@ func (e *EmailNotifier) buildEmailMessage(recipient, subject, htmlBody, textBody filename = "backup.log" } - email.WriteString(fmt.Sprintf("--%s\n", mixedBoundary)) - email.WriteString(fmt.Sprintf("Content-Type: text/plain; charset=UTF-8; name=\"%s\"\n", filename)) - email.WriteString(fmt.Sprintf("Content-Disposition: attachment; filename=\"%s\"\n", filename)) + fmt.Fprintf(&email, "--%s\n", mixedBoundary) + fmt.Fprintf(&email, "Content-Type: text/plain; charset=UTF-8; name=\"%s\"\n", filename) + fmt.Fprintf(&email, "Content-Disposition: attachment; filename=\"%s\"\n", filename) email.WriteString("Content-Transfer-Encoding: base64\n") email.WriteString("\n") @@ -1220,18 +1220,18 @@ func (e *EmailNotifier) buildEmailMessage(recipient, subject, htmlBody, textBody email.WriteString("\n") } email.WriteString("\n") - email.WriteString(fmt.Sprintf("--%s--\n", mixedBoundary)) + fmt.Fprintf(&email, "--%s--\n", mixedBoundary) } } if !attachLog { // Fallback / default: simple multipart/alternative (no attachment) altBoundary := "boundary42" - email.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\n", altBoundary)) + fmt.Fprintf(&email, "Content-Type: multipart/alternative; boundary=\"%s\"\n", altBoundary) email.WriteString("\n") // Plain text part - email.WriteString(fmt.Sprintf("--%s\n", altBoundary)) + fmt.Fprintf(&email, "--%s\n", altBoundary) email.WriteString("Content-Type: text/plain; charset=UTF-8\n") email.WriteString("Content-Transfer-Encoding: quoted-printable\n") email.WriteString("\n") @@ -1239,14 +1239,14 @@ func (e *EmailNotifier) buildEmailMessage(recipient, subject, htmlBody, textBody email.WriteString("\n\n") // HTML part - email.WriteString(fmt.Sprintf("--%s\n", altBoundary)) + fmt.Fprintf(&email, "--%s\n", altBoundary) email.WriteString("Content-Type: text/html; charset=UTF-8\n") email.WriteString("Content-Transfer-Encoding: quoted-printable\n") email.WriteString("\n") email.WriteString(encodeQuotedPrintableBody(htmlBody)) email.WriteString("\n\n") - email.WriteString(fmt.Sprintf("--%s--\n", altBoundary)) + fmt.Fprintf(&email, "--%s--\n", altBoundary) } e.logger.Debug("Email message built (%d bytes)", email.Len()) diff --git a/internal/notify/email_delivery_methods_test.go b/internal/notify/email_delivery_methods_test.go index 10a73fea..d9807683 100644 --- a/internal/notify/email_delivery_methods_test.go +++ b/internal/notify/email_delivery_methods_test.go @@ -42,7 +42,7 @@ func TestEmailNotifier_RelayNoFallback_ReturnsError(t *testing.T) { // Force relay failure. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(`{"error":"temporary"}`)) + _, _ = w.Write([]byte(`{"error":"temporary"}`)) })) defer server.Close() @@ -136,7 +136,7 @@ func TestEmailNotifier_RelayFallback_UsesPMFOnly(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(`{"error":"temporary"}`)) + _, _ = w.Write([]byte(`{"error":"temporary"}`)) })) defer server.Close() diff --git a/internal/notify/email_relay.go b/internal/notify/email_relay.go index dd17a19d..26cd84c3 100644 --- a/internal/notify/email_relay.go +++ b/internal/notify/email_relay.go @@ -131,7 +131,7 @@ func sendViaCloudRelay( // Read response body body, err := io.ReadAll(resp.Body) - resp.Body.Close() + closeErr := resp.Body.Close() if err != nil { if ctxErr := ctx.Err(); ctxErr != nil { return ctxErr @@ -139,6 +139,10 @@ func sendViaCloudRelay( lastErr = fmt.Errorf("failed to read response: %w", err) continue } + if closeErr != nil { + lastErr = fmt.Errorf("failed to close response body: %w", closeErr) + continue + } // Log raw response body for all status codes (aids future diagnosis) logger.Debug("Cloud relay: HTTP %d response (%d bytes): %s", resp.StatusCode, len(body), string(body)) diff --git a/internal/notify/email_relay_test.go b/internal/notify/email_relay_test.go index 94a9b148..99467e4f 100644 --- a/internal/notify/email_relay_test.go +++ b/internal/notify/email_relay_test.go @@ -154,7 +154,7 @@ func TestSendViaCloudRelay_StatusHandling(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ w.WriteHeader(tt.statusCode) - w.Write([]byte(tt.body)) + _, _ = w.Write([]byte(tt.body)) })) defer server.Close() @@ -195,7 +195,7 @@ func TestSendViaCloudRelay_RetryOnServerError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if atomic.AddInt32(&attempts, 1) < 3 { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(`{"error":"temporary"}`)) + _, _ = w.Write([]byte(`{"error":"temporary"}`)) return } w.WriteHeader(http.StatusOK) @@ -238,7 +238,7 @@ func TestSendViaCloudRelay_StopsRetryingWhenContextCanceled(t *testing.T) { cancel() } w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(`{"error":"temporary"}`)) + _, _ = w.Write([]byte(`{"error":"temporary"}`)) })) defer server.Close() diff --git a/internal/notify/gotify.go b/internal/notify/gotify.go index be2aa370..44acc3b6 100644 --- a/internal/notify/gotify.go +++ b/internal/notify/gotify.go @@ -143,7 +143,7 @@ func (g *GotifyNotifier) Send(ctx context.Context, data *NotificationData) (*Not result.Duration = time.Since(start) return result, nil } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() result.Metadata["status_code"] = resp.StatusCode respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) diff --git a/internal/notify/gotify_test.go b/internal/notify/gotify_test.go index 0f1f9c06..a019f90b 100644 --- a/internal/notify/gotify_test.go +++ b/internal/notify/gotify_test.go @@ -128,7 +128,7 @@ func TestGotifySendSuccessAndFailure(t *testing.T) { // Now force server to return 500 to trigger failure path. serverFail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("fail")) + _, _ = w.Write([]byte("fail")) })) defer serverFail.Close() diff --git a/internal/notify/telegram.go b/internal/notify/telegram.go index 7b45f38e..f1cb14e3 100644 --- a/internal/notify/telegram.go +++ b/internal/notify/telegram.go @@ -199,7 +199,7 @@ func (t *TelegramNotifier) fetchCentralizedCredentials(ctx context.Context) (str if err != nil { return "", "", fmt.Errorf("API request failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() // Read response body, err := io.ReadAll(resp.Body) @@ -245,61 +245,61 @@ func (t *TelegramNotifier) buildMessage(data *NotificationData) string { // Tool name and version header version := strings.TrimSpace(data.ScriptVersion) if version != "" { - msg.WriteString(fmt.Sprintf("ProxSave - v%s\n\n", version)) + fmt.Fprintf(&msg, "ProxSave - v%s\n\n", version) } else { msg.WriteString("ProxSave\n\n") } // Header with status and hostname statusEmoji := GetStatusEmoji(data.Status) - msg.WriteString(fmt.Sprintf("%s Backup %s - %s\n\n", + fmt.Fprintf(&msg, "%s Backup %s - %s\n\n", statusEmoji, data.ProxmoxType.String(), - data.Hostname)) + data.Hostname) // Storage status localEmoji := GetStorageEmoji(data.LocalStatus) - msg.WriteString(fmt.Sprintf("%s Local (%s backups)\n", localEmoji, data.LocalStatusSummary)) + fmt.Fprintf(&msg, "%s Local (%s backups)\n", localEmoji, data.LocalStatusSummary) if data.SecondaryEnabled { secondaryEmoji := GetStorageEmoji(data.SecondaryStatus) - msg.WriteString(fmt.Sprintf("%s Secondary (%s backups)\n", secondaryEmoji, data.SecondaryStatusSummary)) + fmt.Fprintf(&msg, "%s Secondary (%s backups)\n", secondaryEmoji, data.SecondaryStatusSummary) } else { msg.WriteString("➖ Secondary (disabled)\n") } if data.CloudEnabled { cloudEmoji := GetStorageEmoji(data.CloudStatus) - msg.WriteString(fmt.Sprintf("%s Cloud (%s backups)\n", cloudEmoji, data.CloudStatusSummary)) + fmt.Fprintf(&msg, "%s Cloud (%s backups)\n", cloudEmoji, data.CloudStatusSummary) } else { msg.WriteString("➖ Cloud (disabled)\n") } // Email status emailEmoji := GetStorageEmoji(data.EmailStatus) - msg.WriteString(fmt.Sprintf("%s Email\n\n", emailEmoji)) + fmt.Fprintf(&msg, "%s Email\n\n", emailEmoji) // File counts - msg.WriteString(fmt.Sprintf("📁 Included files: %d\n", data.FilesIncluded)) + fmt.Fprintf(&msg, "📁 Included files: %d\n", data.FilesIncluded) if data.FilesMissing > 0 { - msg.WriteString(fmt.Sprintf("⚠️ Missing files: %d\n", data.FilesMissing)) + fmt.Fprintf(&msg, "⚠️ Missing files: %d\n", data.FilesMissing) } msg.WriteString("\n") // Disk space msg.WriteString("💾 Available space:\n") - msg.WriteString(fmt.Sprintf("🔹 Local: %s\n", data.LocalFree)) + fmt.Fprintf(&msg, "🔹 Local: %s\n", data.LocalFree) if data.SecondaryEnabled && data.SecondaryFree != "" { - msg.WriteString(fmt.Sprintf("🔹 Secondary: %s\n", data.SecondaryFree)) + fmt.Fprintf(&msg, "🔹 Secondary: %s\n", data.SecondaryFree) } msg.WriteString("\n") // Backup metadata - msg.WriteString(fmt.Sprintf("📅 Backup date: %s\n", data.BackupDate.Format("2006-01-02 15:04"))) - msg.WriteString(fmt.Sprintf("⏱️ Duration: %s\n\n", FormatDuration(data.BackupDuration))) + fmt.Fprintf(&msg, "📅 Backup date: %s\n", data.BackupDate.Format("2006-01-02 15:04")) + fmt.Fprintf(&msg, "⏱️ Duration: %s\n\n", FormatDuration(data.BackupDuration)) // Exit code - msg.WriteString(fmt.Sprintf("🔢 Exit code: %d", data.ExitCode)) + fmt.Fprintf(&msg, "🔢 Exit code: %d", data.ExitCode) // Optional version update information if data.NewVersionAvailable && strings.TrimSpace(data.LatestVersion) != "" { @@ -307,9 +307,9 @@ func (t *TelegramNotifier) buildMessage(data *NotificationData) string { current := strings.TrimSpace(data.CurrentVersion) if current != "" { - msg.WriteString(fmt.Sprintf("New version: %s (current: %s)\n", data.LatestVersion, current)) + fmt.Fprintf(&msg, "New version: %s (current: %s)\n", data.LatestVersion, current) } else { - msg.WriteString(fmt.Sprintf("New version: %s\n", data.LatestVersion)) + fmt.Fprintf(&msg, "New version: %s\n", data.LatestVersion) } msg.WriteString("Run 'proxsave --upgrade'\n") } @@ -340,7 +340,7 @@ func (t *TelegramNotifier) sendToTelegram(ctx context.Context, botToken, chatID, if err != nil { return fmt.Errorf("api request failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() // Check response if resp.StatusCode != 200 { diff --git a/internal/notify/telegram_registration.go b/internal/notify/telegram_registration.go index 69a4fd39..4730094b 100644 --- a/internal/notify/telegram_registration.go +++ b/internal/notify/telegram_registration.go @@ -70,7 +70,7 @@ func CheckTelegramRegistration(ctx context.Context, serverAPIHost, serverID stri logTelegramRegistrationDebug(logger, "Telegram registration: request failed: %v", err) return TelegramRegistrationStatus{Message: "Connection failed", Error: err} } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, _ := io.ReadAll(resp.Body) logTelegramRegistrationDebug(logger, "Telegram registration: response status=%d bodyBytes=%d bodyPreview=%q", resp.StatusCode, len(body), truncateTelegramRegistrationBody(body, 200)) diff --git a/internal/notify/telegram_registration_test.go b/internal/notify/telegram_registration_test.go index bf38776c..9e1dfa77 100644 --- a/internal/notify/telegram_registration_test.go +++ b/internal/notify/telegram_registration_test.go @@ -39,7 +39,7 @@ func TestCheckTelegramRegistrationResponses(t *testing.T) { t.Run(tt.name, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(tt.statusCode) - w.Write([]byte(tt.name)) + _, _ = w.Write([]byte(tt.name)) })) defer server.Close() diff --git a/internal/notify/templates.go b/internal/notify/templates.go index d8ed667e..0bdbfb14 100644 --- a/internal/notify/templates.go +++ b/internal/notify/templates.go @@ -20,53 +20,53 @@ func BuildEmailPlainText(data *NotificationData) string { var body strings.Builder statusEmoji := GetStatusEmoji(data.Status) - body.WriteString(fmt.Sprintf("%s %s BACKUP REPORT - %s\n", - statusEmoji, strings.ToUpper(data.ProxmoxType.String()), strings.ToUpper(data.Status.String()))) - body.WriteString(fmt.Sprintf("Hostname: %s\n", data.Hostname)) - body.WriteString(fmt.Sprintf("Date: %s\n\n", data.BackupDate.Format("2006-01-02 15:04:05"))) + fmt.Fprintf(&body, "%s %s BACKUP REPORT - %s\n", + statusEmoji, strings.ToUpper(data.ProxmoxType.String()), strings.ToUpper(data.Status.String())) + fmt.Fprintf(&body, "Hostname: %s\n", data.Hostname) + fmt.Fprintf(&body, "Date: %s\n\n", data.BackupDate.Format("2006-01-02 15:04:05")) body.WriteString("BACKUP STATUS:\n") - body.WriteString(fmt.Sprintf(" Local: %s backups (%s free)\n", data.LocalStatusSummary, data.LocalFree)) + fmt.Fprintf(&body, " Local: %s backups (%s free)\n", data.LocalStatusSummary, data.LocalFree) if data.SecondaryEnabled { - body.WriteString(fmt.Sprintf(" Secondary: %s backups (%s free)\n", data.SecondaryStatusSummary, data.SecondaryFree)) + fmt.Fprintf(&body, " Secondary: %s backups (%s free)\n", data.SecondaryStatusSummary, data.SecondaryFree) } if data.CloudEnabled { - body.WriteString(fmt.Sprintf(" Cloud: %s backups\n", data.CloudStatusSummary)) + fmt.Fprintf(&body, " Cloud: %s backups\n", data.CloudStatusSummary) } body.WriteString("\n") body.WriteString("BACKUP DETAILS:\n") - body.WriteString(fmt.Sprintf(" Backup File: %s\n", data.BackupFile)) - body.WriteString(fmt.Sprintf(" Size: %s\n", data.BackupSizeHR)) - body.WriteString(fmt.Sprintf(" Included Files: %d\n", data.FilesIncluded)) - body.WriteString(fmt.Sprintf(" Missing Files: %d\n", data.FilesMissing)) - body.WriteString(fmt.Sprintf(" Duration: %s\n", FormatDuration(data.BackupDuration))) - body.WriteString(fmt.Sprintf(" Compression: %s (level %d, ratio %.2f%%)\n", - data.CompressionType, data.CompressionLevel, data.CompressionRatio)) + fmt.Fprintf(&body, " Backup File: %s\n", data.BackupFile) + fmt.Fprintf(&body, " Size: %s\n", data.BackupSizeHR) + fmt.Fprintf(&body, " Included Files: %d\n", data.FilesIncluded) + fmt.Fprintf(&body, " Missing Files: %d\n", data.FilesMissing) + fmt.Fprintf(&body, " Duration: %s\n", FormatDuration(data.BackupDuration)) + fmt.Fprintf(&body, " Compression: %s (level %d, ratio %.2f%%)\n", + data.CompressionType, data.CompressionLevel, data.CompressionRatio) body.WriteString("\n") body.WriteString("ISSUES:\n") - body.WriteString(fmt.Sprintf(" Errors: %d\n", data.ErrorCount)) - body.WriteString(fmt.Sprintf(" Warnings: %d\n", data.WarningCount)) - body.WriteString(fmt.Sprintf(" Total Issues: %d\n", data.TotalIssues)) + fmt.Fprintf(&body, " Errors: %d\n", data.ErrorCount) + fmt.Fprintf(&body, " Warnings: %d\n", data.WarningCount) + fmt.Fprintf(&body, " Total Issues: %d\n", data.TotalIssues) if data.LogFilePath != "" { - body.WriteString(fmt.Sprintf(" Log: %s\n", data.LogFilePath)) + fmt.Fprintf(&body, " Log: %s\n", data.LogFilePath) } body.WriteString("\n") if len(data.LogCategories) > 0 { body.WriteString("ISSUE DETAILS:\n") for _, cat := range data.LogCategories { - body.WriteString(fmt.Sprintf(" - [%s] %s (count: %d)\n", cat.Type, cat.Label, cat.Count)) + fmt.Fprintf(&body, " - [%s] %s (count: %d)\n", cat.Type, cat.Label, cat.Count) if cat.Example != "" { - body.WriteString(fmt.Sprintf(" Example: %s\n", cat.Example)) + fmt.Fprintf(&body, " Example: %s\n", cat.Example) } } body.WriteString("\n") } - body.WriteString(fmt.Sprintf("Exit Code: %d\n", data.ExitCode)) - body.WriteString(fmt.Sprintf("Script Version: %s\n", data.ScriptVersion)) + fmt.Fprintf(&body, "Exit Code: %d\n", data.ExitCode) + fmt.Fprintf(&body, "Script Version: %s\n", data.ScriptVersion) return body.String() } @@ -101,7 +101,7 @@ func BuildEmailHTML(data *NotificationData) string { html.WriteString("\n") html.WriteString("\n
\n") html.WriteString(" \n") - html.WriteString(fmt.Sprintf("%s - %s
\n", data.Hostname, data.BackupDate.Format("2006-01-02 15:04:05"))) + fmt.Fprintf(&html, "%s - %s
\n", data.Hostname, data.BackupDate.Format("2006-01-02 15:04:05")) html.WriteString("Total Issues: %d
\n", data.TotalIssues)) - html.WriteString(fmt.Sprintf("Errors: %d
\n", data.ErrorCount)) - html.WriteString(fmt.Sprintf("Warnings: %d
\n", data.WarningCount)) + fmt.Fprintf(&html, "Total Issues: %d
\n", data.TotalIssues) + fmt.Fprintf(&html, "Errors: %d
\n", data.ErrorCount) + fmt.Fprintf(&html, "Warnings: %d
\n", data.WarningCount) html.WriteString("Full log available at: %s
\n", escapeHTML(data.LogFilePath))) + fmt.Fprintf(&html, "Full log available at: %s
\n", escapeHTML(data.LogFilePath)) } html.WriteString("⚠️ Local storage is %.1f%% full. Consider cleaning old backups or expanding storage capacity.
\n", data.LocalUsagePercent)) + fmt.Fprintf(&html, "⚠️ Local storage is %.1f%% full. Consider cleaning old backups or expanding storage capacity.
\n", data.LocalUsagePercent) } if data.SecondaryEnabled && data.SecondaryUsagePercent > 85 { - html.WriteString(fmt.Sprintf("⚠️ Secondary storage is %.1f%% full. Consider cleaning old backups or expanding storage capacity.
\n", data.SecondaryUsagePercent)) + fmt.Fprintf(&html, "⚠️ Secondary storage is %.1f%% full. Consider cleaning old backups or expanding storage capacity.
\n", data.SecondaryUsagePercent) } html.WriteString("