From 75ad134b5b8819cabbff2bd0f13472c410148a33 Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:58:59 +0200 Subject: [PATCH 01/35] docs upgrade - dual collection --- docs/BACKUP_ENV_MAPPING.md | 1 + docs/CLI_REFERENCE.md | 45 ++++--- docs/COLLECTOR_ARCHITECTURE.md | 224 +++++++++++++++++++++++++++++++++ docs/CONFIGURATION.md | 26 +++- docs/DEVELOPER_GUIDE.md | 34 ++++- docs/EXAMPLES.md | 66 ++++++++++ docs/README.md | 33 +++++ docs/RESTORE_DIAGRAMS.md | 21 ++-- docs/RESTORE_GUIDE.md | 71 ++++++++++- docs/RESTORE_TECHNICAL.md | 71 +++++------ docs/TROUBLESHOOTING.md | 3 +- 11 files changed, 520 insertions(+), 75 deletions(-) create mode 100644 docs/COLLECTOR_ARCHITECTURE.md create mode 100644 docs/README.md diff --git a/docs/BACKUP_ENV_MAPPING.md b/docs/BACKUP_ENV_MAPPING.md index 001b21d6..24d5eb81 100644 --- a/docs/BACKUP_ENV_MAPPING.md +++ b/docs/BACKUP_ENV_MAPPING.md @@ -90,6 +90,7 @@ SYSTEM_ROOT_PREFIX = NEW (Go-only) → Override system root for collection (test PVESH_TIMEOUT = NEW (Go-only) → Timeout (seconds) for each `pvesh` command execution (0=disabled). FS_IO_TIMEOUT = NEW (Go-only) → Timeout (seconds) for filesystem probes (stat/readdir/statfs) on storages (0=disabled). Helps avoid hangs on unreachable network mounts. NOTE: PBS restore behavior is selected interactively during `--restore` and is intentionally not configured via `backup.env`. +NOTE: There is no dedicated `DUAL_*` environment family. Dual-role hosts are detected automatically and use the same PVE/PBS collector flags in a single run. BACKUP_PBS_S3_ENDPOINTS = NEW (Go-only) → Collect `s3.cfg` and S3 endpoint snapshots (PBS). BACKUP_PBS_NODE_CONFIG = NEW (Go-only) → Collect `node.cfg` and node snapshots (PBS). BACKUP_PBS_ACME_ACCOUNTS = NEW (Go-only) → Collect `acme/accounts.cfg` and ACME account snapshots (PBS). diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index 5ed74a5b..bfd14304 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -432,27 +432,36 @@ Next step: ./build/proxsave --dry-run **Use `--cli` when**: TUI rendering issues occur or advanced debugging is needed. **Note**: CLI and TUI run the same workflow logic; `--cli` only changes the interface (prompts/progress rendering), not the restore/decrypt behavior. -**`--restore` workflow** (14 phases): +**`--restore` workflow** (16 phases): 1. Scans configured storage locations (local/secondary/cloud) 2. Lists available backups with metadata (encrypted or unencrypted) 3. If encrypted, prompts for decryption key/passphrase and decrypts -4. Validates system compatibility (PVE/PBS mismatch warning) -5. Analyzes backup categories -6. Presents restore mode selection: - - **Full Restore**: All categories - - **Storage Restore**: PVE/PBS-specific configs - - **Base System Restore**: Network, SSH, system files - - **Custom Restore**: Select specific categories -7. For cluster backups: prompts for **SAFE** (export+API) or **RECOVERY** (full restore) mode -8. Shows detailed restore plan with selected categories -9. Requires confirmation: type `RESTORE` to proceed -10. Creates safety backup of existing files -11. Stops services if needed (PVE: pve-cluster, pvedaemon, pveproxy, pvestatd; PBS: proxmox-backup-proxy, proxmox-backup) -12. Extracts selected categories to system root (`/`) -13. Exports export-only categories to separate directory -14. For SAFE cluster mode: offers to apply configs via `pvesh` API -15. Recreates storage/datastore directories, checks ZFS pools -16. Restarts services and displays completion summary +4. Detects the current host role (`pve`, `pbs`, `dual`, or `unknown`) +5. Validates compatibility using capability overlap and backup targets + - exact match: proceed normally + - partial match: continue with warning, then filter categories automatically + - no overlap: warn strongly before continuing +6. Analyzes backup categories +7. Presents restore mode selection: + - **Full Restore**: all compatible categories + - **Storage Restore**: storage/datastore-focused categories + - **Base System Restore**: network, SSH, system files + - **Custom Restore**: select specific categories +8. For cluster backups: prompts for **SAFE** (export+API) or **RECOVERY** (full restore) mode +9. Shows detailed restore plan with selected categories +10. Requires confirmation: type `RESTORE` to proceed +11. Creates safety backup of existing files +12. Stops services if needed (PVE: pve-cluster, pvedaemon, pveproxy, pvestatd; PBS: proxmox-backup-proxy, proxmox-backup) +13. Extracts selected categories to system root (`/`) +14. Exports export-only categories to separate directory +15. For SAFE cluster mode: offers to apply configs via `pvesh` API +16. Recreates storage/datastore directories, checks ZFS pools, restarts services, and displays completion summary + +**Compatibility model**: +- `dual` backups persist explicit targets (`pve`, `pbs`) +- restoring a `dual` backup to a single-role host is allowed +- ProxSave restores only categories compatible with the current host role +- `common` categories remain available across roles **⚠️ WARNING**: Restore operations overwrite files in-place. **Always test in a VM or snapshot your system first!** diff --git a/docs/COLLECTOR_ARCHITECTURE.md b/docs/COLLECTOR_ARCHITECTURE.md new file mode 100644 index 00000000..630f7e4e --- /dev/null +++ b/docs/COLLECTOR_ARCHITECTURE.md @@ -0,0 +1,224 @@ +# Collector Architecture + +This document describes the current backup collector design after the refactor to +recipes and fine-grained bricks. + +## Goals + +The collector is designed around three principles: + +- explicit orchestration +- fine-grained collection bricks +- role-aware composition for `pve`, `pbs`, `dual`, and `common/system` + +The goal is to avoid hidden macro-flows and make each backup branch easy to +compose, test, and reuse. + +## Domain Model + +The collector recognizes four environment types: + +- `pve`: Proxmox VE only +- `pbs`: Proxmox Backup Server only +- `dual`: host supports both PVE and PBS roles +- `unknown`: only `system/common` collection is considered authoritative + +`dual` is a real domain type, not an alias. It is represented in +`internal/types/common.go` and propagated through detection, stats, manifest, +metadata, collector, and restore. + +## Authoritative Entrypoints + +Top-level collection flows live on the collector and are the only authoritative +entrypoints: + +- `CollectAll()` +- `CollectPVEConfigs()` +- `CollectPBSConfigs()` +- `CollectDualConfigs()` +- `CollectSystemInfo()` + +Internal legacy wrappers are not part of the architecture contract and should +not be reintroduced as hidden orchestration layers. + +## Recipes + +The collector runtime is built from explicit recipes in +`internal/backup/collector_bricks.go`. + +The important builders are: + +- `newPVERecipe()` +- `newPBSRecipe()` +- `newDualRecipe()` +- `newSystemRecipe()` + +### Composition Rules + +- `newPVERecipe()` = PVE-only bricks +- `newPBSRecipe()` = PBS-only bricks +- `newDualRecipe()` = PVE bricks + PBS bricks +- `newSystemRecipe()` = common/system bricks only + +`system/common` is executed once. It is not duplicated inside `dual`. + +## Bricks + +Each recipe is composed of `collectionBrick` items identified by a stable +`BrickID`. + +A brick should be one of: + +- a domain brick with clear ownership +- a technical brick with a narrow, explicit purpose + +Examples: + +- domain bricks: + - `pve_runtime_core` + - `pbs_runtime_access_users` + - `common_storage_stack_lvm` + - `system_network_runtime_routes` +- technical bricks: + - `pbs_config_directory_copy` + +`pbs_config_directory_copy` is intentionally technical: it preserves pass-through +snapshot behavior for `/etc/proxmox-backup`, including unmodeled files. + +## PVE Branch + +The PVE branch is split into: + +- validation and cluster detection +- config snapshots +- runtime data +- guest config and inventory +- jobs and schedules +- replication +- storage pipeline +- Ceph +- alias/finalize steps + +The storage pipeline is explicitly broken into resolve, probe, metadata, backup +analysis, and summary steps. + +## PBS Branch + +The PBS branch is split into: + +- validation +- config snapshot and manifest population +- runtime collection +- datastore discovery/config/namespaces +- PXAR pipeline +- datastore inventory +- final summary + +PBS access control, notifications, remotes, jobs, tape, datastore state, and +PXAR metadata are no longer handled by macro-wrappers. They are exposed as +independent recipe bricks. + +## Dual Branch + +`CollectDualConfigs()` runs `newDualRecipe()` and collects both product roles in +a single backup run. + +Important semantics: + +- a `dual` backup creates one archive +- metadata persists `BACKUP_TYPE=dual` +- metadata/manifest also persist `BACKUP_TARGETS=pve,pbs` +- `system/common` remains single-pass + +`dual` is therefore a composition of PVE + PBS payloads plus one shared +`common/system` payload, not a separate category namespace. + +## Common/System Ownership + +`storage_stack` now belongs to `common/system`, not PBS. + +The common storage stack is split into dedicated bricks such as: + +- `common_filesystem_fstab` +- `common_storage_stack_crypttab` +- `common_storage_stack_iscsi` +- `common_storage_stack_multipath` +- `common_storage_stack_mdadm` +- `common_storage_stack_lvm` +- `common_storage_stack_mount_units` +- `common_storage_stack_autofs` +- `common_storage_stack_referenced_files` + +PBS inventory still records storage-related diagnostics, but it no longer owns +the copied files in the backup tree. + +## State and Context + +Recipes share state through `collectionState` and role-specific contexts: + +- `pveContext` +- `pbsContext` +- `systemContext` + +This allows later bricks to consume facts gathered earlier without re-reading +the environment implicitly. + +Examples: + +- datastore discovery feeding namespaces and PXAR +- PBS user IDs feeding token aggregation +- inventory state feeding report generation + +## Manifest and Metadata + +Two layers are important: + +- collector manifest (`manifest.json`) written in the temp tree +- backup metadata/sidecars written by the orchestrator/archive flow + +Current persisted role fields include: + +- `ProxmoxType` +- `ProxmoxTargets` +- `PVEVersion` +- `PBSVersion` + +These fields are used later by restore for backup-type detection and category +filtering. + +## Restore Relationship + +The collector does not introduce a new restore category for `dual`. + +Restore still works with category types: + +- `PVE` +- `PBS` +- `Common` + +`dual` is reconstructed from metadata/manifest/targets and then filtered through +capability overlap: + +- `dual` host can restore `PVE + PBS + Common` +- `pve` host can restore `PVE + Common` from a `dual` backup +- `pbs` host can restore `PBS + Common` from a `dual` backup + +## Testing Policy + +Tests should target: + +- top-level real entrypoints for orchestration/integration +- recipes and bricks for feature-level behavior + +Tests should not depend on historical wrapper functions that are not part of the +real collector flow. + +## Related Files + +- `internal/backup/collector.go` +- `internal/backup/collector_bricks.go` +- `internal/backup/collector_dual.go` +- `internal/backup/collector_manifest.go` +- `internal/backup/collector_storage_stack_common.go` +- `internal/environment/detect.go` +- `internal/orchestrator/compatibility.go` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index d9997b26..f43edcdd 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -6,7 +6,7 @@ Complete reference for all 200+ configuration variables in `configs/backup.env`. - [Configuration File Location](#configuration-file-location) - [General Settings](#general-settings) -- [Restore (PBS)](#restore-pbs) +- [Restore Behavior & Dual-Role Hosts](#restore-behavior--dual-role-hosts) - [Security Settings](#security-settings) - [Disk Space](#disk-space) - [Storage Paths](#storage-paths) @@ -72,9 +72,10 @@ PROFILING_ENABLED=true # true | false (profiles written under LOG_PA --- -## Restore (PBS) +## Restore Behavior & Dual-Role Hosts -PBS restore behavior is chosen **interactively at restore time** on PBS hosts (not via `backup.env`). +Restore behavior is chosen **interactively at restore time** when PBS-specific +categories are going to be applied. This is not configured through `backup.env`. You will be asked to choose a behavior: - **Merge (existing PBS)**: intended for restoring onto an already operational PBS; ProxSave applies supported PBS categories via `proxmox-backup-manager` without deleting existing objects that are not in the backup. @@ -82,6 +83,23 @@ You will be asked to choose a behavior: ProxSave applies supported PBS staged categories via API automatically (and may fall back to file-based staged apply only in **Clean 1:1** mode). +### Dual-role hosts + +ProxSave automatically detects the current host role as one of: + +- `pve` +- `pbs` +- `dual` +- `unknown` + +There is no dedicated `backup.env` switch for `dual`. On a co-installed host, +`dual` is detected automatically and a single backup run can include both PVE +and PBS payloads plus one shared `common/system` payload. + +Dual backups persist explicit target metadata (`BACKUP_TYPE=dual`, +`BACKUP_TARGETS=pve,pbs`) and restore uses that metadata to filter categories +to the roles supported by the current host. + **Current API coverage**: - Node + traffic control (`pbs_host`) - Datastores + S3 endpoints (`datastore_pbs`) @@ -846,10 +864,12 @@ If `EMAIL_ENABLED` is omitted, the default remains `false`. The legacy alias `EM - Allowed values for `EMAIL_DELIVERY_METHOD` are: `relay`, `sendmail`, `pmf` (invalid values will skip Email with a warning). - `EMAIL_FALLBACK_SENDMAIL` is a historical name (kept for compatibility). When `EMAIL_DELIVERY_METHOD=relay`, it enables fallback to **pmf** (it will not fall back to `/usr/sbin/sendmail`). - `relay` requires a real mailbox recipient and blocks `root@…` recipients; set `EMAIL_RECIPIENT` to a non-root mailbox if needed. +- When relay preconditions fail before delivery starts (for example missing recipient, autodetect failure, or blocked `root@…` recipient) and fallback is enabled, ProxSave may bypass relay and invoke `pmf` directly. - When logs say the relay "accepted request", it means the worker and upstream email API accepted the submission. It does **not** guarantee final inbox delivery (the message may still bounce, be deferred, or land in spam later). - If `EMAIL_RECIPIENT` is empty, ProxSave auto-detects the recipient from the `root@pam` user: - **PVE**: Proxmox API via `pvesh get /access/users/root@pam` → fallback to `pveum user list` → fallback to `/etc/pve/user.cfg` - **PBS**: `proxmox-backup-manager user list` → fallback to `/etc/proxmox-backup/user.cfg` + - **Dual**: intentionally uses the **PVE** detection path for `root@pam` email discovery - `sendmail` requires a recipient and uses `/usr/sbin/sendmail` (auto-detect applies if `EMAIL_RECIPIENT` is empty, as described above). - With `pmf`, final delivery recipients are determined by Proxmox Notifications targets/matchers. `EMAIL_RECIPIENT` is only used for the `To:` header and may be empty. diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index d6c823b2..7dbde475 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -116,7 +116,7 @@ proxsave/ │ ├── tui/ # TUI wizards │ ├── types/ # Shared types │ └── version/ # Version info -├── pkg/ # Shared helper packages for ProxSave (not an implicit stable external API) +├── pkg/ # Shared helper packages for Proxsave (not an implicit stable external API) ├── build/ # Build artifacts (binary output) ├── configs/ # Configuration files ├── docs/ # Documentation @@ -130,15 +130,37 @@ proxsave/ | Module | Purpose | Files | |--------|---------|-------| -| **orchestrator** | Core backup/restore orchestration | `internal/orchestrator/*.go` | +| **orchestrator** | Core backup/restore orchestration and capability-based restore decisions | `internal/orchestrator/*.go` | | **config** | Configuration management | `internal/config/config.go` | | **storage** | Local/secondary/cloud storage | `internal/storage/*.go` | -| **backup** | Archiving + manifest/checksum helpers | `internal/backup/*.go` | +| **backup** | Collector recipes/bricks, archiving, manifest/checksum helpers | `internal/backup/*.go` | | **notify** | Notification channels | `internal/notify/*.go` | | **security** | Security checks, permissions | `internal/security/*.go` | --- +### Collector Architecture + +The backup collector is no longer organized around large branch-specific +wrappers. It is built from explicit recipes and fine-grained bricks: + +- `newPVERecipe()` +- `newPBSRecipe()` +- `newDualRecipe()` +- `newSystemRecipe()` + +Important invariants: + +- `dual` is a real type, not an alias +- `dual` composes PVE + PBS bricks in a single run +- `system/common` runs only once +- `storage_stack` belongs to `common/system`, not PBS + +For the authoritative architecture description, see +[Collector Architecture](COLLECTOR_ARCHITECTURE.md). + +--- + ## Building & Running ### Development Build @@ -643,11 +665,13 @@ rclone check /local/dir/ gdrive:pbs-backups/ --checksum ## Related Documentation ### User Documentation -- **[README](../README.md)** - Project overview and quick start +- **[Docs Index](README.md)** - Documentation hub for the `docs/` tree - **[Configuration Guide](CONFIGURATION.md)** - All configuration variables - **[CLI Reference](CLI_REFERENCE.md)** - Command-line flags ### Contributor Documentation +- **[Collector Architecture](COLLECTOR_ARCHITECTURE.md)** - Collector recipes, bricks, and `dual` +- **[Restore Technical](RESTORE_TECHNICAL.md)** - Restore internals and compatibility flow - **[Migration Guide](MIGRATION_GUIDE.md)** - Bash to Go migration - **[Troubleshooting](TROUBLESHOOTING.md)** - Common issues @@ -687,7 +711,7 @@ Testing: Documentation: □ Update relevant docs/*.md files □ Add usage examples -□ Update README.md if needed +□ Update docs/README.md and architecture docs if navigation changes □ Write clear commit messages Before Submitting PR: diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 3f8a6eb2..4571e0cb 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -14,6 +14,7 @@ Real-world configuration examples for Proxsave covering common deployment scenar - [Example 7: Multi-Notification Setup](#example-7-multi-notification-setup) - [Example 8: Complete Production Setup](#example-8-complete-production-setup) - [Example 9: Test in a Chroot/Fixture](#example-9-test-in-a-chrootfixture) +- [Example 10: Dual PVE+PBS Host](#example-10-dual-pvepbs-host) - [Related Documentation](#related-documentation) --- @@ -888,6 +889,71 @@ SYSTEM_ROOT_PREFIX=/mnt/snapshot-root ./build/proxsave --- +## Example 10: Dual PVE+PBS Host + +**Scenario**: A single node runs both Proxmox VE and Proxmox Backup Server. + +**Use case**: +- Lab or edge node with co-installed PVE + PBS +- Single backup run should include both product roles +- Restore must remain compatible with `dual`, `pve`, or `pbs` targets + +### Configuration + +```bash +# configs/backup.env +BACKUP_ENABLED=true +BACKUP_PATH=/opt/proxsave/backup +LOG_PATH=/opt/proxsave/log + +# Common/system collection +BACKUP_NETWORK_CONFIGS=true +BACKUP_CRON_JOBS=true +BACKUP_SYSTEMD_SERVICES=true +BACKUP_ZFS_CONFIG=true + +# PVE collection +BACKUP_VM_CONFIGS=true +BACKUP_CLUSTER_CONFIG=true +BACKUP_PVE_JOBS=true +BACKUP_PVE_REPLICATION=true +BACKUP_PVE_FIREWALL=true + +# PBS collection +BACKUP_DATASTORE_CONFIGS=true +BACKUP_REMOTE_CONFIGS=true +BACKUP_SYNC_JOBS=true +BACKUP_VERIFICATION_JOBS=true +BACKUP_PBS_NOTIFICATIONS=true +BACKUP_PBS_NODE_CONFIG=true + +# Recommended for dual labs: keep diagnostics +BACKUP_PXAR_FILES=true +``` + +### Expected Behavior + +- ProxSave auto-detects the host as `dual` +- One archive is produced for the run +- Metadata persists: + - `BACKUP_TYPE=dual` + - `BACKUP_TARGETS=pve,pbs` +- The backup contains: + - PVE categories + - PBS categories + - one shared `common/system` payload + +### Restore Notes + +- Restore on a `dual` host: full `PVE + PBS + Common` +- Restore on a `pve` host: `PVE + Common` +- Restore on a `pbs` host: `PBS + Common` + +The restore workflow filters categories automatically when the current host does +not support all backup targets. + +--- + ## Next Steps 1. **Choose an example** closest to your use case diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..774ddd27 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,33 @@ +# Proxsave Documentation Index + +This directory contains the authoritative project documentation. + +The repository root `README.md` intentionally remains minimal. Use the documents +below for the current operational and technical behavior. + +## User Guides + +- [INSTALL.md](INSTALL.md): installation, reinstall, and upgrade flows +- [CONFIGURATION.md](CONFIGURATION.md): complete `backup.env` reference +- [CLI_REFERENCE.md](CLI_REFERENCE.md): commands, flags, and workflow phases +- [EXAMPLES.md](EXAMPLES.md): ready-to-use configuration examples +- [RESTORE_GUIDE.md](RESTORE_GUIDE.md): full restore guide and category behavior +- [TROUBLESHOOTING.md](TROUBLESHOOTING.md): operational diagnostics and fixes + +## Architecture & Developer Docs + +- [DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md): contributor setup and development workflow +- [COLLECTOR_ARCHITECTURE.md](COLLECTOR_ARCHITECTURE.md): collector recipes, bricks, and `dual` +- [RESTORE_TECHNICAL.md](RESTORE_TECHNICAL.md): restore internals and orchestration details +- [RESTORE_DIAGRAMS.md](RESTORE_DIAGRAMS.md): visual restore workflow diagrams + +## Supporting References + +- [BACKUP_ENV_MAPPING.md](BACKUP_ENV_MAPPING.md): legacy Bash to Go env mapping +- [CLOUD_STORAGE.md](CLOUD_STORAGE.md): cloud/rclone behavior +- [ENCRYPTION.md](ENCRYPTION.md): archive encryption and decrypt/restore flow +- [PROVENANCE_VERIFICATION.md](PROVENANCE_VERIFICATION.md): attestation verification +- [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md): migration from the Bash implementation +- [LEGACY_BASH.md](LEGACY_BASH.md): legacy Bash notes and compatibility +- [CLUSTER_RECOVERY.md](CLUSTER_RECOVERY.md): PVE cluster disaster recovery +- [RELEASE-PROCESS.md](RELEASE-PROCESS.md): release engineering notes diff --git a/docs/RESTORE_DIAGRAMS.md b/docs/RESTORE_DIAGRAMS.md index 3dca90ab..5ae50e80 100644 --- a/docs/RESTORE_DIAGRAMS.md +++ b/docs/RESTORE_DIAGRAMS.md @@ -100,11 +100,13 @@ flowchart TD Full --> SystemFull{System Type?} SystemFull -->|PVE| PVEFull[PVE Categories:
- pve_cluster
- storage_pve
- pve_jobs
- pve_notifications
- pve_access_control
- pve_firewall
- pve_ha
- pve_sdn
- corosync
- ceph
+ Common] SystemFull -->|PBS| PBSFull[PBS Categories:
- pbs_host
- datastore_pbs
- maintenance_pbs
- pbs_jobs
- pbs_remotes
- pbs_notifications
- pbs_access_control
- pbs_tape
+ Common] + SystemFull -->|DUAL| DualFull[Dual Categories:
- PVE categories
- PBS categories
- Common categories] SystemFull -->|Unknown| CommonFull[Common Only:
- filesystem
- storage_stack
- network
- ssl
- ssh
- scripts
- crontabs
- services
- user_data
- zfs
- proxsave_info] Storage --> SystemStorage{System Type?} SystemStorage -->|PVE| PVEStorage[- pve_cluster
- storage_pve
- pve_jobs
- filesystem
- storage_stack
- zfs] SystemStorage -->|PBS| PBSStorage[- datastore_pbs
- maintenance_pbs
- pbs_jobs
- pbs_remotes
- filesystem
- storage_stack
- zfs] + SystemStorage -->|DUAL| DualStorage[- PVE storage categories
- PBS storage categories
- filesystem
- storage_stack
- zfs] Base --> BaseCats[- network
- ssl
- ssh
- services
- filesystem] @@ -456,14 +458,17 @@ flowchart TD CheckSystem -->|PVE| FilterPVE[Filter Categories] CheckSystem -->|PBS| FilterPBS[Filter Categories] + CheckSystem -->|DUAL| FilterDual[Filter Categories] CheckSystem -->|Unknown| FilterCommon[Filter Categories] FilterPVE --> IncludePVE["Include:
- CategoryTypePVE
- CategoryTypeCommon"] FilterPBS --> IncludePBS["Include:
- CategoryTypePBS
- CategoryTypeCommon"] + FilterDual --> IncludeDual["Include:
- CategoryTypePVE
- CategoryTypePBS
- CategoryTypeCommon"] FilterCommon --> IncludeOnlyCommon["Include:
- CategoryTypeCommon only"] IncludePVE --> CheckMode{Restore Mode?} IncludePBS --> CheckMode + IncludeDual --> CheckMode IncludeOnlyCommon --> CheckMode CheckMode -->|Full/Storage/Base| RemoveExport[Remove ExportOnly = true] @@ -629,25 +634,27 @@ flowchart TD ```mermaid flowchart TD Start([Backup Prepared]) --> DetectCurrent[Detect Current System] - DetectCurrent --> CheckPVE{"/etc/pve exists
AND /usr/bin/qm exists?"} + DetectCurrent --> CheckSystem{PVE indicators?
PBS indicators?} - CheckPVE -->|Yes| CurrentPVE[Current: PVE] - CheckPVE -->|No| CheckPBS{"/etc/proxmox-backup exists
AND /usr/sbin/proxmox-backup-proxy?"} - - CheckPBS -->|Yes| CurrentPBS[Current: PBS] - CheckPBS -->|No| CurrentUnknown[Current: Unknown] + CheckSystem -->|PVE only| CurrentPVE[Current: PVE] + CheckSystem -->|PBS only| CurrentPBS[Current: PBS] + CheckSystem -->|Both| CurrentDual[Current: DUAL] + CheckSystem -->|Neither| CurrentUnknown[Current: Unknown] CurrentPVE --> ReadManifest CurrentPBS --> ReadManifest + CurrentDual --> ReadManifest CurrentUnknown --> ReadManifest[Read Backup Manifest] - ReadManifest --> CheckBackupType{manifest.ProxmoxType
OR hostname pattern} + ReadManifest --> CheckBackupType{manifest.ProxmoxTargets
or ProxmoxType
or hostname pattern} CheckBackupType -->|pve| BackupPVE[Backup: PVE] CheckBackupType -->|pbs| BackupPBS[Backup: PBS] + CheckBackupType -->|dual| BackupDual[Backup: DUAL] CheckBackupType -->|Unknown| BackupUnknown[Backup: Unknown] BackupPVE --> Compare BackupPBS --> Compare + BackupDual --> Compare BackupUnknown --> Compare[Compare Types] Compare --> Match{Current == Backup?} diff --git a/docs/RESTORE_GUIDE.md b/docs/RESTORE_GUIDE.md index 53312fb9..d71aff3e 100644 --- a/docs/RESTORE_GUIDE.md +++ b/docs/RESTORE_GUIDE.md @@ -1,6 +1,7 @@ # Proxsave - Restore Guide -Complete guide for restoring Proxmox VE and Proxmox Backup Server configurations using the interactive restore workflow. +Complete guide for restoring Proxmox VE, Proxmox Backup Server, and dual-role +PVE+PBS backups using the interactive restore workflow. ## Table of Contents @@ -60,6 +61,32 @@ The `--restore` command provides an **interactive, category-based restoration sy - **Export-only protection**: Critical paths protected from direct writes - **Comprehensive logging**: Detailed audit trail of all operations +### System Types and Compatibility + +Restore decisions are now based on four host types: + +- `pve` +- `pbs` +- `dual` +- `unknown` + +Backups also persist explicit target roles. This means compatibility is no +longer a simple exact match: + +- **Full compatibility**: current host and backup targets match exactly +- **Partial compatibility**: backup and host share at least one role +- **Incompatible**: backup and host share no role + +Examples: + +- `dual` backup on `dual` host: restore `PVE + PBS + Common` +- `dual` backup on `pve` host: restore `PVE + Common` +- `dual` backup on `pbs` host: restore `PBS + Common` +- `pve` backup on `dual` host: restore `PVE + Common` + +`unknown` hosts can still use export-oriented or common-only workflows, but +ProxSave warns because role-specific compatibility cannot be verified. + ### What Gets Restored - System configurations (network, SSH, SSL, services) @@ -204,6 +231,10 @@ Four predefined modes provide common restoration scenarios, plus custom selectio - **Normal** categories are restored to system paths - **Staged** categories are extracted under `/tmp/proxsave/restore-stage-*` and applied automatically (API/file apply) - **Export-only** categories (e.g. `pve_config_export`, `pbs_config`) are extracted to the export directory for manual review/application +- On a `dual` host, FULL restore can include PVE, PBS, and Common categories in + the same run +- On a single-role host restoring a `dual` backup, ProxSave automatically + filters the FULL selection to compatible categories **Command Flow**: ``` @@ -239,6 +270,10 @@ Select restore mode: - `storage_stack` - Storage stack config (mount prerequisites) - `zfs` - ZFS configuration +**Dual hosts**: +- STORAGE mode on a `dual` host includes the compatible storage-focused + categories from both product roles plus the common storage categories + **Command Flow**: ``` Select restore mode: @@ -334,10 +369,10 @@ Phase 2: Decryption (if needed) └─ Verify SHA256 checksum Phase 3: Compatibility Check - ├─ Detect current system type (PVE/PBS/Unknown) + ├─ Detect current system type (PVE/PBS/DUAL/Unknown) ├─ Read backup type from manifest - ├─ Validate compatibility - └─ Warn if mismatch, require confirmation + ├─ Validate compatibility (exact / partial / incompatible) + └─ Filter to compatible categories when needed Phase 4: Category Analysis ├─ Open and scan archive @@ -402,7 +437,7 @@ Phase 13: SAFE Apply (Cluster SAFE Mode Only) Phase 14: Post-Restore Tasks ├─ Optional: Apply restored network config with rollback timer (requires COMMIT) ├─ Recreate storage/datastore directories - ├─ Check ZFS pool status (PBS only) + ├─ Check ZFS pool status when the `zfs` category was restored (including dual hosts) ├─ Restart PVE/PBS services (if stopped) └─ Display completion summary ``` @@ -463,6 +498,18 @@ Backup system type: Proxmox Virtual Environment (PVE) ✓ Systems are compatible ``` +**Partial-compatibility warning**: +``` +⚠ WARNING: Partial compatibility detected + +Current system: Proxmox Virtual Environment (PVE) +Backup source: Proxmox VE + Proxmox Backup Server (DUAL) + +ProxSave will continue with the categories compatible with the current host: +- PVE categories +- Common categories +``` + **Incompatibility warning**: ``` ⚠ WARNING: Potential incompatibility detected! @@ -2438,7 +2485,17 @@ systemctl restart proxmox-backup proxmox-backup-proxy **Q: Can I restore PVE backup to PBS system (or vice versa)?** -A: Not recommended. The restore workflow will warn about incompatibility. PVE and PBS have different configurations that are not interchangeable. However, **common categories** (network, SSH, SSL) can be safely restored cross-platform using Custom mode. +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**: + +- `pve` ↔ `pbs`: only common categories are sensible +- `dual` → `pve`: PVE + Common can be restored +- `dual` → `pbs`: PBS + Common can be restored +- `pve` or `pbs` → `dual`: the matching role + Common can be restored + +When overlap exists, ProxSave continues with warnings and automatically filters +the selected categories to the roles supported by the current host. --- @@ -2464,6 +2521,8 @@ Typical full restore: **5-15 minutes** A: Yes, with considerations: - **Same system type** (PVE to PVE, PBS to PBS) recommended +- **Dual-role to single-role** restores are allowed, but only matching role + categories plus Common are applied - **Hostname** should match or be updated manually - **Network configuration** may need adjustment - **Storage paths** may need adjustment diff --git a/docs/RESTORE_TECHNICAL.md b/docs/RESTORE_TECHNICAL.md index b862e377..971552e8 100644 --- a/docs/RESTORE_TECHNICAL.md +++ b/docs/RESTORE_TECHNICAL.md @@ -425,57 +425,58 @@ type PreparedBackup struct { **File**: `internal/orchestrator/restore.go:58-72` ```go -systemType := DetectSystemType(logger) -logger.Info("Current system type: %s", systemType) +systemType := DetectCurrentSystem() +backupType := DetectBackupType(prepared.Manifest) -if err := ValidateCompatibility(systemType, prepared.Manifest, reader); err != nil { +if err := ValidateCompatibility(systemType, backupType); err != nil { logger.Warning("Compatibility check: %v", err) - // Prompt user to continue or abort + // Continue with warning or abort depending on workflow context } ``` -**System Detection** (`compatibility.go:21-33`): +**System Detection** (`compatibility.go`): ```go -func DetectSystemType(logger *logging.Logger) SystemType { - // Check for PVE indicators - if _, err := os.Stat("/etc/pve"); err == nil { - if _, err := os.Stat("/usr/bin/qm"); err == nil { - return SystemTypePVE - } - } +func DetectCurrentSystem() SystemType { + hasPVE := fileExists("/etc/pve") || fileExists("/usr/bin/qm") || fileExists("/usr/bin/pct") + hasPBS := fileExists("/etc/proxmox-backup") || fileExists("/usr/sbin/proxmox-backup-proxy") - // Check for PBS indicators - if _, err := os.Stat("/etc/proxmox-backup"); err == nil { - if _, err := os.Stat("/usr/sbin/proxmox-backup-proxy"); err == nil { - return SystemTypePBS - } + switch { + case hasPVE && hasPBS: + return SystemTypeDual + case hasPVE: + return SystemTypePVE + case hasPBS: + return SystemTypePBS + default: + return SystemTypeUnknown } - - return SystemTypeUnknown } ``` -**Compatibility Check** (`compatibility.go:67-97`): +Restore compatibility is therefore **capability-based**, not exact-match only. + +**Backup Type Detection**: ```go -func ValidateCompatibility( - systemType SystemType, - manifest *Manifest, - reader *bufio.Reader, -) error { - backupType := DetermineBackupSystemType(manifest) - - if systemType != SystemTypeUnknown && - backupType != SystemTypeUnknown && - systemType != backupType { - // Prompt user: Type "yes" to continue - if !getUserConfirmation(reader, "yes") { - return ErrRestoreAborted - } +func DetectBackupType(manifest *backup.Manifest) SystemType { + if len(manifest.ProxmoxTargets) > 0 { + return parseSystemTargets(manifest.ProxmoxTargets) } - return nil + if manifest.ProxmoxType != "" { + return parseSystemTypeString(manifest.ProxmoxType) + } + // Fallback: hostname heuristics + return SystemTypeUnknown } ``` +**Compatibility Check**: +- **incompatible**: no shared role between backup and current host +- **partial compatibility**: shared role exists, but backup and host are not identical +- **full compatibility**: same role set + +When compatibility is partial, restore continues with warnings and later filters +the category set to the roles supported by the current host. + --- #### Phase 4: Category Analysis diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 4290e987..30e789c0 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -568,8 +568,9 @@ If Email is enabled but you don't see it being dispatched, ensure `EMAIL_DELIVER - Recipient auto-detection details (when `EMAIL_RECIPIENT` is empty): - **PVE**: `pvesh get /access/users/root@pam` → fallback to `pveum user list` → fallback to `/etc/pve/user.cfg` - **PBS**: `proxmox-backup-manager user list` → fallback to `/etc/proxmox-backup/user.cfg` + - **Dual**: intentionally reuses the **PVE** path for `root@pam` email discovery - Relay blocks `root@…` recipients; use a real non-root mailbox for `EMAIL_RECIPIENT`. -- If `EMAIL_FALLBACK_SENDMAIL=true`, ProxSave will fall back to `EMAIL_DELIVERY_METHOD=pmf` when the relay fails. +- If `EMAIL_FALLBACK_SENDMAIL=true`, ProxSave will fall back to `EMAIL_DELIVERY_METHOD=pmf` when the relay fails. If relay cannot even start because recipient resolution/preconditions fail, ProxSave can bypass relay and invoke the PMF fallback directly. - Check the proxsave logs for `email-relay` warnings/errors. - `Email relay accepted request ...` means the relay accepted the submission. It does **not** guarantee final inbox delivery; later provider-side failures/bounces are outside the ProxSave process. From dcc283b24b00fbc5918c9e683bd3d9dbad329bab Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:05:40 +0200 Subject: [PATCH 02/35] docs upgrade --- docs/RESTORE_DIAGRAMS.md | 26 +++++++++++++++++--------- docs/RESTORE_GUIDE.md | 38 ++++++++++++++++++++++++++------------ docs/RESTORE_TECHNICAL.md | 9 +++++++++ 3 files changed, 52 insertions(+), 21 deletions(-) diff --git a/docs/RESTORE_DIAGRAMS.md b/docs/RESTORE_DIAGRAMS.md index 5ae50e80..745780f5 100644 --- a/docs/RESTORE_DIAGRAMS.md +++ b/docs/RESTORE_DIAGRAMS.md @@ -1,6 +1,9 @@ # Restore Workflow Diagrams Visual diagrams for understanding the restore system architecture and flow. +Use this file as a visual companion to [RESTORE_GUIDE.md](RESTORE_GUIDE.md) and +[RESTORE_TECHNICAL.md](RESTORE_TECHNICAL.md), not as the primary place for +textual restore rules. ## Table of Contents @@ -655,22 +658,27 @@ flowchart TD BackupPVE --> Compare BackupPBS --> Compare BackupDual --> Compare - BackupUnknown --> Compare[Compare Types] + BackupUnknown --> Compare[Compare Capability Sets] - Compare --> Match{Current == Backup?} - Match -->|Yes| Compatible([Compatible]) - Match -->|No| CheckUnknown{Either Unknown?} + Compare --> SharedRole{Any shared role?} + SharedRole -->|Yes| ExactMatch{Same role set?} + ExactMatch -->|Yes| Compatible([Full Compatibility]) + ExactMatch -->|No| Partial[Partial Compatibility] + Partial --> Filter["Warn user and filter to
supported categories"] + Filter --> ProceedAnyway([Proceed with Warning]) - CheckUnknown -->|Yes| Compatible - CheckUnknown -->|No| Incompatible[Incompatible] + SharedRole -->|No| CheckUnknown{Either side unknown?} + CheckUnknown -->|Yes| WarnUnknown["Warn: compatibility
cannot be fully verified"] + WarnUnknown --> Proceed([Proceed]) + CheckUnknown -->|No| Incompatible["No overlapping role"] - Incompatible --> DisplayWarning["Display Warning:
PVE ↔ PBS mismatch"] + Incompatible --> DisplayWarning["Display Warning:
backup and host roles differ"] DisplayWarning --> AskOverride{Type 'yes'
to continue?} AskOverride -->|No| Abort([Abort]) - AskOverride -->|Yes| ProceedAnyway([Proceed with Warning]) + AskOverride -->|Yes| ProceedAnyway - Compatible --> Proceed([Proceed]) + Compatible --> Proceed style Start fill:#87CEEB style Proceed fill:#90EE90 diff --git a/docs/RESTORE_GUIDE.md b/docs/RESTORE_GUIDE.md index d71aff3e..6c11f31b 100644 --- a/docs/RESTORE_GUIDE.md +++ b/docs/RESTORE_GUIDE.md @@ -51,6 +51,14 @@ PVE+PBS backups using the interactive restore workflow. The `--restore` command provides an **interactive, category-based restoration system** that allows selective or full restoration of Proxmox configuration files from backup archives. +### How to Use the Restore Docs + +The restore documentation is split on purpose: + +- [RESTORE_GUIDE.md](RESTORE_GUIDE.md): operator workflow, modes, warnings, and practical examples +- [RESTORE_TECHNICAL.md](RESTORE_TECHNICAL.md): implementation details, detection logic, and internal architecture +- [RESTORE_DIAGRAMS.md](RESTORE_DIAGRAMS.md): visual companion for the main workflow and decision paths + ### Key Features - **Category-based selection**: Granular control over what gets restored @@ -347,7 +355,10 @@ Your selection: c # Continue to restore plan ## Complete Workflow -The restore process follows a **14-phase workflow** with safety checks at each step. +The restore process follows a phased workflow with safety checks at each step. +This section stays operator-focused. Internal decision rules and code-level +behavior live in [RESTORE_TECHNICAL.md](RESTORE_TECHNICAL.md), while the visual +flow lives in [RESTORE_DIAGRAMS.md](RESTORE_DIAGRAMS.md). ### Workflow Diagram @@ -523,6 +534,10 @@ compatible with PBS. Proceeding may result in system instability. Type "yes" to continue anyway or "no" to abort: ``` +This guide intentionally shows the operator-facing outcomes only. The exact +metadata precedence, host detection order, and capability-overlap rules are +documented in [RESTORE_TECHNICAL.md](RESTORE_TECHNICAL.md#phase-3-system-detection--compatibility). + #### Phase 6: Cluster Restore Mode (PVE Cluster Backups Only) **This phase is SKIPPED for standalone backups** - the workflow proceeds directly to Phase 7. @@ -1828,18 +1843,17 @@ Current auto-skip prompts: ### 3. Compatibility Validation -**System Type Detection**: -``` -Current system: Proxmox Virtual Environment (PVE) -Backup source: Proxmox Virtual Environment (PVE) -✓ Compatible -``` +Compatibility is evaluated with the same `pve | pbs | dual | unknown` model +described in [System Types and Compatibility](#system-types-and-compatibility). -**Incompatibility Warning**: -``` -⚠ WARNING: Potential incompatibility detected! -Type "yes" to continue anyway or "no" to abort: _ -``` +Operator-visible behavior is: +- exact role match: proceed normally +- partial overlap: continue with warnings and automatic category filtering +- no overlap: warn before continuing +- unknown: warn because role-specific validation is incomplete + +For the internal precedence rules and implementation path, see +[RESTORE_TECHNICAL.md](RESTORE_TECHNICAL.md#phase-3-system-detection--compatibility). ### 4. Network Safe Apply (Optional) diff --git a/docs/RESTORE_TECHNICAL.md b/docs/RESTORE_TECHNICAL.md index 971552e8..5dbc7158 100644 --- a/docs/RESTORE_TECHNICAL.md +++ b/docs/RESTORE_TECHNICAL.md @@ -18,6 +18,11 @@ Technical architecture and implementation details for the restore system. ## Architecture Overview +This document is the implementation-oriented companion to +[RESTORE_GUIDE.md](RESTORE_GUIDE.md). Use the guide for operator behavior and +examples; use this file for internal restore logic, module responsibilities, +and decision flow details. + ### Design Principles 1. **Safety First**: Multiple layers of protection against data loss @@ -424,6 +429,10 @@ type PreparedBackup struct { **File**: `internal/orchestrator/restore.go:58-72` +This section is the technical source of truth for restore compatibility. +User-facing examples and warning text live in +[RESTORE_GUIDE.md](RESTORE_GUIDE.md#phase-3-compatibility-check). + ```go systemType := DetectCurrentSystem() backupType := DetectBackupType(prepared.Manifest) From dd3b6ab18438791448632267c8eeabfdd5dbeea9 Mon Sep 17 00:00:00 2001 From: tis24dev <71268257+tis24dev@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:46:19 +0200 Subject: [PATCH 03/35] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 37628858..dbc0e391 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Proxmox PBS & PVE System Files Backup [![rclone](https://img.shields.io/badge/rclone-1.60+-136C9E.svg)](https://rclone.org/) [![💖 Sponsor](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-pink?logo=github)](https://github.com/sponsors/tis24dev) [![☕ Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-tis24dev-yellow?logo=buymeacoffee)](https://github.com/sponsors/tis24dev) +[![💸 Donate](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://www.paypal.com/donate/?hosted_button_id=&cmd=_donations&business=damigioanna%40gmail.com) ## About the Project @@ -104,4 +105,4 @@ A special thanks to the community members who help by testing releases and repor
## Repo Activity -![Alt](https://repobeats.axiom.co/api/embed/d9565d6d1ed8222a5da5fedf25c18a9c8beab382.svg "Repobeats analytics image") \ No newline at end of file +![Alt](https://repobeats.axiom.co/api/embed/d9565d6d1ed8222a5da5fedf25c18a9c8beab382.svg "Repobeats analytics image") From 4a4f4b7c23a93e1ccd1765cdd0aadec5db4cf586 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:23:22 +0200 Subject: [PATCH 04/35] ci: bump the actions-updates group across 1 directory with 2 updates (#196) Bumps the actions-updates group with 2 updates in the / directory: [codecov/codecov-action](https://github.com/codecov/codecov-action) and [dependabot/fetch-metadata](https://github.com/dependabot/fetch-metadata). Updates `codecov/codecov-action` from 5 to 6 - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v5...v6) Updates `dependabot/fetch-metadata` from 2 to 3 - [Release notes](https://github.com/dependabot/fetch-metadata/releases) - [Commits](https://github.com/dependabot/fetch-metadata/compare/v2...v3) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions-updates - dependency-name: dependabot/fetch-metadata dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions-updates ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codecov.yml | 2 +- .github/workflows/dependabot-automerge.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index bb3957bc..ccac49ca 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -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@v5 + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.out diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index 6c82ad1a..74f957de 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Fetch Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@v2 + uses: dependabot/fetch-metadata@v3 with: github-token: "${{ secrets.GITHUB_TOKEN }}" From 9f1d0ee26bcd1fce293326b90ba60e891f85c082 Mon Sep 17 00:00:00 2001 From: Paolo Stivanin Date: Wed, 22 Apr 2026 13:14:11 +0200 Subject: [PATCH 05/35] Add native Pushover support as a webhook format (#199) * Add native Pushover support as a webhook format Pushover requires the application token and user/group key in the JSON body (not in headers), and its API rejects proxsave's existing generic payload shape. Until now users had no way to wire Pushover up without running an external relay. This adds `pushover` as a first-class WEBHOOK_*_FORMAT alongside discord/slack/teams/generic. It reuses the existing AUTH_TOKEN / AUTH_USER fields for Pushover credentials (AUTH_TYPE stays "none"), introduces an optional WEBHOOK__PRIORITY (-2..1, default 0; emergency priority 2 deferred), and produces a Pushover-shaped JSON body with rune-aware truncation to the API's 250-char title and 1024-char message limits. Validation: missing token/user is reported at payload-build time; out-of-range priority is rejected by NewWebhookNotifier so misconfig fails fast at startup rather than per-send. * Address CodeRabbit review on PR #199 - docs/EXAMPLES.md: replace real-looking Pushover token/user values with placeholders so secret scanners stop flagging the example, and update the Example 7 scenario header and expected-results bullets so the narrative matches the Discord + Pushover configuration block. - internal/notify/webhook.go: skip the debug payload preview/content log when the resolved format is "pushover". The Pushover payload puts token + user in the JSON body (not headers), so the existing preview would write both credentials into debug logs. * Validate Pushover method and default-format resolution Fail fast for invalid Pushover webhook configuration. - resolve effective webhook format and method centrally - validate Pushover PRIORITY after default format resolution - require POST for Pushover endpoints at init time - add regression tests for default-format and invalid method cases --------- Co-authored-by: Damiano <71268257+tis24dev@users.noreply.github.com> --- docs/CONFIGURATION.md | 12 +- docs/EXAMPLES.md | 20 ++- internal/config/config.go | 16 +- internal/config/templates/backup.env | 17 +- internal/notify/webhook.go | 71 ++++++-- internal/notify/webhook_payloads.go | 51 ++++++ internal/notify/webhook_test.go | 250 ++++++++++++++++++++++++++- 7 files changed, 403 insertions(+), 34 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f43edcdd..37d4df43 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -906,7 +906,7 @@ WEBHOOK_ENABLED=false # true | false WEBHOOK_ENDPOINTS= # e.g., "discord_alerts,teams_ops" # Default payload format -WEBHOOK_FORMAT=generic # discord | slack | teams | generic +WEBHOOK_FORMAT=generic # discord | slack | teams | generic | pushover # Request timeout (seconds) WEBHOOK_TIMEOUT=30 @@ -923,7 +923,7 @@ WEBHOOK_RETRY_DELAY=2 # Seconds between retries WEBHOOK_DISCORD_ALERTS_URL=https://discord.com/api/webhooks/XXXX/YYY # Payload format -WEBHOOK_DISCORD_ALERTS_FORMAT=discord # discord | slack | teams | generic +WEBHOOK_DISCORD_ALERTS_FORMAT=discord # discord | slack | teams | generic | pushover # HTTP method WEBHOOK_DISCORD_ALERTS_METHOD=POST # POST | GET | HEAD @@ -935,10 +935,13 @@ WEBHOOK_DISCORD_ALERTS_HEADERS="X-Custom-Token:abc123,X-Another:value" WEBHOOK_DISCORD_ALERTS_AUTH_TYPE=none # none | bearer | basic | hmac # Authentication credentials -WEBHOOK_DISCORD_ALERTS_AUTH_TOKEN= # Bearer token -WEBHOOK_DISCORD_ALERTS_AUTH_USER= # Basic auth username +WEBHOOK_DISCORD_ALERTS_AUTH_TOKEN= # Bearer token (or Pushover application token) +WEBHOOK_DISCORD_ALERTS_AUTH_USER= # Basic auth username (or Pushover user/group key) WEBHOOK_DISCORD_ALERTS_AUTH_PASS= # Basic auth password WEBHOOK_DISCORD_ALERTS_AUTH_SECRET= # HMAC secret key + +# Pushover-specific (only honored when FORMAT=pushover; default 0, range -2..1) +WEBHOOK_DISCORD_ALERTS_PRIORITY=0 ``` **Supported formats**: @@ -946,6 +949,7 @@ WEBHOOK_DISCORD_ALERTS_AUTH_SECRET= # HMAC secret key - **slack**: Slack incoming webhook format - **teams**: Microsoft Teams connector format - **generic**: Simple JSON `{"status": "...", "message": "..."}` +- **pushover**: [Pushover](https://pushover.net) push notifications. Reuses `AUTH_TOKEN` (application token) and `AUTH_USER` (user/group key); `AUTH_TYPE` stays `none` because Pushover takes credentials in the JSON body. Title is truncated to 250 characters and message to 1024 characters per Pushover's API limits. `PRIORITY` accepts -2..1 (default 0); emergency priority (2) is not supported. --- diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 4571e0cb..9aa85865 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -504,7 +504,7 @@ crontab -e ## Example 7: Multi-Notification Setup -**Scenario**: Telegram + Email + Webhook (Discord) notifications. +**Scenario**: Telegram + Email + Webhook (Discord + Pushover) notifications. **Use case**: - Multiple notification channels @@ -529,16 +529,26 @@ EMAIL_DELIVERY_METHOD=relay EMAIL_RECIPIENT=admin@example.com EMAIL_FROM=noreply@proxmox.example.com -# Webhook (Discord) +# Webhook (Discord + Pushover) WEBHOOK_ENABLED=true -WEBHOOK_ENDPOINTS=discord_alerts +WEBHOOK_ENDPOINTS=discord_alerts,pushover WEBHOOK_DISCORD_ALERTS_URL=https://discord.com/api/webhooks/XXXX/YYYY WEBHOOK_DISCORD_ALERTS_FORMAT=discord WEBHOOK_DISCORD_ALERTS_METHOD=POST +# Pushover (push notifications to phone/desktop). Token + user key go in the +# JSON body, so AUTH_TYPE stays "none". PRIORITY accepts -2..1 (default 0). +WEBHOOK_PUSHOVER_URL=https://api.pushover.net/1/messages.json +WEBHOOK_PUSHOVER_FORMAT=pushover +WEBHOOK_PUSHOVER_METHOD=POST +WEBHOOK_PUSHOVER_AUTH_TYPE=none +WEBHOOK_PUSHOVER_AUTH_TOKEN= +WEBHOOK_PUSHOVER_AUTH_USER= +WEBHOOK_PUSHOVER_PRIORITY=0 + # Run backup ./build/proxsave -# Result: Notifications sent to Telegram, Email, and Discord +# Result: Notifications sent to Telegram, Email, Discord, and Pushover ``` ### Setup Steps @@ -592,11 +602,13 @@ printf "To: root\nSubject: proxsave test\n\nHello from proxsave\n" | sudo /usr/l - ✅ Telegram message with summary - ✅ Email with detailed report - ✅ Discord embed with stats +- ✅ Pushover push notification **On failure**: - ❌ Telegram alert with error - ❌ Email with failure details - ❌ Discord mention with logs +- ❌ Pushover push notification --- diff --git a/internal/config/config.go b/internal/config/config.go index 74d6b6e8..1586a111 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1427,13 +1427,16 @@ func (c *Config) BuildWebhookConfig() *WebhookConfig { } } + priority := c.getInt(prefix+"PRIORITY", 0) + endpoints = append(endpoints, WebhookEndpoint{ - Name: name, - URL: url, - Format: format, - Method: method, - Headers: headers, - Auth: auth, + Name: name, + URL: url, + Format: format, + Method: method, + Headers: headers, + Auth: auth, + Priority: priority, }) } @@ -1528,6 +1531,7 @@ type WebhookEndpoint struct { Method string Headers map[string]string Auth WebhookAuth + Priority int CustomFields map[string]interface{} } diff --git a/internal/config/templates/backup.env b/internal/config/templates/backup.env index 8369ff69..0089f6ed 100644 --- a/internal/config/templates/backup.env +++ b/internal/config/templates/backup.env @@ -236,7 +236,7 @@ GOTIFY_PRIORITY_WARNING=5 GOTIFY_PRIORITY_FAILURE=8 # ---------------------------------------------------------------------- -# Webhook notifications (Phase 5.2 – Discord/Slack/Teams/Generic) +# Webhook notifications (Phase 5.2 – Discord/Slack/Teams/Generic/Pushover) # ---------------------------------------------------------------------- WEBHOOK_ENABLED=false WEBHOOK_ENDPOINTS= # Comma-separated names e.g. discord_alerts,teams_ops @@ -247,7 +247,7 @@ WEBHOOK_RETRY_DELAY=2 # seconds # For each endpoint use the uppercase name as prefix, e.g. "discord_alerts": # WEBHOOK_DISCORD_ALERTS_URL=https://discord.com/api/webhooks/XXXX/YYY -# WEBHOOK_DISCORD_ALERTS_FORMAT=discord # discord | slack | teams | generic +# WEBHOOK_DISCORD_ALERTS_FORMAT=discord # discord | slack | teams | generic | pushover # WEBHOOK_DISCORD_ALERTS_METHOD=POST # POST recommended; GET/HEAD do not send a payload # WEBHOOK_DISCORD_ALERTS_HEADERS="X-Custom-Token:abc123" # WEBHOOK_DISCORD_ALERTS_AUTH_TYPE=none # none | bearer | basic | hmac @@ -256,6 +256,19 @@ WEBHOOK_RETRY_DELAY=2 # seconds # WEBHOOK_DISCORD_ALERTS_AUTH_PASS= # WEBHOOK_DISCORD_ALERTS_AUTH_SECRET= +# Pushover example (https://pushover.net). Token + user are sent in the JSON +# body, so AUTH_TYPE stays "none". Title/message are truncated to Pushover's +# 250/1024 character limits. PRIORITY accepts -2..1 (default 0); emergency +# priority (2) is not supported. +# WEBHOOK_ENDPOINTS=pushover +# WEBHOOK_PUSHOVER_URL=https://api.pushover.net/1/messages.json +# WEBHOOK_PUSHOVER_FORMAT=pushover +# WEBHOOK_PUSHOVER_METHOD=POST +# WEBHOOK_PUSHOVER_AUTH_TYPE=none +# WEBHOOK_PUSHOVER_AUTH_TOKEN= +# WEBHOOK_PUSHOVER_AUTH_USER= +# WEBHOOK_PUSHOVER_PRIORITY=0 + # ---------------------------------------------------------------------- # Metriche / Prometheus # ---------------------------------------------------------------------- diff --git a/internal/notify/webhook.go b/internal/notify/webhook.go index 87e0f1c1..a6b35d79 100644 --- a/internal/notify/webhook.go +++ b/internal/notify/webhook.go @@ -24,6 +24,25 @@ type WebhookNotifier struct { client *http.Client } +func resolveWebhookFormat(format, defaultFormat string) string { + format = strings.TrimSpace(format) + if format == "" { + format = strings.TrimSpace(defaultFormat) + } + if format == "" { + return "generic" + } + return format +} + +func resolveWebhookMethod(method string) string { + method = strings.ToUpper(strings.TrimSpace(method)) + if method == "" { + return http.MethodPost + } + return method +} + // NewWebhookNotifier creates a new webhook notifier func NewWebhookNotifier(webhookConfig *config.WebhookConfig, logger *logging.Logger) (*WebhookNotifier, error) { logger.Debug("WebhookNotifier initialization starting...") @@ -58,6 +77,17 @@ func NewWebhookNotifier(webhookConfig *config.WebhookConfig, logger *logging.Log logger.Debug(" Header: %s (value masked)", k) } } + + format := resolveWebhookFormat(ep.Format, webhookConfig.DefaultFormat) + method := resolveWebhookMethod(ep.Method) + if strings.EqualFold(format, "pushover") { + if ep.Priority < -2 || ep.Priority > 1 { + return nil, fmt.Errorf("webhook endpoint %q: PRIORITY must be in range -2..1 (got %d); priority 2 (emergency) is not supported", ep.Name, ep.Priority) + } + if method != http.MethodPost { + return nil, fmt.Errorf("webhook endpoint %q: METHOD must be POST for pushover (got %s)", ep.Name, method) + } + } } // Create HTTP client with timeout @@ -164,21 +194,29 @@ func (w *WebhookNotifier) sendToEndpoint(ctx context.Context, endpoint config.We w.logger.Debug("Endpoint format: %s, URL: %s", endpoint.Format, maskURL(endpoint.URL)) // Determine format to use - format := endpoint.Format - if format == "" { - format = w.config.DefaultFormat - w.logger.Debug("Using default format: %s", format) + format := resolveWebhookFormat(endpoint.Format, w.config.DefaultFormat) + if strings.TrimSpace(endpoint.Format) == "" { + if strings.TrimSpace(w.config.DefaultFormat) != "" { + w.logger.Debug("Using default format: %s", format) + } else { + w.logger.Debug("No format specified, using generic") + } } - if format == "" { - format = "generic" - w.logger.Debug("No format specified, using generic") + + method := resolveWebhookMethod(endpoint.Method) + if strings.TrimSpace(endpoint.Method) == "" { + w.logger.Debug("No method specified, using POST") + } + if strings.EqualFold(format, "pushover") && method != http.MethodPost { + return fmt.Errorf("webhook endpoint %q: METHOD must be POST for pushover (got %s)", endpoint.Name, method) } // Build payload based on format w.logger.Debug("Building %s payload...", format) payloadStart := time.Now() - payload, err := w.buildPayload(format, data) + endpoint.Format = format + payload, err := w.buildPayload(endpoint, data) if err != nil { w.logger.Error("Failed to build %s payload: %v", format, err) return fmt.Errorf("failed to build payload: %w", err) @@ -197,7 +235,9 @@ func (w *WebhookNotifier) sendToEndpoint(ctx context.Context, endpoint config.We w.logger.Debug("Payload marshaled: %d bytes", len(payloadBytes)) if w.logger.GetLevel() <= types.LogLevelDebug { - if len(payloadBytes) > 200 { + if strings.EqualFold(format, "pushover") { + w.logger.Debug("Payload preview omitted: pushover payload contains credentials") + } else if len(payloadBytes) > 200 { w.logger.Debug("Payload preview (first 200 chars): %s...", string(payloadBytes[:200])) } else { w.logger.Debug("Payload content: %s", string(payloadBytes)) @@ -229,12 +269,6 @@ func (w *WebhookNotifier) sendToEndpoint(ctx context.Context, endpoint config.We } } - // Determine HTTP method - method := strings.ToUpper(strings.TrimSpace(endpoint.Method)) - if method == "" { - method = "POST" - } - parsedURL, parseErr := url.Parse(endpoint.URL) if parseErr != nil { lastErr = fmt.Errorf("invalid webhook URL: %w", parseErr) @@ -415,16 +449,19 @@ func (w *WebhookNotifier) sendToEndpoint(ctx context.Context, endpoint config.We } // buildPayload builds the webhook payload based on format -func (w *WebhookNotifier) buildPayload(format string, data *NotificationData) (interface{}, error) { +func (w *WebhookNotifier) buildPayload(endpoint config.WebhookEndpoint, data *NotificationData) (interface{}, error) { + format := strings.ToLower(endpoint.Format) w.logger.Debug("buildPayload() called with format=%s", format) - switch strings.ToLower(format) { + switch format { case "discord": return buildDiscordPayload(data, w.logger) case "slack": return buildSlackPayload(data, w.logger) case "teams": return buildTeamsPayload(data, w.logger) + case "pushover": + return buildPushoverPayload(endpoint, data, w.logger) case "generic": return buildGenericPayload(data, w.logger) default: diff --git a/internal/notify/webhook_payloads.go b/internal/notify/webhook_payloads.go index 1c4530ff..7436d04c 100644 --- a/internal/notify/webhook_payloads.go +++ b/internal/notify/webhook_payloads.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/logging" ) @@ -575,3 +576,53 @@ func buildGenericPayload(data *NotificationData, logger *logging.Logger) (map[st logger.Debug("Generic payload built successfully with %d top-level keys", len(payload)) return payload, nil } + +// buildPushoverPayload builds a Pushover-formatted webhook payload. +// Pushover requires the application token and user/group key in the JSON body +// (not in headers); this builder reads them from endpoint.Auth.Token and +// endpoint.Auth.User and rejects requests where either is missing. +func buildPushoverPayload(endpoint config.WebhookEndpoint, data *NotificationData, logger *logging.Logger) (map[string]interface{}, error) { + logger.Debug("buildPushoverPayload() starting...") + + if endpoint.Auth.Token == "" { + return nil, fmt.Errorf("pushover: AUTH_TOKEN (Pushover application token) is required") + } + if endpoint.Auth.User == "" { + return nil, fmt.Errorf("pushover: AUTH_USER (Pushover user/group key) is required") + } + + title := truncateRunes(fmt.Sprintf("%s Proxmox Backup — %s", GetStatusEmoji(data.Status), data.Hostname), 250) + + message := truncateRunes(fmt.Sprintf( + "Status: %s\nDuration: %s\nSize: %s\nErrors: %d | Warnings: %d", + data.StatusMessage, + FormatDuration(data.BackupDuration), + data.BackupSizeHR, + data.ErrorCount, + data.WarningCount, + ), 1024) + + payload := map[string]interface{}{ + "token": endpoint.Auth.Token, + "user": endpoint.Auth.User, + "title": title, + "message": message, + "priority": endpoint.Priority, + } + + logger.Debug("Pushover payload built (priority=%d, title_len=%d, message_len=%d)", endpoint.Priority, len([]rune(title)), len([]rune(message))) + return payload, nil +} + +// truncateRunes shortens s to at most max runes, suffixing with "…" when cut. +// Operates on runes (not bytes) so multibyte characters like emoji are not split. +func truncateRunes(s string, max int) string { + if max <= 0 { + return "" + } + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max-1]) + "…" +} diff --git a/internal/notify/webhook_test.go b/internal/notify/webhook_test.go index 85503332..ad639690 100644 --- a/internal/notify/webhook_test.go +++ b/internal/notify/webhook_test.go @@ -658,7 +658,8 @@ func TestWebhookNotifier_buildPayload_CoversFormats(t *testing.T) { for _, format := range formats { format := format t.Run(format, func(t *testing.T) { - payload, err := notifier.buildPayload(format, data) + ep := config.WebhookEndpoint{Name: "x", URL: "https://example.com", Format: format} + payload, err := notifier.buildPayload(ep, data) if err != nil { t.Fatalf("buildPayload(%q) error = %v", format, err) } @@ -1070,3 +1071,250 @@ func TestMaskHeaderValue(t *testing.T) { }) } } + +func pushoverTestEndpoint(priority int) config.WebhookEndpoint { + return config.WebhookEndpoint{ + Name: "pushover", + URL: "https://api.pushover.net/1/messages.json", + Format: "pushover", + Method: "POST", + Auth: config.WebhookAuth{Type: "none", Token: "app-token-abc", User: "user-key-xyz"}, + Priority: priority, + } +} + +func TestBuildPushoverPayload_Success(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + data := createTestNotificationData() + + payload, err := buildPushoverPayload(pushoverTestEndpoint(0), data, logger) + if err != nil { + t.Fatalf("buildPushoverPayload() error: %v", err) + } + + if got := payload["token"]; got != "app-token-abc" { + t.Errorf("token = %v, want app-token-abc", got) + } + if got := payload["user"]; got != "user-key-xyz" { + t.Errorf("user = %v, want user-key-xyz", got) + } + if got := payload["priority"]; got != 0 { + t.Errorf("priority = %v, want 0", got) + } + + title, ok := payload["title"].(string) + if !ok { + t.Fatalf("title is not a string: %T", payload["title"]) + } + if !strings.Contains(title, data.Hostname) { + t.Errorf("title %q does not contain hostname %q", title, data.Hostname) + } + if !strings.Contains(title, GetStatusEmoji(data.Status)) { + t.Errorf("title %q does not contain status emoji", title) + } + + message, ok := payload["message"].(string) + if !ok { + t.Fatalf("message is not a string: %T", payload["message"]) + } + for _, want := range []string{"Status:", "Duration:", "Size:", "Errors:", "Warnings:"} { + if !strings.Contains(message, want) { + t.Errorf("message missing %q; got %q", want, message) + } + } +} + +func TestBuildPushoverPayload_MissingToken(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + data := createTestNotificationData() + ep := pushoverTestEndpoint(0) + ep.Auth.Token = "" + + _, err := buildPushoverPayload(ep, data, logger) + if err == nil { + t.Fatal("expected error for missing token, got nil") + } + if !strings.Contains(err.Error(), "AUTH_TOKEN") { + t.Errorf("error %q does not mention AUTH_TOKEN", err.Error()) + } +} + +func TestBuildPushoverPayload_MissingUser(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + data := createTestNotificationData() + ep := pushoverTestEndpoint(0) + ep.Auth.User = "" + + _, err := buildPushoverPayload(ep, data, logger) + if err == nil { + t.Fatal("expected error for missing user, got nil") + } + if !strings.Contains(err.Error(), "AUTH_USER") { + t.Errorf("error %q does not mention AUTH_USER", err.Error()) + } +} + +func TestBuildPushoverPayload_TitleTruncated(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + data := createTestNotificationData() + data.Hostname = strings.Repeat("h", 300) + + payload, err := buildPushoverPayload(pushoverTestEndpoint(0), data, logger) + if err != nil { + t.Fatalf("buildPushoverPayload() error: %v", err) + } + + title := payload["title"].(string) + if got := len([]rune(title)); got > 250 { + t.Errorf("title rune length = %d, want <= 250", got) + } + if !strings.HasSuffix(title, "…") { + t.Errorf("truncated title should end with ellipsis; got %q", title) + } +} + +func TestBuildPushoverPayload_MessageTruncated(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + data := createTestNotificationData() + data.StatusMessage = strings.Repeat("x", 1100) + + payload, err := buildPushoverPayload(pushoverTestEndpoint(0), data, logger) + if err != nil { + t.Fatalf("buildPushoverPayload() error: %v", err) + } + + message := payload["message"].(string) + if got := len([]rune(message)); got > 1024 { + t.Errorf("message rune length = %d, want <= 1024", got) + } + if !strings.HasSuffix(message, "…") { + t.Errorf("truncated message should end with ellipsis; got %q", message) + } +} + +func TestBuildPushoverPayload_PriorityPassthrough(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + data := createTestNotificationData() + + for _, p := range []int{-2, -1, 0, 1} { + payload, err := buildPushoverPayload(pushoverTestEndpoint(p), data, logger) + if err != nil { + t.Fatalf("priority=%d: buildPushoverPayload() error: %v", p, err) + } + if got := payload["priority"]; got != p { + t.Errorf("priority=%d: got %v", p, got) + } + } +} + +func TestNewWebhookNotifier_PushoverPriority(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + + tests := []struct { + name string + priority int + expectError bool + }{ + {"min valid", -2, false}, + {"zero", 0, false}, + {"max valid", 1, false}, + {"too low", -3, true}, + {"emergency rejected", 2, true}, + {"too high", 3, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.WebhookConfig{ + Enabled: true, + DefaultFormat: "pushover", + Timeout: 30, + Endpoints: []config.WebhookEndpoint{pushoverTestEndpoint(tt.priority)}, + } + _, err := NewWebhookNotifier(cfg, logger) + if tt.expectError { + if err == nil { + t.Fatalf("priority=%d: expected error, got nil", tt.priority) + } + if !strings.Contains(err.Error(), "PRIORITY") { + t.Errorf("error %q does not mention PRIORITY", err.Error()) + } + return + } + if err != nil { + t.Fatalf("priority=%d: unexpected error: %v", tt.priority, err) + } + }) + } +} + +func TestNewWebhookNotifier_PushoverPriority_UsesDefaultFormat(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + + ep := pushoverTestEndpoint(2) + ep.Format = "" + + cfg := &config.WebhookConfig{ + Enabled: true, + DefaultFormat: "pushover", + Timeout: 30, + Endpoints: []config.WebhookEndpoint{ep}, + } + + _, err := NewWebhookNotifier(cfg, logger) + if err == nil { + t.Fatal("expected error for invalid pushover priority resolved from default format, got nil") + } + if !strings.Contains(err.Error(), "PRIORITY") { + t.Fatalf("error %q does not mention PRIORITY", err.Error()) + } +} + +func TestNewWebhookNotifier_PushoverMethod(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + + tests := []struct { + name string + method string + format string + defaultFormat string + expectError bool + }{ + {name: "explicit post", method: "POST", format: "pushover", expectError: false}, + {name: "implicit post", method: "", format: "pushover", expectError: false}, + {name: "default format post", method: "", format: "", defaultFormat: "pushover", expectError: false}, + {name: "get rejected", method: "GET", format: "pushover", expectError: true}, + {name: "head rejected", method: "HEAD", format: "pushover", expectError: true}, + {name: "put rejected", method: "PUT", format: "pushover", expectError: true}, + {name: "default format get rejected", method: "GET", format: "", defaultFormat: "pushover", expectError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ep := pushoverTestEndpoint(0) + ep.Method = tt.method + ep.Format = tt.format + + cfg := &config.WebhookConfig{ + Enabled: true, + DefaultFormat: tt.defaultFormat, + Timeout: 30, + Endpoints: []config.WebhookEndpoint{ep}, + } + + _, err := NewWebhookNotifier(cfg, logger) + if tt.expectError { + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "METHOD must be POST") { + t.Fatalf("error %q does not mention POST method requirement", err.Error()) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} From e9a0b72a4175a859e038d27ae1c0b463854c517f Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Fri, 24 Apr 2026 20:53:21 +0200 Subject: [PATCH 06/35] Stabilize orchestrator tests and fix TUI race Remove t.Parallel() from orchestrator tests to avoid unsafe concurrency around package-level test dependencies such as restoreFS, restoreCmd, and restoreTime. Fix the decrypt TUI E2E test harness by taking synchronized snapshots of the tcell simulation screen instead of reading screen contents concurrently with rendering. Verified with: - go test ./internal/orchestrator -count=1 - go test -race ./internal/orchestrator -count=1 - go vet ./internal/orchestrator --- internal/orchestrator/decrypt_test.go | 3 - .../decrypt_tui_e2e_helpers_test.go | 99 +++++++++++++++---- internal/orchestrator/guards_cleanup_test.go | 4 - .../orchestrator/mount_guard_more_test.go | 10 -- internal/orchestrator/pbs_mount_guard_test.go | 3 - .../pbs_staged_apply_additional_test.go | 14 --- .../orchestrator/pbs_staged_apply_test.go | 2 - .../orchestrator/pve_staged_apply_test.go | 4 - internal/orchestrator/temp_registry_test.go | 4 - .../orchestrator/unescape_proc_path_test.go | 3 - 10 files changed, 81 insertions(+), 65 deletions(-) diff --git a/internal/orchestrator/decrypt_test.go b/internal/orchestrator/decrypt_test.go index 4932b1bf..ab6a4b81 100644 --- a/internal/orchestrator/decrypt_test.go +++ b/internal/orchestrator/decrypt_test.go @@ -190,7 +190,6 @@ func TestBuildDecryptPathOptions(t *testing.T) { } func TestBaseNameFromRemoteRef(t *testing.T) { - t.Parallel() tests := []struct { in string want string @@ -464,7 +463,6 @@ func TestParseIdentityInput(t *testing.T) { } func TestSanitizeBundleEntryName(t *testing.T) { - t.Parallel() tests := []struct { name string input string @@ -489,7 +487,6 @@ func TestSanitizeBundleEntryName(t *testing.T) { for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - t.Parallel() got, err := sanitizeBundleEntryName(tt.input) if tt.expectErr { if err == nil { diff --git a/internal/orchestrator/decrypt_tui_e2e_helpers_test.go b/internal/orchestrator/decrypt_tui_e2e_helpers_test.go index 84cf85de..e15fe555 100644 --- a/internal/orchestrator/decrypt_tui_e2e_helpers_test.go +++ b/internal/orchestrator/decrypt_tui_e2e_helpers_test.go @@ -29,23 +29,85 @@ var decryptTUIE2EMu sync.Mutex type notifyingSimulationScreen struct { tcell.SimulationScreen - notify func() + mu sync.Mutex + snapshot timedSimScreenSnapshot + notify func() +} + +type timedSimScreenSnapshot struct { + cells []tcell.SimCell + width int + height int + cursorX int + cursorY int + cursorVisible bool + ready bool } func (s *notifyingSimulationScreen) Show() { + s.mu.Lock() s.SimulationScreen.Show() - if s.notify != nil { - s.notify() - } + s.captureLocked() + s.mu.Unlock() + s.notifyChange() } func (s *notifyingSimulationScreen) Sync() { + s.mu.Lock() s.SimulationScreen.Sync() + s.captureLocked() + s.mu.Unlock() + s.notifyChange() +} + +func (s *notifyingSimulationScreen) snapshotState() timedSimScreenSnapshot { + s.mu.Lock() + defer s.mu.Unlock() + return cloneTimedSimScreenSnapshot(s.snapshot) +} + +func (s *notifyingSimulationScreen) captureLocked() { + cells, width, height := s.SimulationScreen.GetContents() + cursorX, cursorY, cursorVisible := s.SimulationScreen.GetCursor() + s.snapshot = timedSimScreenSnapshot{ + cells: cloneSimCells(cells), + width: width, + height: height, + cursorX: cursorX, + cursorY: cursorY, + cursorVisible: cursorVisible, + ready: true, + } +} + +func (s *notifyingSimulationScreen) notifyChange() { if s.notify != nil { s.notify() } } +func cloneTimedSimScreenSnapshot(snapshot timedSimScreenSnapshot) timedSimScreenSnapshot { + snapshot.cells = cloneSimCells(snapshot.cells) + return snapshot +} + +func cloneSimCells(cells []tcell.SimCell) []tcell.SimCell { + if len(cells) == 0 { + return nil + } + cloned := make([]tcell.SimCell, len(cells)) + for i, cell := range cells { + cloned[i] = cell + if cell.Bytes != nil { + cloned[i].Bytes = append([]byte(nil), cell.Bytes...) + } + if cell.Runes != nil { + cloned[i].Runes = append([]rune(nil), cell.Runes...) + } + } + return cloned +} + type timedSimKey struct { Key tcell.Key R rune @@ -129,10 +191,11 @@ func withTimedSimAppSequence(t *testing.T, keys []timedSimKey) { if app != nil { focus = app.GetFocus() } + snapshot := screen.snapshotState() return timedSimScreenState{ - signature: timedSimScreenStateSignature(screen, focus), - text: timedSimScreenText(screen), + signature: timedSimScreenStateSignature(snapshot, focus), + text: timedSimScreenText(snapshot), } } @@ -181,30 +244,30 @@ func withTimedSimAppSequence(t *testing.T, keys []timedSimKey) { } } -func timedSimScreenStateSignature(screen tcell.SimulationScreen, focus any) string { - cells, width, height := screen.GetContents() - cursorX, cursorY, cursorVisible := screen.GetCursor() +func timedSimScreenStateSignature(snapshot timedSimScreenSnapshot, focus any) string { + if !snapshot.ready || snapshot.width <= 0 || snapshot.height <= 0 || len(snapshot.cells) < snapshot.width*snapshot.height { + return "" + } sum := sha256.New() - fmt.Fprintf(sum, "size:%d:%d cursor:%d:%d:%t focus:%T:%p\n", width, height, cursorX, cursorY, cursorVisible, focus, focus) - for _, cell := range cells { + fmt.Fprintf(sum, "size:%d:%d cursor:%d:%d:%t focus:%T:%p\n", snapshot.width, snapshot.height, snapshot.cursorX, snapshot.cursorY, snapshot.cursorVisible, focus, focus) + for _, cell := range snapshot.cells { fg, bg, attr := cell.Style.Decompose() fmt.Fprintf(sum, "%x/%d/%d/%d;", cell.Bytes, fg, bg, attr) } return hex.EncodeToString(sum.Sum(nil)) } -func timedSimScreenText(screen tcell.SimulationScreen) string { - cells, width, height := screen.GetContents() - if width <= 0 || height <= 0 || len(cells) < width*height { +func timedSimScreenText(snapshot timedSimScreenSnapshot) string { + if !snapshot.ready || snapshot.width <= 0 || snapshot.height <= 0 || len(snapshot.cells) < snapshot.width*snapshot.height { return "" } var b strings.Builder - for y := 0; y < height; y++ { - row := make([]byte, 0, width) - for x := 0; x < width; x++ { - cell := cells[y*width+x] + for y := 0; y < snapshot.height; y++ { + row := make([]byte, 0, snapshot.width) + for x := 0; x < snapshot.width; x++ { + cell := snapshot.cells[y*snapshot.width+x] if len(cell.Bytes) == 0 { row = append(row, ' ') continue diff --git a/internal/orchestrator/guards_cleanup_test.go b/internal/orchestrator/guards_cleanup_test.go index 7e4e9684..c8b0eb8b 100644 --- a/internal/orchestrator/guards_cleanup_test.go +++ b/internal/orchestrator/guards_cleanup_test.go @@ -11,8 +11,6 @@ import ( ) func TestGuardMountpointsFromMountinfo_VisibleAndHidden(t *testing.T) { - t.Parallel() - mountinfo := strings.Join([]string{ "10 1 0:1 " + mountGuardBaseDir + "/g1 /mnt/visible rw - ext4 /dev/sda1 rw", "20 1 0:1 " + mountGuardBaseDir + "/g2 /mnt/hidden rw - ext4 /dev/sda1 rw", @@ -32,8 +30,6 @@ func TestGuardMountpointsFromMountinfo_VisibleAndHidden(t *testing.T) { } func TestGuardMountpointsFromMountinfo_UnescapesMountpoint(t *testing.T) { - t.Parallel() - mountinfo := "10 1 0:1 " + mountGuardBaseDir + "/g1 /mnt/with\\040space rw - ext4 /dev/sda1 rw\n" visible, hidden, mounts := guardMountpointsFromMountinfo(mountinfo) if mounts != 1 { diff --git a/internal/orchestrator/mount_guard_more_test.go b/internal/orchestrator/mount_guard_more_test.go index 41109084..67719ac7 100644 --- a/internal/orchestrator/mount_guard_more_test.go +++ b/internal/orchestrator/mount_guard_more_test.go @@ -14,8 +14,6 @@ import ( ) func TestGuardDirForTarget(t *testing.T) { - t.Parallel() - target := "/mnt/datastore" sum := sha256.Sum256([]byte(target)) id := fmt.Sprintf("%x", sum[:8]) @@ -34,8 +32,6 @@ func TestGuardDirForTarget(t *testing.T) { } func TestIsMountedFromMountinfo(t *testing.T) { - t.Parallel() - mountinfo := strings.Join([]string{ "36 25 0:32 / / rw,relatime - ext4 /dev/sda1 rw", `37 36 0:33 / /mnt/pbs\040datastore rw,relatime - ext4 /dev/sdb1 rw`, @@ -98,8 +94,6 @@ func TestFstabMountpointsSet_Error(t *testing.T) { } func TestSplitPathAndMountRootWithPrefix(t *testing.T) { - t.Parallel() - if got := splitPath("a//b/ /c/"); strings.Join(got, ",") != "a,b,c" { t.Fatalf("splitPath unexpected: %#v", got) } @@ -112,8 +106,6 @@ func TestSplitPathAndMountRootWithPrefix(t *testing.T) { } func TestSortByLengthDesc(t *testing.T) { - t.Parallel() - items := []string{"a", "abc", "ab"} sortByLengthDesc(items) if len(items) != 3 { @@ -125,8 +117,6 @@ func TestSortByLengthDesc(t *testing.T) { } func TestFirstFstabMountpointMatch(t *testing.T) { - t.Parallel() - mountpoints := []string{"/mnt/storage/pbs", "/mnt/storage", "/"} if got := firstFstabMountpointMatch("/mnt/storage/pbs/ds1/data", mountpoints); got != "/mnt/storage/pbs" { t.Fatalf("firstFstabMountpointMatch got %q want %q", got, "/mnt/storage/pbs") diff --git a/internal/orchestrator/pbs_mount_guard_test.go b/internal/orchestrator/pbs_mount_guard_test.go index a9efbc17..75a4d385 100644 --- a/internal/orchestrator/pbs_mount_guard_test.go +++ b/internal/orchestrator/pbs_mount_guard_test.go @@ -3,8 +3,6 @@ package orchestrator import "testing" func TestPBSMountGuardRootForDatastorePath(t *testing.T) { - t.Parallel() - tests := []struct { name string in string @@ -25,7 +23,6 @@ func TestPBSMountGuardRootForDatastorePath(t *testing.T) { for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - t.Parallel() if got := pbsMountGuardRootForDatastorePath(tt.in); got != tt.want { t.Fatalf("pbsMountGuardRootForDatastorePath(%q)=%q want %q", tt.in, got, tt.want) } diff --git a/internal/orchestrator/pbs_staged_apply_additional_test.go b/internal/orchestrator/pbs_staged_apply_additional_test.go index 5c0d1d23..6d360c4b 100644 --- a/internal/orchestrator/pbs_staged_apply_additional_test.go +++ b/internal/orchestrator/pbs_staged_apply_additional_test.go @@ -12,8 +12,6 @@ import ( ) func TestPBSConfigHasHeader_AcceptsAndRejectsExpectedForms(t *testing.T) { - t.Parallel() - tests := []struct { name string content string @@ -348,8 +346,6 @@ func TestLoadPBSDatastoreCfgFromInventory_PropagatesErrors(t *testing.T) { } func TestDetectPBSDatastoreCfgDuplicateKeys_DetectsDuplicateKeys(t *testing.T) { - t.Parallel() - blocks := []pbsDatastoreBlock{{ Name: "DS1", Lines: []string{ @@ -366,8 +362,6 @@ func TestDetectPBSDatastoreCfgDuplicateKeys_DetectsDuplicateKeys(t *testing.T) { } func TestDetectPBSDatastoreCfgDuplicateKeys_AllowsUniqueKeys(t *testing.T) { - t.Parallel() - blocks := []pbsDatastoreBlock{{ Name: "DS1", Lines: []string{ @@ -382,8 +376,6 @@ func TestDetectPBSDatastoreCfgDuplicateKeys_AllowsUniqueKeys(t *testing.T) { } func TestParsePBSDatastoreCfgBlocks_IgnoresGarbageAndHandlesMissingNames(t *testing.T) { - t.Parallel() - content := strings.Join([]string{ "path /should/be/ignored", "datastore:", @@ -416,8 +408,6 @@ func TestParsePBSDatastoreCfgBlocks_IgnoresGarbageAndHandlesMissingNames(t *test } func TestParsePBSDatastoreCfgBlocks_DropsEmptyNamedBlocks(t *testing.T) { - t.Parallel() - content := strings.Join([]string{ "datastore: :", " path /mnt/ignored", @@ -440,8 +430,6 @@ func TestParsePBSDatastoreCfgBlocks_DropsEmptyNamedBlocks(t *testing.T) { } func TestShouldApplyPBSDatastoreBlock_CoversCommonBranches(t *testing.T) { - t.Parallel() - if ok, reason := shouldApplyPBSDatastoreBlock(pbsDatastoreBlock{Name: "ds", Path: "/"}, newTestLogger()); ok || !strings.Contains(reason, "invalid") { t.Fatalf("expected invalid path rejection, got ok=%v reason=%q", ok, reason) } @@ -464,8 +452,6 @@ func TestShouldApplyPBSDatastoreBlock_CoversCommonBranches(t *testing.T) { } func TestWriteDeferredPBSDatastoreCfg_EmptyInputIsNoop(t *testing.T) { - t.Parallel() - if path, err := writeDeferredPBSDatastoreCfg(nil); err != nil { t.Fatalf("err=%v", err) } else if path != "" { diff --git a/internal/orchestrator/pbs_staged_apply_test.go b/internal/orchestrator/pbs_staged_apply_test.go index 0ee7ab72..d881eff2 100644 --- a/internal/orchestrator/pbs_staged_apply_test.go +++ b/internal/orchestrator/pbs_staged_apply_test.go @@ -63,8 +63,6 @@ func TestApplyPBSRemoteCfgFromStage_RemovesWhenEmpty(t *testing.T) { } func TestShouldApplyPBSDatastoreBlock_AllowsMountLikePathsOnRootFS(t *testing.T) { - t.Parallel() - dir, err := os.MkdirTemp("/mnt", "proxsave-test-ds-") if err != nil { t.Skipf("cannot create temp dir under /mnt: %v", err) diff --git a/internal/orchestrator/pve_staged_apply_test.go b/internal/orchestrator/pve_staged_apply_test.go index a5561beb..fcc80117 100644 --- a/internal/orchestrator/pve_staged_apply_test.go +++ b/internal/orchestrator/pve_staged_apply_test.go @@ -7,8 +7,6 @@ import ( ) func TestPVEStorageMountGuardItems_BuildsExpectedTargets(t *testing.T) { - t.Parallel() - candidates := []pveStorageMountGuardCandidate{ {StorageID: "Data1", StorageType: "dir", Path: "/mnt/datastore/Data1"}, {StorageID: "Synology-Archive", StorageType: "dir", Path: "/mnt/Synology_NFS/PBS_Backup"}, @@ -40,8 +38,6 @@ func TestPVEStorageMountGuardItems_BuildsExpectedTargets(t *testing.T) { } func TestApplyPVEBackupJobsFromStage_CreatesJobsViaPvesh(t *testing.T) { - t.Parallel() - origFS := restoreFS origCmd := restoreCmd t.Cleanup(func() { diff --git a/internal/orchestrator/temp_registry_test.go b/internal/orchestrator/temp_registry_test.go index 071a4bc8..37eafc45 100644 --- a/internal/orchestrator/temp_registry_test.go +++ b/internal/orchestrator/temp_registry_test.go @@ -15,8 +15,6 @@ func newTestLogger() *logging.Logger { } func TestTempDirRegistryRegisterAndDeregister(t *testing.T) { - t.Parallel() - regPath := filepath.Join(t.TempDir(), "temp-dirs.json") registry, err := NewTempDirRegistry(newTestLogger(), regPath) if err != nil { @@ -54,8 +52,6 @@ func TestTempDirRegistryRegisterAndDeregister(t *testing.T) { } func TestTempDirRegistryCleanupOrphaned(t *testing.T) { - t.Parallel() - regPath := filepath.Join(t.TempDir(), "temp-dirs.json") registry, err := NewTempDirRegistry(newTestLogger(), regPath) if err != nil { diff --git a/internal/orchestrator/unescape_proc_path_test.go b/internal/orchestrator/unescape_proc_path_test.go index 86c7352b..8e0a1676 100644 --- a/internal/orchestrator/unescape_proc_path_test.go +++ b/internal/orchestrator/unescape_proc_path_test.go @@ -3,8 +3,6 @@ package orchestrator import "testing" func TestUnescapeProcPath(t *testing.T) { - t.Parallel() - tests := []struct { name string in string @@ -25,7 +23,6 @@ func TestUnescapeProcPath(t *testing.T) { for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - t.Parallel() if got := unescapeProcPath(tt.in); got != tt.want { t.Fatalf("unescapeProcPath(%q)=%q want %q", tt.in, got, tt.want) } From 03ae548b38563dc9a9f563f76fda025943e19259 Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:32:45 +0200 Subject: [PATCH 07/35] Use safeexec and CommandSpec for external commands Replace direct exec.CommandContext usages with a new internal/safeexec package and introduce a CommandSpec type for validated command invocation. Collector APIs now accept CommandSpec (with validation and stringification) and use safe execution helpers; many PBS-related collector calls and tests were updated accordingly. Archiver and other components were updated to propagate command creation errors, improving error handling and security when spawning external processes. Added internal/safeexec implementation and tests. --- cmd/proxsave/install.go | 7 +- cmd/proxsave/runtime_helpers.go | 20 +- cmd/proxsave/upgrade.go | 6 +- internal/backup/archiver.go | 70 ++++- internal/backup/collector.go | 161 +++++----- internal/backup/collector_deps.go | 7 +- internal/backup/collector_pbs.go | 70 ++--- internal/backup/collector_pbs_auth_test.go | 46 +-- internal/backup/collector_pbs_datastore.go | 2 +- .../collector_privilege_sensitive_test.go | 8 +- internal/backup/collector_pve.go | 66 ++--- .../backup/collector_pve_patterns_test.go | 2 +- internal/backup/collector_pve_util_test.go | 2 +- internal/backup/collector_system.go | 114 +++---- internal/backup/collector_system_test.go | 2 +- internal/backup/collector_test.go | 48 +-- internal/config/config.go | 29 ++ internal/config/migration.go | 13 + internal/environment/detect.go | 16 +- internal/identity/identity.go | 7 +- internal/notify/email.go | 48 ++- internal/orchestrator/backup_sources.go | 12 +- internal/orchestrator/decrypt.go | 22 +- internal/orchestrator/deps.go | 11 +- internal/orchestrator/network_apply.go | 13 +- internal/orchestrator/restore.go | 9 +- .../orchestrator/restore_access_control_ui.go | 4 +- internal/orchestrator/restore_firewall.go | 4 +- internal/orchestrator/restore_ha.go | 4 +- internal/pbs/namespaces.go | 9 +- internal/pbs/namespaces_test.go | 4 +- internal/safeexec/safeexec.go | 277 ++++++++++++++++++ internal/safeexec/safeexec_test.go | 79 +++++ internal/security/procscan.go | 31 +- internal/security/security.go | 24 +- internal/storage/cloud.go | 44 ++- .../tui/wizard/post_install_audit_core.go | 6 +- 37 files changed, 969 insertions(+), 328 deletions(-) create mode 100644 internal/safeexec/safeexec.go create mode 100644 internal/safeexec/safeexec_test.go diff --git a/cmd/proxsave/install.go b/cmd/proxsave/install.go index d3682624..4b8609cd 100644 --- a/cmd/proxsave/install.go +++ b/cmd/proxsave/install.go @@ -15,6 +15,7 @@ import ( cronutil "github.com/tis24dev/proxsave/internal/cron" "github.com/tis24dev/proxsave/internal/identity" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/internal/tui/wizard" buildinfo "github.com/tis24dev/proxsave/internal/version" ) @@ -860,7 +861,11 @@ func clearImmutableAttributesWithContext(ctx context.Context, target string, boo if err := ctx.Err(); err != nil { return err } - cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd, err := safeexec.TrustedCommandContext(ctx, args[0], args[1:]...) + if err != nil { + logBootstrapWarning(bootstrap, "Failed to prepare chattr for %s: %v", target, err) + continue + } if out, err := cmd.CombinedOutput(); err != nil { if ctxErr := ctx.Err(); ctxErr != nil { return ctxErr diff --git a/cmd/proxsave/runtime_helpers.go b/cmd/proxsave/runtime_helpers.go index 0c8e8794..12fc9f9d 100644 --- a/cmd/proxsave/runtime_helpers.go +++ b/cmd/proxsave/runtime_helpers.go @@ -18,6 +18,7 @@ import ( "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/internal/storage" "github.com/tis24dev/proxsave/internal/types" "github.com/tis24dev/proxsave/pkg/utils" @@ -219,9 +220,12 @@ func logServerIdentityValues(serverID, mac string) { func resolveHostname() string { if path, err := exec.LookPath("hostname"); err == nil { - if out, err := exec.Command(path, "-f").Output(); err == nil { - if fqdn := strings.TrimSpace(string(out)); fqdn != "" { - return fqdn + cmd, cmdErr := safeexec.TrustedCommandContext(context.Background(), path, "-f") + if cmdErr == nil { + if out, err := cmd.Output(); err == nil { + if fqdn := strings.TrimSpace(string(out)); fqdn != "" { + return fqdn + } } } } @@ -678,7 +682,10 @@ func migrateLegacyCronEntries(ctx context.Context, baseDir, execPath string, boo } readCron := func() (string, error) { - cmd := exec.CommandContext(ctx, "crontab", "-l") + cmd, err := safeexec.CommandContext(ctx, "crontab", "-l") + if err != nil { + return "", err + } output, err := cmd.CombinedOutput() if err != nil { lower := strings.ToLower(string(output)) @@ -691,7 +698,10 @@ func migrateLegacyCronEntries(ctx context.Context, baseDir, execPath string, boo } writeCron := func(content string) error { - cmd := exec.CommandContext(ctx, "crontab", "-") + cmd, err := safeexec.CommandContext(ctx, "crontab", "-") + if err != nil { + return err + } cmd.Stdin = strings.NewReader(content) output, err := cmd.CombinedOutput() if err != nil { diff --git a/cmd/proxsave/upgrade.go b/cmd/proxsave/upgrade.go index 0aac1efb..e462a8fe 100644 --- a/cmd/proxsave/upgrade.go +++ b/cmd/proxsave/upgrade.go @@ -25,6 +25,7 @@ import ( "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/identity" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/internal/types" buildinfo "github.com/tis24dev/proxsave/internal/version" ) @@ -650,7 +651,10 @@ func upgradeConfigWithBinary(ctx context.Context, execPath, configPath string) ( return nil, fmt.Errorf("configuration path is empty") } - cmd := exec.CommandContext(ctx, execPath, "--config", configPath, "--upgrade-config-json") + cmd, err := safeexec.TrustedCommandContext(ctx, execPath, "--config", configPath, "--upgrade-config-json") + if err != nil { + return nil, err + } var stdout bytes.Buffer var stderr bytes.Buffer cmd.Stdout = &stdout diff --git a/internal/backup/archiver.go b/internal/backup/archiver.go index 5a1c2333..b63d7826 100644 --- a/internal/backup/archiver.go +++ b/internal/backup/archiver.go @@ -16,6 +16,7 @@ import ( "filippo.io/age" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/internal/types" ) @@ -24,13 +25,13 @@ var lookPath = exec.LookPath // ArchiverDeps groups external dependencies used by Archiver. type ArchiverDeps struct { LookPath func(string) (string, error) - CommandContext func(context.Context, string, ...string) *exec.Cmd + CommandContext func(context.Context, string, ...string) (*exec.Cmd, error) } func defaultArchiverDeps() ArchiverDeps { return ArchiverDeps{ LookPath: lookPath, - CommandContext: exec.CommandContext, + CommandContext: safeexec.CommandContext, } } @@ -156,11 +157,11 @@ func NewArchiver(logger *logging.Logger, config *ArchiverConfig) *Archiver { } } -func (a *Archiver) cmd(ctx context.Context, name string, args ...string) *exec.Cmd { +func (a *Archiver) cmd(ctx context.Context, name string, args ...string) (*exec.Cmd, error) { if a.deps.CommandContext != nil { return a.deps.CommandContext(ctx, name, args...) } - return exec.CommandContext(ctx, name, args...) + return safeexec.CommandContext(ctx, name, args...) } func (a *Archiver) findPath(name string) (string, error) { @@ -476,7 +477,10 @@ func (a *Archiver) createGzipArchive(ctx context.Context, sourceDir, outputPath func (a *Archiver) createPigzArchive(ctx context.Context, sourceDir, outputPath string) error { a.logger.Debug("Creating pigz archive with level %d (mode %s)", a.compressionLevel, a.CompressionMode()) args := buildPigzArgs(a.compressionLevel, a.compressionThreads, a.CompressionMode()) - cmd := a.cmd(ctx, "pigz", args...) + cmd, err := a.cmd(ctx, "pigz", args...) + if err != nil { + return err + } return a.pipeTarThroughCommand(ctx, sourceDir, outputPath, cmd, "pigz") } @@ -516,18 +520,25 @@ func (a *Archiver) createBzip2Archive(ctx context.Context, sourceDir, outputPath var cmd *exec.Cmd if a.compressionThreads > 1 { if _, err := a.findPath("pbzip2"); err == nil { - cmd = a.cmd(ctx, "pbzip2", + cmd, err = a.cmd(ctx, "pbzip2", fmt.Sprintf("-%d", a.compressionLevel), fmt.Sprintf("-p%d", a.compressionThreads), "-c", ) + if err != nil { + return err + } } } if cmd == nil { - cmd = a.cmd(ctx, "bzip2", + var err error + cmd, err = a.cmd(ctx, "bzip2", fmt.Sprintf("-%d", a.compressionLevel), "-c", ) + if err != nil { + return err + } } return a.pipeTarThroughCommand(ctx, sourceDir, outputPath, cmd, "bzip2") } @@ -538,10 +549,13 @@ func (a *Archiver) createLzmaArchive(ctx context.Context, sourceDir, outputPath if requiresExtremeMode(a.CompressionMode()) { levelFlag += "e" } - cmd := a.cmd(ctx, "lzma", + cmd, err := a.cmd(ctx, "lzma", levelFlag, "-c", ) + if err != nil { + return err + } return a.pipeTarThroughCommand(ctx, sourceDir, outputPath, cmd, "lzma") } @@ -550,7 +564,10 @@ func (a *Archiver) createXZArchive(ctx context.Context, sourceDir, outputPath st a.logger.Debug("Creating xz archive with level %d (mode %s)", a.compressionLevel, a.CompressionMode()) args := buildXZArgs(a.compressionLevel, a.compressionThreads, a.CompressionMode()) - cmd := a.cmd(ctx, "xz", args...) + cmd, err := a.cmd(ctx, "xz", args...) + if err != nil { + return err + } if err := a.attachStderrLogger(cmd, "xz"); err != nil { return fmt.Errorf("capture xz output: %w", err) } @@ -620,7 +637,10 @@ func (a *Archiver) createZstdArchive(ctx context.Context, sourceDir, outputPath a.logger.Debug("Creating zstd archive with level %d (mode %s)", a.compressionLevel, a.CompressionMode()) args := buildZstdArgs(a.compressionLevel, a.compressionThreads) - cmd := a.cmd(ctx, "zstd", args...) + cmd, err := a.cmd(ctx, "zstd", args...) + if err != nil { + return err + } if err := a.attachStderrLogger(cmd, "zstd"); err != nil { return fmt.Errorf("capture zstd output: %w", err) } @@ -985,7 +1005,10 @@ func (a *Archiver) verifyXZArchive(ctx context.Context, archivePath string) erro a.logger.Debug("Testing XZ compression integrity") // Test XZ compression integrity - cmd := a.cmd(ctx, "xz", "--test", archivePath) + cmd, err := a.cmd(ctx, "xz", "--test", archivePath) + if err != nil { + return err + } if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("xz integrity test failed: %w (output: %s)", err, string(output)) } @@ -993,7 +1016,10 @@ func (a *Archiver) verifyXZArchive(ctx context.Context, archivePath string) erro a.logger.Debug("XZ compression test passed") // Test tar listing (decompress and list without extracting) - cmd = a.cmd(ctx, "tar", "-tJf", archivePath) + cmd, err = a.cmd(ctx, "tar", "-tJf", archivePath) + if err != nil { + return err + } cmd.Stdout = nil // Discard output if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("tar listing failed: %w (output: %s)", err, string(output)) @@ -1008,7 +1034,10 @@ func (a *Archiver) verifyZstdArchive(ctx context.Context, archivePath string) er a.logger.Debug("Testing Zstd compression integrity") // Test Zstd compression integrity - cmd := a.cmd(ctx, "zstd", "--test", archivePath) + cmd, err := a.cmd(ctx, "zstd", "--test", archivePath) + if err != nil { + return err + } if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("zstd integrity test failed: %w (output: %s)", err, string(output)) } @@ -1016,7 +1045,10 @@ func (a *Archiver) verifyZstdArchive(ctx context.Context, archivePath string) er a.logger.Debug("Zstd compression test passed") // Test tar listing (decompress and list without extracting) - cmd = a.cmd(ctx, "tar", "--use-compress-program=zstd", "-tf", archivePath) + cmd, err = a.cmd(ctx, "tar", "--use-compress-program=zstd", "-tf", archivePath) + if err != nil { + return err + } cmd.Stdout = nil // Discard output if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("tar listing failed: %w (output: %s)", err, string(output)) @@ -1031,7 +1063,10 @@ func (a *Archiver) verifyGzipArchive(ctx context.Context, archivePath string) er a.logger.Debug("Testing Gzip compression integrity") // Test tar listing (tar will test gzip integrity automatically) - cmd := a.cmd(ctx, "tar", "-tzf", archivePath) + cmd, err := a.cmd(ctx, "tar", "-tzf", archivePath) + if err != nil { + return err + } cmd.Stdout = nil // Discard output if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("tar/gzip verification failed: %w (output: %s)", err, string(output)) @@ -1046,7 +1081,10 @@ func (a *Archiver) verifyTarArchive(ctx context.Context, archivePath string) err a.logger.Debug("Testing uncompressed tar integrity") // Test tar listing - cmd := a.cmd(ctx, "tar", "-tf", archivePath) + cmd, err := a.cmd(ctx, "tar", "-tf", archivePath) + if err != nil { + return err + } cmd.Stdout = nil // Discard output if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("tar verification failed: %w (output: %s)", err, string(output)) diff --git a/internal/backup/collector.go b/internal/backup/collector.go index 78a6e8e8..4e079da0 100644 --- a/internal/backup/collector.go +++ b/internal/backup/collector.go @@ -117,6 +117,39 @@ func (c *Collector) depRunCommandWithEnv(ctx context.Context, extraEnv []string, return runCommandWithEnv(ctx, extraEnv, name, args...) } +type CommandSpec struct { + Name string + Args []string +} + +func commandSpec(name string, args ...string) CommandSpec { + return CommandSpec{Name: strings.TrimSpace(name), Args: append([]string(nil), args...)} +} + +func (s CommandSpec) validate() error { + if s.Name == "" { + return fmt.Errorf("empty command") + } + if strings.ContainsAny(s.Name, `/\`) { + return fmt.Errorf("command name must not contain path separators: %s", s.Name) + } + for _, arg := range s.Args { + for _, r := range arg { + if r == 0 { + return fmt.Errorf("command argument contains NUL byte") + } + } + } + return nil +} + +func (s CommandSpec) String() string { + if len(s.Args) == 0 { + return s.Name + } + return s.Name + " " + strings.Join(s.Args, " ") +} + func (c *Collector) depStat(path string) (os.FileInfo, error) { if c.deps.Stat != nil { return c.deps.Stat(path) @@ -876,10 +909,13 @@ func (c *Collector) safeCopyDir(ctx context.Context, src, dest, description stri return nil } -func (c *Collector) safeCmdOutput(ctx context.Context, cmd, output, description string, critical bool) error { +func (c *Collector) safeCmdOutput(ctx context.Context, spec CommandSpec, output, description string, critical bool) error { if err := ctx.Err(); err != nil { return err } + if err := spec.validate(); err != nil { + return err + } if output != "" && c.shouldExclude(output) { c.logger.Debug("Skipping %s: output %s excluded by pattern", description, output) @@ -887,39 +923,34 @@ func (c *Collector) safeCmdOutput(ctx context.Context, cmd, output, description return nil } - c.logger.Debug("Collecting %s via command: %s > %s", description, cmd, output) - - cmdParts := strings.Fields(cmd) - if len(cmdParts) == 0 { - return fmt.Errorf("empty command") - } + c.logger.Debug("Collecting %s via command: %s > %s", description, spec.String(), output) // Check if command exists - if _, err := c.depLookPath(cmdParts[0]); err != nil { + if _, err := c.depLookPath(spec.Name); err != nil { if critical { c.incFilesFailed() - return fmt.Errorf("critical command not available: %s", cmdParts[0]) + return fmt.Errorf("critical command not available: %s", spec.Name) } - c.logger.Debug("Command not available: %s (skipping %s)", cmdParts[0], description) + c.logger.Debug("Command not available: %s (skipping %s)", spec.Name, description) return nil } if c.dryRun { - c.logger.Debug("[DRY RUN] Would execute command: %s > %s", cmd, output) + c.logger.Debug("[DRY RUN] Would execute command: %s > %s", spec.String(), output) return nil } - cmdString := strings.Join(cmdParts, " ") + cmdString := spec.String() runCtx := ctx var cancel context.CancelFunc - if cmdParts[0] == "pvesh" && c.config != nil && c.config.PveshTimeoutSeconds > 0 { + if spec.Name == "pvesh" && c.config != nil && c.config.PveshTimeoutSeconds > 0 { runCtx, cancel = context.WithTimeout(ctx, time.Duration(c.config.PveshTimeoutSeconds)*time.Second) } if cancel != nil { defer cancel() } - out, err := c.depRunCommand(runCtx, cmdParts[0], cmdParts[1:]...) + out, err := c.depRunCommand(runCtx, spec.Name, spec.Args...) if err != nil { if critical { c.incFilesFailed() @@ -940,12 +971,12 @@ func (c *Collector) safeCmdOutput(ctx context.Context, cmd, output, description reason := "" if ctxInfo.Detected { - c.logger.Debug("Privilege-sensitive allowlist: command=%q allowlisted=%t", cmdParts[0], isPrivilegeSensitiveCommand(cmdParts[0])) - match := privilegeSensitiveFailureMatch(cmdParts[0], exitCode, outputText) + c.logger.Debug("Privilege-sensitive allowlist: command=%q allowlisted=%t", spec.Name, isPrivilegeSensitiveCommand(spec.Name)) + match := privilegeSensitiveFailureMatch(spec.Name, exitCode, outputText) reason = match.Reason - c.logger.Debug("Privilege-sensitive classification: command=%q matched=%t match=%q reason=%q", cmdParts[0], reason != "", match.Match, reason) + c.logger.Debug("Privilege-sensitive classification: command=%q matched=%t match=%q reason=%q", spec.Name, reason != "", match.Match, reason) } else { - c.logger.Debug("Privilege-sensitive downgrade not considered: limited-privilege context not detected (command=%q)", cmdParts[0]) + c.logger.Debug("Privilege-sensitive downgrade not considered: limited-privilege context not detected (command=%q)", spec.Name) } if ctxInfo.Detected && reason != "" { @@ -958,7 +989,7 @@ func (c *Collector) safeCmdOutput(ctx context.Context, cmd, output, description } if ctxInfo.Detected { - c.logger.Debug("No privilege-sensitive downgrade applied: command=%q did not match known patterns; emitting WARNING", cmdParts[0]) + c.logger.Debug("No privilege-sensitive downgrade applied: command=%q did not match known patterns; emitting WARNING", spec.Name) } 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", @@ -980,10 +1011,13 @@ func (c *Collector) safeCmdOutput(ctx context.Context, cmd, output, description // 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, cmd, output, description string, critical bool) error { +func (c *Collector) safeCmdOutputWithPBSAuth(ctx context.Context, spec CommandSpec, output, description string, critical bool) error { if err := ctx.Err(); err != nil { return err } + if err := spec.validate(); err != nil { + return err + } if output != "" && c.shouldExclude(output) { c.logger.Debug("Skipping %s: output %s excluded by pattern", description, output) @@ -991,23 +1025,18 @@ func (c *Collector) safeCmdOutputWithPBSAuth(ctx context.Context, cmd, output, d return nil } - cmdParts := strings.Fields(cmd) - if len(cmdParts) == 0 { - return fmt.Errorf("empty command") - } - // Check if command exists - if _, err := c.depLookPath(cmdParts[0]); err != nil { + if _, err := c.depLookPath(spec.Name); err != nil { if critical { c.incFilesFailed() - return fmt.Errorf("critical command not available: %s", cmdParts[0]) + return fmt.Errorf("critical command not available: %s", spec.Name) } - c.logger.Debug("Command not available: %s (skipping %s)", cmdParts[0], description) + c.logger.Debug("Command not available: %s (skipping %s)", spec.Name, description) return nil } if c.dryRun { - c.logger.Debug("[DRY RUN] Would execute command with PBS auth: %s > %s", cmd, output) + c.logger.Debug("[DRY RUN] Would execute command with PBS auth: %s > %s", spec.String(), output) return nil } @@ -1028,11 +1057,11 @@ func (c *Collector) safeCmdOutputWithPBSAuth(ctx context.Context, cmd, output, d } if pbsAuthUsed { - c.logger.Debug("Using PBS authentication for command: %s", cmdParts[0]) + c.logger.Debug("Using PBS authentication for command: %s", spec.Name) } - cmdString := strings.Join(cmdParts, " ") - out, err := c.depRunCommandWithEnv(ctx, extraEnv, cmdParts[0], cmdParts[1:]...) + cmdString := spec.String() + out, err := c.depRunCommandWithEnv(ctx, extraEnv, spec.Name, spec.Args...) if err != nil { if critical { c.incFilesFailed() @@ -1057,10 +1086,13 @@ func (c *Collector) safeCmdOutputWithPBSAuth(ctx context.Context, cmd, output, d // safeCmdOutputWithPBSAuthForDatastore executes a command with PBS authentication for a specific datastore // This function appends the datastore name to the PBS_REPOSITORY environment variable -func (c *Collector) safeCmdOutputWithPBSAuthForDatastore(ctx context.Context, cmd, output, description, datastoreName string, critical bool) error { +func (c *Collector) safeCmdOutputWithPBSAuthForDatastore(ctx context.Context, spec CommandSpec, output, description, datastoreName string, critical bool) error { if err := ctx.Err(); err != nil { return err } + if err := spec.validate(); err != nil { + return err + } if output != "" && c.shouldExclude(output) { c.logger.Debug("Skipping %s: output %s excluded by pattern", description, output) @@ -1068,23 +1100,18 @@ func (c *Collector) safeCmdOutputWithPBSAuthForDatastore(ctx context.Context, cm return nil } - cmdParts := strings.Fields(cmd) - if len(cmdParts) == 0 { - return fmt.Errorf("empty command") - } - // Check if command exists - if _, err := c.depLookPath(cmdParts[0]); err != nil { + if _, err := c.depLookPath(spec.Name); err != nil { if critical { c.incFilesFailed() - return fmt.Errorf("critical command not available: %s", cmdParts[0]) + return fmt.Errorf("critical command not available: %s", spec.Name) } - c.logger.Debug("Command not available: %s (skipping %s)", cmdParts[0], description) + c.logger.Debug("Command not available: %s (skipping %s)", spec.Name, description) return nil } if c.dryRun { - c.logger.Debug("[DRY RUN] Would execute command with PBS auth for datastore %s: %s > %s", datastoreName, cmd, output) + c.logger.Debug("[DRY RUN] Would execute command with PBS auth for datastore %s: %s > %s", datastoreName, spec.String(), output) return nil } @@ -1128,8 +1155,8 @@ func (c *Collector) safeCmdOutputWithPBSAuthForDatastore(ctx context.Context, cm c.logger.Debug("Using PBS_FINGERPRINT=%s", c.config.PBSFingerprint) } - cmdString := strings.Join(cmdParts, " ") - out, err := c.depRunCommandWithEnv(ctx, extraEnv, cmdParts[0], cmdParts[1:]...) + cmdString := spec.String() + out, err := c.depRunCommandWithEnv(ctx, extraEnv, spec.Name, spec.Args...) if err != nil { if critical { c.incFilesFailed() @@ -1244,10 +1271,13 @@ func (c *Collector) writeReportFile(path string, data []byte) error { return nil } -func (c *Collector) captureCommandOutput(ctx context.Context, cmd, output, description string, critical bool) ([]byte, error) { +func (c *Collector) captureCommandOutput(ctx context.Context, spec CommandSpec, output, description string, critical bool) ([]byte, error) { if err := ctx.Err(); err != nil { return nil, err } + if err := spec.validate(); err != nil { + return nil, err + } if output != "" && c.shouldExclude(output) { c.logger.Debug("Skipping %s: output %s excluded by pattern", description, output) @@ -1255,37 +1285,32 @@ func (c *Collector) captureCommandOutput(ctx context.Context, cmd, output, descr return nil, nil } - parts := strings.Fields(cmd) - if len(parts) == 0 { - return nil, fmt.Errorf("empty command") - } - - if _, err := c.depLookPath(parts[0]); err != nil { + if _, err := c.depLookPath(spec.Name); err != nil { if critical { c.incFilesFailed() - return nil, fmt.Errorf("critical command not available: %s", parts[0]) + return nil, fmt.Errorf("critical command not available: %s", spec.Name) } - c.logger.Debug("Command not available: %s (skipping %s)", parts[0], description) + c.logger.Debug("Command not available: %s (skipping %s)", spec.Name, description) return nil, nil } if c.dryRun { - c.logger.Debug("[DRY RUN] Would execute command: %s > %s", cmd, output) + c.logger.Debug("[DRY RUN] Would execute command: %s > %s", spec.String(), output) return nil, nil } runCtx := ctx var cancel context.CancelFunc - if parts[0] == "pvesh" && c.config != nil && c.config.PveshTimeoutSeconds > 0 { + if spec.Name == "pvesh" && c.config != nil && c.config.PveshTimeoutSeconds > 0 { runCtx, cancel = context.WithTimeout(ctx, time.Duration(c.config.PveshTimeoutSeconds)*time.Second) } if cancel != nil { defer cancel() } - out, err := c.depRunCommand(runCtx, parts[0], parts[1:]...) + out, err := c.depRunCommand(runCtx, spec.Name, spec.Args...) if err != nil { - cmdString := strings.Join(parts, " ") + cmdString := spec.String() if critical { c.incFilesFailed() return nil, fmt.Errorf("critical command `%s` failed for %s: %w (output: %s)", cmdString, description, err, summarizeCommandOutputText(string(out))) @@ -1305,12 +1330,12 @@ func (c *Collector) captureCommandOutput(ctx context.Context, cmd, output, descr reason := "" if ctxInfo.Detected { - c.logger.Debug("Privilege-sensitive allowlist: command=%q allowlisted=%t", parts[0], isPrivilegeSensitiveCommand(parts[0])) - match := privilegeSensitiveFailureMatch(parts[0], exitCode, outputText) + c.logger.Debug("Privilege-sensitive allowlist: command=%q allowlisted=%t", spec.Name, isPrivilegeSensitiveCommand(spec.Name)) + match := privilegeSensitiveFailureMatch(spec.Name, exitCode, outputText) reason = match.Reason - c.logger.Debug("Privilege-sensitive classification: command=%q matched=%t match=%q reason=%q", parts[0], reason != "", match.Match, reason) + c.logger.Debug("Privilege-sensitive classification: command=%q matched=%t match=%q reason=%q", spec.Name, reason != "", match.Match, reason) } else { - c.logger.Debug("Privilege-sensitive downgrade not considered: limited-privilege context not detected (command=%q)", parts[0]) + c.logger.Debug("Privilege-sensitive downgrade not considered: limited-privilege context not detected (command=%q)", spec.Name) } if ctxInfo.Detected && reason != "" { @@ -1323,11 +1348,11 @@ func (c *Collector) captureCommandOutput(ctx context.Context, cmd, output, descr } if ctxInfo.Detected { - c.logger.Debug("No privilege-sensitive downgrade applied: command=%q did not match known patterns; continuing with standard handling", parts[0]) + c.logger.Debug("No privilege-sensitive downgrade applied: command=%q did not match known patterns; continuing with standard handling", spec.Name) } - if parts[0] == "systemctl" && len(parts) >= 2 && parts[1] == "status" { - unit := parts[len(parts)-1] + if spec.Name == "systemctl" && len(spec.Args) >= 2 && spec.Args[0] == "status" { + unit := spec.Args[len(spec.Args)-1] if exitCode == 4 || strings.Contains(outputText, "could not be found") { c.logger.Warning("Skipping %s: %s.service not found (not installed?). Set BACKUP_FIREWALL_RULES=false to disable.", description, @@ -1360,12 +1385,12 @@ func (c *Collector) captureCommandOutput(ctx context.Context, cmd, output, descr return out, nil } -func (c *Collector) collectCommandMulti(ctx context.Context, cmd, output, description string, critical bool, mirrors ...string) error { +func (c *Collector) collectCommandMulti(ctx context.Context, spec CommandSpec, output, description string, critical bool, mirrors ...string) error { if output == "" { return fmt.Errorf("primary output path cannot be empty for %s", description) } - data, err := c.captureCommandOutput(ctx, cmd, output, description, critical) + data, err := c.captureCommandOutput(ctx, spec, output, description, critical) if err != nil { return err } @@ -1385,13 +1410,13 @@ func (c *Collector) collectCommandMulti(ctx context.Context, cmd, output, descri return nil } -func (c *Collector) collectCommandOptional(ctx context.Context, cmd, output, description string, mirrors ...string) { +func (c *Collector) collectCommandOptional(ctx context.Context, spec CommandSpec, output, description string, mirrors ...string) { if output == "" { c.logger.Debug("Optional command %s skipped: no primary output path", description) return } - data, err := c.captureCommandOutput(ctx, cmd, output, description, false) + data, err := c.captureCommandOutput(ctx, spec, output, description, false) if err != nil { c.logger.Debug("Optional command %s skipped: %v", description, err) return diff --git a/internal/backup/collector_deps.go b/internal/backup/collector_deps.go index 6cb980a8..cc65d0de 100644 --- a/internal/backup/collector_deps.go +++ b/internal/backup/collector_deps.go @@ -4,13 +4,18 @@ import ( "context" "os" "os/exec" + + "github.com/tis24dev/proxsave/internal/safeexec" ) var ( execLookPath = exec.LookPath runCommandWithEnv = func(ctx context.Context, extraEnv []string, name string, args ...string) ([]byte, error) { - cmd := exec.CommandContext(ctx, name, args...) + cmd, err := safeexec.CommandContext(ctx, name, args...) + if err != nil { + return nil, err + } if len(extraEnv) > 0 { cmd.Env = append(os.Environ(), extraEnv...) } diff --git a/internal/backup/collector_pbs.go b/internal/backup/collector_pbs.go index c3dce1f0..61c12077 100644 --- a/internal/backup/collector_pbs.go +++ b/internal/backup/collector_pbs.go @@ -270,7 +270,7 @@ func (c *Collector) collectPBSManifestPrune(ctx context.Context, root string) er func (c *Collector) collectPBSCoreRuntime(ctx context.Context, commandsDir string) error { if err := c.collectCommandMulti(ctx, - "proxmox-backup-manager version", + commandSpec("proxmox-backup-manager", "version"), filepath.Join(commandsDir, "pbs_version.txt"), "PBS version", true); err != nil { @@ -282,7 +282,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, - "proxmox-backup-manager node show --output-format=json", + commandSpec("proxmox-backup-manager", "node", "show", "--output-format=json"), filepath.Join(commandsDir, "node_config.json"), "Node configuration", false) @@ -293,7 +293,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, - "proxmox-backup-manager network list --output-format=json", + commandSpec("proxmox-backup-manager", "network", "list", "--output-format=json"), filepath.Join(commandsDir, "network_list.json"), "Network configuration", false) @@ -303,7 +303,7 @@ func (c *Collector) collectPBSNetworkRuntime(ctx context.Context, commandsDir st func (c *Collector) collectPBSDatastoreListRuntime(ctx context.Context, commandsDir string) error { return c.collectCommandMulti(ctx, - "proxmox-backup-manager datastore list --output-format=json", + commandSpec("proxmox-backup-manager", "datastore", "list", "--output-format=json"), filepath.Join(commandsDir, "datastore_list.json"), "Datastore list", false) @@ -325,7 +325,7 @@ func (c *Collector) collectPBSDatastoreStatusRuntime(ctx context.Context, comman } dsKey := ds.pathKey() c.safeCmdOutput(ctx, - fmt.Sprintf("proxmox-backup-manager datastore show %s --output-format=json", cliName), + 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) @@ -338,7 +338,7 @@ func (c *Collector) collectPBSAcmeAccountsListRuntime(ctx context.Context, comma return nil, nil } raw, err := c.captureCommandOutput(ctx, - "proxmox-backup-manager acme account list --output-format=json", + commandSpec("proxmox-backup-manager", "acme", "account", "list", "--output-format=json"), filepath.Join(commandsDir, "acme_accounts.json"), "ACME accounts", false) @@ -359,7 +359,7 @@ func (c *Collector) collectPBSAcmeAccountInfoRuntime(ctx context.Context, comman for _, name := range uniqueSortedStrings(accountNames) { out := filepath.Join(commandsDir, fmt.Sprintf("acme_account_%s_info.json", sanitizeFilename(name))) c.collectCommandOptional(ctx, - fmt.Sprintf("proxmox-backup-manager acme account info %s --output-format=json", name), + commandSpec("proxmox-backup-manager", "acme", "account", "info", name, "--output-format=json"), out, fmt.Sprintf("ACME account info (%s)", name)) } @@ -371,7 +371,7 @@ func (c *Collector) collectPBSAcmePluginsListRuntime(ctx context.Context, comman return nil, nil } raw, err := c.captureCommandOutput(ctx, - "proxmox-backup-manager acme plugin list --output-format=json", + commandSpec("proxmox-backup-manager", "acme", "plugin", "list", "--output-format=json"), filepath.Join(commandsDir, "acme_plugins.json"), "ACME plugins", false) @@ -392,7 +392,7 @@ func (c *Collector) collectPBSAcmePluginConfigRuntime(ctx context.Context, comma for _, id := range uniqueSortedStrings(pluginIDs) { out := filepath.Join(commandsDir, fmt.Sprintf("acme_plugin_%s_config.json", sanitizeFilename(id))) c.collectCommandOptional(ctx, - fmt.Sprintf("proxmox-backup-manager acme plugin config %s --output-format=json", id), + commandSpec("proxmox-backup-manager", "acme", "plugin", "config", id, "--output-format=json"), out, fmt.Sprintf("ACME plugin config (%s)", id)) } @@ -404,7 +404,7 @@ func (c *Collector) collectPBSNotificationTargetsRuntime(ctx context.Context, co return nil } return c.collectCommandMulti(ctx, - "proxmox-backup-manager notification target list --output-format=json", + commandSpec("proxmox-backup-manager", "notification", "target", "list", "--output-format=json"), filepath.Join(commandsDir, "notification_targets.json"), "Notification targets", false) @@ -415,7 +415,7 @@ func (c *Collector) collectPBSNotificationMatchersRuntime(ctx context.Context, c return nil } return c.collectCommandMulti(ctx, - "proxmox-backup-manager notification matcher list --output-format=json", + commandSpec("proxmox-backup-manager", "notification", "matcher", "list", "--output-format=json"), filepath.Join(commandsDir, "notification_matchers.json"), "Notification matchers", false) @@ -426,7 +426,7 @@ func (c *Collector) collectPBSNotificationEndpointRuntime(ctx context.Context, c return nil } return c.collectCommandMulti(ctx, - fmt.Sprintf("proxmox-backup-manager notification endpoint %s list --output-format=json", typ), + commandSpec("proxmox-backup-manager", "notification", "endpoint", typ, "list", "--output-format=json"), filepath.Join(commandsDir, fmt.Sprintf("notification_endpoints_%s.json", typ)), fmt.Sprintf("Notification endpoints (%s)", typ), false) @@ -453,7 +453,7 @@ func (c *Collector) collectPBSAccessUsersRuntime(ctx context.Context, commandsDi return nil, nil } raw, err := c.captureCommandOutput(ctx, - "proxmox-backup-manager user list --output-format=json", + commandSpec("proxmox-backup-manager", "user", "list", "--output-format=json"), filepath.Join(commandsDir, "user_list.json"), "User list", false) @@ -467,23 +467,23 @@ func (c *Collector) collectPBSAccessUsersRuntime(ctx context.Context, commandsDi return ids, nil } -func (c *Collector) collectPBSAccessRealmRuntime(ctx context.Context, commandsDir, cmd, out, desc string) error { +func (c *Collector) collectPBSAccessRealmRuntime(ctx context.Context, commandsDir string, spec CommandSpec, out, desc string) error { if !c.config.BackupUserConfigs { return nil } - return c.collectCommandMulti(ctx, cmd, filepath.Join(commandsDir, out), desc, false) + return c.collectCommandMulti(ctx, spec, filepath.Join(commandsDir, out), desc, false) } func (c *Collector) collectPBSAccessRealmLDAPRuntime(ctx context.Context, commandsDir string) error { - return c.collectPBSAccessRealmRuntime(ctx, commandsDir, "proxmox-backup-manager ldap list --output-format=json", "realms_ldap.json", "LDAP realms") + return c.collectPBSAccessRealmRuntime(ctx, commandsDir, commandSpec("proxmox-backup-manager", "ldap", "list", "--output-format=json"), "realms_ldap.json", "LDAP realms") } func (c *Collector) collectPBSAccessRealmADRuntime(ctx context.Context, commandsDir string) error { - return c.collectPBSAccessRealmRuntime(ctx, commandsDir, "proxmox-backup-manager ad list --output-format=json", "realms_ad.json", "Active Directory realms") + return c.collectPBSAccessRealmRuntime(ctx, commandsDir, commandSpec("proxmox-backup-manager", "ad", "list", "--output-format=json"), "realms_ad.json", "Active Directory realms") } func (c *Collector) collectPBSAccessRealmOpenIDRuntime(ctx context.Context, commandsDir string) error { - return c.collectPBSAccessRealmRuntime(ctx, commandsDir, "proxmox-backup-manager openid list --output-format=json", "realms_openid.json", "OpenID realms") + return c.collectPBSAccessRealmRuntime(ctx, commandsDir, commandSpec("proxmox-backup-manager", "openid", "list", "--output-format=json"), "realms_openid.json", "OpenID realms") } func (c *Collector) collectPBSAccessACLRuntime(ctx context.Context, commandsDir string) error { @@ -491,7 +491,7 @@ func (c *Collector) collectPBSAccessACLRuntime(ctx context.Context, commandsDir return nil } return c.collectCommandMulti(ctx, - "proxmox-backup-manager acl list --output-format=json", + commandSpec("proxmox-backup-manager", "acl", "list", "--output-format=json"), filepath.Join(commandsDir, "acl_list.json"), "ACL list", false) @@ -519,7 +519,7 @@ func (c *Collector) collectPBSRemotesRuntime(ctx context.Context, commandsDir st return nil } return c.collectCommandMulti(ctx, - "proxmox-backup-manager remote list --output-format=json", + commandSpec("proxmox-backup-manager", "remote", "list", "--output-format=json"), filepath.Join(commandsDir, "remote_list.json"), "Remote list", false) @@ -530,7 +530,7 @@ func (c *Collector) collectPBSSyncJobsRuntime(ctx context.Context, commandsDir s return nil } return c.collectCommandMulti(ctx, - "proxmox-backup-manager sync-job list --output-format=json", + commandSpec("proxmox-backup-manager", "sync-job", "list", "--output-format=json"), filepath.Join(commandsDir, "sync_jobs.json"), "Sync jobs", false) @@ -541,7 +541,7 @@ func (c *Collector) collectPBSVerificationJobsRuntime(ctx context.Context, comma return nil } return c.collectCommandMulti(ctx, - "proxmox-backup-manager verify-job list --output-format=json", + commandSpec("proxmox-backup-manager", "verify-job", "list", "--output-format=json"), filepath.Join(commandsDir, "verification_jobs.json"), "Verification jobs", false) @@ -552,7 +552,7 @@ func (c *Collector) collectPBSPruneJobsRuntime(ctx context.Context, commandsDir return nil } return c.collectCommandMulti(ctx, - "proxmox-backup-manager prune-job list --output-format=json", + commandSpec("proxmox-backup-manager", "prune-job", "list", "--output-format=json"), filepath.Join(commandsDir, "prune_jobs.json"), "Prune jobs", false) @@ -560,7 +560,7 @@ func (c *Collector) collectPBSPruneJobsRuntime(ctx context.Context, commandsDir func (c *Collector) collectPBSGCJobsRuntime(ctx context.Context, commandsDir string) error { return c.collectCommandMulti(ctx, - "proxmox-backup-manager garbage-collection list --output-format=json", + commandSpec("proxmox-backup-manager", "garbage-collection", "list", "--output-format=json"), filepath.Join(commandsDir, "gc_jobs.json"), "Garbage collection jobs", false) @@ -575,7 +575,7 @@ func (c *Collector) collectPBSTapeDrivesRuntime(ctx context.Context, commandsDir return nil } c.safeCmdOutput(ctx, - "proxmox-tape drive list --output-format=json", + commandSpec("proxmox-tape", "drive", "list", "--output-format=json"), filepath.Join(commandsDir, "tape_drives.json"), "Tape drives", false) @@ -587,7 +587,7 @@ func (c *Collector) collectPBSTapeChangersRuntime(ctx context.Context, commandsD return nil } c.safeCmdOutput(ctx, - "proxmox-tape changer list --output-format=json", + commandSpec("proxmox-tape", "changer", "list", "--output-format=json"), filepath.Join(commandsDir, "tape_changers.json"), "Tape changers", false) @@ -599,7 +599,7 @@ func (c *Collector) collectPBSTapePoolsRuntime(ctx context.Context, commandsDir return nil } c.safeCmdOutput(ctx, - "proxmox-tape pool list --output-format=json", + commandSpec("proxmox-tape", "pool", "list", "--output-format=json"), filepath.Join(commandsDir, "tape_pools.json"), "Tape pools", false) @@ -608,7 +608,7 @@ func (c *Collector) collectPBSTapePoolsRuntime(ctx context.Context, commandsDir func (c *Collector) collectPBSDisksRuntime(ctx context.Context, commandsDir string) error { c.safeCmdOutput(ctx, - "proxmox-backup-manager disk list --output-format=json", + commandSpec("proxmox-backup-manager", "disk", "list", "--output-format=json"), filepath.Join(commandsDir, "disk_list.json"), "Disk list", false) @@ -617,7 +617,7 @@ func (c *Collector) collectPBSDisksRuntime(ctx context.Context, commandsDir stri func (c *Collector) collectPBSCertInfoRuntime(ctx context.Context, commandsDir string) error { return c.collectCommandMulti(ctx, - "proxmox-backup-manager cert info", + commandSpec("proxmox-backup-manager", "cert", "info"), filepath.Join(commandsDir, "cert_info.txt"), "Certificate information", false) @@ -628,7 +628,7 @@ func (c *Collector) collectPBSTrafficControlRuntime(ctx context.Context, command return nil } c.safeCmdOutput(ctx, - "proxmox-backup-manager traffic-control list --output-format=json", + commandSpec("proxmox-backup-manager", "traffic-control", "list", "--output-format=json"), filepath.Join(commandsDir, "traffic_control.json"), "Traffic control rules", false) @@ -637,7 +637,7 @@ func (c *Collector) collectPBSTrafficControlRuntime(ctx context.Context, command func (c *Collector) collectPBSRecentTasksRuntime(ctx context.Context, commandsDir string) error { c.safeCmdOutput(ctx, - "proxmox-backup-manager task list --limit 50 --output-format=json", + commandSpec("proxmox-backup-manager", "task", "list", "--limit", "50", "--output-format=json"), filepath.Join(commandsDir, "recent_tasks.json"), "Recent tasks", false) @@ -649,7 +649,7 @@ func (c *Collector) collectPBSS3EndpointsRuntime(ctx context.Context, commandsDi return nil, nil } raw, err := c.captureCommandOutput(ctx, - "proxmox-backup-manager s3 endpoint list --output-format=json", + commandSpec("proxmox-backup-manager", "s3", "endpoint", "list", "--output-format=json"), filepath.Join(commandsDir, "s3_endpoints.json"), "S3 endpoints", false) @@ -670,7 +670,7 @@ func (c *Collector) collectPBSS3EndpointBucketsRuntime(ctx context.Context, comm for _, id := range uniqueSortedStrings(endpointIDs) { out := filepath.Join(commandsDir, fmt.Sprintf("s3_endpoint_%s_buckets.json", sanitizeFilename(id))) c.collectCommandOptional(ctx, - fmt.Sprintf("proxmox-backup-manager s3 endpoint list-buckets %s --output-format=json", id), + commandSpec("proxmox-backup-manager", "s3", "endpoint", "list-buckets", id, "--output-format=json"), out, fmt.Sprintf("S3 endpoint buckets (%s)", id)) } @@ -738,8 +738,8 @@ func (c *Collector) collectPBSUserTokensForIDs(ctx context.Context, usersDir str aggregated := make(map[string]json.RawMessage) for _, id := range uniqueSortedStrings(userIDs) { tokenPath := filepath.Join(usersDir, fmt.Sprintf("%s_tokens.json", sanitizeFilename(id))) - cmd := fmt.Sprintf("proxmox-backup-manager user list-tokens %s --output-format=json", id) - if err := c.safeCmdOutput(ctx, cmd, tokenPath, fmt.Sprintf("API tokens for %s", id), false); err != nil { + spec := commandSpec("proxmox-backup-manager", "user", "list-tokens", id, "--output-format=json") + if err := c.safeCmdOutput(ctx, spec, tokenPath, fmt.Sprintf("API tokens for %s", id), false); err != nil { c.logger.Debug("Token export skipped for %s: %v", id, err) continue } diff --git a/internal/backup/collector_pbs_auth_test.go b/internal/backup/collector_pbs_auth_test.go index d5575383..42da4656 100644 --- a/internal/backup/collector_pbs_auth_test.go +++ b/internal/backup/collector_pbs_auth_test.go @@ -34,7 +34,7 @@ func TestSafeCmdOutputWithPBSAuthSetsEnv(t *testing.T) { collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuth(context.Background(), "echo hi", output, "test", true); err != nil { + if err := collector.safeCmdOutputWithPBSAuth(context.Background(), commandSpec("echo", "hi"), output, "test", true); err != nil { t.Fatalf("safeCmdOutputWithPBSAuth error: %v", err) } @@ -72,7 +72,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreBuildsRepo(t *testing.T) { collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "echo hi", output, "desc", "newds", true); err != nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("echo", "hi"), output, "desc", "newds", true); err != nil { t.Fatalf("safeCmdOutputWithPBSAuthForDatastore error: %v", err) } @@ -107,7 +107,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreSkipsWhenNoCredentials(t *testing.T collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "echo hi", output, "desc", "ds", true); err != nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("echo", "hi"), output, "desc", "ds", true); err != nil { t.Fatalf("safeCmdOutputWithPBSAuthForDatastore error: %v", err) } @@ -142,7 +142,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreDefaultsUserWhenRepoEmpty(t *testin collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "echo hi", output, "desc", "ds1", true); err != nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("echo", "hi"), output, "desc", "ds1", true); err != nil { t.Fatalf("safeCmdOutputWithPBSAuthForDatastore error: %v", err) } @@ -161,7 +161,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreDefaultsUserWhenRepoEmpty(t *testin func TestSafeCmdOutputWithPBSAuthReturnsErrorOnEmptyCommand(t *testing.T) { cfg := GetDefaultCollectorConfig() collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) - if err := collector.safeCmdOutputWithPBSAuth(context.Background(), " ", filepath.Join(t.TempDir(), "out.txt"), "desc", false); err == nil { + if err := collector.safeCmdOutputWithPBSAuth(context.Background(), CommandSpec{}, filepath.Join(t.TempDir(), "out.txt"), "desc", false); err == nil { t.Fatalf("expected error for empty command") } } @@ -173,7 +173,7 @@ func TestSafeCmdOutputWithPBSAuthCriticalCommandNotAvailableIncrementsFilesFaile cfg := GetDefaultCollectorConfig() collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) - if err := collector.safeCmdOutputWithPBSAuth(context.Background(), "missing-cmd arg", filepath.Join(t.TempDir(), "out.txt"), "desc", true); err == nil { + if err := collector.safeCmdOutputWithPBSAuth(context.Background(), commandSpec("missing-cmd", "arg"), filepath.Join(t.TempDir(), "out.txt"), "desc", true); err == nil { t.Fatalf("expected error for critical missing command") } if got := collector.GetStats().FilesFailed; got != 1 { @@ -198,7 +198,7 @@ func TestSafeCmdOutputWithPBSAuthDryRunSkipsExecution(t *testing.T) { cfg := GetDefaultCollectorConfig() collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, true) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuth(context.Background(), "echo hi", output, "desc", false); err != nil { + if err := collector.safeCmdOutputWithPBSAuth(context.Background(), commandSpec("echo", "hi"), output, "desc", false); err != nil { t.Fatalf("unexpected error: %v", err) } if _, err := os.Stat(output); !os.IsNotExist(err) { @@ -226,7 +226,7 @@ func TestSafeCmdOutputWithPBSAuthWriteFailureIncrementsFilesFailed(t *testing.T) if err := os.MkdirAll(outputDir, 0o755); err != nil { t.Fatalf("mkdir outputDir: %v", err) } - if err := collector.safeCmdOutputWithPBSAuth(context.Background(), "echo hi", outputDir, "desc", false); err == nil { + if err := collector.safeCmdOutputWithPBSAuth(context.Background(), commandSpec("echo", "hi"), outputDir, "desc", false); err == nil { t.Fatalf("expected write error when output path is a directory") } if got := collector.GetStats().FilesFailed; got != 1 { @@ -241,7 +241,7 @@ func TestSafeCmdOutputWithPBSAuthHonorsContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - if err := collector.safeCmdOutputWithPBSAuth(ctx, "echo hi", filepath.Join(t.TempDir(), "out.txt"), "desc", false); !errors.Is(err, context.Canceled) { + if err := collector.safeCmdOutputWithPBSAuth(ctx, commandSpec("echo", "hi"), filepath.Join(t.TempDir(), "out.txt"), "desc", false); !errors.Is(err, context.Canceled) { t.Fatalf("expected context.Canceled, got %v", err) } } @@ -254,7 +254,7 @@ func TestSafeCmdOutputWithPBSAuthNonCriticalCommandNotAvailableIsSkipped(t *test cfg := GetDefaultCollectorConfig() collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuth(context.Background(), "missing-cmd arg", output, "desc", false); err != nil { + if err := collector.safeCmdOutputWithPBSAuth(context.Background(), commandSpec("missing-cmd", "arg"), output, "desc", false); err != nil { t.Fatalf("expected non-critical missing command to be skipped, got %v", err) } if _, err := os.Stat(output); !os.IsNotExist(err) { @@ -278,7 +278,7 @@ func TestSafeCmdOutputWithPBSAuthNonCriticalCommandFailureIsSwallowed(t *testing cfg := GetDefaultCollectorConfig() collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuth(context.Background(), "echo hi", output, "desc", false); err != nil { + if err := collector.safeCmdOutputWithPBSAuth(context.Background(), commandSpec("echo", "hi"), output, "desc", false); err != nil { t.Fatalf("expected non-critical failure to be swallowed, got %v", err) } if _, err := os.Stat(output); !os.IsNotExist(err) { @@ -307,7 +307,7 @@ func TestSafeCmdOutputWithPBSAuthEnsureDirFailureReturnsError(t *testing.T) { t.Fatalf("write blocker: %v", err) } output := filepath.Join(blocker, "out.txt") - if err := collector.safeCmdOutputWithPBSAuth(context.Background(), "echo hi", output, "desc", true); err == nil { + if err := collector.safeCmdOutputWithPBSAuth(context.Background(), commandSpec("echo", "hi"), output, "desc", true); err == nil { t.Fatalf("expected ensureDir error") } } @@ -328,7 +328,7 @@ func TestSafeCmdOutputWithPBSAuthCriticalFailureIncrementsFilesFailed(t *testing cfg := GetDefaultCollectorConfig() collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuth(context.Background(), "echo hi", output, "desc", true); err == nil { + if err := collector.safeCmdOutputWithPBSAuth(context.Background(), commandSpec("echo", "hi"), output, "desc", true); err == nil { t.Fatalf("expected critical error") } if got := collector.GetStats().FilesFailed; got != 1 { @@ -358,7 +358,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreAppendsDatastoreAndIncludesFingerpr collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "echo hi", output, "desc", "ds1", false); err != nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("echo", "hi"), output, "desc", "ds1", false); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -389,7 +389,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreNonCriticalFailureReturnsNil(t *tes collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "echo hi", output, "desc", "ds1", false); err != nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("echo", "hi"), output, "desc", "ds1", false); err != nil { t.Fatalf("expected non-critical failure to be swallowed, got %v", err) } if _, err := os.Stat(output); !os.IsNotExist(err) { @@ -403,7 +403,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreReturnsErrorOnEmptyCommand(t *testi cfg.PBSPassword = "secret" collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), " ", filepath.Join(t.TempDir(), "out.txt"), "desc", "ds", false); err == nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), CommandSpec{}, filepath.Join(t.TempDir(), "out.txt"), "desc", "ds", false); err == nil { t.Fatalf("expected error for empty command") } } @@ -417,7 +417,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreHonorsContextCancellation(t *testin ctx, cancel := context.WithCancel(context.Background()) cancel() - if err := collector.safeCmdOutputWithPBSAuthForDatastore(ctx, "echo hi", filepath.Join(t.TempDir(), "out.txt"), "desc", "ds", false); !errors.Is(err, context.Canceled) { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(ctx, commandSpec("echo", "hi"), filepath.Join(t.TempDir(), "out.txt"), "desc", "ds", false); !errors.Is(err, context.Canceled) { t.Fatalf("expected context.Canceled, got %v", err) } } @@ -432,7 +432,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreNonCriticalCommandNotAvailableIsSki cfg.PBSPassword = "secret" collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "missing-cmd arg", output, "desc", "ds", false); err != nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("missing-cmd", "arg"), output, "desc", "ds", false); err != nil { t.Fatalf("expected non-critical missing command to be skipped, got %v", err) } if _, err := os.Stat(output); !os.IsNotExist(err) { @@ -450,7 +450,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreCriticalCommandNotAvailableIncremen cfg.PBSPassword = "secret" collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "missing-cmd arg", output, "desc", "ds", true); err == nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("missing-cmd", "arg"), output, "desc", "ds", true); err == nil { t.Fatalf("expected critical error for missing command") } if got := collector.GetStats().FilesFailed; got != 1 { @@ -478,7 +478,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreDryRunSkipsExecution(t *testing.T) collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, true) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "echo hi", output, "desc", "ds", false); err != nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("echo", "hi"), output, "desc", "ds", false); err != nil { t.Fatalf("unexpected error: %v", err) } if _, err := os.Stat(output); !os.IsNotExist(err) { @@ -505,7 +505,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreCriticalFailureIncrementsFilesFaile collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false) output := filepath.Join(t.TempDir(), "out.txt") - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "echo hi", output, "desc", "ds", true); err == nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("echo", "hi"), output, "desc", "ds", true); err == nil { t.Fatalf("expected critical error") } if got := collector.GetStats().FilesFailed; got != 1 { @@ -536,7 +536,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreWriteFailureIncrementsFilesFailed(t t.Fatalf("mkdir outputDir: %v", err) } - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "echo hi", outputDir, "desc", "ds", false); err == nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("echo", "hi"), outputDir, "desc", "ds", false); err == nil { t.Fatalf("expected write error") } if got := collector.GetStats().FilesFailed; got != 1 { @@ -567,7 +567,7 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreEnsureDirFailureReturnsError(t *tes t.Fatalf("write blocker: %v", err) } output := filepath.Join(blocker, "out.txt") - if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), "echo hi", output, "desc", "ds", false); err == nil { + if err := collector.safeCmdOutputWithPBSAuthForDatastore(context.Background(), commandSpec("echo", "hi"), output, "desc", "ds", false); err == nil { t.Fatalf("expected ensureDir error") } } diff --git a/internal/backup/collector_pbs_datastore.go b/internal/backup/collector_pbs_datastore.go index b399b20b..da5715e1 100644 --- a/internal/backup/collector_pbs_datastore.go +++ b/internal/backup/collector_pbs_datastore.go @@ -344,7 +344,7 @@ func (c *Collector) collectPBSDatastoreCLIConfigs(ctx context.Context, state *pb dsKey := ds.pathKey() if cliName := ds.cliName(); cliName != "" && !ds.isOverride() { c.safeCmdOutput(ctx, - fmt.Sprintf("proxmox-backup-manager datastore show %s --output-format=json", cliName), + 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) diff --git a/internal/backup/collector_privilege_sensitive_test.go b/internal/backup/collector_privilege_sensitive_test.go index ff7925f4..0a2a79fc 100644 --- a/internal/backup/collector_privilege_sensitive_test.go +++ b/internal/backup/collector_privilege_sensitive_test.go @@ -33,7 +33,7 @@ func TestSafeCmdOutput_LimitedPrivileges_DowngradesDmidecodeToSkip(t *testing.T) c := NewCollectorWithDeps(logger, GetDefaultCollectorConfig(), tmp, types.ProxmoxUnknown, false, deps) outPath := filepath.Join(tmp, "dmidecode.txt") - if err := c.safeCmdOutput(context.Background(), "dmidecode", outPath, "Hardware DMI information", false); err != nil { + if err := c.safeCmdOutput(context.Background(), commandSpec("dmidecode"), outPath, "Hardware DMI information", false); err != nil { t.Fatalf("safeCmdOutput returned error: %v", err) } @@ -73,7 +73,7 @@ func TestCaptureCommandOutput_LimitedPrivileges_DowngradesBlkidToSkipWithRestore c := NewCollectorWithDeps(logger, GetDefaultCollectorConfig(), tmp, types.ProxmoxUnknown, false, deps) outPath := filepath.Join(tmp, "blkid.txt") - data, err := c.captureCommandOutput(context.Background(), "blkid", outPath, "Block device identifiers (blkid)", false) + data, err := c.captureCommandOutput(context.Background(), commandSpec("blkid"), outPath, "Block device identifiers (blkid)", false) if err != nil { t.Fatalf("captureCommandOutput returned error: %v", err) } @@ -116,7 +116,7 @@ func TestSafeCmdOutput_LimitedPrivileges_DowngradesSensorsToSkip(t *testing.T) { c := NewCollectorWithDeps(logger, GetDefaultCollectorConfig(), tmp, types.ProxmoxUnknown, false, deps) outPath := filepath.Join(tmp, "sensors.txt") - if err := c.safeCmdOutput(context.Background(), "sensors", outPath, "Hardware sensors", false); err != nil { + if err := c.safeCmdOutput(context.Background(), commandSpec("sensors"), outPath, "Hardware sensors", false); err != nil { t.Fatalf("safeCmdOutput returned error: %v", err) } @@ -156,7 +156,7 @@ func TestSafeCmdOutput_LimitedPrivileges_DowngradesSmartctlToSkip(t *testing.T) c := NewCollectorWithDeps(logger, GetDefaultCollectorConfig(), tmp, types.ProxmoxUnknown, false, deps) outPath := filepath.Join(tmp, "smartctl_scan.txt") - if err := c.safeCmdOutput(context.Background(), "smartctl --scan", outPath, "SMART scan", false); err != nil { + if err := c.safeCmdOutput(context.Background(), commandSpec("smartctl", "--scan"), outPath, "SMART scan", false); err != nil { t.Fatalf("safeCmdOutput returned error: %v", err) } diff --git a/internal/backup/collector_pve.go b/internal/backup/collector_pve.go index 801a01f8..815e88fb 100644 --- a/internal/backup/collector_pve.go +++ b/internal/backup/collector_pve.go @@ -456,7 +456,7 @@ func (c *Collector) collectPVEVZDumpSnapshot(ctx context.Context) error { func (c *Collector) collectPVECoreRuntime(ctx context.Context, commandsDir string, info *pveRuntimeInfo) error { if err := c.safeCmdOutput(ctx, - "pveversion -v", + commandSpec("pveversion", "-v"), filepath.Join(commandsDir, "pveversion.txt"), "PVE version", true); err != nil { @@ -464,19 +464,19 @@ func (c *Collector) collectPVECoreRuntime(ctx context.Context, commandsDir strin } c.safeCmdOutput(ctx, - "pvenode config get", + commandSpec("pvenode", "config", "get"), filepath.Join(commandsDir, "node_config.txt"), "Node configuration", false) c.safeCmdOutput(ctx, - "pvesh get /version --output-format=json", + commandSpec("pvesh", "get", "/version", "--output-format=json"), filepath.Join(commandsDir, "api_version.json"), "API version", false) if nodeData, err := c.captureCommandOutput(ctx, - "pvesh get /nodes --output-format=json", + commandSpec("pvesh", "get", "/nodes", "--output-format=json"), filepath.Join(commandsDir, "nodes_status.json"), "node status", false); err != nil { @@ -505,22 +505,22 @@ func (c *Collector) collectPVEACLRuntime(ctx context.Context, commandsDir string } c.safeCmdOutput(ctx, - "pveum user list --output-format=json", + commandSpec("pveum", "user", "list", "--output-format=json"), filepath.Join(commandsDir, "pve_users.json"), "PVE users", false) c.safeCmdOutput(ctx, - "pveum group list --output-format=json", + commandSpec("pveum", "group", "list", "--output-format=json"), filepath.Join(commandsDir, "pve_groups.json"), "PVE groups", false) c.safeCmdOutput(ctx, - "pveum role list --output-format=json", + commandSpec("pveum", "role", "list", "--output-format=json"), filepath.Join(commandsDir, "pve_roles.json"), "PVE roles", false) c.safeCmdOutput(ctx, - "pveum pool list --output-format=json", + commandSpec("pveum", "pool", "list", "--output-format=json"), filepath.Join(commandsDir, "pools.json"), "PVE resource pools", false) @@ -529,32 +529,32 @@ func (c *Collector) collectPVEACLRuntime(ctx context.Context, commandsDir string func (c *Collector) collectPVEClusterRuntime(ctx context.Context, commandsDir string, clustered bool) { if clustered && c.config.BackupClusterConfig { c.safeCmdOutput(ctx, - "pvecm status", + commandSpec("pvecm", "status"), filepath.Join(commandsDir, "cluster_status.txt"), "Cluster status", false) c.safeCmdOutput(ctx, - "pvecm nodes", + commandSpec("pvecm", "nodes"), filepath.Join(commandsDir, "cluster_nodes.txt"), "Cluster nodes", false) c.safeCmdOutput(ctx, - "pvesh get /cluster/ha/status --output-format=json", + commandSpec("pvesh", "get", "/cluster/ha/status", "--output-format=json"), filepath.Join(commandsDir, "ha_status.json"), "HA status", false) c.safeCmdOutput(ctx, - "pvesh get /cluster/mapping/pci --output-format=json", + commandSpec("pvesh", "get", "/cluster/mapping/pci", "--output-format=json"), filepath.Join(commandsDir, "mapping_pci.json"), "PCI resource mappings", false) c.safeCmdOutput(ctx, - "pvesh get /cluster/mapping/usb --output-format=json", + commandSpec("pvesh", "get", "/cluster/mapping/usb", "--output-format=json"), filepath.Join(commandsDir, "mapping_usb.json"), "USB resource mappings", false) c.safeCmdOutput(ctx, - "pvesh get /cluster/mapping/dir --output-format=json", + commandSpec("pvesh", "get", "/cluster/mapping/dir", "--output-format=json"), filepath.Join(commandsDir, "mapping_dir.json"), "Directory resource mappings", false) @@ -571,14 +571,14 @@ func (c *Collector) collectPVEStorageRuntime(ctx context.Context, commandsDir st } c.safeCmdOutput(ctx, - fmt.Sprintf("pvesh get /nodes/%s/disks/list --output-format=json", nodeName), + commandSpec("pvesh", "get", fmt.Sprintf("/nodes/%s/disks/list", nodeName), "--output-format=json"), filepath.Join(commandsDir, "disks_list.json"), "Disks list", false) storageJSONPath := filepath.Join(commandsDir, "storage_status.json") if storageData, err := c.captureCommandOutput(ctx, - fmt.Sprintf("pvesh get /nodes/%s/storage --output-format=json", nodeName), + commandSpec("pvesh", "get", fmt.Sprintf("/nodes/%s/storage", nodeName), "--output-format=json"), storageJSONPath, "Storage status", false); err != nil { @@ -596,7 +596,7 @@ func (c *Collector) collectPVEStorageRuntime(ctx context.Context, commandsDir st } c.safeCmdOutput(ctx, - "pvesm status", + commandSpec("pvesm", "status"), filepath.Join(commandsDir, "pvesm_status.txt"), "Storage manager status", false) @@ -730,13 +730,13 @@ func (c *Collector) collectPVEGuestInventory(ctx context.Context) error { } c.safeCmdOutput(ctx, - fmt.Sprintf("pvesh get /nodes/%s/qemu --output-format=json", nodeName), + commandSpec("pvesh", "get", fmt.Sprintf("/nodes/%s/qemu", nodeName), "--output-format=json"), filepath.Join(commandsDir, "qemu_vms.json"), "QEMU VMs list", false) c.safeCmdOutput(ctx, - fmt.Sprintf("pvesh get /nodes/%s/lxc --output-format=json", nodeName), + commandSpec("pvesh", "get", fmt.Sprintf("/nodes/%s/lxc", nodeName), "--output-format=json"), filepath.Join(commandsDir, "lxc_containers.json"), "LXC containers list", false) @@ -771,7 +771,7 @@ func (c *Collector) collectPVEBackupJobDefinitions(ctx context.Context) error { } if _, err := c.captureCommandOutput(ctx, - "pvesh get /cluster/backup --output-format=json", + commandSpec("pvesh", "get", "/cluster/backup", "--output-format=json"), filepath.Join(jobsDir, "backup_jobs.json"), "backup jobs", false); err != nil { @@ -804,7 +804,7 @@ func (c *Collector) collectPVEBackupJobHistory(ctx context.Context, nodes []stri seen[node] = struct{}{} outputPath := filepath.Join(jobsDir, fmt.Sprintf("%s_backup_history.json", node)) c.captureCommandOutput(ctx, - fmt.Sprintf("pvesh get /nodes/%s/tasks --output-format=json --typefilter=vzdump", node), + commandSpec("pvesh", "get", fmt.Sprintf("/nodes/%s/tasks", node), "--output-format=json", "--typefilter=vzdump"), outputPath, fmt.Sprintf("%s backup history", node), false) @@ -889,7 +889,7 @@ func (c *Collector) collectPVEScheduleCrontab(ctx context.Context) error { } c.captureCommandOutput(ctx, - "crontab -l", + commandSpec("crontab", "-l"), filepath.Join(schedulesDir, "root_crontab.txt"), "root crontab", false) @@ -905,7 +905,7 @@ func (c *Collector) collectPVEScheduleTimers(ctx context.Context) error { return fmt.Errorf("failed to create schedules directory: %w", err) } c.captureCommandOutput(ctx, - "systemctl list-timers --all --no-pager", + commandSpec("systemctl", "list-timers", "--all", "--no-pager"), filepath.Join(schedulesDir, "systemd_timers.txt"), "systemd timers", false) @@ -946,7 +946,7 @@ func (c *Collector) collectPVEReplicationDefinitions(ctx context.Context) error } if _, err := c.captureCommandOutput(ctx, - "pvesh get /cluster/replication --output-format=json", + commandSpec("pvesh", "get", "/cluster/replication", "--output-format=json"), filepath.Join(repDir, "replication_jobs.json"), "replication jobs", false); err != nil { @@ -979,7 +979,7 @@ func (c *Collector) collectPVEReplicationStatus(ctx context.Context, nodes []str seen[node] = struct{}{} outputPath := filepath.Join(repDir, fmt.Sprintf("%s_replication_status.json", node)) c.captureCommandOutput(ctx, - fmt.Sprintf("pvesh get /nodes/%s/replication --output-format=json", node), + commandSpec("pvesh", "get", fmt.Sprintf("/nodes/%s/replication", node), "--output-format=json"), outputPath, fmt.Sprintf("%s replication status", node), false) @@ -1677,16 +1677,16 @@ func (c *Collector) collectPVECephRuntime(ctx context.Context) error { } commands := []struct { - cmd string + cmd CommandSpec file string desc string }{ - {"ceph -s", "ceph_status.txt", "Ceph status"}, - {"ceph osd df", "ceph_osd_df.txt", "Ceph OSD DF"}, - {"ceph osd tree", "ceph_osd_tree.txt", "Ceph OSD tree"}, - {"ceph mon stat", "ceph_mon_stat.txt", "Ceph mon stat"}, - {"ceph pg stat", "ceph_pg_stat.txt", "Ceph PG stat"}, - {"ceph health detail", "ceph_health.txt", "Ceph health"}, + {commandSpec("ceph", "-s"), "ceph_status.txt", "Ceph status"}, + {commandSpec("ceph", "osd", "df"), "ceph_osd_df.txt", "Ceph OSD DF"}, + {commandSpec("ceph", "osd", "tree"), "ceph_osd_tree.txt", "Ceph OSD tree"}, + {commandSpec("ceph", "mon", "stat"), "ceph_mon_stat.txt", "Ceph mon stat"}, + {commandSpec("ceph", "pg", "stat"), "ceph_pg_stat.txt", "Ceph PG stat"}, + {commandSpec("ceph", "health", "detail"), "ceph_health.txt", "Ceph health"}, } for _, command := range commands { @@ -1890,7 +1890,7 @@ func (c *Collector) aggregateReplicationStatus(ctx context.Context, replicationD func (c *Collector) writePVEVersionInfo(ctx context.Context, baseInfoDir string) error { versionFile := filepath.Join(baseInfoDir, "pve_version.txt") - if err := c.safeCmdOutput(ctx, "pveversion", versionFile, "PVE version info", false); err != nil { + if err := c.safeCmdOutput(ctx, commandSpec("pveversion"), versionFile, "PVE version info", false); err != nil { return err } return nil diff --git a/internal/backup/collector_pve_patterns_test.go b/internal/backup/collector_pve_patterns_test.go index 9c6e8f14..9876f8f0 100644 --- a/internal/backup/collector_pve_patterns_test.go +++ b/internal/backup/collector_pve_patterns_test.go @@ -208,7 +208,7 @@ func TestEffectivePVEClusterPath(t *testing.T) { }, { name: "whitespace only uses default", - configPath: " ", + configPath: "", expected: "/var/lib/pve-cluster", }, { diff --git a/internal/backup/collector_pve_util_test.go b/internal/backup/collector_pve_util_test.go index db04c747..7dccf8ae 100644 --- a/internal/backup/collector_pve_util_test.go +++ b/internal/backup/collector_pve_util_test.go @@ -1122,7 +1122,7 @@ func TestEffectivePVEConfigPathDetailed(t *testing.T) { }, { name: "whitespace only uses default", - configPath: " ", + configPath: "", expected: "/etc/pve", }, { diff --git a/internal/backup/collector_system.go b/internal/backup/collector_system.go index 720a1d04..13c4ab93 100644 --- a/internal/backup/collector_system.go +++ b/internal/backup/collector_system.go @@ -511,7 +511,7 @@ func (c *Collector) collectSystemRuntimeLeases(ctx context.Context) error { func (c *Collector) collectSystemCoreRuntime(ctx context.Context, commandsDir string) error { osReleasePath := c.systemPath("/etc/os-release") if err := c.collectCommandMulti(ctx, - fmt.Sprintf("cat %s", osReleasePath), + commandSpec("cat", osReleasePath), filepath.Join(commandsDir, "os_release.txt"), "OS release", true); err != nil { @@ -519,7 +519,7 @@ func (c *Collector) collectSystemCoreRuntime(ctx context.Context, commandsDir st } if err := c.collectCommandMulti(ctx, - "uname -a", + commandSpec("uname", "-a"), filepath.Join(commandsDir, "uname.txt"), "Kernel version", true); err != nil { @@ -527,7 +527,7 @@ func (c *Collector) collectSystemCoreRuntime(ctx context.Context, commandsDir st } c.safeCmdOutput(ctx, - "hostname -f", + commandSpec("hostname", "-f"), filepath.Join(commandsDir, "hostname.txt"), "Hostname", false) @@ -537,14 +537,14 @@ func (c *Collector) collectSystemCoreRuntime(ctx context.Context, commandsDir st func (c *Collector) collectSystemNetworkAddrRuntime(ctx context.Context, commandsDir string) error { if err := c.collectCommandMulti(ctx, - "ip addr show", + commandSpec("ip", "addr", "show"), filepath.Join(commandsDir, "ip_addr.txt"), "IP addresses", false); err != nil { return err } c.collectCommandOptional(ctx, - "ip -j addr show", + commandSpec("ip", "-j", "addr", "show"), filepath.Join(commandsDir, "ip_addr.json"), "IP addresses (json)") @@ -554,14 +554,14 @@ func (c *Collector) collectSystemNetworkAddrRuntime(ctx context.Context, command func (c *Collector) collectSystemNetworkRulesRuntime(ctx context.Context, commandsDir string) error { if err := c.collectCommandMulti(ctx, - "ip rule show", + commandSpec("ip", "rule", "show"), filepath.Join(commandsDir, "ip_rule.txt"), "IP rules", false); err != nil { return err } c.collectCommandOptional(ctx, - "ip -j rule show", + commandSpec("ip", "-j", "rule", "show"), filepath.Join(commandsDir, "ip_rule.json"), "IP rules (json)") @@ -571,23 +571,23 @@ func (c *Collector) collectSystemNetworkRulesRuntime(ctx context.Context, comman func (c *Collector) collectSystemNetworkRoutesRuntime(ctx context.Context, commandsDir string) error { if err := c.collectCommandMulti(ctx, - "ip route show", + commandSpec("ip", "route", "show"), filepath.Join(commandsDir, "ip_route.txt"), "IP routes", false); err != nil { return err } c.collectCommandOptional(ctx, - "ip -j route show", + commandSpec("ip", "-j", "route", "show"), filepath.Join(commandsDir, "ip_route.json"), "IP routes (json)") c.collectCommandOptional(ctx, - "ip -4 route show table all", + commandSpec("ip", "-4", "route", "show", "table", "all"), filepath.Join(commandsDir, "ip_route_all_v4.txt"), "IP routes (all tables v4)") c.collectCommandOptional(ctx, - "ip -6 route show table all", + commandSpec("ip", "-6", "route", "show", "table", "all"), filepath.Join(commandsDir, "ip_route_all_v6.txt"), "IP routes (all tables v6)") @@ -596,11 +596,11 @@ func (c *Collector) collectSystemNetworkRoutesRuntime(ctx context.Context, comma func (c *Collector) collectSystemNetworkLinksRuntime(ctx context.Context, commandsDir string) error { c.collectCommandOptional(ctx, - "ip -s link", + commandSpec("ip", "-s", "link"), filepath.Join(commandsDir, "ip_link.txt"), "IP link statistics") c.collectCommandOptional(ctx, - "ip -j link", + commandSpec("ip", "-j", "link"), filepath.Join(commandsDir, "ip_link.json"), "IP links (json)") @@ -609,12 +609,12 @@ func (c *Collector) collectSystemNetworkLinksRuntime(ctx context.Context, comman func (c *Collector) collectSystemNetworkNeighborsRuntime(ctx context.Context, commandsDir string) error { c.safeCmdOutput(ctx, - "ip neigh show", + commandSpec("ip", "neigh", "show"), filepath.Join(commandsDir, "ip_neigh.txt"), "Neighbor table", false) c.safeCmdOutput(ctx, - "ip -6 neigh show", + commandSpec("ip", "-6", "neigh", "show"), filepath.Join(commandsDir, "ip6_neigh.txt"), "Neighbor table (IPv6)", false) @@ -624,19 +624,19 @@ func (c *Collector) collectSystemNetworkNeighborsRuntime(ctx context.Context, co func (c *Collector) collectSystemNetworkBridgesRuntime(ctx context.Context, commandsDir string) error { c.collectCommandOptional(ctx, - "bridge -d link show", + commandSpec("bridge", "-d", "link", "show"), filepath.Join(commandsDir, "bridge_link.txt"), "Bridge links") c.collectCommandOptional(ctx, - "bridge vlan show", + commandSpec("bridge", "vlan", "show"), filepath.Join(commandsDir, "bridge_vlan.txt"), "Bridge VLANs") c.collectCommandOptional(ctx, - "bridge fdb show", + commandSpec("bridge", "fdb", "show"), filepath.Join(commandsDir, "bridge_fdb.txt"), "Bridge FDB") c.collectCommandOptional(ctx, - "bridge mdb show", + commandSpec("bridge", "mdb", "show"), filepath.Join(commandsDir, "bridge_mdb.txt"), "Bridge MDB") @@ -677,7 +677,7 @@ func (c *Collector) collectSystemNetworkBondingRuntime(ctx context.Context, comm func (c *Collector) collectSystemNetworkDNSRuntime(ctx context.Context, commandsDir string) error { resolvPath := c.systemPath("/etc/resolv.conf") return c.safeCmdOutput(ctx, - fmt.Sprintf("cat %s", resolvPath), + commandSpec("cat", resolvPath), filepath.Join(commandsDir, "resolv_conf.txt"), "DNS configuration", false) @@ -685,7 +685,7 @@ func (c *Collector) collectSystemNetworkDNSRuntime(ctx context.Context, commands func (c *Collector) collectSystemStorageMountsRuntime(ctx context.Context, commandsDir string) error { if err := c.collectCommandMulti(ctx, - "df -h", + commandSpec("df", "-h"), filepath.Join(commandsDir, "df.txt"), "Disk usage", false); err != nil { @@ -693,7 +693,7 @@ func (c *Collector) collectSystemStorageMountsRuntime(ctx context.Context, comma } c.safeCmdOutput(ctx, - "mount", + commandSpec("mount"), filepath.Join(commandsDir, "mount.txt"), "Mounted filesystems", false) @@ -703,7 +703,7 @@ func (c *Collector) collectSystemStorageMountsRuntime(ctx context.Context, comma func (c *Collector) collectSystemStorageBlockDevicesRuntime(ctx context.Context, commandsDir string) error { if err := c.collectCommandMulti(ctx, - "lsblk -f", + commandSpec("lsblk", "-f"), filepath.Join(commandsDir, "lsblk.txt"), "Block devices", false); err != nil { @@ -711,12 +711,12 @@ func (c *Collector) collectSystemStorageBlockDevicesRuntime(ctx context.Context, } c.collectCommandOptional(ctx, - "lsblk -J -O", + commandSpec("lsblk", "-J", "-O"), filepath.Join(commandsDir, "lsblk_json.json"), "Block devices (JSON)") c.collectCommandOptional(ctx, - "blkid", + commandSpec("blkid"), filepath.Join(commandsDir, "blkid.txt"), "Block device identifiers (blkid)") @@ -725,7 +725,7 @@ func (c *Collector) collectSystemStorageBlockDevicesRuntime(ctx context.Context, func (c *Collector) collectSystemComputeMemoryCPURuntime(ctx context.Context, commandsDir string) error { if err := c.collectCommandMulti(ctx, - "free -h", + commandSpec("free", "-h"), filepath.Join(commandsDir, "free.txt"), "Memory usage", false); err != nil { @@ -733,7 +733,7 @@ func (c *Collector) collectSystemComputeMemoryCPURuntime(ctx context.Context, co } if err := c.collectCommandMulti(ctx, - "lscpu", + commandSpec("lscpu"), filepath.Join(commandsDir, "lscpu.txt"), "CPU information", false); err != nil { @@ -745,7 +745,7 @@ func (c *Collector) collectSystemComputeMemoryCPURuntime(ctx context.Context, co func (c *Collector) collectSystemComputeBusInventoryRuntime(ctx context.Context, commandsDir string) error { if err := c.collectCommandMulti(ctx, - "lspci -v", + commandSpec("lspci", "-v"), filepath.Join(commandsDir, "lspci.txt"), "PCI devices", false); err != nil { @@ -753,7 +753,7 @@ func (c *Collector) collectSystemComputeBusInventoryRuntime(ctx context.Context, } c.safeCmdOutput(ctx, - "lsusb", + commandSpec("lsusb"), filepath.Join(commandsDir, "lsusb.txt"), "USB devices", false) @@ -767,14 +767,14 @@ func (c *Collector) collectSystemServicesRuntime(ctx context.Context, commandsDi } if err := c.collectCommandMulti(ctx, - "systemctl list-units --type=service --all", + commandSpec("systemctl", "list-units", "--type=service", "--all"), filepath.Join(commandsDir, "systemctl_services.txt"), "Systemd services", false); err != nil { return err } - c.safeCmdOutput(ctx, "systemctl list-unit-files --type=service", + c.safeCmdOutput(ctx, commandSpec("systemctl", "list-unit-files", "--type=service"), filepath.Join(commandsDir, "systemctl_service_files.txt"), "Systemd service files", false) @@ -789,7 +789,7 @@ func (c *Collector) collectSystemPackagesInstalledRuntime(ctx context.Context, c } if err := c.collectCommandMulti(ctx, - "dpkg -l", + commandSpec("dpkg", "-l"), filepath.Join(packagesDir, "dpkg_list.txt"), "Installed packages", false); err != nil { @@ -806,7 +806,7 @@ func (c *Collector) collectSystemPackagesAptPolicyRuntime(ctx context.Context, c } return c.safeCmdOutput(ctx, - "apt-cache policy", + commandSpec("apt-cache", "policy"), filepath.Join(commandsDir, "apt_policy.txt"), "APT policy", false) @@ -818,7 +818,7 @@ func (c *Collector) collectSystemFirewallIPTablesRuntime(ctx context.Context, co } if err := c.collectCommandMulti(ctx, - "iptables-save", + commandSpec("iptables-save"), filepath.Join(commandsDir, "iptables.txt"), "iptables rules", false); err != nil { @@ -826,7 +826,7 @@ func (c *Collector) collectSystemFirewallIPTablesRuntime(ctx context.Context, co } c.collectCommandOptional(ctx, - "iptables -t nat -vnL --line-numbers", + commandSpec("iptables", "-t", "nat", "-vnL", "--line-numbers"), filepath.Join(commandsDir, "iptables_nat.txt"), "iptables NAT table") @@ -839,7 +839,7 @@ func (c *Collector) collectSystemFirewallIP6TablesRuntime(ctx context.Context, c } if err := c.collectCommandMulti(ctx, - "ip6tables-save", + commandSpec("ip6tables-save"), filepath.Join(commandsDir, "ip6tables.txt"), "ip6tables rules", false); err != nil { @@ -847,7 +847,7 @@ func (c *Collector) collectSystemFirewallIP6TablesRuntime(ctx context.Context, c } c.collectCommandOptional(ctx, - "ip6tables -t nat -vnL --line-numbers", + commandSpec("ip6tables", "-t", "nat", "-vnL", "--line-numbers"), filepath.Join(commandsDir, "ip6tables_nat.txt"), "ip6tables NAT table") @@ -860,7 +860,7 @@ func (c *Collector) collectSystemFirewallNFTablesRuntime(ctx context.Context, co } return c.safeCmdOutput(ctx, - "nft list ruleset", + commandSpec("nft", "list", "ruleset"), filepath.Join(commandsDir, "nftables.txt"), "nftables rules", false) @@ -872,11 +872,11 @@ func (c *Collector) collectSystemFirewallUFWRuntime(ctx context.Context, command } c.collectCommandOptional(ctx, - "ufw status verbose", + commandSpec("ufw", "status", "verbose"), filepath.Join(commandsDir, "ufw_status.txt"), "UFW status") c.collectCommandOptional(ctx, - "systemctl status --no-pager ufw", + commandSpec("systemctl", "status", "--no-pager", "ufw"), filepath.Join(commandsDir, "systemctl_ufw.txt"), "systemctl ufw") @@ -889,15 +889,15 @@ func (c *Collector) collectSystemFirewallFirewalldRuntime(ctx context.Context, c } c.collectCommandOptional(ctx, - "firewall-cmd --state", + commandSpec("firewall-cmd", "--state"), filepath.Join(commandsDir, "firewalld_state.txt"), "firewalld state") c.collectCommandOptional(ctx, - "firewall-cmd --list-all", + commandSpec("firewall-cmd", "--list-all"), filepath.Join(commandsDir, "firewalld_list_all.txt"), "firewalld rules") c.collectCommandOptional(ctx, - "systemctl status --no-pager firewalld", + commandSpec("systemctl", "status", "--no-pager", "firewalld"), filepath.Join(commandsDir, "systemctl_firewalld.txt"), "systemctl firewalld") @@ -910,7 +910,7 @@ func (c *Collector) collectSystemKernelModulesRuntime(ctx context.Context, comma } c.safeCmdOutput(ctx, - "lsmod", + commandSpec("lsmod"), filepath.Join(commandsDir, "lsmod.txt"), "Loaded kernel modules", false) @@ -923,7 +923,7 @@ func (c *Collector) collectSystemSysctlRuntime(ctx context.Context, commandsDir } c.safeCmdOutput(ctx, - "sysctl -a", + commandSpec("sysctl", "-a"), filepath.Join(commandsDir, "sysctl.txt"), "Sysctl values", false) @@ -949,24 +949,24 @@ func (c *Collector) collectSystemZFSRuntime(ctx context.Context, commandsDir str if _, err := c.depLookPath("zpool"); err == nil { c.collectCommandOptional(ctx, - "zpool status", + commandSpec("zpool", "status"), filepath.Join(zfsDir, "zpool_status.txt"), "ZFS pool status") c.collectCommandOptional(ctx, - "zpool list", + commandSpec("zpool", "list"), filepath.Join(zfsDir, "zpool_list.txt"), "ZFS pool list") } if _, err := c.depLookPath("zfs"); err == nil { c.collectCommandOptional(ctx, - "zfs list", + commandSpec("zfs", "list"), filepath.Join(zfsDir, "zfs_list.txt"), "ZFS filesystem list") c.collectCommandOptional(ctx, - "zfs get all", + commandSpec("zfs", "get", "all"), filepath.Join(zfsDir, "zfs_get_all.txt"), "ZFS properties", ) @@ -981,21 +981,21 @@ func (c *Collector) collectSystemLVMRuntime(ctx context.Context, commandsDir str } if _, err := c.depLookPath("pvs"); err == nil { c.safeCmdOutput(ctx, - "pvs", + commandSpec("pvs"), filepath.Join(commandsDir, "lvm_pvs.txt"), "LVM physical volumes", false) } if _, err := c.depLookPath("vgs"); err == nil { c.safeCmdOutput(ctx, - "vgs", + commandSpec("vgs"), filepath.Join(commandsDir, "lvm_vgs.txt"), "LVM volume groups", false) } if _, err := c.depLookPath("lvs"); err == nil { c.safeCmdOutput(ctx, - "lvs", + commandSpec("lvs"), filepath.Join(commandsDir, "lvm_lvs.txt"), "LVM logical volumes", false) @@ -1161,14 +1161,14 @@ func (c *Collector) collectKernelInfo(ctx context.Context) error { // Kernel command line c.safeCmdOutput(ctx, - fmt.Sprintf("cat %s", c.systemPath("/proc/cmdline")), + commandSpec("cat", c.systemPath("/proc/cmdline")), filepath.Join(commandsDir, "kernel_cmdline.txt"), "Kernel command line", false) // Kernel version details c.safeCmdOutput(ctx, - fmt.Sprintf("cat %s", c.systemPath("/proc/version")), + commandSpec("cat", c.systemPath("/proc/version")), filepath.Join(commandsDir, "kernel_version.txt"), "Kernel version details", false) @@ -1184,7 +1184,7 @@ func (c *Collector) collectHardwareInfo(ctx context.Context) error { // DMI decode (requires root) c.safeCmdOutput(ctx, - "dmidecode", + commandSpec("dmidecode"), filepath.Join(commandsDir, "dmidecode.txt"), "Hardware DMI information", false) @@ -1192,7 +1192,7 @@ func (c *Collector) collectHardwareInfo(ctx context.Context) error { // Hardware sensors (if available) if _, err := c.depStat(c.systemPath("/usr/bin/sensors")); err == nil { c.safeCmdOutput(ctx, - "sensors", + commandSpec("sensors"), filepath.Join(commandsDir, "sensors.txt"), "Hardware sensors", false) @@ -1202,7 +1202,7 @@ func (c *Collector) collectHardwareInfo(ctx context.Context) error { if _, err := c.depStat(c.systemPath("/usr/sbin/smartctl")); err == nil { // Get list of disks c.safeCmdOutput(ctx, - "smartctl --scan", + commandSpec("smartctl", "--scan"), filepath.Join(commandsDir, "smartctl_scan.txt"), "SMART scan", false) diff --git a/internal/backup/collector_system_test.go b/internal/backup/collector_system_test.go index 14af5a69..2983a478 100644 --- a/internal/backup/collector_system_test.go +++ b/internal/backup/collector_system_test.go @@ -58,7 +58,7 @@ func TestEnsureSystemPathPreservesCustomPrefix(t *testing.T) { func TestCollectCustomPathsIgnoresEmptyEntries(t *testing.T) { collector := newTestCollector(t) - collector.config.CustomBackupPaths = []string{"", " ", ""} + collector.config.CustomBackupPaths = []string{"", "", ""} if err := collector.collectCustomPaths(context.Background()); err != nil { t.Fatalf("collectCustomPaths returned error for empty paths: %v", err) diff --git a/internal/backup/collector_test.go b/internal/backup/collector_test.go index 372393db..c4c92379 100644 --- a/internal/backup/collector_test.go +++ b/internal/backup/collector_test.go @@ -270,7 +270,7 @@ func TestCollectorSafeCmdOutput(t *testing.T) { // Use a simple command that should be available on all systems outputFile := filepath.Join(tempDir, "output.txt") ctx := context.Background() - err := collector.safeCmdOutput(ctx, "echo test", outputFile, "test command", false) + err := collector.safeCmdOutput(ctx, commandSpec("echo", "test"), outputFile, "test command", false) if err != nil { t.Fatalf("safeCmdOutput failed: %v", err) @@ -298,7 +298,7 @@ func TestCollectorSafeCmdOutputNonCriticalFailure(t *testing.T) { ctx := context.Background() outputFile := filepath.Join(tempDir, "non_critical.txt") - if err := collector.safeCmdOutput(ctx, "false", outputFile, "non critical failure", false); err != nil { + if err := collector.safeCmdOutput(ctx, commandSpec("false"), outputFile, "non critical failure", false); err != nil { t.Fatalf("non-critical command should not return error: %v", err) } @@ -321,7 +321,7 @@ func TestCollectorSafeCmdOutputCriticalFailure(t *testing.T) { ctx := context.Background() outputFile := filepath.Join(tempDir, "critical.txt") - if err := collector.safeCmdOutput(ctx, "false", outputFile, "critical failure", true); err == nil { + if err := collector.safeCmdOutput(ctx, commandSpec("false"), outputFile, "critical failure", true); err == nil { t.Fatalf("expected error for critical command failure") } @@ -344,7 +344,7 @@ func TestCollectorSafeCmdOutputCommandNotFound(t *testing.T) { // Use a command that definitely doesn't exist outputFile := filepath.Join(tempDir, "output.txt") ctx := context.Background() - err := collector.safeCmdOutput(ctx, "nonexistent_command_xyz", outputFile, "test command", false) + err := collector.safeCmdOutput(ctx, commandSpec("nonexistent_command_xyz"), outputFile, "test command", false) // Should return nil (command not found is not an error for non-critical commands) if err != nil { @@ -814,7 +814,7 @@ func TestCaptureCommandOutput_SystemctlStatusUnitNotFound_Skips(t *testing.T) { collector := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) outPath := filepath.Join(tmp, "systemctl_status.txt") - data, err := collector.captureCommandOutput(context.Background(), "systemctl status foo", outPath, "systemctl status", false) + data, err := collector.captureCommandOutput(context.Background(), commandSpec("systemctl", "status", "foo"), outPath, "systemctl status", false) if err != nil { t.Fatalf("captureCommandOutput returned error: %v", err) } @@ -848,7 +848,7 @@ func TestCaptureCommandOutput_SystemctlStatusSystemdUnavailable_Skips(t *testing collector := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) outPath := filepath.Join(tmp, "systemctl_status.txt") - data, err := collector.captureCommandOutput(context.Background(), "systemctl status ssh", outPath, "systemctl status", false) + data, err := collector.captureCommandOutput(context.Background(), commandSpec("systemctl", "status", "ssh"), outPath, "systemctl status", false) if err != nil { t.Fatalf("captureCommandOutput returned error: %v", err) } @@ -1412,7 +1412,7 @@ func TestSafeCmdOutputHonorsContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - err := c.safeCmdOutput(ctx, "echo hi", filepath.Join(tmp, "out.txt"), "canceled", false) + err := c.safeCmdOutput(ctx, commandSpec("echo", "hi"), filepath.Join(tmp, "out.txt"), "canceled", false) if !errors.Is(err, context.Canceled) { t.Fatalf("expected context.Canceled, got %v", err) } @@ -1424,7 +1424,7 @@ func TestSafeCmdOutputReturnsErrorOnEmptyCommand(t *testing.T) { tmp := t.TempDir() c := NewCollector(logger, cfg, tmp, types.ProxmoxUnknown, false) - if err := c.safeCmdOutput(context.Background(), " ", filepath.Join(tmp, "out.txt"), "empty", false); err == nil { + if err := c.safeCmdOutput(context.Background(), CommandSpec{}, filepath.Join(tmp, "out.txt"), "empty", false); err == nil { t.Fatalf("expected error for empty command") } } @@ -1439,7 +1439,7 @@ func TestSafeCmdOutputCriticalCommandNotAvailableIncrementsFilesFailed(t *testin } c := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) - err := c.safeCmdOutput(context.Background(), "does-not-exist", filepath.Join(tmp, "out.txt"), "critical", true) + err := c.safeCmdOutput(context.Background(), commandSpec("does-not-exist"), filepath.Join(tmp, "out.txt"), "critical", true) if err == nil { t.Fatalf("expected error for critical missing command") } @@ -1468,7 +1468,7 @@ func TestSafeCmdOutputWriteFailureIncrementsFilesFailed(t *testing.T) { } c := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) - err := c.safeCmdOutput(context.Background(), "echo hi", outDir, "write-fail", false) + err := c.safeCmdOutput(context.Background(), commandSpec("echo", "hi"), outDir, "write-fail", false) if err == nil { t.Fatalf("expected write error when output path is a directory") } @@ -1498,7 +1498,7 @@ func TestSafeCmdOutputEnsureDirFailureReturnsError(t *testing.T) { } c := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) - if err := c.safeCmdOutput(context.Background(), "echo hi", output, "ensureDir-fail", false); err == nil { + if err := c.safeCmdOutput(context.Background(), commandSpec("echo", "hi"), output, "ensureDir-fail", false); err == nil { t.Fatalf("expected ensureDir error") } } @@ -1509,7 +1509,7 @@ func TestCaptureCommandOutputReturnsErrorOnEmptyCommand(t *testing.T) { tmp := t.TempDir() c := NewCollector(logger, cfg, tmp, types.ProxmoxUnknown, false) - if _, err := c.captureCommandOutput(context.Background(), " ", filepath.Join(tmp, "out.txt"), "empty", false); err == nil { + if _, err := c.captureCommandOutput(context.Background(), CommandSpec{}, filepath.Join(tmp, "out.txt"), "empty", false); err == nil { t.Fatalf("expected error for empty command") } } @@ -1523,7 +1523,7 @@ func TestCaptureCommandOutputHonorsContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - if _, err := c.captureCommandOutput(ctx, "echo hi", filepath.Join(tmp, "out.txt"), "canceled", false); !errors.Is(err, context.Canceled) { + if _, err := c.captureCommandOutput(ctx, commandSpec("echo", "hi"), filepath.Join(tmp, "out.txt"), "canceled", false); !errors.Is(err, context.Canceled) { t.Fatalf("expected context.Canceled, got %v", err) } } @@ -1547,7 +1547,7 @@ func TestCaptureCommandOutputPropagatesWriteReportFileError(t *testing.T) { c := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) // writeReportFile should fail because output path is a directory. - if _, err := c.captureCommandOutput(context.Background(), "echo hi", outDir, "desc", false); err == nil { + if _, err := c.captureCommandOutput(context.Background(), commandSpec("echo", "hi"), outDir, "desc", false); err == nil { t.Fatalf("expected writeReportFile error") } } @@ -1562,7 +1562,7 @@ func TestCaptureCommandOutputCriticalCommandNotAvailableIncrementsFilesFailed(t } c := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) - _, err := c.captureCommandOutput(context.Background(), "missing-cmd arg", filepath.Join(tmp, "out.txt"), "critical", true) + _, err := c.captureCommandOutput(context.Background(), commandSpec("missing-cmd", "arg"), filepath.Join(tmp, "out.txt"), "critical", true) if err == nil { t.Fatalf("expected error for critical missing command") } @@ -1588,7 +1588,7 @@ func TestCaptureCommandOutputNonCriticalFailureReturnsNil(t *testing.T) { c := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) outPath := filepath.Join(tmp, "out.txt") - data, err := c.captureCommandOutput(context.Background(), "cmd arg", outPath, "noncritical", false) + data, err := c.captureCommandOutput(context.Background(), commandSpec("cmd", "arg"), outPath, "noncritical", false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1606,7 +1606,7 @@ func TestCollectCommandMultiRequiresPrimaryOutput(t *testing.T) { tmp := t.TempDir() c := NewCollector(logger, cfg, tmp, types.ProxmoxUnknown, false) - if err := c.collectCommandMulti(context.Background(), "echo hi", "", "desc", false); err == nil { + if err := c.collectCommandMulti(context.Background(), commandSpec("echo", "hi"), "", "desc", false); err == nil { t.Fatalf("expected error when primary output is empty") } } @@ -1630,7 +1630,7 @@ func TestCollectCommandMultiFailsWhenMirrorWriteFails(t *testing.T) { t.Fatalf("mkdir mirrorDir: %v", err) } - if err := c.collectCommandMulti(context.Background(), "echo hi", primary, "desc", false, mirrorDir, ""); err == nil { + if err := c.collectCommandMulti(context.Background(), commandSpec("echo", "hi"), primary, "desc", false, mirrorDir, ""); err == nil { t.Fatalf("expected error when mirror path is a directory") } } @@ -1649,7 +1649,7 @@ func TestCollectCommandMultiSkipsEmptyMirrors(t *testing.T) { c := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) primary := filepath.Join(tmp, "primary.txt") - if err := c.collectCommandMulti(context.Background(), "echo hi", primary, "desc", false, ""); err != nil { + if err := c.collectCommandMulti(context.Background(), commandSpec("echo", "hi"), primary, "desc", false, ""); err != nil { t.Fatalf("unexpected error: %v", err) } if _, err := os.Stat(primary); err != nil { @@ -1670,7 +1670,7 @@ func TestCollectCommandOptionalSkipsWhenNoOutputPath(t *testing.T) { }, } c := NewCollectorWithDeps(logger, cfg, tmp, types.ProxmoxUnknown, false, deps) - c.collectCommandOptional(context.Background(), "echo hi", "", "desc", filepath.Join(tmp, "mirror")) + c.collectCommandOptional(context.Background(), commandSpec("echo", "hi"), "", "desc", filepath.Join(tmp, "mirror")) } func TestCollectCommandOptionalDoesNotMirrorEmptyOutput(t *testing.T) { @@ -1688,7 +1688,7 @@ func TestCollectCommandOptionalDoesNotMirrorEmptyOutput(t *testing.T) { primary := filepath.Join(tmp, "primary.txt") mirror := filepath.Join(tmp, "mirror.txt") - c.collectCommandOptional(context.Background(), "echo hi", primary, "desc", mirror) + c.collectCommandOptional(context.Background(), commandSpec("echo", "hi"), primary, "desc", mirror) if _, err := os.Stat(primary); err != nil { t.Fatalf("expected primary file to exist: %v", err) @@ -1718,7 +1718,7 @@ func TestCollectCommandOptionalSkipsOnCaptureError(t *testing.T) { } mirror := filepath.Join(tmp, "mirror.txt") - c.collectCommandOptional(context.Background(), "echo hi", outDir, "desc", "", mirror) + c.collectCommandOptional(context.Background(), commandSpec("echo", "hi"), outDir, "desc", "", mirror) if _, err := os.Stat(mirror); !os.IsNotExist(err) { t.Fatalf("expected mirror to be skipped on capture error, stat err=%v", err) } @@ -1739,7 +1739,7 @@ func TestCollectCommandOptionalSkipsEmptyMirrorEntries(t *testing.T) { primary := filepath.Join(tmp, "primary.txt") mirror := filepath.Join(tmp, "mirror.txt") - c.collectCommandOptional(context.Background(), "echo hi", primary, "desc", "", mirror) + c.collectCommandOptional(context.Background(), commandSpec("echo", "hi"), primary, "desc", "", mirror) if _, err := os.Stat(mirror); err != nil { t.Fatalf("expected mirror file to exist: %v", err) } @@ -1764,5 +1764,5 @@ func TestCollectCommandOptionalIgnoresMirrorWriteFailures(t *testing.T) { t.Fatalf("mkdir mirrorDir: %v", err) } - c.collectCommandOptional(context.Background(), "echo hi", primary, "desc", mirrorDir) + c.collectCommandOptional(context.Background(), commandSpec("echo", "hi"), primary, "desc", mirrorDir) } diff --git a/internal/config/config.go b/internal/config/config.go index 1586a111..ae3c5e4c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/internal/types" "github.com/tis24dev/proxsave/pkg/utils" ) @@ -381,6 +382,9 @@ func (c *Config) parse() error { if err := c.validateSecondarySettings(); err != nil { return err } + if err := c.validateCloudSettings(); err != nil { + return err + } c.autoDetectPBSAuth() return nil } @@ -397,6 +401,31 @@ func (c *Config) validateSecondarySettings() error { return nil } +func (c *Config) validateCloudSettings() error { + if !c.CloudEnabled { + return nil + } + remoteName, basePath := splitCloudRemoteRef(strings.TrimSpace(c.CloudRemote)) + if err := safeexec.ValidateRcloneRemoteName(remoteName); err != nil { + return fmt.Errorf("CLOUD_REMOTE invalid: %w", err) + } + if err := safeexec.ValidateRemoteRelativePath(strings.Trim(strings.TrimSpace(basePath), "/"), "CLOUD_REMOTE path"); err != nil { + return err + } + if err := safeexec.ValidateRemoteRelativePath(strings.Trim(strings.TrimSpace(c.CloudRemotePath), "/"), "CLOUD_REMOTE_PATH"); err != nil { + return err + } + return nil +} + +func splitCloudRemoteRef(ref string) (remoteName, relPath string) { + parts := strings.SplitN(ref, ":", 2) + if len(parts) < 2 { + return ref, "" + } + return parts[0], parts[1] +} + func (c *Config) parseGeneralSettings() { c.BackupEnabled = c.getBool("BACKUP_ENABLED", true) c.DryRun = c.getBool("DRY_RUN", false) diff --git a/internal/config/migration.go b/internal/config/migration.go index cf5c2f5f..e8448578 100644 --- a/internal/config/migration.go +++ b/internal/config/migration.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/pkg/utils" ) @@ -221,6 +222,18 @@ func validateMigratedConfig(cfg *Config) error { if cfg.CloudEnabled && strings.TrimSpace(cfg.CloudRemote) == "" { return fmt.Errorf("CLOUD_REMOTE required when CLOUD_ENABLED=true") } + if cfg.CloudEnabled { + remoteName, basePath := splitCloudRemoteRef(strings.TrimSpace(cfg.CloudRemote)) + if err := safeexec.ValidateRcloneRemoteName(remoteName); err != nil { + return fmt.Errorf("CLOUD_REMOTE invalid: %w", err) + } + if err := safeexec.ValidateRemoteRelativePath(strings.Trim(strings.TrimSpace(basePath), "/"), "CLOUD_REMOTE path"); err != nil { + return err + } + if err := safeexec.ValidateRemoteRelativePath(strings.Trim(strings.TrimSpace(cfg.CloudRemotePath), "/"), "CLOUD_REMOTE_PATH"); err != nil { + return err + } + } if cfg.SetBackupPermissions { if strings.TrimSpace(cfg.BackupUser) == "" || strings.TrimSpace(cfg.BackupGroup) == "" { return fmt.Errorf("BACKUP_USER/BACKUP_GROUP must be set when SET_BACKUP_PERMISSIONS=true") diff --git a/internal/environment/detect.go b/internal/environment/detect.go index d734702b..49ef60b9 100644 --- a/internal/environment/detect.go +++ b/internal/environment/detect.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/internal/types" ) @@ -47,7 +48,7 @@ var ( } lookPathFunc = exec.LookPath - commandContextFunc = exec.CommandContext + commandContextFunc = safeexec.TrustedCommandContext readFileFunc = os.ReadFile statFunc = os.Stat @@ -341,7 +342,18 @@ func runCommand(command string, args ...string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) defer cancel() - cmd := commandContextFunc(ctx, command, args...) + var ( + cmd *exec.Cmd + cmdErr error + ) + if filepath.IsAbs(command) { + cmd, cmdErr = commandContextFunc(ctx, command, args...) + } else { + cmd, cmdErr = safeexec.CommandContext(ctx, command, args...) + } + if cmdErr != nil { + return "", cmdErr + } output, err := cmd.Output() if ctx.Err() == context.DeadlineExceeded { return "", fmt.Errorf("command %s timed out", command) diff --git a/internal/identity/identity.go b/internal/identity/identity.go index f8f0c7e3..755dc24b 100644 --- a/internal/identity/identity.go +++ b/internal/identity/identity.go @@ -19,6 +19,7 @@ import ( "time" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" ) const ( @@ -969,7 +970,11 @@ func setImmutableAttributeWithContext(ctx context.Context, path string, enable b flag = "-i" } - cmd := exec.CommandContext(ctx, chattrPath, flag, path) + cmd, err := safeexec.TrustedCommandContext(ctx, chattrPath, flag, path) + if err != nil { + logDebug(logger, "Identity: immutable: chattr path rejected for %s: %v", path, err) + return nil + } if err := cmd.Run(); err != nil { if ctxErr := ctx.Err(); ctxErr != nil { logDebug(logger, "Identity: immutable: chattr canceled for %s: %v", path, ctxErr) diff --git a/internal/notify/email.go b/internal/notify/email.go index 8dfa0082..ac7675ae 100644 --- a/internal/notify/email.go +++ b/internal/notify/email.go @@ -15,6 +15,7 @@ import ( "time" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/internal/types" ) @@ -656,7 +657,10 @@ func (e *EmailNotifier) detectRecipientViaUserCfg(cfgPath string, targetUserID s } func runCombinedOutput(ctx context.Context, name string, args ...string) ([]byte, error) { - cmd := exec.CommandContext(ctx, name, args...) + cmd, err := safeexec.CommandContext(ctx, name, args...) + if err != nil { + return nil, err + } out, err := cmd.CombinedOutput() if err != nil { return out, err @@ -671,6 +675,13 @@ func truncateForLog(s string, maxBytes int) string { return s[:maxBytes] + "...(truncated)" } +func commandForMailTool(ctx context.Context, pathOrName string, args ...string) (*exec.Cmd, error) { + if filepath.IsAbs(pathOrName) { + return safeexec.TrustedCommandContext(ctx, pathOrName, args...) + } + return safeexec.CommandContext(ctx, pathOrName, args...) +} + // sendViaRelay sends email via cloud relay func (e *EmailNotifier) sendViaRelay(ctx context.Context, recipient, subject, htmlBody, textBody string, data *NotificationData) error { // Build payload @@ -697,7 +708,10 @@ func (e *EmailNotifier) isMTAServiceActive(ctx context.Context) (bool, string) { } for _, service := range services { - cmd := exec.CommandContext(ctx, "systemctl", "is-active", service) + cmd, err := safeexec.CommandContext(ctx, "systemctl", "is-active", service) + if err != nil { + return false, err.Error() + } if err := cmd.Run(); err == nil { e.logger.Debug("MTA service %s is active", service) return true, service @@ -768,7 +782,10 @@ func (e *EmailNotifier) checkMailQueue(ctx context.Context) (int, error) { mailqPath = "mailq" } - cmd := exec.CommandContext(ctx, mailqPath) + cmd, err := commandForMailTool(ctx, mailqPath) + if err != nil { + return 0, err + } output, err := cmd.Output() if err != nil { return 0, fmt.Errorf("mailq failed: %w", err) @@ -810,7 +827,10 @@ func (e *EmailNotifier) detectQueueEntry(ctx context.Context, recipient string) return "", "", fmt.Errorf("mailq command not found") } - cmd := exec.CommandContext(ctx, mailqPath) + cmd, err := commandForMailTool(ctx, mailqPath) + if err != nil { + return "", "", err + } output, err := cmd.Output() if err != nil { return "", "", fmt.Errorf("mailq failed: %w", err) @@ -851,7 +871,10 @@ func (e *EmailNotifier) tailMailLog(ctx context.Context, maxLines int) ([]string continue } - cmd := exec.CommandContext(ctx, "tail", "-n", strconv.Itoa(maxLines), logFile) + cmd, err := safeexec.CommandContext(ctx, "tail", "-n", strconv.Itoa(maxLines), logFile) + if err != nil { + continue + } output, err := cmd.Output() if err != nil { if ctx.Err() != nil { @@ -874,7 +897,10 @@ func (e *EmailNotifier) tailMailLog(ctx context.Context, maxLines int) ([]string args = append(args, "-u", unit) } - cmd := exec.CommandContext(ctx, "journalctl", args...) + cmd, err := safeexec.CommandContext(ctx, "journalctl", args...) + if err != nil { + return nil, "" + } output, err := cmd.Output() if err == nil && len(output) > 0 { lines := strings.Split(strings.TrimRight(string(output), "\n"), "\n") @@ -1218,7 +1244,10 @@ func (e *EmailNotifier) sendViaPMF(ctx context.Context, recipient, subject, html e.logger.Debug("=== Sending email via proxmox-mail-forward ===") e.logger.Debug("proxmox-mail-forward routing is handled by Proxmox Notifications; To=%q is only a mail header", toHeader) - cmd := exec.CommandContext(ctx, pmfPath) + cmd, err := commandForMailTool(ctx, pmfPath) + if err != nil { + return "", "", err + } cmd.Stdin = strings.NewReader(emailMessage) var stdoutBuf, stderrBuf strings.Builder @@ -1329,7 +1358,10 @@ func (e *EmailNotifier) sendViaSendmail(ctx context.Context, recipient, subject, } // Create sendmail command - cmd := exec.CommandContext(ctx, sendmailPath, args...) + cmd, err := commandForMailTool(ctx, sendmailPath, args...) + if err != nil { + return "", "", "", err + } cmd.Stdin = strings.NewReader(emailMessage) // Capture stdout and stderr separately diff --git a/internal/orchestrator/backup_sources.go b/internal/orchestrator/backup_sources.go index 86b5f2b6..05225a8b 100644 --- a/internal/orchestrator/backup_sources.go +++ b/internal/orchestrator/backup_sources.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "os/exec" "path" "path/filepath" "sort" @@ -17,6 +16,7 @@ import ( "github.com/tis24dev/proxsave/internal/backup" "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" ) // decryptPathOption describes a logical backup source (local, secondary, cloud) @@ -117,7 +117,10 @@ func discoverRcloneBackups(ctx context.Context, cfg *config.Config, remotePath s // Use rclone lsf to list files inside the backup directory lsfCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - cmd := exec.CommandContext(lsfCtx, "rclone", "lsf", fullPath) + cmd, err := safeexec.CommandContext(lsfCtx, "rclone", "lsf", fullPath) + if err != nil { + return nil, err + } lsfStart := time.Now() output, err := cmd.CombinedOutput() if err != nil { @@ -545,7 +548,10 @@ func inspectRcloneChecksumFile(ctx context.Context, remotePath string, logger *l defer func() { done(err) }() logging.DebugStep(logger, "inspect rclone checksum", "executing: rclone cat %s", remotePath) - cmd := exec.CommandContext(ctx, "rclone", "cat", remotePath) + cmd, err := safeexec.CommandContext(ctx, "rclone", "cat", remotePath) + if err != nil { + return "", err + } stdout, err := cmd.StdoutPipe() if err != nil { return "", fmt.Errorf("start rclone cat %s: %w", remotePath, err) diff --git a/internal/orchestrator/decrypt.go b/internal/orchestrator/decrypt.go index 9ce590d5..f55cde81 100644 --- a/internal/orchestrator/decrypt.go +++ b/internal/orchestrator/decrypt.go @@ -10,7 +10,6 @@ import ( "fmt" "io" "os" - "os/exec" "path" "path/filepath" "strconv" @@ -22,6 +21,7 @@ import ( "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/input" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" "golang.org/x/text/cases" "golang.org/x/text/language" ) @@ -186,7 +186,10 @@ func inspectRcloneBundleManifest(ctx context.Context, remotePath string, logger defer cancel() logging.DebugStep(logger, "inspect rclone bundle manifest", "executing: rclone cat %s", remotePath) - cmd := exec.CommandContext(cmdCtx, "rclone", "cat", remotePath) + cmd, err := safeexec.CommandContext(cmdCtx, "rclone", "cat", remotePath) + if err != nil { + return nil, fmt.Errorf("prepare rclone cat: %w", err) + } stdout, err := cmd.StdoutPipe() if err != nil { return nil, fmt.Errorf("open rclone stream: %w", err) @@ -270,7 +273,10 @@ func inspectRcloneMetadataManifest(ctx context.Context, remoteMetadataPath, remo defer func() { done(err) }() logging.DebugStep(logger, "inspect rclone metadata manifest", "executing: rclone cat %s", remoteMetadataPath) - cmd := exec.CommandContext(ctx, "rclone", "cat", remoteMetadataPath) + cmd, err := safeexec.CommandContext(ctx, "rclone", "cat", remoteMetadataPath) + if err != nil { + return nil, fmt.Errorf("prepare rclone metadata cat: %w", err) + } output, err := cmd.CombinedOutput() if err != nil { return nil, fmt.Errorf("rclone cat %s failed: %w (output: %s)", remoteMetadataPath, err, strings.TrimSpace(string(output))) @@ -418,7 +424,10 @@ func downloadRcloneBackup(ctx context.Context, remotePath string, logger *loggin logging.DebugStep(logger, "download rclone backup", "local temp file=%s", tmpPath) // Use rclone copyto to download with progress - cmd := exec.CommandContext(ctx, "rclone", "copyto", remotePath, tmpPath, "--progress") + cmd, err := safeexec.CommandContext(ctx, "rclone", "copyto", remotePath, tmpPath, "--progress") + if err != nil { + return "", nil, err + } cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -573,7 +582,10 @@ func rcloneCopyTo(ctx context.Context, remotePath, localPath string, showProgres if showProgress { args = append(args, "--progress") } - cmd := exec.CommandContext(ctx, "rclone", args...) + cmd, err := safeexec.CommandContext(ctx, "rclone", args...) + if err != nil { + return err + } cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() diff --git a/internal/orchestrator/deps.go b/internal/orchestrator/deps.go index 6530c30b..3e7d1f56 100644 --- a/internal/orchestrator/deps.go +++ b/internal/orchestrator/deps.go @@ -11,6 +11,7 @@ import ( "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" ) // FS abstracts filesystem operations to simplify testing. @@ -123,7 +124,10 @@ type osCommandRunner struct{} const defaultCommandWaitDelay = 3 * time.Second func (osCommandRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { - cmd := exec.CommandContext(ctx, name, args...) + cmd, err := safeexec.CommandContext(ctx, name, args...) + if err != nil { + return nil, err + } cmd.WaitDelay = defaultCommandWaitDelay out, err := cmd.CombinedOutput() if err != nil && errors.Is(err, exec.ErrWaitDelay) { @@ -134,7 +138,10 @@ func (osCommandRunner) Run(ctx context.Context, name string, args ...string) ([] // RunStream returns a stdout pipe for streaming commands that read from stdin. func (osCommandRunner) RunStream(ctx context.Context, name string, stdin io.Reader, args ...string) (io.ReadCloser, error) { - cmd := exec.CommandContext(ctx, name, args...) + cmd, err := safeexec.CommandContext(ctx, name, args...) + if err != nil { + return nil, err + } cmd.Stdin = stdin stdout, err := cmd.StdoutPipe() if err != nil { diff --git a/internal/orchestrator/network_apply.go b/internal/orchestrator/network_apply.go index faa76b18..ca5a6f7d 100644 --- a/internal/orchestrator/network_apply.go +++ b/internal/orchestrator/network_apply.go @@ -295,8 +295,8 @@ func armNetworkRollback(ctx context.Context, logger *logging.Logger, backupPath if handle.unitName == "" { logging.DebugStep(logger, "arm network rollback", "Arm timer via background sleep (%ds)", timeoutSeconds) - cmd := fmt.Sprintf("nohup sh -c 'sleep %d; /bin/sh %s' >/dev/null 2>&1 &", timeoutSeconds, handle.scriptPath) - if output, err := restoreCmd.Run(ctx, "sh", "-c", cmd); err != nil { + output, err := runBackgroundRollbackTimer(ctx, timeoutSeconds, handle.scriptPath) + if err != nil { logger.Debug("Background rollback output: %s", strings.TrimSpace(string(output))) return nil, fmt.Errorf("failed to arm rollback timer: %w", err) } @@ -794,6 +794,15 @@ func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" } +const backgroundRollbackCommand = `nohup sh -c 'sleep "$1"; /bin/sh "$2"' proxsave-rollback-worker "$1" "$2" >/dev/null 2>&1 &` + +func runBackgroundRollbackTimer(ctx context.Context, timeoutSeconds int, scriptPath string) ([]byte, error) { + if timeoutSeconds < 1 { + timeoutSeconds = 1 + } + return restoreCmd.Run(ctx, "sh", "-c", backgroundRollbackCommand, "proxsave-rollback", fmt.Sprintf("%d", timeoutSeconds), scriptPath) +} + func commandAvailable(name string) bool { _, err := exec.LookPath(name) return err == nil diff --git a/internal/orchestrator/restore.go b/internal/orchestrator/restore.go index 03b79316..a7b582d3 100644 --- a/internal/orchestrator/restore.go +++ b/internal/orchestrator/restore.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "os" - "os/exec" "path" "path/filepath" "sort" @@ -21,6 +20,7 @@ import ( "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/input" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" ) var ErrRestoreAborted = errors.New("restore workflow aborted by user") @@ -1421,7 +1421,7 @@ func createLzmaReader(ctx context.Context, file *os.File) (io.Reader, error) { } // runRestoreCommandStream starts a command that reads from stdin and exposes stdout as a ReadCloser. -// It prefers an injectable streaming runner when available; otherwise falls back to exec.CommandContext. +// It prefers an injectable streaming runner when available; otherwise falls back to safeexec. func runRestoreCommandStream(ctx context.Context, name string, stdin io.Reader, args ...string) (io.Reader, error) { type streamingRunner interface { RunStream(ctx context.Context, name string, stdin io.Reader, args ...string) (io.ReadCloser, error) @@ -1430,7 +1430,10 @@ func runRestoreCommandStream(ctx context.Context, name string, stdin io.Reader, return sr.RunStream(ctx, name, stdin, args...) } - cmd := exec.CommandContext(ctx, name, args...) + cmd, err := safeexec.CommandContext(ctx, name, args...) + if err != nil { + return nil, err + } cmd.Stdin = stdin stdout, err := cmd.StdoutPipe() if err != nil { diff --git a/internal/orchestrator/restore_access_control_ui.go b/internal/orchestrator/restore_access_control_ui.go index db96a8c6..82bf9d2f 100644 --- a/internal/orchestrator/restore_access_control_ui.go +++ b/internal/orchestrator/restore_access_control_ui.go @@ -402,8 +402,8 @@ func armAccessControlRollback(ctx context.Context, logger *logging.Logger, backu } if handle.unitName == "" { - cmd := fmt.Sprintf("nohup sh -c 'sleep %d; /bin/sh %s' >/dev/null 2>&1 &", timeoutSeconds, handle.scriptPath) - if output, err := restoreCmd.Run(ctx, "sh", "-c", cmd); err != nil { + output, err := runBackgroundRollbackTimer(ctx, timeoutSeconds, handle.scriptPath) + if err != nil { logger.Debug("Background rollback output: %s", strings.TrimSpace(string(output))) return nil, fmt.Errorf("failed to schedule rollback timer: %w", err) } diff --git a/internal/orchestrator/restore_firewall.go b/internal/orchestrator/restore_firewall.go index 50e27a6a..0c2c6899 100644 --- a/internal/orchestrator/restore_firewall.go +++ b/internal/orchestrator/restore_firewall.go @@ -476,8 +476,8 @@ func armFirewallRollback(ctx context.Context, logger *logging.Logger, backupPath } if handle.unitName == "" { - cmd := fmt.Sprintf("nohup sh -c 'sleep %d; /bin/sh %s' >/dev/null 2>&1 &", timeoutSeconds, handle.scriptPath) - if output, err := restoreCmd.Run(ctx, "sh", "-c", cmd); err != nil { + output, err := runBackgroundRollbackTimer(ctx, timeoutSeconds, handle.scriptPath) + if err != nil { logger.Debug("Background rollback output: %s", strings.TrimSpace(string(output))) return nil, fmt.Errorf("failed to arm rollback timer: %w", err) } diff --git a/internal/orchestrator/restore_ha.go b/internal/orchestrator/restore_ha.go index 0111a16d..6765e7c9 100644 --- a/internal/orchestrator/restore_ha.go +++ b/internal/orchestrator/restore_ha.go @@ -404,8 +404,8 @@ func armHARollback(ctx context.Context, logger *logging.Logger, backupPath strin } if handle.unitName == "" { - cmd := fmt.Sprintf("nohup sh -c 'sleep %d; /bin/sh %s' >/dev/null 2>&1 &", timeoutSeconds, handle.scriptPath) - if output, err := restoreCmd.Run(ctx, "sh", "-c", cmd); err != nil { + output, err := runBackgroundRollbackTimer(ctx, timeoutSeconds, handle.scriptPath) + if err != nil { logger.Debug("Background rollback output: %s", strings.TrimSpace(string(output))) return nil, fmt.Errorf("failed to schedule rollback timer: %w", err) } diff --git a/internal/pbs/namespaces.go b/internal/pbs/namespaces.go index d168bef4..973074f8 100644 --- a/internal/pbs/namespaces.go +++ b/internal/pbs/namespaces.go @@ -6,14 +6,14 @@ import ( "encoding/json" "errors" "fmt" - "os/exec" "path/filepath" "time" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/internal/safefs" ) -var execCommand = exec.CommandContext +var execCommand = safeexec.CommandContext // Namespace represents a single PBS namespace. type Namespace struct { @@ -57,7 +57,7 @@ func listNamespacesViaCLI(ctx context.Context, datastore string) ([]Namespace, e return nil, err } - cmd := execCommand( + cmd, cmdErr := execCommand( ctx, "proxmox-backup-manager", "datastore", @@ -66,6 +66,9 @@ func listNamespacesViaCLI(ctx context.Context, datastore string) ([]Namespace, e datastore, "--output-format=json", ) + if cmdErr != nil { + return nil, cmdErr + } var stdout, stderr bytes.Buffer cmd.Stdout = &stdout diff --git a/internal/pbs/namespaces_test.go b/internal/pbs/namespaces_test.go index f151caeb..2ff65c1a 100644 --- a/internal/pbs/namespaces_test.go +++ b/internal/pbs/namespaces_test.go @@ -213,13 +213,13 @@ func TestHelperProcess(t *testing.T) { func setExecCommandStub(t *testing.T, scenario string) { t.Helper() original := execCommand - execCommand = func(context.Context, string, ...string) *exec.Cmd { + execCommand = func(context.Context, string, ...string) (*exec.Cmd, error) { cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcess", "--") cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1", "PBS_HELPER_SCENARIO="+scenario, ) - return cmd + return cmd, nil } t.Cleanup(func() { execCommand = original diff --git a/internal/safeexec/safeexec.go b/internal/safeexec/safeexec.go new file mode 100644 index 00000000..3be319ad --- /dev/null +++ b/internal/safeexec/safeexec.go @@ -0,0 +1,277 @@ +package safeexec + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + "unicode" +) + +var ErrCommandNotAllowed = errors.New("command not allowed") + +// CommandContext creates commands only for binaries that are intentionally +// allowed by the application. Keep exec.CommandContext calls in the switch so +// static analyzers can see literal command names. +func CommandContext(ctx context.Context, name string, args ...string) (*exec.Cmd, error) { + if strings.TrimSpace(name) != name || name == "" || strings.ContainsAny(name, `/\`) { + return nil, fmt.Errorf("%w: %q", ErrCommandNotAllowed, name) + } + + switch name { + case "apt-cache": + return exec.CommandContext(ctx, "apt-cache", args...), nil + case "blkid": + return exec.CommandContext(ctx, "blkid", args...), nil + case "bridge": + return exec.CommandContext(ctx, "bridge", args...), nil + case "bzip2": + return exec.CommandContext(ctx, "bzip2", args...), nil + case "cat": + return exec.CommandContext(ctx, "cat", args...), nil + case "ceph": + return exec.CommandContext(ctx, "ceph", args...), nil + case "chattr": + return exec.CommandContext(ctx, "chattr", args...), nil + case "crontab": + return exec.CommandContext(ctx, "crontab", args...), nil + case "df": + return exec.CommandContext(ctx, "df", args...), nil + case "dmidecode": + return exec.CommandContext(ctx, "dmidecode", args...), nil + case "dpkg": + return exec.CommandContext(ctx, "dpkg", args...), nil + case "dpkg-query": + return exec.CommandContext(ctx, "dpkg-query", args...), nil + case "echo": + return exec.CommandContext(ctx, "echo", args...), nil + case "ethtool": + return exec.CommandContext(ctx, "ethtool", args...), nil + case "firewall-cmd": + return exec.CommandContext(ctx, "firewall-cmd", args...), nil + case "free": + return exec.CommandContext(ctx, "free", args...), nil + case "hostname": + return exec.CommandContext(ctx, "hostname", args...), nil + case "ifreload": + return exec.CommandContext(ctx, "ifreload", args...), nil + case "ifup": + return exec.CommandContext(ctx, "ifup", args...), nil + case "ip": + return exec.CommandContext(ctx, "ip", args...), nil + case "iptables": + return exec.CommandContext(ctx, "iptables", args...), nil + case "iptables-save": + return exec.CommandContext(ctx, "iptables-save", args...), nil + case "ip6tables": + return exec.CommandContext(ctx, "ip6tables", args...), nil + case "ip6tables-save": + return exec.CommandContext(ctx, "ip6tables-save", args...), nil + case "journalctl": + return exec.CommandContext(ctx, "journalctl", args...), nil + case "lsblk": + return exec.CommandContext(ctx, "lsblk", args...), nil + case "lspci": + return exec.CommandContext(ctx, "lspci", args...), nil + case "lscpu": + return exec.CommandContext(ctx, "lscpu", args...), nil + case "lsmod": + return exec.CommandContext(ctx, "lsmod", args...), nil + case "lsusb": + return exec.CommandContext(ctx, "lsusb", args...), nil + case "lvs": + return exec.CommandContext(ctx, "lvs", args...), nil + case "lzma": + return exec.CommandContext(ctx, "lzma", args...), nil + case "mailq": + return exec.CommandContext(ctx, "mailq", args...), nil + case "mount": + return exec.CommandContext(ctx, "mount", args...), nil + case "mountpoint": + return exec.CommandContext(ctx, "mountpoint", args...), nil + case "nft": + return exec.CommandContext(ctx, "nft", args...), nil + case "pbzip2": + return exec.CommandContext(ctx, "pbzip2", args...), nil + case "pgrep": + return exec.CommandContext(ctx, "pgrep", args...), nil + case "pigz": + return exec.CommandContext(ctx, "pigz", args...), nil + case "ping": + return exec.CommandContext(ctx, "ping", args...), nil + case "pvs": + return exec.CommandContext(ctx, "pvs", args...), nil + case "proxmox-backup-client": + return exec.CommandContext(ctx, "proxmox-backup-client", args...), nil + case "proxmox-backup-manager": + return exec.CommandContext(ctx, "proxmox-backup-manager", args...), nil + case "proxmox-mail-forward": + return exec.CommandContext(ctx, "proxmox-mail-forward", args...), nil + case "proxmox-tape": + return exec.CommandContext(ctx, "proxmox-tape", args...), nil + case "ps": + return exec.CommandContext(ctx, "ps", args...), nil + case "pvecm": + return exec.CommandContext(ctx, "pvecm", args...), nil + case "pve-firewall": + return exec.CommandContext(ctx, "pve-firewall", args...), nil + case "pvenode": + return exec.CommandContext(ctx, "pvenode", args...), nil + case "pvesh": + return exec.CommandContext(ctx, "pvesh", args...), nil + case "pvesm": + return exec.CommandContext(ctx, "pvesm", args...), nil + case "pveum": + return exec.CommandContext(ctx, "pveum", args...), nil + case "pveversion": + return exec.CommandContext(ctx, "pveversion", args...), nil + case "rclone": + return exec.CommandContext(ctx, "rclone", args...), nil + case "sendmail": + return exec.CommandContext(ctx, "sendmail", args...), nil + case "sensors": + return exec.CommandContext(ctx, "sensors", args...), nil + case "sh": + return exec.CommandContext(ctx, "sh", args...), nil + case "smartctl": + return exec.CommandContext(ctx, "smartctl", args...), nil + case "ss": + return exec.CommandContext(ctx, "ss", args...), nil + case "systemctl": + return exec.CommandContext(ctx, "systemctl", args...), nil + case "systemd-run": + return exec.CommandContext(ctx, "systemd-run", args...), nil + case "tail": + return exec.CommandContext(ctx, "tail", args...), nil + case "tar": + return exec.CommandContext(ctx, "tar", args...), nil + case "udevadm": + return exec.CommandContext(ctx, "udevadm", args...), nil + case "umount": + return exec.CommandContext(ctx, "umount", args...), nil + case "uname": + return exec.CommandContext(ctx, "uname", args...), nil + case "ufw": + return exec.CommandContext(ctx, "ufw", args...), nil + case "vgs": + return exec.CommandContext(ctx, "vgs", args...), nil + case "which": + return exec.CommandContext(ctx, "which", args...), nil + case "xz": + return exec.CommandContext(ctx, "xz", args...), nil + case "zfs": + return exec.CommandContext(ctx, "zfs", args...), nil + case "zpool": + return exec.CommandContext(ctx, "zpool", args...), nil + case "zstd": + return exec.CommandContext(ctx, "zstd", args...), nil + default: + return nil, fmt.Errorf("%w: %q", ErrCommandNotAllowed, name) + } +} + +func CombinedOutput(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd, err := CommandContext(ctx, name, args...) + if err != nil { + return nil, err + } + return cmd.CombinedOutput() +} + +func Output(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd, err := CommandContext(ctx, name, args...) + if err != nil { + return nil, err + } + return cmd.Output() +} + +func TrustedCommandContext(ctx context.Context, execPath string, args ...string) (*exec.Cmd, error) { + if err := ValidateTrustedExecutablePath(execPath); err != nil { + return nil, err + } + // #nosec G204 -- execPath is absolute, regular, executable, and not world-writable. + return exec.CommandContext(ctx, execPath, args...), nil +} + +func ValidateTrustedExecutablePath(execPath string) error { + clean := strings.TrimSpace(execPath) + if clean == "" { + return fmt.Errorf("executable path is empty") + } + if !filepath.IsAbs(clean) { + return fmt.Errorf("executable path must be absolute: %s", execPath) + } + info, err := os.Stat(clean) + if err != nil { + return fmt.Errorf("stat executable path: %w", err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("executable path is not a regular file: %s", clean) + } + if info.Mode().Perm()&0o111 == 0 { + return fmt.Errorf("executable path is not executable: %s", clean) + } + if info.Mode().Perm()&0o002 != 0 { + return fmt.Errorf("executable path is world-writable: %s", clean) + } + return nil +} + +func ValidateRcloneRemoteName(remote string) error { + if remote == "" { + return fmt.Errorf("rclone remote name is empty") + } + if strings.HasPrefix(remote, "-") { + return fmt.Errorf("rclone remote name must not start with '-'") + } + if strings.ContainsAny(remote, `/\:`) { + return fmt.Errorf("rclone remote name contains a path separator or colon") + } + for _, r := range remote { + if unicode.IsSpace(r) || unicode.IsControl(r) { + return fmt.Errorf("rclone remote name contains whitespace or control characters") + } + } + return nil +} + +func ValidateRemoteRelativePath(value, field string) error { + clean := strings.TrimSpace(value) + if clean == "" { + return nil + } + for _, r := range clean { + if unicode.IsControl(r) { + return fmt.Errorf("%s contains control characters", field) + } + } + normalized := path.Clean(strings.Trim(clean, "/")) + if normalized == "." { + return nil + } + if strings.HasPrefix(normalized, "../") || normalized == ".." { + return fmt.Errorf("%s must not traverse outside the configured remote", field) + } + return nil +} + +func ProcPath(pid int, leaf string) (string, error) { + if pid <= 0 { + return "", fmt.Errorf("pid must be positive") + } + switch leaf { + case "comm": + return fmt.Sprintf("/proc/%d/comm", pid), nil + case "status": + return fmt.Sprintf("/proc/%d/status", pid), nil + case "exe": + return fmt.Sprintf("/proc/%d/exe", pid), nil + default: + return "", fmt.Errorf("unsupported proc leaf: %s", leaf) + } +} diff --git a/internal/safeexec/safeexec_test.go b/internal/safeexec/safeexec_test.go new file mode 100644 index 00000000..84586108 --- /dev/null +++ b/internal/safeexec/safeexec_test.go @@ -0,0 +1,79 @@ +package safeexec + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestCommandContextAllowlist(t *testing.T) { + if _, err := CommandContext(context.Background(), "rclone", "lsf", "remote:"); err != nil { + t.Fatalf("CommandContext allowed command error: %v", err) + } + if _, err := CommandContext(context.Background(), "not-a-proxsave-command"); !errors.Is(err, ErrCommandNotAllowed) { + t.Fatalf("CommandContext unknown command error = %v, want ErrCommandNotAllowed", err) + } + if _, err := CommandContext(context.Background(), "/bin/sh"); !errors.Is(err, ErrCommandNotAllowed) { + t.Fatalf("CommandContext path command error = %v, want ErrCommandNotAllowed", err) + } +} + +func TestValidateTrustedExecutablePath(t *testing.T) { + dir := t.TempDir() + execPath := filepath.Join(dir, "proxsave") + if err := os.WriteFile(execPath, []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil { + t.Fatal(err) + } + if err := ValidateTrustedExecutablePath(execPath); err != nil { + t.Fatalf("ValidateTrustedExecutablePath valid error: %v", err) + } + + if err := ValidateTrustedExecutablePath("relative"); err == nil { + t.Fatalf("expected relative path to be rejected") + } + + worldWritable := filepath.Join(dir, "ww") + if err := os.WriteFile(worldWritable, []byte("#!/bin/sh\nexit 0\n"), 0o777); err != nil { + t.Fatal(err) + } + if err := os.Chmod(worldWritable, 0o777); err != nil { + t.Fatal(err) + } + if err := ValidateTrustedExecutablePath(worldWritable); err == nil { + t.Fatalf("expected world-writable executable to be rejected") + } +} + +func TestValidateRcloneRemoteName(t *testing.T) { + valid := []string{"remote", "s3backup_01", "gdrive-prod"} + for _, name := range valid { + if err := ValidateRcloneRemoteName(name); err != nil { + t.Fatalf("ValidateRcloneRemoteName(%q) error: %v", name, err) + } + } + + invalid := []string{"", "-remote", "bad remote", "bad/remote", "bad:remote", "bad\nremote"} + for _, name := range invalid { + if err := ValidateRcloneRemoteName(name); err == nil { + t.Fatalf("ValidateRcloneRemoteName(%q) expected error", name) + } + } +} + +func TestProcPath(t *testing.T) { + got, err := ProcPath(123, "status") + if err != nil { + t.Fatalf("ProcPath valid error: %v", err) + } + if got != "/proc/123/status" { + t.Fatalf("ProcPath = %q", got) + } + if _, err := ProcPath(0, "status"); err == nil { + t.Fatalf("expected pid 0 to be rejected") + } + if _, err := ProcPath(123, "../status"); err == nil { + t.Fatalf("expected unsupported leaf to be rejected") + } +} diff --git a/internal/security/procscan.go b/internal/security/procscan.go index 0ea3ab98..b8755fa4 100644 --- a/internal/security/procscan.go +++ b/internal/security/procscan.go @@ -6,6 +6,8 @@ import ( "path/filepath" "regexp" "strings" + + "github.com/tis24dev/proxsave/internal/safeexec" ) // Heuristic detection for safe kernel-style processes. @@ -28,25 +30,28 @@ type procInfo struct { func readProcInfo(pid int) procInfo { info := procInfo{} - commPath := fmt.Sprintf("/proc/%d/comm", pid) - if data, err := os.ReadFile(commPath); err == nil { - info.comm = strings.TrimSpace(string(data)) + if commPath, err := safeexec.ProcPath(pid, "comm"); err == nil { + if data, err := os.ReadFile(commPath); err == nil { + info.comm = strings.TrimSpace(string(data)) + } } - statusPath := fmt.Sprintf("/proc/%d/status", pid) - if data, err := os.ReadFile(statusPath); err == nil { - lines := strings.Split(string(data), "\n") - for _, line := range lines { - if strings.HasPrefix(line, "PPid:") { - _, _ = fmt.Sscanf(line, "PPid:\t%d", &info.ppid) - break + if statusPath, err := safeexec.ProcPath(pid, "status"); err == nil { + if data, err := os.ReadFile(statusPath); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "PPid:") { + _, _ = fmt.Sscanf(line, "PPid:\t%d", &info.ppid) + break + } } } } - exePath := fmt.Sprintf("/proc/%d/exe", pid) - if target, err := filepath.EvalSymlinks(exePath); err == nil { - info.exe = target + if exePath, err := safeexec.ProcPath(pid, "exe"); err == nil { + if target, err := filepath.EvalSymlinks(exePath); err == nil { + info.exe = target + } } return info diff --git a/internal/security/security.go b/internal/security/security.go index 152c1802..5104a469 100644 --- a/internal/security/security.go +++ b/internal/security/security.go @@ -21,6 +21,7 @@ import ( "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/environment" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/internal/types" ) @@ -634,7 +635,11 @@ func (c *Checker) checkFirewall(ctx context.Context) { return } - cmd := exec.CommandContext(ctx, "iptables", "-L", "-n") + cmd, err := safeexec.CommandContext(ctx, "iptables", "-L", "-n") + if err != nil { + c.addWarning("Failed to prepare iptables command: %v", err) + return + } output, err := cmd.Output() if err != nil { c.addWarning("Failed to run iptables -L -n: %v", err) @@ -664,7 +669,11 @@ func (c *Checker) checkOpenPorts(ctx context.Context) { return } - cmd := exec.CommandContext(ctx, "ss", "-tulnap") + cmd, err := safeexec.CommandContext(ctx, "ss", "-tulnap") + if err != nil { + c.addWarning("Failed to prepare 'ss -tulnap': %v", err) + return + } output, err := cmd.Output() if err != nil { c.addWarning("Failed to execute 'ss -tulnap': %v", err) @@ -700,7 +709,10 @@ func (c *Checker) checkOpenPortsAgainstSuspiciousList(ctx context.Context) { if _, err := exec.LookPath("ss"); err != nil { return } - cmd := exec.CommandContext(ctx, "ss", "-tuln") + cmd, err := safeexec.CommandContext(ctx, "ss", "-tuln") + if err != nil { + return + } output, err := cmd.Output() if err != nil { return @@ -721,7 +733,11 @@ func (c *Checker) checkOpenPortsAgainstSuspiciousList(ctx context.Context) { } func (c *Checker) checkSuspiciousProcesses(ctx context.Context) { - cmd := exec.CommandContext(ctx, "ps", "-eo", "user=,state=,vsz=,pid=,command=") + cmd, err := safeexec.CommandContext(ctx, "ps", "-eo", "user=,state=,vsz=,pid=,command=") + if err != nil { + c.addWarning("Failed to prepare 'ps' for process inspection: %v", err) + return + } output, err := cmd.Output() if err != nil { c.addWarning("Failed to execute 'ps' for process inspection: %v", err) diff --git a/internal/storage/cloud.go b/internal/storage/cloud.go index 8d306ad1..b367ff50 100644 --- a/internal/storage/cloud.go +++ b/internal/storage/cloud.go @@ -15,6 +15,7 @@ import ( "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safeexec" "github.com/tis24dev/proxsave/internal/types" "github.com/tis24dev/proxsave/pkg/utils" ) @@ -89,6 +90,28 @@ func (c *CloudStorage) buildRcloneArgs(subcommand string) []string { return args } +func validateRcloneArgs(args []string) error { + if len(args) == 0 { + return fmt.Errorf("missing rclone subcommand") + } + switch args[0] { + case "copyto", "delete", "deletefile", "ls", "lsf", "lsl", "mkdir", "touch": + default: + return fmt.Errorf("rclone subcommand not allowed: %s", args[0]) + } + for _, arg := range args { + if strings.TrimSpace(arg) == "" { + return fmt.Errorf("rclone argument must not be empty") + } + for _, r := range arg { + if r < 0x20 || r == 0x7f { + return fmt.Errorf("rclone argument contains control characters") + } + } + } + return nil +} + func splitRemoteRef(ref string) (remoteName, relPath string) { parts := strings.SplitN(ref, ":", 2) if len(parts) < 2 { @@ -163,9 +186,19 @@ func NewCloudStorage(cfg *config.Config, logger *logging.Logger) (*CloudStorage, // (base path from CLOUD_REMOTE plus optional CLOUD_REMOTE_PATH) rawRemote := strings.TrimSpace(cfg.CloudRemote) remoteName, basePath := splitRemoteRef(rawRemote) + remoteName = strings.TrimSpace(remoteName) + if err := safeexec.ValidateRcloneRemoteName(remoteName); err != nil { + return nil, fmt.Errorf("invalid CLOUD_REMOTE: %w", err) + } basePath = strings.Trim(strings.TrimSpace(basePath), "/") + if err := safeexec.ValidateRemoteRelativePath(basePath, "CLOUD_REMOTE path"); err != nil { + return nil, err + } userPrefix := strings.Trim(strings.TrimSpace(cfg.CloudRemotePath), "/") + if err := safeexec.ValidateRemoteRelativePath(userPrefix, "CLOUD_REMOTE_PATH"); err != nil { + return nil, err + } combinedPrefix := strings.Trim(path.Join(basePath, userPrefix), "/") @@ -1759,6 +1792,12 @@ func (c *CloudStorage) markCloudLogPathAvailable() { } func (c *CloudStorage) exec(ctx context.Context, name string, args ...string) ([]byte, error) { + if name != "rclone" { + return nil, fmt.Errorf("cloud storage may only execute rclone, got %q", name) + } + if err := validateRcloneArgs(args); err != nil { + return nil, err + } if c.execCommand != nil { return c.execCommand(ctx, name, args...) } @@ -1773,7 +1812,10 @@ func (c *CloudStorage) callWaitForRetry(ctx context.Context, d time.Duration) er } func defaultExecCommand(ctx context.Context, name string, args ...string) ([]byte, error) { - cmd := exec.CommandContext(ctx, name, args...) + cmd, err := safeexec.CommandContext(ctx, name, args...) + if err != nil { + return nil, err + } return cmd.CombinedOutput() } diff --git a/internal/tui/wizard/post_install_audit_core.go b/internal/tui/wizard/post_install_audit_core.go index 5d9a76a0..9ba0ee41 100644 --- a/internal/tui/wizard/post_install_audit_core.go +++ b/internal/tui/wizard/post_install_audit_core.go @@ -13,6 +13,7 @@ import ( "time" "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/safeexec" ) // PostInstallAuditSuggestion represents an optional feature that appears to be enabled @@ -51,11 +52,14 @@ func postInstallAuditAllowedKeysSet() map[string]struct{} { func runPostInstallAuditDryRun(ctx context.Context, execPath, configPath string) (output string, exitCode int, err error) { // Run a dry-run with warning-level logs to keep output minimal while still capturing // all actionable "set KEY=false" hints. - cmd := exec.CommandContext(ctx, execPath, + cmd, err := safeexec.TrustedCommandContext(ctx, execPath, "--dry-run", "--log-level", "warning", "--config", configPath, ) + if err != nil { + return "", -1, err + } out, runErr := cmd.CombinedOutput() if runErr == nil { return string(out), 0, nil From 04745a436c5a5ce47bd9f6213e13b13a1d1e3b8f Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:25:52 +0200 Subject: [PATCH 08/35] Refactor rollback tests, add safeexec cases Introduce backgroundRollbackCallKey helper and update numerous rollback tests to use it; add TestRunBackgroundRollbackTimer_UsesPositionalArgsForScriptPath. Add ValidateRemoteRelativePath unit test. Extend internal/safeexec to recognize "false" and "sysctl" commands and add a nosemgrep comment for TrustedCommandContext. Remove unused os/exec import in cmd/proxsave/upgrade.go. Add .github/instructions/codacy.instructions.md with Codacy MCP Server rules and guidelines. --- .github/instructions/codacy.instructions.md | 72 +++++++++++++++++++ cmd/proxsave/upgrade.go | 1 - internal/orchestrator/deps_test.go | 10 +++ .../network_apply_additional_test.go | 33 +++++++-- ...store_access_control_ui_additional_test.go | 8 +-- .../restore_firewall_additional_test.go | 8 +-- .../restore_ha_additional_test.go | 8 +-- internal/safeexec/safeexec.go | 5 ++ internal/safeexec/safeexec_test.go | 16 +++++ 9 files changed, 142 insertions(+), 19 deletions(-) create mode 100644 .github/instructions/codacy.instructions.md diff --git a/.github/instructions/codacy.instructions.md b/.github/instructions/codacy.instructions.md new file mode 100644 index 00000000..cb073c46 --- /dev/null +++ b/.github/instructions/codacy.instructions.md @@ -0,0 +1,72 @@ +--- + description: Configuration for AI behavior when interacting with Codacy's MCP Server + applyTo: '**' +--- +--- +# Codacy Rules +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 +- 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. + +## When you tried to run the `codacy_cli_analyze` tool and the Codacy CLI is not installed +- Ask the user 'Codacy CLI is not installed. Would you like me to install it now?' +- If the user responds with "yes", run the `codacy_cli_install` tool and then continue with the original task +- If the user responds with "no", instruct the user that they can disable automatic analysis in the extension settings +- Wait for the user to respond before proceeding with any other actions + +## After every response +- If you made any file edits in this conversation, verify you ran `codacy_cli_analyze` tool from 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) +- 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 +- Always use the standard, non-URL-encoded file system path + +## 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 +- You MUST run the `codacy_cli_analyze` tool with: + - `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 +- EXAMPLE: + - 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. +- "Propose fixes" means to both suggest and, if possible, automatically apply the fixes. +- You MUST NOT wait for the user to ask for analysis or remind you to run the tool. +- Do not run `codacy_cli_analyze` looking for changes in duplicated code or code complexity metrics. +- Complexity metrics are different from complexity issues. When trying to fix complexity in a repository or file, focus on solving the complexity issues and ignore the complexity metric. +- Do not run `codacy_cli_analyze` looking for changes in code coverage. +- Do not try to manually install Codacy CLI using either brew, npm, npx, or any other package manager. +- If the Codacy CLI is not installed, just run the `codacy_cli_analyze` tool from Codacy's MCP Server. +- When calling `codacy_cli_analyze`, only send provider, organization and repository if the project is a git repository. + +## Whenever a call to a Codacy tool that uses `repository` or `organization` as a parameter returns a 404 error +- Offer to run the `codacy_setup_repository` tool to add the repository to Codacy +- If the user accepts, run the `codacy_setup_repository` tool +- Do not ever try to run the `codacy_setup_repository` tool on your own +- After setup, immediately retry the action that failed (only retry once) +--- \ No newline at end of file diff --git a/cmd/proxsave/upgrade.go b/cmd/proxsave/upgrade.go index e462a8fe..b1bab7f6 100644 --- a/cmd/proxsave/upgrade.go +++ b/cmd/proxsave/upgrade.go @@ -14,7 +14,6 @@ import ( "io" "net/http" "os" - "os/exec" "path/filepath" "runtime" "strconv" diff --git a/internal/orchestrator/deps_test.go b/internal/orchestrator/deps_test.go index b2fbfb3e..076220fe 100644 --- a/internal/orchestrator/deps_test.go +++ b/internal/orchestrator/deps_test.go @@ -237,6 +237,16 @@ func commandKey(name string, args []string) string { return fmt.Sprintf("%s %s", name, strings.Join(args, " ")) } +func backgroundRollbackCallKey(timeoutSeconds int, scriptPath string) string { + return commandKey("sh", []string{ + "-c", + backgroundRollbackCommand, + "proxsave-rollback", + fmt.Sprintf("%d", timeoutSeconds), + scriptPath, + }) +} + // FakePrompter simulates user choices. type FakePrompter struct { Mode RestoreMode diff --git a/internal/orchestrator/network_apply_additional_test.go b/internal/orchestrator/network_apply_additional_test.go index b3e27803..2d939b5f 100644 --- a/internal/orchestrator/network_apply_additional_test.go +++ b/internal/orchestrator/network_apply_additional_test.go @@ -606,11 +606,12 @@ func TestArmNetworkRollback_SystemdRunFailureFallsBackToNohup(t *testing.T) { foundSystemdRun := false foundFallback := false + wantFallback := backgroundRollbackCallKey(30, handle.scriptPath) for _, call := range fakeCmd.CallsList() { if strings.HasPrefix(call, "systemd-run ") { foundSystemdRun = true } - if strings.HasPrefix(call, "sh -c nohup sh -c 'sleep ") { + if call == wantFallback { foundFallback = true } } @@ -653,8 +654,9 @@ func TestArmNetworkRollback_WithoutSystemdRunUsesNohup(t *testing.T) { } foundFallback := false + wantFallback := backgroundRollbackCallKey(1, handle.scriptPath) for _, call := range fakeCmd.CallsList() { - if strings.HasPrefix(call, "sh -c nohup sh -c 'sleep ") { + if call == wantFallback { foundFallback = true } } @@ -693,8 +695,9 @@ func TestArmNetworkRollback_SubSecondTimeoutArmsAtLeastOneSecond(t *testing.T) { } foundSleep1 := false + wantFallback := backgroundRollbackCallKey(1, handle.scriptPath) for _, call := range fakeCmd.CallsList() { - if strings.Contains(call, "sleep 1;") { + if call == wantFallback { foundSleep1 = true } } @@ -723,7 +726,7 @@ func TestArmNetworkRollback_FallbackCommandFailureReturnsError(t *testing.T) { restoreCmd = &FakeCommandRunner{ Errors: map[string]error{ - "sh -c nohup sh -c 'sleep 1; /bin/sh /tmp/proxsave/network_rollback_20260201_123456.sh' >/dev/null 2>&1 &": errors.New("boom"), + backgroundRollbackCallKey(1, "/tmp/proxsave/network_rollback_20260201_123456.sh"): errors.New("boom"), }, } @@ -733,6 +736,28 @@ func TestArmNetworkRollback_FallbackCommandFailureReturnsError(t *testing.T) { } } +func TestRunBackgroundRollbackTimer_UsesPositionalArgsForScriptPath(t *testing.T) { + origCmd := restoreCmd + t.Cleanup(func() { restoreCmd = origCmd }) + + fakeCmd := &FakeCommandRunner{} + restoreCmd = fakeCmd + + scriptPath := "/tmp/proxsave dir/rollback's ; touch /tmp/proxsave-injected.sh" + if _, err := runBackgroundRollbackTimer(context.Background(), 2, scriptPath); err != nil { + t.Fatalf("runBackgroundRollbackTimer error: %v", err) + } + + want := backgroundRollbackCallKey(2, scriptPath) + calls := fakeCmd.CallsList() + if len(calls) != 1 || calls[0] != want { + t.Fatalf("unexpected calls: %#v", calls) + } + if strings.Contains(backgroundRollbackCommand, scriptPath) { + t.Fatalf("rollback script path must not be interpolated into shell command") + } +} + func TestDisarmNetworkRollback_RemovesMarkerAndStopsTimer(t *testing.T) { origFS := restoreFS origCmd := restoreCmd diff --git a/internal/orchestrator/restore_access_control_ui_additional_test.go b/internal/orchestrator/restore_access_control_ui_additional_test.go index e8427514..8252d1f0 100644 --- a/internal/orchestrator/restore_access_control_ui_additional_test.go +++ b/internal/orchestrator/restore_access_control_ui_additional_test.go @@ -224,8 +224,7 @@ func TestArmAccessControlRollback_SystemdAndBackgroundPaths(t *testing.T) { t.Fatalf("expected unitName cleared after systemd-run failure, got %q", handle.unitName) } - cmd := fmt.Sprintf("nohup sh -c 'sleep %d; /bin/sh %s' >/dev/null 2>&1 &", 2, scriptPath) - wantBackground := "sh -c " + cmd + wantBackground := backgroundRollbackCallKey(2, scriptPath) calls := fakeCmd.CallsList() if len(calls) != 2 || calls[1] != wantBackground { t.Fatalf("unexpected calls: %#v", calls) @@ -243,8 +242,7 @@ func TestArmAccessControlRollback_SystemdAndBackgroundPaths(t *testing.T) { timestamp := fakeTime.Current.Format("20060102_150405") scriptPath := filepath.Join("/tmp/proxsave", fmt.Sprintf("access_control_rollback_%s.sh", timestamp)) - cmd := fmt.Sprintf("nohup sh -c 'sleep %d; /bin/sh %s' >/dev/null 2>&1 &", 1, scriptPath) - backgroundKey := "sh -c " + cmd + backgroundKey := backgroundRollbackCallKey(1, scriptPath) fakeCmd.Errors[backgroundKey] = fmt.Errorf("boom") if _, err := armAccessControlRollback(context.Background(), logger, "/backup.tgz", 1*time.Second, "/tmp/proxsave"); err == nil { @@ -290,7 +288,7 @@ func TestArmAccessControlRollback_DefaultWorkDirAndMinTimeout(t *testing.T) { if len(calls) != 1 { t.Fatalf("unexpected calls: %#v", calls) } - if !strings.Contains(calls[0], "sleep 1; /bin/sh") { + if calls[0] != backgroundRollbackCallKey(1, handle.scriptPath) { t.Fatalf("expected timeoutSeconds to clamp to 1, got call=%q", calls[0]) } } diff --git a/internal/orchestrator/restore_firewall_additional_test.go b/internal/orchestrator/restore_firewall_additional_test.go index 11f47ecb..beef3796 100644 --- a/internal/orchestrator/restore_firewall_additional_test.go +++ b/internal/orchestrator/restore_firewall_additional_test.go @@ -704,8 +704,7 @@ func TestArmFirewallRollback_SystemdAndBackgroundPaths(t *testing.T) { t.Fatalf("expected unitName cleared after systemd-run failure, got %q", handle.unitName) } - cmd := fmt.Sprintf("nohup sh -c 'sleep %d; /bin/sh %s' >/dev/null 2>&1 &", 2, scriptPath) - wantBackground := "sh -c " + cmd + wantBackground := backgroundRollbackCallKey(2, scriptPath) calls := fakeCmd.CallsList() if len(calls) != 2 || calls[1] != wantBackground { t.Fatalf("unexpected calls: %#v", calls) @@ -723,8 +722,7 @@ func TestArmFirewallRollback_SystemdAndBackgroundPaths(t *testing.T) { timestamp := fakeTime.Current.Format("20060102_150405") scriptPath := filepath.Join("/tmp/proxsave", fmt.Sprintf("firewall_rollback_%s.sh", timestamp)) - cmd := fmt.Sprintf("nohup sh -c 'sleep %d; /bin/sh %s' >/dev/null 2>&1 &", 1, scriptPath) - backgroundKey := "sh -c " + cmd + backgroundKey := backgroundRollbackCallKey(1, scriptPath) fakeCmd.Errors[backgroundKey] = fmt.Errorf("boom") if _, err := armFirewallRollback(context.Background(), logger, "/backup.tgz", 1*time.Second, "/tmp/proxsave"); err == nil { @@ -1563,7 +1561,7 @@ func TestArmFirewallRollback_DefaultWorkDirAndMinTimeout(t *testing.T) { if len(calls) != 1 { t.Fatalf("unexpected calls: %#v", calls) } - if !strings.Contains(calls[0], "sleep 1; /bin/sh") { + if calls[0] != backgroundRollbackCallKey(1, handle.scriptPath) { t.Fatalf("expected timeoutSeconds to clamp to 1, got call=%q", calls[0]) } } diff --git a/internal/orchestrator/restore_ha_additional_test.go b/internal/orchestrator/restore_ha_additional_test.go index 4bee40e4..0d1554b7 100644 --- a/internal/orchestrator/restore_ha_additional_test.go +++ b/internal/orchestrator/restore_ha_additional_test.go @@ -275,7 +275,7 @@ func TestArmHARollback_CoversSchedulingPaths(t *testing.T) { t.Fatalf("expected backup path in script, got:\n%s", string(script)) } - wantBackground := "sh -c nohup sh -c 'sleep 2; /bin/sh " + handle.scriptPath + "' >/dev/null 2>&1 &" + wantBackground := backgroundRollbackCallKey(2, handle.scriptPath) calls := env.cmd.CallsList() if len(calls) != 1 || calls[0] != wantBackground { t.Fatalf("unexpected calls: %#v", calls) @@ -290,7 +290,7 @@ func TestArmHARollback_CoversSchedulingPaths(t *testing.T) { if err != nil { t.Fatalf("armHARollback error: %v", err) } - wantBackground := "sh -c nohup sh -c 'sleep 1; /bin/sh " + handle.scriptPath + "' >/dev/null 2>&1 &" + wantBackground := backgroundRollbackCallKey(1, handle.scriptPath) calls := env.cmd.CallsList() if len(calls) != 1 || calls[0] != wantBackground { t.Fatalf("unexpected calls: %#v", calls) @@ -319,7 +319,7 @@ func TestArmHARollback_CoversSchedulingPaths(t *testing.T) { t.Fatalf("expected unitName to be cleared after systemd-run failure, got %q", handle.unitName) } - wantBackground := "sh -c nohup sh -c 'sleep 2; /bin/sh " + scriptPath + "' >/dev/null 2>&1 &" + wantBackground := backgroundRollbackCallKey(2, scriptPath) calls := env.cmd.CallsList() if len(calls) != 2 || calls[0] != systemdKey || calls[1] != wantBackground { t.Fatalf("unexpected calls: %#v", calls) @@ -332,7 +332,7 @@ func TestArmHARollback_CoversSchedulingPaths(t *testing.T) { timestamp := env.fakeTime.Current.Format("20060102_150405") scriptPath := filepath.Join("/tmp/proxsave", fmt.Sprintf("ha_rollback_%s.sh", timestamp)) - backgroundKey := "sh -c nohup sh -c 'sleep 1; /bin/sh " + scriptPath + "' >/dev/null 2>&1 &" + backgroundKey := backgroundRollbackCallKey(1, scriptPath) env.cmd.Errors = map[string]error{ backgroundKey: fmt.Errorf("boom"), } diff --git a/internal/safeexec/safeexec.go b/internal/safeexec/safeexec.go index 3be319ad..ed0e9dfd 100644 --- a/internal/safeexec/safeexec.go +++ b/internal/safeexec/safeexec.go @@ -51,6 +51,8 @@ func CommandContext(ctx context.Context, name string, args ...string) (*exec.Cmd return exec.CommandContext(ctx, "echo", args...), nil case "ethtool": return exec.CommandContext(ctx, "ethtool", args...), nil + case "false": + return exec.CommandContext(ctx, "false", args...), nil case "firewall-cmd": return exec.CommandContext(ctx, "firewall-cmd", args...), nil case "free": @@ -145,6 +147,8 @@ func CommandContext(ctx context.Context, name string, args ...string) (*exec.Cmd return exec.CommandContext(ctx, "systemctl", args...), nil case "systemd-run": return exec.CommandContext(ctx, "systemd-run", args...), nil + case "sysctl": + return exec.CommandContext(ctx, "sysctl", args...), nil case "tail": return exec.CommandContext(ctx, "tail", args...), nil case "tar": @@ -195,6 +199,7 @@ func TrustedCommandContext(ctx context.Context, execPath string, args ...string) return nil, err } // #nosec G204 -- execPath is absolute, regular, executable, and not world-writable. + // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command return exec.CommandContext(ctx, execPath, args...), nil } diff --git a/internal/safeexec/safeexec_test.go b/internal/safeexec/safeexec_test.go index 84586108..96bee49b 100644 --- a/internal/safeexec/safeexec_test.go +++ b/internal/safeexec/safeexec_test.go @@ -62,6 +62,22 @@ func TestValidateRcloneRemoteName(t *testing.T) { } } +func TestValidateRemoteRelativePath(t *testing.T) { + valid := []string{"", "tenant/a", "/tenant/a/", "tenant with spaces/a"} + for _, value := range valid { + if err := ValidateRemoteRelativePath(value, "path"); err != nil { + t.Fatalf("ValidateRemoteRelativePath(%q) error: %v", value, err) + } + } + + invalid := []string{"../escape", "tenant/../../escape", "bad\npath"} + for _, value := range invalid { + if err := ValidateRemoteRelativePath(value, "path"); err == nil { + t.Fatalf("ValidateRemoteRelativePath(%q) expected error", value) + } + } +} + func TestProcPath(t *testing.T) { got, err := ProcPath(123, "status") if err != nil { From 2f45d9a24a6400f1ea8d7544e18f37071ba633dd Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:37:41 +0200 Subject: [PATCH 09/35] Refactor backup orchestration and validation Split the large backup startup/orchestration code into focused cmd/proxsave modules: backup_mode, backup_execution, backup_storage and backup_notifications, and updated main.go to call runBackupMode. Centralized orchestration flow (pre-checks, storage init, notifications, RunGoBackup invocation, stats persistence and exit/status logging) and improved error handling for backup runs. Improved collector validation in internal/backup: added validateExcludePatterns, combined validation checks (ensure at least one collection option, CLI timeouts, pxar concurrency, max PVE backup size and absolute system root prefix) and normalization steps. Also added/updated orchestrator helpers and tests to support the refactor. --- cmd/proxsave/backup_execution.go | 149 ++++++ cmd/proxsave/backup_mode.go | 259 +++++++++ cmd/proxsave/backup_notifications.go | 200 +++++++ cmd/proxsave/backup_storage.go | 156 ++++++ cmd/proxsave/main.go | 564 +------------------- internal/backup/collector.go | 233 +++++--- internal/orchestrator/backup_run_helpers.go | 321 +++++++++++ internal/orchestrator/backup_run_phases.go | 378 +++++++++++++ internal/orchestrator/decrypt_test.go | 429 +++++---------- internal/orchestrator/orchestrator.go | 527 +----------------- 10 files changed, 1788 insertions(+), 1428 deletions(-) create mode 100644 cmd/proxsave/backup_execution.go create mode 100644 cmd/proxsave/backup_mode.go create mode 100644 cmd/proxsave/backup_notifications.go create mode 100644 cmd/proxsave/backup_storage.go create mode 100644 internal/orchestrator/backup_run_helpers.go create mode 100644 internal/orchestrator/backup_run_phases.go diff --git a/cmd/proxsave/backup_execution.go b/cmd/proxsave/backup_execution.go new file mode 100644 index 00000000..8ed9644f --- /dev/null +++ b/cmd/proxsave/backup_execution.go @@ -0,0 +1,149 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/notify" + "github.com/tis24dev/proxsave/internal/orchestrator" + "github.com/tis24dev/proxsave/internal/types" +) + +func runConfiguredBackup(opts backupModeOptions, orch *orchestrator.Orchestrator) (*orchestrator.BackupStats, *orchestrator.EarlyErrorState, int) { + if !opts.cfg.BackupEnabled { + logging.Warning("Backup is disabled in configuration") + return nil, nil, types.ExitSuccess.Int() + } + + if earlyErrorState, exitCode := runPreBackupChecks(opts, orch); earlyErrorState != nil { + return nil, earlyErrorState, exitCode + } + + logging.Step("Start Go backup orchestration") + hostname := resolveHostname() + backupDone := logging.DebugStart(opts.logger, "backup run", "proxmox=%s host=%s", opts.envInfo.Type, hostname) + stats, err := orch.RunGoBackup(opts.ctx, opts.envInfo, hostname) + if err != nil { + backupDone(err) + return handleBackupRunError(opts.ctx, orch, stats, err) + } + backupDone(nil) + + persistBackupStats(orch, stats) + logBackupStatistics(stats) + logging.Info("✓ Go backup orchestration completed") + logServerIdentityValues(opts.serverIDValue, opts.serverMACValue) + + if opts.heapProfilePath != "" { + logging.Info("Heap profiling saved: %s", opts.heapProfilePath) + } + + logBackupExitStatus(stats.ExitCode) + return stats, nil, stats.ExitCode +} + +func runPreBackupChecks(opts backupModeOptions, orch *orchestrator.Orchestrator) (*orchestrator.EarlyErrorState, int) { + preCheckDone := logging.DebugStart(opts.logger, "pre-backup checks", "") + if err := orch.RunPreBackupChecks(opts.ctx); err != nil { + preCheckDone(err) + logging.Error("Pre-backup validation failed: %v", err) + return &orchestrator.EarlyErrorState{ + Phase: "pre_backup_checks", + Error: err, + ExitCode: types.ExitBackupError, + Timestamp: time.Now(), + }, types.ExitBackupError.Int() + } + preCheckDone(nil) + fmt.Println() + return nil, types.ExitSuccess.Int() +} + +func handleBackupRunError(ctx context.Context, orch *orchestrator.Orchestrator, stats *orchestrator.BackupStats, err error) (*orchestrator.BackupStats, *orchestrator.EarlyErrorState, int) { + if ctx.Err() == context.Canceled { + logging.Warning("Backup was canceled") + orch.FinalizeAfterRun(ctx, stats) + return stats, nil, exitCodeInterrupted + } + + var backupErr *orchestrator.BackupError + if errors.As(err, &backupErr) { + logging.Error("Backup %s failed: %v", backupErr.Phase, backupErr.Err) + orch.FinalizeAfterRun(ctx, stats) + return stats, nil, backupErr.Code.Int() + } + + logging.Error("Backup orchestration failed: %v", err) + orch.FinalizeAfterRun(ctx, stats) + return stats, nil, types.ExitBackupError.Int() +} + +func persistBackupStats(orch *orchestrator.Orchestrator, stats *orchestrator.BackupStats) { + if err := orch.SaveStatsReport(stats); err != nil { + logging.Warning("Failed to persist backup statistics: %v", err) + } else if stats.ReportPath != "" { + logging.Info("✓ Statistics report saved to %s", stats.ReportPath) + } +} + +func logBackupStatistics(stats *orchestrator.BackupStats) { + fmt.Println() + logging.Info("=== Backup Statistics ===") + logging.Info("Files collected: %d", stats.FilesCollected) + if stats.FilesFailed > 0 { + logging.Warning("Files failed: %d", stats.FilesFailed) + } + logging.Info("Directories created: %d", stats.DirsCreated) + logging.Info("Data collected: %s", formatBytes(stats.BytesCollected)) + logging.Info("Archive size: %s", formatBytes(stats.ArchiveSize)) + logCompressionRatio(stats) + logging.Info("Compression used: %s (level %d, mode %s)", stats.Compression, stats.CompressionLevel, stats.CompressionMode) + if stats.RequestedCompression != stats.Compression { + logging.Info("Requested compression: %s", stats.RequestedCompression) + } + logging.Info("Duration: %s", formatDuration(stats.Duration)) + logBackupArtifactPaths(stats) + fmt.Println() +} + +func logCompressionRatio(stats *orchestrator.BackupStats) { + switch { + case stats.CompressionSavingsPercent > 0: + logging.Info("Compression ratio: %.1f%%", stats.CompressionSavingsPercent) + case stats.CompressionRatioPercent > 0: + logging.Info("Compression ratio: %.1f%%", stats.CompressionRatioPercent) + case stats.BytesCollected > 0: + ratio := float64(stats.ArchiveSize) / float64(stats.BytesCollected) * 100 + logging.Info("Compression ratio: %.1f%%", ratio) + default: + logging.Info("Compression ratio: N/A") + } +} + +func logBackupArtifactPaths(stats *orchestrator.BackupStats) { + if stats.BundleCreated { + logging.Info("Bundle path: %s", stats.ArchivePath) + logging.Info("Bundle contents: archive + checksum + metadata") + return + } + + logging.Info("Archive path: %s", stats.ArchivePath) + if stats.ManifestPath != "" { + logging.Info("Manifest path: %s", stats.ManifestPath) + } + if stats.Checksum != "" { + logging.Info("Archive checksum (SHA256): %s", stats.Checksum) + } +} + +func logBackupExitStatus(exitCode int) { + status := notify.StatusFromExitCode(exitCode) + statusLabel := strings.ToUpper(status.String()) + emoji := notify.GetStatusEmoji(status) + logging.Info("Exit status: %s %s (code=%d)", emoji, statusLabel, exitCode) +} diff --git a/cmd/proxsave/backup_mode.go b/cmd/proxsave/backup_mode.go new file mode 100644 index 00000000..89809b79 --- /dev/null +++ b/cmd/proxsave/backup_mode.go @@ -0,0 +1,259 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/tis24dev/proxsave/internal/backup" + "github.com/tis24dev/proxsave/internal/checks" + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/environment" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/orchestrator" + "github.com/tis24dev/proxsave/internal/types" +) + +type backupModeOptions struct { + ctx context.Context + cfg *config.Config + logger *logging.Logger + envInfo *environment.EnvironmentInfo + unprivilegedInfo environment.UnprivilegedContainerInfo + updateInfo *UpdateInfo + toolVersion string + dryRun bool + startTime time.Time + heapProfilePath string + serverIDValue string + serverMACValue string +} + +type backupModeResult struct { + orch *orchestrator.Orchestrator + earlyErrorState *orchestrator.EarlyErrorState + supportStats *orchestrator.BackupStats + exitCode int +} + +func runBackupMode(opts backupModeOptions) backupModeResult { + orch, earlyErrorState, exitCode := initializeBackupOrchestrator(opts) + if earlyErrorState != nil { + return finishBackupMode(orch, earlyErrorState, nil, exitCode) + } + + verifyBackupDirectories(opts.cfg, opts.logger) + + checker, earlyErrorState, exitCode := configurePreBackupChecker(opts, orch) + if earlyErrorState != nil { + return finishBackupMode(orch, earlyErrorState, nil, exitCode) + } + + defer func() { + if err := orch.ReleaseBackupLock(); err != nil { + logging.Warning("Failed to release backup lock: %v", err) + } + }() + + storageState, earlyErrorState, exitCode := initializeBackupStorage(opts, orch, checker) + if earlyErrorState != nil { + return finishBackupMode(orch, earlyErrorState, nil, exitCode) + } + + initializeBackupNotifications(opts, orch) + logBackupRuntimeSummary(opts.cfg, storageState) + + stats, earlyErrorState, exitCode := runConfiguredBackup(opts, orch) + return finishBackupMode(orch, earlyErrorState, stats, exitCode) +} + +func finishBackupMode(orch *orchestrator.Orchestrator, earlyErrorState *orchestrator.EarlyErrorState, stats *orchestrator.BackupStats, exitCode int) backupModeResult { + return backupModeResult{ + orch: orch, + earlyErrorState: earlyErrorState, + supportStats: stats, + exitCode: exitCode, + } +} + +func initializeBackupOrchestrator(opts backupModeOptions) (*orchestrator.Orchestrator, *orchestrator.EarlyErrorState, int) { + logger := opts.logger + + logging.Step("Initializing backup orchestrator") + orchInitDone := logging.DebugStart(logger, "orchestrator init", "dry_run=%v", opts.dryRun) + orch := orchestrator.New(logger, opts.dryRun) + configureBackupOrchestrator(opts, orch) + + if earlyErrorState, exitCode := ensureBackupAgeRecipientsReady(opts, orch, orchInitDone); earlyErrorState != nil { + return orch, earlyErrorState, exitCode + } + orchInitDone(nil) + + logging.Info("✓ Orchestrator initialized") + fmt.Println() + return orch, nil, types.ExitSuccess.Int() +} + +func configureBackupOrchestrator(opts backupModeOptions, orch *orchestrator.Orchestrator) { + cfg := opts.cfg + orch.SetUnprivilegedContainerContext(opts.unprivilegedInfo.Detected, opts.unprivilegedInfo.Details) + orch.SetVersion(opts.toolVersion) + orch.SetConfig(cfg) + orch.SetIdentity(opts.serverIDValue, opts.serverMACValue) + orch.SetEnvironmentInfo(opts.envInfo) + orch.SetStartTime(opts.startTime) + if opts.updateInfo != nil { + orch.SetUpdateInfo(opts.updateInfo.NewVersion, opts.updateInfo.Current, opts.updateInfo.Latest) + } + + orch.SetBackupConfig( + cfg.BackupPath, + cfg.LogPath, + cfg.CompressionType, + cfg.CompressionLevel, + cfg.CompressionThreads, + cfg.CompressionMode, + buildBackupExcludePatterns(cfg), + ) + orch.SetOptimizationConfig(backupOptimizationConfig(cfg)) +} + +func backupOptimizationConfig(cfg *config.Config) backup.OptimizationConfig { + return backup.OptimizationConfig{ + EnableChunking: cfg.EnableSmartChunking, + EnableDeduplication: cfg.EnableDeduplication, + EnablePrefilter: cfg.EnablePrefilter, + ChunkSizeBytes: int64(cfg.ChunkSizeMB) * bytesPerMegabyte, + ChunkThresholdBytes: int64(cfg.ChunkThresholdMB) * bytesPerMegabyte, + PrefilterMaxFileSizeBytes: int64(cfg.PrefilterMaxFileSizeMB) * bytesPerMegabyte, + } +} + +func ensureBackupAgeRecipientsReady(opts backupModeOptions, orch *orchestrator.Orchestrator, orchInitDone func(error)) (*orchestrator.EarlyErrorState, int) { + err := orch.EnsureAgeRecipientsReady(opts.ctx) + if err == nil { + return nil, types.ExitSuccess.Int() + } + + orchInitDone(err) + if errors.Is(err, orchestrator.ErrAgeRecipientSetupAborted) { + logging.Warning("Encryption setup aborted by user. Exiting...") + return backupAgeRecipientEarlyError(err, types.ExitGenericError), types.ExitGenericError.Int() + } + + logging.Error("ERROR: %v", err) + return backupAgeRecipientEarlyError(err, types.ExitConfigError), types.ExitConfigError.Int() +} + +func backupAgeRecipientEarlyError(err error, exitCode types.ExitCode) *orchestrator.EarlyErrorState { + return &orchestrator.EarlyErrorState{ + Phase: "encryption_setup", + Error: err, + ExitCode: exitCode, + Timestamp: time.Now(), + } +} + +func buildBackupExcludePatterns(cfg *config.Config) []string { + excludePatterns := append([]string(nil), cfg.ExcludePatterns...) + excludePatterns = addPathExclusion(excludePatterns, cfg.BackupPath) + if cfg.SecondaryEnabled { + excludePatterns = addPathExclusion(excludePatterns, cfg.SecondaryPath) + } + if cfg.CloudEnabled && isLocalPath(cfg.CloudRemote) { + excludePatterns = addPathExclusion(excludePatterns, cfg.CloudRemote) + } + return excludePatterns +} + +func verifyBackupDirectories(cfg *config.Config, logger *logging.Logger) { + logging.Step("Verifying directory structure") + checkDir := func(name, path string) { + ensureDirectoryExists(logger, name, path) + } + + checkDir("Backup directory", cfg.BackupPath) + checkDir("Log directory", cfg.LogPath) + if cfg.SecondaryEnabled { + secondaryLogPath := strings.TrimSpace(cfg.SecondaryLogPath) + if secondaryLogPath != "" { + checkDir("Secondary log directory", secondaryLogPath) + } else { + logging.Warning("✗ Secondary log directory not configured (secondary storage enabled)") + } + } + if cfg.CloudEnabled { + logCloudLogDirectory(cfg) + } + checkDir("Lock directory", cfg.LockPath) +} + +func logCloudLogDirectory(cfg *config.Config) { + cloudLogPath := strings.TrimSpace(cfg.CloudLogPath) + if cloudLogPath == "" { + logging.Warning("✗ Cloud log directory not configured (cloud storage enabled)") + return + } + if strings.Contains(cloudLogPath, ":") { + logging.Info("Cloud log path (legacy): %s", cloudLogPath) + return + } + + remoteName := extractRemoteName(cfg.CloudRemote) + if remoteName != "" { + logging.Info("Cloud log path: %s (using remote: %s)", cloudLogPath, remoteName) + } else { + logging.Warning("Cloud log path %s requires CLOUD_REMOTE to be set", cloudLogPath) + } +} + +func configurePreBackupChecker(opts backupModeOptions, orch *orchestrator.Orchestrator) (*checks.Checker, *orchestrator.EarlyErrorState, int) { + cfg := opts.cfg + logger := opts.logger + + logging.Debug("Configuring pre-backup validation checks...") + checkerConfig := checks.GetDefaultCheckerConfig(cfg.BackupPath, cfg.LogPath, cfg.LockPath) + checkerConfig.SecondaryEnabled = cfg.SecondaryEnabled + if cfg.SecondaryEnabled && strings.TrimSpace(cfg.SecondaryPath) != "" { + checkerConfig.SecondaryPath = cfg.SecondaryPath + } else { + checkerConfig.SecondaryPath = "" + } + checkerConfig.CloudEnabled = cfg.CloudEnabled + if cfg.CloudEnabled && strings.TrimSpace(cfg.CloudRemote) != "" { + if isLocalPath(cfg.CloudRemote) { + checkerConfig.CloudPath = cfg.CloudRemote + } else { + checkerConfig.CloudPath = "" + logging.Info("Skipping cloud disk-space check: %s is a remote rclone path (no local mount detected)", cfg.CloudRemote) + } + } else { + checkerConfig.CloudPath = "" + } + checkerConfig.MinDiskPrimaryGB = cfg.MinDiskPrimaryGB + checkerConfig.MinDiskSecondaryGB = cfg.MinDiskSecondaryGB + checkerConfig.MinDiskCloudGB = cfg.MinDiskCloudGB + checkerConfig.FsIoTimeout = time.Duration(cfg.FsIoTimeoutSeconds) * time.Second + checkerConfig.DryRun = opts.dryRun + checkerDone := logging.DebugStart(logger, "pre-backup check config", "dry_run=%v", opts.dryRun) + if err := checkerConfig.Validate(); err != nil { + checkerDone(err) + logging.Error("Invalid checker configuration: %v", err) + return nil, &orchestrator.EarlyErrorState{ + Phase: "checker_config", + Error: err, + ExitCode: types.ExitConfigError, + Timestamp: time.Now(), + }, types.ExitConfigError.Int() + } + checkerDone(nil) + checker := checks.NewChecker(logger, checkerConfig) + orch.SetChecker(checker) + + logging.Info("✓ Pre-backup checks configured") + fmt.Println() + return checker, nil, types.ExitSuccess.Int() +} diff --git a/cmd/proxsave/backup_notifications.go b/cmd/proxsave/backup_notifications.go new file mode 100644 index 00000000..85cd6b4f --- /dev/null +++ b/cmd/proxsave/backup_notifications.go @@ -0,0 +1,200 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "fmt" + "strings" + + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/notify" + "github.com/tis24dev/proxsave/internal/orchestrator" +) + +func initializeBackupNotifications(opts backupModeOptions, orch *orchestrator.Orchestrator) { + logger := opts.logger + + logging.Step("Initializing notification channels") + notifyDone := logging.DebugStart(logger, "notifications init", "") + initializeEmailNotification(opts, orch) + initializeTelegramNotification(opts, orch) + initializeGotifyNotification(opts, orch) + initializeWebhookNotification(opts, orch) + notifyDone(nil) + + fmt.Println() +} + +func initializeEmailNotification(opts backupModeOptions, orch *orchestrator.Orchestrator) { + cfg := opts.cfg + logger := opts.logger + if !cfg.EmailEnabled { + logging.DebugStep(logger, "notifications init", "email disabled") + logging.Skip("Email: disabled") + return + } + + logging.DebugStep(logger, "notifications init", "email enabled") + emailConfig := notify.EmailConfig{ + Enabled: true, + DeliveryMethod: notify.EmailDeliveryMethod(cfg.EmailDeliveryMethod), + FallbackSendmail: cfg.EmailFallbackSendmail, + Recipient: cfg.EmailRecipient, + From: cfg.EmailFrom, + CloudRelayConfig: notify.CloudRelayConfig{ + WorkerURL: cfg.CloudflareWorkerURL, + WorkerToken: cfg.CloudflareWorkerToken, + HMACSecret: cfg.CloudflareHMACSecret, + Timeout: cfg.WorkerTimeout, + MaxRetries: cfg.WorkerMaxRetries, + RetryDelay: cfg.WorkerRetryDelay, + }, + } + emailNotifier, err := notify.NewEmailNotifier(emailConfig, opts.envInfo.Type, logger) + if err != nil { + logging.Warning("Failed to initialize Email notifier: %v", err) + return + } + emailAdapter := orchestrator.NewNotificationAdapter(emailNotifier, logger) + orch.RegisterNotificationChannel(emailAdapter) + logging.Info("✓ Email initialized (method: %s)", cfg.EmailDeliveryMethod) +} + +func initializeTelegramNotification(opts backupModeOptions, orch *orchestrator.Orchestrator) { + cfg := opts.cfg + logger := opts.logger + if !cfg.TelegramEnabled { + logging.DebugStep(logger, "notifications init", "telegram disabled") + logging.Skip("Telegram: disabled") + return + } + + logging.DebugStep(logger, "notifications init", "telegram enabled (mode=%s)", cfg.TelegramBotType) + telegramConfig := notify.TelegramConfig{ + Enabled: true, + Mode: notify.TelegramMode(cfg.TelegramBotType), + BotToken: cfg.TelegramBotToken, + ChatID: cfg.TelegramChatID, + ServerAPIHost: cfg.TelegramServerAPIHost, + ServerID: cfg.ServerID, + } + telegramNotifier, err := notify.NewTelegramNotifier(telegramConfig, logger) + if err != nil { + logging.Warning("Failed to initialize Telegram notifier: %v", err) + return + } + telegramAdapter := orchestrator.NewNotificationAdapter(telegramNotifier, logger) + orch.RegisterNotificationChannel(telegramAdapter) + logging.Info("✓ Telegram initialized (mode: %s)", cfg.TelegramBotType) +} + +func initializeGotifyNotification(opts backupModeOptions, orch *orchestrator.Orchestrator) { + cfg := opts.cfg + logger := opts.logger + if !cfg.GotifyEnabled { + logging.DebugStep(logger, "notifications init", "gotify disabled") + logging.Skip("Gotify: disabled") + return + } + + logging.DebugStep(logger, "notifications init", "gotify enabled") + gotifyConfig := notify.GotifyConfig{ + Enabled: true, + ServerURL: cfg.GotifyServerURL, + Token: cfg.GotifyToken, + PrioritySuccess: cfg.GotifyPrioritySuccess, + PriorityWarning: cfg.GotifyPriorityWarning, + PriorityFailure: cfg.GotifyPriorityFailure, + } + gotifyNotifier, err := notify.NewGotifyNotifier(gotifyConfig, logger) + if err != nil { + logging.Warning("Failed to initialize Gotify notifier: %v", err) + return + } + gotifyAdapter := orchestrator.NewNotificationAdapter(gotifyNotifier, logger) + orch.RegisterNotificationChannel(gotifyAdapter) + logging.Info("✓ Gotify initialized") +} + +func initializeWebhookNotification(opts backupModeOptions, orch *orchestrator.Orchestrator) { + cfg := opts.cfg + logger := opts.logger + if !cfg.WebhookEnabled { + logging.DebugStep(logger, "notifications init", "webhook disabled") + logging.Skip("Webhook: disabled") + return + } + + logging.DebugStep(logger, "notifications init", "webhook enabled") + logging.Debug("Initializing webhook notifier...") + webhookConfig := cfg.BuildWebhookConfig() + logging.Debug("Webhook config built: %d endpoints configured", len(webhookConfig.Endpoints)) + + webhookNotifier, err := notify.NewWebhookNotifier(webhookConfig, logger) + if err != nil { + logging.Warning("Failed to initialize Webhook notifier: %v", err) + return + } + logging.Debug("Creating webhook notification adapter...") + webhookAdapter := orchestrator.NewNotificationAdapter(webhookNotifier, logger) + + logging.Debug("Registering webhook notification channel with orchestrator...") + orch.RegisterNotificationChannel(webhookAdapter) + logging.Info("✓ Webhook initialized (%d endpoint(s))", len(webhookConfig.Endpoints)) +} + +func logBackupRuntimeSummary(cfg *config.Config, storageState backupStorageState) { + logBackupStorageSummary(cfg, storageState) + logBackupLogSummary(cfg) + logBackupNotificationSummary(cfg) +} + +func logBackupStorageSummary(cfg *config.Config, storageState backupStorageState) { + logging.Info("Storage configuration:") + logging.Info(" Primary: %s", formatStorageLabel(cfg.BackupPath, storageState.localFS)) + if cfg.SecondaryEnabled { + logging.Info(" Secondary storage: %s", formatStorageLabel(cfg.SecondaryPath, storageState.secondaryFS)) + } else { + logging.Skip(" Secondary storage: disabled") + } + if cfg.CloudEnabled { + logging.Info(" Cloud storage: %s", formatStorageLabel(cfg.CloudRemote, storageState.cloudFS)) + } else { + logging.Skip(" Cloud storage: disabled") + } + fmt.Println() +} + +func logBackupLogSummary(cfg *config.Config) { + logging.Info("Log configuration:") + logging.Info(" Primary: %s", cfg.LogPath) + if cfg.SecondaryEnabled { + if strings.TrimSpace(cfg.SecondaryLogPath) != "" { + logging.Info(" Secondary: %s", cfg.SecondaryLogPath) + } else { + logging.Skip(" Secondary: disabled (log path not configured)") + } + } else { + logging.Skip(" Secondary: disabled") + } + if cfg.CloudEnabled { + if strings.TrimSpace(cfg.CloudLogPath) != "" { + logging.Info(" Cloud: %s", cfg.CloudLogPath) + } else { + logging.Skip(" Cloud: disabled (log path not configured)") + } + } else { + logging.Skip(" Cloud: disabled") + } + fmt.Println() +} + +func logBackupNotificationSummary(cfg *config.Config) { + logging.Info("Notification configuration:") + logging.Info(" Telegram: %v", cfg.TelegramEnabled) + logging.Info(" Email: %v", cfg.EmailEnabled) + logging.Info(" Gotify: %v", cfg.GotifyEnabled) + logging.Info(" Webhook: %v", cfg.WebhookEnabled) + logging.Info(" Metrics: %v", cfg.MetricsEnabled) + fmt.Println() +} diff --git a/cmd/proxsave/backup_storage.go b/cmd/proxsave/backup_storage.go new file mode 100644 index 00000000..02e4135d --- /dev/null +++ b/cmd/proxsave/backup_storage.go @@ -0,0 +1,156 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "fmt" + "time" + + "github.com/tis24dev/proxsave/internal/checks" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/orchestrator" + "github.com/tis24dev/proxsave/internal/storage" + "github.com/tis24dev/proxsave/internal/types" +) + +type backupStorageState struct { + localFS *storage.FilesystemInfo + secondaryFS *storage.FilesystemInfo + cloudFS *storage.FilesystemInfo +} + +func initializeBackupStorage(opts backupModeOptions, orch *orchestrator.Orchestrator, checker *checks.Checker) (backupStorageState, *orchestrator.EarlyErrorState, int) { + cfg := opts.cfg + logger := opts.logger + state := backupStorageState{} + + logging.Step("Initializing storage backends") + storageDone := logging.DebugStart(logger, "storage init", "primary=%s secondary=%v cloud=%v", cfg.BackupPath, cfg.SecondaryEnabled, cfg.CloudEnabled) + + localBackend, localFS, storageFailureMessage, err := initializePrimaryStorage(opts) + if err != nil { + storageDone(err) + logging.Error("%s: %v", storageFailureMessage, err) + return state, &orchestrator.EarlyErrorState{ + Phase: "storage_init", + Error: err, + ExitCode: types.ExitConfigError, + Timestamp: time.Now(), + }, types.ExitConfigError.Int() + } + state.localFS = localFS + registerPrimaryStorage(opts, orch, localBackend, localFS) + + state.secondaryFS = initializeSecondaryStorage(opts, orch) + state.cloudFS = initializeCloudStorage(opts, orch, checker) + storageDone(nil) + + fmt.Println() + return state, nil, types.ExitSuccess.Int() +} + +func initializePrimaryStorage(opts backupModeOptions) (storage.Storage, *storage.FilesystemInfo, string, error) { + cfg := opts.cfg + logger := opts.logger + + logging.DebugStep(logger, "storage init", "primary backend") + localBackend, err := storage.NewLocalStorage(cfg, logger) + if err != nil { + return nil, nil, "Failed to initialize local storage", err + } + localFS, err := detectFilesystemInfo(opts.ctx, localBackend, cfg.BackupPath, logger) + if err != nil { + return nil, nil, "Failed to prepare primary storage", err + } + + logging.DebugStep(logger, "storage init", "primary filesystem=%s", formatDetailedFilesystemLabel(cfg.BackupPath, localFS)) + logging.Info("Path Primary: %s", formatDetailedFilesystemLabel(cfg.BackupPath, localFS)) + return localBackend, localFS, "", nil +} + +func registerPrimaryStorage(opts backupModeOptions, orch *orchestrator.Orchestrator, localBackend storage.Storage, localFS *storage.FilesystemInfo) { + cfg := opts.cfg + logger := opts.logger + + localStats := fetchStorageStats(opts.ctx, localBackend, logger, "Local storage") + localBackups := fetchBackupList(opts.ctx, localBackend) + logging.DebugStep(logger, "storage init", "primary stats=%v backups=%d", localStats != nil, len(localBackups)) + + localAdapter := orchestrator.NewStorageAdapter(localBackend, logger, cfg) + localAdapter.SetFilesystemInfo(localFS) + localAdapter.SetInitialStats(localStats) + orch.RegisterStorageTarget(localAdapter) + logStorageInitSummary(formatStorageInitSummary("Local storage", cfg, storage.LocationPrimary, localStats, localBackups)) +} + +func initializeSecondaryStorage(opts backupModeOptions, orch *orchestrator.Orchestrator) *storage.FilesystemInfo { + cfg := opts.cfg + logger := opts.logger + if !cfg.SecondaryEnabled { + logging.Skip("Path Secondary: disabled") + return nil + } + + logging.DebugStep(logger, "storage init", "secondary backend") + secondaryBackend, err := storage.NewSecondaryStorage(cfg, logger) + if err != nil { + logging.Warning("Failed to initialize secondary storage: %v", err) + logging.Info("Path Secondary: %s", formatDetailedFilesystemLabel(cfg.SecondaryPath, nil)) + return nil + } + + secondaryFS, _ := detectFilesystemInfo(opts.ctx, secondaryBackend, cfg.SecondaryPath, logger) + logging.DebugStep(logger, "storage init", "secondary filesystem=%s", formatDetailedFilesystemLabel(cfg.SecondaryPath, secondaryFS)) + logging.Info("Path Secondary: %s", formatDetailedFilesystemLabel(cfg.SecondaryPath, secondaryFS)) + secondaryStats := fetchStorageStats(opts.ctx, secondaryBackend, logger, "Secondary storage") + secondaryBackups := fetchBackupList(opts.ctx, secondaryBackend) + logging.DebugStep(logger, "storage init", "secondary stats=%v backups=%d", secondaryStats != nil, len(secondaryBackups)) + secondaryAdapter := orchestrator.NewStorageAdapter(secondaryBackend, logger, cfg) + secondaryAdapter.SetFilesystemInfo(secondaryFS) + secondaryAdapter.SetInitialStats(secondaryStats) + orch.RegisterStorageTarget(secondaryAdapter) + logStorageInitSummary(formatStorageInitSummary("Secondary storage", cfg, storage.LocationSecondary, secondaryStats, secondaryBackups)) + return secondaryFS +} + +func initializeCloudStorage(opts backupModeOptions, orch *orchestrator.Orchestrator, checker *checks.Checker) *storage.FilesystemInfo { + cfg := opts.cfg + logger := opts.logger + if !cfg.CloudEnabled { + logging.Skip("Path Cloud: disabled") + return nil + } + + logging.DebugStep(logger, "storage init", "cloud backend") + cloudBackend, err := storage.NewCloudStorage(cfg, logger) + if err != nil { + logging.Warning("Failed to initialize cloud storage: %v", err) + logging.Info("Path Cloud: %s", formatDetailedFilesystemLabel(cfg.CloudRemote, nil)) + logStorageInitSummary(formatStorageInitSummary("Cloud storage", cfg, storage.LocationCloud, nil, nil)) + return nil + } + + cloudFS, _ := detectFilesystemInfo(opts.ctx, cloudBackend, cfg.CloudRemote, logger) + if cloudFS == nil { + logging.DebugStep(logger, "storage init", "cloud unavailable, disabling") + cfg.CloudEnabled = false + cfg.CloudLogPath = "" + if checker != nil { + checker.DisableCloud() + } + logStorageInitSummary(formatStorageInitSummary("Cloud storage", cfg, storage.LocationCloud, nil, nil)) + logging.Skip("Path Cloud: disabled") + return nil + } + + logging.DebugStep(logger, "storage init", "cloud filesystem=%s", formatDetailedFilesystemLabel(cfg.CloudRemote, cloudFS)) + logging.Info("Path Cloud: %s", formatDetailedFilesystemLabel(cfg.CloudRemote, cloudFS)) + cloudStats := fetchStorageStats(opts.ctx, cloudBackend, logger, "Cloud storage") + cloudBackups := fetchBackupList(opts.ctx, cloudBackend) + logging.DebugStep(logger, "storage init", "cloud stats=%v backups=%d", cloudStats != nil, len(cloudBackups)) + cloudAdapter := orchestrator.NewStorageAdapter(cloudBackend, logger, cfg) + cloudAdapter.SetFilesystemInfo(cloudFS) + cloudAdapter.SetInitialStats(cloudStats) + orch.RegisterStorageTarget(cloudAdapter) + logStorageInitSummary(formatStorageInitSummary("Cloud storage", cfg, storage.LocationCloud, cloudStats, cloudBackups)) + return cloudFS +} diff --git a/cmd/proxsave/main.go b/cmd/proxsave/main.go index 893f8451..c92e0494 100644 --- a/cmd/proxsave/main.go +++ b/cmd/proxsave/main.go @@ -1,3 +1,4 @@ +// Package main contains the proxsave command entrypoint. package main import ( @@ -17,8 +18,6 @@ import ( "syscall" "time" - "github.com/tis24dev/proxsave/internal/backup" - "github.com/tis24dev/proxsave/internal/checks" "github.com/tis24dev/proxsave/internal/cli" "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/environment" @@ -27,7 +26,6 @@ import ( "github.com/tis24dev/proxsave/internal/notify" "github.com/tis24dev/proxsave/internal/orchestrator" "github.com/tis24dev/proxsave/internal/security" - "github.com/tis24dev/proxsave/internal/storage" "github.com/tis24dev/proxsave/internal/support" "github.com/tis24dev/proxsave/internal/tui" "github.com/tis24dev/proxsave/internal/types" @@ -938,550 +936,24 @@ func run() int { return finalize(types.ExitSuccess.Int()) } - // Initialize orchestrator - logging.Step("Initializing backup orchestrator") - orchInitDone := logging.DebugStart(logger, "orchestrator init", "dry_run=%v", dryRun) - orch = orchestrator.New(logger, dryRun) - orch.SetUnprivilegedContainerContext(unprivilegedInfo.Detected, unprivilegedInfo.Details) - orch.SetVersion(toolVersion) - orch.SetConfig(cfg) - orch.SetIdentity(serverIDValue, serverMACValue) - orch.SetEnvironmentInfo(envInfo) - orch.SetStartTime(startTime) - if updateInfo != nil { - orch.SetUpdateInfo(updateInfo.NewVersion, updateInfo.Current, updateInfo.Latest) - } - - // Configure backup paths and compression - excludePatterns := append([]string(nil), cfg.ExcludePatterns...) - excludePatterns = addPathExclusion(excludePatterns, cfg.BackupPath) - if cfg.SecondaryEnabled { - excludePatterns = addPathExclusion(excludePatterns, cfg.SecondaryPath) - } - if cfg.CloudEnabled && isLocalPath(cfg.CloudRemote) { - excludePatterns = addPathExclusion(excludePatterns, cfg.CloudRemote) - } - - orch.SetBackupConfig( - cfg.BackupPath, - cfg.LogPath, - cfg.CompressionType, - cfg.CompressionLevel, - cfg.CompressionThreads, - cfg.CompressionMode, - excludePatterns, - ) - - orch.SetOptimizationConfig(backup.OptimizationConfig{ - EnableChunking: cfg.EnableSmartChunking, - EnableDeduplication: cfg.EnableDeduplication, - EnablePrefilter: cfg.EnablePrefilter, - ChunkSizeBytes: int64(cfg.ChunkSizeMB) * bytesPerMegabyte, - ChunkThresholdBytes: int64(cfg.ChunkThresholdMB) * bytesPerMegabyte, - PrefilterMaxFileSizeBytes: int64(cfg.PrefilterMaxFileSizeMB) * bytesPerMegabyte, + backupResult := runBackupMode(backupModeOptions{ + ctx: ctx, + cfg: cfg, + logger: logger, + envInfo: envInfo, + unprivilegedInfo: unprivilegedInfo, + updateInfo: updateInfo, + toolVersion: toolVersion, + dryRun: dryRun, + startTime: startTime, + heapProfilePath: heapProfilePath, + serverIDValue: serverIDValue, + serverMACValue: serverMACValue, }) - - if err := orch.EnsureAgeRecipientsReady(ctx); err != nil { - orchInitDone(err) - if errors.Is(err, orchestrator.ErrAgeRecipientSetupAborted) { - logging.Warning("Encryption setup aborted by user. Exiting...") - earlyErrorState = &orchestrator.EarlyErrorState{ - Phase: "encryption_setup", - Error: err, - ExitCode: types.ExitGenericError, - Timestamp: time.Now(), - } - return finalize(types.ExitGenericError.Int()) - } - logging.Error("ERROR: %v", err) - earlyErrorState = &orchestrator.EarlyErrorState{ - Phase: "encryption_setup", - Error: err, - ExitCode: types.ExitConfigError, - Timestamp: time.Now(), - } - return finalize(types.ExitConfigError.Int()) - } - orchInitDone(nil) - - logging.Info("✓ Orchestrator initialized") - fmt.Println() - - // Verify directories - logging.Step("Verifying directory structure") - checkDir := func(name, path string) { - ensureDirectoryExists(logger, name, path) - } - - checkDir("Backup directory", cfg.BackupPath) - checkDir("Log directory", cfg.LogPath) - if cfg.SecondaryEnabled { - secondaryLogPath := strings.TrimSpace(cfg.SecondaryLogPath) - if secondaryLogPath != "" { - checkDir("Secondary log directory", secondaryLogPath) - } else { - logging.Warning("✗ Secondary log directory not configured (secondary storage enabled)") - } - } - if cfg.CloudEnabled { - cloudLogPath := strings.TrimSpace(cfg.CloudLogPath) - if cloudLogPath == "" { - logging.Warning("✗ Cloud log directory not configured (cloud storage enabled)") - } else if strings.Contains(cloudLogPath, ":") { - // Legacy format with explicit remote (e.g., "gdrive:/logs") - logging.Info("Cloud log path (legacy): %s", cloudLogPath) - } else { - // New format without remote - will use CLOUD_REMOTE (e.g., "/logs") - remoteName := extractRemoteName(cfg.CloudRemote) - if remoteName != "" { - logging.Info("Cloud log path: %s (using remote: %s)", cloudLogPath, remoteName) - } else { - logging.Warning("Cloud log path %s requires CLOUD_REMOTE to be set", cloudLogPath) - } - } - } - checkDir("Lock directory", cfg.LockPath) - - // Initialize pre-backup checker - logging.Debug("Configuring pre-backup validation checks...") - checkerConfig := checks.GetDefaultCheckerConfig(cfg.BackupPath, cfg.LogPath, cfg.LockPath) - checkerConfig.SecondaryEnabled = cfg.SecondaryEnabled - if cfg.SecondaryEnabled && strings.TrimSpace(cfg.SecondaryPath) != "" { - checkerConfig.SecondaryPath = cfg.SecondaryPath - } else { - checkerConfig.SecondaryPath = "" - } - checkerConfig.CloudEnabled = cfg.CloudEnabled - if cfg.CloudEnabled && strings.TrimSpace(cfg.CloudRemote) != "" { - if isLocalPath(cfg.CloudRemote) { - checkerConfig.CloudPath = cfg.CloudRemote - } else { - checkerConfig.CloudPath = "" - logging.Info("Skipping cloud disk-space check: %s is a remote rclone path (no local mount detected)", cfg.CloudRemote) - } - } else { - checkerConfig.CloudPath = "" - } - checkerConfig.MinDiskPrimaryGB = cfg.MinDiskPrimaryGB - checkerConfig.MinDiskSecondaryGB = cfg.MinDiskSecondaryGB - checkerConfig.MinDiskCloudGB = cfg.MinDiskCloudGB - checkerConfig.FsIoTimeout = time.Duration(cfg.FsIoTimeoutSeconds) * time.Second - checkerConfig.DryRun = dryRun - checkerDone := logging.DebugStart(logger, "pre-backup check config", "dry_run=%v", dryRun) - if err := checkerConfig.Validate(); err != nil { - checkerDone(err) - logging.Error("Invalid checker configuration: %v", err) - earlyErrorState = &orchestrator.EarlyErrorState{ - Phase: "checker_config", - Error: err, - ExitCode: types.ExitConfigError, - Timestamp: time.Now(), - } - return finalize(types.ExitConfigError.Int()) - } - checkerDone(nil) - checker := checks.NewChecker(logger, checkerConfig) - orch.SetChecker(checker) - - // Ensure lock is released on exit - defer func() { - if err := orch.ReleaseBackupLock(); err != nil { - logging.Warning("Failed to release backup lock: %v", err) - } - }() - - logging.Info("✓ Pre-backup checks configured") - fmt.Println() - - // Initialize storage backends - logging.Step("Initializing storage backends") - storageDone := logging.DebugStart(logger, "storage init", "primary=%s secondary=%v cloud=%v", cfg.BackupPath, cfg.SecondaryEnabled, cfg.CloudEnabled) - - // Primary (local) storage - always enabled - logging.DebugStep(logger, "storage init", "primary backend") - localBackend, err := storage.NewLocalStorage(cfg, logger) - if err != nil { - storageDone(err) - logging.Error("Failed to initialize local storage: %v", err) - earlyErrorState = &orchestrator.EarlyErrorState{ - Phase: "storage_init", - Error: err, - ExitCode: types.ExitConfigError, - Timestamp: time.Now(), - } - return finalize(types.ExitConfigError.Int()) - } - localFS, err := detectFilesystemInfo(ctx, localBackend, cfg.BackupPath, logger) - if err != nil { - storageDone(err) - logging.Error("Failed to prepare primary storage: %v", err) - earlyErrorState = &orchestrator.EarlyErrorState{ - Phase: "storage_init", - Error: err, - ExitCode: types.ExitConfigError, - Timestamp: time.Now(), - } - return finalize(types.ExitConfigError.Int()) - } - logging.DebugStep(logger, "storage init", "primary filesystem=%s", formatDetailedFilesystemLabel(cfg.BackupPath, localFS)) - logging.Info("Path Primary: %s", formatDetailedFilesystemLabel(cfg.BackupPath, localFS)) - - localStats := fetchStorageStats(ctx, localBackend, logger, "Local storage") - localBackups := fetchBackupList(ctx, localBackend) - logging.DebugStep(logger, "storage init", "primary stats=%v backups=%d", localStats != nil, len(localBackups)) - - localAdapter := orchestrator.NewStorageAdapter(localBackend, logger, cfg) - localAdapter.SetFilesystemInfo(localFS) - localAdapter.SetInitialStats(localStats) - orch.RegisterStorageTarget(localAdapter) - logStorageInitSummary(formatStorageInitSummary("Local storage", cfg, storage.LocationPrimary, localStats, localBackups)) - - // Secondary storage - optional - var secondaryFS *storage.FilesystemInfo - if cfg.SecondaryEnabled { - logging.DebugStep(logger, "storage init", "secondary backend") - secondaryBackend, err := storage.NewSecondaryStorage(cfg, logger) - if err != nil { - logging.Warning("Failed to initialize secondary storage: %v", err) - logging.Info("Path Secondary: %s", formatDetailedFilesystemLabel(cfg.SecondaryPath, nil)) - } else { - secondaryFS, _ = detectFilesystemInfo(ctx, secondaryBackend, cfg.SecondaryPath, logger) - logging.DebugStep(logger, "storage init", "secondary filesystem=%s", formatDetailedFilesystemLabel(cfg.SecondaryPath, secondaryFS)) - logging.Info("Path Secondary: %s", formatDetailedFilesystemLabel(cfg.SecondaryPath, secondaryFS)) - secondaryStats := fetchStorageStats(ctx, secondaryBackend, logger, "Secondary storage") - secondaryBackups := fetchBackupList(ctx, secondaryBackend) - logging.DebugStep(logger, "storage init", "secondary stats=%v backups=%d", secondaryStats != nil, len(secondaryBackups)) - secondaryAdapter := orchestrator.NewStorageAdapter(secondaryBackend, logger, cfg) - secondaryAdapter.SetFilesystemInfo(secondaryFS) - secondaryAdapter.SetInitialStats(secondaryStats) - orch.RegisterStorageTarget(secondaryAdapter) - logStorageInitSummary(formatStorageInitSummary("Secondary storage", cfg, storage.LocationSecondary, secondaryStats, secondaryBackups)) - } - } else { - logging.Skip("Path Secondary: disabled") - } - - // Cloud storage - optional - var cloudFS *storage.FilesystemInfo - if cfg.CloudEnabled { - logging.DebugStep(logger, "storage init", "cloud backend") - cloudBackend, err := storage.NewCloudStorage(cfg, logger) - if err != nil { - logging.Warning("Failed to initialize cloud storage: %v", err) - logging.Info("Path Cloud: %s", formatDetailedFilesystemLabel(cfg.CloudRemote, nil)) - logStorageInitSummary(formatStorageInitSummary("Cloud storage", cfg, storage.LocationCloud, nil, nil)) - } else { - cloudFS, _ = detectFilesystemInfo(ctx, cloudBackend, cfg.CloudRemote, logger) - if cloudFS == nil { - logging.DebugStep(logger, "storage init", "cloud unavailable, disabling") - cfg.CloudEnabled = false - cfg.CloudLogPath = "" - if checker != nil { - checker.DisableCloud() - } - logStorageInitSummary(formatStorageInitSummary("Cloud storage", cfg, storage.LocationCloud, nil, nil)) - logging.Skip("Path Cloud: disabled") - } else { - logging.DebugStep(logger, "storage init", "cloud filesystem=%s", formatDetailedFilesystemLabel(cfg.CloudRemote, cloudFS)) - logging.Info("Path Cloud: %s", formatDetailedFilesystemLabel(cfg.CloudRemote, cloudFS)) - cloudStats := fetchStorageStats(ctx, cloudBackend, logger, "Cloud storage") - cloudBackups := fetchBackupList(ctx, cloudBackend) - logging.DebugStep(logger, "storage init", "cloud stats=%v backups=%d", cloudStats != nil, len(cloudBackups)) - cloudAdapter := orchestrator.NewStorageAdapter(cloudBackend, logger, cfg) - cloudAdapter.SetFilesystemInfo(cloudFS) - cloudAdapter.SetInitialStats(cloudStats) - orch.RegisterStorageTarget(cloudAdapter) - logStorageInitSummary(formatStorageInitSummary("Cloud storage", cfg, storage.LocationCloud, cloudStats, cloudBackups)) - } - } - } else { - logging.Skip("Path Cloud: disabled") - } - storageDone(nil) - - fmt.Println() - - // Initialize notification channels - logging.Step("Initializing notification channels") - notifyDone := logging.DebugStart(logger, "notifications init", "") - - // Email notifications - if cfg.EmailEnabled { - logging.DebugStep(logger, "notifications init", "email enabled") - emailConfig := notify.EmailConfig{ - Enabled: true, - DeliveryMethod: notify.EmailDeliveryMethod(cfg.EmailDeliveryMethod), - FallbackSendmail: cfg.EmailFallbackSendmail, - Recipient: cfg.EmailRecipient, - From: cfg.EmailFrom, - CloudRelayConfig: notify.CloudRelayConfig{ - WorkerURL: cfg.CloudflareWorkerURL, - WorkerToken: cfg.CloudflareWorkerToken, - HMACSecret: cfg.CloudflareHMACSecret, - Timeout: cfg.WorkerTimeout, - MaxRetries: cfg.WorkerMaxRetries, - RetryDelay: cfg.WorkerRetryDelay, - }, - } - emailNotifier, err := notify.NewEmailNotifier(emailConfig, envInfo.Type, logger) - if err != nil { - logging.Warning("Failed to initialize Email notifier: %v", err) - } else { - emailAdapter := orchestrator.NewNotificationAdapter(emailNotifier, logger) - orch.RegisterNotificationChannel(emailAdapter) - logging.Info("✓ Email initialized (method: %s)", cfg.EmailDeliveryMethod) - } - } else { - logging.DebugStep(logger, "notifications init", "email disabled") - logging.Skip("Email: disabled") - } - - // Telegram notifications - if cfg.TelegramEnabled { - logging.DebugStep(logger, "notifications init", "telegram enabled (mode=%s)", cfg.TelegramBotType) - telegramConfig := notify.TelegramConfig{ - Enabled: true, - Mode: notify.TelegramMode(cfg.TelegramBotType), - BotToken: cfg.TelegramBotToken, - ChatID: cfg.TelegramChatID, - ServerAPIHost: cfg.TelegramServerAPIHost, - ServerID: cfg.ServerID, - } - telegramNotifier, err := notify.NewTelegramNotifier(telegramConfig, logger) - if err != nil { - logging.Warning("Failed to initialize Telegram notifier: %v", err) - } else { - telegramAdapter := orchestrator.NewNotificationAdapter(telegramNotifier, logger) - orch.RegisterNotificationChannel(telegramAdapter) - logging.Info("✓ Telegram initialized (mode: %s)", cfg.TelegramBotType) - } - } else { - logging.DebugStep(logger, "notifications init", "telegram disabled") - logging.Skip("Telegram: disabled") - } - - // Gotify notifications - if cfg.GotifyEnabled { - logging.DebugStep(logger, "notifications init", "gotify enabled") - gotifyConfig := notify.GotifyConfig{ - Enabled: true, - ServerURL: cfg.GotifyServerURL, - Token: cfg.GotifyToken, - PrioritySuccess: cfg.GotifyPrioritySuccess, - PriorityWarning: cfg.GotifyPriorityWarning, - PriorityFailure: cfg.GotifyPriorityFailure, - } - gotifyNotifier, err := notify.NewGotifyNotifier(gotifyConfig, logger) - if err != nil { - logging.Warning("Failed to initialize Gotify notifier: %v", err) - } else { - gotifyAdapter := orchestrator.NewNotificationAdapter(gotifyNotifier, logger) - orch.RegisterNotificationChannel(gotifyAdapter) - logging.Info("✓ Gotify initialized") - } - } else { - logging.DebugStep(logger, "notifications init", "gotify disabled") - logging.Skip("Gotify: disabled") - } - - // Webhook Notifications - if cfg.WebhookEnabled { - logging.DebugStep(logger, "notifications init", "webhook enabled") - logging.Debug("Initializing webhook notifier...") - webhookConfig := cfg.BuildWebhookConfig() - logging.Debug("Webhook config built: %d endpoints configured", len(webhookConfig.Endpoints)) - - webhookNotifier, err := notify.NewWebhookNotifier(webhookConfig, logger) - if err != nil { - logging.Warning("Failed to initialize Webhook notifier: %v", err) - } else { - logging.Debug("Creating webhook notification adapter...") - webhookAdapter := orchestrator.NewNotificationAdapter(webhookNotifier, logger) - - logging.Debug("Registering webhook notification channel with orchestrator...") - orch.RegisterNotificationChannel(webhookAdapter) - logging.Info("✓ Webhook initialized (%d endpoint(s))", len(webhookConfig.Endpoints)) - } - } else { - logging.DebugStep(logger, "notifications init", "webhook disabled") - logging.Skip("Webhook: disabled") - } - notifyDone(nil) - - fmt.Println() - - // Storage info - logging.Info("Storage configuration:") - logging.Info(" Primary: %s", formatStorageLabel(cfg.BackupPath, localFS)) - if cfg.SecondaryEnabled { - logging.Info(" Secondary storage: %s", formatStorageLabel(cfg.SecondaryPath, secondaryFS)) - } else { - logging.Skip(" Secondary storage: disabled") - } - if cfg.CloudEnabled { - logging.Info(" Cloud storage: %s", formatStorageLabel(cfg.CloudRemote, cloudFS)) - } else { - logging.Skip(" Cloud storage: disabled") - } - fmt.Println() - - // Log configuration info - logging.Info("Log configuration:") - logging.Info(" Primary: %s", cfg.LogPath) - if cfg.SecondaryEnabled { - if strings.TrimSpace(cfg.SecondaryLogPath) != "" { - logging.Info(" Secondary: %s", cfg.SecondaryLogPath) - } else { - logging.Skip(" Secondary: disabled (log path not configured)") - } - } else { - logging.Skip(" Secondary: disabled") - } - if cfg.CloudEnabled { - if strings.TrimSpace(cfg.CloudLogPath) != "" { - logging.Info(" Cloud: %s", cfg.CloudLogPath) - } else { - logging.Skip(" Cloud: disabled (log path not configured)") - } - } else { - logging.Skip(" Cloud: disabled") - } - fmt.Println() - - // Notification info - logging.Info("Notification configuration:") - logging.Info(" Telegram: %v", cfg.TelegramEnabled) - logging.Info(" Email: %v", cfg.EmailEnabled) - logging.Info(" Gotify: %v", cfg.GotifyEnabled) - logging.Info(" Webhook: %v", cfg.WebhookEnabled) - logging.Info(" Metrics: %v", cfg.MetricsEnabled) - fmt.Println() - - // Run backup orchestration - if cfg.BackupEnabled { - preCheckDone := logging.DebugStart(logger, "pre-backup checks", "") - if err := orch.RunPreBackupChecks(ctx); err != nil { - preCheckDone(err) - logging.Error("Pre-backup validation failed: %v", err) - earlyErrorState = &orchestrator.EarlyErrorState{ - Phase: "pre_backup_checks", - Error: err, - ExitCode: types.ExitBackupError, - Timestamp: time.Now(), - } - return finalize(types.ExitBackupError.Int()) - } - preCheckDone(nil) - fmt.Println() - - logging.Step("Start Go backup orchestration") - - // Get hostname for backup naming - hostname := resolveHostname() - - // Run Go-based backup (collection + archive) - backupDone := logging.DebugStart(logger, "backup run", "proxmox=%s host=%s", envInfo.Type, hostname) - stats, err := orch.RunGoBackup(ctx, envInfo, hostname) - if err != nil { - backupDone(err) - // Check if error is due to cancellation - if ctx.Err() == context.Canceled { - logging.Warning("Backup was canceled") - orch.FinalizeAfterRun(ctx, stats) - if stats != nil { - pendingSupportStats = stats - } - return finalize(exitCodeInterrupted) // Standard Unix exit code for SIGINT - } - - // Check if it's a BackupError with specific exit code - var backupErr *orchestrator.BackupError - if errors.As(err, &backupErr) { - logging.Error("Backup %s failed: %v", backupErr.Phase, backupErr.Err) - orch.FinalizeAfterRun(ctx, stats) - if stats != nil { - pendingSupportStats = stats - } - return finalize(backupErr.Code.Int()) - } - - // Generic backup error - logging.Error("Backup orchestration failed: %v", err) - orch.FinalizeAfterRun(ctx, stats) - if stats != nil { - pendingSupportStats = stats - } - return finalize(types.ExitBackupError.Int()) - } - backupDone(nil) - - if err := orch.SaveStatsReport(stats); err != nil { - logging.Warning("Failed to persist backup statistics: %v", err) - } else if stats.ReportPath != "" { - logging.Info("✓ Statistics report saved to %s", stats.ReportPath) - } - - // Display backup statistics - fmt.Println() - logging.Info("=== Backup Statistics ===") - logging.Info("Files collected: %d", stats.FilesCollected) - if stats.FilesFailed > 0 { - logging.Warning("Files failed: %d", stats.FilesFailed) - } - logging.Info("Directories created: %d", stats.DirsCreated) - logging.Info("Data collected: %s", formatBytes(stats.BytesCollected)) - logging.Info("Archive size: %s", formatBytes(stats.ArchiveSize)) - switch { - case stats.CompressionSavingsPercent > 0: - logging.Info("Compression ratio: %.1f%%", stats.CompressionSavingsPercent) - case stats.CompressionRatioPercent > 0: - logging.Info("Compression ratio: %.1f%%", stats.CompressionRatioPercent) - case stats.BytesCollected > 0: - ratio := float64(stats.ArchiveSize) / float64(stats.BytesCollected) * 100 - logging.Info("Compression ratio: %.1f%%", ratio) - default: - logging.Info("Compression ratio: N/A") - } - logging.Info("Compression used: %s (level %d, mode %s)", stats.Compression, stats.CompressionLevel, stats.CompressionMode) - if stats.RequestedCompression != stats.Compression { - logging.Info("Requested compression: %s", stats.RequestedCompression) - } - logging.Info("Duration: %s", formatDuration(stats.Duration)) - if stats.BundleCreated { - logging.Info("Bundle path: %s", stats.ArchivePath) - logging.Info("Bundle contents: archive + checksum + metadata") - } else { - logging.Info("Archive path: %s", stats.ArchivePath) - if stats.ManifestPath != "" { - logging.Info("Manifest path: %s", stats.ManifestPath) - } - if stats.Checksum != "" { - logging.Info("Archive checksum (SHA256): %s", stats.Checksum) - } - } - fmt.Println() - - logging.Info("✓ Go backup orchestration completed") - logServerIdentityValues(serverIDValue, serverMACValue) - - if heapProfilePath != "" { - logging.Info("Heap profiling saved: %s", heapProfilePath) - } - - exitCode := stats.ExitCode - status := notify.StatusFromExitCode(exitCode) - statusLabel := strings.ToUpper(status.String()) - emoji := notify.GetStatusEmoji(status) - logging.Info("Exit status: %s %s (code=%d)", emoji, statusLabel, exitCode) - - pendingSupportStats = stats - - finalExitCode = exitCode - } else { - logging.Warning("Backup is disabled in configuration") - } - - return finalExitCode + orch = backupResult.orch + earlyErrorState = backupResult.earlyErrorState + pendingSupportStats = backupResult.supportStats + return finalize(backupResult.exitCode) } const rollbackCountdownDisplayDuration = 10 * time.Second diff --git a/internal/backup/collector.go b/internal/backup/collector.go index 4e079da0..7bd627bc 100644 --- a/internal/backup/collector.go +++ b/internal/backup/collector.go @@ -1,3 +1,4 @@ +// Package backup contains collection, archive, manifest, and optimization helpers. package backup import ( @@ -263,54 +264,79 @@ var defaultExcludePatterns = []string{ // Validate checks if the collector configuration is valid func (c *CollectorConfig) Validate() error { - // Validate exclude patterns (basic glob syntax check) + if err := c.validateExcludePatterns(); err != nil { + return err + } + + if !c.hasCollectionOptionEnabled() { + return fmt.Errorf("at least one backup option must be enabled") + } + + c.normalizePxarConcurrency() + if c.MaxPVEBackupSizeBytes < 0 { + return fmt.Errorf("MAX_PVE_BACKUP_SIZE must be >= 0") + } + c.normalizeCommandTimeouts() + if c.SystemRootPrefix != "" && !filepath.IsAbs(c.SystemRootPrefix) { + return fmt.Errorf("system root prefix must be an absolute path") + } + + return nil +} + +func (c *CollectorConfig) validateExcludePatterns() error { for i, pattern := range c.ExcludePatterns { if pattern == "" { return fmt.Errorf("exclude pattern at index %d is empty", i) } - // Test if pattern is valid glob syntax if _, err := filepath.Match(pattern, "test"); err != nil { return fmt.Errorf("invalid glob pattern at index %d: %s (error: %w)", i, pattern, err) } } + return nil +} - // At least one collection option should be enabled - hasAnyEnabled := c.BackupVMConfigs || c.BackupClusterConfig || - c.BackupPVEFirewall || c.BackupVZDumpConfig || c.BackupPVEACL || - c.BackupPVEJobs || c.BackupPVESchedules || c.BackupPVEReplication || - c.BackupPVEBackupFiles || c.BackupCephConfig || - c.BackupDatastoreConfigs || c.BackupPBSS3Endpoints || c.BackupPBSNodeConfig || - c.BackupPBSAcmeAccounts || c.BackupPBSAcmePlugins || c.BackupPBSMetricServers || - c.BackupPBSTrafficControl || c.BackupPBSNotifications || c.BackupUserConfigs || c.BackupRemoteConfigs || - c.BackupSyncJobs || c.BackupVerificationJobs || c.BackupTapeConfigs || - c.BackupPBSNetworkConfig || c.BackupPruneSchedules || c.BackupPxarFiles || - c.BackupNetworkConfigs || c.BackupAptSources || c.BackupCronJobs || - c.BackupSystemdServices || c.BackupSSLCerts || c.BackupSysctlConfig || - c.BackupKernelModules || c.BackupFirewallRules || - c.BackupInstalledPackages || c.BackupScriptDir || c.BackupCriticalFiles || - c.BackupSSHKeys || c.BackupZFSConfig || c.BackupConfigFile - - if !hasAnyEnabled { - return fmt.Errorf("at least one backup option must be enabled") +func (c *CollectorConfig) hasCollectionOptionEnabled() bool { + for _, enabled := range c.collectionOptionFlags() { + if enabled { + return true + } + } + return false +} + +func (c *CollectorConfig) collectionOptionFlags() []bool { + return []bool{ + c.BackupVMConfigs, c.BackupClusterConfig, + c.BackupPVEFirewall, c.BackupVZDumpConfig, c.BackupPVEACL, + c.BackupPVEJobs, c.BackupPVESchedules, c.BackupPVEReplication, + c.BackupPVEBackupFiles, c.BackupCephConfig, + c.BackupDatastoreConfigs, c.BackupPBSS3Endpoints, c.BackupPBSNodeConfig, + c.BackupPBSAcmeAccounts, c.BackupPBSAcmePlugins, c.BackupPBSMetricServers, + c.BackupPBSTrafficControl, c.BackupPBSNotifications, c.BackupUserConfigs, c.BackupRemoteConfigs, + c.BackupSyncJobs, c.BackupVerificationJobs, c.BackupTapeConfigs, + c.BackupPBSNetworkConfig, c.BackupPruneSchedules, c.BackupPxarFiles, + c.BackupNetworkConfigs, c.BackupAptSources, c.BackupCronJobs, + c.BackupSystemdServices, c.BackupSSLCerts, c.BackupSysctlConfig, + c.BackupKernelModules, c.BackupFirewallRules, + c.BackupInstalledPackages, c.BackupScriptDir, c.BackupCriticalFiles, + c.BackupSSHKeys, c.BackupZFSConfig, c.BackupConfigFile, } +} +func (c *CollectorConfig) normalizePxarConcurrency() { if c.PxarDatastoreConcurrency <= 0 { c.PxarDatastoreConcurrency = 3 } - if c.MaxPVEBackupSizeBytes < 0 { - return fmt.Errorf("MAX_PVE_BACKUP_SIZE must be >= 0") - } +} + +func (c *CollectorConfig) normalizeCommandTimeouts() { if c.PveshTimeoutSeconds < 0 { c.PveshTimeoutSeconds = 15 } if c.FsIoTimeoutSeconds < 0 { c.FsIoTimeoutSeconds = 30 } - if c.SystemRootPrefix != "" && !filepath.IsAbs(c.SystemRootPrefix) { - return fmt.Errorf("system root prefix must be an absolute path") - } - - return nil } // NewCollector creates a new backup collector @@ -729,19 +755,12 @@ func (c *Collector) safeCopyFile(ctx context.Context, src, dest, description str c.logger.Debug("Collecting %s: %s -> %s", description, src, dest) - info, err := os.Lstat(src) - if err != nil { - if os.IsNotExist(err) { - c.logger.Debug("%s not found: %s (skipping)", description, src) - return nil - } - c.incFilesFailed() - return fmt.Errorf("failed to stat %s: %w", src, err) + info, found, err := c.statCopySource(src, description) + if err != nil || !found { + return err } - // Check if this file should be excluded - if c.shouldExclude(src) || c.shouldExclude(dest) { - c.incFilesSkipped() + if c.shouldSkipCopy(src, dest) { return nil } @@ -751,79 +770,102 @@ func (c *Collector) safeCopyFile(ctx context.Context, src, dest, description str return nil } - // Handle symbolic links by recreating the link if info.Mode()&os.ModeSymlink != 0 { - target, err := osReadlink(src) - if err != nil { - c.incFilesFailed() - return fmt.Errorf("symlink read failed - path: %s: %w", src, err) - } + return c.copySymlinkFile(src, dest, info) + } - if err := c.ensureDir(filepath.Dir(dest)); err != nil { - c.incFilesFailed() - return err - } - c.applyDirectoryMetadataFromSource(filepath.Dir(src), filepath.Dir(dest)) + if !info.Mode().IsRegular() { + c.logger.Debug("Skipping non-regular file: %s", src) + return nil + } - // Remove existing file if present - if _, err := os.Lstat(dest); err == nil { - if err := os.Remove(dest); err != nil { - c.incFilesFailed() - return fmt.Errorf("file replacement failed - path: %s: %w", dest, err) - } - } + return c.copyRegularFile(src, dest, description, info) +} - if err := osSymlink(target, dest); err != nil { - c.incFilesFailed() - return fmt.Errorf("symlink creation failed - source: %s - target: %s - absolute: %v: %w", - src, target, filepath.IsAbs(target), err) +func (c *Collector) statCopySource(src, description string) (os.FileInfo, bool, error) { + info, err := os.Lstat(src) + if err != nil { + if os.IsNotExist(err) { + c.logger.Debug("%s not found: %s (skipping)", description, src) + return nil, false, nil } + c.incFilesFailed() + return nil, false, fmt.Errorf("failed to stat %s: %w", src, err) + } + return info, true, nil +} - c.applySymlinkOwnership(dest, info) +func (c *Collector) shouldSkipCopy(src, dest string) bool { + if c.shouldExclude(src) || c.shouldExclude(dest) { + c.incFilesSkipped() + return true + } + return false +} - c.incFilesProcessed() - c.logger.Debug("Successfully copied symlink %s -> %s", dest, target) - return nil +func (c *Collector) copySymlinkFile(src, dest string, info os.FileInfo) error { + target, err := osReadlink(src) + if err != nil { + c.incFilesFailed() + return fmt.Errorf("symlink read failed - path: %s: %w", src, err) } - if !info.Mode().IsRegular() { - // Skip non-regular files (devices, sockets, etc.) but count as processed - c.logger.Debug("Skipping non-regular file: %s", src) - return nil + if err := c.prepareCopyDestination(src, dest); err != nil { + c.incFilesFailed() + return err } - // Ensure destination directory exists - if err := c.ensureDir(filepath.Dir(dest)); err != nil { + if err := c.removeExistingSymlinkDestination(dest); err != nil { c.incFilesFailed() return err } - c.applyDirectoryMetadataFromSource(filepath.Dir(src), filepath.Dir(dest)) - // Open source file - srcFile, err := osOpen(src) - if err != nil { + if err := osSymlink(target, dest); err != nil { c.incFilesFailed() - return fmt.Errorf("failed to open %s: %w", src, err) + return fmt.Errorf("symlink creation failed - source: %s - target: %s - absolute: %v: %w", + src, target, filepath.IsAbs(target), err) } - defer srcFile.Close() - // Create destination file with a safe default mode; we'll apply the original metadata after copy. - destFile, err := osOpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) - if err != nil { + c.applySymlinkOwnership(dest, info) + c.incFilesProcessed() + c.logger.Debug("Successfully copied symlink %s -> %s", dest, target) + return nil +} + +func (c *Collector) prepareCopyDestination(src, dest string) error { + if err := c.ensureDir(filepath.Dir(dest)); err != nil { + return err + } + c.applyDirectoryMetadataFromSource(filepath.Dir(src), filepath.Dir(dest)) + return nil +} + +func (c *Collector) removeExistingSymlinkDestination(dest string) error { + if _, err := os.Lstat(dest); err == nil { + if err := os.Remove(dest); err != nil { + return fmt.Errorf("file replacement failed - path: %s: %w", dest, err) + } + } + return nil +} + +func (c *Collector) copyRegularFile(src, dest, description string, info os.FileInfo) error { + if err := c.prepareCopyDestination(src, dest); err != nil { c.incFilesFailed() - return fmt.Errorf("failed to create %s: %w", dest, err) + return err } - // Copy content - written, err := io.Copy(destFile, srcFile) - closeErr := destFile.Close() + srcFile, err := osOpen(src) if err != nil { c.incFilesFailed() - return fmt.Errorf("failed to copy %s: %w", src, err) + return fmt.Errorf("failed to open %s: %w", src, err) } - if closeErr != nil { + defer srcFile.Close() + + written, err := copyRegularFileContents(srcFile, src, dest) + if err != nil { c.incFilesFailed() - return fmt.Errorf("failed to close %s: %w", dest, closeErr) + return err } c.applyMetadata(dest, info) @@ -835,6 +877,23 @@ func (c *Collector) safeCopyFile(ctx context.Context, src, dest, description str return nil } +func copyRegularFileContents(srcFile io.Reader, src, dest string) (int64, error) { + destFile, err := osOpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return 0, fmt.Errorf("failed to create %s: %w", dest, err) + } + + written, err := io.Copy(destFile, srcFile) + closeErr := destFile.Close() + if err != nil { + return 0, fmt.Errorf("failed to copy %s: %w", src, err) + } + if closeErr != nil { + return 0, fmt.Errorf("failed to close %s: %w", dest, closeErr) + } + return written, nil +} + func (c *Collector) safeCopyDir(ctx context.Context, src, dest, description string) error { if err := ctx.Err(); err != nil { return err diff --git a/internal/orchestrator/backup_run_helpers.go b/internal/orchestrator/backup_run_helpers.go new file mode 100644 index 00000000..8591bd7a --- /dev/null +++ b/internal/orchestrator/backup_run_helpers.go @@ -0,0 +1,321 @@ +// Package orchestrator coordinates backup, restore, decrypt, and notification workflows. +package orchestrator + +import ( + "context" + "errors" + "fmt" + "path/filepath" + + "filippo.io/age" + "github.com/tis24dev/proxsave/internal/backup" + "github.com/tis24dev/proxsave/internal/metrics" + "github.com/tis24dev/proxsave/internal/types" +) + +func (o *Orchestrator) shouldExportBackupMetrics(stats *BackupStats) bool { + return stats != nil && o.cfg != nil && o.cfg.MetricsEnabled && !o.dryRun +} + +func (o *Orchestrator) ensureBackupStatsTiming(stats *BackupStats) { + if stats.EndTime.IsZero() { + stats.EndTime = o.now() + } + if stats.Duration == 0 && !stats.StartTime.IsZero() { + stats.Duration = stats.EndTime.Sub(stats.StartTime) + } +} + +func backupMetricsExitCode(stats *BackupStats, runErr error) int { + if runErr == nil { + if stats.ExitCode == 0 { + return types.ExitSuccess.Int() + } + return stats.ExitCode + } + + var backupErr *BackupError + if errors.As(runErr, &backupErr) { + return backupErr.Code.Int() + } + return types.ExitGenericError.Int() +} + +func (o *Orchestrator) exportPrometheusBackupMetrics(stats *BackupStats) { + m := stats.toPrometheusMetrics() + if m == nil { + return + } + + exporter := metrics.NewPrometheusExporter(o.cfg.MetricsPath, o.logger) + if err := exporter.Export(m); err != nil { + o.logger.Warning("Failed to export Prometheus metrics: %v", err) + } +} + +func (o *Orchestrator) parseFailedBackupLogCounts(stats *BackupStats) { + if stats.LogFilePath == "" { + o.logger.Debug("No log file path specified, error/warning counts will be 0 (failure path)") + return + } + + o.logger.Debug("Parsing log file for error/warning counts after failure: %s", stats.LogFilePath) + _, errorCount, warningCount := ParseLogCounts(stats.LogFilePath, 0) + stats.ErrorCount = errorCount + stats.WarningCount = warningCount + if errorCount > 0 || warningCount > 0 { + o.logger.Debug("Found %d errors and %d warnings in log file (failure path)", errorCount, warningCount) + } +} + +func backupFailureExitCode(runErr error) int { + var backupErr *BackupError + if errors.As(runErr, &backupErr) { + return backupErr.Code.Int() + } + return types.ExitBackupError.Int() +} + +func (o *Orchestrator) buildBackupCollectorConfig() *backup.CollectorConfig { + collectorConfig := backup.GetDefaultCollectorConfig() + collectorConfig.ExcludePatterns = append([]string(nil), o.excludePatterns...) + if o.cfg == nil { + return collectorConfig + } + + applyCollectorOverrides(collectorConfig, o.cfg) + if len(o.cfg.BackupBlacklist) > 0 { + collectorConfig.ExcludePatterns = append(collectorConfig.ExcludePatterns, o.cfg.BackupBlacklist...) + } + return collectorConfig +} + +func (o *Orchestrator) runBackupCollector(run *backupRunContext, workspace *backupWorkspace, collectorConfig *backup.CollectorConfig) (*backup.Collector, error) { + collector := backup.NewCollectorWithDeps(o.logger, collectorConfig, workspace.tempDir, run.proxmoxType, o.dryRun, o.collectorDeps()) + o.logger.Debug("Starting collector run (type=%s)", run.proxmoxType) + if err := collector.CollectAll(run.ctx); err != nil { + return nil, err + } + return collector, nil +} + +func (o *Orchestrator) applyBackupCollectionStats(stats *BackupStats, collStats *backup.CollectionStats, collector *backup.Collector) { + stats.FilesCollected = int(collStats.FilesProcessed) + stats.FilesFailed = int(collStats.FilesFailed) + stats.FilesNotFound = int(collStats.FilesNotFound) + stats.DirsCreated = int(collStats.DirsCreated) + stats.BytesCollected = collStats.BytesCollected + stats.FilesIncluded = int(collStats.FilesProcessed) + stats.FilesMissing = int(collStats.FilesNotFound) + stats.UncompressedSize = collStats.BytesCollected + if stats.ProxmoxType.SupportsPVE() { + stats.ClusterMode = standaloneClusterMode(collector) + } +} + +func standaloneClusterMode(collector *backup.Collector) string { + if collector.IsClusteredPVE() { + return "cluster" + } + return "standalone" +} + +func (o *Orchestrator) writeBackupCollectionMetadata(tempDir, hostname string, stats *BackupStats, collector *backup.Collector) { + if err := o.writeBackupMetadata(tempDir, stats); err != nil { + o.logger.Debug("Failed to write backup metadata: %v", err) + } + if err := collector.WriteManifest(hostname); err != nil { + o.logger.Debug("Failed to write backup manifest: %v", err) + } +} + +func (o *Orchestrator) logBackupCollectionSummary(collStats *backup.CollectionStats) { + o.logger.Info("Collection completed: %d files (%s), %d failed, %d dirs created", + collStats.FilesProcessed, + backup.FormatBytes(collStats.BytesCollected), + collStats.FilesFailed, + collStats.DirsCreated) +} + +func (o *Orchestrator) applyBackupOptimizations(ctx context.Context, tempDir string) error { + if !o.optimizationCfg.Enabled() { + o.logger.Debug("Skipping optimization step (all features disabled)") + return nil + } + + fmt.Println() + o.logger.Step("Backup optimizations on collected data") + if err := backup.ApplyOptimizations(ctx, o.logger, tempDir, o.optimizationCfg); err != nil { + o.logger.Warning("Backup optimizations completed with warnings: %v", err) + } + return nil +} + +func estimatedBackupSizeGB(bytesCollected int64) float64 { + estimatedSizeGB := float64(bytesCollected) / (1024.0 * 1024.0 * 1024.0) + if estimatedSizeGB < 0.001 { + return 0.001 + } + return estimatedSizeGB +} + +func backupDiskValidationError(message string, diskErr error) error { + errMsg := message + if errMsg == "" && diskErr != nil { + errMsg = diskErr.Error() + } + if errMsg == "" { + errMsg = "insufficient disk space" + } + if diskErr == nil { + diskErr = errors.New(errMsg) + } + return &BackupError{ + Phase: "disk", + Err: fmt.Errorf("disk space validation failed: %w", diskErr), + Code: types.ExitDiskSpaceError, + } +} + +func (o *Orchestrator) buildBackupArchiverConfig(run *backupRunContext, ageRecipients []age.Recipient) *backup.ArchiverConfig { + return BuildArchiverConfig( + o.compressionType, + run.normalizedLevel, + o.compressionThreads, + o.compressionMode, + o.dryRun, + o.cfg != nil && o.cfg.EncryptArchive, + ageRecipients, + run.collectorConfig.ExcludePatterns, + ) +} + +func (o *Orchestrator) applyBackupArchiverStats(stats *BackupStats, archiver *backup.Archiver) { + stats.Compression = archiver.ResolveCompression() + stats.CompressionLevel = archiver.CompressionLevel() + stats.CompressionMode = archiver.CompressionMode() + stats.CompressionThreads = archiver.CompressionThreads() +} + +func (o *Orchestrator) backupArchivePath(run *backupRunContext, archiver *backup.Archiver) string { + archiveBasename := fmt.Sprintf("%s-backup-%s", run.hostname, run.timestamp) + return filepath.Join(o.backupPath, archiveBasename+archiver.GetArchiveExtension()) +} + +func (o *Orchestrator) logResolvedBackupCompression(stats *BackupStats) { + if stats.RequestedCompression != stats.Compression { + o.logger.Info("Using %s compression (requested %s)", stats.Compression, stats.RequestedCompression) + } +} + +func createBackupArchiveFile(ctx context.Context, archiver *backup.Archiver, tempDir, archivePath string) error { + if err := archiver.CreateArchive(ctx, tempDir, archivePath); err != nil { + return backupArchiveCreationError(err) + } + return nil +} + +func backupArchiveCreationError(err error) error { + phase := "archive" + code := types.ExitArchiveError + var compressionErr *backup.CompressionError + if errors.As(err, &compressionErr) { + phase = "compression" + code = types.ExitCompressionError + } + return &BackupError{Phase: phase, Err: err, Code: code} +} + +func (o *Orchestrator) skipDryRunArtifactVerification(stats *BackupStats, artifacts *backupArtifacts) error { + fmt.Println() + o.logStep(4, "Verification skipped (dry run mode)") + o.logger.Info("[DRY RUN] Would create archive: %s", artifacts.archivePath) + stats.EndTime = o.now() + return nil +} + +func (o *Orchestrator) recordArchiveSize(stats *BackupStats, artifacts *backupArtifacts) { + size, err := artifacts.archiver.GetArchiveSize(artifacts.archivePath) + if err != nil { + o.logger.Warning("Failed to get archive size: %v", err) + return + } + + stats.ArchiveSize = size + stats.CompressedSize = size + stats.updateCompressionMetrics() + o.logger.Debug("Archive created: %s (%s)", artifacts.archivePath, backup.FormatBytes(size)) +} + +func (o *Orchestrator) generateArchiveChecksum(ctx context.Context, archivePath string) (string, error) { + checksum, err := backup.GenerateChecksum(ctx, o.logger, archivePath) + if err != nil { + return "", &BackupError{ + Phase: "verification", + Err: fmt.Errorf("checksum generation failed: %w", err), + Code: types.ExitVerificationError, + } + } + return checksum, nil +} + +func (o *Orchestrator) writeArchiveChecksum(workspace *backupWorkspace, artifacts *backupArtifacts, checksum string) { + checksumContent := fmt.Sprintf("%s %s\n", checksum, filepath.Base(artifacts.archivePath)) + if err := workspace.fs.WriteFile(artifacts.checksumPath, []byte(checksumContent), 0640); err != nil { + o.logger.Warning("Failed to write checksum file %s: %v", artifacts.checksumPath, err) + } else { + o.logger.Debug("Checksum file written to %s", artifacts.checksumPath) + } +} + +func (o *Orchestrator) writeArchiveManifest(run *backupRunContext, artifacts *backupArtifacts, checksum string) error { + manifestPath := artifacts.archivePath + ".manifest.json" + manifest := o.newArchiveManifest(run.stats, artifacts.archivePath, checksum) + if err := backup.CreateManifest(run.ctx, o.logger, manifest, manifestPath); err != nil { + return &BackupError{ + Phase: "verification", + Err: fmt.Errorf("manifest creation failed: %w", err), + Code: types.ExitVerificationError, + } + } + run.stats.ManifestPath = manifestPath + artifacts.manifestPath = manifestPath + return nil +} + +func (o *Orchestrator) newArchiveManifest(stats *BackupStats, archivePath, checksum string) *backup.Manifest { + return &backup.Manifest{ + ArchivePath: archivePath, + ArchiveSize: stats.ArchiveSize, + SHA256: checksum, + CreatedAt: stats.Timestamp, + CompressionType: string(stats.Compression), + CompressionLevel: stats.CompressionLevel, + CompressionMode: stats.CompressionMode, + ProxmoxType: string(stats.ProxmoxType), + ProxmoxTargets: append([]string(nil), stats.ProxmoxTargets...), + ProxmoxVersion: stats.ProxmoxVersion, + PVEVersion: stats.PVEVersion, + PBSVersion: stats.PBSVersion, + Hostname: stats.Hostname, + ScriptVersion: stats.ScriptVersion, + EncryptionMode: o.archiveEncryptionMode(), + ClusterMode: stats.ClusterMode, + } +} + +func (o *Orchestrator) archiveEncryptionMode() string { + if o.cfg != nil && o.cfg.EncryptArchive { + return "age" + } + return "none" +} + +func (o *Orchestrator) writeLegacyMetadataAlias(workspace *backupWorkspace, artifacts *backupArtifacts) { + metadataAlias := artifacts.archivePath + ".metadata" + if err := copyFile(workspace.fs, artifacts.manifestPath, metadataAlias); err != nil { + o.logger.Warning("Failed to write legacy metadata file %s: %v", metadataAlias, err) + } else { + o.logger.Debug("Legacy metadata file written to %s", metadataAlias) + } +} diff --git a/internal/orchestrator/backup_run_phases.go b/internal/orchestrator/backup_run_phases.go new file mode 100644 index 00000000..d2e5d47e --- /dev/null +++ b/internal/orchestrator/backup_run_phases.go @@ -0,0 +1,378 @@ +// Package orchestrator coordinates backup, restore, decrypt, and notification workflows. +package orchestrator + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/tis24dev/proxsave/internal/backup" + "github.com/tis24dev/proxsave/internal/environment" + "github.com/tis24dev/proxsave/internal/types" +) + +type backupRunContext struct { + ctx context.Context + envInfo *environment.EnvironmentInfo + hostname string + proxmoxType types.ProxmoxType + startTime time.Time + timestamp string + normalizedLevel int + collectorConfig *backup.CollectorConfig + stats *BackupStats +} + +type backupWorkspace struct { + registry *TempDirRegistry + fs FS + tempRoot string + tempDir string +} + +type backupArtifacts struct { + archiver *backup.Archiver + archivePath string + checksumPath string + manifestPath string + bundlePath string +} + +func (o *Orchestrator) newBackupRunContext(ctx context.Context, envInfo *environment.EnvironmentInfo, hostname string) *backupRunContext { + if ctx == nil { + ctx = context.Background() + } + if envInfo == nil { + envInfo = o.envInfo + } else { + o.SetEnvironmentInfo(envInfo) + } + + pType := types.ProxmoxUnknown + if envInfo != nil { + pType = envInfo.Type + } + + startTime := o.startTime + if startTime.IsZero() { + startTime = o.now() + o.startTime = startTime + } + + return &backupRunContext{ + ctx: ctx, + envInfo: envInfo, + hostname: hostname, + proxmoxType: pType, + startTime: startTime, + timestamp: startTime.Format("20060102-150405"), + normalizedLevel: normalizeCompressionLevel(o.compressionType, o.compressionLevel), + } +} + +func (o *Orchestrator) initBackupRun(run *backupRunContext) *BackupStats { + fmt.Println() + o.logStep(1, "Initializing backup statistics and temporary workspace") + run.stats = InitializeBackupStats( + run.hostname, + run.envInfo, + o.version, + run.startTime, + o.cfg, + o.compressionType, + o.compressionMode, + run.normalizedLevel, + o.compressionThreads, + o.backupPath, + o.serverID, + o.serverMAC, + ) + if logFile := o.logger.GetLogFilePath(); logFile != "" { + run.stats.LogFilePath = logFile + } + if o.versionUpdateAvailable || o.updateCurrentVersion != "" || o.updateLatestVersion != "" { + run.stats.NewVersionAvailable = o.versionUpdateAvailable + run.stats.CurrentVersion = o.updateCurrentVersion + run.stats.LatestVersion = o.updateLatestVersion + } + return run.stats +} + +func (o *Orchestrator) exportBackupMetrics(run *backupRunContext, runErr error) { + stats := run.stats + if !o.shouldExportBackupMetrics(stats) { + return + } + + o.ensureBackupStatsTiming(stats) + stats.ExitCode = backupMetricsExitCode(stats, runErr) + o.exportPrometheusBackupMetrics(stats) +} + +func (o *Orchestrator) finalizeFailedBackupStats(run *backupRunContext, runErr error) { + stats := run.stats + if runErr == nil || stats == nil { + return + } + + o.ensureBackupStatsTiming(stats) + o.parseFailedBackupLogCounts(stats) + stats.ExitCode = backupFailureExitCode(runErr) +} + +func (o *Orchestrator) prepareBackupWorkspace(run *backupRunContext, workspace *backupWorkspace) error { + o.logger.Debug("Creating temporary directory for collection output") + workspace.tempRoot = filepath.Join("/tmp", "proxsave") + if err := workspace.fs.MkdirAll(workspace.tempRoot, 0o755); err != nil { + return fmt.Errorf("Temp directory creation failed - path: %s: %w", workspace.tempRoot, err) + } + + tempDir, err := workspace.fs.MkdirTemp(workspace.tempRoot, fmt.Sprintf("proxsave-%s-%s-", run.hostname, run.timestamp)) + if err != nil { + return fmt.Errorf("failed to create temporary directory: %w", err) + } + workspace.tempDir = tempDir + + if o.dryRun { + o.logger.Info("[DRY RUN] Temporary directory would be: %s", workspace.tempDir) + } else { + o.logger.Debug("Using temporary directory: %s", workspace.tempDir) + } + return nil +} + +func (o *Orchestrator) cleanupBackupWorkspace(workspace *backupWorkspace) { + if workspace.registry == nil { + if cleanupErr := workspace.fs.RemoveAll(workspace.tempDir); cleanupErr != nil { + o.logger.Warning("Failed to remove temp directory %s: %v", workspace.tempDir, cleanupErr) + } + return + } + o.logger.Debug("Temporary workspace preserved at %s (will be removed at the next startup)", workspace.tempDir) +} + +func (o *Orchestrator) markBackupWorkspace(workspace *backupWorkspace) error { + markerPath := filepath.Join(workspace.tempDir, ".proxsave-marker") + markerContent := fmt.Sprintf( + "Created by PID %d on %s UTC\n", + os.Getpid(), + o.now().UTC().Format("2006-01-02 15:04:05"), + ) + return workspace.fs.WriteFile(markerPath, []byte(markerContent), 0600) +} + +func (o *Orchestrator) registerBackupWorkspace(workspace *backupWorkspace) { + if workspace.registry == nil { + return + } + if err := workspace.registry.Register(workspace.tempDir); err != nil { + o.logger.Debug("Failed to register temp directory %s: %v", workspace.tempDir, err) + } +} + +func (o *Orchestrator) collectBackupData(run *backupRunContext, workspace *backupWorkspace) error { + fmt.Println() + o.logStep(2, "Collection of configuration files and optimizations") + o.logger.Info("Collecting configuration files...") + o.logger.Debug("Collector dry-run=%v excludePatterns=%d", o.dryRun, len(o.excludePatterns)) + + collectorConfig := o.buildBackupCollectorConfig() + run.collectorConfig = collectorConfig + + if err := collectorConfig.Validate(); err != nil { + return &BackupError{Phase: "config", Err: err, Code: types.ExitConfigError} + } + + collector, err := o.runBackupCollector(run, workspace, collectorConfig) + if err != nil { + return &BackupError{Phase: "collection", Err: err, Code: types.ExitCollectionError} + } + + collStats := collector.GetStats() + o.applyBackupCollectionStats(run.stats, collStats, collector) + o.writeBackupCollectionMetadata(workspace.tempDir, run.hostname, run.stats, collector) + o.logBackupCollectionSummary(collStats) + + if err := o.validateCollectedBackupSize(run.stats); err != nil { + return err + } + + return o.applyBackupOptimizations(run.ctx, workspace.tempDir) +} + +func (o *Orchestrator) validateCollectedBackupSize(stats *BackupStats) error { + if o.checker == nil || stats.BytesCollected <= 0 { + return nil + } + + o.logger.Debug("Running disk-space validation for estimated data size") + result := o.checker.CheckDiskSpaceForEstimate(estimatedBackupSizeGB(stats.BytesCollected)) + if result.Passed { + o.logger.Debug("Disk check passed: %s", result.Message) + return nil + } + + return backupDiskValidationError(result.Message, result.Error) +} + +func (o *Orchestrator) createBackupArchive(run *backupRunContext, workspace *backupWorkspace) (*backupArtifacts, error) { + fmt.Println() + o.logStep(3, "Creation of compressed archive") + o.logger.Info("Creating compressed archive...") + o.logger.Debug("Archiver configuration: type=%s level=%d mode=%s threads=%d", + o.compressionType, run.normalizedLevel, o.compressionMode, o.compressionThreads) + + ageRecipients, err := o.prepareAgeRecipients(run.ctx) + if err != nil { + return nil, &BackupError{Phase: "config", Err: err, Code: types.ExitConfigError} + } + + archiverConfig := o.buildBackupArchiverConfig(run, ageRecipients) + if err := archiverConfig.Validate(); err != nil { + return nil, &BackupError{Phase: "config", Err: err, Code: types.ExitConfigError} + } + + archiver := backup.NewArchiver(o.logger, archiverConfig) + o.applyBackupArchiverStats(run.stats, archiver) + archivePath := o.backupArchivePath(run, archiver) + o.logResolvedBackupCompression(run.stats) + + if err := createBackupArchiveFile(run.ctx, archiver, workspace.tempDir, archivePath); err != nil { + return nil, err + } + + run.stats.ArchivePath = archivePath + return &backupArtifacts{ + archiver: archiver, + archivePath: archivePath, + checksumPath: archivePath + ".sha256", + }, nil +} + +func (o *Orchestrator) verifyAndWriteBackupArtifacts(run *backupRunContext, workspace *backupWorkspace, artifacts *backupArtifacts) error { + stats := run.stats + if o.dryRun { + return o.skipDryRunArtifactVerification(stats, artifacts) + } + + fmt.Println() + o.logStep(4, "Verification of archive and metadata generation") + o.recordArchiveSize(stats, artifacts) + + if err := artifacts.archiver.VerifyArchive(run.ctx, artifacts.archivePath); err != nil { + return &BackupError{Phase: "verification", Err: err, Code: types.ExitVerificationError} + } + + checksum, err := o.generateArchiveChecksum(run.ctx, artifacts.archivePath) + if err != nil { + return err + } + stats.Checksum = checksum + + o.writeArchiveChecksum(workspace, artifacts, checksum) + if err := o.writeArchiveManifest(run, artifacts, checksum); err != nil { + return err + } + o.writeLegacyMetadataAlias(workspace, artifacts) + return nil +} + +func (o *Orchestrator) bundleBackupArtifacts(run *backupRunContext, workspace *backupWorkspace, artifacts *backupArtifacts) error { + if o.dryRun { + return nil + } + + bundleEnabled := o.cfg != nil && o.cfg.BundleAssociatedFiles + if !bundleEnabled { + fmt.Println() + o.logger.Skip("Bundling disabled") + run.stats.EndTime = o.now() + o.logger.Info("✓ Archive created and verified") + return nil + } + + fmt.Println() + o.logStep(5, "Bundling of archive, checksum and metadata") + o.logger.Debug("Bundling enabled: creating bundle from %s", filepath.Base(artifacts.archivePath)) + bundlePath, err := o.createBundle(run.ctx, artifacts.archivePath) + if err != nil { + return &BackupError{ + Phase: "archive", + Err: fmt.Errorf("bundle creation failed: %w", err), + Code: types.ExitArchiveError, + } + } + + if err := o.removeAssociatedFiles(artifacts.archivePath); err != nil { + o.logger.Warning("Failed to remove raw files after bundling: %v", err) + } else { + o.logger.Debug("Removed raw tar/checksum/metadata after bundling") + } + + stats := run.stats + if info, err := workspace.fs.Stat(bundlePath); err == nil { + stats.ArchiveSize = info.Size() + stats.CompressedSize = info.Size() + stats.updateCompressionMetrics() + } + stats.ArchivePath = bundlePath + stats.ManifestPath = "" + stats.BundleCreated = true + artifacts.bundlePath = bundlePath + artifacts.archivePath = bundlePath + o.logger.Debug("Bundle ready: %s", filepath.Base(bundlePath)) + + stats.EndTime = o.now() + o.logger.Info("✓ Archive created and verified") + return nil +} + +func (o *Orchestrator) finalizeBackupStats(run *backupRunContext) { + stats := run.stats + stats.Duration = stats.EndTime.Sub(stats.StartTime) + + if stats.LogFilePath != "" { + o.logger.Debug("Parsing log file for error/warning counts: %s", stats.LogFilePath) + _, errorCount, warningCount := ParseLogCounts(stats.LogFilePath, 0) + stats.ErrorCount = errorCount + stats.WarningCount = warningCount + if errorCount > 0 || warningCount > 0 { + o.logger.Debug("Found %d errors and %d warnings in log file", errorCount, warningCount) + } + } else { + o.logger.Debug("No log file path specified, error/warning counts will be 0") + } + + switch { + case stats.ErrorCount > 0: + stats.ExitCode = types.ExitBackupError.Int() + case stats.WarningCount > 0: + stats.ExitCode = types.ExitGenericError.Int() + default: + stats.ExitCode = types.ExitSuccess.Int() + } + o.logger.Debug("Aggregated exit code based on log analysis: %d", stats.ExitCode) +} + +func (o *Orchestrator) dispatchBackupArtifacts(run *backupRunContext) error { + if len(o.storageTargets) == 0 { + fmt.Println() + o.logStep(6, "No storage targets registered - skipping") + } else if o.dryRun { + fmt.Println() + o.logStep(6, "Storage dispatch skipped (dry run mode)") + } else { + fmt.Println() + o.logStep(6, "Dispatching archive to %d storage target(s)", len(o.storageTargets)) + o.logGlobalRetentionPolicy() + } + + if o.dryRun { + return nil + } + + o.logger.Debug("Dispatching archive to %d storage targets", len(o.storageTargets)) + return o.dispatchPostBackup(run.ctx, run.stats) +} diff --git a/internal/orchestrator/decrypt_test.go b/internal/orchestrator/decrypt_test.go index ab6a4b81..a952e3b0 100644 --- a/internal/orchestrator/decrypt_test.go +++ b/internal/orchestrator/decrypt_test.go @@ -564,7 +564,12 @@ func createTestBundle(t *testing.T, entries []bundleEntry) string { t.Helper() dir := t.TempDir() bundlePath := filepath.Join(dir, "bundle.tar") + createTestBundleAt(t, bundlePath, entries) + return bundlePath +} +func createTestBundleAt(t *testing.T, bundlePath string, entries []bundleEntry) { + t.Helper() f, err := os.Create(bundlePath) if err != nil { t.Fatalf("create bundle: %v", err) @@ -588,7 +593,88 @@ func createTestBundle(t *testing.T, entries []bundleEntry) string { if err := tw.Close(); err != nil { t.Fatalf("close tar writer: %v", err) } - return bundlePath +} + +func createPlainBackupBundle(t *testing.T, bundlePath string, archiveData []byte, manifest backup.Manifest, includeChecksum bool) { + t.Helper() + metaJSON, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + + entries := []bundleEntry{ + {name: "backup.tar.xz", data: archiveData}, + {name: "backup.metadata", data: metaJSON}, + } + if includeChecksum { + entries = append(entries, bundleEntry{ + name: "backup.sha256", + data: []byte(checksumLineForBytes("backup.tar.xz", archiveData)), + }) + } + createTestBundleAt(t, bundlePath, entries) +} + +func useRestoreFS(t *testing.T, fs FS) { + t.Helper() + orig := restoreFS + restoreFS = fs + t.Cleanup(func() { restoreFS = orig }) +} + +type rawArtifactFixture struct { + candidate *backupCandidate + workDir string +} + +func createRawArtifactFixture(t *testing.T, includeMetadata bool, checksumContent string) rawArtifactFixture { + t.Helper() + srcDir := t.TempDir() + + archivePath := filepath.Join(srcDir, "backup.tar.xz") + if err := os.WriteFile(archivePath, []byte("archive data"), 0o644); err != nil { + t.Fatalf("write archive: %v", err) + } + + metadataPath := "/nonexistent/backup.metadata" + if includeMetadata { + metadataPath = filepath.Join(srcDir, "backup.metadata") + if err := os.WriteFile(metadataPath, []byte("{}"), 0o644); err != nil { + t.Fatalf("write metadata: %v", err) + } + } + + checksumPath := "" + if checksumContent != "" { + checksumPath = filepath.Join(srcDir, "backup.sha256") + if err := os.WriteFile(checksumPath, []byte(checksumContent), 0o644); err != nil { + t.Fatalf("write checksum: %v", err) + } + } + + return rawArtifactFixture{ + candidate: &backupCandidate{ + RawArchivePath: archivePath, + RawMetadataPath: metadataPath, + RawChecksumPath: checksumPath, + }, + workDir: t.TempDir(), + } +} + +func plainBundleCandidate(path string, manifest *backup.Manifest) *backupCandidate { + return &backupCandidate{ + Source: sourceBundle, + BundlePath: path, + Manifest: manifest, + } +} + +func preparePlainBundleTestInput(path string, manifest *backup.Manifest) (*backupCandidate, context.Context, *bufio.Reader, *logging.Logger) { + return plainBundleCandidate(path, manifest), + context.Background(), + bufio.NewReader(strings.NewReader("")), + logging.New(types.LogLevelError, false) } func TestEnsureWritablePath(t *testing.T) { @@ -1913,34 +1999,10 @@ func TestMoveFileSafe_SameDevice(t *testing.T) { // ===================================== func TestCopyRawArtifactsToWorkdir_Success(t *testing.T) { - origFS := restoreFS - restoreFS = osFS{} - t.Cleanup(func() { restoreFS = origFS }) + useRestoreFS(t, osFS{}) - srcDir := t.TempDir() - workDir := t.TempDir() - - // Create source files - archivePath := filepath.Join(srcDir, "backup.tar.xz") - if err := os.WriteFile(archivePath, []byte("archive data"), 0o644); err != nil { - t.Fatalf("write archive: %v", err) - } - metadataPath := filepath.Join(srcDir, "backup.metadata") - if err := os.WriteFile(metadataPath, []byte("{}"), 0o644); err != nil { - t.Fatalf("write metadata: %v", err) - } - checksumPath := filepath.Join(srcDir, "backup.sha256") - if err := os.WriteFile(checksumPath, []byte("checksum"), 0o644); err != nil { - t.Fatalf("write checksum: %v", err) - } - - cand := &backupCandidate{ - RawArchivePath: archivePath, - RawMetadataPath: metadataPath, - RawChecksumPath: checksumPath, - } - - staged, err := copyRawArtifactsToWorkdir(context.Background(), cand, workDir) + fixture := createRawArtifactFixture(t, true, "checksum") + staged, err := copyRawArtifactsToWorkdir(context.Background(), fixture.candidate, fixture.workDir) if err != nil { t.Fatalf("copyRawArtifactsToWorkdir error: %v", err) } @@ -1950,9 +2012,7 @@ func TestCopyRawArtifactsToWorkdir_Success(t *testing.T) { } func TestCopyRawArtifactsToWorkdir_ArchiveError(t *testing.T) { - origFS := restoreFS - restoreFS = osFS{} - t.Cleanup(func() { restoreFS = origFS }) + useRestoreFS(t, osFS{}) cand := &backupCandidate{ RawArchivePath: "/nonexistent/archive.tar.xz", @@ -1970,26 +2030,11 @@ func TestCopyRawArtifactsToWorkdir_ArchiveError(t *testing.T) { } func TestCopyRawArtifactsToWorkdir_MetadataError(t *testing.T) { - origFS := restoreFS - restoreFS = osFS{} - t.Cleanup(func() { restoreFS = origFS }) - - srcDir := t.TempDir() - workDir := t.TempDir() - - // Create only archive, no metadata - archivePath := filepath.Join(srcDir, "backup.tar.xz") - if err := os.WriteFile(archivePath, []byte("archive data"), 0o644); err != nil { - t.Fatalf("write archive: %v", err) - } + useRestoreFS(t, osFS{}) - cand := &backupCandidate{ - RawArchivePath: archivePath, - RawMetadataPath: "/nonexistent/backup.metadata", - RawChecksumPath: "/nonexistent/backup.sha256", - } - - _, err := copyRawArtifactsToWorkdir(context.Background(), cand, workDir) + fixture := createRawArtifactFixture(t, false, "") + fixture.candidate.RawChecksumPath = "/nonexistent/backup.sha256" + _, err := copyRawArtifactsToWorkdir(context.Background(), fixture.candidate, fixture.workDir) if err == nil { t.Fatal("expected error for nonexistent metadata") } @@ -1999,30 +2044,11 @@ func TestCopyRawArtifactsToWorkdir_MetadataError(t *testing.T) { } func TestCopyRawArtifactsToWorkdir_ChecksumError(t *testing.T) { - origFS := restoreFS - restoreFS = osFS{} - t.Cleanup(func() { restoreFS = origFS }) - - srcDir := t.TempDir() - workDir := t.TempDir() - - // Create archive and metadata, no checksum - archivePath := filepath.Join(srcDir, "backup.tar.xz") - if err := os.WriteFile(archivePath, []byte("archive data"), 0o644); err != nil { - t.Fatalf("write archive: %v", err) - } - metadataPath := filepath.Join(srcDir, "backup.metadata") - if err := os.WriteFile(metadataPath, []byte("{}"), 0o644); err != nil { - t.Fatalf("write metadata: %v", err) - } - - cand := &backupCandidate{ - RawArchivePath: archivePath, - RawMetadataPath: metadataPath, - RawChecksumPath: "/nonexistent/backup.sha256", - } + useRestoreFS(t, osFS{}) - staged, err := copyRawArtifactsToWorkdir(context.Background(), cand, workDir) + fixture := createRawArtifactFixture(t, true, "") + fixture.candidate.RawChecksumPath = "/nonexistent/backup.sha256" + staged, err := copyRawArtifactsToWorkdir(context.Background(), fixture.candidate, fixture.workDir) if err != nil { t.Fatalf("expected checksum to be optional, got error: %v", err) } @@ -2532,30 +2558,10 @@ func TestInspectRcloneMetadataManifest_RcloneFails(t *testing.T) { // ===================================== func TestCopyRawArtifactsToWorkdir_ContextWorks(t *testing.T) { - origFS := restoreFS - restoreFS = osFS{} - t.Cleanup(func() { restoreFS = origFS }) - - srcDir := t.TempDir() - workDir := t.TempDir() - - // Create source files - archivePath := filepath.Join(srcDir, "backup.tar.xz") - if err := os.WriteFile(archivePath, []byte("archive data"), 0o644); err != nil { - t.Fatalf("write archive: %v", err) - } - metadataPath := filepath.Join(srcDir, "backup.metadata") - if err := os.WriteFile(metadataPath, []byte("{}"), 0o644); err != nil { - t.Fatalf("write metadata: %v", err) - } + useRestoreFS(t, osFS{}) - cand := &backupCandidate{ - RawArchivePath: archivePath, - RawMetadataPath: metadataPath, - RawChecksumPath: "", - } - - staged, err := copyRawArtifactsToWorkdirWithLogger(context.TODO(), cand, workDir, nil) + fixture := createRawArtifactFixture(t, true, "") + staged, err := copyRawArtifactsToWorkdirWithLogger(context.TODO(), fixture.candidate, fixture.workDir, nil) if err != nil { t.Fatalf("copyRawArtifactsToWorkdirWithLogger error: %v", err) } @@ -3302,34 +3308,10 @@ func TestInspectRcloneBundleManifest_StartError(t *testing.T) { } func TestCopyRawArtifactsToWorkdir_WithChecksum(t *testing.T) { - origFS := restoreFS - restoreFS = osFS{} - t.Cleanup(func() { restoreFS = origFS }) - - srcDir := t.TempDir() - workDir := t.TempDir() - - // Create source files including checksum - archivePath := filepath.Join(srcDir, "backup.tar.xz") - if err := os.WriteFile(archivePath, []byte("archive data"), 0o644); err != nil { - t.Fatalf("write archive: %v", err) - } - metadataPath := filepath.Join(srcDir, "backup.metadata") - if err := os.WriteFile(metadataPath, []byte("{}"), 0o644); err != nil { - t.Fatalf("write metadata: %v", err) - } - checksumPath := filepath.Join(srcDir, "backup.sha256") - if err := os.WriteFile(checksumPath, []byte("checksum backup.tar.xz"), 0o644); err != nil { - t.Fatalf("write checksum: %v", err) - } + useRestoreFS(t, osFS{}) - cand := &backupCandidate{ - RawArchivePath: archivePath, - RawMetadataPath: metadataPath, - RawChecksumPath: checksumPath, - } - - staged, err := copyRawArtifactsToWorkdirWithLogger(context.Background(), cand, workDir, nil) + fixture := createRawArtifactFixture(t, true, "checksum backup.tar.xz") + staged, err := copyRawArtifactsToWorkdirWithLogger(context.Background(), fixture.candidate, fixture.workDir, nil) if err != nil { t.Fatalf("copyRawArtifactsToWorkdirWithLogger error: %v", err) } @@ -3710,26 +3692,9 @@ func TestSelectDecryptCandidate_RequireEncryptedAllPlain(t *testing.T) { // Create a plain backup bundle (must have .bundle.tar suffix) bundlePath := filepath.Join(backupDir, "backup-2024-01-01.bundle.tar") - bundleFile, _ := os.Create(bundlePath) - tw := tar.NewWriter(bundleFile) - - // Add archive (plain, no .age extension) archiveData := []byte("archive content") - tw.WriteHeader(&tar.Header{Name: "backup.tar.xz", Size: int64(len(archiveData)), Mode: 0o640}) - tw.Write(archiveData) - - // Add metadata with encryption=none manifest := backup.Manifest{EncryptionMode: "none", Hostname: "test", CreatedAt: time.Now(), ArchivePath: "backup.tar.xz"} - metaJSON, _ := json.Marshal(manifest) - tw.WriteHeader(&tar.Header{Name: "backup.metadata", Size: int64(len(metaJSON)), Mode: 0o640}) - tw.Write(metaJSON) - - // Add checksum - checksum := checksumLineForBytes("backup.tar.xz", archiveData) - tw.WriteHeader(&tar.Header{Name: "backup.sha256", Size: int64(len(checksum)), Mode: 0o640}) - tw.Write(checksum) - tw.Close() - bundleFile.Close() + createPlainBackupBundle(t, bundlePath, archiveData, manifest, true) cfg := &config.Config{ BackupPath: backupDir, @@ -3821,23 +3786,9 @@ exit 1 // Bundle must have .bundle.tar suffix to be discovered bundlePath := filepath.Join(backupDir, "backup.bundle.tar") - bundleFile, _ := os.Create(bundlePath) - tw := tar.NewWriter(bundleFile) - archiveData := []byte("archive content") - tw.WriteHeader(&tar.Header{Name: "backup.tar.xz", Size: int64(len(archiveData)), Mode: 0o640}) - tw.Write(archiveData) - manifest := backup.Manifest{EncryptionMode: "age", Hostname: "test", CreatedAt: time.Now(), ArchivePath: "backup.tar.xz"} - metaJSON, _ := json.Marshal(manifest) - tw.WriteHeader(&tar.Header{Name: "backup.metadata", Size: int64(len(metaJSON)), Mode: 0o640}) - tw.Write(metaJSON) - - checksum := checksumLineForBytes("backup.tar.xz", archiveData) - tw.WriteHeader(&tar.Header{Name: "backup.sha256", Size: int64(len(checksum)), Mode: 0o640}) - tw.Write(checksum) - tw.Close() - bundleFile.Close() + createPlainBackupBundle(t, bundlePath, archiveData, manifest, true) cfg := &config.Config{ BackupPath: backupDir, @@ -3872,23 +3823,9 @@ func TestPreparePlainBundle_StatErrorAfterExtract(t *testing.T) { // Create a valid bundle bundlePath := filepath.Join(tmp, "bundle.tar") - bundleFile, _ := os.Create(bundlePath) - tw := tar.NewWriter(bundleFile) - archiveData := []byte("archive content") - tw.WriteHeader(&tar.Header{Name: "backup.tar.xz", Size: int64(len(archiveData)), Mode: 0o640}) - tw.Write(archiveData) - manifest := backup.Manifest{EncryptionMode: "none", Hostname: "test", CreatedAt: time.Now()} - metaJSON, _ := json.Marshal(manifest) - tw.WriteHeader(&tar.Header{Name: "backup.metadata", Size: int64(len(metaJSON)), Mode: 0o640}) - tw.Write(metaJSON) - - checksum := checksumLineForBytes("backup.tar.xz", archiveData) - tw.WriteHeader(&tar.Header{Name: "backup.sha256", Size: int64(len(checksum)), Mode: 0o640}) - tw.Write(checksum) - tw.Close() - bundleFile.Close() + createPlainBackupBundle(t, bundlePath, archiveData, manifest, true) // Create FakeFS that will fail on stat for the extracted archive fake := NewFakeFS() @@ -3905,18 +3842,11 @@ func TestPreparePlainBundle_StatErrorAfterExtract(t *testing.T) { fake.StatErr["/tmp/proxsave"] = nil // Allow this stat // After extraction, stat will be called on the plain archive - we set error later - orig := restoreFS - restoreFS = fake - defer func() { restoreFS = orig }() - - cand := &backupCandidate{ - Source: sourceBundle, - BundlePath: bundlePath, - Manifest: &backup.Manifest{EncryptionMode: "none", Hostname: "test"}, - } - ctx := context.Background() - reader := bufio.NewReader(strings.NewReader("")) - logger := logging.New(types.LogLevelError, false) + useRestoreFS(t, fake) + cand, ctx, reader, logger := preparePlainBundleTestInput( + bundlePath, + &backup.Manifest{EncryptionMode: "none", Hostname: "test"}, + ) // The test shows that with proper setup, stat error would be triggered // For now, run with FakeFS to cover the MkdirAll/MkdirTemp paths @@ -3972,19 +3902,9 @@ func TestPreparePlainBundle_MkdirTempErrorWithRcloneCleanup(t *testing.T) { // Create a fake downloaded bundle file bundlePath := filepath.Join(tmp, "downloaded.bundle.tar") - bundleFile, _ := os.Create(bundlePath) - tw := tar.NewWriter(bundleFile) archiveData := []byte("data") - tw.WriteHeader(&tar.Header{Name: "backup.tar.xz", Size: int64(len(archiveData)), Mode: 0o640}) - tw.Write(archiveData) - metaJSON, _ := json.Marshal(backup.Manifest{EncryptionMode: "none", ArchivePath: "backup.tar.xz"}) - tw.WriteHeader(&tar.Header{Name: "backup.metadata", Size: int64(len(metaJSON)), Mode: 0o640}) - tw.Write(metaJSON) - checksum := checksumLineForBytes("backup.tar.xz", archiveData) - tw.WriteHeader(&tar.Header{Name: "backup.sha256", Size: int64(len(checksum)), Mode: 0o640}) - tw.Write(checksum) - tw.Close() - bundleFile.Close() + manifest := backup.Manifest{EncryptionMode: "none", ArchivePath: "backup.tar.xz"} + createPlainBackupBundle(t, bundlePath, archiveData, manifest, true) // Track if cleanup was called cleanupCalled := false @@ -4158,23 +4078,9 @@ func TestPreparePlainBundle_CopyFileError(t *testing.T) { // Create a valid bundle bundlePath := filepath.Join(tmp, "bundle.tar") - bundleFile, _ := os.Create(bundlePath) - tw := tar.NewWriter(bundleFile) - archiveData := []byte("archive content") - tw.WriteHeader(&tar.Header{Name: "backup.tar.xz", Size: int64(len(archiveData)), Mode: 0o640}) - tw.Write(archiveData) - manifest := backup.Manifest{EncryptionMode: "none", Hostname: "test", CreatedAt: time.Now(), ArchivePath: "backup.tar.xz"} - metaJSON, _ := json.Marshal(manifest) - tw.WriteHeader(&tar.Header{Name: "backup.metadata", Size: int64(len(metaJSON)), Mode: 0o640}) - tw.Write(metaJSON) - - checksum := checksumLineForBytes("backup.tar.xz", archiveData) - tw.WriteHeader(&tar.Header{Name: "backup.sha256", Size: int64(len(checksum)), Mode: 0o640}) - tw.Write(checksum) - tw.Close() - bundleFile.Close() + createPlainBackupBundle(t, bundlePath, archiveData, manifest, true) // Use FakeFS fake := NewFakeFS() @@ -4187,18 +4093,11 @@ func TestPreparePlainBundle_CopyFileError(t *testing.T) { // After extraction, set OpenFile error for the archive copy destination // The copyFile function will try to create the destination file - orig := restoreFS - restoreFS = fake - defer func() { restoreFS = orig }() - - cand := &backupCandidate{ - Source: sourceBundle, - BundlePath: bundlePath, - Manifest: &backup.Manifest{EncryptionMode: "none", Hostname: "test"}, - } - ctx := context.Background() - reader := bufio.NewReader(strings.NewReader("")) - logger := logging.New(types.LogLevelError, false) + useRestoreFS(t, fake) + cand, ctx, reader, logger := preparePlainBundleTestInput( + bundlePath, + &backup.Manifest{EncryptionMode: "none", Hostname: "test"}, + ) bundle, err := preparePlainBundle(ctx, reader, cand, "1.0.0", logger) // This test verifies that the path goes through successfully for plain archives @@ -4267,39 +4166,18 @@ func TestPreparePlainBundle_StatErrorOnPlainArchive(t *testing.T) { // Create a valid bundle with plain (non-encrypted) archive bundlePath := filepath.Join(tmp, "bundle.tar") - bundleFile, _ := os.Create(bundlePath) - tw := tar.NewWriter(bundleFile) - archiveData := []byte("archive content for stat test") - tw.WriteHeader(&tar.Header{Name: "backup.tar.xz", Size: int64(len(archiveData)), Mode: 0o640}) - tw.Write(archiveData) - manifest := backup.Manifest{EncryptionMode: "none", Hostname: "test", CreatedAt: time.Now(), ArchivePath: "backup.tar.xz"} - metaJSON, _ := json.Marshal(manifest) - tw.WriteHeader(&tar.Header{Name: "backup.metadata", Size: int64(len(metaJSON)), Mode: 0o640}) - tw.Write(metaJSON) - - checksum := checksumLineForBytes("backup.tar.xz", archiveData) - tw.WriteHeader(&tar.Header{Name: "backup.sha256", Size: int64(len(checksum)), Mode: 0o640}) - tw.Write(checksum) - tw.Close() - bundleFile.Close() + createPlainBackupBundle(t, bundlePath, archiveData, manifest, true) // Use wrapped osFS that fails stat on plain archive after several calls fake := &fakeStatFailOnPlainArchive{} - orig := restoreFS - restoreFS = fake - defer func() { restoreFS = orig }() - - cand := &backupCandidate{ - Source: sourceBundle, - BundlePath: bundlePath, - Manifest: &backup.Manifest{EncryptionMode: "none", Hostname: "test"}, - } - ctx := context.Background() - reader := bufio.NewReader(strings.NewReader("")) - logger := logging.New(types.LogLevelError, false) + useRestoreFS(t, fake) + cand, ctx, reader, logger := preparePlainBundleTestInput( + bundlePath, + &backup.Manifest{EncryptionMode: "none", Hostname: "test"}, + ) bundle, err := preparePlainBundle(ctx, reader, cand, "1.0.0", logger) if err == nil { @@ -4325,17 +4203,9 @@ func TestPreparePlainBundle_MkdirAllErrorWithRcloneDownloadCleanup(t *testing.T) // Create a valid bundle that rclone will "download" bundlePath := filepath.Join(downloadDir, "backup.bundle.tar") - bundleFile, _ := os.Create(bundlePath) - tw := tar.NewWriter(bundleFile) archiveData := []byte("archive content") - tw.WriteHeader(&tar.Header{Name: "backup.tar.xz", Size: int64(len(archiveData)), Mode: 0o640}) - tw.Write(archiveData) manifest := backup.Manifest{EncryptionMode: "none", Hostname: "test", CreatedAt: time.Now()} - metaJSON, _ := json.Marshal(manifest) - tw.WriteHeader(&tar.Header{Name: "backup.metadata", Size: int64(len(metaJSON)), Mode: 0o640}) - tw.Write(metaJSON) - tw.Close() - bundleFile.Close() + createPlainBackupBundle(t, bundlePath, archiveData, manifest, false) // Script that copies the pre-made bundle to the destination script := fmt.Sprintf(`#!/bin/bash @@ -4401,39 +4271,18 @@ func TestPreparePlainBundle_GenerateChecksumErrorPath(t *testing.T) { // Create a valid bundle bundlePath := filepath.Join(tmp, "bundle.tar") - bundleFile, _ := os.Create(bundlePath) - tw := tar.NewWriter(bundleFile) - archiveData := []byte("archive content for checksum error test") - tw.WriteHeader(&tar.Header{Name: "backup.tar.xz", Size: int64(len(archiveData)), Mode: 0o640}) - tw.Write(archiveData) - manifest := backup.Manifest{EncryptionMode: "none", Hostname: "test", CreatedAt: time.Now(), ArchivePath: "backup.tar.xz"} - metaJSON, _ := json.Marshal(manifest) - tw.WriteHeader(&tar.Header{Name: "backup.metadata", Size: int64(len(metaJSON)), Mode: 0o640}) - tw.Write(metaJSON) - - checksum := checksumLineForBytes("backup.tar.xz", archiveData) - tw.WriteHeader(&tar.Header{Name: "backup.sha256", Size: int64(len(checksum)), Mode: 0o640}) - tw.Write(checksum) - tw.Close() - bundleFile.Close() + createPlainBackupBundle(t, bundlePath, archiveData, manifest, true) // Use FS that removes file after stat fake := &fakeStatThenRemoveFS{} - orig := restoreFS - restoreFS = fake - defer func() { restoreFS = orig }() - - cand := &backupCandidate{ - Source: sourceBundle, - BundlePath: bundlePath, - Manifest: &backup.Manifest{EncryptionMode: "none", Hostname: "test"}, - } - ctx := context.Background() - reader := bufio.NewReader(strings.NewReader("")) - logger := logging.New(types.LogLevelError, false) + useRestoreFS(t, fake) + cand, ctx, reader, logger := preparePlainBundleTestInput( + bundlePath, + &backup.Manifest{EncryptionMode: "none", Hostname: "test"}, + ) bundle, err := preparePlainBundle(ctx, reader, cand, "1.0.0", logger) if err == nil { @@ -4473,17 +4322,9 @@ func TestPreparePlainBundle_MkdirAllErrorAfterRcloneDownload(t *testing.T) { // Create the bundle that will be "downloaded" sourceBundlePath := filepath.Join(bundleDir, "backup.bundle.tar") - bundleFile, _ := os.Create(sourceBundlePath) - tw := tar.NewWriter(bundleFile) archiveData := []byte("archive") - tw.WriteHeader(&tar.Header{Name: "backup.tar.xz", Size: int64(len(archiveData)), Mode: 0o640}) - tw.Write(archiveData) manifest := backup.Manifest{EncryptionMode: "none", Hostname: "test", CreatedAt: time.Now()} - metaJSON, _ := json.Marshal(manifest) - tw.WriteHeader(&tar.Header{Name: "backup.metadata", Size: int64(len(metaJSON)), Mode: 0o640}) - tw.Write(metaJSON) - tw.Close() - bundleFile.Close() + createPlainBackupBundle(t, sourceBundlePath, archiveData, manifest, false) // Script that copies the bundle to destination script := fmt.Sprintf(`#!/bin/bash diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 2107ddef..985d1fdb 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -4,7 +4,6 @@ import ( "archive/tar" "context" "encoding/json" - "errors" "fmt" "io" "os" @@ -504,528 +503,54 @@ func (o *Orchestrator) ensureTempRegistry() *TempDirRegistry { // RunGoBackup performs the entire backup using Go components (collector + archiver) func (o *Orchestrator) RunGoBackup(ctx context.Context, envInfo *environment.EnvironmentInfo, hostname string) (stats *BackupStats, err error) { - if envInfo == nil { - envInfo = o.envInfo - } else { - o.SetEnvironmentInfo(envInfo) - } - pType := types.ProxmoxUnknown - if envInfo != nil { - pType = envInfo.Type - } - done := logging.DebugStart(o.logger, "backup run", "type=%s hostname=%s", pType, hostname) + run := o.newBackupRunContext(ctx, envInfo, hostname) + done := logging.DebugStart(o.logger, "backup run", "type=%s hostname=%s", run.proxmoxType, hostname) defer func() { done(err) }() - o.logger.Info("Starting Go-based backup orchestration for %s", pType) - - // Unified cleanup of previous execution artifacts - registry := o.cleanupPreviousExecutionArtifacts() - fs := o.filesystem() + o.logger.Info("Starting Go-based backup orchestration for %s", run.proxmoxType) - startTime := o.startTime - if startTime.IsZero() { - startTime = o.now() - o.startTime = startTime + workspace := &backupWorkspace{ + registry: o.cleanupPreviousExecutionArtifacts(), + fs: o.filesystem(), } - normalizedLevel := normalizeCompressionLevel(o.compressionType, o.compressionLevel) - - fmt.Println() - o.logStep(1, "Initializing backup statistics and temporary workspace") - stats = InitializeBackupStats( - hostname, - envInfo, - o.version, - startTime, - o.cfg, - o.compressionType, - o.compressionMode, - normalizedLevel, - o.compressionThreads, - o.backupPath, - o.serverID, - o.serverMAC, - ) - // Get log file path from logger (more reliable than env var) - if logFile := o.logger.GetLogFilePath(); logFile != "" { - stats.LogFilePath = logFile - } - - // Propagate version update information (if any) into stats so that - // downstream notification adapters can include it in their payloads. - if o.versionUpdateAvailable || o.updateCurrentVersion != "" || o.updateLatestVersion != "" { - stats.NewVersionAvailable = o.versionUpdateAvailable - stats.CurrentVersion = o.updateCurrentVersion - stats.LatestVersion = o.updateLatestVersion - } - - metricsStats := stats + stats = o.initBackupRun(run) defer func() { - if metricsStats == nil || o.cfg == nil || !o.cfg.MetricsEnabled || o.dryRun { - return - } - - if metricsStats.EndTime.IsZero() { - metricsStats.EndTime = o.now() - } - if metricsStats.Duration == 0 && !metricsStats.StartTime.IsZero() { - metricsStats.Duration = metricsStats.EndTime.Sub(metricsStats.StartTime) - } - - if err != nil { - var backupErr *BackupError - if errors.As(err, &backupErr) { - metricsStats.ExitCode = backupErr.Code.Int() - } else { - metricsStats.ExitCode = types.ExitGenericError.Int() - } - } else if metricsStats.ExitCode == 0 { - metricsStats.ExitCode = types.ExitSuccess.Int() - } - - if m := metricsStats.toPrometheusMetrics(); m != nil { - exporter := metrics.NewPrometheusExporter(o.cfg.MetricsPath, o.logger) - if exportErr := exporter.Export(m); exportErr != nil { - o.logger.Warning("Failed to export Prometheus metrics: %v", exportErr) - } - } + o.exportBackupMetrics(run, err) }() - - // Ensure that, in case of failure, we still perform log parsing, - // derive an exit code and dispatch notifications/log rotation. defer func() { - if err == nil || stats == nil { - return - } - - // Ensure end time and duration are set - if stats.EndTime.IsZero() { - stats.EndTime = o.now() - } - if stats.Duration == 0 && !stats.StartTime.IsZero() { - stats.Duration = stats.EndTime.Sub(stats.StartTime) - } - - // Parse log file to populate error/warning counts - if stats.LogFilePath != "" { - o.logger.Debug("Parsing log file for error/warning counts after failure: %s", stats.LogFilePath) - _, errorCount, warningCount := ParseLogCounts(stats.LogFilePath, 0) - stats.ErrorCount = errorCount - stats.WarningCount = warningCount - if errorCount > 0 || warningCount > 0 { - o.logger.Debug("Found %d errors and %d warnings in log file (failure path)", errorCount, warningCount) - } - } else { - o.logger.Debug("No log file path specified, error/warning counts will be 0 (failure path)") - } - - // Derive exit code from the error when possible - var backupErr *BackupError - if errors.As(err, &backupErr) { - stats.ExitCode = backupErr.Code.Int() - } else { - stats.ExitCode = types.ExitBackupError.Int() - } - + o.finalizeFailedBackupStats(run, err) }() - o.logger.Debug("Creating temporary directory for collection output") - // Create temporary directory for collection (outside backup path) - // Note: /tmp/proxsave is validated in pre-backup checks (CheckTempDirectory) - // This MkdirAll is a fallback for cases where pre-checks don't run - timestampStr := startTime.Format("20060102-150405") - tempRoot := filepath.Join("/tmp", "proxsave") - if err := fs.MkdirAll(tempRoot, 0o755); err != nil { - return nil, fmt.Errorf("Temp directory creation failed - path: %s: %w", tempRoot, err) - } - tempDir, err := fs.MkdirTemp(tempRoot, fmt.Sprintf("proxsave-%s-%s-", hostname, timestampStr)) - if err != nil { - return nil, fmt.Errorf("failed to create temporary directory: %w", err) - } - if o.dryRun { - o.logger.Info("[DRY RUN] Temporary directory would be: %s", tempDir) - } else { - o.logger.Debug("Using temporary directory: %s", tempDir) + if err := o.prepareBackupWorkspace(run, workspace); err != nil { + return nil, err } defer func() { - if registry == nil { - if cleanupErr := fs.RemoveAll(tempDir); cleanupErr != nil { - o.logger.Warning("Failed to remove temp directory %s: %v", tempDir, cleanupErr) - } - return - } - o.logger.Debug("Temporary workspace preserved at %s (will be removed at the next startup)", tempDir) + o.cleanupBackupWorkspace(workspace) }() - - // Create marker file for parity with Bash cleanup guarantees - markerPath := filepath.Join(tempDir, ".proxsave-marker") - markerContent := fmt.Sprintf( - "Created by PID %d on %s UTC\n", - os.Getpid(), - o.now().UTC().Format("2006-01-02 15:04:05"), - ) - if err := fs.WriteFile(markerPath, []byte(markerContent), 0600); err != nil { + if err := o.markBackupWorkspace(workspace); err != nil { return stats, fmt.Errorf("failed to create temp marker file: %w", err) } + o.registerBackupWorkspace(workspace) - if registry != nil { - if err := registry.Register(tempDir); err != nil { - o.logger.Debug("Failed to register temp directory %s: %v", tempDir, err) - } - } - - // Step 1: Collect configuration files - fmt.Println() - o.logStep(2, "Collection of configuration files and optimizations") - o.logger.Info("Collecting configuration files...") - o.logger.Debug("Collector dry-run=%v excludePatterns=%d", o.dryRun, len(o.excludePatterns)) - collectorConfig := backup.GetDefaultCollectorConfig() - collectorConfig.ExcludePatterns = append([]string(nil), o.excludePatterns...) - if o.cfg != nil { - applyCollectorOverrides(collectorConfig, o.cfg) - if len(o.cfg.BackupBlacklist) > 0 { - collectorConfig.ExcludePatterns = append(collectorConfig.ExcludePatterns, o.cfg.BackupBlacklist...) - } - } - - if err := collectorConfig.Validate(); err != nil { - return stats, &BackupError{ - Phase: "config", - Err: err, - Code: types.ExitConfigError, - } - } - - collector := backup.NewCollectorWithDeps(o.logger, collectorConfig, tempDir, pType, o.dryRun, o.collectorDeps()) - - o.logger.Debug("Starting collector run (type=%s)", pType) - if err := collector.CollectAll(ctx); err != nil { - // Return collection-specific error - return stats, &BackupError{ - Phase: "collection", - Err: err, - Code: types.ExitCollectionError, - } - } - - // Get collection statistics - collStats := collector.GetStats() - stats.FilesCollected = int(collStats.FilesProcessed) - stats.FilesFailed = int(collStats.FilesFailed) - stats.FilesNotFound = int(collStats.FilesNotFound) - stats.DirsCreated = int(collStats.DirsCreated) - stats.BytesCollected = collStats.BytesCollected - stats.FilesIncluded = int(collStats.FilesProcessed) - stats.FilesMissing = int(collStats.FilesNotFound) - stats.UncompressedSize = collStats.BytesCollected - if stats.ProxmoxType.SupportsPVE() { - if collector.IsClusteredPVE() { - stats.ClusterMode = "cluster" - } else { - stats.ClusterMode = "standalone" - } - } - - if err := o.writeBackupMetadata(tempDir, stats); err != nil { - o.logger.Debug("Failed to write backup metadata: %v", err) - } - - // Write backup manifest with file status details - if err := collector.WriteManifest(hostname); err != nil { - o.logger.Debug("Failed to write backup manifest: %v", err) - } - - o.logger.Info("Collection completed: %d files (%s), %d failed, %d dirs created", - collStats.FilesProcessed, - backup.FormatBytes(collStats.BytesCollected), - collStats.FilesFailed, - collStats.DirsCreated) - - // Additional disk space check using estimated size and safety factor - if o.checker != nil && stats.BytesCollected > 0 { - o.logger.Debug("Running disk-space validation for estimated data size") - estimatedSizeGB := float64(stats.BytesCollected) / (1024.0 * 1024.0 * 1024.0) - // Ensure we always reserve at least a small amount - if estimatedSizeGB < 0.001 { - estimatedSizeGB = 0.001 - } - result := o.checker.CheckDiskSpaceForEstimate(estimatedSizeGB) - if result.Passed { - o.logger.Debug("Disk check passed: %s", result.Message) - } else { - errMsg := result.Message - diskErr := result.Error - if errMsg == "" && diskErr != nil { - errMsg = diskErr.Error() - } - if errMsg == "" { - errMsg = "insufficient disk space" - } - if diskErr == nil { - diskErr = errors.New(errMsg) - } - return stats, &BackupError{ - Phase: "disk", - Err: fmt.Errorf("disk space validation failed: %w", diskErr), - Code: types.ExitDiskSpaceError, - } - } - } - - if o.optimizationCfg.Enabled() { - fmt.Println() - o.logger.Step("Backup optimizations on collected data") - if err := backup.ApplyOptimizations(ctx, o.logger, tempDir, o.optimizationCfg); err != nil { - o.logger.Warning("Backup optimizations completed with warnings: %v", err) - } - } else { - o.logger.Debug("Skipping optimization step (all features disabled)") + if err := o.collectBackupData(run, workspace); err != nil { + return stats, err } - - // Step 2: Create archive - fmt.Println() - o.logStep(3, "Creation of compressed archive") - o.logger.Info("Creating compressed archive...") - o.logger.Debug("Archiver configuration: type=%s level=%d mode=%s threads=%d", - o.compressionType, normalizedLevel, o.compressionMode, o.compressionThreads) - - // Generate archive filename - archiveBasename := fmt.Sprintf("%s-backup-%s", hostname, timestampStr) - - ageRecipients, err := o.prepareAgeRecipients(ctx) + artifacts, err := o.createBackupArchive(run, workspace) if err != nil { - return stats, &BackupError{ - Phase: "config", - Err: err, - Code: types.ExitConfigError, - } + return stats, err } - - archiverConfig := BuildArchiverConfig( - o.compressionType, - normalizedLevel, - o.compressionThreads, - o.compressionMode, - o.dryRun, - o.cfg != nil && o.cfg.EncryptArchive, - ageRecipients, - collectorConfig.ExcludePatterns, - ) - - if err := archiverConfig.Validate(); err != nil { - return stats, &BackupError{ - Phase: "config", - Err: err, - Code: types.ExitConfigError, - } + if err := o.verifyAndWriteBackupArtifacts(run, workspace, artifacts); err != nil { + return stats, err } - - archiver := backup.NewArchiver(o.logger, archiverConfig) - effectiveCompression := archiver.ResolveCompression() - stats.Compression = effectiveCompression - stats.CompressionLevel = archiver.CompressionLevel() - stats.CompressionMode = archiver.CompressionMode() - stats.CompressionThreads = archiver.CompressionThreads() - archiveExt := archiver.GetArchiveExtension() - archivePath := filepath.Join(o.backupPath, archiveBasename+archiveExt) - if stats.RequestedCompression != stats.Compression { - o.logger.Info("Using %s compression (requested %s)", stats.Compression, stats.RequestedCompression) - } - - if err := archiver.CreateArchive(ctx, tempDir, archivePath); err != nil { - phase := "archive" - code := types.ExitArchiveError - var compressionErr *backup.CompressionError - if errors.As(err, &compressionErr) { - phase = "compression" - code = types.ExitCompressionError - } - - return stats, &BackupError{ - Phase: phase, - Err: err, - Code: code, - } + if err := o.bundleBackupArtifacts(run, workspace, artifacts); err != nil { + return stats, err } - - stats.ArchivePath = archivePath - checksumPath := archivePath + ".sha256" - - // Get archive size - if !o.dryRun { - fmt.Println() - o.logStep(4, "Verification of archive and metadata generation") - if size, err := archiver.GetArchiveSize(archivePath); err == nil { - stats.ArchiveSize = size - stats.CompressedSize = size - stats.updateCompressionMetrics() - o.logger.Debug("Archive created: %s (%s)", archivePath, backup.FormatBytes(size)) - } else { - o.logger.Warning("Failed to get archive size: %v", err) - } - - // Verify archive (skipped internally when encryption is enabled) - if err := archiver.VerifyArchive(ctx, archivePath); err != nil { - // Return verification-specific error - return stats, &BackupError{ - Phase: "verification", - Err: err, - Code: types.ExitVerificationError, - } - } - - // Generate checksum and manifest for the archive - checksum, err := backup.GenerateChecksum(ctx, o.logger, archivePath) - if err != nil { - return stats, &BackupError{ - Phase: "verification", - Err: fmt.Errorf("checksum generation failed: %w", err), - Code: types.ExitVerificationError, - } - } - stats.Checksum = checksum - - checksumContent := fmt.Sprintf("%s %s\n", checksum, filepath.Base(archivePath)) - if err := fs.WriteFile(checksumPath, []byte(checksumContent), 0640); err != nil { - o.logger.Warning("Failed to write checksum file %s: %v", checksumPath, err) - } else { - o.logger.Debug("Checksum file written to %s", checksumPath) - } - - manifestPath := archivePath + ".manifest.json" - manifestCreatedAt := stats.Timestamp - encryptionMode := "none" - if o.cfg != nil && o.cfg.EncryptArchive { - encryptionMode = "age" - } - targets := append([]string(nil), stats.ProxmoxTargets...) - manifest := &backup.Manifest{ - ArchivePath: archivePath, - ArchiveSize: stats.ArchiveSize, - SHA256: checksum, - CreatedAt: manifestCreatedAt, - CompressionType: string(stats.Compression), - CompressionLevel: stats.CompressionLevel, - CompressionMode: stats.CompressionMode, - ProxmoxType: string(stats.ProxmoxType), - ProxmoxTargets: targets, - ProxmoxVersion: stats.ProxmoxVersion, - PVEVersion: stats.PVEVersion, - PBSVersion: stats.PBSVersion, - Hostname: stats.Hostname, - ScriptVersion: stats.ScriptVersion, - EncryptionMode: encryptionMode, - ClusterMode: stats.ClusterMode, - } - - if err := backup.CreateManifest(ctx, o.logger, manifest, manifestPath); err != nil { - return stats, &BackupError{ - Phase: "verification", - Err: fmt.Errorf("manifest creation failed: %w", err), - Code: types.ExitVerificationError, - } - } - stats.ManifestPath = manifestPath - - // Maintain Bash-compatible metadata filename for downstream tooling - metadataAlias := archivePath + ".metadata" - if err := copyFile(fs, manifestPath, metadataAlias); err != nil { - o.logger.Warning("Failed to write legacy metadata file %s: %v", metadataAlias, err) - } else { - o.logger.Debug("Legacy metadata file written to %s", metadataAlias) - } - - // Create bundle (if requested) before dispatching to other storage targets - bundleEnabled := o.cfg != nil && o.cfg.BundleAssociatedFiles - if bundleEnabled { - fmt.Println() - o.logStep(5, "Bundling of archive, checksum and metadata") - o.logger.Debug("Bundling enabled: creating bundle from %s", filepath.Base(archivePath)) - bundlePath, err := o.createBundle(ctx, archivePath) - if err != nil { - return stats, &BackupError{ - Phase: "archive", - Err: fmt.Errorf("bundle creation failed: %w", err), - Code: types.ExitArchiveError, - } - } - - if err := o.removeAssociatedFiles(archivePath); err != nil { - o.logger.Warning("Failed to remove raw files after bundling: %v", err) - } else { - o.logger.Debug("Removed raw tar/checksum/metadata after bundling") - } - - if info, err := fs.Stat(bundlePath); err == nil { - stats.ArchiveSize = info.Size() - stats.CompressedSize = info.Size() - stats.updateCompressionMetrics() - } - stats.ArchivePath = bundlePath - stats.ManifestPath = "" - stats.BundleCreated = true - archivePath = bundlePath - o.logger.Debug("Bundle ready: %s", filepath.Base(bundlePath)) - } else { - fmt.Println() - o.logger.Skip("Bundling disabled") - } - - stats.EndTime = o.now() - - o.logger.Info("✓ Archive created and verified") - } else { - fmt.Println() - o.logStep(4, "Verification skipped (dry run mode)") - o.logger.Info("[DRY RUN] Would create archive: %s", archivePath) - stats.EndTime = o.now() - } - - stats.Duration = stats.EndTime.Sub(stats.StartTime) - - // Parse log file to populate error/warning counts before dispatch - if stats.LogFilePath != "" { - o.logger.Debug("Parsing log file for error/warning counts: %s", stats.LogFilePath) - _, errorCount, warningCount := ParseLogCounts(stats.LogFilePath, 0) - stats.ErrorCount = errorCount - stats.WarningCount = warningCount - if errorCount > 0 || warningCount > 0 { - o.logger.Debug("Found %d errors and %d warnings in log file", errorCount, warningCount) - } - } else { - o.logger.Debug("No log file path specified, error/warning counts will be 0") - } - - // Determine aggregated exit code (similar to legacy Bash logic) - switch { - case stats.ErrorCount > 0: - stats.ExitCode = types.ExitBackupError.Int() - case stats.WarningCount > 0: - stats.ExitCode = types.ExitGenericError.Int() - default: - stats.ExitCode = types.ExitSuccess.Int() - } - o.logger.Debug("Aggregated exit code based on log analysis: %d", stats.ExitCode) - - if len(o.storageTargets) == 0 { - fmt.Println() - o.logStep(6, "No storage targets registered - skipping") - } else if o.dryRun { - fmt.Println() - o.logStep(6, "Storage dispatch skipped (dry run mode)") - } else { - fmt.Println() - o.logStep(6, "Dispatching archive to %d storage target(s)", len(o.storageTargets)) - o.logGlobalRetentionPolicy() - } - - if !o.dryRun { - o.logger.Debug("Dispatching archive to %d storage targets", len(o.storageTargets)) - if err := o.dispatchPostBackup(ctx, stats); err != nil { - return stats, err - } + o.finalizeBackupStats(run) + if err := o.dispatchBackupArtifacts(run); err != nil { + return stats, err } fmt.Println() - o.logger.Debug("Go backup completed in %s", backup.FormatDuration(stats.Duration)) + o.logger.Debug("Go backup completed in %s", backup.FormatDuration(run.stats.Duration)) return stats, nil } From 7220bd269051992d04282916b5f8411c1a49af2f Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:43:28 +0200 Subject: [PATCH 10/35] Refactor backup bricks into modular files Introduce BrickID doc and helper constructors (brick, collectorBrick, pbsCommandBrick, systemCommandBrick, pbsInventoryBrick) to standardize collection-brick creation, and split the large collector_bricks implementation into multiple focused files under internal/backup (common, pbs, pbs_features, pbs_inventory, pbs_runtime, pve, pve_finalize, pve_jobs, pve_storage, system). Remove the in-file recipe and brick definitions from collector_bricks.go and update tests (collector_bricks_test.go) to match the new layout. This reorganizes the code for better separation of concerns and easier maintenance. --- internal/backup/collector_bricks.go | 2002 +---------------- internal/backup/collector_bricks_common.go | 21 + internal/backup/collector_bricks_pbs.go | 280 +++ .../backup/collector_bricks_pbs_features.go | 172 ++ .../backup/collector_bricks_pbs_inventory.go | 72 + .../backup/collector_bricks_pbs_runtime.go | 217 ++ internal/backup/collector_bricks_pve.go | 215 ++ .../backup/collector_bricks_pve_finalize.go | 156 ++ internal/backup/collector_bricks_pve_jobs.go | 141 ++ .../backup/collector_bricks_pve_storage.go | 177 ++ internal/backup/collector_bricks_system.go | 217 ++ internal/backup/collector_bricks_test.go | 43 + 12 files changed, 1749 insertions(+), 1964 deletions(-) create mode 100644 internal/backup/collector_bricks_common.go create mode 100644 internal/backup/collector_bricks_pbs.go create mode 100644 internal/backup/collector_bricks_pbs_features.go create mode 100644 internal/backup/collector_bricks_pbs_inventory.go create mode 100644 internal/backup/collector_bricks_pbs_runtime.go create mode 100644 internal/backup/collector_bricks_pve.go create mode 100644 internal/backup/collector_bricks_pve_finalize.go create mode 100644 internal/backup/collector_bricks_pve_jobs.go create mode 100644 internal/backup/collector_bricks_pve_storage.go create mode 100644 internal/backup/collector_bricks_system.go diff --git a/internal/backup/collector_bricks.go b/internal/backup/collector_bricks.go index cb4513db..7b2136af 100644 --- a/internal/backup/collector_bricks.go +++ b/internal/backup/collector_bricks.go @@ -1,13 +1,12 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. package backup import ( "context" - "errors" "fmt" - "os" - "strings" ) +// BrickID identifies one behavior-preserving collection step within a backup recipe. type BrickID string const ( @@ -203,6 +202,42 @@ type collectionBrick struct { Run func(context.Context, *collectionState) error } +func brick(id BrickID, description string, run func(context.Context, *collectionState) error) collectionBrick { + return collectionBrick{ID: id, Description: description, Run: run} +} + +func collectorBrick(id BrickID, description string, run func(*Collector, context.Context) error) collectionBrick { + return brick(id, description, func(ctx context.Context, state *collectionState) error { + return run(state.collector, ctx) + }) +} + +func pbsCommandBrick(id BrickID, description string, run func(*Collector, context.Context, string) error) collectionBrick { + return brick(id, description, func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + return run(state.collector, ctx, commandsDir) + }) +} + +func systemCommandBrick(id BrickID, description string, run func(*Collector, context.Context, string) error) collectionBrick { + return brick(id, description, func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensureSystemCommandsDir() + if err != nil { + return err + } + return run(state.collector, ctx, commandsDir) + }) +} + +func pbsInventoryBrick(id BrickID, description string, run func(*Collector, context.Context, *pbsInventoryState) error) collectionBrick { + return brick(id, description, func(ctx context.Context, state *collectionState) error { + return run(state.collector, ctx, state.ensurePBSInventoryState()) + }) +} + type recipe struct { Name string Bricks []collectionBrick @@ -348,687 +383,6 @@ func (s *collectionState) ensurePVERuntimeInfo() *pveRuntimeInfo { return s.pve.runtimeInfo } -func newPVERecipe() recipe { - return recipe{ - Name: "pve", - Bricks: []collectionBrick{ - { - ID: brickPVEValidateAndCluster, - Description: "Validate PVE environment and detect cluster state", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - c.logger.Debug("Validating PVE environment and cluster state prior to collection") - - pveConfigPath := c.effectivePVEConfigPath() - if _, err := os.Stat(pveConfigPath); err != nil { - if errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("not a PVE system: %s not found", pveConfigPath) - } - return fmt.Errorf("failed to access PVE config path %s: %w", pveConfigPath, err) - } - c.logger.Debug("%s detected, continuing with PVE collection", pveConfigPath) - - clustered := false - if isClustered, err := c.isClusteredPVE(ctx); err != nil { - if ctx.Err() != nil { - return err - } - c.logger.Debug("Cluster detection failed, assuming standalone node: %v", err) - } else { - clustered = isClustered - c.logger.Debug("Cluster detection completed: clustered=%v", clustered) - } - - state.pve.clustered = clustered - c.clusteredPVE = clustered - return nil - }, - }, - { - ID: brickPVEConfigSnapshot, - Description: "Collect base PVE configuration snapshot", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPVEConfigSnapshot(ctx) - }, - }, - { - ID: brickPVEClusterSnapshot, - Description: "Collect cluster-specific PVE snapshot", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPVEClusterSnapshot(ctx, state.pve.clustered) - }, - }, - { - ID: brickPVEFirewallSnapshot, - Description: "Collect PVE firewall snapshot", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPVEFirewallSnapshot(ctx) - }, - }, - { - ID: brickPVEVZDumpSnapshot, - Description: "Collect VZDump snapshot", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPVEVZDumpSnapshot(ctx) - }, - }, - { - ID: brickPVERuntimeCore, - Description: "Collect core PVE runtime information", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - commandsDir, err := state.ensurePVECommandsDir() - if err != nil { - return err - } - c.logger.Debug("Collecting PVE core runtime state") - return c.collectPVECoreRuntime(ctx, commandsDir, state.ensurePVERuntimeInfo()) - }, - }, - { - ID: brickPVERuntimeACL, - Description: "Collect PVE ACL runtime information", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePVECommandsDir() - if err != nil { - return err - } - state.collector.collectPVEACLRuntime(ctx, commandsDir) - return nil - }, - }, - { - ID: brickPVERuntimeCluster, - Description: "Collect PVE cluster runtime information", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePVECommandsDir() - if err != nil { - return err - } - state.collector.collectPVEClusterRuntime(ctx, commandsDir, state.pve.clustered) - return nil - }, - }, - { - ID: brickPVERuntimeStorage, - Description: "Collect PVE storage runtime information", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - commandsDir, err := state.ensurePVECommandsDir() - if err != nil { - return err - } - if err := c.collectPVEStorageRuntime(ctx, commandsDir, state.ensurePVERuntimeInfo()); err != nil { - return err - } - c.finalizePVERuntimeInfo(state.ensurePVERuntimeInfo()) - return nil - }, - }, - { - ID: brickPVEVMQEMUConfigs, - Description: "Collect QEMU VM configurations", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupVMConfigs { - c.logger.Skip("VM/container configuration backup disabled.") - return nil - } - if state.pve.guestCollectionAborted { - return nil - } - c.logger.Info("Collecting VM and container configurations") - if err := c.collectPVEQEMUConfigs(ctx); err != nil { - c.logger.Warning("Failed to collect QEMU VM configs: %v", err) - state.pve.guestCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEVMLXCConfigs, - Description: "Collect LXC container configurations", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupVMConfigs || state.pve.guestCollectionAborted { - return nil - } - if err := c.collectPVELXCConfigs(ctx); err != nil { - c.logger.Warning("Failed to collect LXC configs: %v", err) - state.pve.guestCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEGuestInventory, - Description: "Collect guest inventory", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupVMConfigs || state.pve.guestCollectionAborted { - return nil - } - if err := c.collectPVEGuestInventory(ctx); err != nil { - c.logger.Warning("Failed to collect guest inventory: %v", err) - state.pve.guestCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEBackupJobDefs, - Description: "Collect PVE backup job definitions", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEJobs || state.pve.jobCollectionAborted { - return nil - } - c.logger.Debug("Collecting PVE job definitions for nodes: %v", state.pve.runtimeNodes()) - if err := c.collectPVEBackupJobDefinitions(ctx); err != nil { - c.logger.Warning("Failed to collect PVE backup job definitions: %v", err) - state.pve.jobCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEBackupJobHistory, - Description: "Collect PVE backup job history", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEJobs || state.pve.jobCollectionAborted { - return nil - } - if err := c.collectPVEBackupJobHistory(ctx, state.pve.runtimeNodes()); err != nil { - c.logger.Warning("Failed to collect PVE backup history: %v", err) - state.pve.jobCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEVZDumpCron, - Description: "Collect VZDump cron snapshot", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEJobs || state.pve.jobCollectionAborted { - return nil - } - if err := c.collectPVEVZDumpCronSnapshot(ctx); err != nil { - c.logger.Warning("Failed to collect VZDump cron snapshot: %v", err) - state.pve.jobCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEScheduleCrontab, - Description: "Collect root crontab schedule data", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVESchedules || state.pve.scheduleCollectionAborted { - return nil - } - if err := c.collectPVEScheduleCrontab(ctx); err != nil { - c.logger.Warning("Failed to collect PVE crontab schedules: %v", err) - state.pve.scheduleCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEScheduleTimers, - Description: "Collect systemd timer schedule data", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVESchedules || state.pve.scheduleCollectionAborted { - return nil - } - if err := c.collectPVEScheduleTimers(ctx); err != nil { - c.logger.Warning("Failed to collect PVE timer schedules: %v", err) - state.pve.scheduleCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEScheduleCronFiles, - Description: "Collect PVE-related cron files", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVESchedules || state.pve.scheduleCollectionAborted { - return nil - } - if err := c.collectPVEScheduleCronFiles(ctx); err != nil { - c.logger.Warning("Failed to collect PVE cron schedule files: %v", err) - state.pve.scheduleCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEReplicationDefs, - Description: "Collect PVE replication definitions", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEReplication || state.pve.replicationCollectionAborted { - return nil - } - c.logger.Debug("Collecting PVE replication settings for nodes: %v", state.pve.runtimeNodes()) - if err := c.collectPVEReplicationDefinitions(ctx); err != nil { - c.logger.Warning("Failed to collect PVE replication definitions: %v", err) - state.pve.replicationCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEReplicationStatus, - Description: "Collect PVE replication status", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEReplication || state.pve.replicationCollectionAborted { - return nil - } - if err := c.collectPVEReplicationStatus(ctx, state.pve.runtimeNodes()); err != nil { - c.logger.Warning("Failed to collect PVE replication status: %v", err) - state.pve.replicationCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEStorageResolve, - Description: "Resolve PVE storage list for backup analysis", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEBackupFiles { - return nil - } - if state.pve.storageCollectionAborted { - return nil - } - if err := ctx.Err(); err != nil { - return err - } - c.logger.Info("Collecting PVE datastore information using auto-detection") - c.logger.Debug("Collecting datastore metadata for %d storages", len(state.pve.runtimeStorages())) - state.pve.resolvedStorages = c.resolvePVEStorages(state.pve.runtimeStorages()) - if len(state.pve.resolvedStorages) == 0 { - c.logger.Info("Found 0 PVE datastore(s) via auto-detection") - c.logger.Info("No PVE datastores detected - skipping metadata collection") - return nil - } - c.logger.Info("Found %d PVE datastore(s) via auto-detection", len(state.pve.resolvedStorages)) - return nil - }, - }, - { - ID: brickPVEStorageProbe, - Description: "Probe resolved PVE storages", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEBackupFiles || state.pve.storageCollectionAborted || len(state.pve.resolvedStorages) == 0 { - return nil - } - baseDir := c.pveDatastoresBaseDir() - if err := c.ensureDir(baseDir); err != nil { - c.logger.Warning("Failed to create datastore metadata directory: %v", err) - state.pve.storageCollectionAborted = true - return nil - } - ioTimeout := c.pveStorageIOTimeout() - state.pve.probedStorages = nil - state.pve.storageScanResults = nil - for _, storage := range state.pve.resolvedStorages { - result, err := c.preparePVEStorageScan(ctx, storage, baseDir, ioTimeout) - if err != nil { - c.logger.Warning("Failed to probe PVE datastore %s: %v", storage.Name, err) - state.pve.storageCollectionAborted = true - return nil - } - if result == nil { - continue - } - state.pve.probedStorages = append(state.pve.probedStorages, storage) - state.pve.ensureStorageScanResults()[storage.pathKey()] = result - } - return nil - }, - }, - { - ID: brickPVEStorageMetadataJSON, - Description: "Write JSON metadata for probed PVE storages", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEBackupFiles || state.pve.storageCollectionAborted || len(state.pve.probedStorages) == 0 { - return nil - } - ioTimeout := c.pveStorageIOTimeout() - for _, storage := range state.pve.probedStorages { - result := state.pve.storageResult(storage) - if result == nil || result.SkipRemaining { - continue - } - if err := c.collectPVEStorageMetadataJSONStep(ctx, result, ioTimeout); err != nil { - c.logger.Warning("Failed to write PVE datastore JSON metadata for %s: %v", storage.Name, err) - state.pve.storageCollectionAborted = true - return nil - } - } - return nil - }, - }, - { - ID: brickPVEStorageMetadataText, - Description: "Write text metadata for probed PVE storages", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEBackupFiles || state.pve.storageCollectionAborted || len(state.pve.probedStorages) == 0 { - return nil - } - ioTimeout := c.pveStorageIOTimeout() - for _, storage := range state.pve.probedStorages { - result := state.pve.storageResult(storage) - if result == nil || result.SkipRemaining { - continue - } - if err := c.collectPVEStorageMetadataTextStep(ctx, result, ioTimeout); err != nil { - c.logger.Warning("Failed to write PVE datastore text metadata for %s: %v", storage.Name, err) - state.pve.storageCollectionAborted = true - return nil - } - } - return nil - }, - }, - { - ID: brickPVEStorageBackupAnalysis, - Description: "Analyze PVE backup files for probed storages", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEBackupFiles || state.pve.storageCollectionAborted || len(state.pve.probedStorages) == 0 { - return nil - } - ioTimeout := c.pveStorageIOTimeout() - for _, storage := range state.pve.probedStorages { - result := state.pve.storageResult(storage) - if result == nil || result.SkipRemaining { - continue - } - if err := c.collectPVEStorageBackupAnalysisStep(ctx, result, ioTimeout); err != nil { - c.logger.Warning("Detailed backup analysis for %s failed: %v", storage.Name, err) - } - } - return nil - }, - }, - { - ID: brickPVEStorageSummary, - Description: "Write PVE datastore summary", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPVEBackupFiles || state.pve.storageCollectionAborted || len(state.pve.probedStorages) == 0 { - return nil - } - if err := c.writePVEStorageSummary(ctx, state.pve.probedStorages); err != nil { - c.logger.Warning("Failed to write PVE datastore summary: %v", err) - state.pve.storageCollectionAborted = true - return nil - } - c.logger.Debug("PVE datastore metadata collection completed (%d processed)", len(state.pve.probedStorages)) - return nil - }, - }, - { - ID: brickPVECephConfigSnapshot, - Description: "Collect Ceph configuration snapshot", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupCephConfig || state.pve.cephCollectionAborted { - return nil - } - c.logger.Debug("Collecting Ceph configuration and status") - if err := c.collectPVECephConfigSnapshot(ctx); err != nil { - c.logger.Warning("Failed to collect Ceph configuration snapshot: %v", err) - state.pve.cephCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVECephRuntime, - Description: "Collect Ceph runtime information", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupCephConfig || state.pve.cephCollectionAborted { - return nil - } - if err := c.collectPVECephRuntime(ctx); err != nil { - c.logger.Warning("Failed to collect Ceph runtime information: %v", err) - state.pve.cephCollectionAborted = true - } else { - c.logger.Debug("Ceph information collection completed") - } - return nil - }, - }, - { - ID: brickPVEAliasCore, - Description: "Create core PVE aliases", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - c.logger.Debug("Creating PVE info aliases under /var/lib/pve-cluster/info") - if err := c.createPVECoreAliases(ctx); err != nil { - c.logger.Warning("Failed to create PVE core aliases: %v", err) - state.pve.finalizeCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEAggregateBackupHistory, - Description: "Aggregate backup history aliases", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if state.pve.finalizeCollectionAborted { - return nil - } - if err := c.createPVEBackupHistoryAggregate(ctx); err != nil { - c.logger.Warning("Failed to aggregate PVE backup history: %v", err) - state.pve.finalizeCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEAggregateReplicationStatus, - Description: "Aggregate replication status aliases", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if state.pve.finalizeCollectionAborted { - return nil - } - if err := c.createPVEReplicationAggregate(ctx); err != nil { - c.logger.Warning("Failed to aggregate PVE replication status: %v", err) - state.pve.finalizeCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEVersionInfo, - Description: "Write PVE version alias information", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if state.pve.finalizeCollectionAborted { - return nil - } - if err := c.createPVEVersionInfo(ctx); err != nil { - c.logger.Warning("Failed to write PVE version info: %v", err) - state.pve.finalizeCollectionAborted = true - } - return nil - }, - }, - { - ID: brickPVEManifestFinalize, - Description: "Finalize the PVE manifest", - Run: func(_ context.Context, state *collectionState) error { - state.collector.populatePVEManifest() - return nil - }, - }, - }, - } -} - -func newPBSRecipe() recipe { - bricks := []collectionBrick{ - { - ID: brickPBSValidate, - Description: "Validate PBS environment", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - c.logger.Debug("Validating PBS environment before collection") - - pbsConfigPath := c.pbsConfigPath() - if _, err := os.Stat(pbsConfigPath); err != nil { - if errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("not a PBS system: %s not found", pbsConfigPath) - } - return fmt.Errorf("failed to access PBS config path %s: %w", pbsConfigPath, err) - } - c.logger.Debug("Detected %s, proceeding with PBS collection", pbsConfigPath) - return nil - }, - }, - { - ID: brickPBSConfigDirectoryCopy, - Description: "Copy the PBS configuration directory", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSConfigSnapshot(ctx, state.collector.pbsConfigPath()) - }, - }, - { - ID: brickPBSManifestInit, - Description: "Initialize the PBS manifest", - Run: func(_ context.Context, state *collectionState) error { - state.collector.initPBSManifest() - return nil - }, - }, - { - ID: brickPBSDatastoreDiscovery, - Description: "Discover PBS datastores", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - datastores, err := c.getDatastoreList(ctx) - if err != nil { - if ctx.Err() != nil { - return err - } - return fmt.Errorf("failed to detect PBS datastores: %w", err) - } - state.pbs.datastores = datastores - c.logger.Debug("Detected %d PBS datastores", len(datastores)) - - if len(datastores) == 0 { - c.logger.Info("Found 0 PBS datastore(s) via auto-detection") - } else { - summary := make([]string, 0, len(datastores)) - for _, ds := range datastores { - if ds.Path != "" { - summary = append(summary, fmt.Sprintf("%s (%s)", ds.Name, ds.Path)) - } else { - summary = append(summary, ds.Name) - } - } - c.logger.Info("Found %d PBS datastore(s) via auto-detection: %s", len(datastores), strings.Join(summary, ", ")) - } - return nil - }, - }, - } - bricks = append(bricks, newPBSManifestBricks()...) - bricks = append(bricks, newPBSRuntimeBricks()...) - bricks = append(bricks, newPBSInventoryBricks()...) - bricks = append(bricks, newPBSFeatureBricks()...) - bricks = append(bricks, newPBSFinalizeBricks()...) - return recipe{Name: "pbs", Bricks: bricks} -} - -func newPBSCommandsRecipe() recipe { - return recipe{Name: "pbs-commands", Bricks: newPBSRuntimeBricks()} -} - -func newPBSDatastoreInventoryRecipe() recipe { - return recipe{Name: "pbs-inventory", Bricks: newPBSInventoryBricks()} -} - -func newPBSDatastoreConfigRecipe() recipe { - return recipe{Name: "pbs-datastore-config", Bricks: newPBSDatastoreConfigBricks()} -} - -func newPBSPXARRecipe() recipe { - return recipe{Name: "pbs-pxar", Bricks: newPBSPXARBricks()} -} - -func newPBSUserConfigRecipe() recipe { - return recipe{ - Name: "pbs-user-config", - Bricks: []collectionBrick{ - { - ID: BrickID("pbs_access_load_user_ids_from_command_file"), - Description: "Load PBS user IDs from collected command snapshots", - Run: func(_ context.Context, state *collectionState) error { - userIDs, err := state.collector.loadPBSUserIDsFromCommandFile(state.collector.proxsaveCommandsDir("pbs")) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - state.collector.logger.Debug("User list not available for token export: %v", err) - state.pbs.userIDs = nil - return nil - } - state.collector.logger.Debug("Failed to parse user list for token export: %v", err) - state.pbs.userIDs = nil - return nil - } - state.pbs.userIDs = userIDs - return nil - }, - }, - { - ID: brickPBSRuntimeAccessUserTokens, - Description: "Collect PBS API token snapshots", - Run: func(ctx context.Context, state *collectionState) error { - if len(state.pbs.userIDs) == 0 { - return nil - } - usersDir, err := state.collector.ensurePBSAccessControlDir() - if err != nil { - return err - } - return state.collector.collectPBSAccessUserTokensRuntime(ctx, usersDir, state.pbs.userIDs) - }, - }, - { - ID: brickPBSRuntimeAccessTokensAggregate, - Description: "Aggregate PBS API token snapshots", - Run: func(_ context.Context, state *collectionState) error { - if len(state.pbs.userIDs) == 0 { - return nil - } - usersDir, err := state.collector.ensurePBSAccessControlDir() - if err != nil { - return err - } - return state.collector.collectPBSAccessTokensAggregateRuntime(usersDir, state.pbs.userIDs) - }, - }, - }, - } -} - func newDualRecipe() recipe { bricks := append([]collectionBrick{}, newPVERecipe().Bricks...) bricks = append(bricks, newPBSRecipe().Bricks...) @@ -1037,1283 +391,3 @@ func newDualRecipe() recipe { Bricks: bricks, } } - -func newPBSManifestBricks() []collectionBrick { - return []collectionBrick{ - {ID: brickPBSManifestDatastore, Description: "Collect PBS datastore manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestDatastore(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestS3, Description: "Collect PBS S3 manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestS3(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestNode, Description: "Collect PBS node manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestNode(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestACMEAccounts, Description: "Collect PBS ACME account manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestACMEAccounts(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestACMEPlugins, Description: "Collect PBS ACME plugin manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestACMEPlugins(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestMetricServers, Description: "Collect PBS metric server manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestMetricServers(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestTrafficControl, Description: "Collect PBS traffic control manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestTrafficControl(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestNotifications, Description: "Collect PBS notification manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestNotifications(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestNotificationsPriv, Description: "Collect PBS notification secret manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestNotificationsPriv(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestUserCfg, Description: "Collect PBS user manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestUserCfg(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestACLCfg, Description: "Collect PBS ACL manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestACLCfg(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestDomainsCfg, Description: "Collect PBS auth realm manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestDomainsCfg(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestRemote, Description: "Collect PBS remote manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestRemote(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestSyncJobs, Description: "Collect PBS sync-job manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestSyncJobs(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestVerificationJobs, Description: "Collect PBS verification-job manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestVerificationJobs(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestTapeCfg, Description: "Collect PBS tape configuration manifest entry", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestTapeCfg(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestTapeJobs, Description: "Collect PBS tape job manifest entry", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestTapeJobs(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestMediaPools, Description: "Collect PBS media pool manifest entry", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestMediaPools(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestTapeEncryptionKeys, Description: "Collect PBS tape encryption keys manifest entry", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestTapeEncryptionKeys(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestNetwork, Description: "Collect PBS network manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestNetwork(ctx, state.collector.pbsConfigPath()) - }}, - {ID: brickPBSManifestPrune, Description: "Collect PBS prune manifest entries", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectPBSManifestPrune(ctx, state.collector.pbsConfigPath()) - }}, - } -} - -func newPBSRuntimeBricks() []collectionBrick { - return []collectionBrick{ - { - ID: brickPBSRuntimeCore, - Description: "Collect core PBS runtime information", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSCoreRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeNode, - Description: "Collect PBS node runtime information", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSNodeRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeDatastoreList, - Description: "Collect PBS datastore list", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSDatastoreListRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeDatastoreStatus, - Description: "Collect PBS datastore status details", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSDatastoreStatusRuntime(ctx, commandsDir, state.pbs.datastores) - }, - }, - { - ID: brickPBSRuntimeACMEAccountsList, - Description: "Collect the PBS ACME account list", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - ids, err := state.collector.collectPBSAcmeAccountsListRuntime(ctx, commandsDir) - if err != nil { - return err - } - state.pbs.acmeAccountNames = ids - return nil - }, - }, - { - ID: brickPBSRuntimeACMEAccountInfo, - Description: "Collect PBS ACME account details", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSAcmeAccountInfoRuntime(ctx, commandsDir, state.pbs.acmeAccountNames) - }, - }, - { - ID: brickPBSRuntimeACMEPluginsList, - Description: "Collect the PBS ACME plugin list", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - ids, err := state.collector.collectPBSAcmePluginsListRuntime(ctx, commandsDir) - if err != nil { - return err - } - state.pbs.acmePluginIDs = ids - return nil - }, - }, - { - ID: brickPBSRuntimeACMEPluginConfig, - Description: "Collect PBS ACME plugin configuration", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSAcmePluginConfigRuntime(ctx, commandsDir, state.pbs.acmePluginIDs) - }, - }, - { - ID: brickPBSRuntimeNotificationTargets, - Description: "Collect PBS notification targets", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSNotificationTargetsRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeNotificationMatchers, - Description: "Collect PBS notification matchers", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSNotificationMatchersRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeNotificationEndpointSMTP, - Description: "Collect PBS SMTP notification endpoints", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSNotificationEndpointSMTPRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeNotificationEndpointSendmail, - Description: "Collect PBS sendmail notification endpoints", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSNotificationEndpointSendmailRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeNotificationEndpointGotify, - Description: "Collect PBS gotify notification endpoints", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSNotificationEndpointGotifyRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeNotificationEndpointWebhook, - Description: "Collect PBS webhook notification endpoints", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSNotificationEndpointWebhookRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeNotificationSummary, - Description: "Write the PBS notification summary", - Run: func(_ context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - state.collector.writePBSNotificationSummary(commandsDir) - return nil - }, - }, - { - ID: brickPBSRuntimeAccessUsers, - Description: "Collect the PBS user list", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - ids, err := state.collector.collectPBSAccessUsersRuntime(ctx, commandsDir) - if err != nil { - return err - } - state.pbs.userIDs = ids - return nil - }, - }, - { - ID: brickPBSRuntimeAccessRealmsLDAP, - Description: "Collect PBS LDAP realm definitions", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSAccessRealmLDAPRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeAccessRealmsAD, - Description: "Collect PBS Active Directory realm definitions", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSAccessRealmADRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeAccessRealmsOpenID, - Description: "Collect PBS OpenID realm definitions", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSAccessRealmOpenIDRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeAccessACL, - Description: "Collect PBS ACL definitions", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSAccessACLRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeAccessUserTokens, - Description: "Collect PBS API token snapshots", - Run: func(ctx context.Context, state *collectionState) error { - if !state.collector.config.BackupUserConfigs || len(state.pbs.userIDs) == 0 { - return nil - } - usersDir, err := state.collector.ensurePBSAccessControlDir() - if err != nil { - return err - } - return state.collector.collectPBSAccessUserTokensRuntime(ctx, usersDir, state.pbs.userIDs) - }, - }, - { - ID: brickPBSRuntimeAccessTokensAggregate, - Description: "Aggregate PBS API token snapshots", - Run: func(_ context.Context, state *collectionState) error { - if !state.collector.config.BackupUserConfigs || len(state.pbs.userIDs) == 0 { - return nil - } - usersDir, err := state.collector.ensurePBSAccessControlDir() - if err != nil { - return err - } - return state.collector.collectPBSAccessTokensAggregateRuntime(usersDir, state.pbs.userIDs) - }, - }, - { - ID: brickPBSRuntimeRemotes, - Description: "Collect PBS remote definitions", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSRemotesRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeSyncJobs, - Description: "Collect PBS sync jobs", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSSyncJobsRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeVerificationJobs, - Description: "Collect PBS verification jobs", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSVerificationJobsRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimePruneJobs, - Description: "Collect PBS prune jobs", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSPruneJobsRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeGCJobs, - Description: "Collect PBS garbage collection jobs", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSGCJobsRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeTapeDetect, - Description: "Detect PBS tape support", - Run: func(ctx context.Context, state *collectionState) error { - if !state.collector.config.BackupTapeConfigs { - state.pbs.tapeSupportKnown = true - state.pbs.tapeSupported = false - return nil - } - supported, err := state.collector.detectPBSTapeSupport(ctx) - if err != nil { - if ctx.Err() != nil { - return err - } - state.collector.logger.Debug("Skipping tape details collection: %v", err) - state.pbs.tapeSupportKnown = true - state.pbs.tapeSupported = false - return nil - } - state.pbs.tapeSupportKnown = true - state.pbs.tapeSupported = supported - return nil - }, - }, - { - ID: brickPBSRuntimeTapeDrives, - Description: "Collect PBS tape drive inventory", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSTapeDrivesRuntime(ctx, commandsDir, state.pbs.tapeSupportKnown && state.pbs.tapeSupported) - }, - }, - { - ID: brickPBSRuntimeTapeChangers, - Description: "Collect PBS tape changer inventory", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSTapeChangersRuntime(ctx, commandsDir, state.pbs.tapeSupportKnown && state.pbs.tapeSupported) - }, - }, - { - ID: brickPBSRuntimeTapePools, - Description: "Collect PBS tape pool inventory", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSTapePoolsRuntime(ctx, commandsDir, state.pbs.tapeSupportKnown && state.pbs.tapeSupported) - }, - }, - { - ID: brickPBSRuntimeNetwork, - Description: "Collect PBS network runtime information", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSNetworkRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeDisks, - Description: "Collect the PBS disk inventory", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSDisksRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeCertInfo, - Description: "Collect the PBS certificate summary", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSCertInfoRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeTrafficControl, - Description: "Collect PBS traffic control runtime information", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSTrafficControlRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeRecentTasks, - Description: "Collect recent PBS tasks", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSRecentTasksRuntime(ctx, commandsDir) - }, - }, - { - ID: brickPBSRuntimeS3Endpoints, - Description: "Collect PBS S3 endpoints", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - ids, err := state.collector.collectPBSS3EndpointsRuntime(ctx, commandsDir) - if err != nil { - return err - } - state.pbs.s3EndpointIDs = ids - return nil - }, - }, - { - ID: brickPBSRuntimeS3EndpointBuckets, - Description: "Collect PBS S3 endpoint bucket inventories", - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.collectPBSS3EndpointBucketsRuntime(ctx, commandsDir, state.pbs.s3EndpointIDs) - }, - }, - } -} - -func newCommonFilesystemBricks() []collectionBrick { - return []collectionBrick{ - { - ID: brickCommonFilesystemFstab, - Description: "Collect the common filesystem table", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectCommonFilesystemFstab(ctx) - }, - }, - } -} - -func newCommonStorageStackBricks() []collectionBrick { - return []collectionBrick{ - { - ID: brickCommonStorageStackCrypttab, - Description: "Collect common storage-stack crypttab data", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectCommonStorageStackCrypttab(ctx) - }, - }, - { - ID: brickCommonStorageStackISCSISnapshot, - Description: "Collect common iSCSI storage-stack data", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectCommonStorageStackISCSISnapshot(ctx) - }, - }, - { - ID: brickCommonStorageStackMultipathSnapshot, - Description: "Collect common multipath storage-stack data", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectCommonStorageStackMultipathSnapshot(ctx) - }, - }, - { - ID: brickCommonStorageStackMDADMSnapshot, - Description: "Collect common mdadm storage-stack data", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectCommonStorageStackMDADMSnapshot(ctx) - }, - }, - { - ID: brickCommonStorageStackLVMSnapshot, - Description: "Collect common LVM storage-stack data", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectCommonStorageStackLVMSnapshot(ctx) - }, - }, - { - ID: brickCommonStorageStackMountUnitsSnapshot, - Description: "Collect common storage-stack mount units", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectCommonStorageStackMountUnitsSnapshot(ctx) - }, - }, - { - ID: brickCommonStorageStackAutofsSnapshot, - Description: "Collect common storage-stack autofs data", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectCommonStorageStackAutofsSnapshot(ctx) - }, - }, - { - ID: brickCommonStorageStackReferencedFiles, - Description: "Collect common storage-stack referenced files", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectCommonStorageStackReferencedFiles(ctx) - }, - }, - } -} - -func newPBSInventoryBricks() []collectionBrick { - return []collectionBrick{ - { - ID: brickPBSInventoryInit, - Description: "Initialize the PBS datastore inventory state", - Run: func(ctx context.Context, state *collectionState) error { - inventory, err := state.collector.initPBSDatastoreInventoryState(ctx, state.pbs.datastores) - if err != nil { - return err - } - state.pbs.inventory = inventory - return nil - }, - }, - { - ID: brickPBSInventoryMountFiles, - Description: "Populate PBS inventory mount files", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryMountFiles(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryOSFiles, - Description: "Populate PBS inventory OS files", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryOSFiles(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryMultipathFiles, - Description: "Populate PBS inventory multipath files", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryMultipathFiles(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryISCSIFiles, - Description: "Populate PBS inventory iSCSI files", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryISCSIFiles(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryAutofsFiles, - Description: "Populate PBS inventory autofs files", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryAutofsFiles(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryZFSFiles, - Description: "Populate PBS inventory ZFS files", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryZFSFiles(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryLVMDirs, - Description: "Populate PBS inventory LVM directories", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryLVMDirs(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventorySystemdMountUnits, - Description: "Populate PBS inventory systemd mount units", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventorySystemdMountUnits(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryReferencedFiles, - Description: "Populate PBS inventory referenced files", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryReferencedFiles(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryHostCommandsCore, - Description: "Populate PBS inventory core host commands", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryHostCommandsCore(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryHostCommandsDMSetup, - Description: "Populate PBS inventory dmsetup host commands", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryHostCommandsDMSetup(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryHostCommandsLVM, - Description: "Populate PBS inventory LVM host commands", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryHostCommandsLVM(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryHostCommandsMDADM, - Description: "Populate PBS inventory mdadm host commands", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryHostCommandsMDADM(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryHostCommandsMultipath, - Description: "Populate PBS inventory multipath host commands", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryHostCommandsMultipath(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryHostCommandsISCSI, - Description: "Populate PBS inventory iSCSI host commands", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryHostCommandsISCSI(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryHostCommandsZFS, - Description: "Populate PBS inventory ZFS host commands", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSInventoryHostCommandsZFS(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryCommandFiles, - Description: "Populate PBS inventory with collected PBS command files", - Run: func(_ context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.populatePBSInventoryCommandFiles(state.ensurePBSInventoryState(), commandsDir) - }, - }, - { - ID: brickPBSInventoryDatastores, - Description: "Populate PBS datastore inventory entries", - Run: func(ctx context.Context, state *collectionState) error { - return state.collector.populatePBSDatastoreInventoryEntries(ctx, state.ensurePBSInventoryState()) - }, - }, - { - ID: brickPBSInventoryWrite, - Description: "Write the PBS datastore inventory report", - Run: func(_ context.Context, state *collectionState) error { - commandsDir, err := state.ensurePBSCommandsDir() - if err != nil { - return err - } - return state.collector.writePBSInventoryState(state.ensurePBSInventoryState(), commandsDir) - }, - }, - } -} - -func newPBSDatastoreConfigBricks() []collectionBrick { - return []collectionBrick{ - { - ID: brickPBSDatastoreCLIConfigs, - Description: "Collect PBS datastore CLI configuration files", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupDatastoreConfigs { - c.logger.Skip("PBS datastore configuration backup disabled.") - return nil - } - cfgState, err := state.ensurePBSDatastoreConfigState() - if err != nil { - c.logger.Warning("Failed to prepare datastore config state: %v", err) - return nil - } - if err := c.collectPBSDatastoreCLIConfigs(ctx, cfgState); err != nil { - c.logger.Warning("Failed to collect datastore CLI configs: %v", err) - } - return nil - }, - }, - { - ID: brickPBSDatastoreNamespaces, - Description: "Collect PBS datastore namespace inventories", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupDatastoreConfigs { - return nil - } - cfgState, err := state.ensurePBSDatastoreConfigState() - if err != nil { - c.logger.Warning("Failed to prepare datastore config state: %v", err) - return nil - } - if err := c.collectPBSDatastoreNamespaces(ctx, cfgState); err != nil { - c.logger.Warning("Failed to collect datastore namespaces: %v", err) - } - return nil - }, - }, - } -} - -func newPBSPXARBricks() []collectionBrick { - return []collectionBrick{ - { - ID: brickPBSPXARPrepare, - Description: "Prepare PBS PXAR state", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if !c.config.BackupPxarFiles { - c.logger.Skip("PBS PXAR metadata collection disabled.") - return nil - } - pxarState, err := state.ensurePBSPXARState(ctx) - if err != nil { - c.logger.Warning("Failed to prepare PBS PXAR state: %v", err) - return nil - } - state.pbs.pxar = pxarState - return nil - }, - }, - { - ID: brickPBSPXARMetadata, - Description: "Collect PBS PXAR metadata snapshots", - Run: func(ctx context.Context, state *collectionState) error { - if !state.collector.config.BackupPxarFiles { - return nil - } - pxarState, err := state.ensurePBSPXARState(ctx) - if err != nil { - state.collector.logger.Warning("Failed to prepare PBS PXAR state: %v", err) - return nil - } - if err := state.collector.collectPBSPXARMetadataStep(ctx, pxarState); err != nil { - state.collector.logger.Warning("Failed to collect PBS PXAR metadata: %v", err) - } - return nil - }, - }, - { - ID: brickPBSPXARSubdirReports, - Description: "Collect PBS PXAR subdirectory reports", - Run: func(ctx context.Context, state *collectionState) error { - if !state.collector.config.BackupPxarFiles { - return nil - } - pxarState, err := state.ensurePBSPXARState(ctx) - if err != nil { - state.collector.logger.Warning("Failed to prepare PBS PXAR state: %v", err) - return nil - } - if err := state.collector.collectPBSPXARSubdirReportsStep(ctx, pxarState); err != nil { - state.collector.logger.Warning("Failed to collect PBS PXAR subdir reports: %v", err) - } - return nil - }, - }, - { - ID: brickPBSPXARVMLists, - Description: "Collect PBS PXAR VM reports", - Run: func(ctx context.Context, state *collectionState) error { - if !state.collector.config.BackupPxarFiles { - return nil - } - pxarState, err := state.ensurePBSPXARState(ctx) - if err != nil { - state.collector.logger.Warning("Failed to prepare PBS PXAR state: %v", err) - return nil - } - if err := state.collector.collectPBSPXARVMListsStep(ctx, pxarState); err != nil { - state.collector.logger.Warning("Failed to collect PBS PXAR VM lists: %v", err) - } - return nil - }, - }, - { - ID: brickPBSPXARCTLists, - Description: "Collect PBS PXAR CT reports", - Run: func(ctx context.Context, state *collectionState) error { - if !state.collector.config.BackupPxarFiles { - return nil - } - pxarState, err := state.ensurePBSPXARState(ctx) - if err != nil { - state.collector.logger.Warning("Failed to prepare PBS PXAR state: %v", err) - return nil - } - if err := state.collector.collectPBSPXARCTListsStep(ctx, pxarState); err != nil { - state.collector.logger.Warning("Failed to collect PBS PXAR CT lists: %v", err) - } - return nil - }, - }, - } -} - -func newPBSFeatureBricks() []collectionBrick { - bricks := append([]collectionBrick{}, newPBSDatastoreConfigBricks()...) - bricks = append(bricks, newPBSPXARBricks()...) - return bricks -} - -func newPBSFinalizeBricks() []collectionBrick { - return []collectionBrick{ - { - ID: brickPBSFinalizeSummary, - Description: "Finalize PBS collection state", - Run: func(_ context.Context, state *collectionState) error { - c := state.collector - c.logger.Info("PBS collection summary:") - c.logger.Info(" Files collected: %d", c.stats.FilesProcessed) - c.logger.Info(" Files not found: %d", c.stats.FilesNotFound) - if c.stats.FilesFailed > 0 { - c.logger.Warning(" Files failed: %d", c.stats.FilesFailed) - } - c.logger.Debug(" Files skipped: %d", c.stats.FilesSkipped) - c.logger.Debug(" Bytes collected: %d", c.stats.BytesCollected) - return nil - }, - }, - } -} - -func newSystemRecipe() recipe { - systemCommandsBrick := func(id BrickID, description string, run func(*Collector, context.Context, string) error) collectionBrick { - return collectionBrick{ - ID: id, - Description: description, - Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return run(state.collector, ctx, commandsDir) - }, - } - } - - bricks := []collectionBrick{ - {ID: brickSystemNetworkStatic, Description: "Collect static network configuration", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemNetworkStatic(ctx) - }}, - {ID: brickSystemIdentityStatic, Description: "Collect static identity files", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemIdentityStatic(ctx) - }}, - {ID: brickSystemAptStatic, Description: "Collect static APT configuration", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemAptStatic(ctx) - }}, - {ID: brickSystemCronStatic, Description: "Collect static cron configuration", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemCronStatic(ctx) - }}, - {ID: brickSystemServicesStatic, Description: "Collect static service configuration", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemServicesStatic(ctx) - }}, - {ID: brickSystemLoggingStatic, Description: "Collect static logging configuration", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemLoggingStatic(ctx) - }}, - {ID: brickSystemSSLStatic, Description: "Collect static SSL configuration", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemSSLStatic(ctx) - }}, - {ID: brickSystemSysctlStatic, Description: "Collect static sysctl configuration", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemSysctlStatic(ctx) - }}, - {ID: brickSystemKernelModulesStatic, Description: "Collect static kernel module configuration", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemKernelModuleStatic(ctx) - }}, - } - bricks = append(bricks, newCommonFilesystemBricks()...) - bricks = append(bricks, newCommonStorageStackBricks()...) - bricks = append(bricks, []collectionBrick{ - {ID: brickSystemZFSStatic, Description: "Collect static ZFS configuration", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemZFSStatic(ctx) - }}, - {ID: brickSystemFirewallStatic, Description: "Collect static firewall configuration", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemFirewallStatic(ctx) - }}, - {ID: brickSystemRuntimeLeases, Description: "Collect runtime lease snapshots", Run: func(ctx context.Context, state *collectionState) error { - return state.collector.collectSystemRuntimeLeases(ctx) - }}, - {ID: brickSystemCoreRuntime, Description: "Collect core system runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemCoreRuntime(ctx, commandsDir) - }}, - systemCommandsBrick(brickSystemNetworkRuntimeAddr, "Collect network address runtime information", (*Collector).collectSystemNetworkAddrRuntime), - systemCommandsBrick(brickSystemNetworkRuntimeRules, "Collect network rule runtime information", (*Collector).collectSystemNetworkRulesRuntime), - systemCommandsBrick(brickSystemNetworkRuntimeRoutes, "Collect network route runtime information", (*Collector).collectSystemNetworkRoutesRuntime), - systemCommandsBrick(brickSystemNetworkRuntimeLinks, "Collect network link runtime information", (*Collector).collectSystemNetworkLinksRuntime), - systemCommandsBrick(brickSystemNetworkRuntimeNeighbors, "Collect network neighbor runtime information", (*Collector).collectSystemNetworkNeighborsRuntime), - systemCommandsBrick(brickSystemNetworkRuntimeBridges, "Collect bridge runtime information", (*Collector).collectSystemNetworkBridgesRuntime), - systemCommandsBrick(brickSystemNetworkRuntimeInventory, "Collect network inventory runtime information", (*Collector).collectSystemNetworkInventoryRuntime), - systemCommandsBrick(brickSystemNetworkRuntimeBonding, "Collect bonding runtime information", (*Collector).collectSystemNetworkBondingRuntime), - systemCommandsBrick(brickSystemNetworkRuntimeDNS, "Collect DNS runtime information", (*Collector).collectSystemNetworkDNSRuntime), - {ID: brickSystemStorageRuntimeMounts, Description: "Collect storage mount runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemStorageMountsRuntime(ctx, commandsDir) - }}, - {ID: brickSystemStorageRuntimeBlock, Description: "Collect block device runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemStorageBlockDevicesRuntime(ctx, commandsDir) - }}, - {ID: brickSystemComputeRuntimeMemoryCPU, Description: "Collect memory and CPU runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemComputeMemoryCPURuntime(ctx, commandsDir) - }}, - {ID: brickSystemComputeRuntimeBusInv, Description: "Collect bus inventory runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemComputeBusInventoryRuntime(ctx, commandsDir) - }}, - {ID: brickSystemServicesRuntime, Description: "Collect service runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemServicesRuntime(ctx, commandsDir) - }}, - {ID: brickSystemPackagesRuntimeInstalled, Description: "Collect installed package runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemPackagesInstalledRuntime(ctx, commandsDir) - }}, - {ID: brickSystemPackagesRuntimeAPTPolicy, Description: "Collect APT policy runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemPackagesAptPolicyRuntime(ctx, commandsDir) - }}, - systemCommandsBrick(brickSystemFirewallRuntimeIPTables, "Collect iptables runtime information", (*Collector).collectSystemFirewallIPTablesRuntime), - systemCommandsBrick(brickSystemFirewallRuntimeIP6Tables, "Collect ip6tables runtime information", (*Collector).collectSystemFirewallIP6TablesRuntime), - systemCommandsBrick(brickSystemFirewallRuntimeNFTables, "Collect nftables runtime information", (*Collector).collectSystemFirewallNFTablesRuntime), - systemCommandsBrick(brickSystemFirewallRuntimeUFW, "Collect UFW runtime information", (*Collector).collectSystemFirewallUFWRuntime), - systemCommandsBrick(brickSystemFirewallRuntimeFirewalld, "Collect firewalld runtime information", (*Collector).collectSystemFirewallFirewalldRuntime), - {ID: brickSystemKernelModulesRuntime, Description: "Collect kernel module runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemKernelModulesRuntime(ctx, commandsDir) - }}, - {ID: brickSystemSysctlRuntime, Description: "Collect sysctl runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemSysctlRuntime(ctx, commandsDir) - }}, - {ID: brickSystemZFSRuntime, Description: "Collect ZFS runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemZFSRuntime(ctx, commandsDir) - }}, - {ID: brickSystemLVMRuntime, Description: "Collect LVM runtime information", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - return state.collector.collectSystemLVMRuntime(ctx, commandsDir) - }}, - {ID: brickSystemNetworkReport, Description: "Finalize derived system reports", Run: func(ctx context.Context, state *collectionState) error { - commandsDir, err := state.ensureSystemCommandsDir() - if err != nil { - return err - } - if err := state.collector.finalizeSystemRuntimeReports(ctx, commandsDir); err != nil { - state.collector.logger.Debug("Network report generation failed: %v", err) - } - return nil - }}, - { - ID: brickSystemKernel, - Description: "Collect kernel information", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - c.logger.Debug("Collecting kernel information (uname/modules)") - if err := c.collectKernelInfo(ctx); err != nil { - c.logger.Warning("Failed to collect kernel info: %v", err) - } else { - c.logger.Debug("Kernel information collected successfully") - } - return nil - }, - }, - { - ID: brickSystemHardware, - Description: "Collect hardware information", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - c.logger.Debug("Collecting hardware inventory (CPU/memory/devices)") - if err := c.collectHardwareInfo(ctx); err != nil { - c.logger.Warning("Failed to collect hardware info: %v", err) - } else { - c.logger.Debug("Hardware inventory collected successfully") - } - return nil - }, - }, - { - ID: brickSystemCriticalFiles, - Description: "Collect critical system files", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if c.config.BackupCriticalFiles { - c.logger.Debug("Collecting critical files specified in configuration") - if err := c.collectCriticalFiles(ctx); err != nil { - c.logger.Warning("Failed to collect critical files: %v", err) - } else { - c.logger.Debug("Critical files collected successfully") - } - } - return nil - }, - }, - { - ID: brickSystemConfigFile, - Description: "Collect backup configuration file", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if c.config.BackupConfigFile { - c.logger.Debug("Collecting backup configuration file") - if err := c.collectConfigFile(ctx); err != nil { - c.logger.Warning("Failed to collect backup configuration file: %v", err) - } else { - c.logger.Debug("Backup configuration file collected successfully") - } - } - return nil - }, - }, - { - ID: brickSystemCustomPaths, - Description: "Collect custom backup paths", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if len(c.config.CustomBackupPaths) > 0 { - c.logger.Debug("Collecting custom paths: %v", c.config.CustomBackupPaths) - if err := c.collectCustomPaths(ctx); err != nil { - c.logger.Warning("Failed to collect custom paths: %v", err) - } else { - c.logger.Debug("Custom paths collected successfully") - } - } - return nil - }, - }, - { - ID: brickSystemScriptDirs, - Description: "Collect script directories", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if c.config.BackupScriptDir { - c.logger.Debug("Collecting script directories (/usr/local/bin,/usr/local/sbin)") - if err := c.collectScriptDirectories(ctx); err != nil { - c.logger.Warning("Failed to collect script directories: %v", err) - } else { - c.logger.Debug("Script directories collected successfully") - } - } - return nil - }, - }, - { - ID: brickSystemScriptRepo, - Description: "Collect script repository", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if c.config.BackupScriptRepository { - c.logger.Debug("Collecting script repository from %s", c.config.ScriptRepositoryPath) - if err := c.collectScriptRepository(ctx); err != nil { - c.logger.Warning("Failed to collect script repository: %v", err) - } else { - c.logger.Debug("Script repository collected successfully") - } - } - return nil - }, - }, - { - ID: brickSystemSSHKeys, - Description: "Collect SSH keys", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if c.config.BackupSSHKeys { - c.logger.Debug("Collecting SSH keys for root and users") - if err := c.collectSSHKeys(ctx); err != nil { - c.logger.Warning("Failed to collect SSH keys: %v", err) - } else { - c.logger.Debug("SSH keys collected successfully") - } - } - return nil - }, - }, - { - ID: brickSystemRootHome, - Description: "Collect root home directory", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if c.config.BackupRootHome { - c.logger.Debug("Collecting /root home directory") - if err := c.collectRootHome(ctx); err != nil { - c.logger.Warning("Failed to collect root home files: %v", err) - } else { - c.logger.Debug("Root home directory collected successfully") - } - } - return nil - }, - }, - { - ID: brickSystemUserHomes, - Description: "Collect user home directories", - Run: func(ctx context.Context, state *collectionState) error { - c := state.collector - if c.config.BackupUserHomes { - c.logger.Debug("Collecting user home directories under /home") - if err := c.collectUserHomes(ctx); err != nil { - c.logger.Warning("Failed to collect user home directories: %v", err) - } else { - c.logger.Debug("User home directories collected successfully") - } - } - return nil - }, - }, - }...) - return recipe{Name: "system", Bricks: bricks} -} - -func (p pveContext) runtimeNodes() []string { - if p.runtimeInfo == nil { - return nil - } - return p.runtimeInfo.Nodes -} - -func (p pveContext) runtimeStorages() []pveStorageEntry { - if p.runtimeInfo == nil { - return nil - } - return p.runtimeInfo.Storages -} - -func (p *pveContext) ensureStorageScanResults() map[string]*pveStorageScanResult { - if p.storageScanResults == nil { - p.storageScanResults = make(map[string]*pveStorageScanResult) - } - return p.storageScanResults -} - -func (p pveContext) storageResult(storage pveStorageEntry) *pveStorageScanResult { - if p.storageScanResults == nil { - return nil - } - return p.storageScanResults[storage.pathKey()] -} diff --git a/internal/backup/collector_bricks_common.go b/internal/backup/collector_bricks_common.go new file mode 100644 index 00000000..1912384a --- /dev/null +++ b/internal/backup/collector_bricks_common.go @@ -0,0 +1,21 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. +package backup + +func newCommonFilesystemBricks() []collectionBrick { + return []collectionBrick{ + collectorBrick(brickCommonFilesystemFstab, "Collect the common filesystem table", (*Collector).collectCommonFilesystemFstab), + } +} + +func newCommonStorageStackBricks() []collectionBrick { + return []collectionBrick{ + collectorBrick(brickCommonStorageStackCrypttab, "Collect common storage-stack crypttab data", (*Collector).collectCommonStorageStackCrypttab), + collectorBrick(brickCommonStorageStackISCSISnapshot, "Collect common iSCSI storage-stack data", (*Collector).collectCommonStorageStackISCSISnapshot), + collectorBrick(brickCommonStorageStackMultipathSnapshot, "Collect common multipath storage-stack data", (*Collector).collectCommonStorageStackMultipathSnapshot), + collectorBrick(brickCommonStorageStackMDADMSnapshot, "Collect common mdadm storage-stack data", (*Collector).collectCommonStorageStackMDADMSnapshot), + collectorBrick(brickCommonStorageStackLVMSnapshot, "Collect common LVM storage-stack data", (*Collector).collectCommonStorageStackLVMSnapshot), + collectorBrick(brickCommonStorageStackMountUnitsSnapshot, "Collect common storage-stack mount units", (*Collector).collectCommonStorageStackMountUnitsSnapshot), + collectorBrick(brickCommonStorageStackAutofsSnapshot, "Collect common storage-stack autofs data", (*Collector).collectCommonStorageStackAutofsSnapshot), + collectorBrick(brickCommonStorageStackReferencedFiles, "Collect common storage-stack referenced files", (*Collector).collectCommonStorageStackReferencedFiles), + } +} diff --git a/internal/backup/collector_bricks_pbs.go b/internal/backup/collector_bricks_pbs.go new file mode 100644 index 00000000..098f14bc --- /dev/null +++ b/internal/backup/collector_bricks_pbs.go @@ -0,0 +1,280 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. +package backup + +import ( + "context" + "errors" + "fmt" + "os" + "strings" +) + +func newPBSRecipe() recipe { + bricks := []collectionBrick{ + { + ID: brickPBSValidate, + Description: "Validate PBS environment", + Run: func(_ context.Context, state *collectionState) error { + c := state.collector + c.logger.Debug("Validating PBS environment before collection") + + pbsConfigPath := c.pbsConfigPath() + if _, err := os.Stat(pbsConfigPath); err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("not a PBS system: %s not found", pbsConfigPath) + } + return fmt.Errorf("failed to access PBS config path %s: %w", pbsConfigPath, err) + } + c.logger.Debug("Detected %s, proceeding with PBS collection", pbsConfigPath) + return nil + }, + }, + { + ID: brickPBSConfigDirectoryCopy, + Description: "Copy the PBS configuration directory", + Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSConfigSnapshot(ctx, state.collector.pbsConfigPath()) + }, + }, + { + ID: brickPBSManifestInit, + Description: "Initialize the PBS manifest", + Run: func(_ context.Context, state *collectionState) error { + state.collector.initPBSManifest() + return nil + }, + }, + { + ID: brickPBSDatastoreDiscovery, + Description: "Discover PBS datastores", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + datastores, err := c.getDatastoreList(ctx) + if err != nil { + if ctx.Err() != nil { + return err + } + return fmt.Errorf("failed to detect PBS datastores: %w", err) + } + state.pbs.datastores = datastores + c.logger.Debug("Detected %d PBS datastores", len(datastores)) + + if len(datastores) == 0 { + c.logger.Info("Found 0 PBS datastore(s) via auto-detection") + } else { + summary := make([]string, 0, len(datastores)) + for _, ds := range datastores { + if ds.Path != "" { + summary = append(summary, fmt.Sprintf("%s (%s)", ds.Name, ds.Path)) + } else { + summary = append(summary, ds.Name) + } + } + c.logger.Info("Found %d PBS datastore(s) via auto-detection: %s", len(datastores), strings.Join(summary, ", ")) + } + return nil + }, + }, + } + bricks = append(bricks, newPBSManifestBricks()...) + bricks = append(bricks, newPBSRuntimeBricks()...) + bricks = append(bricks, newPBSInventoryBricks()...) + bricks = append(bricks, newPBSFeatureBricks()...) + bricks = append(bricks, newPBSFinalizeBricks()...) + return recipe{Name: "pbs", Bricks: bricks} +} + +func newPBSCommandsRecipe() recipe { + return recipe{Name: "pbs-commands", Bricks: newPBSRuntimeBricks()} +} + +func newPBSDatastoreInventoryRecipe() recipe { + return recipe{Name: "pbs-inventory", Bricks: newPBSInventoryBricks()} +} + +func newPBSDatastoreConfigRecipe() recipe { + return recipe{Name: "pbs-datastore-config", Bricks: newPBSDatastoreConfigBricks()} +} + +func newPBSPXARRecipe() recipe { + return recipe{Name: "pbs-pxar", Bricks: newPBSPXARBricks()} +} + +func newPBSUserConfigRecipe() recipe { + return recipe{ + Name: "pbs-user-config", + Bricks: []collectionBrick{ + { + ID: BrickID("pbs_access_load_user_ids_from_command_file"), + Description: "Load PBS user IDs from collected command snapshots", + Run: func(_ context.Context, state *collectionState) error { + userIDs, err := state.collector.loadPBSUserIDsFromCommandFile(state.collector.proxsaveCommandsDir("pbs")) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + state.collector.logger.Debug("User list not available for token export: %v", err) + state.pbs.userIDs = nil + return nil + } + state.collector.logger.Debug("Failed to parse user list for token export: %v", err) + state.pbs.userIDs = nil + return nil + } + state.pbs.userIDs = userIDs + return nil + }, + }, + { + ID: brickPBSRuntimeAccessUserTokens, + Description: "Collect PBS API token snapshots", + Run: func(ctx context.Context, state *collectionState) error { + if len(state.pbs.userIDs) == 0 { + return nil + } + usersDir, err := state.collector.ensurePBSAccessControlDir() + if err != nil { + return err + } + return state.collector.collectPBSAccessUserTokensRuntime(ctx, usersDir, state.pbs.userIDs) + }, + }, + { + ID: brickPBSRuntimeAccessTokensAggregate, + Description: "Aggregate PBS API token snapshots", + Run: func(_ context.Context, state *collectionState) error { + if len(state.pbs.userIDs) == 0 { + return nil + } + usersDir, err := state.collector.ensurePBSAccessControlDir() + if err != nil { + return err + } + return state.collector.collectPBSAccessTokensAggregateRuntime(usersDir, state.pbs.userIDs) + }, + }, + }, + } +} + +func newPBSManifestBricks() []collectionBrick { + bricks := []collectionBrick{} + bricks = append(bricks, newPBSManifestDatastoreNodeBricks()...) + bricks = append(bricks, newPBSManifestACMEAndMetricsBricks()...) + bricks = append(bricks, newPBSManifestNotificationAccessBricks()...) + bricks = append(bricks, newPBSManifestRemoteJobBricks()...) + bricks = append(bricks, newPBSManifestTapeAndNetworkBricks()...) + return bricks +} +func newPBSManifestDatastoreNodeBricks() []collectionBrick { + return []collectionBrick{ + {ID: brickPBSManifestDatastore, Description: "Collect PBS datastore manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestDatastore(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestS3, Description: "Collect PBS S3 manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestS3(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestNode, Description: "Collect PBS node manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestNode(ctx, state.collector.pbsConfigPath()) + }}, + } +} + +func newPBSManifestACMEAndMetricsBricks() []collectionBrick { + return []collectionBrick{ + {ID: brickPBSManifestACMEAccounts, Description: "Collect PBS ACME account manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestACMEAccounts(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestACMEPlugins, Description: "Collect PBS ACME plugin manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestACMEPlugins(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestMetricServers, Description: "Collect PBS metric server manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestMetricServers(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestTrafficControl, Description: "Collect PBS traffic control manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestTrafficControl(ctx, state.collector.pbsConfigPath()) + }}, + } +} + +func newPBSManifestNotificationAccessBricks() []collectionBrick { + return []collectionBrick{ + {ID: brickPBSManifestNotifications, Description: "Collect PBS notification manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestNotifications(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestNotificationsPriv, Description: "Collect PBS notification secret manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestNotificationsPriv(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestUserCfg, Description: "Collect PBS user manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestUserCfg(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestACLCfg, Description: "Collect PBS ACL manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestACLCfg(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestDomainsCfg, Description: "Collect PBS auth realm manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestDomainsCfg(ctx, state.collector.pbsConfigPath()) + }}, + } +} + +func newPBSManifestRemoteJobBricks() []collectionBrick { + return []collectionBrick{ + {ID: brickPBSManifestRemote, Description: "Collect PBS remote manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestRemote(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestSyncJobs, Description: "Collect PBS sync-job manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestSyncJobs(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestVerificationJobs, Description: "Collect PBS verification-job manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestVerificationJobs(ctx, state.collector.pbsConfigPath()) + }}, + } +} + +func newPBSManifestTapeAndNetworkBricks() []collectionBrick { + return []collectionBrick{ + {ID: brickPBSManifestTapeCfg, Description: "Collect PBS tape configuration manifest entry", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestTapeCfg(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestTapeJobs, Description: "Collect PBS tape job manifest entry", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestTapeJobs(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestMediaPools, Description: "Collect PBS media pool manifest entry", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestMediaPools(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestTapeEncryptionKeys, Description: "Collect PBS tape encryption keys manifest entry", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestTapeEncryptionKeys(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestNetwork, Description: "Collect PBS network manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestNetwork(ctx, state.collector.pbsConfigPath()) + }}, + {ID: brickPBSManifestPrune, Description: "Collect PBS prune manifest entries", Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPBSManifestPrune(ctx, state.collector.pbsConfigPath()) + }}, + } +} + +func newPBSFeatureBricks() []collectionBrick { + bricks := append([]collectionBrick{}, newPBSDatastoreConfigBricks()...) + bricks = append(bricks, newPBSPXARBricks()...) + return bricks +} + +func newPBSFinalizeBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPBSFinalizeSummary, + Description: "Finalize PBS collection state", + Run: func(_ context.Context, state *collectionState) error { + c := state.collector + c.logger.Info("PBS collection summary:") + c.logger.Info(" Files collected: %d", c.stats.FilesProcessed) + c.logger.Info(" Files not found: %d", c.stats.FilesNotFound) + if c.stats.FilesFailed > 0 { + c.logger.Warning(" Files failed: %d", c.stats.FilesFailed) + } + c.logger.Debug(" Files skipped: %d", c.stats.FilesSkipped) + c.logger.Debug(" Bytes collected: %d", c.stats.BytesCollected) + return nil + }, + }, + } +} diff --git a/internal/backup/collector_bricks_pbs_features.go b/internal/backup/collector_bricks_pbs_features.go new file mode 100644 index 00000000..16f98ae6 --- /dev/null +++ b/internal/backup/collector_bricks_pbs_features.go @@ -0,0 +1,172 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. +package backup + +import "context" + +func newPBSDatastoreConfigBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPBSDatastoreCLIConfigs, + Description: "Collect PBS datastore CLI configuration files", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupDatastoreConfigs { + c.logger.Skip("PBS datastore configuration backup disabled.") + return nil + } + cfgState, err := state.ensurePBSDatastoreConfigState() + if err != nil { + c.logger.Warning("Failed to prepare datastore config state: %v", err) + return nil + } + if err := c.collectPBSDatastoreCLIConfigs(ctx, cfgState); err != nil { + c.logger.Warning("Failed to collect datastore CLI configs: %v", err) + } + return nil + }, + }, + { + ID: brickPBSDatastoreNamespaces, + Description: "Collect PBS datastore namespace inventories", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupDatastoreConfigs { + return nil + } + cfgState, err := state.ensurePBSDatastoreConfigState() + if err != nil { + c.logger.Warning("Failed to prepare datastore config state: %v", err) + return nil + } + if err := c.collectPBSDatastoreNamespaces(ctx, cfgState); err != nil { + c.logger.Warning("Failed to collect datastore namespaces: %v", err) + } + return nil + }, + }, + } +} + +func newPBSPXARBricks() []collectionBrick { + bricks := []collectionBrick{} + bricks = append(bricks, newPBSPXARPrepareBricks()...) + bricks = append(bricks, newPBSPXARMetadataBricks()...) + bricks = append(bricks, newPBSPXARSubdirReportBricks()...) + bricks = append(bricks, newPBSPXARVMListBricks()...) + bricks = append(bricks, newPBSPXARCTListBricks()...) + return bricks +} +func newPBSPXARPrepareBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPBSPXARPrepare, + Description: "Prepare PBS PXAR state", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPxarFiles { + c.logger.Skip("PBS PXAR metadata collection disabled.") + return nil + } + pxarState, err := state.ensurePBSPXARState(ctx) + if err != nil { + c.logger.Warning("Failed to prepare PBS PXAR state: %v", err) + return nil + } + state.pbs.pxar = pxarState + return nil + }, + }, + } +} + +func newPBSPXARMetadataBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPBSPXARMetadata, + Description: "Collect PBS PXAR metadata snapshots", + Run: func(ctx context.Context, state *collectionState) error { + if !state.collector.config.BackupPxarFiles { + return nil + } + pxarState, err := state.ensurePBSPXARState(ctx) + if err != nil { + state.collector.logger.Warning("Failed to prepare PBS PXAR state: %v", err) + return nil + } + if err := state.collector.collectPBSPXARMetadataStep(ctx, pxarState); err != nil { + state.collector.logger.Warning("Failed to collect PBS PXAR metadata: %v", err) + } + return nil + }, + }, + } +} + +func newPBSPXARSubdirReportBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPBSPXARSubdirReports, + Description: "Collect PBS PXAR subdirectory reports", + Run: func(ctx context.Context, state *collectionState) error { + if !state.collector.config.BackupPxarFiles { + return nil + } + pxarState, err := state.ensurePBSPXARState(ctx) + if err != nil { + state.collector.logger.Warning("Failed to prepare PBS PXAR state: %v", err) + return nil + } + if err := state.collector.collectPBSPXARSubdirReportsStep(ctx, pxarState); err != nil { + state.collector.logger.Warning("Failed to collect PBS PXAR subdir reports: %v", err) + } + return nil + }, + }, + } +} + +func newPBSPXARVMListBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPBSPXARVMLists, + Description: "Collect PBS PXAR VM reports", + Run: func(ctx context.Context, state *collectionState) error { + if !state.collector.config.BackupPxarFiles { + return nil + } + pxarState, err := state.ensurePBSPXARState(ctx) + if err != nil { + state.collector.logger.Warning("Failed to prepare PBS PXAR state: %v", err) + return nil + } + if err := state.collector.collectPBSPXARVMListsStep(ctx, pxarState); err != nil { + state.collector.logger.Warning("Failed to collect PBS PXAR VM lists: %v", err) + } + return nil + }, + }, + } +} + +func newPBSPXARCTListBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPBSPXARCTLists, + Description: "Collect PBS PXAR CT reports", + Run: func(ctx context.Context, state *collectionState) error { + if !state.collector.config.BackupPxarFiles { + return nil + } + pxarState, err := state.ensurePBSPXARState(ctx) + if err != nil { + state.collector.logger.Warning("Failed to prepare PBS PXAR state: %v", err) + return nil + } + if err := state.collector.collectPBSPXARCTListsStep(ctx, pxarState); err != nil { + state.collector.logger.Warning("Failed to collect PBS PXAR CT lists: %v", err) + } + return nil + }, + }, + } +} diff --git a/internal/backup/collector_bricks_pbs_inventory.go b/internal/backup/collector_bricks_pbs_inventory.go new file mode 100644 index 00000000..9db724cc --- /dev/null +++ b/internal/backup/collector_bricks_pbs_inventory.go @@ -0,0 +1,72 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. +package backup + +import "context" + +func newPBSInventoryBricks() []collectionBrick { + bricks := []collectionBrick{} + bricks = append(bricks, newPBSInventoryInitBricks()...) + bricks = append(bricks, newPBSInventoryFileBricks()...) + bricks = append(bricks, newPBSInventoryHostCommandBricks()...) + bricks = append(bricks, newPBSInventoryFinalizeBricks()...) + return bricks +} + +func newPBSInventoryInitBricks() []collectionBrick { + return []collectionBrick{ + brick(brickPBSInventoryInit, "Initialize the PBS datastore inventory state", func(ctx context.Context, state *collectionState) error { + inventory, err := state.collector.initPBSDatastoreInventoryState(ctx, state.pbs.datastores) + if err != nil { + return err + } + state.pbs.inventory = inventory + return nil + }), + } +} + +func newPBSInventoryFileBricks() []collectionBrick { + return []collectionBrick{ + pbsInventoryBrick(brickPBSInventoryMountFiles, "Populate PBS inventory mount files", (*Collector).populatePBSInventoryMountFiles), + pbsInventoryBrick(brickPBSInventoryOSFiles, "Populate PBS inventory OS files", (*Collector).populatePBSInventoryOSFiles), + pbsInventoryBrick(brickPBSInventoryMultipathFiles, "Populate PBS inventory multipath files", (*Collector).populatePBSInventoryMultipathFiles), + pbsInventoryBrick(brickPBSInventoryISCSIFiles, "Populate PBS inventory iSCSI files", (*Collector).populatePBSInventoryISCSIFiles), + pbsInventoryBrick(brickPBSInventoryAutofsFiles, "Populate PBS inventory autofs files", (*Collector).populatePBSInventoryAutofsFiles), + pbsInventoryBrick(brickPBSInventoryZFSFiles, "Populate PBS inventory ZFS files", (*Collector).populatePBSInventoryZFSFiles), + pbsInventoryBrick(brickPBSInventoryLVMDirs, "Populate PBS inventory LVM directories", (*Collector).populatePBSInventoryLVMDirs), + pbsInventoryBrick(brickPBSInventorySystemdMountUnits, "Populate PBS inventory systemd mount units", (*Collector).populatePBSInventorySystemdMountUnits), + pbsInventoryBrick(brickPBSInventoryReferencedFiles, "Populate PBS inventory referenced files", (*Collector).populatePBSInventoryReferencedFiles), + } +} + +func newPBSInventoryHostCommandBricks() []collectionBrick { + return []collectionBrick{ + pbsInventoryBrick(brickPBSInventoryHostCommandsCore, "Populate PBS inventory core host commands", (*Collector).populatePBSInventoryHostCommandsCore), + pbsInventoryBrick(brickPBSInventoryHostCommandsDMSetup, "Populate PBS inventory dmsetup host commands", (*Collector).populatePBSInventoryHostCommandsDMSetup), + pbsInventoryBrick(brickPBSInventoryHostCommandsLVM, "Populate PBS inventory LVM host commands", (*Collector).populatePBSInventoryHostCommandsLVM), + pbsInventoryBrick(brickPBSInventoryHostCommandsMDADM, "Populate PBS inventory mdadm host commands", (*Collector).populatePBSInventoryHostCommandsMDADM), + pbsInventoryBrick(brickPBSInventoryHostCommandsMultipath, "Populate PBS inventory multipath host commands", (*Collector).populatePBSInventoryHostCommandsMultipath), + pbsInventoryBrick(brickPBSInventoryHostCommandsISCSI, "Populate PBS inventory iSCSI host commands", (*Collector).populatePBSInventoryHostCommandsISCSI), + pbsInventoryBrick(brickPBSInventoryHostCommandsZFS, "Populate PBS inventory ZFS host commands", (*Collector).populatePBSInventoryHostCommandsZFS), + } +} + +func newPBSInventoryFinalizeBricks() []collectionBrick { + return []collectionBrick{ + brick(brickPBSInventoryCommandFiles, "Populate PBS inventory with collected PBS command files", func(_ context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + return state.collector.populatePBSInventoryCommandFiles(state.ensurePBSInventoryState(), commandsDir) + }), + pbsInventoryBrick(brickPBSInventoryDatastores, "Populate PBS datastore inventory entries", (*Collector).populatePBSDatastoreInventoryEntries), + brick(brickPBSInventoryWrite, "Write the PBS datastore inventory report", func(_ context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + return state.collector.writePBSInventoryState(state.ensurePBSInventoryState(), commandsDir) + }), + } +} diff --git a/internal/backup/collector_bricks_pbs_runtime.go b/internal/backup/collector_bricks_pbs_runtime.go new file mode 100644 index 00000000..4e5a152f --- /dev/null +++ b/internal/backup/collector_bricks_pbs_runtime.go @@ -0,0 +1,217 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. +package backup + +import "context" + +func newPBSRuntimeBricks() []collectionBrick { + bricks := []collectionBrick{} + bricks = append(bricks, newPBSRuntimeCoreBricks()...) + bricks = append(bricks, newPBSRuntimeACMEBricks()...) + bricks = append(bricks, newPBSRuntimeNotificationBricks()...) + bricks = append(bricks, newPBSRuntimeAccessBricks()...) + bricks = append(bricks, newPBSRuntimeJobBricks()...) + bricks = append(bricks, newPBSRuntimeTapeBricks()...) + bricks = append(bricks, newPBSRuntimeSystemBricks()...) + bricks = append(bricks, newPBSRuntimeS3Bricks()...) + return bricks +} + +func newPBSRuntimeCoreBricks() []collectionBrick { + return []collectionBrick{ + pbsCommandBrick(brickPBSRuntimeCore, "Collect core PBS runtime information", (*Collector).collectPBSCoreRuntime), + pbsCommandBrick(brickPBSRuntimeNode, "Collect PBS node runtime information", (*Collector).collectPBSNodeRuntime), + pbsCommandBrick(brickPBSRuntimeDatastoreList, "Collect PBS datastore list", (*Collector).collectPBSDatastoreListRuntime), + brick(brickPBSRuntimeDatastoreStatus, "Collect PBS datastore status details", func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + return state.collector.collectPBSDatastoreStatusRuntime(ctx, commandsDir, state.pbs.datastores) + }), + } +} + +func newPBSRuntimeACMEBricks() []collectionBrick { + return []collectionBrick{ + brick(brickPBSRuntimeACMEAccountsList, "Collect the PBS ACME account list", func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + ids, err := state.collector.collectPBSAcmeAccountsListRuntime(ctx, commandsDir) + if err != nil { + return err + } + state.pbs.acmeAccountNames = ids + return nil + }), + brick(brickPBSRuntimeACMEAccountInfo, "Collect PBS ACME account details", func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + return state.collector.collectPBSAcmeAccountInfoRuntime(ctx, commandsDir, state.pbs.acmeAccountNames) + }), + brick(brickPBSRuntimeACMEPluginsList, "Collect the PBS ACME plugin list", func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + ids, err := state.collector.collectPBSAcmePluginsListRuntime(ctx, commandsDir) + if err != nil { + return err + } + state.pbs.acmePluginIDs = ids + return nil + }), + brick(brickPBSRuntimeACMEPluginConfig, "Collect PBS ACME plugin configuration", func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + return state.collector.collectPBSAcmePluginConfigRuntime(ctx, commandsDir, state.pbs.acmePluginIDs) + }), + } +} + +func newPBSRuntimeNotificationBricks() []collectionBrick { + return []collectionBrick{ + pbsCommandBrick(brickPBSRuntimeNotificationTargets, "Collect PBS notification targets", (*Collector).collectPBSNotificationTargetsRuntime), + pbsCommandBrick(brickPBSRuntimeNotificationMatchers, "Collect PBS notification matchers", (*Collector).collectPBSNotificationMatchersRuntime), + pbsCommandBrick(brickPBSRuntimeNotificationEndpointSMTP, "Collect PBS SMTP notification endpoints", (*Collector).collectPBSNotificationEndpointSMTPRuntime), + pbsCommandBrick(brickPBSRuntimeNotificationEndpointSendmail, "Collect PBS sendmail notification endpoints", (*Collector).collectPBSNotificationEndpointSendmailRuntime), + pbsCommandBrick(brickPBSRuntimeNotificationEndpointGotify, "Collect PBS gotify notification endpoints", (*Collector).collectPBSNotificationEndpointGotifyRuntime), + pbsCommandBrick(brickPBSRuntimeNotificationEndpointWebhook, "Collect PBS webhook notification endpoints", (*Collector).collectPBSNotificationEndpointWebhookRuntime), + brick(brickPBSRuntimeNotificationSummary, "Write the PBS notification summary", func(_ context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + state.collector.writePBSNotificationSummary(commandsDir) + return nil + }), + } +} + +func newPBSRuntimeAccessBricks() []collectionBrick { + return []collectionBrick{ + brick(brickPBSRuntimeAccessUsers, "Collect the PBS user list", func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + ids, err := state.collector.collectPBSAccessUsersRuntime(ctx, commandsDir) + if err != nil { + return err + } + state.pbs.userIDs = ids + return nil + }), + pbsCommandBrick(brickPBSRuntimeAccessRealmsLDAP, "Collect PBS LDAP realm definitions", (*Collector).collectPBSAccessRealmLDAPRuntime), + pbsCommandBrick(brickPBSRuntimeAccessRealmsAD, "Collect PBS Active Directory realm definitions", (*Collector).collectPBSAccessRealmADRuntime), + pbsCommandBrick(brickPBSRuntimeAccessRealmsOpenID, "Collect PBS OpenID realm definitions", (*Collector).collectPBSAccessRealmOpenIDRuntime), + pbsCommandBrick(brickPBSRuntimeAccessACL, "Collect PBS ACL definitions", (*Collector).collectPBSAccessACLRuntime), + brick(brickPBSRuntimeAccessUserTokens, "Collect PBS API token snapshots", func(ctx context.Context, state *collectionState) error { + if !state.collector.config.BackupUserConfigs || len(state.pbs.userIDs) == 0 { + return nil + } + usersDir, err := state.collector.ensurePBSAccessControlDir() + if err != nil { + return err + } + return state.collector.collectPBSAccessUserTokensRuntime(ctx, usersDir, state.pbs.userIDs) + }), + brick(brickPBSRuntimeAccessTokensAggregate, "Aggregate PBS API token snapshots", func(_ context.Context, state *collectionState) error { + if !state.collector.config.BackupUserConfigs || len(state.pbs.userIDs) == 0 { + return nil + } + usersDir, err := state.collector.ensurePBSAccessControlDir() + if err != nil { + return err + } + return state.collector.collectPBSAccessTokensAggregateRuntime(usersDir, state.pbs.userIDs) + }), + } +} + +func newPBSRuntimeJobBricks() []collectionBrick { + return []collectionBrick{ + pbsCommandBrick(brickPBSRuntimeRemotes, "Collect PBS remote definitions", (*Collector).collectPBSRemotesRuntime), + pbsCommandBrick(brickPBSRuntimeSyncJobs, "Collect PBS sync jobs", (*Collector).collectPBSSyncJobsRuntime), + pbsCommandBrick(brickPBSRuntimeVerificationJobs, "Collect PBS verification jobs", (*Collector).collectPBSVerificationJobsRuntime), + pbsCommandBrick(brickPBSRuntimePruneJobs, "Collect PBS prune jobs", (*Collector).collectPBSPruneJobsRuntime), + pbsCommandBrick(brickPBSRuntimeGCJobs, "Collect PBS garbage collection jobs", (*Collector).collectPBSGCJobsRuntime), + } +} + +func newPBSRuntimeTapeBricks() []collectionBrick { + return []collectionBrick{ + brick(brickPBSRuntimeTapeDetect, "Detect PBS tape support", func(ctx context.Context, state *collectionState) error { + if !state.collector.config.BackupTapeConfigs { + state.pbs.tapeSupportKnown = true + state.pbs.tapeSupported = false + return nil + } + supported, err := state.collector.detectPBSTapeSupport(ctx) + if err != nil { + if ctx.Err() != nil { + return err + } + state.collector.logger.Debug("Skipping tape details collection: %v", err) + state.pbs.tapeSupportKnown = true + state.pbs.tapeSupported = false + return nil + } + state.pbs.tapeSupportKnown = true + state.pbs.tapeSupported = supported + return nil + }), + pbsTapeCommandBrick(brickPBSRuntimeTapeDrives, "Collect PBS tape drive inventory", (*Collector).collectPBSTapeDrivesRuntime), + pbsTapeCommandBrick(brickPBSRuntimeTapeChangers, "Collect PBS tape changer inventory", (*Collector).collectPBSTapeChangersRuntime), + pbsTapeCommandBrick(brickPBSRuntimeTapePools, "Collect PBS tape pool inventory", (*Collector).collectPBSTapePoolsRuntime), + } +} + +func pbsTapeCommandBrick(id BrickID, description string, run func(*Collector, context.Context, string, bool) error) collectionBrick { + return brick(id, description, func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + return run(state.collector, ctx, commandsDir, state.pbs.tapeSupportKnown && state.pbs.tapeSupported) + }) +} + +func newPBSRuntimeSystemBricks() []collectionBrick { + return []collectionBrick{ + pbsCommandBrick(brickPBSRuntimeNetwork, "Collect PBS network runtime information", (*Collector).collectPBSNetworkRuntime), + pbsCommandBrick(brickPBSRuntimeDisks, "Collect the PBS disk inventory", (*Collector).collectPBSDisksRuntime), + pbsCommandBrick(brickPBSRuntimeCertInfo, "Collect the PBS certificate summary", (*Collector).collectPBSCertInfoRuntime), + pbsCommandBrick(brickPBSRuntimeTrafficControl, "Collect PBS traffic control runtime information", (*Collector).collectPBSTrafficControlRuntime), + pbsCommandBrick(brickPBSRuntimeRecentTasks, "Collect recent PBS tasks", (*Collector).collectPBSRecentTasksRuntime), + } +} + +func newPBSRuntimeS3Bricks() []collectionBrick { + return []collectionBrick{ + brick(brickPBSRuntimeS3Endpoints, "Collect PBS S3 endpoints", func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + ids, err := state.collector.collectPBSS3EndpointsRuntime(ctx, commandsDir) + if err != nil { + return err + } + state.pbs.s3EndpointIDs = ids + return nil + }), + brick(brickPBSRuntimeS3EndpointBuckets, "Collect PBS S3 endpoint bucket inventories", func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePBSCommandsDir() + if err != nil { + return err + } + return state.collector.collectPBSS3EndpointBucketsRuntime(ctx, commandsDir, state.pbs.s3EndpointIDs) + }), + } +} diff --git a/internal/backup/collector_bricks_pve.go b/internal/backup/collector_bricks_pve.go new file mode 100644 index 00000000..b2488546 --- /dev/null +++ b/internal/backup/collector_bricks_pve.go @@ -0,0 +1,215 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. +package backup + +import ( + "context" + "errors" + "fmt" + "os" +) + +func newPVERecipe() recipe { + bricks := []collectionBrick{} + bricks = append(bricks, newPVEValidationBricks()...) + bricks = append(bricks, newPVESnapshotBricks()...) + bricks = append(bricks, newPVERuntimeBricks()...) + bricks = append(bricks, newPVEGuestBricks()...) + bricks = append(bricks, newPVEBackupJobBricks()...) + bricks = append(bricks, newPVEScheduleBricks()...) + bricks = append(bricks, newPVEReplicationBricks()...) + bricks = append(bricks, newPVEStorageResolveBricks()...) + bricks = append(bricks, newPVEStorageProbeBricks()...) + bricks = append(bricks, newPVEStorageMetadataJSONBricks()...) + bricks = append(bricks, newPVEStorageMetadataTextBricks()...) + bricks = append(bricks, newPVEStorageAnalysisBricks()...) + bricks = append(bricks, newPVEStorageSummaryBricks()...) + bricks = append(bricks, newPVECephBricks()...) + bricks = append(bricks, newPVEAliasBricks()...) + bricks = append(bricks, newPVEAggregateBricks()...) + bricks = append(bricks, newPVEVersionBricks()...) + bricks = append(bricks, newPVEManifestBricks()...) + return recipe{Name: "pve", Bricks: bricks} +} + +func newPVEValidationBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEValidateAndCluster, + Description: "Validate PVE environment and detect cluster state", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + c.logger.Debug("Validating PVE environment and cluster state prior to collection") + + pveConfigPath := c.effectivePVEConfigPath() + if _, err := os.Stat(pveConfigPath); err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("not a PVE system: %s not found", pveConfigPath) + } + return fmt.Errorf("failed to access PVE config path %s: %w", pveConfigPath, err) + } + c.logger.Debug("%s detected, continuing with PVE collection", pveConfigPath) + + clustered := false + if isClustered, err := c.isClusteredPVE(ctx); err != nil { + if ctx.Err() != nil { + return err + } + c.logger.Debug("Cluster detection failed, assuming standalone node: %v", err) + } else { + clustered = isClustered + c.logger.Debug("Cluster detection completed: clustered=%v", clustered) + } + + state.pve.clustered = clustered + c.clusteredPVE = clustered + return nil + }, + }, + } +} + +func newPVESnapshotBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEConfigSnapshot, + Description: "Collect base PVE configuration snapshot", + Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPVEConfigSnapshot(ctx) + }, + }, + { + ID: brickPVEClusterSnapshot, + Description: "Collect cluster-specific PVE snapshot", + Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPVEClusterSnapshot(ctx, state.pve.clustered) + }, + }, + { + ID: brickPVEFirewallSnapshot, + Description: "Collect PVE firewall snapshot", + Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPVEFirewallSnapshot(ctx) + }, + }, + { + ID: brickPVEVZDumpSnapshot, + Description: "Collect VZDump snapshot", + Run: func(ctx context.Context, state *collectionState) error { + return state.collector.collectPVEVZDumpSnapshot(ctx) + }, + }, + } +} + +func newPVERuntimeBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVERuntimeCore, + Description: "Collect core PVE runtime information", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + commandsDir, err := state.ensurePVECommandsDir() + if err != nil { + return err + } + c.logger.Debug("Collecting PVE core runtime state") + return c.collectPVECoreRuntime(ctx, commandsDir, state.ensurePVERuntimeInfo()) + }, + }, + { + ID: brickPVERuntimeACL, + Description: "Collect PVE ACL runtime information", + Run: func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePVECommandsDir() + if err != nil { + return err + } + state.collector.collectPVEACLRuntime(ctx, commandsDir) + return nil + }, + }, + { + ID: brickPVERuntimeCluster, + Description: "Collect PVE cluster runtime information", + Run: func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensurePVECommandsDir() + if err != nil { + return err + } + state.collector.collectPVEClusterRuntime(ctx, commandsDir, state.pve.clustered) + return nil + }, + }, + { + ID: brickPVERuntimeStorage, + Description: "Collect PVE storage runtime information", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + commandsDir, err := state.ensurePVECommandsDir() + if err != nil { + return err + } + if err := c.collectPVEStorageRuntime(ctx, commandsDir, state.ensurePVERuntimeInfo()); err != nil { + return err + } + c.finalizePVERuntimeInfo(state.ensurePVERuntimeInfo()) + return nil + }, + }, + } +} + +func newPVEGuestBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEVMQEMUConfigs, + Description: "Collect QEMU VM configurations", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupVMConfigs { + c.logger.Skip("VM/container configuration backup disabled.") + return nil + } + if state.pve.guestCollectionAborted { + return nil + } + c.logger.Info("Collecting VM and container configurations") + if err := c.collectPVEQEMUConfigs(ctx); err != nil { + c.logger.Warning("Failed to collect QEMU VM configs: %v", err) + state.pve.guestCollectionAborted = true + } + return nil + }, + }, + { + ID: brickPVEVMLXCConfigs, + Description: "Collect LXC container configurations", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupVMConfigs || state.pve.guestCollectionAborted { + return nil + } + if err := c.collectPVELXCConfigs(ctx); err != nil { + c.logger.Warning("Failed to collect LXC configs: %v", err) + state.pve.guestCollectionAborted = true + } + return nil + }, + }, + { + ID: brickPVEGuestInventory, + Description: "Collect guest inventory", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupVMConfigs || state.pve.guestCollectionAborted { + return nil + } + if err := c.collectPVEGuestInventory(ctx); err != nil { + c.logger.Warning("Failed to collect guest inventory: %v", err) + state.pve.guestCollectionAborted = true + } + return nil + }, + }, + } +} diff --git a/internal/backup/collector_bricks_pve_finalize.go b/internal/backup/collector_bricks_pve_finalize.go new file mode 100644 index 00000000..1585ff9b --- /dev/null +++ b/internal/backup/collector_bricks_pve_finalize.go @@ -0,0 +1,156 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. +package backup + +import "context" + +func newPVECephBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVECephConfigSnapshot, + Description: "Collect Ceph configuration snapshot", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupCephConfig || state.pve.cephCollectionAborted { + return nil + } + c.logger.Debug("Collecting Ceph configuration and status") + if err := c.collectPVECephConfigSnapshot(ctx); err != nil { + c.logger.Warning("Failed to collect Ceph configuration snapshot: %v", err) + state.pve.cephCollectionAborted = true + } + return nil + }, + }, + { + ID: brickPVECephRuntime, + Description: "Collect Ceph runtime information", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupCephConfig || state.pve.cephCollectionAborted { + return nil + } + if err := c.collectPVECephRuntime(ctx); err != nil { + c.logger.Warning("Failed to collect Ceph runtime information: %v", err) + state.pve.cephCollectionAborted = true + } else { + c.logger.Debug("Ceph information collection completed") + } + return nil + }, + }, + } +} + +func newPVEAliasBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEAliasCore, + Description: "Create core PVE aliases", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + c.logger.Debug("Creating PVE info aliases under /var/lib/pve-cluster/info") + if err := c.createPVECoreAliases(ctx); err != nil { + c.logger.Warning("Failed to create PVE core aliases: %v", err) + state.pve.finalizeCollectionAborted = true + } + return nil + }, + }, + } +} + +func newPVEAggregateBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEAggregateBackupHistory, + Description: "Aggregate backup history aliases", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if state.pve.finalizeCollectionAborted { + return nil + } + if err := c.createPVEBackupHistoryAggregate(ctx); err != nil { + c.logger.Warning("Failed to aggregate PVE backup history: %v", err) + state.pve.finalizeCollectionAborted = true + } + return nil + }, + }, + { + ID: brickPVEAggregateReplicationStatus, + Description: "Aggregate replication status aliases", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if state.pve.finalizeCollectionAborted { + return nil + } + if err := c.createPVEReplicationAggregate(ctx); err != nil { + c.logger.Warning("Failed to aggregate PVE replication status: %v", err) + state.pve.finalizeCollectionAborted = true + } + return nil + }, + }, + } +} + +func newPVEVersionBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEVersionInfo, + Description: "Write PVE version alias information", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if state.pve.finalizeCollectionAborted { + return nil + } + if err := c.createPVEVersionInfo(ctx); err != nil { + c.logger.Warning("Failed to write PVE version info: %v", err) + state.pve.finalizeCollectionAborted = true + } + return nil + }, + }, + } +} + +func newPVEManifestBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEManifestFinalize, + Description: "Finalize the PVE manifest", + Run: func(_ context.Context, state *collectionState) error { + state.collector.populatePVEManifest() + return nil + }, + }, + } +} + +func (p pveContext) runtimeNodes() []string { + if p.runtimeInfo == nil { + return nil + } + return p.runtimeInfo.Nodes +} + +func (p pveContext) runtimeStorages() []pveStorageEntry { + if p.runtimeInfo == nil { + return nil + } + return p.runtimeInfo.Storages +} + +func (p *pveContext) ensureStorageScanResults() map[string]*pveStorageScanResult { + if p.storageScanResults == nil { + p.storageScanResults = make(map[string]*pveStorageScanResult) + } + return p.storageScanResults +} + +func (p pveContext) storageResult(storage pveStorageEntry) *pveStorageScanResult { + if p.storageScanResults == nil { + return nil + } + return p.storageScanResults[storage.pathKey()] +} diff --git a/internal/backup/collector_bricks_pve_jobs.go b/internal/backup/collector_bricks_pve_jobs.go new file mode 100644 index 00000000..826b03dd --- /dev/null +++ b/internal/backup/collector_bricks_pve_jobs.go @@ -0,0 +1,141 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. +package backup + +import "context" + +func newPVEBackupJobBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEBackupJobDefs, + Description: "Collect PVE backup job definitions", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEJobs || state.pve.jobCollectionAborted { + return nil + } + c.logger.Debug("Collecting PVE job definitions for nodes: %v", state.pve.runtimeNodes()) + if err := c.collectPVEBackupJobDefinitions(ctx); err != nil { + c.logger.Warning("Failed to collect PVE backup job definitions: %v", err) + state.pve.jobCollectionAborted = true + } + return nil + }, + }, + { + ID: brickPVEBackupJobHistory, + Description: "Collect PVE backup job history", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEJobs || state.pve.jobCollectionAborted { + return nil + } + if err := c.collectPVEBackupJobHistory(ctx, state.pve.runtimeNodes()); err != nil { + c.logger.Warning("Failed to collect PVE backup history: %v", err) + state.pve.jobCollectionAborted = true + } + return nil + }, + }, + { + ID: brickPVEVZDumpCron, + Description: "Collect VZDump cron snapshot", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEJobs || state.pve.jobCollectionAborted { + return nil + } + if err := c.collectPVEVZDumpCronSnapshot(ctx); err != nil { + c.logger.Warning("Failed to collect VZDump cron snapshot: %v", err) + state.pve.jobCollectionAborted = true + } + return nil + }, + }, + } +} + +func newPVEScheduleBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEScheduleCrontab, + Description: "Collect root crontab schedule data", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVESchedules || state.pve.scheduleCollectionAborted { + return nil + } + if err := c.collectPVEScheduleCrontab(ctx); err != nil { + c.logger.Warning("Failed to collect PVE crontab schedules: %v", err) + state.pve.scheduleCollectionAborted = true + } + return nil + }, + }, + { + ID: brickPVEScheduleTimers, + Description: "Collect systemd timer schedule data", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVESchedules || state.pve.scheduleCollectionAborted { + return nil + } + if err := c.collectPVEScheduleTimers(ctx); err != nil { + c.logger.Warning("Failed to collect PVE timer schedules: %v", err) + state.pve.scheduleCollectionAborted = true + } + return nil + }, + }, + { + ID: brickPVEScheduleCronFiles, + Description: "Collect PVE-related cron files", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVESchedules || state.pve.scheduleCollectionAborted { + return nil + } + if err := c.collectPVEScheduleCronFiles(ctx); err != nil { + c.logger.Warning("Failed to collect PVE cron schedule files: %v", err) + state.pve.scheduleCollectionAborted = true + } + return nil + }, + }, + } +} + +func newPVEReplicationBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEReplicationDefs, + Description: "Collect PVE replication definitions", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEReplication || state.pve.replicationCollectionAborted { + return nil + } + c.logger.Debug("Collecting PVE replication settings for nodes: %v", state.pve.runtimeNodes()) + if err := c.collectPVEReplicationDefinitions(ctx); err != nil { + c.logger.Warning("Failed to collect PVE replication definitions: %v", err) + state.pve.replicationCollectionAborted = true + } + return nil + }, + }, + { + ID: brickPVEReplicationStatus, + Description: "Collect PVE replication status", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEReplication || state.pve.replicationCollectionAborted { + return nil + } + if err := c.collectPVEReplicationStatus(ctx, state.pve.runtimeNodes()); err != nil { + c.logger.Warning("Failed to collect PVE replication status: %v", err) + state.pve.replicationCollectionAborted = true + } + return nil + }, + }, + } +} diff --git a/internal/backup/collector_bricks_pve_storage.go b/internal/backup/collector_bricks_pve_storage.go new file mode 100644 index 00000000..9299ce3f --- /dev/null +++ b/internal/backup/collector_bricks_pve_storage.go @@ -0,0 +1,177 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. +package backup + +import "context" + +func newPVEStorageResolveBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEStorageResolve, + Description: "Resolve PVE storage list for backup analysis", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEBackupFiles { + return nil + } + if state.pve.storageCollectionAborted { + return nil + } + if err := ctx.Err(); err != nil { + return err + } + c.logger.Info("Collecting PVE datastore information using auto-detection") + c.logger.Debug("Collecting datastore metadata for %d storages", len(state.pve.runtimeStorages())) + state.pve.resolvedStorages = c.resolvePVEStorages(state.pve.runtimeStorages()) + if len(state.pve.resolvedStorages) == 0 { + c.logger.Info("Found 0 PVE datastore(s) via auto-detection") + c.logger.Info("No PVE datastores detected - skipping metadata collection") + return nil + } + c.logger.Info("Found %d PVE datastore(s) via auto-detection", len(state.pve.resolvedStorages)) + return nil + }, + }, + } +} + +func newPVEStorageProbeBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEStorageProbe, + Description: "Probe resolved PVE storages", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEBackupFiles || state.pve.storageCollectionAborted || len(state.pve.resolvedStorages) == 0 { + return nil + } + baseDir := c.pveDatastoresBaseDir() + if err := c.ensureDir(baseDir); err != nil { + c.logger.Warning("Failed to create datastore metadata directory: %v", err) + state.pve.storageCollectionAborted = true + return nil + } + ioTimeout := c.pveStorageIOTimeout() + state.pve.probedStorages = nil + state.pve.storageScanResults = nil + for _, storage := range state.pve.resolvedStorages { + result, err := c.preparePVEStorageScan(ctx, storage, baseDir, ioTimeout) + if err != nil { + c.logger.Warning("Failed to probe PVE datastore %s: %v", storage.Name, err) + state.pve.storageCollectionAborted = true + return nil + } + if result == nil { + continue + } + state.pve.probedStorages = append(state.pve.probedStorages, storage) + state.pve.ensureStorageScanResults()[storage.pathKey()] = result + } + return nil + }, + }, + } +} + +func newPVEStorageMetadataJSONBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEStorageMetadataJSON, + Description: "Write JSON metadata for probed PVE storages", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEBackupFiles || state.pve.storageCollectionAborted || len(state.pve.probedStorages) == 0 { + return nil + } + ioTimeout := c.pveStorageIOTimeout() + for _, storage := range state.pve.probedStorages { + result := state.pve.storageResult(storage) + if result == nil || result.SkipRemaining { + continue + } + if err := c.collectPVEStorageMetadataJSONStep(ctx, result, ioTimeout); err != nil { + c.logger.Warning("Failed to write PVE datastore JSON metadata for %s: %v", storage.Name, err) + state.pve.storageCollectionAborted = true + return nil + } + } + return nil + }, + }, + } +} + +func newPVEStorageMetadataTextBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEStorageMetadataText, + Description: "Write text metadata for probed PVE storages", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEBackupFiles || state.pve.storageCollectionAborted || len(state.pve.probedStorages) == 0 { + return nil + } + ioTimeout := c.pveStorageIOTimeout() + for _, storage := range state.pve.probedStorages { + result := state.pve.storageResult(storage) + if result == nil || result.SkipRemaining { + continue + } + if err := c.collectPVEStorageMetadataTextStep(ctx, result, ioTimeout); err != nil { + c.logger.Warning("Failed to write PVE datastore text metadata for %s: %v", storage.Name, err) + state.pve.storageCollectionAborted = true + return nil + } + } + return nil + }, + }, + } +} + +func newPVEStorageAnalysisBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEStorageBackupAnalysis, + Description: "Analyze PVE backup files for probed storages", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEBackupFiles || state.pve.storageCollectionAborted || len(state.pve.probedStorages) == 0 { + return nil + } + ioTimeout := c.pveStorageIOTimeout() + for _, storage := range state.pve.probedStorages { + result := state.pve.storageResult(storage) + if result == nil || result.SkipRemaining { + continue + } + if err := c.collectPVEStorageBackupAnalysisStep(ctx, result, ioTimeout); err != nil { + c.logger.Warning("Detailed backup analysis for %s failed: %v", storage.Name, err) + } + } + return nil + }, + }, + } +} + +func newPVEStorageSummaryBricks() []collectionBrick { + return []collectionBrick{ + { + ID: brickPVEStorageSummary, + Description: "Write PVE datastore summary", + Run: func(ctx context.Context, state *collectionState) error { + c := state.collector + if !c.config.BackupPVEBackupFiles || state.pve.storageCollectionAborted || len(state.pve.probedStorages) == 0 { + return nil + } + if err := c.writePVEStorageSummary(ctx, state.pve.probedStorages); err != nil { + c.logger.Warning("Failed to write PVE datastore summary: %v", err) + state.pve.storageCollectionAborted = true + return nil + } + c.logger.Debug("PVE datastore metadata collection completed (%d processed)", len(state.pve.probedStorages)) + return nil + }, + }, + } +} diff --git a/internal/backup/collector_bricks_system.go b/internal/backup/collector_bricks_system.go new file mode 100644 index 00000000..e4ca2b1f --- /dev/null +++ b/internal/backup/collector_bricks_system.go @@ -0,0 +1,217 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. +package backup + +import "context" + +func newSystemRecipe() recipe { + bricks := []collectionBrick{} + bricks = append(bricks, newSystemStaticBricks()...) + bricks = append(bricks, newCommonFilesystemBricks()...) + bricks = append(bricks, newCommonStorageStackBricks()...) + bricks = append(bricks, newSystemPostCommonStaticBricks()...) + bricks = append(bricks, newSystemRuntimeCommandBricks()...) + bricks = append(bricks, newSystemReportBricks()...) + bricks = append(bricks, newSystemFileCollectionBricks()...) + bricks = append(bricks, newSystemScriptCollectionBricks()...) + bricks = append(bricks, newSystemHomeCollectionBricks()...) + return recipe{Name: "system", Bricks: bricks} +} + +func newSystemStaticBricks() []collectionBrick { + return []collectionBrick{ + collectorBrick(brickSystemNetworkStatic, "Collect static network configuration", (*Collector).collectSystemNetworkStatic), + collectorBrick(brickSystemIdentityStatic, "Collect static identity files", (*Collector).collectSystemIdentityStatic), + collectorBrick(brickSystemAptStatic, "Collect static APT configuration", (*Collector).collectSystemAptStatic), + collectorBrick(brickSystemCronStatic, "Collect static cron configuration", (*Collector).collectSystemCronStatic), + collectorBrick(brickSystemServicesStatic, "Collect static service configuration", (*Collector).collectSystemServicesStatic), + collectorBrick(brickSystemLoggingStatic, "Collect static logging configuration", (*Collector).collectSystemLoggingStatic), + collectorBrick(brickSystemSSLStatic, "Collect static SSL configuration", (*Collector).collectSystemSSLStatic), + collectorBrick(brickSystemSysctlStatic, "Collect static sysctl configuration", (*Collector).collectSystemSysctlStatic), + collectorBrick(brickSystemKernelModulesStatic, "Collect static kernel module configuration", (*Collector).collectSystemKernelModuleStatic), + } +} + +func newSystemPostCommonStaticBricks() []collectionBrick { + return []collectionBrick{ + collectorBrick(brickSystemZFSStatic, "Collect static ZFS configuration", (*Collector).collectSystemZFSStatic), + collectorBrick(brickSystemFirewallStatic, "Collect static firewall configuration", (*Collector).collectSystemFirewallStatic), + collectorBrick(brickSystemRuntimeLeases, "Collect runtime lease snapshots", (*Collector).collectSystemRuntimeLeases), + } +} + +func newSystemRuntimeCommandBricks() []collectionBrick { + return []collectionBrick{ + systemCommandBrick(brickSystemCoreRuntime, "Collect core system runtime information", (*Collector).collectSystemCoreRuntime), + systemCommandBrick(brickSystemNetworkRuntimeAddr, "Collect network address runtime information", (*Collector).collectSystemNetworkAddrRuntime), + systemCommandBrick(brickSystemNetworkRuntimeRules, "Collect network rule runtime information", (*Collector).collectSystemNetworkRulesRuntime), + systemCommandBrick(brickSystemNetworkRuntimeRoutes, "Collect network route runtime information", (*Collector).collectSystemNetworkRoutesRuntime), + systemCommandBrick(brickSystemNetworkRuntimeLinks, "Collect network link runtime information", (*Collector).collectSystemNetworkLinksRuntime), + systemCommandBrick(brickSystemNetworkRuntimeNeighbors, "Collect network neighbor runtime information", (*Collector).collectSystemNetworkNeighborsRuntime), + systemCommandBrick(brickSystemNetworkRuntimeBridges, "Collect bridge runtime information", (*Collector).collectSystemNetworkBridgesRuntime), + systemCommandBrick(brickSystemNetworkRuntimeInventory, "Collect network inventory runtime information", (*Collector).collectSystemNetworkInventoryRuntime), + systemCommandBrick(brickSystemNetworkRuntimeBonding, "Collect bonding runtime information", (*Collector).collectSystemNetworkBondingRuntime), + systemCommandBrick(brickSystemNetworkRuntimeDNS, "Collect DNS runtime information", (*Collector).collectSystemNetworkDNSRuntime), + systemCommandBrick(brickSystemStorageRuntimeMounts, "Collect storage mount runtime information", (*Collector).collectSystemStorageMountsRuntime), + systemCommandBrick(brickSystemStorageRuntimeBlock, "Collect block device runtime information", (*Collector).collectSystemStorageBlockDevicesRuntime), + systemCommandBrick(brickSystemComputeRuntimeMemoryCPU, "Collect memory and CPU runtime information", (*Collector).collectSystemComputeMemoryCPURuntime), + systemCommandBrick(brickSystemComputeRuntimeBusInv, "Collect bus inventory runtime information", (*Collector).collectSystemComputeBusInventoryRuntime), + systemCommandBrick(brickSystemServicesRuntime, "Collect service runtime information", (*Collector).collectSystemServicesRuntime), + systemCommandBrick(brickSystemPackagesRuntimeInstalled, "Collect installed package runtime information", (*Collector).collectSystemPackagesInstalledRuntime), + systemCommandBrick(brickSystemPackagesRuntimeAPTPolicy, "Collect APT policy runtime information", (*Collector).collectSystemPackagesAptPolicyRuntime), + systemCommandBrick(brickSystemFirewallRuntimeIPTables, "Collect iptables runtime information", (*Collector).collectSystemFirewallIPTablesRuntime), + systemCommandBrick(brickSystemFirewallRuntimeIP6Tables, "Collect ip6tables runtime information", (*Collector).collectSystemFirewallIP6TablesRuntime), + systemCommandBrick(brickSystemFirewallRuntimeNFTables, "Collect nftables runtime information", (*Collector).collectSystemFirewallNFTablesRuntime), + systemCommandBrick(brickSystemFirewallRuntimeUFW, "Collect UFW runtime information", (*Collector).collectSystemFirewallUFWRuntime), + systemCommandBrick(brickSystemFirewallRuntimeFirewalld, "Collect firewalld runtime information", (*Collector).collectSystemFirewallFirewalldRuntime), + systemCommandBrick(brickSystemKernelModulesRuntime, "Collect kernel module runtime information", (*Collector).collectSystemKernelModulesRuntime), + systemCommandBrick(brickSystemSysctlRuntime, "Collect sysctl runtime information", (*Collector).collectSystemSysctlRuntime), + systemCommandBrick(brickSystemZFSRuntime, "Collect ZFS runtime information", (*Collector).collectSystemZFSRuntime), + systemCommandBrick(brickSystemLVMRuntime, "Collect LVM runtime information", (*Collector).collectSystemLVMRuntime), + } +} + +func newSystemReportBricks() []collectionBrick { + return []collectionBrick{ + brick(brickSystemNetworkReport, "Finalize derived system reports", func(ctx context.Context, state *collectionState) error { + commandsDir, err := state.ensureSystemCommandsDir() + if err != nil { + return err + } + if err := state.collector.finalizeSystemRuntimeReports(ctx, commandsDir); err != nil { + state.collector.logger.Debug("Network report generation failed: %v", err) + } + return nil + }), + brick(brickSystemKernel, "Collect kernel information", func(ctx context.Context, state *collectionState) error { + c := state.collector + c.logger.Debug("Collecting kernel information (uname/modules)") + if err := c.collectKernelInfo(ctx); err != nil { + c.logger.Warning("Failed to collect kernel info: %v", err) + } else { + c.logger.Debug("Kernel information collected successfully") + } + return nil + }), + brick(brickSystemHardware, "Collect hardware information", func(ctx context.Context, state *collectionState) error { + c := state.collector + c.logger.Debug("Collecting hardware inventory (CPU/memory/devices)") + if err := c.collectHardwareInfo(ctx); err != nil { + c.logger.Warning("Failed to collect hardware info: %v", err) + } else { + c.logger.Debug("Hardware inventory collected successfully") + } + return nil + }), + } +} + +func newSystemFileCollectionBricks() []collectionBrick { + return []collectionBrick{ + brick(brickSystemCriticalFiles, "Collect critical system files", func(ctx context.Context, state *collectionState) error { + c := state.collector + if c.config.BackupCriticalFiles { + c.logger.Debug("Collecting critical files specified in configuration") + if err := c.collectCriticalFiles(ctx); err != nil { + c.logger.Warning("Failed to collect critical files: %v", err) + } else { + c.logger.Debug("Critical files collected successfully") + } + } + return nil + }), + brick(brickSystemConfigFile, "Collect backup configuration file", func(ctx context.Context, state *collectionState) error { + c := state.collector + if c.config.BackupConfigFile { + c.logger.Debug("Collecting backup configuration file") + if err := c.collectConfigFile(ctx); err != nil { + c.logger.Warning("Failed to collect backup configuration file: %v", err) + } else { + c.logger.Debug("Backup configuration file collected successfully") + } + } + return nil + }), + brick(brickSystemCustomPaths, "Collect custom backup paths", func(ctx context.Context, state *collectionState) error { + c := state.collector + if len(c.config.CustomBackupPaths) > 0 { + c.logger.Debug("Collecting custom paths: %v", c.config.CustomBackupPaths) + if err := c.collectCustomPaths(ctx); err != nil { + c.logger.Warning("Failed to collect custom paths: %v", err) + } else { + c.logger.Debug("Custom paths collected successfully") + } + } + return nil + }), + } +} + +func newSystemScriptCollectionBricks() []collectionBrick { + return []collectionBrick{ + brick(brickSystemScriptDirs, "Collect script directories", func(ctx context.Context, state *collectionState) error { + c := state.collector + if c.config.BackupScriptDir { + c.logger.Debug("Collecting script directories (/usr/local/bin,/usr/local/sbin)") + if err := c.collectScriptDirectories(ctx); err != nil { + c.logger.Warning("Failed to collect script directories: %v", err) + } else { + c.logger.Debug("Script directories collected successfully") + } + } + return nil + }), + brick(brickSystemScriptRepo, "Collect script repository", func(ctx context.Context, state *collectionState) error { + c := state.collector + if c.config.BackupScriptRepository { + c.logger.Debug("Collecting script repository from %s", c.config.ScriptRepositoryPath) + if err := c.collectScriptRepository(ctx); err != nil { + c.logger.Warning("Failed to collect script repository: %v", err) + } else { + c.logger.Debug("Script repository collected successfully") + } + } + return nil + }), + brick(brickSystemSSHKeys, "Collect SSH keys", func(ctx context.Context, state *collectionState) error { + c := state.collector + if c.config.BackupSSHKeys { + c.logger.Debug("Collecting SSH keys for root and users") + if err := c.collectSSHKeys(ctx); err != nil { + c.logger.Warning("Failed to collect SSH keys: %v", err) + } else { + c.logger.Debug("SSH keys collected successfully") + } + } + return nil + }), + } +} + +func newSystemHomeCollectionBricks() []collectionBrick { + return []collectionBrick{ + brick(brickSystemRootHome, "Collect root home directory", func(ctx context.Context, state *collectionState) error { + c := state.collector + if c.config.BackupRootHome { + c.logger.Debug("Collecting /root home directory") + if err := c.collectRootHome(ctx); err != nil { + c.logger.Warning("Failed to collect root home files: %v", err) + } else { + c.logger.Debug("Root home directory collected successfully") + } + } + return nil + }), + brick(brickSystemUserHomes, "Collect user home directories", func(ctx context.Context, state *collectionState) error { + c := state.collector + if c.config.BackupUserHomes { + c.logger.Debug("Collecting user home directories under /home") + if err := c.collectUserHomes(ctx); err != nil { + c.logger.Warning("Failed to collect user home directories: %v", err) + } else { + c.logger.Debug("User home directories collected successfully") + } + } + return nil + }), + } +} diff --git a/internal/backup/collector_bricks_test.go b/internal/backup/collector_bricks_test.go index d5747aa1..6270107c 100644 --- a/internal/backup/collector_bricks_test.go +++ b/internal/backup/collector_bricks_test.go @@ -1,3 +1,4 @@ +// Package backup provides collection, archive, and verification logic for ProxSave backups. package backup import ( @@ -127,6 +128,48 @@ func recipeBrickIDs(r recipe) []BrickID { return ids } +func TestRealRecipesHaveCompleteUniqueBricks(t *testing.T) { + recipes := []recipe{ + newPVERecipe(), + newPBSRecipe(), + newPBSCommandsRecipe(), + newPBSDatastoreInventoryRecipe(), + newPBSDatastoreConfigRecipe(), + newPBSPXARRecipe(), + newPBSUserConfigRecipe(), + newSystemRecipe(), + newDualRecipe(), + } + + for _, r := range recipes { + t.Run(r.Name, func(t *testing.T) { + if r.Name == "" { + t.Fatalf("recipe name is empty") + } + if len(r.Bricks) == 0 { + t.Fatalf("recipe %s has no bricks", r.Name) + } + + seen := make(map[BrickID]int, len(r.Bricks)) + for i, brick := range r.Bricks { + if brick.ID == "" { + t.Fatalf("recipe %s brick %d has empty ID", r.Name, i) + } + if brick.Description == "" { + t.Fatalf("recipe %s brick %s has empty description", r.Name, brick.ID) + } + if brick.Run == nil { + t.Fatalf("recipe %s brick %s has nil Run", r.Name, brick.ID) + } + if first, ok := seen[brick.ID]; ok { + t.Fatalf("recipe %s has duplicate brick ID %s at indexes %d and %d", r.Name, brick.ID, first, i) + } + seen[brick.ID] = i + } + }) + } +} + func TestNewPVERecipeOrder(t *testing.T) { got := recipeBrickIDs(newPVERecipe()) want := []BrickID{ From 06dbc3b82af7da87e77514882d6c6a0f2525616b Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:10:33 +0200 Subject: [PATCH 11/35] Refactor main into modular run pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split and reorganize the large monolithic cmd/proxsave/main.go into a modular run pipeline. Main now delegates to staged helper functions (startMainRun, setupRunContext, preparePreRuntimeArgs, prepareRuntime, runRuntime) and most runtime logic and helpers were moved into new files under cmd/proxsave/ (main_config_modes.go, main_defers.go, main_footer.go, main_identity.go, main_lifecycle.go, main_modes.go, main_modes_test.go, main_network.go, main_restore_decrypt.go, main_runtime.go, main_runtime_log.go, main_security.go, main_signals.go, main_state.go, main_support.go, main_update.go). This reduces imports and responsibilities in main.go, improves readability and testability, and adds a focused unit test (main_modes_test.go). No behavioral changes intended—code was relocated and organized for clearer control flow and easier maintenance. --- cmd/proxsave/main.go | 1326 +------------------------- cmd/proxsave/main_config_modes.go | 178 ++++ cmd/proxsave/main_defers.go | 86 ++ cmd/proxsave/main_footer.go | 236 +++++ cmd/proxsave/main_identity.go | 74 ++ cmd/proxsave/main_lifecycle.go | 148 +++ cmd/proxsave/main_modes.go | 261 +++++ cmd/proxsave/main_modes_test.go | 65 ++ cmd/proxsave/main_network.go | 122 +++ cmd/proxsave/main_restore_decrypt.go | 112 +++ cmd/proxsave/main_runtime.go | 304 ++++++ cmd/proxsave/main_runtime_log.go | 53 + cmd/proxsave/main_security.go | 22 + cmd/proxsave/main_signals.go | 36 + cmd/proxsave/main_state.go | 85 ++ cmd/proxsave/main_support.go | 34 + cmd/proxsave/main_update.go | 122 +++ 17 files changed, 1948 insertions(+), 1316 deletions(-) create mode 100644 cmd/proxsave/main_config_modes.go create mode 100644 cmd/proxsave/main_defers.go create mode 100644 cmd/proxsave/main_footer.go create mode 100644 cmd/proxsave/main_identity.go create mode 100644 cmd/proxsave/main_lifecycle.go create mode 100644 cmd/proxsave/main_modes.go create mode 100644 cmd/proxsave/main_modes_test.go create mode 100644 cmd/proxsave/main_network.go create mode 100644 cmd/proxsave/main_restore_decrypt.go create mode 100644 cmd/proxsave/main_runtime.go create mode 100644 cmd/proxsave/main_runtime_log.go create mode 100644 cmd/proxsave/main_security.go create mode 100644 cmd/proxsave/main_signals.go create mode 100644 cmd/proxsave/main_state.go create mode 100644 cmd/proxsave/main_support.go create mode 100644 cmd/proxsave/main_update.go diff --git a/cmd/proxsave/main.go b/cmd/proxsave/main.go index c92e0494..a5c3f2cd 100644 --- a/cmd/proxsave/main.go +++ b/cmd/proxsave/main.go @@ -2,34 +2,9 @@ package main import ( - "context" - "encoding/json" - "errors" - "fmt" "os" - "os/signal" - "path/filepath" - "runtime" - "runtime/debug" - "runtime/pprof" - "strconv" - "strings" - "sync" "syscall" "time" - - "github.com/tis24dev/proxsave/internal/cli" - "github.com/tis24dev/proxsave/internal/config" - "github.com/tis24dev/proxsave/internal/environment" - "github.com/tis24dev/proxsave/internal/identity" - "github.com/tis24dev/proxsave/internal/logging" - "github.com/tis24dev/proxsave/internal/notify" - "github.com/tis24dev/proxsave/internal/orchestrator" - "github.com/tis24dev/proxsave/internal/security" - "github.com/tis24dev/proxsave/internal/support" - "github.com/tis24dev/proxsave/internal/tui" - "github.com/tis24dev/proxsave/internal/types" - buildinfo "github.com/tis24dev/proxsave/internal/version" ) const ( @@ -51,1301 +26,20 @@ func main() { os.Exit(run()) } -var closeStdinOnce sync.Once - func run() int { - bootstrap := logging.NewBootstrapLogger() - - // Resolve the effective tool version once for the entire run. - toolVersion := buildinfo.String() - runDone := logging.DebugStartBootstrap(bootstrap, "main run", "version=%s", toolVersion) - - finalExitCode := types.ExitSuccess.Int() - showSummary := false - finalize := func(code int) int { - finalExitCode = code - return code - } - - // Track early errors that occur before backup starts - // This ensures notifications are sent even for initialization/config errors - var earlyErrorState *orchestrator.EarlyErrorState - var orch *orchestrator.Orchestrator - var pendingSupportStats *orchestrator.BackupStats - - defer func() { - logging.DebugStepBootstrap(bootstrap, "main run", "exit_code=%d", finalExitCode) - runDone(nil) - if r := recover(); r != nil { - stack := debug.Stack() - bootstrap.Error("PANIC: %v", r) - fmt.Fprintf(os.Stderr, "panic: %v\n%s\n", r, stack) - os.Exit(types.ExitPanicError.Int()) - } - }() - - // Setup signal handling for graceful shutdown - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - tui.SetAbortContext(ctx) - - // Handle SIGINT (Ctrl+C) and SIGTERM - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - go func() { - sig := <-sigChan - logging.DebugStepBootstrap(bootstrap, "signal", "received=%v", sig) - bootstrap.Info("\nReceived signal %v, initiating graceful shutdown...", sig) - cancel() // Cancel context to stop all operations - closeStdinOnce.Do(func() { - if file := os.Stdin; file != nil { - _ = file.Close() - } - }) - }() - - // Parse command-line arguments - args := cli.Parse() - logging.DebugStepBootstrap(bootstrap, "main run", "args parsed") - - // Handle version flag - if args.ShowVersion { - cli.ShowVersion() - return types.ExitSuccess.Int() - } - - // Handle help flag - if args.ShowHelp { - cli.ShowHelp() - return types.ExitSuccess.Int() - } - - if args.CleanupGuards { - incompatible := make([]string, 0, 8) - if args.Support { - incompatible = append(incompatible, "--support") - } - if args.Restore { - incompatible = append(incompatible, "--restore") - } - if args.Decrypt { - incompatible = append(incompatible, "--decrypt") - } - if args.Install { - incompatible = append(incompatible, "--install") - } - if args.NewInstall { - incompatible = append(incompatible, "--new-install") - } - if args.Upgrade { - incompatible = append(incompatible, "--upgrade") - } - if args.ForceNewKey { - incompatible = append(incompatible, "--newkey") - } - if args.EnvMigration || args.EnvMigrationDry { - incompatible = append(incompatible, "--env-migration/--env-migration-dry-run") - } - if args.UpgradeConfig || args.UpgradeConfigDry || args.UpgradeConfigJSON { - incompatible = append(incompatible, "--upgrade-config/--upgrade-config-dry-run/--upgrade-config-json") - } - - if len(incompatible) > 0 { - bootstrap.Error("--cleanup-guards cannot be combined with: %s", strings.Join(incompatible, ", ")) - return types.ExitConfigError.Int() - } - - level := types.LogLevelInfo - if args.LogLevel != types.LogLevelNone { - level = args.LogLevel - } - logger := logging.New(level, false) - - if err := orchestrator.CleanupMountGuards(ctx, logger, args.DryRun); err != nil { - bootstrap.Error("ERROR: %v", err) - return types.ExitGenericError.Int() - } - return types.ExitSuccess.Int() - } - - // Validate support mode compatibility with other CLI modes - logging.DebugStepBootstrap(bootstrap, "main run", "support_mode=%v", args.Support) - if args.Support { - incompatible := make([]string, 0, 6) - if args.Restore { - // allowed - } - if args.Decrypt { - incompatible = append(incompatible, "--decrypt") - } - if args.Install { - incompatible = append(incompatible, "--install") - } - if args.NewInstall { - incompatible = append(incompatible, "--new-install") - } - if args.EnvMigration || args.EnvMigrationDry { - incompatible = append(incompatible, "--env-migration") - } - if args.UpgradeConfig || args.UpgradeConfigDry || args.UpgradeConfigJSON { - incompatible = append(incompatible, "--upgrade-config") - } - if args.ForceNewKey { - incompatible = append(incompatible, "--newkey") - } - - if len(incompatible) > 0 { - bootstrap.Error("Support mode cannot be combined with: %s", strings.Join(incompatible, ", ")) - bootstrap.Error("--support is only available for the standard backup run or --restore.") - return types.ExitConfigError.Int() - } - } - - if args.Install && args.NewInstall { - bootstrap.Error("Cannot use --install and --new-install together. Choose one installation mode.") - return types.ExitConfigError.Int() - } - - if args.Upgrade && (args.Install || args.NewInstall) { - bootstrap.Error("Cannot use --upgrade together with --install or --new-install.") - return types.ExitConfigError.Int() - } - - // Resolve configuration path relative to the executable's base directory so - // that configs/ is located consistently next to the binary, regardless of - // the current working directory. - logging.DebugStepBootstrap(bootstrap, "main run", "resolving config path") - resolvedConfigPath, err := resolveInstallConfigPath(args.ConfigPath) - if err != nil { - bootstrap.Error("ERROR: %v", err) - return types.ExitConfigError.Int() - } - args.ConfigPath = resolvedConfigPath - - if args.UpgradeConfigJSON { - if _, err := os.Stat(args.ConfigPath); err != nil { - fmt.Fprintf(os.Stderr, "ERROR: configuration file not found: %v\n", err) - return types.ExitConfigError.Int() - } - - result, err := config.UpgradeConfigFile(args.ConfigPath) - if err != nil { - fmt.Fprintf(os.Stderr, "ERROR: Failed to upgrade configuration: %v\n", err) - return types.ExitConfigError.Int() - } - if result == nil { - result = &config.UpgradeResult{} - } - - enc := json.NewEncoder(os.Stdout) - if err := enc.Encode(result); err != nil { - fmt.Fprintf(os.Stderr, "ERROR: Failed to encode JSON: %v\n", err) - return types.ExitGenericError.Int() - } - return types.ExitSuccess.Int() - } - - // Dedicated upgrade mode (download latest binary and upgrade config keys) - if args.Upgrade { - logging.DebugStepBootstrap(bootstrap, "main run", "mode=upgrade") - return runUpgrade(ctx, args, bootstrap) - } - - newKeyCLI := args.ForceCLI - // Dedicated new key mode (no backup run) - if args.ForceNewKey { - logging.DebugStepBootstrap(bootstrap, "main run", "mode=newkey cli=%v", newKeyCLI) - flowLogLevel := types.LogLevelInfo - if args.LogLevel != types.LogLevelNone { - flowLogLevel = args.LogLevel - } - if err := runNewKey(ctx, args.ConfigPath, flowLogLevel, bootstrap, newKeyCLI); err != nil { - if isInstallAbortedError(err) || errors.Is(err, orchestrator.ErrAgeRecipientSetupAborted) { - return types.ExitSuccess.Int() - } - bootstrap.Error("ERROR: %v", err) - return types.ExitConfigError.Int() - } - return types.ExitSuccess.Int() - } - - decryptCLI := args.ForceCLI - if args.Decrypt { - logging.DebugStepBootstrap(bootstrap, "main run", "mode=decrypt cli=%v", decryptCLI) - if err := runDecryptWorkflowOnly(ctx, args.ConfigPath, bootstrap, toolVersion, decryptCLI); err != nil { - if errors.Is(err, orchestrator.ErrDecryptAborted) { - bootstrap.Info("Decrypt workflow aborted by user") - return types.ExitSuccess.Int() - } - bootstrap.Error("ERROR: %v", err) - return types.ExitGenericError.Int() - } - bootstrap.Info("Decrypt workflow completed successfully") - return types.ExitSuccess.Int() - } - - newInstallCLI := args.ForceCLI - if args.NewInstall { - logging.DebugStepBootstrap(bootstrap, "main run", "mode=new-install cli=%v", newInstallCLI) - flowLogLevel := types.LogLevelInfo - if args.LogLevel != types.LogLevelNone { - flowLogLevel = args.LogLevel - } - sessionLogger, cleanupSessionLog := startFlowSessionLog("new-install", flowLogLevel, bootstrap) - defer cleanupSessionLog() - if sessionLogger != nil { - sessionLogger.Info("Starting --new-install (config=%s)", args.ConfigPath) - } - if err := runNewInstall(ctx, args.ConfigPath, bootstrap, newInstallCLI); err != nil { - if sessionLogger != nil { - if isInstallAbortedError(err) { - sessionLogger.Warning("new-install aborted by user: %v", err) - } else { - sessionLogger.Error("new-install failed: %v", err) - } - } - // Interactive aborts (Ctrl+C, explicit cancel) are treated as a graceful exit - // and already summarized by the install footer. - if isInstallAbortedError(err) { - return types.ExitSuccess.Int() - } - bootstrap.Error("ERROR: %v", err) - return types.ExitConfigError.Int() - } - if sessionLogger != nil { - sessionLogger.Info("new-install completed successfully") - } - return types.ExitSuccess.Int() - } - - // Handle configuration upgrade dry-run (plan-only, no writes). - if args.UpgradeConfigDry { - logging.DebugStepBootstrap(bootstrap, "main run", "mode=upgrade-config-dry") - if err := ensureConfigExists(args.ConfigPath, bootstrap); err != nil { - bootstrap.Error("ERROR: %v", err) - return types.ExitConfigError.Int() - } - - bootstrap.Printf("Planning configuration upgrade using embedded template: %s", args.ConfigPath) - result, err := config.PlanUpgradeConfigFile(args.ConfigPath) - if err != nil { - bootstrap.Error("ERROR: Failed to plan configuration upgrade: %v", err) - return types.ExitConfigError.Int() - } - if len(result.Warnings) > 0 { - bootstrap.Warning("Config upgrade warnings (%d):", len(result.Warnings)) - for _, warning := range result.Warnings { - bootstrap.Warning(" - %s", warning) - } - } - if !result.Changed { - bootstrap.Println("Configuration is already up to date with the embedded template; no changes are required.") - return types.ExitSuccess.Int() - } - - if len(result.MissingKeys) > 0 { - bootstrap.Printf("Missing keys that would be added from the template (%d): %s", - len(result.MissingKeys), strings.Join(result.MissingKeys, ", ")) - } - if result.PreservedValues > 0 { - bootstrap.Printf("Existing values that would be preserved: %d", result.PreservedValues) - } - if len(result.ExtraKeys) > 0 { - bootstrap.Printf("Custom keys that would be preserved (not present in template) (%d): %s", - len(result.ExtraKeys), strings.Join(result.ExtraKeys, ", ")) - } - if len(result.CaseConflictKeys) > 0 { - bootstrap.Printf("Keys that differ only by case from the template (%d): %s", - len(result.CaseConflictKeys), strings.Join(result.CaseConflictKeys, ", ")) - } - bootstrap.Println("Dry run only: no files were modified. Use --upgrade-config to apply these changes.") - return types.ExitSuccess.Int() - } - - // Handle install wizard (runs before normal execution) - if args.Install { - logging.DebugStepBootstrap(bootstrap, "main run", "mode=install cli=%v", args.ForceCLI) - flowLogLevel := types.LogLevelInfo - if args.LogLevel != types.LogLevelNone { - flowLogLevel = args.LogLevel - } - sessionLogger, cleanupSessionLog := startFlowSessionLog("install", flowLogLevel, bootstrap) - defer cleanupSessionLog() - if sessionLogger != nil { - sessionLogger.Info("Starting --install (config=%s)", args.ConfigPath) - } - - var err error - if args.ForceCLI { - err = runInstall(ctx, args.ConfigPath, bootstrap) - } else { - err = runInstallTUI(ctx, args.ConfigPath, bootstrap) - } - - if err != nil { - if sessionLogger != nil { - if isInstallAbortedError(err) { - sessionLogger.Warning("install aborted by user: %v", err) - } else { - sessionLogger.Error("install failed: %v", err) - } - } - // Interactive aborts (Ctrl+C, explicit cancel) are treated as a graceful exit - // and already summarized by the install footer. - if isInstallAbortedError(err) { - return types.ExitSuccess.Int() - } - bootstrap.Error("ERROR: %v", err) - return types.ExitConfigError.Int() - } - if sessionLogger != nil { - sessionLogger.Info("install completed successfully") - } - return types.ExitSuccess.Int() - } - - // Pre-flight: enforce Go runtime version - if err := checkGoRuntimeVersion(goRuntimeMinVersion); err != nil { - bootstrap.Error("ERROR: %v", err) - return types.ExitEnvironmentError.Int() - } - - // Print header - bootstrap.Println("===========================================") - bootstrap.Println(" ProxSave - Go Version") - bootstrap.Printf(" Version: %s", toolVersion) - if sig := buildSignature(); sig != "" { - bootstrap.Printf(" Build Signature: %s", sig) - } - bootstrap.Println("===========================================") - bootstrap.Println("") - - // Detect Proxmox environment - bootstrap.Println("Detecting Proxmox environment...") - envInfo, err := environment.Detect() - if err != nil { - bootstrap.Warning("WARNING: %v", err) - bootstrap.Println("Continuing with limited functionality...") - } - bootstrap.Printf("✓ Proxmox Type: %s", envInfo.Type) - if envInfo.Type == types.ProxmoxDual { - bootstrap.Printf(" PVE Version: %s", envInfo.PVEVersion) - bootstrap.Printf(" PBS Version: %s", envInfo.PBSVersion) - } else { - bootstrap.Printf(" Version: %s", envInfo.Version) - } - bootstrap.Println("") - - // Handle configuration upgrade (schema-aware merge with embedded template). - if args.UpgradeConfig { - logging.DebugStepBootstrap(bootstrap, "main run", "mode=upgrade-config") - if err := ensureConfigExists(args.ConfigPath, bootstrap); err != nil { - bootstrap.Error("ERROR: %v", err) - return types.ExitConfigError.Int() - } - - bootstrap.Printf("Upgrading configuration file: %s", args.ConfigPath) - result, err := config.UpgradeConfigFile(args.ConfigPath) - if err != nil { - bootstrap.Error("ERROR: Failed to upgrade configuration: %v", err) - return types.ExitConfigError.Int() - } - if len(result.Warnings) > 0 { - bootstrap.Warning("Config upgrade warnings (%d):", len(result.Warnings)) - for _, warning := range result.Warnings { - bootstrap.Warning(" - %s", warning) - } - } - if !result.Changed { - bootstrap.Println("Configuration is already up to date with the embedded template; no changes were made.") - return types.ExitSuccess.Int() - } - - bootstrap.Println("Configuration upgraded successfully!") - if len(result.MissingKeys) > 0 { - bootstrap.Printf("- Added %d missing key(s): %s", - len(result.MissingKeys), strings.Join(result.MissingKeys, ", ")) - } else { - bootstrap.Println("- No new keys were required from the template") - } - if result.PreservedValues > 0 { - bootstrap.Printf("- Preserved %d existing value(s) from current configuration", result.PreservedValues) - } - if len(result.ExtraKeys) > 0 { - bootstrap.Printf("- Kept %d custom key(s) not present in the template: %s", - len(result.ExtraKeys), strings.Join(result.ExtraKeys, ", ")) - } - if len(result.CaseConflictKeys) > 0 { - bootstrap.Printf("- Preserved %d key(s) that differ only by case: %s", - len(result.CaseConflictKeys), strings.Join(result.CaseConflictKeys, ", ")) - } - if result.BackupPath != "" { - bootstrap.Printf("- Backup saved to: %s", result.BackupPath) - } - bootstrap.Println("✓ Configuration upgrade completed successfully.") - return types.ExitSuccess.Int() - } - - if args.EnvMigrationDry { - logging.DebugStepBootstrap(bootstrap, "main run", "mode=env-migration-dry") - return runEnvMigrationDry(ctx, args, bootstrap) - } - - if args.EnvMigration { - logging.DebugStepBootstrap(bootstrap, "main run", "mode=env-migration") - return runEnvMigration(ctx, args, bootstrap) - } - - // Support mode: interactive pre-flight questionnaire (mandatory) - if args.Support { - logging.DebugStepBootstrap(bootstrap, "main run", "mode=support") - meta, continueRun, interrupted := support.RunIntro(ctx, bootstrap) - if continueRun { - args.SupportGitHubUser = meta.GitHubUser - args.SupportIssueID = meta.IssueID - } else { - if interrupted { - // Interrupted by signal (Ctrl+C): set exit code and still show footer. - finalize(exitCodeInterrupted) - printFinalSummary(finalExitCode) - return finalExitCode - } - // Graceful abort (user declined support flow) - show standard footer. - finalize(types.ExitGenericError.Int()) - printFinalSummary(finalExitCode) - return finalExitCode - } - } - - // Load configuration - autoBaseDir, autoFound := detectBaseDir() - if autoBaseDir == "" { - if _, err := os.Stat("/opt/proxsave"); err == nil { - autoBaseDir = "/opt/proxsave" - } else { - autoBaseDir = "/opt/proxmox-backup" - } - } - initialEnvBaseDir := os.Getenv("BASE_DIR") - if initialEnvBaseDir == "" { - _ = os.Setenv("BASE_DIR", autoBaseDir) - } - - if err := ensureConfigExists(args.ConfigPath, bootstrap); err != nil { - bootstrap.Error("ERROR: %v", err) - return types.ExitConfigError.Int() - } - - bootstrap.Printf("Loading configuration from: %s", args.ConfigPath) - logging.DebugStepBootstrap(bootstrap, "main run", "loading configuration") - cfg, err := config.LoadConfig(args.ConfigPath) - if err != nil { - bootstrap.Error("ERROR: Failed to load configuration: %v", err) - return types.ExitConfigError.Int() - } - if cfg.BaseDir == "" { - cfg.BaseDir = autoBaseDir - } - _ = os.Setenv("BASE_DIR", cfg.BaseDir) - bootstrap.Println("✓ Configuration loaded successfully") - - // Show dry-run status early in bootstrap phase - dryRun := args.DryRun || cfg.DryRun - if dryRun { - if args.DryRun { - bootstrap.Println("⚠ DRY RUN MODE (enabled via --dry-run flag)") - } else { - bootstrap.Println("⚠ DRY RUN MODE (enabled via DRY_RUN config)") - } - } - bootstrap.Println("") - - if err := validateFutureFeatures(cfg); err != nil { - bootstrap.Error("ERROR: Invalid configuration: %v", err) - return types.ExitConfigError.Int() - } - - // Validate log path configuration early to avoid "cosmetic only" logging. - // If a log feature is enabled but its path is empty, disable the path-driven - // behavior and document the detection to the user. - if strings.TrimSpace(cfg.LogPath) == "" { - bootstrap.Warning("WARNING: LOG_PATH is empty - file logging disabled, using stdout only") - } - if cfg.SecondaryEnabled && strings.TrimSpace(cfg.SecondaryLogPath) == "" { - bootstrap.Warning("WARNING: Secondary storage enabled but SECONDARY_LOG_PATH is empty - secondary log copy and cleanup will be disabled for this run") - } - if cfg.CloudEnabled && strings.TrimSpace(cfg.CloudLogPath) == "" { - bootstrap.Warning("WARNING: Cloud storage enabled but CLOUD_LOG_PATH is empty - cloud log copy and cleanup will be disabled for this run") - } - - // Pre-flight: if features require network, verify basic connectivity - if needs, reasons := featuresNeedNetwork(cfg); needs { - if cfg.DisableNetworkPreflight { - bootstrap.Warning("WARNING: Network preflight disabled via DISABLE_NETWORK_PREFLIGHT; features: %s", strings.Join(reasons, ", ")) - } else { - if err := checkInternetConnectivity(networkPreflightTimeout); err != nil { - bootstrap.Warning("WARNING: Network connectivity unavailable for: %s. %v", strings.Join(reasons, ", "), err) - bootstrap.Warning("WARNING: Disabling network-dependent features for this run") - disableNetworkFeaturesForRun(cfg, bootstrap) - } - } - } - - // Determine log level (CLI overrides config) - logLevel := cfg.DebugLevel - if args.Support { - bootstrap.Println("Support mode enabled: forcing log level to DEBUG") - logLevel = types.LogLevelDebug - } else if args.LogLevel != types.LogLevelNone { - logLevel = args.LogLevel - } - logging.DebugStepBootstrap(bootstrap, "main run", "log_level=%s", logLevel.String()) - - // Initialize logger with configuration - logger := logging.New(logLevel, cfg.UseColor) - sessionLogActive := false - sessionLogCloser := func() {} - if args.Restore { - logging.DebugStepBootstrap(bootstrap, "main run", "restore log enabled") - if restoreLogger, restoreLogPath, closeFn, err := logging.StartSessionLogger("restore", logLevel, cfg.UseColor); err == nil { - logger = restoreLogger - sessionLogCloser = closeFn - sessionLogActive = true - bootstrap.Info("Restore log: %s", restoreLogPath) - _ = os.Setenv("LOG_FILE", restoreLogPath) - } else { - bootstrap.Warning("WARNING: Unable to start restore log: %v", err) - } - } - - logging.SetDefaultLogger(logger) - bootstrap.SetLevel(logLevel) - - // Open log file for real-time writing (will be closed after notifications) - hostname := resolveHostname() - startTime := time.Now() - timestampStr := startTime.Format("20060102-150405") - - if sessionLogActive { - defer sessionLogCloser() - } else { - logFileName := fmt.Sprintf("backup-%s-%s.log", hostname, timestampStr) - logFilePath := filepath.Join(cfg.LogPath, logFileName) - - // Ensure log directory exists - if err := os.MkdirAll(cfg.LogPath, defaultDirPerm); err != nil { - logging.Warning("Failed to create log directory %s: %v", cfg.LogPath, err) - } else { - if err := logger.OpenLogFile(logFilePath); err != nil { - logging.Warning("Failed to open log file %s: %v", logFilePath, err) - } else { - logging.Info("Log file opened: %s", logFilePath) - // Store log path in environment for backup stats - _ = os.Setenv("LOG_FILE", logFilePath) - } - } - } - - // Flush bootstrap logs into the main logger now that log files (if any) - // are attached, so that early banners and messages appear at the top - // of the corresponding log. - bootstrap.Flush(logger) - - // Best-effort check for newer releases on GitHub. - // If the installed version is up to date, nothing is printed at INFO/WARNING level - // (only a DEBUG message is logged). If a newer version exists, a WARNING is emitted - // suggesting the use of --upgrade. - updateInfo := checkForUpdates(ctx, logger, toolVersion) - - // Apply backup permissions (optional, Bash-compatible behavior) - if cfg.SetBackupPermissions { - logging.DebugStep(logger, "main", "applying backup permissions") - if err := applyBackupPermissions(cfg, logger); err != nil { - logging.Warning("Failed to apply backup permissions: %v", err) - } - } - - // Optional CPU/heap profiling (pprof) - controlled by PROFILING_ENABLED - var cpuProfileFile *os.File - var heapProfilePath string - if cfg.ProfilingEnabled { - cpuProfilePath := filepath.Join(cfg.LogPath, fmt.Sprintf("cpu-%s-%s.pprof", hostname, timestampStr)) - f, err := os.Create(cpuProfilePath) - if err != nil { - logging.Warning("Failed to create CPU profile file: %v", err) - } else { - if err := pprof.StartCPUProfile(f); err != nil { - logging.Warning("Failed to start CPU profiling: %v", err) - _ = f.Close() - } else { - cpuProfileFile = f - logging.Info("CPU profiling enabled: %s", cpuProfilePath) - - tmpProfileDir := filepath.Join("/tmp", "proxsave") - if err := os.MkdirAll(tmpProfileDir, defaultDirPerm); err != nil { - logging.Warning("Failed to create temp profile directory %s: %v", tmpProfileDir, err) - } else { - heapProfilePath = filepath.Join(tmpProfileDir, fmt.Sprintf("heap-%s-%s.pprof", hostname, timestampStr)) - } - } - } - } - - defer func() { - if showSummary { - printFinalSummary(finalExitCode) - } - }() - - // Defer for network rollback countdown (LIFO: executes BEFORE footer) - defer func() { - if finalExitCode == exitCodeInterrupted { - if abortInfo := orchestrator.GetLastRestoreAbortInfo(); abortInfo != nil { - printNetworkRollbackCountdown(abortInfo) - } - } - }() - - defer func() { - if !args.Support || pendingSupportStats == nil { - return - } - logging.Step("Support mode - sending support email with attached log") - support.SendEmail(ctx, cfg, logger, envInfo.Type, pendingSupportStats, support.Meta{ - GitHubUser: args.SupportGitHubUser, - IssueID: args.SupportIssueID, - }, buildSignature()) - }() - - // Defer for early error notifications - // This executes BEFORE the footer defer (LIFO order) - // Ensures notifications are sent even for errors that occur before backup starts - defer func() { - if earlyErrorState != nil && earlyErrorState.HasError() && orch != nil { - fmt.Println() - logging.Step("Sending error notifications") - stats := orch.DispatchEarlyErrorNotification(ctx, earlyErrorState) - if stats != nil { - pendingSupportStats = stats - } - orch.FinalizeAndCloseLog(ctx) - } - }() - - defer func() { - if cpuProfileFile != nil { - pprof.StopCPUProfile() - _ = cpuProfileFile.Close() - } - if heapProfilePath != "" { - if f, err := os.Create(heapProfilePath); err == nil { - if err := pprof.WriteHeapProfile(f); err != nil { - logging.Warning("Failed to write heap profile: %v", err) - } - _ = f.Close() - } else { - logging.Warning("Failed to create heap profile file: %v", err) - } - } - }() - - defer cleanupAfterRun(logger) - showSummary = true - - // Log dry-run status in main logger (already shown in bootstrap) - if dryRun { - if args.DryRun { - logging.Info("DRY RUN MODE: No actual changes will be made (enabled via --dry-run flag)") - } else { - logging.Info("DRY RUN MODE: No actual changes will be made (enabled via DRY_RUN config)") - } - } - - // Determine base directory source for logging - baseDirSource := "default fallback" - if rawBaseDir, ok := cfg.Get("BASE_DIR"); ok && strings.TrimSpace(rawBaseDir) != "" { - baseDirSource = "configured in backup.env" - } else if initialEnvBaseDir != "" { - baseDirSource = "from environment (BASE_DIR)" - } else if autoFound { - baseDirSource = "auto-detected from executable path" - } - - // Log environment info - logging.Info("Environment: %s %s", envInfo.Type, envInfo.Version) - unprivilegedInfo := environment.DetectUnprivilegedContainer() - logUserNamespaceContext(logger, unprivilegedInfo) - logging.Info("Backup enabled: %v", cfg.BackupEnabled) - logging.Info("Debug level: %s", logLevel.String()) - logging.Info("Compression: %s (level %d, mode %s)", cfg.CompressionType, cfg.CompressionLevel, cfg.CompressionMode) - logging.Info("Base directory: %s (%s)", cfg.BaseDir, baseDirSource) - configSource := args.ConfigPathSource - if configSource == "" { - configSource = "configured path" - } - logging.Info("Configuration file: %s (%s)", args.ConfigPath, configSource) - - var identityInfo *identity.Info - serverIDValue := strings.TrimSpace(cfg.ServerID) - serverMACValue := "" - telegramServerStatus := "Telegram disabled" - if info, err := identity.DetectWithContext(ctx, cfg.BaseDir, logger); err != nil { - logging.Warning("WARNING: Failed to load server identity: %v", err) - identityInfo = info - } else { - identityInfo = info - } - - if identityInfo != nil { - if identityInfo.ServerID != "" { - serverIDValue = identityInfo.ServerID - } - if identityInfo.PrimaryMAC != "" { - serverMACValue = identityInfo.PrimaryMAC - } - } - - if serverIDValue != "" && cfg.ServerID == "" { - cfg.ServerID = serverIDValue - } - - logServerIdentityValues(serverIDValue, serverMACValue) - logTelegramInfo := true - if cfg.TelegramEnabled { - if strings.EqualFold(cfg.TelegramBotType, "centralized") { - logging.Debug("Contacting remote Telegram server...") - logging.Debug("Telegram server URL: %s", cfg.TelegramServerAPIHost) - status := notify.CheckTelegramRegistration(ctx, cfg.TelegramServerAPIHost, serverIDValue, logger) - if status.Error != nil { - logging.Warning("Telegram: %s", status.Message) - logging.Debug("Telegram connection error: %v", status.Error) - logging.Skip("Telegram: disabled") - cfg.TelegramEnabled = false - logTelegramInfo = false - } else { - logging.Debug("Remote server contacted: Bot token / chat ID verified (handshake)") - } - telegramServerStatus = status.Message - } else { - telegramServerStatus = "Personal mode - no remote contact" - } - } - if logTelegramInfo { - logging.Info("Server Telegram: %s", telegramServerStatus) - } - fmt.Println() - - execInfo := getExecInfo() - execPath := execInfo.ExecPath - logging.DebugStep(logger, "main", "running security checks") - if _, secErr := security.Run(ctx, logger, cfg, args.ConfigPath, execPath, envInfo); secErr != nil { - logging.Error("Security checks failed: %v", secErr) - return finalize(types.ExitSecurityError.Int()) - } - fmt.Println() - - restoreCLI := args.ForceCLI - if args.Restore { - logging.DebugStep(logger, "main", "mode=restore cli=%v", restoreCLI) - if restoreCLI { - logging.Info("Restore mode enabled - starting CLI workflow...") - if err := orchestrator.RunRestoreWorkflow(ctx, cfg, logger, toolVersion); err != nil { - if errors.Is(err, orchestrator.ErrRestoreAborted) { - logging.Warning("Restore workflow aborted by user") - if args.Support { - pendingSupportStats = support.BuildSupportStats(logger, resolveHostname(), envInfo.Type, envInfo.Version, toolVersion, startTime, time.Now(), exitCodeInterrupted, "restore") - } - return finalize(exitCodeInterrupted) - } - logging.Error("Restore workflow failed: %v", err) - if args.Support { - pendingSupportStats = support.BuildSupportStats(logger, resolveHostname(), envInfo.Type, envInfo.Version, toolVersion, startTime, time.Now(), types.ExitGenericError.Int(), "restore") - } - return finalize(types.ExitGenericError.Int()) - } - if logger.HasWarnings() { - logging.Warning("Restore workflow completed with warnings (see log above)") - } else { - logging.Info("Restore workflow completed successfully") - } - if args.Support { - pendingSupportStats = support.BuildSupportStats(logger, resolveHostname(), envInfo.Type, envInfo.Version, toolVersion, startTime, time.Now(), types.ExitSuccess.Int(), "restore") - } - return finalize(types.ExitSuccess.Int()) - } - - logging.Info("Restore mode enabled - starting interactive workflow...") - sig := buildSignature() - if strings.TrimSpace(sig) == "" { - sig = "n/a" - } - if err := orchestrator.RunRestoreWorkflowTUI(ctx, cfg, logger, toolVersion, args.ConfigPath, sig); err != nil { - if errors.Is(err, orchestrator.ErrRestoreAborted) || errors.Is(err, orchestrator.ErrDecryptAborted) { - logging.Warning("Restore workflow aborted by user") - if args.Support { - pendingSupportStats = support.BuildSupportStats(logger, resolveHostname(), envInfo.Type, envInfo.Version, toolVersion, startTime, time.Now(), exitCodeInterrupted, "restore") - } - return finalize(exitCodeInterrupted) - } - logging.Error("Restore workflow failed: %v", err) - if args.Support { - pendingSupportStats = support.BuildSupportStats(logger, resolveHostname(), envInfo.Type, envInfo.Version, toolVersion, startTime, time.Now(), types.ExitGenericError.Int(), "restore") - } - return finalize(types.ExitGenericError.Int()) - } - if logger.HasWarnings() { - logging.Warning("Restore workflow completed with warnings (see log above)") - } else { - logging.Info("Restore workflow completed successfully") - } - if args.Support { - pendingSupportStats = support.BuildSupportStats(logger, resolveHostname(), envInfo.Type, envInfo.Version, toolVersion, startTime, time.Now(), types.ExitSuccess.Int(), "restore") - } - return finalize(types.ExitSuccess.Int()) - } - - if args.Decrypt { - logging.DebugStep(logger, "main", "mode=decrypt cli=%v", decryptCLI) - if decryptCLI { - logging.Info("Decrypt mode enabled - starting CLI workflow...") - if err := orchestrator.RunDecryptWorkflow(ctx, cfg, logger, toolVersion); err != nil { - if errors.Is(err, orchestrator.ErrDecryptAborted) { - logging.Info("Decrypt workflow aborted by user") - return finalize(types.ExitSuccess.Int()) - } - logging.Error("Decrypt workflow failed: %v", err) - return finalize(types.ExitGenericError.Int()) - } - logging.Info("Decrypt workflow completed successfully") - } else { - logging.Info("Decrypt mode enabled - starting interactive workflow...") - sig := buildSignature() - if strings.TrimSpace(sig) == "" { - sig = "n/a" - } - if err := orchestrator.RunDecryptWorkflowTUI(ctx, cfg, logger, toolVersion, args.ConfigPath, sig); err != nil { - if errors.Is(err, orchestrator.ErrDecryptAborted) { - logging.Info("Decrypt workflow aborted by user") - return finalize(types.ExitSuccess.Int()) - } - logging.Error("Decrypt workflow failed: %v", err) - return finalize(types.ExitGenericError.Int()) - } - logging.Info("Decrypt workflow completed successfully") - } - return finalize(types.ExitSuccess.Int()) - } - - backupResult := runBackupMode(backupModeOptions{ - ctx: ctx, - cfg: cfg, - logger: logger, - envInfo: envInfo, - unprivilegedInfo: unprivilegedInfo, - updateInfo: updateInfo, - toolVersion: toolVersion, - dryRun: dryRun, - startTime: startTime, - heapProfilePath: heapProfilePath, - serverIDValue: serverIDValue, - serverMACValue: serverMACValue, - }) - orch = backupResult.orch - earlyErrorState = backupResult.earlyErrorState - pendingSupportStats = backupResult.supportStats - return finalize(backupResult.exitCode) -} - -const rollbackCountdownDisplayDuration = 10 * time.Second - -func printNetworkRollbackCountdown(abortInfo *orchestrator.RestoreAbortInfo) { - if abortInfo == nil { - return - } - - color := "\033[33m" // yellow - colorReset := "\033[0m" - - markerExists := false - if strings.TrimSpace(abortInfo.NetworkRollbackMarker) != "" { - if _, err := os.Stat(strings.TrimSpace(abortInfo.NetworkRollbackMarker)); err == nil { - markerExists = true - } - } - - status := "UNKNOWN" - switch { - case markerExists: - status = "ARMED (will execute automatically)" - case !abortInfo.RollbackDeadline.IsZero() && time.Now().After(abortInfo.RollbackDeadline): - status = "EXECUTED (marker removed)" - case strings.TrimSpace(abortInfo.NetworkRollbackMarker) != "": - status = "DISARMED/CLEARED (marker removed before deadline)" - case abortInfo.NetworkRollbackArmed: - status = "ARMED (status from snapshot)" - default: - status = "NOT ARMED" - } - - fmt.Println() - fmt.Printf("%s===========================================\n", color) - fmt.Printf("NETWORK ROLLBACK%s\n", colorReset) - fmt.Println() - - // Static info - fmt.Printf(" Status: %s\n", status) - if strings.TrimSpace(abortInfo.OriginalIP) != "" && abortInfo.OriginalIP != "unknown" { - fmt.Printf(" Pre-apply IP (from snapshot): %s\n", strings.TrimSpace(abortInfo.OriginalIP)) - } - if strings.TrimSpace(abortInfo.CurrentIP) != "" && abortInfo.CurrentIP != "unknown" { - fmt.Printf(" Post-apply IP (observed): %s\n", strings.TrimSpace(abortInfo.CurrentIP)) - } - if strings.TrimSpace(abortInfo.NetworkRollbackLog) != "" { - fmt.Printf(" Rollback log: %s\n", strings.TrimSpace(abortInfo.NetworkRollbackLog)) - } - fmt.Println() - - switch { - case markerExists && !abortInfo.RollbackDeadline.IsZero() && time.Until(abortInfo.RollbackDeadline) > 0: - fmt.Println("Connection will be temporarily interrupted during restore.") - if strings.TrimSpace(abortInfo.OriginalIP) != "" && abortInfo.OriginalIP != "unknown" { - fmt.Printf("Remember to reconnect using the pre-apply IP: %s\n", strings.TrimSpace(abortInfo.OriginalIP)) - } - case !markerExists && !abortInfo.RollbackDeadline.IsZero() && time.Now().After(abortInfo.RollbackDeadline): - if strings.TrimSpace(abortInfo.OriginalIP) != "" && abortInfo.OriginalIP != "unknown" { - fmt.Printf("Rollback executed: reconnect using the pre-apply IP: %s\n", strings.TrimSpace(abortInfo.OriginalIP)) - } - case !markerExists && strings.TrimSpace(abortInfo.NetworkRollbackMarker) != "": - if strings.TrimSpace(abortInfo.CurrentIP) != "" && abortInfo.CurrentIP != "unknown" { - fmt.Printf("Rollback will NOT run: reconnect using the post-apply IP: %s\n", strings.TrimSpace(abortInfo.CurrentIP)) - } - } - - // Live countdown for max 10 seconds (only when rollback is still armed). - if !markerExists || abortInfo.RollbackDeadline.IsZero() { - fmt.Printf("%s===========================================%s\n", color, colorReset) - return - } - - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - displayEnd := time.Now().Add(rollbackCountdownDisplayDuration) - - for { - remaining := time.Until(abortInfo.RollbackDeadline) - if remaining <= 0 { - fmt.Printf("\r Remaining: Rollback executing now... \n") - break - } - if time.Now().After(displayEnd) { - fmt.Printf("\r Remaining: %ds (exiting, rollback will proceed)\n", int(remaining.Seconds())) - break - } - fmt.Printf("\r Remaining: %ds ", int(remaining.Seconds())) - - <-ticker.C - } - - fmt.Printf("%s===========================================%s\n", color, colorReset) -} - -func printFinalSummary(finalExitCode int) { - fmt.Println() - - // Print a flat list of all WARNING/ERROR/CRITICAL log entries that occurred during the run. - // This makes it easy to spot the root cause(s) behind a WARNING/ERROR exit status without - // scrolling through the full log. - logger := logging.GetDefaultLogger() - if logger != nil { - issues := logger.IssueLines() - if len(issues) > 0 { - fmt.Println("===========================================") - fmt.Printf("WARNINGS/ERRORS DURING RUN (warnings=%d errors=%d)\n", logger.WarningCount(), logger.ErrorCount()) - fmt.Println() - for _, line := range issues { - fmt.Println(line) - } - fmt.Println("===========================================") - fmt.Println() - } - } - - summarySig := buildSignature() - if summarySig == "" { - summarySig = "unknown" - } - - colorReset := "\033[0m" - color := "" - hasWarnings := logger != nil && logger.HasWarnings() - - switch { - case finalExitCode == exitCodeInterrupted: - color = "\033[35m" // magenta for Ctrl+C - case finalExitCode == 0 && hasWarnings: - color = "\033[33m" // yellow for success with warnings - case finalExitCode == 0: - color = "\033[32m" // green for clean success - case finalExitCode == types.ExitGenericError.Int(): - color = "\033[33m" // yellow for generic error (non-fatal) - default: - color = "\033[31m" // red for all other errors - } - - if color != "" { - fmt.Printf("%s===========================================\n", color) - fmt.Printf("ProxSave - Go - %s\n", summarySig) - fmt.Printf("===========================================%s\n", colorReset) - } else { - fmt.Println("===========================================") - fmt.Printf("ProxSave - Go - %s\n", summarySig) - fmt.Println("===========================================") - } - - fmt.Println() - fmt.Println("\033[31mEXTRA STEP - IF YOU FIND THIS TOOL USEFUL AND WANT TO THANK ME, A COFFEE IS ALWAYS WELCOME!\033[0m") - fmt.Println("https://github.com/sponsors/tis24dev") - fmt.Println() - fmt.Println("Commands:") - fmt.Println(" proxsave (alias: proxmox-backup) - Start backup") - fmt.Println(" --help - Show all options") - fmt.Println(" --dry-run - Test without changes") - fmt.Println(" --install - Re-run interactive installation/setup") - fmt.Println(" --new-install - Wipe installation directory (keep build/env/identity) then run installer") - fmt.Println(" --env-migration - Run installer and migrate legacy Bash backup.env to Go template") - fmt.Println(" --env-migration-dry-run - Preview installer/migration without writing files") - fmt.Println(" --upgrade - Update proxsave binary to latest release (also adds missing keys to backup.env)") - fmt.Println(" --newkey - Generate a new encryption key for backups") - fmt.Println(" --decrypt - Decrypt an existing backup archive") - fmt.Println(" --restore - Run interactive restore workflow (select bundle, decrypt if needed, apply to system)") - fmt.Println(" --upgrade-config - Upgrade configuration file using the embedded template (run after installing a new binary)") - fmt.Println(" --support - Run in support mode (force debug log level and send email with attached log to github-support@tis24.it); available for standard backup and --restore") - fmt.Println() -} - -// checkGoRuntimeVersion ensures the running binary was built with at least the specified Go version (semver: major.minor.patch). -func checkGoRuntimeVersion(min string) error { - rt := runtime.Version() // e.g., "go1.25.4" - // 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 - v = strings.TrimPrefix(v, "go") - parts := strings.Split(v, ".") - toInt := func(s string) int { n, _ := strconv.Atoi(s); return n } - major, minor, patch := 0, 0, 0 - if len(parts) > 0 { - major = toInt(parts[0]) - } - if len(parts) > 1 { - minor = toInt(parts[1]) - } - if len(parts) > 2 { - patch = toInt(parts[2]) - } - return major, minor, patch - } - - rtMaj, rtMin, rtPatch := parse(rt) - minMaj, minMin, minPatch := parse(min) - - newer := func(aMaj, aMin, aPatch, bMaj, bMin, bPatch int) bool { - if aMaj != bMaj { - return aMaj > bMaj - } - if aMin != bMin { - return aMin > bMin - } - return aPatch >= bPatch - } - - if !newer(rtMaj, rtMin, rtPatch, minMaj, minMin, minPatch) { - return fmt.Errorf("go runtime version %s is below required %s — rebuild with go %s or set GOTOOLCHAIN=auto", rt, "go"+min, "go"+min) - } - return nil -} - -// featuresNeedNetwork returns whether current configuration requires outbound network, and human reasons. -func featuresNeedNetwork(cfg *config.Config) (bool, []string) { - reasons := []string{} - // Telegram (any mode uses network) - if cfg.TelegramEnabled { - if strings.EqualFold(cfg.TelegramBotType, "centralized") { - reasons = append(reasons, "Telegram centralized registration") - } else { - reasons = append(reasons, "Telegram personal notifications") - } - } - // Email via relay - if cfg.EmailEnabled && strings.EqualFold(cfg.EmailDeliveryMethod, "relay") { - reasons = append(reasons, "Email relay delivery") - } - // Gotify - if cfg.GotifyEnabled { - reasons = append(reasons, "Gotify notifications") - } - // Webhooks - if cfg.WebhookEnabled { - reasons = append(reasons, "Webhooks") - } - // Cloud uploads via rclone - if cfg.CloudEnabled { - reasons = append(reasons, "Cloud storage (rclone)") - } - return len(reasons) > 0, reasons -} - -// disableNetworkFeaturesForRun disables all network-dependent features when connectivity is unavailable. -func disableNetworkFeaturesForRun(cfg *config.Config, bootstrap *logging.BootstrapLogger) { - if cfg == nil { - return - } - warn := func(format string, args ...interface{}) { - if bootstrap != nil { - bootstrap.Warning(format, args...) - return - } - logging.Warning(format, args...) - } - - if cfg.CloudEnabled { - warn("WARNING: Disabling cloud storage (rclone) due to missing network connectivity") - cfg.CloudEnabled = false - cfg.CloudLogPath = "" - } - - if cfg.TelegramEnabled { - warn("WARNING: Disabling Telegram notifications due to missing network connectivity") - cfg.TelegramEnabled = false - } - - if cfg.EmailEnabled && strings.EqualFold(cfg.EmailDeliveryMethod, "relay") { - if cfg.EmailFallbackSendmail { - warn("WARNING: Network unavailable; switching Email delivery to sendmail for this run") - cfg.EmailDeliveryMethod = "sendmail" - } else { - warn("WARNING: Disabling Email relay notifications due to missing network connectivity") - cfg.EmailEnabled = false - } - } - - if cfg.GotifyEnabled { - warn("WARNING: Disabling Gotify notifications due to missing network connectivity") - cfg.GotifyEnabled = false - } - - if cfg.WebhookEnabled { - warn("WARNING: Disabling Webhook notifications due to missing network connectivity") - cfg.WebhookEnabled = false - } - -} - -// UpdateInfo holds information about the version check result. -type UpdateInfo struct { - NewVersion bool - Current string - Latest string -} - -// checkForUpdates performs a best-effort check against the latest GitHub release. -// - If the latest version cannot be determined or the current version is already up to date, -// only a DEBUG log entry is written (no user-facing output). -// - If a newer version is available, a WARNING is logged suggesting the --upgrade command. -// Additionally, a populated *UpdateInfo is returned so that callers can propagate -// structured information into notifications/metrics. -func checkForUpdates(ctx context.Context, logger *logging.Logger, currentVersion string) *UpdateInfo { - if logger == nil { - return nil - } - - currentVersion = strings.TrimSpace(currentVersion) - if currentVersion == "" { - logger.Debug("Update check skipped: current version is empty") - return nil - } - - checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + runInfo := startMainRun() + defer finishMainRun(runInfo) + ctx, cancel := setupRunContext(runInfo.bootstrap) defer cancel() - logger.Debug("Checking for ProxSave updates (current: %s)", currentVersion) - - apiURL := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo) - logger.Debug("Fetching latest release from GitHub: %s", apiURL) - - _, latestVersion, err := fetchLatestRelease(checkCtx) - if err != nil { - logger.Debug("Update check skipped: GitHub unreachable: %v", err) - return &UpdateInfo{ - NewVersion: false, - Current: currentVersion, - } + args, exitCode, handled := preparePreRuntimeArgs(ctx, runInfo.bootstrap, runInfo.toolVersion) + if handled { + return exitCode } - latestVersion = strings.TrimSpace(latestVersion) - if latestVersion == "" { - logger.Debug("Update check skipped: latest version from GitHub is empty") - return &UpdateInfo{ - NewVersion: false, - Current: currentVersion, - } - } - - if !isNewerVersion(currentVersion, latestVersion) { - logger.Debug("Update check completed: latest=%s current=%s (up to date)", latestVersion, currentVersion) - return &UpdateInfo{ - NewVersion: false, - Current: currentVersion, - Latest: latestVersion, - } - } - - logger.Debug("Update check completed: latest=%s current=%s (new version available)", latestVersion, currentVersion) - logger.Warning("New ProxSave version %s (current %s): run 'proxsave --upgrade' to install.", latestVersion, currentVersion) - - return &UpdateInfo{ - NewVersion: true, - Current: currentVersion, - Latest: latestVersion, - } -} - -// isNewerVersion returns true if latest is strictly newer than current, -// comparing MAJOR.MINOR.PATCH (ignoring any leading 'v' and pre-release suffixes). -func isNewerVersion(current, latest string) bool { - parse := func(v string) (int, int, int) { - v = strings.TrimSpace(v) - v = strings.TrimPrefix(v, "v") - if i := strings.IndexByte(v, '-'); i >= 0 { - v = v[:i] - } - - parts := strings.Split(v, ".") - toInt := func(s string) int { - n, _ := strconv.Atoi(s) - return n - } - - major, minor, patch := 0, 0, 0 - if len(parts) > 0 { - major = toInt(parts[0]) - } - if len(parts) > 1 { - minor = toInt(parts[1]) - } - if len(parts) > 2 { - patch = toInt(parts[2]) - } - return major, minor, patch - } - - curMaj, curMin, curPatch := parse(current) - latMaj, latMin, latPatch := parse(latest) - - if latMaj != curMaj { - return latMaj > curMaj - } - if latMin != curMin { - return latMin > curMin + rt, exitCode, ok := prepareRuntime(ctx, args, runInfo.bootstrap, runInfo.state, runInfo.toolVersion) + if !ok { + return exitCode } - return latPatch > curPatch + return runRuntime(rt, runInfo.state) } diff --git a/cmd/proxsave/main_config_modes.go b/cmd/proxsave/main_config_modes.go new file mode 100644 index 00000000..e47179ee --- /dev/null +++ b/cmd/proxsave/main_config_modes.go @@ -0,0 +1,178 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/tis24dev/proxsave/internal/cli" + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/types" +) + +type postHeaderConfigModeHandler func(context.Context, *cli.Args, *logging.BootstrapLogger) (int, bool) + +func runUpgradeConfigJSONMode(args *cli.Args) (int, bool) { + if !args.UpgradeConfigJSON { + return types.ExitSuccess.Int(), false + } + if _, err := os.Stat(args.ConfigPath); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: configuration file not found: %v\n", err) + return types.ExitConfigError.Int(), true + } + + result, err := config.UpgradeConfigFile(args.ConfigPath) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: Failed to upgrade configuration: %v\n", err) + return types.ExitConfigError.Int(), true + } + if result == nil { + result = &config.UpgradeResult{} + } + + enc := json.NewEncoder(os.Stdout) + if err := enc.Encode(result); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: Failed to encode JSON: %v\n", err) + return types.ExitGenericError.Int(), true + } + return types.ExitSuccess.Int(), true +} + +func dispatchPostHeaderConfigModes(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger) (int, bool) { + for _, handler := range []postHeaderConfigModeHandler{ + runUpgradeConfigMode, + runEnvMigrationDryMode, + runEnvMigrationMode, + } { + if exitCode, handled := handler(ctx, args, bootstrap); handled { + return exitCode, true + } + } + return types.ExitSuccess.Int(), false +} + +func runUpgradeConfigMode(_ context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger) (int, bool) { + if !args.UpgradeConfig { + return types.ExitSuccess.Int(), false + } + logging.DebugStepBootstrap(bootstrap, "main run", "mode=upgrade-config") + if err := ensureConfigExists(args.ConfigPath, bootstrap); err != nil { + bootstrap.Error("ERROR: %v", err) + return types.ExitConfigError.Int(), true + } + + bootstrap.Printf("Upgrading configuration file: %s", args.ConfigPath) + result, err := config.UpgradeConfigFile(args.ConfigPath) + if err != nil { + bootstrap.Error("ERROR: Failed to upgrade configuration: %v", err) + return types.ExitConfigError.Int(), true + } + logConfigUpgradeWarnings(bootstrap, result.Warnings) + if !result.Changed { + bootstrap.Println("Configuration is already up to date with the embedded template; no changes were made.") + return types.ExitSuccess.Int(), true + } + + printConfigUpgradeApplyResult(bootstrap, result) + return types.ExitSuccess.Int(), true +} + +func runUpgradeConfigDryMode(_ context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger, _ string) (int, bool) { + if !args.UpgradeConfigDry { + return types.ExitSuccess.Int(), false + } + logging.DebugStepBootstrap(bootstrap, "main run", "mode=upgrade-config-dry") + if err := ensureConfigExists(args.ConfigPath, bootstrap); err != nil { + bootstrap.Error("ERROR: %v", err) + return types.ExitConfigError.Int(), true + } + + bootstrap.Printf("Planning configuration upgrade using embedded template: %s", args.ConfigPath) + result, err := config.PlanUpgradeConfigFile(args.ConfigPath) + if err != nil { + bootstrap.Error("ERROR: Failed to plan configuration upgrade: %v", err) + return types.ExitConfigError.Int(), true + } + logConfigUpgradeWarnings(bootstrap, result.Warnings) + if !result.Changed { + bootstrap.Println("Configuration is already up to date with the embedded template; no changes are required.") + return types.ExitSuccess.Int(), true + } + + printConfigUpgradeDryRunResult(bootstrap, result) + return types.ExitSuccess.Int(), true +} + +func runEnvMigrationDryMode(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger) (int, bool) { + if !args.EnvMigrationDry { + return types.ExitSuccess.Int(), false + } + logging.DebugStepBootstrap(bootstrap, "main run", "mode=env-migration-dry") + return runEnvMigrationDry(ctx, args, bootstrap), true +} + +func runEnvMigrationMode(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger) (int, bool) { + if !args.EnvMigration { + return types.ExitSuccess.Int(), false + } + logging.DebugStepBootstrap(bootstrap, "main run", "mode=env-migration") + return runEnvMigration(ctx, args, bootstrap), true +} + +func logConfigUpgradeWarnings(bootstrap *logging.BootstrapLogger, warnings []string) { + if len(warnings) == 0 { + return + } + bootstrap.Warning("Config upgrade warnings (%d):", len(warnings)) + for _, warning := range warnings { + bootstrap.Warning(" - %s", warning) + } +} + +func printConfigUpgradeDryRunResult(bootstrap *logging.BootstrapLogger, result *config.UpgradeResult) { + if len(result.MissingKeys) > 0 { + bootstrap.Printf("Missing keys that would be added from the template (%d): %s", + len(result.MissingKeys), strings.Join(result.MissingKeys, ", ")) + } + if result.PreservedValues > 0 { + bootstrap.Printf("Existing values that would be preserved: %d", result.PreservedValues) + } + if len(result.ExtraKeys) > 0 { + bootstrap.Printf("Custom keys that would be preserved (not present in template) (%d): %s", + len(result.ExtraKeys), strings.Join(result.ExtraKeys, ", ")) + } + if len(result.CaseConflictKeys) > 0 { + bootstrap.Printf("Keys that differ only by case from the template (%d): %s", + len(result.CaseConflictKeys), strings.Join(result.CaseConflictKeys, ", ")) + } + bootstrap.Println("Dry run only: no files were modified. Use --upgrade-config to apply these changes.") +} + +func printConfigUpgradeApplyResult(bootstrap *logging.BootstrapLogger, result *config.UpgradeResult) { + bootstrap.Println("Configuration upgraded successfully!") + if len(result.MissingKeys) > 0 { + bootstrap.Printf("- Added %d missing key(s): %s", + len(result.MissingKeys), strings.Join(result.MissingKeys, ", ")) + } else { + bootstrap.Println("- No new keys were required from the template") + } + if result.PreservedValues > 0 { + bootstrap.Printf("- Preserved %d existing value(s) from current configuration", result.PreservedValues) + } + if len(result.ExtraKeys) > 0 { + bootstrap.Printf("- Kept %d custom key(s) not present in the template: %s", + len(result.ExtraKeys), strings.Join(result.ExtraKeys, ", ")) + } + if len(result.CaseConflictKeys) > 0 { + bootstrap.Printf("- Preserved %d key(s) that differ only by case: %s", + len(result.CaseConflictKeys), strings.Join(result.CaseConflictKeys, ", ")) + } + if result.BackupPath != "" { + bootstrap.Printf("- Backup saved to: %s", result.BackupPath) + } + bootstrap.Println("✓ Configuration upgrade completed successfully.") +} diff --git a/cmd/proxsave/main_defers.go b/cmd/proxsave/main_defers.go new file mode 100644 index 00000000..0ed07345 --- /dev/null +++ b/cmd/proxsave/main_defers.go @@ -0,0 +1,86 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "fmt" + "os" + "runtime/pprof" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/orchestrator" + "github.com/tis24dev/proxsave/internal/support" +) + +type runDeferredAction func() + +func runDeferredActions(rt *appRuntime, state *appRunState) []runDeferredAction { + return []runDeferredAction{ + func() { + if state.showSummary { + printFinalSummary(state.finalExitCode) + } + }, + func() { + if state.finalExitCode == exitCodeInterrupted { + if abortInfo := orchestrator.GetLastRestoreAbortInfo(); abortInfo != nil { + printNetworkRollbackCountdown(abortInfo) + } + } + }, + func() { + sendDeferredSupportEmail(rt, state) + }, + func() { + dispatchDeferredEarlyErrorNotification(rt, state) + }, + func() { + closeRunProfiling(rt) + }, + func() { + cleanupAfterRun(rt.logger) + }, + } +} + +func sendDeferredSupportEmail(rt *appRuntime, state *appRunState) { + if !rt.args.Support || state.pendingSupportStat == nil { + return + } + logging.Step("Support mode - sending support email with attached log") + support.SendEmail(rt.ctx, rt.cfg, rt.logger, rt.envInfo.Type, state.pendingSupportStat, support.Meta{ + GitHubUser: rt.args.SupportGitHubUser, + IssueID: rt.args.SupportIssueID, + }, buildSignature()) +} + +func dispatchDeferredEarlyErrorNotification(rt *appRuntime, state *appRunState) { + if state.earlyErrorState == nil || !state.earlyErrorState.HasError() || state.orch == nil { + return + } + fmt.Println() + logging.Step("Sending error notifications") + stats := state.orch.DispatchEarlyErrorNotification(rt.ctx, state.earlyErrorState) + if stats != nil { + state.pendingSupportStat = stats + } + state.orch.FinalizeAndCloseLog(rt.ctx) +} + +func closeRunProfiling(rt *appRuntime) { + if rt.cpuProfileFile != nil { + pprof.StopCPUProfile() + _ = rt.cpuProfileFile.Close() + } + if rt.heapProfilePath == "" { + return + } + f, err := os.Create(rt.heapProfilePath) + if err != nil { + logging.Warning("Failed to create heap profile file: %v", err) + return + } + if err := pprof.WriteHeapProfile(f); err != nil { + logging.Warning("Failed to write heap profile: %v", err) + } + _ = f.Close() +} diff --git a/cmd/proxsave/main_footer.go b/cmd/proxsave/main_footer.go new file mode 100644 index 00000000..1a8ddc9f --- /dev/null +++ b/cmd/proxsave/main_footer.go @@ -0,0 +1,236 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/orchestrator" + "github.com/tis24dev/proxsave/internal/types" +) + +const rollbackCountdownDisplayDuration = 10 * time.Second + +func printNetworkRollbackCountdown(abortInfo *orchestrator.RestoreAbortInfo) { + if abortInfo == nil { + return + } + + color := "\033[33m" // yellow + colorReset := "\033[0m" + markerExists := networkRollbackMarkerExists(abortInfo.NetworkRollbackMarker) + status := networkRollbackStatus(abortInfo, markerExists, time.Now()) + + printNetworkRollbackHeader(color, colorReset) + printNetworkRollbackStaticInfo(abortInfo, status) + printNetworkRollbackReconnectHint(abortInfo, markerExists, time.Now()) + + // Live countdown for max 10 seconds (only when rollback is still armed). + if !markerExists || abortInfo.RollbackDeadline.IsZero() { + fmt.Printf("%s===========================================%s\n", color, colorReset) + return + } + + printNetworkRollbackLiveCountdown(abortInfo.RollbackDeadline) + fmt.Printf("%s===========================================%s\n", color, colorReset) +} + +func networkRollbackMarkerExists(marker string) bool { + marker = strings.TrimSpace(marker) + if marker == "" { + return false + } + _, err := os.Stat(marker) + return err == nil +} + +func networkRollbackStatus(abortInfo *orchestrator.RestoreAbortInfo, markerExists bool, now time.Time) string { + switch { + case markerExists: + return "ARMED (will execute automatically)" + case !abortInfo.RollbackDeadline.IsZero() && now.After(abortInfo.RollbackDeadline): + return "EXECUTED (marker removed)" + case strings.TrimSpace(abortInfo.NetworkRollbackMarker) != "": + return "DISARMED/CLEARED (marker removed before deadline)" + case abortInfo.NetworkRollbackArmed: + return "ARMED (status from snapshot)" + default: + return "NOT ARMED" + } +} + +func printNetworkRollbackHeader(color, colorReset string) { + fmt.Println() + fmt.Printf("%s===========================================\n", color) + fmt.Printf("NETWORK ROLLBACK%s\n", colorReset) + fmt.Println() +} + +func printNetworkRollbackStaticInfo(abortInfo *orchestrator.RestoreAbortInfo, status string) { + fmt.Printf(" Status: %s\n", status) + if knownValue(abortInfo.OriginalIP) { + fmt.Printf(" Pre-apply IP (from snapshot): %s\n", strings.TrimSpace(abortInfo.OriginalIP)) + } + if knownValue(abortInfo.CurrentIP) { + fmt.Printf(" Post-apply IP (observed): %s\n", strings.TrimSpace(abortInfo.CurrentIP)) + } + if strings.TrimSpace(abortInfo.NetworkRollbackLog) != "" { + fmt.Printf(" Rollback log: %s\n", strings.TrimSpace(abortInfo.NetworkRollbackLog)) + } + fmt.Println() +} + +func printNetworkRollbackReconnectHint(abortInfo *orchestrator.RestoreAbortInfo, markerExists bool, now time.Time) { + if printArmedRollbackReconnectHint(abortInfo, markerExists) { + return + } + if printExecutedRollbackReconnectHint(abortInfo, markerExists, now) { + return + } + printDisarmedRollbackReconnectHint(abortInfo, markerExists) +} + +func printArmedRollbackReconnectHint(abortInfo *orchestrator.RestoreAbortInfo, markerExists bool) bool { + if !markerExists || abortInfo.RollbackDeadline.IsZero() || time.Until(abortInfo.RollbackDeadline) <= 0 { + return false + } + fmt.Println("Connection will be temporarily interrupted during restore.") + if knownValue(abortInfo.OriginalIP) { + fmt.Printf("Remember to reconnect using the pre-apply IP: %s\n", strings.TrimSpace(abortInfo.OriginalIP)) + } + return true +} + +func printExecutedRollbackReconnectHint(abortInfo *orchestrator.RestoreAbortInfo, markerExists bool, now time.Time) bool { + if markerExists || abortInfo.RollbackDeadline.IsZero() || !now.After(abortInfo.RollbackDeadline) { + return false + } + if knownValue(abortInfo.OriginalIP) { + fmt.Printf("Rollback executed: reconnect using the pre-apply IP: %s\n", strings.TrimSpace(abortInfo.OriginalIP)) + } + return true +} + +func printDisarmedRollbackReconnectHint(abortInfo *orchestrator.RestoreAbortInfo, markerExists bool) { + if markerExists || strings.TrimSpace(abortInfo.NetworkRollbackMarker) == "" || !knownValue(abortInfo.CurrentIP) { + return + } + fmt.Printf("Rollback will NOT run: reconnect using the post-apply IP: %s\n", strings.TrimSpace(abortInfo.CurrentIP)) +} + +func knownValue(value string) bool { + value = strings.TrimSpace(value) + return value != "" && value != "unknown" +} + +func printNetworkRollbackLiveCountdown(deadline time.Time) { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + displayEnd := time.Now().Add(rollbackCountdownDisplayDuration) + + for { + remaining := time.Until(deadline) + if remaining <= 0 { + fmt.Printf("\r Remaining: Rollback executing now... \n") + break + } + if time.Now().After(displayEnd) { + fmt.Printf("\r Remaining: %ds (exiting, rollback will proceed)\n", int(remaining.Seconds())) + break + } + fmt.Printf("\r Remaining: %ds ", int(remaining.Seconds())) + + <-ticker.C + } +} + +func printFinalSummary(finalExitCode int) { + fmt.Println() + + logger := logging.GetDefaultLogger() + printRunIssueSummary(logger) + printFinalSummaryHeader(finalSummarySignature(), finalSummaryColor(finalExitCode, logger)) + printFinalSummaryCommands() +} + +func finalSummarySignature() string { + summarySig := buildSignature() + if summarySig == "" { + return "unknown" + } + return summarySig +} + +func finalSummaryColor(finalExitCode int, logger *logging.Logger) string { + hasWarnings := logger != nil && logger.HasWarnings() + + switch { + case finalExitCode == exitCodeInterrupted: + return "\033[35m" // magenta for Ctrl+C + case finalExitCode == 0 && hasWarnings: + return "\033[33m" // yellow for success with warnings + case finalExitCode == 0: + return "\033[32m" // green for clean success + case finalExitCode == types.ExitGenericError.Int(): + return "\033[33m" // yellow for generic error (non-fatal) + default: + return "\033[31m" // red for all other errors + } +} + +func printRunIssueSummary(logger *logging.Logger) { + if logger == nil { + return + } + issues := logger.IssueLines() + if len(issues) == 0 { + return + } + + fmt.Println("===========================================") + fmt.Printf("WARNINGS/ERRORS DURING RUN (warnings=%d errors=%d)\n", logger.WarningCount(), logger.ErrorCount()) + fmt.Println() + for _, line := range issues { + fmt.Println(line) + } + fmt.Println("===========================================") + fmt.Println() +} + +func printFinalSummaryHeader(summarySig, color string) { + colorReset := "\033[0m" + if color != "" { + fmt.Printf("%s===========================================\n", color) + fmt.Printf("ProxSave - Go - %s\n", summarySig) + fmt.Printf("===========================================%s\n", colorReset) + } else { + fmt.Println("===========================================") + fmt.Printf("ProxSave - Go - %s\n", summarySig) + fmt.Println("===========================================") + } +} + +func printFinalSummaryCommands() { + fmt.Println() + fmt.Println("\033[31mEXTRA STEP - IF YOU FIND THIS TOOL USEFUL AND WANT TO THANK ME, A COFFEE IS ALWAYS WELCOME!\033[0m") + fmt.Println("https://github.com/sponsors/tis24dev") + fmt.Println() + fmt.Println("Commands:") + fmt.Println(" proxsave (alias: proxmox-backup) - Start backup") + fmt.Println(" --help - Show all options") + fmt.Println(" --dry-run - Test without changes") + fmt.Println(" --install - Re-run interactive installation/setup") + fmt.Println(" --new-install - Wipe installation directory (keep build/env/identity) then run installer") + fmt.Println(" --env-migration - Run installer and migrate legacy Bash backup.env to Go template") + fmt.Println(" --env-migration-dry-run - Preview installer/migration without writing files") + fmt.Println(" --upgrade - Update proxsave binary to latest release (also adds missing keys to backup.env)") + fmt.Println(" --newkey - Generate a new encryption key for backups") + fmt.Println(" --decrypt - Decrypt an existing backup archive") + fmt.Println(" --restore - Run interactive restore workflow (select bundle, decrypt if needed, apply to system)") + fmt.Println(" --upgrade-config - Upgrade configuration file using the embedded template (run after installing a new binary)") + fmt.Println(" --support - Run in support mode (force debug log level and send email with attached log to github-support@tis24.it); available for standard backup and --restore") + fmt.Println() +} diff --git a/cmd/proxsave/main_identity.go b/cmd/proxsave/main_identity.go new file mode 100644 index 00000000..397f09bd --- /dev/null +++ b/cmd/proxsave/main_identity.go @@ -0,0 +1,74 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "fmt" + "strings" + + "github.com/tis24dev/proxsave/internal/identity" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/notify" +) + +func initializeServerIdentity(rt *appRuntime) { + identityInfo := detectServerIdentity(rt) + rt.serverIDValue = strings.TrimSpace(rt.cfg.ServerID) + rt.serverMACValue = "" + if identityInfo != nil { + applyDetectedIdentity(rt, identityInfo) + } + if rt.serverIDValue != "" && rt.cfg.ServerID == "" { + rt.cfg.ServerID = rt.serverIDValue + } + + logServerIdentityValues(rt.serverIDValue, rt.serverMACValue) + logTelegramServerStatus(rt) + fmt.Println() +} + +func detectServerIdentity(rt *appRuntime) *identity.Info { + info, err := identity.DetectWithContext(rt.ctx, rt.cfg.BaseDir, rt.logger) + if err != nil { + logging.Warning("WARNING: Failed to load server identity: %v", err) + } + return info +} + +func applyDetectedIdentity(rt *appRuntime, info *identity.Info) { + if info.ServerID != "" { + rt.serverIDValue = info.ServerID + } + if info.PrimaryMAC != "" { + rt.serverMACValue = info.PrimaryMAC + } +} + +func logTelegramServerStatus(rt *appRuntime) { + status := "Telegram disabled" + logTelegramInfo := true + if rt.cfg.TelegramEnabled { + status, logTelegramInfo = checkTelegramServerStatus(rt) + } + if logTelegramInfo { + logging.Info("Server Telegram: %s", status) + } +} + +func checkTelegramServerStatus(rt *appRuntime) (string, bool) { + if !strings.EqualFold(rt.cfg.TelegramBotType, "centralized") { + return "Personal mode - no remote contact", true + } + + logging.Debug("Contacting remote Telegram server...") + logging.Debug("Telegram server URL: %s", rt.cfg.TelegramServerAPIHost) + status := notify.CheckTelegramRegistration(rt.ctx, rt.cfg.TelegramServerAPIHost, rt.serverIDValue, rt.logger) + if status.Error != nil { + logging.Warning("Telegram: %s", status.Message) + logging.Debug("Telegram connection error: %v", status.Error) + logging.Skip("Telegram: disabled") + rt.cfg.TelegramEnabled = false + return status.Message, false + } + logging.Debug("Remote server contacted: Bot token / chat ID verified (handshake)") + return status.Message, true +} diff --git a/cmd/proxsave/main_lifecycle.go b/cmd/proxsave/main_lifecycle.go new file mode 100644 index 00000000..54d82fae --- /dev/null +++ b/cmd/proxsave/main_lifecycle.go @@ -0,0 +1,148 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "context" + "fmt" + "os" + "runtime/debug" + + "github.com/tis24dev/proxsave/internal/cli" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/types" + buildinfo "github.com/tis24dev/proxsave/internal/version" +) + +type runBootstrap struct { + bootstrap *logging.BootstrapLogger + toolVersion string + runDone func(error) + state *appRunState +} + +func startMainRun() runBootstrap { + bootstrap := logging.NewBootstrapLogger() + toolVersion := buildinfo.String() + return runBootstrap{ + bootstrap: bootstrap, + toolVersion: toolVersion, + runDone: logging.DebugStartBootstrap(bootstrap, "main run", "version=%s", toolVersion), + state: newAppRunState(), + } +} + +func finishMainRun(run runBootstrap) { + logging.DebugStepBootstrap(run.bootstrap, "main run", "exit_code=%d", run.state.finalExitCode) + run.runDone(nil) + if r := recover(); r != nil { + stack := debug.Stack() + run.bootstrap.Error("PANIC: %v", r) + fmt.Fprintf(os.Stderr, "panic: %v\n%s\n", r, stack) + os.Exit(types.ExitPanicError.Int()) + } +} + +func preparePreRuntimeArgs(ctx context.Context, bootstrap *logging.BootstrapLogger, toolVersion string) (*cli.Args, int, bool) { + args := cli.Parse() + logging.DebugStepBootstrap(bootstrap, "main run", "args parsed") + if exitCode, handled := dispatchFlagOnlyModes(args); handled { + return args, exitCode, true + } + if exitCode, handled := rejectIncompatibleModes(args, bootstrap); handled { + return args, exitCode, true + } + if exitCode, handled := runCleanupGuardsMode(ctx, args, bootstrap); handled { + return args, exitCode, true + } + logging.DebugStepBootstrap(bootstrap, "main run", "support_mode=%v", args.Support) + if exitCode, ok := resolveRunConfigPath(args, bootstrap); !ok { + return args, exitCode, true + } + if exitCode, handled := runUpgradeConfigJSONMode(args); handled { + return args, exitCode, true + } + if exitCode, handled := dispatchPreRuntimeModes(ctx, args, bootstrap, toolVersion); handled { + return args, exitCode, true + } + return args, types.ExitSuccess.Int(), false +} + +func dispatchFlagOnlyModes(args *cli.Args) (int, bool) { + if args.ShowVersion { + cli.ShowVersion() + return types.ExitSuccess.Int(), true + } + if args.ShowHelp { + cli.ShowHelp() + return types.ExitSuccess.Int(), true + } + return types.ExitSuccess.Int(), false +} + +func rejectIncompatibleModes(args *cli.Args, bootstrap *logging.BootstrapLogger) (int, bool) { + messages := validateModeCompatibility(args) + if len(messages) == 0 { + return types.ExitSuccess.Int(), false + } + for _, message := range messages { + bootstrap.Error("%s", message) + } + return types.ExitConfigError.Int(), true +} + +func resolveRunConfigPath(args *cli.Args, bootstrap *logging.BootstrapLogger) (int, bool) { + logging.DebugStepBootstrap(bootstrap, "main run", "resolving config path") + resolvedConfigPath, err := resolveInstallConfigPath(args.ConfigPath) + if err != nil { + bootstrap.Error("ERROR: %v", err) + return types.ExitConfigError.Int(), false + } + args.ConfigPath = resolvedConfigPath + return types.ExitSuccess.Int(), true +} + +func prepareRuntime(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger, state *appRunState, toolVersion string) (*appRuntime, int, bool) { + if exitCode, ok := enforceGoRuntimeVersion(bootstrap); !ok { + return nil, exitCode, false + } + printVersionHeader(bootstrap, toolVersion) + envInfo := detectAndPrintEnvironment(bootstrap) + if exitCode, handled := dispatchPostHeaderConfigModes(ctx, args, bootstrap); handled { + return nil, exitCode, false + } + if exitCode, handled := handleSupportIntro(ctx, args, bootstrap, state); handled { + return nil, exitCode, false + } + return bootstrapRuntime(ctx, args, bootstrap, envInfo, toolVersion) +} + +func enforceGoRuntimeVersion(bootstrap *logging.BootstrapLogger) (int, bool) { + if err := checkGoRuntimeVersion(goRuntimeMinVersion); err != nil { + bootstrap.Error("ERROR: %v", err) + return types.ExitEnvironmentError.Int(), false + } + return types.ExitSuccess.Int(), true +} + +func runRuntime(rt *appRuntime, state *appRunState) int { + defer rt.sessionLogCloser() + for _, deferredAction := range runDeferredActions(rt, state) { + defer deferredAction() + } + state.showSummary = true + + logRunContext(rt) + initializeServerIdentity(rt) + if exitCode, ok := runSecurityPreflight(rt); !ok { + return state.finalize(exitCode) + } + if result := dispatchRestoreMode(rt); result.handled { + return finalizeModeResult(state, result) + } + return finalizeModeResult(state, dispatchBackupMode(rt)) +} + +func finalizeModeResult(state *appRunState, result modeResult) int { + state.applyModeResult(result) + return state.finalize(result.exitCode) +} diff --git a/cmd/proxsave/main_modes.go b/cmd/proxsave/main_modes.go new file mode 100644 index 00000000..c8d96912 --- /dev/null +++ b/cmd/proxsave/main_modes.go @@ -0,0 +1,261 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/tis24dev/proxsave/internal/cli" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/orchestrator" + "github.com/tis24dev/proxsave/internal/types" +) + +type incompatibleMode struct { + enabled bool + label string +} + +type modeCompatibilityRule func(*cli.Args) []string + +type preRuntimeModeHandler func(context.Context, *cli.Args, *logging.BootstrapLogger, string) (int, bool) + +func validateModeCompatibility(args *cli.Args) []string { + if args == nil { + return []string{"command-line arguments are required"} + } + + for _, rule := range []modeCompatibilityRule{ + validateCleanupGuardsCompatibility, + validateSupportCompatibility, + validateInstallCompatibility, + validateUpgradeCompatibility, + } { + if messages := rule(args); len(messages) > 0 { + return messages + } + } + return nil +} + +func validateCleanupGuardsCompatibility(args *cli.Args) []string { + if args.CleanupGuards { + if incompatible := cleanupGuardsIncompatibleModes(args); len(incompatible) > 0 { + return []string{fmt.Sprintf("--cleanup-guards cannot be combined with: %s", strings.Join(incompatible, ", "))} + } + return nil + } + return nil +} + +func validateSupportCompatibility(args *cli.Args) []string { + if args.Support { + if incompatible := supportIncompatibleModes(args); len(incompatible) > 0 { + return []string{ + fmt.Sprintf("Support mode cannot be combined with: %s", strings.Join(incompatible, ", ")), + "--support is only available for the standard backup run or --restore.", + } + } + } + return nil +} + +func validateInstallCompatibility(args *cli.Args) []string { + if args.Install && args.NewInstall { + return []string{"Cannot use --install and --new-install together. Choose one installation mode."} + } + return nil +} + +func validateUpgradeCompatibility(args *cli.Args) []string { + if args.Upgrade && (args.Install || args.NewInstall) { + return []string{"Cannot use --upgrade together with --install or --new-install."} + } + return nil +} + +func cleanupGuardsIncompatibleModes(args *cli.Args) []string { + return enabledModes([]incompatibleMode{ + {enabled: args.Support, label: "--support"}, + {enabled: args.Restore, label: "--restore"}, + {enabled: args.Decrypt, label: "--decrypt"}, + {enabled: args.Install, label: "--install"}, + {enabled: args.NewInstall, label: "--new-install"}, + {enabled: args.Upgrade, label: "--upgrade"}, + {enabled: args.ForceNewKey, label: "--newkey"}, + {enabled: args.EnvMigration || args.EnvMigrationDry, label: "--env-migration/--env-migration-dry-run"}, + {enabled: args.UpgradeConfig || args.UpgradeConfigDry || args.UpgradeConfigJSON, label: "--upgrade-config/--upgrade-config-dry-run/--upgrade-config-json"}, + }) +} + +func supportIncompatibleModes(args *cli.Args) []string { + return enabledModes([]incompatibleMode{ + {enabled: args.Decrypt, label: "--decrypt"}, + {enabled: args.Install, label: "--install"}, + {enabled: args.NewInstall, label: "--new-install"}, + {enabled: args.EnvMigration || args.EnvMigrationDry, label: "--env-migration"}, + {enabled: args.UpgradeConfig || args.UpgradeConfigDry || args.UpgradeConfigJSON, label: "--upgrade-config"}, + {enabled: args.ForceNewKey, label: "--newkey"}, + }) +} + +func enabledModes(modes []incompatibleMode) []string { + incompatible := make([]string, 0, len(modes)) + for _, mode := range modes { + if mode.enabled { + incompatible = append(incompatible, mode.label) + } + } + return incompatible +} + +func dispatchPreRuntimeModes(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger, toolVersion string) (int, bool) { + for _, handler := range []preRuntimeModeHandler{ + runUpgradeMode, + runNewKeyMode, + runDecryptOnlyMode, + runNewInstallMode, + runUpgradeConfigDryMode, + runInstallMode, + } { + if exitCode, handled := handler(ctx, args, bootstrap, toolVersion); handled { + return exitCode, true + } + } + return types.ExitSuccess.Int(), false +} + +func runCleanupGuardsMode(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger) (int, bool) { + if !args.CleanupGuards { + return types.ExitSuccess.Int(), false + } + + level := types.LogLevelInfo + if args.LogLevel != types.LogLevelNone { + level = args.LogLevel + } + logger := logging.New(level, false) + + if err := orchestrator.CleanupMountGuards(ctx, logger, args.DryRun); err != nil { + bootstrap.Error("ERROR: %v", err) + return types.ExitGenericError.Int(), true + } + return types.ExitSuccess.Int(), true +} + +func runUpgradeMode(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger, _ string) (int, bool) { + if !args.Upgrade { + return types.ExitSuccess.Int(), false + } + logging.DebugStepBootstrap(bootstrap, "main run", "mode=upgrade") + return runUpgrade(ctx, args, bootstrap), true +} + +func runNewKeyMode(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger, _ string) (int, bool) { + if !args.ForceNewKey { + return types.ExitSuccess.Int(), false + } + newKeyCLI := args.ForceCLI + logging.DebugStepBootstrap(bootstrap, "main run", "mode=newkey cli=%v", newKeyCLI) + if err := runNewKey(ctx, args.ConfigPath, cliFlowLogLevel(args), bootstrap, newKeyCLI); err != nil { + if isInstallAbortedError(err) || errors.Is(err, orchestrator.ErrAgeRecipientSetupAborted) { + return types.ExitSuccess.Int(), true + } + bootstrap.Error("ERROR: %v", err) + return types.ExitConfigError.Int(), true + } + return types.ExitSuccess.Int(), true +} + +func runDecryptOnlyMode(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger, toolVersion string) (int, bool) { + if !args.Decrypt { + return types.ExitSuccess.Int(), false + } + decryptCLI := args.ForceCLI + logging.DebugStepBootstrap(bootstrap, "main run", "mode=decrypt cli=%v", decryptCLI) + if err := runDecryptWorkflowOnly(ctx, args.ConfigPath, bootstrap, toolVersion, decryptCLI); err != nil { + if errors.Is(err, orchestrator.ErrDecryptAborted) { + bootstrap.Info("Decrypt workflow aborted by user") + return types.ExitSuccess.Int(), true + } + bootstrap.Error("ERROR: %v", err) + return types.ExitGenericError.Int(), true + } + bootstrap.Info("Decrypt workflow completed successfully") + return types.ExitSuccess.Int(), true +} + +func runNewInstallMode(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger, _ string) (int, bool) { + if !args.NewInstall { + return types.ExitSuccess.Int(), false + } + newInstallCLI := args.ForceCLI + logging.DebugStepBootstrap(bootstrap, "main run", "mode=new-install cli=%v", newInstallCLI) + sessionLogger, cleanupSessionLog := startFlowSessionLog("new-install", cliFlowLogLevel(args), bootstrap) + defer cleanupSessionLog() + if sessionLogger != nil { + sessionLogger.Info("Starting --new-install (config=%s)", args.ConfigPath) + } + if err := runNewInstall(ctx, args.ConfigPath, bootstrap, newInstallCLI); err != nil { + logInstallModeError(sessionLogger, "new-install", err) + if isInstallAbortedError(err) { + return types.ExitSuccess.Int(), true + } + bootstrap.Error("ERROR: %v", err) + return types.ExitConfigError.Int(), true + } + if sessionLogger != nil { + sessionLogger.Info("new-install completed successfully") + } + return types.ExitSuccess.Int(), true +} + +func runInstallMode(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger, _ string) (int, bool) { + if !args.Install { + return types.ExitSuccess.Int(), false + } + logging.DebugStepBootstrap(bootstrap, "main run", "mode=install cli=%v", args.ForceCLI) + sessionLogger, cleanupSessionLog := startFlowSessionLog("install", cliFlowLogLevel(args), bootstrap) + defer cleanupSessionLog() + if sessionLogger != nil { + sessionLogger.Info("Starting --install (config=%s)", args.ConfigPath) + } + + err := runInstallTUI(ctx, args.ConfigPath, bootstrap) + if args.ForceCLI { + err = runInstall(ctx, args.ConfigPath, bootstrap) + } + + if err != nil { + logInstallModeError(sessionLogger, "install", err) + if isInstallAbortedError(err) { + return types.ExitSuccess.Int(), true + } + bootstrap.Error("ERROR: %v", err) + return types.ExitConfigError.Int(), true + } + if sessionLogger != nil { + sessionLogger.Info("install completed successfully") + } + return types.ExitSuccess.Int(), true +} + +func cliFlowLogLevel(args *cli.Args) types.LogLevel { + if args.LogLevel != types.LogLevelNone { + return args.LogLevel + } + return types.LogLevelInfo +} + +func logInstallModeError(sessionLogger *logging.Logger, flowName string, err error) { + if sessionLogger == nil { + return + } + if isInstallAbortedError(err) { + sessionLogger.Warning("%s aborted by user: %v", flowName, err) + return + } + sessionLogger.Error("%s failed: %v", flowName, err) +} diff --git a/cmd/proxsave/main_modes_test.go b/cmd/proxsave/main_modes_test.go new file mode 100644 index 00000000..3b9d5b59 --- /dev/null +++ b/cmd/proxsave/main_modes_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "reflect" + "testing" + + "github.com/tis24dev/proxsave/internal/cli" +) + +func TestValidateModeCompatibility(t *testing.T) { + tests := []struct { + name string + args *cli.Args + want []string + }{ + { + name: "backup default allowed", + args: &cli.Args{}, + }, + { + name: "support restore allowed", + args: &cli.Args{Support: true, Restore: true}, + }, + { + name: "cleanup guards rejects support and restore first", + args: &cli.Args{CleanupGuards: true, Support: true, Restore: true}, + want: []string{"--cleanup-guards cannot be combined with: --support, --restore"}, + }, + { + name: "support rejects decrypt", + args: &cli.Args{Support: true, Decrypt: true}, + want: []string{ + "Support mode cannot be combined with: --decrypt", + "--support is only available for the standard backup run or --restore.", + }, + }, + { + name: "support rejects config utility modes", + args: &cli.Args{Support: true, UpgradeConfigDry: true}, + want: []string{ + "Support mode cannot be combined with: --upgrade-config", + "--support is only available for the standard backup run or --restore.", + }, + }, + { + name: "install new install conflict", + args: &cli.Args{Install: true, NewInstall: true}, + want: []string{"Cannot use --install and --new-install together. Choose one installation mode."}, + }, + { + name: "upgrade install conflict", + args: &cli.Args{Upgrade: true, Install: true}, + want: []string{"Cannot use --upgrade together with --install or --new-install."}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := validateModeCompatibility(tt.args) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("validateModeCompatibility() = %#v, want %#v", got, tt.want) + } + }) + } +} diff --git a/cmd/proxsave/main_network.go b/cmd/proxsave/main_network.go new file mode 100644 index 00000000..c0f5af30 --- /dev/null +++ b/cmd/proxsave/main_network.go @@ -0,0 +1,122 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "strings" + + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/logging" +) + +type networkFeatureDisablement struct { + enabled func(*config.Config) bool + apply func(*config.Config, networkWarningFunc) +} + +type networkWarningFunc func(string, ...interface{}) + +// featuresNeedNetwork returns whether current configuration requires outbound network, and human reasons. +func featuresNeedNetwork(cfg *config.Config) (bool, []string) { + reasons := []string{} + // Telegram (any mode uses network) + if cfg.TelegramEnabled { + if strings.EqualFold(cfg.TelegramBotType, "centralized") { + reasons = append(reasons, "Telegram centralized registration") + } else { + reasons = append(reasons, "Telegram personal notifications") + } + } + // Email via relay + if cfg.EmailEnabled && strings.EqualFold(cfg.EmailDeliveryMethod, "relay") { + reasons = append(reasons, "Email relay delivery") + } + // Gotify + if cfg.GotifyEnabled { + reasons = append(reasons, "Gotify notifications") + } + // Webhooks + if cfg.WebhookEnabled { + reasons = append(reasons, "Webhooks") + } + // Cloud uploads via rclone + if cfg.CloudEnabled { + reasons = append(reasons, "Cloud storage (rclone)") + } + return len(reasons) > 0, reasons +} + +// disableNetworkFeaturesForRun disables all network-dependent features when connectivity is unavailable. +func disableNetworkFeaturesForRun(cfg *config.Config, bootstrap *logging.BootstrapLogger) { + if cfg == nil { + return + } + warn := networkWarning(bootstrap) + for _, disablement := range networkFeatureDisablements() { + if disablement.enabled(cfg) { + disablement.apply(cfg, warn) + } + } +} + +func networkWarning(bootstrap *logging.BootstrapLogger) networkWarningFunc { + return func(format string, args ...interface{}) { + if bootstrap != nil { + bootstrap.Warning(format, args...) + return + } + logging.Warning(format, args...) + } +} + +func networkFeatureDisablements() []networkFeatureDisablement { + return []networkFeatureDisablement{ + {enabled: cloudNetworkEnabled, apply: disableCloudNetworkFeature}, + {enabled: telegramNetworkEnabled, apply: disableTelegramNetworkFeature}, + {enabled: emailRelayNetworkEnabled, apply: disableEmailRelayNetworkFeature}, + {enabled: gotifyNetworkEnabled, apply: disableGotifyNetworkFeature}, + {enabled: webhookNetworkEnabled, apply: disableWebhookNetworkFeature}, + } +} + +func cloudNetworkEnabled(cfg *config.Config) bool { return cfg.CloudEnabled } + +func telegramNetworkEnabled(cfg *config.Config) bool { return cfg.TelegramEnabled } + +func emailRelayNetworkEnabled(cfg *config.Config) bool { + return cfg.EmailEnabled && strings.EqualFold(cfg.EmailDeliveryMethod, "relay") +} + +func gotifyNetworkEnabled(cfg *config.Config) bool { return cfg.GotifyEnabled } + +func webhookNetworkEnabled(cfg *config.Config) bool { return cfg.WebhookEnabled } + +func disableCloudNetworkFeature(cfg *config.Config, warn networkWarningFunc) { + warn("WARNING: Disabling cloud storage (rclone) due to missing network connectivity") + cfg.CloudEnabled = false + cfg.CloudLogPath = "" +} + +func disableTelegramNetworkFeature(cfg *config.Config, warn networkWarningFunc) { + warn("WARNING: Disabling Telegram notifications due to missing network connectivity") + cfg.TelegramEnabled = false +} + +func disableEmailRelayNetworkFeature(cfg *config.Config, warn networkWarningFunc) { + if cfg.EmailFallbackSendmail { + warn("WARNING: Network unavailable; switching Email delivery to sendmail for this run") + cfg.EmailDeliveryMethod = "sendmail" + return + } + warn("WARNING: Disabling Email relay notifications due to missing network connectivity") + cfg.EmailEnabled = false +} + +func disableGotifyNetworkFeature(cfg *config.Config, warn networkWarningFunc) { + warn("WARNING: Disabling Gotify notifications due to missing network connectivity") + cfg.GotifyEnabled = false +} + +func disableWebhookNetworkFeature(cfg *config.Config, warn networkWarningFunc) { + warn("WARNING: Disabling Webhook notifications due to missing network connectivity") + cfg.WebhookEnabled = false +} diff --git a/cmd/proxsave/main_restore_decrypt.go b/cmd/proxsave/main_restore_decrypt.go new file mode 100644 index 00000000..d182acdc --- /dev/null +++ b/cmd/proxsave/main_restore_decrypt.go @@ -0,0 +1,112 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "errors" + "strings" + "time" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/orchestrator" + "github.com/tis24dev/proxsave/internal/support" + "github.com/tis24dev/proxsave/internal/types" +) + +func dispatchRestoreMode(rt *appRuntime) modeResult { + if !rt.args.Restore { + return modeResult{exitCode: types.ExitSuccess.Int()} + } + + restoreCLI := rt.args.ForceCLI + logging.DebugStep(rt.logger, "main", "mode=restore cli=%v", restoreCLI) + if restoreCLI { + return runRestoreCLI(rt) + } + return runRestoreTUI(rt) +} + +func runRestoreCLI(rt *appRuntime) modeResult { + logging.Info("Restore mode enabled - starting CLI workflow...") + err := orchestrator.RunRestoreWorkflow(rt.ctx, rt.cfg, rt.logger, rt.toolVersion) + if err != nil { + return finishFailedRestore(rt, err, false) + } + return finishSuccessfulRestore(rt) +} + +func runRestoreTUI(rt *appRuntime) modeResult { + logging.Info("Restore mode enabled - starting interactive workflow...") + sig := buildSignature() + if strings.TrimSpace(sig) == "" { + sig = "n/a" + } + err := orchestrator.RunRestoreWorkflowTUI(rt.ctx, rt.cfg, rt.logger, rt.toolVersion, rt.args.ConfigPath, sig) + if err != nil { + return finishFailedRestore(rt, err, true) + } + return finishSuccessfulRestore(rt) +} + +func finishFailedRestore(rt *appRuntime, err error, includeDecryptAbort bool) modeResult { + if isRestoreAbort(err, includeDecryptAbort) { + logging.Warning("Restore workflow aborted by user") + return restoreModeResult(rt, exitCodeInterrupted) + } + logging.Error("Restore workflow failed: %v", err) + return restoreModeResult(rt, types.ExitGenericError.Int()) +} + +func isRestoreAbort(err error, includeDecryptAbort bool) bool { + if errors.Is(err, orchestrator.ErrRestoreAborted) { + return true + } + return includeDecryptAbort && errors.Is(err, orchestrator.ErrDecryptAborted) +} + +func finishSuccessfulRestore(rt *appRuntime) modeResult { + if rt.logger.HasWarnings() { + logging.Warning("Restore workflow completed with warnings (see log above)") + } else { + logging.Info("Restore workflow completed successfully") + } + return restoreModeResult(rt, types.ExitSuccess.Int()) +} + +func restoreModeResult(rt *appRuntime, exitCode int) modeResult { + return modeResult{ + exitCode: exitCode, + handled: true, + supportStats: restoreSupportStats(rt, exitCode), + } +} + +func restoreSupportStats(rt *appRuntime, exitCode int) *orchestrator.BackupStats { + if !rt.args.Support { + return nil + } + return support.BuildSupportStats(rt.logger, resolveHostname(), rt.envInfo.Type, rt.envInfo.Version, rt.toolVersion, rt.startTime, time.Now(), exitCode, "restore") +} + +func dispatchBackupMode(rt *appRuntime) modeResult { + result := runBackupMode(backupModeOptions{ + ctx: rt.ctx, + cfg: rt.cfg, + logger: rt.logger, + envInfo: rt.envInfo, + unprivilegedInfo: rt.unprivilegedInfo, + updateInfo: rt.updateInfo, + toolVersion: rt.toolVersion, + dryRun: rt.dryRun, + startTime: rt.startTime, + heapProfilePath: rt.heapProfilePath, + serverIDValue: rt.serverIDValue, + serverMACValue: rt.serverMACValue, + }) + return modeResult{ + orch: result.orch, + earlyErrorState: result.earlyErrorState, + supportStats: result.supportStats, + exitCode: result.exitCode, + handled: true, + } +} diff --git a/cmd/proxsave/main_runtime.go b/cmd/proxsave/main_runtime.go new file mode 100644 index 00000000..a30818a4 --- /dev/null +++ b/cmd/proxsave/main_runtime.go @@ -0,0 +1,304 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "runtime/pprof" + "strconv" + "strings" + + "github.com/tis24dev/proxsave/internal/cli" + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/environment" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/types" +) + +func printVersionHeader(bootstrap *logging.BootstrapLogger, toolVersion string) { + bootstrap.Println("===========================================") + bootstrap.Println(" ProxSave - Go Version") + bootstrap.Printf(" Version: %s", toolVersion) + if sig := buildSignature(); sig != "" { + bootstrap.Printf(" Build Signature: %s", sig) + } + bootstrap.Println("===========================================") + bootstrap.Println("") +} + +func detectAndPrintEnvironment(bootstrap *logging.BootstrapLogger) *environment.EnvironmentInfo { + bootstrap.Println("Detecting Proxmox environment...") + envInfo, err := environment.Detect() + if err != nil { + bootstrap.Warning("WARNING: %v", err) + bootstrap.Println("Continuing with limited functionality...") + } + bootstrap.Printf("✓ Proxmox Type: %s", envInfo.Type) + if envInfo.Type == types.ProxmoxDual { + bootstrap.Printf(" PVE Version: %s", envInfo.PVEVersion) + bootstrap.Printf(" PBS Version: %s", envInfo.PBSVersion) + } else { + bootstrap.Printf(" Version: %s", envInfo.Version) + } + bootstrap.Println("") + return envInfo +} + +func bootstrapRuntime(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger, envInfo *environment.EnvironmentInfo, toolVersion string) (*appRuntime, int, bool) { + rt := &appRuntime{ + ctx: ctx, + args: args, + bootstrap: bootstrap, + deps: defaultAppDeps(), + envInfo: envInfo, + toolVersion: toolVersion, + sessionLogCloser: func() {}, + } + + cfg, initialEnvBaseDir, autoFound, exitCode, ok := loadRunConfig(args, bootstrap) + if !ok { + return nil, exitCode, false + } + rt.cfg = cfg + rt.initialEnvBaseDir = initialEnvBaseDir + rt.autoBaseDirFound = autoFound + rt.dryRun = args.DryRun || cfg.DryRun + + if exitCode, ok := validateRunConfig(rt); !ok { + return nil, exitCode, false + } + + rt.logLevel = resolveRunLogLevel(args, cfg, bootstrap) + rt.logger = initializeRunLogger(rt) + initializeRunLogFile(rt) + bootstrap.Flush(rt.logger) + rt.updateInfo = checkForUpdates(ctx, rt.logger, toolVersion) + applyRunPermissions(rt) + initializeRunProfiling(rt) + return rt, types.ExitSuccess.Int(), true +} + +func loadRunConfig(args *cli.Args, bootstrap *logging.BootstrapLogger) (*config.Config, string, bool, int, bool) { + autoBaseDir, autoFound := detectBaseDir() + if autoBaseDir == "" { + if _, err := os.Stat("/opt/proxsave"); err == nil { + autoBaseDir = "/opt/proxsave" + } else { + autoBaseDir = "/opt/proxmox-backup" + } + } + + initialEnvBaseDir := os.Getenv("BASE_DIR") + if initialEnvBaseDir == "" { + _ = os.Setenv("BASE_DIR", autoBaseDir) + } + + if err := ensureConfigExists(args.ConfigPath, bootstrap); err != nil { + bootstrap.Error("ERROR: %v", err) + return nil, "", false, types.ExitConfigError.Int(), false + } + + bootstrap.Printf("Loading configuration from: %s", args.ConfigPath) + logging.DebugStepBootstrap(bootstrap, "main run", "loading configuration") + cfg, err := config.LoadConfig(args.ConfigPath) + if err != nil { + bootstrap.Error("ERROR: Failed to load configuration: %v", err) + return nil, "", false, types.ExitConfigError.Int(), false + } + if cfg.BaseDir == "" { + cfg.BaseDir = autoBaseDir + } + _ = os.Setenv("BASE_DIR", cfg.BaseDir) + bootstrap.Println("✓ Configuration loaded successfully") + return cfg, initialEnvBaseDir, autoFound, types.ExitSuccess.Int(), true +} + +func validateRunConfig(rt *appRuntime) (int, bool) { + printDryRunBootstrapStatus(rt) + if err := validateFutureFeatures(rt.cfg); err != nil { + rt.bootstrap.Error("ERROR: Invalid configuration: %v", err) + return types.ExitConfigError.Int(), false + } + warnLogPathConfiguration(rt.cfg, rt.bootstrap) + runNetworkPreflight(rt.cfg, rt.bootstrap) + return types.ExitSuccess.Int(), true +} + +func printDryRunBootstrapStatus(rt *appRuntime) { + if rt.dryRun { + if rt.args.DryRun { + rt.bootstrap.Println("⚠ DRY RUN MODE (enabled via --dry-run flag)") + } else { + rt.bootstrap.Println("⚠ DRY RUN MODE (enabled via DRY_RUN config)") + } + } + rt.bootstrap.Println("") +} + +func warnLogPathConfiguration(cfg *config.Config, bootstrap *logging.BootstrapLogger) { + if strings.TrimSpace(cfg.LogPath) == "" { + bootstrap.Warning("WARNING: LOG_PATH is empty - file logging disabled, using stdout only") + } + if cfg.SecondaryEnabled && strings.TrimSpace(cfg.SecondaryLogPath) == "" { + bootstrap.Warning("WARNING: Secondary storage enabled but SECONDARY_LOG_PATH is empty - secondary log copy and cleanup will be disabled for this run") + } + if cfg.CloudEnabled && strings.TrimSpace(cfg.CloudLogPath) == "" { + bootstrap.Warning("WARNING: Cloud storage enabled but CLOUD_LOG_PATH is empty - cloud log copy and cleanup will be disabled for this run") + } +} + +func runNetworkPreflight(cfg *config.Config, bootstrap *logging.BootstrapLogger) { + needs, reasons := featuresNeedNetwork(cfg) + if !needs { + return + } + if cfg.DisableNetworkPreflight { + bootstrap.Warning("WARNING: Network preflight disabled via DISABLE_NETWORK_PREFLIGHT; features: %s", strings.Join(reasons, ", ")) + return + } + if err := checkInternetConnectivity(networkPreflightTimeout); err != nil { + bootstrap.Warning("WARNING: Network connectivity unavailable for: %s. %v", strings.Join(reasons, ", "), err) + bootstrap.Warning("WARNING: Disabling network-dependent features for this run") + disableNetworkFeaturesForRun(cfg, bootstrap) + } +} + +func resolveRunLogLevel(args *cli.Args, cfg *config.Config, bootstrap *logging.BootstrapLogger) types.LogLevel { + logLevel := cfg.DebugLevel + if args.Support { + bootstrap.Println("Support mode enabled: forcing log level to DEBUG") + logLevel = types.LogLevelDebug + } else if args.LogLevel != types.LogLevelNone { + logLevel = args.LogLevel + } + logging.DebugStepBootstrap(bootstrap, "main run", "log_level=%s", logLevel.String()) + return logLevel +} + +func initializeRunLogger(rt *appRuntime) *logging.Logger { + logger := logging.New(rt.logLevel, rt.cfg.UseColor) + if rt.args.Restore { + logger = initializeRestoreSessionLogger(rt, logger) + } + logging.SetDefaultLogger(logger) + rt.bootstrap.SetLevel(rt.logLevel) + return logger +} + +func initializeRestoreSessionLogger(rt *appRuntime, fallback *logging.Logger) *logging.Logger { + logging.DebugStepBootstrap(rt.bootstrap, "main run", "restore log enabled") + restoreLogger, restoreLogPath, closeFn, err := logging.StartSessionLogger("restore", rt.logLevel, rt.cfg.UseColor) + if err != nil { + rt.bootstrap.Warning("WARNING: Unable to start restore log: %v", err) + return fallback + } + rt.sessionLogCloser = closeFn + rt.bootstrap.Info("Restore log: %s", restoreLogPath) + _ = os.Setenv("LOG_FILE", restoreLogPath) + return restoreLogger +} + +func initializeRunLogFile(rt *appRuntime) { + rt.hostname = resolveHostname() + rt.startTime = rt.deps.now() + rt.timestampStr = rt.startTime.Format("20060102-150405") + if rt.args.Restore { + return + } + + logFileName := fmt.Sprintf("backup-%s-%s.log", rt.hostname, rt.timestampStr) + logFilePath := filepath.Join(rt.cfg.LogPath, logFileName) + if err := os.MkdirAll(rt.cfg.LogPath, defaultDirPerm); err != nil { + logging.Warning("Failed to create log directory %s: %v", rt.cfg.LogPath, err) + return + } + if err := rt.logger.OpenLogFile(logFilePath); err != nil { + logging.Warning("Failed to open log file %s: %v", logFilePath, err) + return + } + logging.Info("Log file opened: %s", logFilePath) + _ = os.Setenv("LOG_FILE", logFilePath) +} + +func applyRunPermissions(rt *appRuntime) { + if !rt.cfg.SetBackupPermissions { + return + } + logging.DebugStep(rt.logger, "main", "applying backup permissions") + if err := applyBackupPermissions(rt.cfg, rt.logger); err != nil { + logging.Warning("Failed to apply backup permissions: %v", err) + } +} + +func initializeRunProfiling(rt *appRuntime) { + if !rt.cfg.ProfilingEnabled { + return + } + cpuProfilePath := filepath.Join(rt.cfg.LogPath, fmt.Sprintf("cpu-%s-%s.pprof", rt.hostname, rt.timestampStr)) + f, err := os.Create(cpuProfilePath) + if err != nil { + logging.Warning("Failed to create CPU profile file: %v", err) + return + } + if err := pprof.StartCPUProfile(f); err != nil { + logging.Warning("Failed to start CPU profiling: %v", err) + _ = f.Close() + return + } + rt.cpuProfileFile = f + logging.Info("CPU profiling enabled: %s", cpuProfilePath) + rt.heapProfilePath = buildHeapProfilePath(rt) +} + +func buildHeapProfilePath(rt *appRuntime) string { + tmpProfileDir := filepath.Join("/tmp", "proxsave") + if err := os.MkdirAll(tmpProfileDir, defaultDirPerm); err != nil { + logging.Warning("Failed to create temp profile directory %s: %v", tmpProfileDir, err) + return "" + } + return filepath.Join(tmpProfileDir, fmt.Sprintf("heap-%s-%s.pprof", rt.hostname, rt.timestampStr)) +} + +// 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" + // 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 + v = strings.TrimPrefix(v, "go") + parts := strings.Split(v, ".") + toInt := func(s string) int { n, _ := strconv.Atoi(s); return n } + major, minor, patch := 0, 0, 0 + if len(parts) > 0 { + major = toInt(parts[0]) + } + if len(parts) > 1 { + minor = toInt(parts[1]) + } + if len(parts) > 2 { + patch = toInt(parts[2]) + } + return major, minor, patch + } + + rtMaj, rtMin, rtPatch := parse(rt) + minMaj, minMin, minPatch := parse(minimum) + + newer := func(aMaj, aMin, aPatch, bMaj, bMin, bPatch int) bool { + if aMaj != bMaj { + return aMaj > bMaj + } + if aMin != bMin { + return aMin > bMin + } + return aPatch >= bPatch + } + + if !newer(rtMaj, rtMin, rtPatch, minMaj, minMin, minPatch) { + return fmt.Errorf("go runtime version %s is below required %s — rebuild with go %s or set GOTOOLCHAIN=auto", rt, "go"+minimum, "go"+minimum) + } + return nil +} diff --git a/cmd/proxsave/main_runtime_log.go b/cmd/proxsave/main_runtime_log.go new file mode 100644 index 00000000..df4513f1 --- /dev/null +++ b/cmd/proxsave/main_runtime_log.go @@ -0,0 +1,53 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "strings" + + "github.com/tis24dev/proxsave/internal/environment" + "github.com/tis24dev/proxsave/internal/logging" +) + +func logRunContext(rt *appRuntime) { + logRunDryRunStatus(rt) + baseDirSource := runBaseDirSource(rt) + logging.Info("Environment: %s %s", rt.envInfo.Type, rt.envInfo.Version) + rt.unprivilegedInfo = environment.DetectUnprivilegedContainer() + logUserNamespaceContext(rt.logger, rt.unprivilegedInfo) + logging.Info("Backup enabled: %v", rt.cfg.BackupEnabled) + logging.Info("Debug level: %s", rt.logLevel.String()) + logging.Info("Compression: %s (level %d, mode %s)", rt.cfg.CompressionType, rt.cfg.CompressionLevel, rt.cfg.CompressionMode) + logging.Info("Base directory: %s (%s)", rt.cfg.BaseDir, baseDirSource) + logging.Info("Configuration file: %s (%s)", rt.args.ConfigPath, runConfigPathSource(rt)) +} + +func logRunDryRunStatus(rt *appRuntime) { + if !rt.dryRun { + return + } + if rt.args.DryRun { + logging.Info("DRY RUN MODE: No actual changes will be made (enabled via --dry-run flag)") + return + } + logging.Info("DRY RUN MODE: No actual changes will be made (enabled via DRY_RUN config)") +} + +func runBaseDirSource(rt *appRuntime) string { + if rawBaseDir, ok := rt.cfg.Get("BASE_DIR"); ok && strings.TrimSpace(rawBaseDir) != "" { + return "configured in backup.env" + } + if rt.initialEnvBaseDir != "" { + return "from environment (BASE_DIR)" + } + if rt.autoBaseDirFound { + return "auto-detected from executable path" + } + return "default fallback" +} + +func runConfigPathSource(rt *appRuntime) string { + if rt.args.ConfigPathSource != "" { + return rt.args.ConfigPathSource + } + return "configured path" +} diff --git a/cmd/proxsave/main_security.go b/cmd/proxsave/main_security.go new file mode 100644 index 00000000..137a4544 --- /dev/null +++ b/cmd/proxsave/main_security.go @@ -0,0 +1,22 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "fmt" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/security" + "github.com/tis24dev/proxsave/internal/types" +) + +func runSecurityPreflight(rt *appRuntime) (int, bool) { + execInfo := getExecInfo() + execPath := execInfo.ExecPath + logging.DebugStep(rt.logger, "main", "running security checks") + if _, secErr := security.Run(rt.ctx, rt.logger, rt.cfg, rt.args.ConfigPath, execPath, rt.envInfo); secErr != nil { + logging.Error("Security checks failed: %v", secErr) + return types.ExitSecurityError.Int(), false + } + fmt.Println() + return types.ExitSuccess.Int(), true +} diff --git a/cmd/proxsave/main_signals.go b/cmd/proxsave/main_signals.go new file mode 100644 index 00000000..d01d1532 --- /dev/null +++ b/cmd/proxsave/main_signals.go @@ -0,0 +1,36 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "context" + "os" + "os/signal" + "sync" + "syscall" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/tui" +) + +var closeStdinOnce sync.Once + +func setupRunContext(bootstrap *logging.BootstrapLogger) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(context.Background()) + tui.SetAbortContext(ctx) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-sigChan + logging.DebugStepBootstrap(bootstrap, "signal", "received=%v", sig) + bootstrap.Info("\nReceived signal %v, initiating graceful shutdown...", sig) + cancel() + closeStdinOnce.Do(func() { + if file := os.Stdin; file != nil { + _ = file.Close() + } + }) + }() + + return ctx, cancel +} diff --git a/cmd/proxsave/main_state.go b/cmd/proxsave/main_state.go new file mode 100644 index 00000000..b577e6e8 --- /dev/null +++ b/cmd/proxsave/main_state.go @@ -0,0 +1,85 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "context" + "os" + "time" + + "github.com/tis24dev/proxsave/internal/cli" + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/environment" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/orchestrator" + "github.com/tis24dev/proxsave/internal/types" +) + +type appRuntime struct { + ctx context.Context + args *cli.Args + bootstrap *logging.BootstrapLogger + deps appDeps + cfg *config.Config + logger *logging.Logger + envInfo *environment.EnvironmentInfo + unprivilegedInfo environment.UnprivilegedContainerInfo + updateInfo *UpdateInfo + toolVersion string + hostname string + startTime time.Time + timestampStr string + dryRun bool + logLevel types.LogLevel + initialEnvBaseDir string + autoBaseDirFound bool + sessionLogCloser func() + heapProfilePath string + cpuProfileFile *os.File + serverIDValue string + serverMACValue string +} + +type appRunState struct { + finalExitCode int + showSummary bool + orch *orchestrator.Orchestrator + earlyErrorState *orchestrator.EarlyErrorState + pendingSupportStat *orchestrator.BackupStats +} + +type modeResult struct { + orch *orchestrator.Orchestrator + earlyErrorState *orchestrator.EarlyErrorState + supportStats *orchestrator.BackupStats + exitCode int + handled bool +} + +type appDeps struct { + now func() time.Time +} + +func defaultAppDeps() appDeps { + return appDeps{now: time.Now} +} + +func newAppRunState() *appRunState { + return &appRunState{finalExitCode: types.ExitSuccess.Int()} +} + +func (state *appRunState) finalize(code int) int { + state.finalExitCode = code + return code +} + +func (state *appRunState) setBackupResult(result backupModeResult) { + state.orch = result.orch + state.earlyErrorState = result.earlyErrorState + state.pendingSupportStat = result.supportStats +} + +func (state *appRunState) applyModeResult(result modeResult) { + state.orch = result.orch + state.earlyErrorState = result.earlyErrorState + state.pendingSupportStat = result.supportStats +} diff --git a/cmd/proxsave/main_support.go b/cmd/proxsave/main_support.go new file mode 100644 index 00000000..93b9acf3 --- /dev/null +++ b/cmd/proxsave/main_support.go @@ -0,0 +1,34 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "context" + + "github.com/tis24dev/proxsave/internal/cli" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/support" + "github.com/tis24dev/proxsave/internal/types" +) + +func handleSupportIntro(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger, state *appRunState) (int, bool) { + if !args.Support { + return types.ExitSuccess.Int(), false + } + + logging.DebugStepBootstrap(bootstrap, "main run", "mode=support") + meta, continueRun, interrupted := support.RunIntro(ctx, bootstrap) + if continueRun { + args.SupportGitHubUser = meta.GitHubUser + args.SupportIssueID = meta.IssueID + return types.ExitSuccess.Int(), false + } + + if interrupted { + state.finalize(exitCodeInterrupted) + printFinalSummary(state.finalExitCode) + return state.finalExitCode, true + } + state.finalize(types.ExitGenericError.Int()) + printFinalSummary(state.finalExitCode) + return state.finalExitCode, true +} diff --git a/cmd/proxsave/main_update.go b/cmd/proxsave/main_update.go new file mode 100644 index 00000000..759c6105 --- /dev/null +++ b/cmd/proxsave/main_update.go @@ -0,0 +1,122 @@ +// Package main contains the proxsave command entrypoint. +package main + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/tis24dev/proxsave/internal/logging" +) + +// UpdateInfo holds information about the version check result. +type UpdateInfo struct { + NewVersion bool + Current string + Latest string +} + +// checkForUpdates performs a best-effort check against the latest GitHub release. +// - If the latest version cannot be determined or the current version is already up to date, +// only a DEBUG log entry is written (no user-facing output). +// - If a newer version is available, a WARNING is logged suggesting the --upgrade command. +// Additionally, a populated *UpdateInfo is returned so that callers can propagate +// structured information into notifications/metrics. +func checkForUpdates(ctx context.Context, logger *logging.Logger, currentVersion string) *UpdateInfo { + if logger == nil { + return nil + } + + currentVersion = strings.TrimSpace(currentVersion) + if currentVersion == "" { + logger.Debug("Update check skipped: current version is empty") + return nil + } + + checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + logger.Debug("Checking for ProxSave updates (current: %s)", currentVersion) + + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo) + logger.Debug("Fetching latest release from GitHub: %s", apiURL) + + _, latestVersion, err := fetchLatestRelease(checkCtx) + if err != nil { + logger.Debug("Update check skipped: GitHub unreachable: %v", err) + return &UpdateInfo{ + NewVersion: false, + Current: currentVersion, + } + } + + latestVersion = strings.TrimSpace(latestVersion) + if latestVersion == "" { + logger.Debug("Update check skipped: latest version from GitHub is empty") + return &UpdateInfo{ + NewVersion: false, + Current: currentVersion, + } + } + + if !isNewerVersion(currentVersion, latestVersion) { + logger.Debug("Update check completed: latest=%s current=%s (up to date)", latestVersion, currentVersion) + return &UpdateInfo{ + NewVersion: false, + Current: currentVersion, + Latest: latestVersion, + } + } + + logger.Debug("Update check completed: latest=%s current=%s (new version available)", latestVersion, currentVersion) + logger.Warning("New ProxSave version %s (current %s): run 'proxsave --upgrade' to install.", latestVersion, currentVersion) + + return &UpdateInfo{ + NewVersion: true, + Current: currentVersion, + Latest: latestVersion, + } +} + +// isNewerVersion returns true if latest is strictly newer than current, +// comparing MAJOR.MINOR.PATCH (ignoring any leading 'v' and pre-release suffixes). +func isNewerVersion(current, latest string) bool { + parse := func(v string) (int, int, int) { + v = strings.TrimSpace(v) + v = strings.TrimPrefix(v, "v") + if i := strings.IndexByte(v, '-'); i >= 0 { + v = v[:i] + } + + parts := strings.Split(v, ".") + toInt := func(s string) int { + n, _ := strconv.Atoi(s) + return n + } + + major, minor, patch := 0, 0, 0 + if len(parts) > 0 { + major = toInt(parts[0]) + } + if len(parts) > 1 { + minor = toInt(parts[1]) + } + if len(parts) > 2 { + patch = toInt(parts[2]) + } + return major, minor, patch + } + + curMaj, curMin, curPatch := parse(current) + latMaj, latMin, latPatch := parse(latest) + + if latMaj != curMaj { + return latMaj > curMaj + } + if latMin != curMin { + return latMin > curMin + } + return latPatch > curPatch +} From a5260404392f63b60ca37d3b74937645f06f991b Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:00:48 +0200 Subject: [PATCH 12/35] Split restore logic into multiple files Refactor the large restore.go by extracting archive, extraction, decompression, service/ZFS handling and cluster-apply logic into dedicated files (restore_archive.go, restore_archive_entries.go, restore_archive_extract.go, restore_archive_paths.go, restore_cluster_apply.go, restore_decompression.go, restore_services.go, restore_zfs.go and related additions). Clean up imports and unused globals in restore.go, add ErrRestoreAborted and a doc comment for RunRestoreWorkflow, and update tests to use the new restoreArchiveOptions API for archive extraction. This improves modularity and maintainability of the restore code. --- .../orchestrator/additional_helpers_test.go | 14 +- internal/orchestrator/restore.go | 1743 +---------------- internal/orchestrator/restore_archive.go | 316 +++ .../orchestrator/restore_archive_entries.go | 279 +++ .../orchestrator/restore_archive_extract.go | 241 +++ .../orchestrator/restore_archive_paths.go | 115 ++ .../orchestrator/restore_cluster_apply.go | 366 ++++ .../orchestrator/restore_decompression.go | 94 + internal/orchestrator/restore_errors_test.go | 7 +- .../orchestrator/restore_filesystem_test.go | 8 +- internal/orchestrator/restore_services.go | 473 +++++ internal/orchestrator/restore_workflow_ui.go | 25 +- internal/orchestrator/restore_zfs.go | 209 ++ 13 files changed, 2143 insertions(+), 1747 deletions(-) create mode 100644 internal/orchestrator/restore_archive.go create mode 100644 internal/orchestrator/restore_archive_entries.go create mode 100644 internal/orchestrator/restore_archive_extract.go create mode 100644 internal/orchestrator/restore_archive_paths.go create mode 100644 internal/orchestrator/restore_cluster_apply.go create mode 100644 internal/orchestrator/restore_decompression.go create mode 100644 internal/orchestrator/restore_services.go create mode 100644 internal/orchestrator/restore_zfs.go diff --git a/internal/orchestrator/additional_helpers_test.go b/internal/orchestrator/additional_helpers_test.go index 94844c43..9e44f7f4 100644 --- a/internal/orchestrator/additional_helpers_test.go +++ b/internal/orchestrator/additional_helpers_test.go @@ -913,7 +913,12 @@ func TestExtractArchiveNativeSymlinkAndHardlink(t *testing.T) { } dest := filepath.Join(tmpDir, "dest") - if err := extractArchiveNative(context.Background(), tarPath, dest, logger, nil, RestoreModeFull, nil, "", nil); err != nil { + if err := extractArchiveNative(context.Background(), restoreArchiveOptions{ + archivePath: tarPath, + destRoot: dest, + logger: logger, + mode: RestoreModeFull, + }); err != nil { t.Fatalf("extractArchiveNative error: %v", err) } @@ -1292,7 +1297,12 @@ func TestExtractArchiveNativeBlocksTraversal(t *testing.T) { _ = f.Close() dest := filepath.Join(tmpDir, "dest") - if err := extractArchiveNative(context.Background(), tarPath, dest, logger, nil, RestoreModeFull, nil, "", nil); err != nil { + if err := extractArchiveNative(context.Background(), restoreArchiveOptions{ + archivePath: tarPath, + destRoot: dest, + logger: logger, + mode: RestoreModeFull, + }); err != nil { t.Fatalf("extractArchiveNative error: %v", err) } if _, err := os.Stat(filepath.Join(dest, "../etc/passwd")); err == nil { diff --git a/internal/orchestrator/restore.go b/internal/orchestrator/restore.go index a7b582d3..f2f779fd 100644 --- a/internal/orchestrator/restore.go +++ b/internal/orchestrator/restore.go @@ -1,44 +1,21 @@ +// Package orchestrator coordinates backup, restore, decrypt, and related workflows. package orchestrator import ( - "archive/tar" "bufio" - "compress/gzip" "context" "errors" "fmt" - "io" "os" - "path" - "path/filepath" - "sort" - "strings" - "sync/atomic" - "syscall" "time" "github.com/tis24dev/proxsave/internal/config" - "github.com/tis24dev/proxsave/internal/input" "github.com/tis24dev/proxsave/internal/logging" - "github.com/tis24dev/proxsave/internal/safeexec" ) +// ErrRestoreAborted is returned when a restore workflow is intentionally aborted by the user. var ErrRestoreAborted = errors.New("restore workflow aborted by user") -var ( - serviceStopTimeout = 45 * time.Second - serviceStopNoBlockTimeout = 15 * time.Second - serviceStartTimeout = 30 * time.Second - serviceVerifyTimeout = 30 * time.Second - serviceStatusCheckTimeout = 5 * time.Second - servicePollInterval = 500 * time.Millisecond - serviceRetryDelay = 500 * time.Millisecond - restoreLogSequence uint64 - restoreGlob = filepath.Glob -) - -const restoreTempPattern = ".proxsave-tmp-*" - // RestoreAbortInfo contains information about an aborted restore with network rollback. type RestoreAbortInfo struct { NetworkRollbackArmed bool @@ -61,6 +38,7 @@ func ClearRestoreAbortInfo() { lastRestoreAbortInfo = nil } +// RunRestoreWorkflow runs the CLI restore workflow using stdin prompts and the provided configuration. func RunRestoreWorkflow(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string) (err error) { if cfg == nil { return fmt.Errorf("configuration not available") @@ -75,1718 +53,3 @@ func RunRestoreWorkflow(ctx context.Context, cfg *config.Config, logger *logging ui := newCLIWorkflowUI(bufio.NewReader(os.Stdin), logger) return runRestoreWorkflowWithUI(ctx, cfg, logger, version, ui) } - -// checkZFSPoolsAfterRestore checks if ZFS pools need to be imported after restore -func checkZFSPoolsAfterRestore(logger *logging.Logger) error { - if _, err := restoreCmd.Run(context.Background(), "which", "zpool"); err != nil { - // zpool utility not available -> no ZFS tooling installed - return nil - } - - logger.Info("Checking ZFS pool status...") - - configuredPools := detectConfiguredZFSPools() - importablePools, importOutput, importErr := detectImportableZFSPools() - - if len(configuredPools) > 0 { - logger.Warning("Found %d ZFS pool(s) configured for automatic import:", len(configuredPools)) - for _, pool := range configuredPools { - logger.Warning(" - %s", pool) - } - logger.Info("") - } - - if importErr != nil { - logger.Warning("`zpool import` command returned an error: %v", importErr) - if strings.TrimSpace(importOutput) != "" { - logger.Warning("`zpool import` output:\n%s", importOutput) - } - } else if len(importablePools) > 0 { - logger.Warning("`zpool import` reports pools waiting to be imported:") - for _, pool := range importablePools { - logger.Warning(" - %s", pool) - } - logger.Info("") - } - - if len(importablePools) == 0 { - logger.Info("`zpool import` did not report pools waiting for import.") - - if len(configuredPools) > 0 { - logger.Info("") - for _, pool := range configuredPools { - if _, err := restoreCmd.Run(context.Background(), "zpool", "status", pool); err == nil { - logger.Info("Pool %s is already imported (no manual action needed)", pool) - } else { - logger.Warning("Systemd expects pool %s, but `zpool import` and `zpool status` did not report it. Check disk visibility and pool status.", pool) - } - } - } - return nil - } - - logger.Info("⚠ IMPORTANT: ZFS pools may need manual import after restore!") - logger.Info(" Before rebooting, run these commands:") - logger.Info(" 1. Check available pools: zpool import") - for _, pool := range importablePools { - logger.Info(" 2. Import pool manually: zpool import %s", pool) - } - logger.Info(" 3. Verify pool status: zpool status") - logger.Info("") - logger.Info(" If pools fail to import, check:") - logger.Info(" - journalctl -u zfs-import@.service oppure import@.service") - logger.Info(" - zpool import -d /dev/disk/by-id") - logger.Info("") - - return nil -} - -func stopPVEClusterServices(ctx context.Context, logger *logging.Logger) error { - services := []string{"pve-cluster", "pvedaemon", "pveproxy", "pvestatd"} - for _, service := range services { - if err := stopServiceWithRetries(ctx, logger, service); err != nil { - return fmt.Errorf("failed to stop PVE services (%s): %w", service, err) - } - } - return nil -} - -func startPVEClusterServices(ctx context.Context, logger *logging.Logger) error { - services := []string{"pve-cluster", "pvedaemon", "pveproxy", "pvestatd"} - for _, service := range services { - if err := startServiceWithRetries(ctx, logger, service); err != nil { - return fmt.Errorf("failed to start PVE services (%s): %w", service, err) - } - } - return nil -} - -func stopPBSServices(ctx context.Context, logger *logging.Logger) error { - if _, err := restoreCmd.Run(ctx, "which", "systemctl"); err != nil { - return fmt.Errorf("systemctl not available: %w", err) - } - services := []string{"proxmox-backup-proxy", "proxmox-backup"} - var failures []string - for _, service := range services { - if err := stopServiceWithRetries(ctx, logger, service); err != nil { - failures = append(failures, fmt.Sprintf("%s: %v", service, err)) - } - } - if len(failures) > 0 { - return errors.New(strings.Join(failures, "; ")) - } - return nil -} - -func startPBSServices(ctx context.Context, logger *logging.Logger) error { - if _, err := restoreCmd.Run(ctx, "which", "systemctl"); err != nil { - return fmt.Errorf("systemctl not available: %w", err) - } - services := []string{"proxmox-backup", "proxmox-backup-proxy"} - var failures []string - for _, service := range services { - if err := startServiceWithRetries(ctx, logger, service); err != nil { - failures = append(failures, fmt.Sprintf("%s: %v", service, err)) - } - } - if len(failures) > 0 { - return errors.New(strings.Join(failures, "; ")) - } - return nil -} - -func unmountEtcPVE(ctx context.Context, logger *logging.Logger) error { - output, err := restoreCmd.Run(ctx, "umount", "/etc/pve") - msg := strings.TrimSpace(string(output)) - if err != nil { - if strings.Contains(msg, "not mounted") { - logger.Info("Skipping umount /etc/pve (already unmounted)") - return nil - } - if msg != "" { - return fmt.Errorf("umount /etc/pve failed: %s", msg) - } - return fmt.Errorf("umount /etc/pve failed: %w", err) - } - if msg != "" { - logger.Debug("umount /etc/pve output: %s", msg) - } - return nil -} - -func runCommandWithTimeout(ctx context.Context, logger *logging.Logger, timeout time.Duration, name string, args ...string) error { - return execCommand(ctx, logger, timeout, name, args...) -} - -func execCommand(ctx context.Context, logger *logging.Logger, timeout time.Duration, name string, args ...string) error { - execCtx := ctx - var cancel context.CancelFunc - if timeout > 0 { - execCtx, cancel = context.WithTimeout(ctx, timeout) - defer cancel() - } - - output, err := restoreCmd.Run(execCtx, name, args...) - msg := strings.TrimSpace(string(output)) - if err != nil { - if timeout > 0 && (errors.Is(execCtx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded)) { - return fmt.Errorf("%s %s timed out after %s", name, strings.Join(args, " "), timeout) - } - if msg != "" { - return fmt.Errorf("%s %s failed: %s", name, strings.Join(args, " "), msg) - } - return fmt.Errorf("%s %s failed: %w", name, strings.Join(args, " "), err) - } - if msg != "" && logger != nil { - logger.Debug("%s %s: %s", name, strings.Join(args, " "), msg) - } - return nil -} - -func stopServiceWithRetries(ctx context.Context, logger *logging.Logger, service string) error { - attempts := []struct { - description string - args []string - timeout time.Duration - }{ - {"stop (no-block)", []string{"stop", "--no-block", service}, serviceStopNoBlockTimeout}, - {"stop (blocking)", []string{"stop", service}, serviceStopTimeout}, - {"aggressive stop", []string{"kill", "--signal=SIGTERM", "--kill-who=all", service}, serviceStopTimeout}, - {"force kill", []string{"kill", "--signal=SIGKILL", "--kill-who=all", service}, serviceStopTimeout}, - } - - var lastErr error - for i, attempt := range attempts { - if i > 0 { - if err := sleepWithContext(ctx, serviceRetryDelay); err != nil { - return err - } - } - - if logger != nil { - logger.Debug("Attempting %s for %s (%d/%d)", attempt.description, service, i+1, len(attempts)) - } - - if err := runCommandWithTimeoutCountdown(ctx, logger, attempt.timeout, service, attempt.description, "systemctl", attempt.args...); err != nil { - lastErr = err - continue - } - if err := waitForServiceInactive(ctx, logger, service, serviceVerifyTimeout); err != nil { - lastErr = err - continue - } - resetFailedService(ctx, logger, service) - return nil - } - - if lastErr == nil { - lastErr = fmt.Errorf("unable to stop %s", service) - } - return lastErr -} - -func startServiceWithRetries(ctx context.Context, logger *logging.Logger, service string) error { - attempts := []struct { - description string - args []string - }{ - {"start", []string{"start", service}}, - {"retry start", []string{"start", service}}, - {"aggressive restart", []string{"restart", service}}, - } - - var lastErr error - for i, attempt := range attempts { - if i > 0 { - if err := sleepWithContext(ctx, serviceRetryDelay); err != nil { - return err - } - } - - if logger != nil { - logger.Debug("Attempting %s for %s (%d/%d)", attempt.description, service, i+1, len(attempts)) - } - - if err := runCommandWithTimeout(ctx, logger, serviceStartTimeout, "systemctl", attempt.args...); err != nil { - lastErr = err - continue - } - return nil - } - - if lastErr == nil { - lastErr = fmt.Errorf("unable to start %s", service) - } - return lastErr -} - -func runCommandWithTimeoutCountdown(ctx context.Context, logger *logging.Logger, timeout time.Duration, service, action, name string, args ...string) error { - if timeout <= 0 { - return execCommand(ctx, logger, timeout, name, args...) - } - - execCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - type result struct { - out []byte - err error - } - - resultCh := make(chan result, 1) - go func() { - out, err := restoreCmd.Run(execCtx, name, args...) - resultCh <- result{out: out, err: err} - }() - - progressEnabled := isTerminal(int(os.Stderr.Fd())) - deadline := time.Now().Add(timeout) - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - - writeProgress := func(left time.Duration) { - if !progressEnabled { - return - } - seconds := int(left.Round(time.Second).Seconds()) - if seconds < 0 { - seconds = 0 - } - fmt.Fprintf(os.Stderr, "\rStopping %s: %s (attempt timeout in %ds)...", service, action, seconds) - } - - for { - select { - case r := <-resultCh: - if progressEnabled { - fmt.Fprint(os.Stderr, "\r") - fmt.Fprintln(os.Stderr, strings.Repeat(" ", 80)) - fmt.Fprint(os.Stderr, "\r") - } - msg := strings.TrimSpace(string(r.out)) - if r.err != nil { - if errors.Is(execCtx.Err(), context.DeadlineExceeded) || errors.Is(r.err, context.DeadlineExceeded) { - return fmt.Errorf("%s %s timed out after %s", name, strings.Join(args, " "), timeout) - } - if msg != "" { - return fmt.Errorf("%s %s failed: %s", name, strings.Join(args, " "), msg) - } - return fmt.Errorf("%s %s failed: %w", name, strings.Join(args, " "), r.err) - } - if msg != "" && logger != nil { - logger.Debug("%s %s: %s", name, strings.Join(args, " "), msg) - } - return nil - case <-ticker.C: - writeProgress(time.Until(deadline)) - case <-execCtx.Done(): - writeProgress(0) - if progressEnabled { - fmt.Fprintln(os.Stderr) - } - select { - case r := <-resultCh: - msg := strings.TrimSpace(string(r.out)) - if msg != "" && logger != nil { - logger.Debug("%s %s: %s", name, strings.Join(args, " "), msg) - } - default: - } - return fmt.Errorf("%s %s timed out after %s", name, strings.Join(args, " "), timeout) - } - } -} - -func waitForServiceInactive(ctx context.Context, logger *logging.Logger, service string, timeout time.Duration) error { - if timeout <= 0 { - return nil - } - deadline := time.Now().Add(timeout) - progressEnabled := isTerminal(int(os.Stderr.Fd())) - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - for { - remaining := time.Until(deadline) - if remaining <= 0 { - if progressEnabled { - fmt.Fprintln(os.Stderr) - } - return fmt.Errorf("%s still active after %s", service, timeout) - } - - checkTimeout := minDuration(remaining, serviceStatusCheckTimeout) - active, err := isServiceActive(ctx, service, checkTimeout) - if err != nil { - return err - } - if !active { - if logger != nil { - logger.Debug("%s stopped successfully", service) - } - return nil - } - - wait := minDuration(remaining, servicePollInterval) - timer := time.NewTimer(wait) - select { - case <-ctx.Done(): - if !timer.Stop() { - <-timer.C - } - if progressEnabled { - fmt.Fprintln(os.Stderr) - } - return ctx.Err() - case <-timer.C: - } - select { - case <-ticker.C: - if progressEnabled { - seconds := int(remaining.Round(time.Second).Seconds()) - if seconds < 0 { - seconds = 0 - } - fmt.Fprintf(os.Stderr, "\rWaiting for %s to stop (%ds remaining)...", service, seconds) - } - default: - } - } -} - -func resetFailedService(ctx context.Context, logger *logging.Logger, service string) { - resetCtx, cancel := context.WithTimeout(ctx, serviceStatusCheckTimeout) - defer cancel() - - if _, err := restoreCmd.Run(resetCtx, "systemctl", "reset-failed", service); err != nil { - if logger != nil { - logger.Debug("systemctl reset-failed %s ignored: %v", service, err) - } - } -} - -func isServiceActive(ctx context.Context, service string, timeout time.Duration) (bool, error) { - if timeout <= 0 { - timeout = serviceStatusCheckTimeout - } - checkCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - output, err := restoreCmd.Run(checkCtx, "systemctl", "is-active", service) - msg := strings.TrimSpace(string(output)) - if err == nil { - return true, nil - } - if errors.Is(checkCtx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) { - return false, fmt.Errorf("systemctl is-active %s timed out after %s", service, timeout) - } - if msg == "" { - msg = err.Error() - } - lower := strings.ToLower(msg) - if strings.Contains(lower, "deactivating") || strings.Contains(lower, "activating") { - return true, nil - } - if strings.Contains(lower, "inactive") || strings.Contains(lower, "failed") || strings.Contains(lower, "dead") { - return false, nil - } - return false, fmt.Errorf("systemctl is-active %s failed: %s", service, msg) -} - -func minDuration(a, b time.Duration) time.Duration { - if a < b { - return a - } - return b -} - -func sleepWithContext(ctx context.Context, d time.Duration) error { - if d <= 0 { - return nil - } - timer := time.NewTimer(d) - defer timer.Stop() - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } -} - -func detectConfiguredZFSPools() []string { - pools := make(map[string]struct{}) - - directories := []string{ - "/etc/systemd/system/zfs-import.target.wants", - "/etc/systemd/system/multi-user.target.wants", - } - - for _, dir := range directories { - entries, err := restoreFS.ReadDir(dir) - if err != nil { - continue - } - - for _, entry := range entries { - if pool := parsePoolNameFromUnit(entry.Name()); pool != "" { - pools[pool] = struct{}{} - } - } - } - - globPatterns := []string{ - "/etc/systemd/system/zfs-import@*.service", - "/etc/systemd/system/import@*.service", - } - - for _, pattern := range globPatterns { - matches, err := restoreGlob(pattern) - if err != nil { - continue - } - for _, match := range matches { - if pool := parsePoolNameFromUnit(filepath.Base(match)); pool != "" { - pools[pool] = struct{}{} - } - } - } - - var poolNames []string - for pool := range pools { - poolNames = append(poolNames, pool) - } - sort.Strings(poolNames) - return poolNames -} - -func parsePoolNameFromUnit(unitName string) string { - switch { - case strings.HasPrefix(unitName, "zfs-import@") && strings.HasSuffix(unitName, ".service"): - pool := strings.TrimPrefix(unitName, "zfs-import@") - return strings.TrimSuffix(pool, ".service") - case strings.HasPrefix(unitName, "import@") && strings.HasSuffix(unitName, ".service"): - pool := strings.TrimPrefix(unitName, "import@") - return strings.TrimSuffix(pool, ".service") - default: - return "" - } -} - -func detectImportableZFSPools() ([]string, string, error) { - output, err := restoreCmd.Run(context.Background(), "zpool", "import") - poolNames := parseZpoolImportOutput(string(output)) - if err != nil { - return poolNames, string(output), err - } - return poolNames, string(output), nil -} - -func parseZpoolImportOutput(output string) []string { - var pools []string - scanner := bufio.NewScanner(strings.NewReader(output)) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if strings.HasPrefix(strings.ToLower(line), "pool:") { - pool := strings.TrimSpace(line[len("pool:"):]) - if pool != "" { - pools = append(pools, pool) - } - } - } - return pools -} - -func combinePoolNames(a, b []string) []string { - merged := make(map[string]struct{}) - for _, pool := range a { - merged[pool] = struct{}{} - } - for _, pool := range b { - merged[pool] = struct{}{} - } - - if len(merged) == 0 { - return nil - } - - names := make([]string, 0, len(merged)) - for pool := range merged { - names = append(names, pool) - } - sort.Strings(names) - return names -} - -func shouldRecreateDirectories(systemType SystemType, categories []Category) bool { - return (systemType.SupportsPVE() && hasCategoryID(categories, "storage_pve")) || - (systemType.SupportsPBS() && hasCategoryID(categories, "datastore_pbs")) -} - -func hasCategoryID(categories []Category, id string) bool { - for _, cat := range categories { - if cat.ID == id { - return true - } - } - return false -} - -// shouldStopPBSServices reports whether any selected categories belong to PBS-specific configuration -// and therefore require stopping PBS services before restore. -func shouldStopPBSServices(categories []Category) bool { - for _, cat := range categories { - if cat.Type == CategoryTypePBS { - return true - } - // Some common categories (e.g. SSL) include PBS paths that require restarting PBS services. - for _, p := range cat.Paths { - p = strings.TrimSpace(p) - if strings.HasPrefix(p, "./etc/proxmox-backup/") || strings.HasPrefix(p, "./var/lib/proxmox-backup/") { - return true - } - } - } - return false -} - -func splitExportCategories(categories []Category) (normal []Category, export []Category) { - for _, cat := range categories { - if cat.ExportOnly { - export = append(export, cat) - continue - } - normal = append(normal, cat) - } - return normal, export -} - -// redirectClusterCategoryToExport removes pve_cluster from normal categories and adds it to export-only list. -func redirectClusterCategoryToExport(normal []Category, export []Category) ([]Category, []Category) { - filtered := make([]Category, 0, len(normal)) - for _, cat := range normal { - if cat.ID == "pve_cluster" { - export = append(export, cat) - continue - } - filtered = append(filtered, cat) - } - return filtered, export -} - -func exportDestRoot(baseDir string) string { - base := strings.TrimSpace(baseDir) - if base == "" { - base = "/opt/proxsave" - } - return filepath.Join(base, fmt.Sprintf("proxmox-config-export-%s", nowRestore().Format("20060102-150405"))) -} - -// runFullRestore performs a full restore without selective options (fallback) -func runFullRestore(ctx context.Context, reader *bufio.Reader, candidate *backupCandidate, prepared *preparedBundle, destRoot string, logger *logging.Logger, dryRun bool) error { - if err := confirmRestoreAction(ctx, reader, candidate, destRoot); err != nil { - return err - } - - safeFstabMerge := destRoot == "/" && isRealRestoreFS(restoreFS) - skipFn := func(name string) bool { - if !safeFstabMerge { - return false - } - clean := strings.TrimPrefix(strings.TrimSpace(name), "./") - clean = strings.TrimPrefix(clean, "/") - return clean == "etc/fstab" - } - - if safeFstabMerge { - logger.Warning("Full restore safety: /etc/fstab will not be overwritten; Smart Merge will be applied after extraction.") - } - - if err := extractPlainArchive(ctx, prepared.ArchivePath, destRoot, logger, skipFn); err != nil { - return err - } - - if safeFstabMerge { - logger.Info("") - fsTempDir, err := restoreFS.MkdirTemp("", "proxsave-fstab-") - if err != nil { - logger.Warning("Failed to create temp dir for fstab merge: %v", err) - } else { - defer restoreFS.RemoveAll(fsTempDir) - fsCategory := []Category{{ - ID: "filesystem", - Name: "Filesystem Configuration", - Paths: []string{ - "./etc/fstab", - }, - }} - if err := extractArchiveNative(ctx, prepared.ArchivePath, fsTempDir, logger, fsCategory, RestoreModeCustom, nil, "", nil); err != nil { - logger.Warning("Failed to extract filesystem config for merge: %v", err) - } else { - // Best-effort: extract ProxSave inventory files used for stable fstab device remapping. - invCategory := []Category{{ - ID: "fstab_inventory", - Name: "Fstab inventory (device mapping)", - Paths: []string{ - "./var/lib/proxsave-info/commands/system/blkid.txt", - "./var/lib/proxsave-info/commands/system/lsblk_json.json", - "./var/lib/proxsave-info/commands/system/lsblk.txt", - "./var/lib/proxsave-info/commands/pbs/pbs_datastore_inventory.json", - }, - }} - if err := extractArchiveNative(ctx, prepared.ArchivePath, fsTempDir, logger, invCategory, RestoreModeCustom, nil, "", nil); err != nil { - logger.Debug("Failed to extract fstab inventory data (continuing): %v", err) - } - - currentFstab := filepath.Join(destRoot, "etc", "fstab") - backupFstab := filepath.Join(fsTempDir, "etc", "fstab") - if err := SmartMergeFstab(ctx, logger, reader, currentFstab, backupFstab, dryRun); err != nil { - if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) { - logger.Info("Restore aborted by user during Smart Filesystem Configuration Merge.") - return err - } - logger.Warning("Smart Fstab Merge failed: %v", err) - } - } - } - } - - logger.Info("Restore completed successfully.") - return nil -} - -func confirmRestoreAction(ctx context.Context, reader *bufio.Reader, cand *backupCandidate, dest string) error { - manifest := cand.Manifest - fmt.Println() - fmt.Printf("Selected backup: %s (%s)\n", cand.DisplayBase, manifest.CreatedAt.Format("2006-01-02 15:04:05")) - cleanDest := filepath.Clean(strings.TrimSpace(dest)) - if cleanDest == "" || cleanDest == "." { - cleanDest = string(os.PathSeparator) - } - if cleanDest == string(os.PathSeparator) { - fmt.Println("Restore destination: / (system root; original paths will be preserved)") - fmt.Println("WARNING: This operation will overwrite configuration files on this system.") - } else { - fmt.Printf("Restore destination: %s (original paths will be preserved under this directory)\n", cleanDest) - fmt.Printf("WARNING: This operation will overwrite existing files under %s.\n", cleanDest) - } - fmt.Println("Type RESTORE to proceed or 0 to cancel.") - - for { - fmt.Print("Confirmation: ") - line, err := input.ReadLineWithContext(ctx, reader) - if err != nil { - return err - } - switch strings.TrimSpace(line) { - case "RESTORE": - return nil - case "0": - return ErrRestoreAborted - default: - fmt.Println("Please type RESTORE to confirm or 0 to cancel.") - } - } -} - -func extractPlainArchive(ctx context.Context, archivePath, destRoot string, logger *logging.Logger, skipFn func(entryName string) bool) error { - if err := restoreFS.MkdirAll(destRoot, 0o755); err != nil { - return fmt.Errorf("create destination directory: %w", err) - } - - // Only enforce root privileges when writing to the real system root. - if destRoot == "/" && isRealRestoreFS(restoreFS) && os.Geteuid() != 0 { - return fmt.Errorf("restore to %s requires root privileges", destRoot) - } - - logger.Info("Extracting archive %s into %s", filepath.Base(archivePath), destRoot) - - // Use native Go extraction to preserve atime/ctime from PAX headers - if err := extractArchiveNative(ctx, archivePath, destRoot, logger, nil, RestoreModeFull, nil, "", skipFn); err != nil { - return fmt.Errorf("archive extraction failed: %w", err) - } - - return nil -} - -// runSafeClusterApply applies selected cluster configs via pvesh without touching config.db. -// It operates on files extracted to exportRoot (e.g. exportDestRoot). -func runSafeClusterApply(ctx context.Context, reader *bufio.Reader, exportRoot string, logger *logging.Logger) (err error) { - if logger == nil { - logger = logging.GetDefaultLogger() - } - ui := newCLIWorkflowUI(reader, logger) - return runSafeClusterApplyWithUI(ctx, ui, exportRoot, logger, nil) -} - -type vmEntry struct { - VMID string - Kind string // qemu | lxc - Name string - Path string -} - -func scanVMConfigs(exportRoot, node string) ([]vmEntry, error) { - var entries []vmEntry - base := filepath.Join(exportRoot, "etc/pve/nodes", node) - - type dirSpec struct { - kind string - path string - } - - dirs := []dirSpec{ - {kind: "qemu", path: filepath.Join(base, "qemu-server")}, - {kind: "lxc", path: filepath.Join(base, "lxc")}, - } - - for _, spec := range dirs { - infos, err := restoreFS.ReadDir(spec.path) - if err != nil { - continue - } - for _, entry := range infos { - if entry.IsDir() { - continue - } - name := entry.Name() - if !strings.HasSuffix(name, ".conf") { - continue - } - vmid := strings.TrimSuffix(name, ".conf") - vmPath := filepath.Join(spec.path, name) - vmName := readVMName(vmPath) - entries = append(entries, vmEntry{ - VMID: vmid, - Kind: spec.kind, - Name: vmName, - Path: vmPath, - }) - } - } - - return entries, nil -} - -func listExportNodeDirs(exportRoot string) ([]string, error) { - nodesRoot := filepath.Join(exportRoot, "etc/pve/nodes") - entries, err := restoreFS.ReadDir(nodesRoot) - if err != nil { - if errors.Is(err, os.ErrNotExist) || os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - - var nodes []string - for _, entry := range entries { - if !entry.IsDir() { - continue - } - name := strings.TrimSpace(entry.Name()) - if name == "" { - continue - } - nodes = append(nodes, name) - } - sort.Strings(nodes) - return nodes, nil -} - -func countVMConfigsForNode(exportRoot, node string) (qemuCount, lxcCount int) { - base := filepath.Join(exportRoot, "etc/pve/nodes", node) - - countInDir := func(dir string) int { - entries, err := restoreFS.ReadDir(dir) - if err != nil { - return 0 - } - n := 0 - for _, entry := range entries { - if entry.IsDir() { - continue - } - if strings.HasSuffix(entry.Name(), ".conf") { - n++ - } - } - return n - } - - qemuCount = countInDir(filepath.Join(base, "qemu-server")) - lxcCount = countInDir(filepath.Join(base, "lxc")) - return qemuCount, lxcCount -} - -func promptExportNodeSelection(ctx context.Context, reader *bufio.Reader, exportRoot, currentNode string, exportNodes []string) (string, error) { - for { - fmt.Println() - fmt.Printf("WARNING: VM/CT configs in this backup are stored under different node names.\n") - fmt.Printf("Current node: %s\n", currentNode) - fmt.Println("Select which exported node to import VM/CT configs from (they will be applied to the current node):") - for idx, node := range exportNodes { - qemuCount, lxcCount := countVMConfigsForNode(exportRoot, node) - fmt.Printf(" [%d] %s (qemu=%d, lxc=%d)\n", idx+1, node, qemuCount, lxcCount) - } - fmt.Println(" [0] Skip VM/CT apply") - - fmt.Print("Choice: ") - line, err := input.ReadLineWithContext(ctx, reader) - if err != nil { - return "", err - } - trimmed := strings.TrimSpace(line) - if trimmed == "0" { - return "", nil - } - if trimmed == "" { - continue - } - idx, err := parseMenuIndex(trimmed, len(exportNodes)) - if err != nil { - fmt.Println(err) - continue - } - return exportNodes[idx], nil - } -} - -func stringSliceContains(items []string, want string) bool { - for _, item := range items { - if item == want { - return true - } - } - return false -} - -func readVMName(confPath string) string { - data, err := restoreFS.ReadFile(confPath) - if err != nil { - return "" - } - for _, line := range strings.Split(string(data), "\n") { - t := strings.TrimSpace(line) - if strings.HasPrefix(t, "name:") { - return strings.TrimSpace(strings.TrimPrefix(t, "name:")) - } - if strings.HasPrefix(t, "hostname:") { - return strings.TrimSpace(strings.TrimPrefix(t, "hostname:")) - } - } - return "" -} - -func applyVMConfigs(ctx context.Context, entries []vmEntry, logger *logging.Logger) (applied, failed int) { - for _, vm := range entries { - if err := ctx.Err(); err != nil { - logger.Warning("VM apply aborted: %v", err) - return applied, failed - } - target := fmt.Sprintf("/nodes/%s/%s/%s/config", detectNodeForVM(), vm.Kind, vm.VMID) - args := []string{"set", target, "--filename", vm.Path} - if err := runPvesh(ctx, logger, args); err != nil { - logger.Warning("Failed to apply %s (vmid=%s kind=%s): %v", target, vm.VMID, vm.Kind, err) - failed++ - } else { - display := vm.VMID - if vm.Name != "" { - display = fmt.Sprintf("%s (%s)", vm.VMID, vm.Name) - } - logger.Info("Applied VM/CT config %s", display) - applied++ - } - } - return applied, failed -} - -func detectNodeForVM() string { - host, _ := os.Hostname() - host = shortHost(host) - if host != "" { - return host - } - return "localhost" -} - -type storageBlock struct { - ID string - data []string -} - -func applyStorageCfg(ctx context.Context, cfgPath string, logger *logging.Logger) (applied, failed int, err error) { - blocks, perr := parseStorageBlocks(cfgPath) - if perr != nil { - return 0, 0, perr - } - if len(blocks) == 0 { - logger.Info("No storage definitions detected in storage.cfg") - return 0, 0, nil - } - - for _, blk := range blocks { - tmp, tmpErr := restoreFS.CreateTemp("", fmt.Sprintf("pve-storage-%s-*.cfg", sanitizeID(blk.ID))) - if tmpErr != nil { - failed++ - continue - } - tmpName := tmp.Name() - if _, werr := tmp.WriteString(strings.Join(blk.data, "\n") + "\n"); werr != nil { - _ = tmp.Close() - _ = restoreFS.Remove(tmpName) - failed++ - continue - } - _ = tmp.Close() - - args := []string{"set", fmt.Sprintf("/cluster/storage/%s", blk.ID), "-conf", tmpName} - if runErr := runPvesh(ctx, logger, args); runErr != nil { - logger.Warning("Failed to apply storage %s: %v", blk.ID, runErr) - failed++ - } else { - logger.Info("Applied storage definition %s", blk.ID) - applied++ - } - - _ = restoreFS.Remove(tmpName) - - if err := ctx.Err(); err != nil { - return applied, failed, err - } - } - - return applied, failed, nil -} - -func parseStorageBlocks(cfgPath string) ([]storageBlock, error) { - data, err := restoreFS.ReadFile(cfgPath) - if err != nil { - return nil, err - } - - var blocks []storageBlock - var current *storageBlock - - flush := func() { - if current != nil { - blocks = append(blocks, *current) - current = nil - } - } - - for _, line := range strings.Split(string(data), "\n") { - trimmed := strings.TrimSpace(line) - if trimmed == "" { - flush() - continue - } - - // storage.cfg blocks use `: ` (e.g. `dir: local`, `nfs: backup`). - // Older exports may still use `storage: ` blocks. - _, name, ok := parseProxmoxNotificationHeader(trimmed) - if ok { - flush() - current = &storageBlock{ID: name, data: []string{line}} - continue - } - if current != nil { - current.data = append(current.data, line) - } - } - flush() - - return blocks, nil -} - -func runPvesh(ctx context.Context, logger *logging.Logger, args []string) error { - output, err := restoreCmd.Run(ctx, "pvesh", args...) - if len(output) > 0 { - logger.Debug("pvesh %v output: %s", args, strings.TrimSpace(string(output))) - } - if err != nil { - return fmt.Errorf("pvesh %v failed: %w", args, err) - } - return nil -} - -func shortHost(host string) string { - if idx := strings.Index(host, "."); idx > 0 { - return host[:idx] - } - return host -} - -func sanitizeID(id string) string { - var b strings.Builder - for _, r := range id { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { - b.WriteRune(r) - } else { - b.WriteRune('_') - } - } - return b.String() -} - -// promptClusterRestoreMode asks how to handle cluster database restore (safe export vs full recovery). -func promptClusterRestoreMode(ctx context.Context, reader *bufio.Reader) (int, error) { - fmt.Println() - fmt.Println("Cluster backup detected. Choose how to restore the cluster database:") - fmt.Println(" [1] SAFE: Do NOT write /var/lib/pve-cluster/config.db. Export cluster files only (manual/apply via API).") - fmt.Println(" [2] RECOVERY: Restore full cluster database (/var/lib/pve-cluster). Use only when cluster is offline/isolated.") - fmt.Println(" [0] Exit") - - for { - fmt.Print("Choice: ") - choiceLine, err := input.ReadLineWithContext(ctx, reader) - if err != nil { - return 0, err - } - switch strings.TrimSpace(choiceLine) { - case "1": - return 1, nil - case "2": - return 2, nil - case "0": - return 0, nil - default: - fmt.Println("Please enter 1, 2, or 0.") - } - } -} - -// extractSelectiveArchive extracts only files matching selected categories -func extractSelectiveArchive(ctx context.Context, archivePath, destRoot string, categories []Category, mode RestoreMode, logger *logging.Logger) (logPath string, err error) { - done := logging.DebugStart(logger, "extract selective archive", "archive=%s dest=%s categories=%d mode=%s", archivePath, destRoot, len(categories), mode) - defer func() { done(err) }() - if err := restoreFS.MkdirAll(destRoot, 0o755); err != nil { - return "", fmt.Errorf("create destination directory: %w", err) - } - - // Only enforce root privileges when writing to the real system root. - if destRoot == "/" && isRealRestoreFS(restoreFS) && os.Geteuid() != 0 { - return "", fmt.Errorf("restore to %s requires root privileges", destRoot) - } - - // Create detailed log directory - logDir := "/tmp/proxsave" - if err := restoreFS.MkdirAll(logDir, 0755); err != nil { - logger.Warning("Could not create log directory: %v", err) - } - - // Create detailed log file - timestamp := nowRestore().Format("20060102_150405") - logSeq := atomic.AddUint64(&restoreLogSequence, 1) - logPath = filepath.Join(logDir, fmt.Sprintf("restore_%s_%d.log", timestamp, logSeq)) - logFile, err := restoreFS.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0640) - if err != nil { - logger.Warning("Could not create detailed log file: %v", err) - logFile = nil - } else { - defer logFile.Close() - logger.Info("Detailed restore log: %s", logPath) - logging.DebugStep(logger, "extract selective archive", "log file=%s", logPath) - } - - logger.Info("Extracting selected categories from archive %s into %s", filepath.Base(archivePath), destRoot) - - // Use native Go extraction with category filter - if err := extractArchiveNative(ctx, archivePath, destRoot, logger, categories, mode, logFile, logPath, nil); err != nil { - return logPath, err - } - - return logPath, nil -} - -// extractArchiveNative extracts TAR archives natively in Go, preserving all timestamps -// If categories is nil, all files are extracted. Otherwise, only files matching the categories are extracted. -func extractArchiveNative(ctx context.Context, archivePath, destRoot string, logger *logging.Logger, categories []Category, mode RestoreMode, logFile *os.File, logFilePath string, skipFn func(entryName string) bool) error { - // Open the archive file - file, err := restoreFS.Open(archivePath) - if err != nil { - return fmt.Errorf("open archive: %w", err) - } - defer file.Close() - - // Create decompression reader based on file extension - reader, err := createDecompressionReader(ctx, file, archivePath) - if err != nil { - return fmt.Errorf("create decompression reader: %w", err) - } - if closer, ok := reader.(io.Closer); ok { - defer closer.Close() - } - - // Create TAR reader - tarReader := tar.NewReader(reader) - - // Write log header if log file is available - if logFile != nil { - fmt.Fprintf(logFile, "=== PROXMOX RESTORE LOG ===\n") - fmt.Fprintf(logFile, "Date: %s\n", nowRestore().Format("2006-01-02 15:04:05")) - fmt.Fprintf(logFile, "Mode: %s\n", getModeName(mode)) - if len(categories) > 0 { - fmt.Fprintf(logFile, "Selected categories: %d categories\n", len(categories)) - for _, cat := range categories { - fmt.Fprintf(logFile, " - %s (%s)\n", cat.Name, cat.ID) - } - } else { - fmt.Fprintf(logFile, "Selected categories: ALL (full restore)\n") - } - fmt.Fprintf(logFile, "Archive: %s\n", filepath.Base(archivePath)) - fmt.Fprintf(logFile, "\n") - } - - // Extract files (selective or full) - filesExtracted := 0 - filesSkipped := 0 - filesFailed := 0 - selectiveMode := len(categories) > 0 - - var restoredTemp, skippedTemp *os.File - if logFile != nil { - if tmp, err := restoreFS.CreateTemp("", "restored_entries_*.log"); err == nil { - restoredTemp = tmp - defer func() { - tmp.Close() - _ = restoreFS.Remove(tmp.Name()) - }() - } else { - logger.Warning("Could not create temporary file for restored entries: %v", err) - } - - if tmp, err := restoreFS.CreateTemp("", "skipped_entries_*.log"); err == nil { - skippedTemp = tmp - defer func() { - tmp.Close() - _ = restoreFS.Remove(tmp.Name()) - }() - } else { - logger.Warning("Could not create temporary file for skipped entries: %v", err) - } - } - - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - header, err := tarReader.Next() - if err == io.EOF { - break - } - if err != nil { - return fmt.Errorf("read tar header: %w", err) - } - - if skipFn != nil && skipFn(header.Name) { - filesSkipped++ - if skippedTemp != nil { - fmt.Fprintf(skippedTemp, "SKIPPED: %s (skipped by restore policy)\n", header.Name) - } - continue - } - - // Check if file should be extracted (selective mode) - if selectiveMode { - shouldExtract := false - for _, cat := range categories { - if PathMatchesCategory(header.Name, cat) { - shouldExtract = true - break - } - } - - if !shouldExtract { - filesSkipped++ - if skippedTemp != nil { - fmt.Fprintf(skippedTemp, "SKIPPED: %s (does not match any selected category)\n", header.Name) - } - continue - } - } - - if err := extractTarEntry(tarReader, header, destRoot, logger); err != nil { - logger.Warning("Failed to extract %s: %v", header.Name, err) - filesFailed++ - continue - } - - filesExtracted++ - if restoredTemp != nil { - fmt.Fprintf(restoredTemp, "RESTORED: %s\n", header.Name) - } - if filesExtracted%100 == 0 { - logger.Debug("Extracted %d files...", filesExtracted) - } - } - - // Write detailed log - if logFile != nil { - fmt.Fprintf(logFile, "=== FILES RESTORED ===\n") - if restoredTemp != nil { - if _, err := restoredTemp.Seek(0, 0); err == nil { - if _, err := io.Copy(logFile, restoredTemp); err != nil { - logger.Warning("Could not write restored entries to log: %v", err) - } - } - } - fmt.Fprintf(logFile, "\n") - - fmt.Fprintf(logFile, "=== FILES SKIPPED ===\n") - if skippedTemp != nil { - if _, err := skippedTemp.Seek(0, 0); err == nil { - if _, err := io.Copy(logFile, skippedTemp); err != nil { - logger.Warning("Could not write skipped entries to log: %v", err) - } - } - } - fmt.Fprintf(logFile, "\n") - - fmt.Fprintf(logFile, "=== SUMMARY ===\n") - fmt.Fprintf(logFile, "Total files extracted: %d\n", filesExtracted) - fmt.Fprintf(logFile, "Total files skipped: %d\n", filesSkipped) - fmt.Fprintf(logFile, "Total files in archive: %d\n", filesExtracted+filesSkipped) - } - - if filesFailed == 0 { - if selectiveMode { - logger.Info("Successfully restored all %d configuration files/directories", filesExtracted) - } else { - logger.Info("Successfully restored all %d files/directories", filesExtracted) - } - } else { - logger.Warning("Restored %d files/directories; %d item(s) failed (see detailed log)", filesExtracted, filesFailed) - } - - if filesSkipped > 0 { - logger.Info("%d additional archive entries (logs, diagnostics, system defaults) were left unchanged on this system; see detailed log for details", filesSkipped) - } - - if logFilePath != "" { - logger.Info("Detailed restore log: %s", logFilePath) - } - - return nil -} - -func isRealRestoreFS(fs FS) bool { - switch fs.(type) { - case osFS, *osFS: - return true - default: - return false - } -} - -// createDecompressionReader creates appropriate decompression reader based on file extension -func createDecompressionReader(ctx context.Context, file *os.File, archivePath string) (io.Reader, error) { - switch { - case strings.HasSuffix(archivePath, ".tar.gz") || strings.HasSuffix(archivePath, ".tgz"): - return gzip.NewReader(file) - case strings.HasSuffix(archivePath, ".tar.xz"): - return createXZReader(ctx, file) - case strings.HasSuffix(archivePath, ".tar.zst") || strings.HasSuffix(archivePath, ".tar.zstd"): - return createZstdReader(ctx, file) - case strings.HasSuffix(archivePath, ".tar.bz2"): - return createBzip2Reader(ctx, file) - case strings.HasSuffix(archivePath, ".tar.lzma"): - return createLzmaReader(ctx, file) - case strings.HasSuffix(archivePath, ".tar"): - return file, nil - default: - return nil, fmt.Errorf("unsupported archive format: %s", filepath.Base(archivePath)) - } -} - -// createXZReader creates an XZ decompression reader using injectable command runner -func createXZReader(ctx context.Context, file *os.File) (io.Reader, error) { - return runRestoreCommandStream(ctx, "xz", file, "-d", "-c") -} - -// createZstdReader creates a Zstd decompression reader using injectable command runner -func createZstdReader(ctx context.Context, file *os.File) (io.Reader, error) { - return runRestoreCommandStream(ctx, "zstd", file, "-d", "-c") -} - -// createBzip2Reader creates a Bzip2 decompression reader using injectable command runner -func createBzip2Reader(ctx context.Context, file *os.File) (io.Reader, error) { - return runRestoreCommandStream(ctx, "bzip2", file, "-d", "-c") -} - -// createLzmaReader creates an LZMA decompression reader using injectable command runner -func createLzmaReader(ctx context.Context, file *os.File) (io.Reader, error) { - return runRestoreCommandStream(ctx, "lzma", file, "-d", "-c") -} - -// runRestoreCommandStream starts a command that reads from stdin and exposes stdout as a ReadCloser. -// It prefers an injectable streaming runner when available; otherwise falls back to safeexec. -func runRestoreCommandStream(ctx context.Context, name string, stdin io.Reader, args ...string) (io.Reader, error) { - type streamingRunner interface { - RunStream(ctx context.Context, name string, stdin io.Reader, args ...string) (io.ReadCloser, error) - } - if sr, ok := restoreCmd.(streamingRunner); ok && sr != nil { - return sr.RunStream(ctx, name, stdin, args...) - } - - cmd, err := safeexec.CommandContext(ctx, name, args...) - if err != nil { - return nil, err - } - cmd.Stdin = stdin - stdout, err := cmd.StdoutPipe() - if err != nil { - return nil, fmt.Errorf("create %s pipe: %w", name, err) - } - if err := cmd.Start(); err != nil { - stdout.Close() - return nil, fmt.Errorf("start %s: %w", name, err) - } - return &waitReadCloser{ReadCloser: stdout, wait: cmd.Wait}, nil -} - -func sanitizeRestoreEntryTarget(destRoot, entryName string) (string, string, error) { - return sanitizeRestoreEntryTargetWithFS(restoreFS, destRoot, entryName) -} - -func sanitizeRestoreEntryTargetWithFS(fsys FS, destRoot, entryName string) (string, string, error) { - cleanDestRoot := filepath.Clean(destRoot) - if cleanDestRoot == "" { - cleanDestRoot = string(os.PathSeparator) - } - - absDestRoot, err := filepath.Abs(cleanDestRoot) - if err != nil { - return "", "", fmt.Errorf("resolve destination root: %w", err) - } - - name := strings.TrimSpace(entryName) - if name == "" { - return "", "", fmt.Errorf("empty archive entry name") - } - - sanitized := path.Clean(name) - for strings.HasPrefix(sanitized, string(os.PathSeparator)) { - sanitized = strings.TrimPrefix(sanitized, string(os.PathSeparator)) - } - - if sanitized == "" || sanitized == "." { - return "", "", fmt.Errorf("invalid archive entry name: %q", entryName) - } - - if sanitized == ".." || strings.HasPrefix(sanitized, "../") || strings.Contains(sanitized, "/../") { - return "", "", fmt.Errorf("illegal path: %s", entryName) - } - - target := filepath.Join(absDestRoot, filepath.FromSlash(sanitized)) - absTarget, err := filepath.Abs(target) - if err != nil { - return "", "", fmt.Errorf("resolve extraction target: %w", err) - } - - rel, err := filepath.Rel(absDestRoot, absTarget) - if err != nil { - return "", "", fmt.Errorf("illegal path: %s: %w", entryName, err) - } - if strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || rel == ".." || filepath.IsAbs(rel) { - return "", "", fmt.Errorf("illegal path: %s", entryName) - } - - if _, err := resolvePathWithinRootFS(fsys, absDestRoot, absTarget); err != nil { - if isPathSecurityError(err) { - return "", "", fmt.Errorf("illegal path: %s: %w", entryName, err) - } - if !isPathOperationalError(err) { - return "", "", fmt.Errorf("resolve extraction target: %w", err) - } - } - - return absTarget, absDestRoot, nil -} - -func shouldSkipProxmoxSystemRestore(relTarget string) (bool, string) { - rel := filepath.ToSlash(filepath.Clean(strings.TrimSpace(relTarget))) - rel = strings.TrimPrefix(rel, "./") - rel = strings.TrimPrefix(rel, "/") - - switch rel { - case "etc/proxmox-backup/domains.cfg": - return true, "PBS auth realms must be recreated (domains.cfg is too fragile to restore raw)" - case "etc/proxmox-backup/user.cfg": - return true, "PBS users must be recreated (user.cfg should not be restored raw)" - case "etc/proxmox-backup/acl.cfg": - return true, "PBS permissions must be recreated (acl.cfg should not be restored raw)" - case "var/lib/proxmox-backup/.clusterlock": - return true, "PBS runtime lock files must not be restored" - } - - if strings.HasPrefix(rel, "var/lib/proxmox-backup/lock/") { - return true, "PBS runtime lock files must not be restored" - } - - return false, "" -} - -// extractTarEntry extracts a single TAR entry, preserving all attributes including atime/ctime -func extractTarEntry(tarReader *tar.Reader, header *tar.Header, destRoot string, logger *logging.Logger) error { - target, cleanDestRoot, err := sanitizeRestoreEntryTargetWithFS(restoreFS, destRoot, header.Name) - if err != nil { - return err - } - - // Hard guard: never write directly into /etc/pve when restoring to system root - if cleanDestRoot == string(os.PathSeparator) && strings.HasPrefix(target, "/etc/pve") { - logger.Warning("Skipping restore to %s (writes to /etc/pve are prohibited)", target) - return nil - } - - if cleanDestRoot == string(os.PathSeparator) { - relTarget, err := filepath.Rel(cleanDestRoot, target) - if err != nil { - return fmt.Errorf("determine restore target for %s: %w", header.Name, err) - } - if skip, reason := shouldSkipProxmoxSystemRestore(relTarget); skip { - logger.Warning("Skipping restore to %s (%s)", target, reason) - return nil - } - } - - // Create parent directories - if err := restoreFS.MkdirAll(filepath.Dir(target), 0755); err != nil { - return fmt.Errorf("create parent directory: %w", err) - } - - switch header.Typeflag { - case tar.TypeDir: - return extractDirectory(target, header, logger) - case tar.TypeReg: - return extractRegularFile(tarReader, target, header, logger) - case tar.TypeSymlink: - return extractSymlink(target, header, cleanDestRoot, logger) - case tar.TypeLink: - return extractHardlink(target, header, cleanDestRoot) - default: - logger.Debug("Skipping unsupported file type %d: %s", header.Typeflag, header.Name) - return nil - } -} - -// extractDirectory creates a directory with proper permissions and timestamps -func extractDirectory(target string, header *tar.Header, logger *logging.Logger) (retErr error) { - // Create with an owner-accessible mode first so the directory can be opened - // before applying restrictive archive permissions. - if err := restoreFS.MkdirAll(target, 0o700); err != nil { - return fmt.Errorf("create directory: %w", err) - } - - dirFile, err := restoreFS.Open(target) - if err != nil { - return fmt.Errorf("open directory: %w", err) - } - defer func() { - if dirFile == nil { - return - } - if err := dirFile.Close(); err != nil && retErr == nil { - retErr = fmt.Errorf("close directory: %w", err) - } - }() - - // Apply metadata on the opened directory handle so logical FS paths - // (e.g. FakeFS-backed test roots) do not leak through to host paths. - // Ownership remains best-effort to match the previous restore behavior on - // unprivileged runs and filesystems that do not support chown. - if err := atomicFileChown(dirFile, header.Uid, header.Gid); err != nil { - logger.Debug("Failed to chown directory %s: %v", target, err) - } - if err := atomicFileChmod(dirFile, os.FileMode(header.Mode)); err != nil { - return fmt.Errorf("chmod directory: %w", err) - } - - // Set timestamps (mtime, atime) - if err := setTimestamps(target, header); err != nil { - logger.Debug("Failed to set timestamps on directory %s: %v", target, err) - } - - return nil -} - -// extractRegularFile extracts a regular file with content and timestamps -func extractRegularFile(tarReader *tar.Reader, target string, header *tar.Header, logger *logging.Logger) (retErr error) { - tmpPath := "" - var outFile *os.File - appendDeferredErr := func(prefix string, err error) { - if err == nil { - return - } - wrapped := fmt.Errorf("%s: %w", prefix, err) - if retErr == nil { - retErr = wrapped - return - } - retErr = errors.Join(retErr, wrapped) - } - closeOutFile := func() error { - if outFile == nil { - return nil - } - err := outFile.Close() - outFile = nil - return err - } - - // Write to a sibling temp file first so a truncated archive entry cannot clobber - // an existing target before the content is fully copied and closed. - outFile, err := restoreFS.CreateTemp(filepath.Dir(target), restoreTempPattern) - if err != nil { - return fmt.Errorf("create file: %w", err) - } - tmpPath = outFile.Name() - defer func() { - appendDeferredErr("close file", closeOutFile()) - if tmpPath != "" { - if err := restoreFS.Remove(tmpPath); err != nil && logger != nil { - logger.Debug("Failed to remove temp file %s: %v", tmpPath, err) - } - } - }() - - // Copy content - if _, err := io.Copy(outFile, tarReader); err != nil { - return fmt.Errorf("write file content: %w", err) - } - - // Set metadata on the temp file before replacing the target so failures do not - // leave the final path in a partially restored state. - // Ownership remains best-effort to match the previous restore behavior on - // unprivileged runs and filesystems that do not support chown. - if err := atomicFileChown(outFile, header.Uid, header.Gid); err != nil { - logger.Debug("Failed to chown file %s: %v", target, err) - } - if err := atomicFileChmod(outFile, os.FileMode(header.Mode)); err != nil { - return fmt.Errorf("chmod file: %w", err) - } - - // Close before renaming into place. - if err := closeOutFile(); err != nil { - return fmt.Errorf("close file: %w", err) - } - - if err := restoreFS.Rename(tmpPath, target); err != nil { - return fmt.Errorf("replace file: %w", err) - } - tmpPath = "" - - // Set timestamps (mtime, atime, ctime via syscall) - if err := setTimestamps(target, header); err != nil { - logger.Debug("Failed to set timestamps on file %s: %v", target, err) - } - - return nil -} - -// extractSymlink creates a symbolic link -func extractSymlink(target string, header *tar.Header, destRoot string, logger *logging.Logger) error { - linkTarget := header.Linkname - - // Pre-validation: ensure the symlink target resolves within destRoot before creation. - if _, err := resolvePathRelativeToBaseWithinRootFS(restoreFS, destRoot, filepath.Dir(target), linkTarget); err != nil { - return fmt.Errorf("symlink target escapes root before creation: %s -> %s: %w", header.Name, linkTarget, err) - } - - // Remove existing file/link if it exists - _ = restoreFS.Remove(target) - - // Create symlink - if err := restoreFS.Symlink(linkTarget, target); err != nil { - return fmt.Errorf("create symlink: %w", err) - } - - // POST-CREATION VALIDATION: Verify the created symlink's target stays within destRoot - actualTarget, err := restoreFS.Readlink(target) - if err != nil { - restoreFS.Remove(target) // Clean up - return fmt.Errorf("read created symlink %s: %w", target, err) - } - - if _, err := resolvePathRelativeToBaseWithinRootFS(restoreFS, destRoot, filepath.Dir(target), actualTarget); err != nil { - restoreFS.Remove(target) - return fmt.Errorf("symlink target escapes root after creation: %s -> %s: %w", header.Name, actualTarget, err) - } - - // Set ownership (on the symlink itself, not the target) - if err := os.Lchown(target, header.Uid, header.Gid); err != nil { - logger.Debug("Failed to lchown symlink %s: %v", target, err) - } - - // Note: timestamps on symlinks are not typically preserved - return nil -} - -// extractHardlink creates a hard link -func extractHardlink(target string, header *tar.Header, destRoot string) error { - // Validate hard link target - linkName := header.Linkname - - // Reject absolute hard link targets immediately - if filepath.IsAbs(linkName) { - return fmt.Errorf("absolute hardlink target not allowed: %s", linkName) - } - - // Validate the hard link target stays within extraction root - if _, err := resolvePathWithinRootFS(restoreFS, destRoot, linkName); err != nil { - return fmt.Errorf("hardlink target escapes root: %s -> %s: %w", header.Name, linkName, err) - } - - linkTarget := filepath.Join(destRoot, linkName) - - // Remove existing file/link if it exists - _ = restoreFS.Remove(target) - - // Create hard link - if err := restoreFS.Link(linkTarget, target); err != nil { - return fmt.Errorf("create hardlink: %w", err) - } - - return nil -} - -// setTimestamps sets atime, mtime, and attempts to set ctime via syscall -func setTimestamps(target string, header *tar.Header) error { - // Convert times to Unix format - atime := header.AccessTime - mtime := header.ModTime - - // Use syscall.UtimesNano to set atime and mtime with nanosecond precision - times := []syscall.Timespec{ - {Sec: atime.Unix(), Nsec: int64(atime.Nanosecond())}, - {Sec: mtime.Unix(), Nsec: int64(mtime.Nanosecond())}, - } - - if err := syscall.UtimesNano(target, times); err != nil { - return fmt.Errorf("set atime/mtime: %w", err) - } - - // Note: ctime (change time) cannot be set directly by user-space programs - // It is automatically updated by the kernel when file metadata changes - // The header.ChangeTime is stored in PAX but cannot be restored - - return nil -} - -// getModeName returns a human-readable name for the restore mode -func getModeName(mode RestoreMode) string { - switch mode { - case RestoreModeFull: - return "FULL restore (all files)" - case RestoreModeStorage: - return "STORAGE/DATASTORE only" - case RestoreModeBase: - return "SYSTEM BASE only" - case RestoreModeCustom: - return "CUSTOM selection" - default: - return "Unknown mode" - } -} diff --git a/internal/orchestrator/restore_archive.go b/internal/orchestrator/restore_archive.go new file mode 100644 index 00000000..e812ff29 --- /dev/null +++ b/internal/orchestrator/restore_archive.go @@ -0,0 +1,316 @@ +// Package orchestrator coordinates backup, restore, decrypt, and related workflows. +package orchestrator + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync/atomic" + + "github.com/tis24dev/proxsave/internal/input" + "github.com/tis24dev/proxsave/internal/logging" +) + +var restoreLogSequence uint64 + +func shouldRecreateDirectories(systemType SystemType, categories []Category) bool { + return (systemType.SupportsPVE() && hasCategoryID(categories, "storage_pve")) || + (systemType.SupportsPBS() && hasCategoryID(categories, "datastore_pbs")) +} + +func hasCategoryID(categories []Category, id string) bool { + for _, cat := range categories { + if cat.ID == id { + return true + } + } + return false +} + +// shouldStopPBSServices reports whether any selected categories belong to PBS-specific configuration +// and therefore require stopping PBS services before restore. +func shouldStopPBSServices(categories []Category) bool { + for _, cat := range categories { + if cat.Type == CategoryTypePBS { + return true + } + // Some common categories (e.g. SSL) include PBS paths that require restarting PBS services. + for _, p := range cat.Paths { + p = strings.TrimSpace(p) + if strings.HasPrefix(p, "./etc/proxmox-backup/") || strings.HasPrefix(p, "./var/lib/proxmox-backup/") { + return true + } + } + } + return false +} + +func splitExportCategories(categories []Category) (normal []Category, export []Category) { + for _, cat := range categories { + if cat.ExportOnly { + export = append(export, cat) + continue + } + normal = append(normal, cat) + } + return normal, export +} + +// redirectClusterCategoryToExport removes pve_cluster from normal categories and adds it to export-only list. +func redirectClusterCategoryToExport(normal []Category, export []Category) ([]Category, []Category) { + filtered := make([]Category, 0, len(normal)) + for _, cat := range normal { + if cat.ID == "pve_cluster" { + export = append(export, cat) + continue + } + filtered = append(filtered, cat) + } + return filtered, export +} + +func exportDestRoot(baseDir string) string { + base := strings.TrimSpace(baseDir) + if base == "" { + base = "/opt/proxsave" + } + return filepath.Join(base, fmt.Sprintf("proxmox-config-export-%s", nowRestore().Format("20060102-150405"))) +} + +// runFullRestore performs a full restore without selective options (fallback) +func runFullRestore(ctx context.Context, reader *bufio.Reader, candidate *backupCandidate, prepared *preparedBundle, destRoot string, logger *logging.Logger, dryRun bool) error { + if err := confirmRestoreAction(ctx, reader, candidate, destRoot); err != nil { + return err + } + + safeFstabMerge := destRoot == "/" && isRealRestoreFS(restoreFS) + if safeFstabMerge { + logger.Warning("Full restore safety: /etc/fstab will not be overwritten; Smart Merge will be applied after extraction.") + } + + if err := extractPlainArchive(ctx, prepared.ArchivePath, destRoot, logger, fullRestoreSkipFn(safeFstabMerge)); err != nil { + return err + } + + if safeFstabMerge { + if err := runFullRestoreFstabMerge(ctx, reader, prepared.ArchivePath, destRoot, logger, dryRun); err != nil { + return err + } + } + + logger.Info("Restore completed successfully.") + return nil +} + +func fullRestoreSkipFn(safeFstabMerge bool) func(name string) bool { + return func(name string) bool { + if !safeFstabMerge { + return false + } + clean := strings.TrimPrefix(strings.TrimSpace(name), "./") + clean = strings.TrimPrefix(clean, "/") + return clean == "etc/fstab" + } +} + +func runFullRestoreFstabMerge(ctx context.Context, reader *bufio.Reader, archivePath, destRoot string, logger *logging.Logger, dryRun bool) error { + logger.Info("") + fsTempDir, err := restoreFS.MkdirTemp("", "proxsave-fstab-") + if err != nil { + logger.Warning("Failed to create temp dir for fstab merge: %v", err) + return nil + } + defer restoreFS.RemoveAll(fsTempDir) + + if err := extractFullRestoreFstab(ctx, archivePath, fsTempDir, logger); err != nil { + logger.Warning("Failed to extract filesystem config for merge: %v", err) + return nil + } + extractFullRestoreFstabInventory(ctx, archivePath, fsTempDir, logger) + currentFstab := filepath.Join(destRoot, "etc", "fstab") + backupFstab := filepath.Join(fsTempDir, "etc", "fstab") + if err := SmartMergeFstab(ctx, logger, reader, currentFstab, backupFstab, dryRun); err != nil { + if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) { + logger.Info("Restore aborted by user during Smart Filesystem Configuration Merge.") + return err + } + logger.Warning("Smart Fstab Merge failed: %v", err) + } + return nil +} + +func extractFullRestoreFstab(ctx context.Context, archivePath, fsTempDir string, logger *logging.Logger) error { + return extractArchiveNative(ctx, restoreArchiveOptions{ + archivePath: archivePath, + destRoot: fsTempDir, + logger: logger, + categories: []Category{{ + ID: "filesystem", + Name: "Filesystem Configuration", + Paths: []string{"./etc/fstab"}, + }}, + mode: RestoreModeCustom, + }) +} + +func extractFullRestoreFstabInventory(ctx context.Context, archivePath, fsTempDir string, logger *logging.Logger) { + invCategory := []Category{{ + ID: "fstab_inventory", + Name: "Fstab inventory (device mapping)", + Paths: []string{ + "./var/lib/proxsave-info/commands/system/blkid.txt", + "./var/lib/proxsave-info/commands/system/lsblk_json.json", + "./var/lib/proxsave-info/commands/system/lsblk.txt", + "./var/lib/proxsave-info/commands/pbs/pbs_datastore_inventory.json", + }, + }} + if err := extractArchiveNative(ctx, restoreArchiveOptions{ + archivePath: archivePath, + destRoot: fsTempDir, + logger: logger, + categories: invCategory, + mode: RestoreModeCustom, + }); err != nil { + logger.Debug("Failed to extract fstab inventory data (continuing): %v", err) + } +} + +func confirmRestoreAction(ctx context.Context, reader *bufio.Reader, cand *backupCandidate, dest string) error { + manifest := cand.Manifest + fmt.Println() + fmt.Printf("Selected backup: %s (%s)\n", cand.DisplayBase, manifest.CreatedAt.Format("2006-01-02 15:04:05")) + cleanDest := filepath.Clean(strings.TrimSpace(dest)) + if cleanDest == "" || cleanDest == "." { + cleanDest = string(os.PathSeparator) + } + if cleanDest == string(os.PathSeparator) { + fmt.Println("Restore destination: / (system root; original paths will be preserved)") + fmt.Println("WARNING: This operation will overwrite configuration files on this system.") + } else { + fmt.Printf("Restore destination: %s (original paths will be preserved under this directory)\n", cleanDest) + fmt.Printf("WARNING: This operation will overwrite existing files under %s.\n", cleanDest) + } + fmt.Println("Type RESTORE to proceed or 0 to cancel.") + + for { + fmt.Print("Confirmation: ") + line, err := input.ReadLineWithContext(ctx, reader) + if err != nil { + return err + } + switch strings.TrimSpace(line) { + case "RESTORE": + return nil + case "0": + return ErrRestoreAborted + default: + fmt.Println("Please type RESTORE to confirm or 0 to cancel.") + } + } +} + +func extractPlainArchive(ctx context.Context, archivePath, destRoot string, logger *logging.Logger, skipFn func(entryName string) bool) error { + if err := restoreFS.MkdirAll(destRoot, 0o755); err != nil { + return fmt.Errorf("create destination directory: %w", err) + } + + // Only enforce root privileges when writing to the real system root. + if destRoot == "/" && isRealRestoreFS(restoreFS) && os.Geteuid() != 0 { + return fmt.Errorf("restore to %s requires root privileges", destRoot) + } + + logger.Info("Extracting archive %s into %s", filepath.Base(archivePath), destRoot) + + // Use native Go extraction to preserve atime/ctime from PAX headers + if err := extractArchiveNative(ctx, restoreArchiveOptions{ + archivePath: archivePath, + destRoot: destRoot, + logger: logger, + mode: RestoreModeFull, + skipFn: skipFn, + }); err != nil { + return fmt.Errorf("archive extraction failed: %w", err) + } + + return nil +} + +// extractSelectiveArchive extracts only files matching selected categories +func extractSelectiveArchive(ctx context.Context, archivePath, destRoot string, categories []Category, mode RestoreMode, logger *logging.Logger) (logPath string, err error) { + done := logging.DebugStart(logger, "extract selective archive", "archive=%s dest=%s categories=%d mode=%s", archivePath, destRoot, len(categories), mode) + defer func() { done(err) }() + if err := restoreFS.MkdirAll(destRoot, 0o755); err != nil { + return "", fmt.Errorf("create destination directory: %w", err) + } + + // Only enforce root privileges when writing to the real system root. + if destRoot == "/" && isRealRestoreFS(restoreFS) && os.Geteuid() != 0 { + return "", fmt.Errorf("restore to %s requires root privileges", destRoot) + } + + // Create detailed log directory + logDir := "/tmp/proxsave" + if err := restoreFS.MkdirAll(logDir, 0755); err != nil { + logger.Warning("Could not create log directory: %v", err) + } + + // Create detailed log file + timestamp := nowRestore().Format("20060102_150405") + logSeq := atomic.AddUint64(&restoreLogSequence, 1) + logPath = filepath.Join(logDir, fmt.Sprintf("restore_%s_%d.log", timestamp, logSeq)) + logFile, err := restoreFS.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0640) + if err != nil { + logger.Warning("Could not create detailed log file: %v", err) + logFile = nil + } else { + defer logFile.Close() + logger.Info("Detailed restore log: %s", logPath) + logging.DebugStep(logger, "extract selective archive", "log file=%s", logPath) + } + + logger.Info("Extracting selected categories from archive %s into %s", filepath.Base(archivePath), destRoot) + + // Use native Go extraction with category filter + if err := extractArchiveNative(ctx, restoreArchiveOptions{ + archivePath: archivePath, + destRoot: destRoot, + logger: logger, + categories: categories, + mode: mode, + logFile: logFile, + logFilePath: logPath, + }); err != nil { + return logPath, err + } + + return logPath, nil +} + +func isRealRestoreFS(fs FS) bool { + switch fs.(type) { + case osFS, *osFS: + return true + default: + return false + } +} + +// getModeName returns a human-readable name for the restore mode +func getModeName(mode RestoreMode) string { + switch mode { + case RestoreModeFull: + return "FULL restore (all files)" + case RestoreModeStorage: + return "STORAGE/DATASTORE only" + case RestoreModeBase: + return "SYSTEM BASE only" + case RestoreModeCustom: + return "CUSTOM selection" + default: + return "Unknown mode" + } +} diff --git a/internal/orchestrator/restore_archive_entries.go b/internal/orchestrator/restore_archive_entries.go new file mode 100644 index 00000000..f77e4946 --- /dev/null +++ b/internal/orchestrator/restore_archive_entries.go @@ -0,0 +1,279 @@ +// Package orchestrator coordinates backup, restore, decrypt, and related workflows. +package orchestrator + +import ( + "archive/tar" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "syscall" + + "github.com/tis24dev/proxsave/internal/logging" +) + +const restoreTempPattern = ".proxsave-tmp-*" + +// extractTarEntry extracts a single TAR entry, preserving all attributes including atime/ctime +func extractTarEntry(tarReader *tar.Reader, header *tar.Header, destRoot string, logger *logging.Logger) error { + target, cleanDestRoot, err := sanitizeRestoreEntryTargetWithFS(restoreFS, destRoot, header.Name) + if err != nil { + return err + } + + skip, err := shouldSkipRestoreEntryTarget(header, target, cleanDestRoot, logger) + if err != nil { + return err + } + if skip { + return nil + } + + // Create parent directories + if err := restoreFS.MkdirAll(filepath.Dir(target), 0755); err != nil { + return fmt.Errorf("create parent directory: %w", err) + } + + return extractTypedTarEntry(tarReader, header, target, cleanDestRoot, logger) +} + +func shouldSkipRestoreEntryTarget(header *tar.Header, target, cleanDestRoot string, logger *logging.Logger) (bool, error) { + if cleanDestRoot != string(os.PathSeparator) { + return false, nil + } + // Hard guard: never write directly into /etc/pve when restoring to system root + if strings.HasPrefix(target, "/etc/pve") { + logger.Warning("Skipping restore to %s (writes to /etc/pve are prohibited)", target) + return true, nil + } + relTarget, err := filepath.Rel(cleanDestRoot, target) + if err != nil { + return false, fmt.Errorf("determine restore target for %s: %w", header.Name, err) + } + if skip, reason := shouldSkipProxmoxSystemRestore(relTarget); skip { + logger.Warning("Skipping restore to %s (%s)", target, reason) + return true, nil + } + return false, nil +} + +func extractTypedTarEntry(tarReader *tar.Reader, header *tar.Header, target, cleanDestRoot string, logger *logging.Logger) error { + switch header.Typeflag { + case tar.TypeDir: + return extractDirectory(target, header, logger) + case tar.TypeReg: + return extractRegularFile(tarReader, target, header, logger) + case tar.TypeSymlink: + return extractSymlink(target, header, cleanDestRoot, logger) + case tar.TypeLink: + return extractHardlink(target, header, cleanDestRoot) + default: + logger.Debug("Skipping unsupported file type %d: %s", header.Typeflag, header.Name) + return nil + } +} + +// extractDirectory creates a directory with proper permissions and timestamps +func extractDirectory(target string, header *tar.Header, logger *logging.Logger) (retErr error) { + // Create with an owner-accessible mode first so the directory can be opened + // before applying restrictive archive permissions. + if err := restoreFS.MkdirAll(target, 0o700); err != nil { + return fmt.Errorf("create directory: %w", err) + } + + dirFile, err := restoreFS.Open(target) + if err != nil { + return fmt.Errorf("open directory: %w", err) + } + defer func() { + if dirFile == nil { + return + } + if err := dirFile.Close(); err != nil && retErr == nil { + retErr = fmt.Errorf("close directory: %w", err) + } + }() + + // Apply metadata on the opened directory handle so logical FS paths + // (e.g. FakeFS-backed test roots) do not leak through to host paths. + // Ownership remains best-effort to match the previous restore behavior on + // unprivileged runs and filesystems that do not support chown. + if err := atomicFileChown(dirFile, header.Uid, header.Gid); err != nil { + logger.Debug("Failed to chown directory %s: %v", target, err) + } + if err := atomicFileChmod(dirFile, os.FileMode(header.Mode)); err != nil { + return fmt.Errorf("chmod directory: %w", err) + } + + // Set timestamps (mtime, atime) + if err := setTimestamps(target, header); err != nil { + logger.Debug("Failed to set timestamps on directory %s: %v", target, err) + } + + return nil +} + +// extractRegularFile extracts a regular file with content and timestamps +func extractRegularFile(tarReader *tar.Reader, target string, header *tar.Header, logger *logging.Logger) (retErr error) { + tmpPath := "" + var outFile *os.File + appendDeferredErr := func(prefix string, err error) { + if err == nil { + return + } + wrapped := fmt.Errorf("%s: %w", prefix, err) + if retErr == nil { + retErr = wrapped + return + } + retErr = errors.Join(retErr, wrapped) + } + closeOutFile := func() error { + if outFile == nil { + return nil + } + err := outFile.Close() + outFile = nil + return err + } + + // Write to a sibling temp file first so a truncated archive entry cannot clobber + // an existing target before the content is fully copied and closed. + outFile, err := restoreFS.CreateTemp(filepath.Dir(target), restoreTempPattern) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + tmpPath = outFile.Name() + defer func() { + appendDeferredErr("close file", closeOutFile()) + if tmpPath != "" { + if err := restoreFS.Remove(tmpPath); err != nil && logger != nil { + logger.Debug("Failed to remove temp file %s: %v", tmpPath, err) + } + } + }() + + // Copy content + if _, err := io.Copy(outFile, tarReader); err != nil { + return fmt.Errorf("write file content: %w", err) + } + + // Set metadata on the temp file before replacing the target so failures do not + // leave the final path in a partially restored state. + // Ownership remains best-effort to match the previous restore behavior on + // unprivileged runs and filesystems that do not support chown. + if err := atomicFileChown(outFile, header.Uid, header.Gid); err != nil { + logger.Debug("Failed to chown file %s: %v", target, err) + } + if err := atomicFileChmod(outFile, os.FileMode(header.Mode)); err != nil { + return fmt.Errorf("chmod file: %w", err) + } + + // Close before renaming into place. + if err := closeOutFile(); err != nil { + return fmt.Errorf("close file: %w", err) + } + + if err := restoreFS.Rename(tmpPath, target); err != nil { + return fmt.Errorf("replace file: %w", err) + } + tmpPath = "" + + // Set timestamps (mtime, atime, ctime via syscall) + if err := setTimestamps(target, header); err != nil { + logger.Debug("Failed to set timestamps on file %s: %v", target, err) + } + + return nil +} + +// extractSymlink creates a symbolic link +func extractSymlink(target string, header *tar.Header, destRoot string, logger *logging.Logger) error { + linkTarget := header.Linkname + + // Pre-validation: ensure the symlink target resolves within destRoot before creation. + if _, err := resolvePathRelativeToBaseWithinRootFS(restoreFS, destRoot, filepath.Dir(target), linkTarget); err != nil { + return fmt.Errorf("symlink target escapes root before creation: %s -> %s: %w", header.Name, linkTarget, err) + } + + // Remove existing file/link if it exists + _ = restoreFS.Remove(target) + + // Create symlink + if err := restoreFS.Symlink(linkTarget, target); err != nil { + return fmt.Errorf("create symlink: %w", err) + } + + // POST-CREATION VALIDATION: Verify the created symlink's target stays within destRoot + actualTarget, err := restoreFS.Readlink(target) + if err != nil { + restoreFS.Remove(target) // Clean up + return fmt.Errorf("read created symlink %s: %w", target, err) + } + + if _, err := resolvePathRelativeToBaseWithinRootFS(restoreFS, destRoot, filepath.Dir(target), actualTarget); err != nil { + restoreFS.Remove(target) + return fmt.Errorf("symlink target escapes root after creation: %s -> %s: %w", header.Name, actualTarget, err) + } + + // Set ownership (on the symlink itself, not the target) + if err := os.Lchown(target, header.Uid, header.Gid); err != nil { + logger.Debug("Failed to lchown symlink %s: %v", target, err) + } + + // Note: timestamps on symlinks are not typically preserved + return nil +} + +// extractHardlink creates a hard link +func extractHardlink(target string, header *tar.Header, destRoot string) error { + // Validate hard link target + linkName := header.Linkname + + // Reject absolute hard link targets immediately + if filepath.IsAbs(linkName) { + return fmt.Errorf("absolute hardlink target not allowed: %s", linkName) + } + + // Validate the hard link target stays within extraction root + if _, err := resolvePathWithinRootFS(restoreFS, destRoot, linkName); err != nil { + return fmt.Errorf("hardlink target escapes root: %s -> %s: %w", header.Name, linkName, err) + } + + linkTarget := filepath.Join(destRoot, linkName) + + // Remove existing file/link if it exists + _ = restoreFS.Remove(target) + + // Create hard link + if err := restoreFS.Link(linkTarget, target); err != nil { + return fmt.Errorf("create hardlink: %w", err) + } + + return nil +} + +// setTimestamps sets atime, mtime, and attempts to set ctime via syscall +func setTimestamps(target string, header *tar.Header) error { + // Convert times to Unix format + atime := header.AccessTime + mtime := header.ModTime + + // Use syscall.UtimesNano to set atime and mtime with nanosecond precision + times := []syscall.Timespec{ + {Sec: atime.Unix(), Nsec: int64(atime.Nanosecond())}, + {Sec: mtime.Unix(), Nsec: int64(mtime.Nanosecond())}, + } + + if err := syscall.UtimesNano(target, times); err != nil { + return fmt.Errorf("set atime/mtime: %w", err) + } + + // Note: ctime (change time) cannot be set directly by user-space programs + // It is automatically updated by the kernel when file metadata changes + // The header.ChangeTime is stored in PAX but cannot be restored + + return nil +} diff --git a/internal/orchestrator/restore_archive_extract.go b/internal/orchestrator/restore_archive_extract.go new file mode 100644 index 00000000..f8964cee --- /dev/null +++ b/internal/orchestrator/restore_archive_extract.go @@ -0,0 +1,241 @@ +// Package orchestrator coordinates backup, restore, decrypt, and related workflows. +package orchestrator + +import ( + "archive/tar" + "context" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/tis24dev/proxsave/internal/logging" +) + +type restoreArchiveOptions struct { + archivePath string + destRoot string + logger *logging.Logger + categories []Category + mode RestoreMode + logFile *os.File + logFilePath string + skipFn func(entryName string) bool +} + +type restoreExtractionStats struct { + filesExtracted int + filesSkipped int + filesFailed int +} + +type restoreExtractionLog struct { + logger *logging.Logger + logFile *os.File + logFilePath string + restoredTemp *os.File + skippedTemp *os.File +} + +// extractArchiveNative extracts TAR archives natively in Go, preserving all timestamps. +func extractArchiveNative(ctx context.Context, opts restoreArchiveOptions) error { + file, err := restoreFS.Open(opts.archivePath) + if err != nil { + return fmt.Errorf("open archive: %w", err) + } + defer file.Close() + + reader, err := createDecompressionReader(ctx, file, opts.archivePath) + if err != nil { + return fmt.Errorf("create decompression reader: %w", err) + } + if closer, ok := reader.(io.Closer); ok { + defer closer.Close() + } + + extractionLog := newRestoreExtractionLog(opts) + defer extractionLog.close() + extractionLog.writeHeader(opts) + + stats, err := processRestoreArchiveEntries(ctx, tar.NewReader(reader), opts, extractionLog) + if err != nil { + return err + } + + extractionLog.writeSummary(stats) + logRestoreExtractionSummary(opts, stats) + return nil +} + +func newRestoreExtractionLog(opts restoreArchiveOptions) *restoreExtractionLog { + extractionLog := &restoreExtractionLog{ + logger: opts.logger, + logFile: opts.logFile, + logFilePath: opts.logFilePath, + } + if opts.logFile == nil { + return extractionLog + } + + if tmp, err := restoreFS.CreateTemp("", "restored_entries_*.log"); err == nil { + extractionLog.restoredTemp = tmp + } else { + opts.logger.Warning("Could not create temporary file for restored entries: %v", err) + } + if tmp, err := restoreFS.CreateTemp("", "skipped_entries_*.log"); err == nil { + extractionLog.skippedTemp = tmp + } else { + opts.logger.Warning("Could not create temporary file for skipped entries: %v", err) + } + return extractionLog +} + +func (log *restoreExtractionLog) close() { + closeAndRemoveRestoreTemp(log.restoredTemp) + closeAndRemoveRestoreTemp(log.skippedTemp) +} + +func closeAndRemoveRestoreTemp(file *os.File) { + if file == nil { + return + } + file.Close() + _ = restoreFS.Remove(file.Name()) +} + +func (log *restoreExtractionLog) writeHeader(opts restoreArchiveOptions) { + if log.logFile == nil { + return + } + fmt.Fprintf(log.logFile, "=== PROXMOX RESTORE LOG ===\n") + fmt.Fprintf(log.logFile, "Date: %s\n", nowRestore().Format("2006-01-02 15:04:05")) + fmt.Fprintf(log.logFile, "Mode: %s\n", getModeName(opts.mode)) + if len(opts.categories) > 0 { + fmt.Fprintf(log.logFile, "Selected categories: %d categories\n", len(opts.categories)) + for _, cat := range opts.categories { + fmt.Fprintf(log.logFile, " - %s (%s)\n", cat.Name, cat.ID) + } + } else { + fmt.Fprintf(log.logFile, "Selected categories: ALL (full restore)\n") + } + fmt.Fprintf(log.logFile, "Archive: %s\n", filepath.Base(opts.archivePath)) + fmt.Fprintf(log.logFile, "\n") +} + +func processRestoreArchiveEntries(ctx context.Context, tarReader *tar.Reader, opts restoreArchiveOptions, extractionLog *restoreExtractionLog) (restoreExtractionStats, error) { + var stats restoreExtractionStats + selectiveMode := len(opts.categories) > 0 + for { + if err := ctx.Err(); err != nil { + return stats, err + } + + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return stats, fmt.Errorf("read tar header: %w", err) + } + + if skipRestoreArchiveEntry(header, opts, selectiveMode, extractionLog, &stats) { + continue + } + if err := extractTarEntry(tarReader, header, opts.destRoot, opts.logger); err != nil { + opts.logger.Warning("Failed to extract %s: %v", header.Name, err) + stats.filesFailed++ + continue + } + + stats.filesExtracted++ + extractionLog.recordRestored(header.Name) + if stats.filesExtracted%100 == 0 { + opts.logger.Debug("Extracted %d files...", stats.filesExtracted) + } + } + return stats, nil +} + +func skipRestoreArchiveEntry(header *tar.Header, opts restoreArchiveOptions, selectiveMode bool, extractionLog *restoreExtractionLog, stats *restoreExtractionStats) bool { + if opts.skipFn != nil && opts.skipFn(header.Name) { + stats.filesSkipped++ + extractionLog.recordSkipped(header.Name, "skipped by restore policy") + return true + } + if !selectiveMode || restoreEntryMatchesCategories(header.Name, opts.categories) { + return false + } + stats.filesSkipped++ + extractionLog.recordSkipped(header.Name, "does not match any selected category") + return true +} + +func restoreEntryMatchesCategories(entryName string, categories []Category) bool { + for _, cat := range categories { + if PathMatchesCategory(entryName, cat) { + return true + } + } + return false +} + +func (log *restoreExtractionLog) recordSkipped(name, reason string) { + if log.skippedTemp != nil { + fmt.Fprintf(log.skippedTemp, "SKIPPED: %s (%s)\n", name, reason) + } +} + +func (log *restoreExtractionLog) recordRestored(name string) { + if log.restoredTemp != nil { + fmt.Fprintf(log.restoredTemp, "RESTORED: %s\n", name) + } +} + +func (log *restoreExtractionLog) writeSummary(stats restoreExtractionStats) { + if log.logFile == nil { + return + } + fmt.Fprintf(log.logFile, "=== FILES RESTORED ===\n") + log.copyTempEntries(log.restoredTemp, "restored") + fmt.Fprintf(log.logFile, "\n") + + fmt.Fprintf(log.logFile, "=== FILES SKIPPED ===\n") + log.copyTempEntries(log.skippedTemp, "skipped") + fmt.Fprintf(log.logFile, "\n") + + fmt.Fprintf(log.logFile, "=== SUMMARY ===\n") + fmt.Fprintf(log.logFile, "Total files extracted: %d\n", stats.filesExtracted) + fmt.Fprintf(log.logFile, "Total files skipped: %d\n", stats.filesSkipped) + fmt.Fprintf(log.logFile, "Total files in archive: %d\n", stats.filesExtracted+stats.filesSkipped) +} + +func (log *restoreExtractionLog) copyTempEntries(tempFile *os.File, label string) { + if tempFile == nil { + return + } + if _, err := tempFile.Seek(0, 0); err == nil { + if _, err := io.Copy(log.logFile, tempFile); err != nil { + log.logger.Warning("Could not write %s entries to log: %v", label, err) + } + } +} + +func logRestoreExtractionSummary(opts restoreArchiveOptions, stats restoreExtractionStats) { + if stats.filesFailed == 0 { + if len(opts.categories) > 0 { + opts.logger.Info("Successfully restored all %d configuration files/directories", stats.filesExtracted) + } else { + opts.logger.Info("Successfully restored all %d files/directories", stats.filesExtracted) + } + } else { + opts.logger.Warning("Restored %d files/directories; %d item(s) failed (see detailed log)", stats.filesExtracted, stats.filesFailed) + } + + if stats.filesSkipped > 0 { + opts.logger.Info("%d additional archive entries (logs, diagnostics, system defaults) were left unchanged on this system; see detailed log for details", stats.filesSkipped) + } + + if opts.logFilePath != "" { + opts.logger.Info("Detailed restore log: %s", opts.logFilePath) + } +} diff --git a/internal/orchestrator/restore_archive_paths.go b/internal/orchestrator/restore_archive_paths.go new file mode 100644 index 00000000..8c9840f7 --- /dev/null +++ b/internal/orchestrator/restore_archive_paths.go @@ -0,0 +1,115 @@ +// Package orchestrator coordinates backup, restore, decrypt, and related workflows. +package orchestrator + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" +) + +func sanitizeRestoreEntryTarget(destRoot, entryName string) (string, string, error) { + return sanitizeRestoreEntryTargetWithFS(restoreFS, destRoot, entryName) +} + +func sanitizeRestoreEntryTargetWithFS(fsys FS, destRoot, entryName string) (string, string, error) { + absDestRoot, err := resolveRestoreDestRoot(destRoot) + if err != nil { + return "", "", fmt.Errorf("resolve destination root: %w", err) + } + + sanitized, err := normalizeRestoreEntryName(entryName) + if err != nil { + return "", "", err + } + absTarget, err := resolveRestoreEntryTarget(absDestRoot, sanitized) + if err != nil { + return "", "", fmt.Errorf("resolve extraction target: %w", err) + } + if err := ensureRestoreTargetWithinRoot(absDestRoot, absTarget, entryName); err != nil { + return "", "", err + } + if err := ensureRestoreTargetResolverAllows(fsys, absDestRoot, absTarget, entryName); err != nil { + return "", "", err + } + + return absTarget, absDestRoot, nil +} + +func resolveRestoreDestRoot(destRoot string) (string, error) { + cleanDestRoot := filepath.Clean(destRoot) + if cleanDestRoot == "" { + cleanDestRoot = string(os.PathSeparator) + } + return filepath.Abs(cleanDestRoot) +} + +func normalizeRestoreEntryName(entryName string) (string, error) { + name := strings.TrimSpace(entryName) + if name == "" { + return "", fmt.Errorf("empty archive entry name") + } + sanitized := path.Clean(name) + for strings.HasPrefix(sanitized, string(os.PathSeparator)) { + sanitized = strings.TrimPrefix(sanitized, string(os.PathSeparator)) + } + if sanitized == "" || sanitized == "." { + return "", fmt.Errorf("invalid archive entry name: %q", entryName) + } + if sanitized == ".." || strings.HasPrefix(sanitized, "../") || strings.Contains(sanitized, "/../") { + return "", fmt.Errorf("illegal path: %s", entryName) + } + return sanitized, nil +} + +func resolveRestoreEntryTarget(absDestRoot, sanitized string) (string, error) { + target := filepath.Join(absDestRoot, filepath.FromSlash(sanitized)) + return filepath.Abs(target) +} + +func ensureRestoreTargetWithinRoot(absDestRoot, absTarget, entryName string) error { + rel, err := filepath.Rel(absDestRoot, absTarget) + if err != nil { + return fmt.Errorf("illegal path: %s: %w", entryName, err) + } + if strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || rel == ".." || filepath.IsAbs(rel) { + return fmt.Errorf("illegal path: %s", entryName) + } + return nil +} + +func ensureRestoreTargetResolverAllows(fsys FS, absDestRoot, absTarget, entryName string) error { + if _, err := resolvePathWithinRootFS(fsys, absDestRoot, absTarget); err != nil { + if isPathSecurityError(err) { + return fmt.Errorf("illegal path: %s: %w", entryName, err) + } + if !isPathOperationalError(err) { + return fmt.Errorf("resolve extraction target: %w", err) + } + } + return nil +} + +func shouldSkipProxmoxSystemRestore(relTarget string) (bool, string) { + rel := filepath.ToSlash(filepath.Clean(strings.TrimSpace(relTarget))) + rel = strings.TrimPrefix(rel, "./") + rel = strings.TrimPrefix(rel, "/") + + switch rel { + case "etc/proxmox-backup/domains.cfg": + return true, "PBS auth realms must be recreated (domains.cfg is too fragile to restore raw)" + case "etc/proxmox-backup/user.cfg": + return true, "PBS users must be recreated (user.cfg should not be restored raw)" + case "etc/proxmox-backup/acl.cfg": + return true, "PBS permissions must be recreated (acl.cfg should not be restored raw)" + case "var/lib/proxmox-backup/.clusterlock": + return true, "PBS runtime lock files must not be restored" + } + + if strings.HasPrefix(rel, "var/lib/proxmox-backup/lock/") { + return true, "PBS runtime lock files must not be restored" + } + + return false, "" +} diff --git a/internal/orchestrator/restore_cluster_apply.go b/internal/orchestrator/restore_cluster_apply.go new file mode 100644 index 00000000..3445ba09 --- /dev/null +++ b/internal/orchestrator/restore_cluster_apply.go @@ -0,0 +1,366 @@ +// Package orchestrator coordinates backup, restore, decrypt, and related workflows. +package orchestrator + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/tis24dev/proxsave/internal/input" + "github.com/tis24dev/proxsave/internal/logging" +) + +// runSafeClusterApply applies selected cluster configs via pvesh without touching config.db. +// It operates on files extracted to exportRoot (e.g. exportDestRoot). +func runSafeClusterApply(ctx context.Context, reader *bufio.Reader, exportRoot string, logger *logging.Logger) (err error) { + if logger == nil { + logger = logging.GetDefaultLogger() + } + ui := newCLIWorkflowUI(reader, logger) + return runSafeClusterApplyWithUI(ctx, ui, exportRoot, logger, nil) +} + +type vmEntry struct { + VMID string + Kind string // qemu | lxc + Name string + Path string +} + +func scanVMConfigs(exportRoot, node string) ([]vmEntry, error) { + var entries []vmEntry + base := filepath.Join(exportRoot, "etc/pve/nodes", node) + + type dirSpec struct { + kind string + path string + } + + dirs := []dirSpec{ + {kind: "qemu", path: filepath.Join(base, "qemu-server")}, + {kind: "lxc", path: filepath.Join(base, "lxc")}, + } + + for _, spec := range dirs { + infos, err := restoreFS.ReadDir(spec.path) + if err != nil { + continue + } + for _, entry := range infos { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(name, ".conf") { + continue + } + vmid := strings.TrimSuffix(name, ".conf") + vmPath := filepath.Join(spec.path, name) + vmName := readVMName(vmPath) + entries = append(entries, vmEntry{ + VMID: vmid, + Kind: spec.kind, + Name: vmName, + Path: vmPath, + }) + } + } + + return entries, nil +} + +func listExportNodeDirs(exportRoot string) ([]string, error) { + nodesRoot := filepath.Join(exportRoot, "etc/pve/nodes") + entries, err := restoreFS.ReadDir(nodesRoot) + if err != nil { + if errors.Is(err, os.ErrNotExist) || os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var nodes []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := strings.TrimSpace(entry.Name()) + if name == "" { + continue + } + nodes = append(nodes, name) + } + sort.Strings(nodes) + return nodes, nil +} + +func countVMConfigsForNode(exportRoot, node string) (qemuCount, lxcCount int) { + base := filepath.Join(exportRoot, "etc/pve/nodes", node) + + countInDir := func(dir string) int { + entries, err := restoreFS.ReadDir(dir) + if err != nil { + return 0 + } + n := 0 + for _, entry := range entries { + if entry.IsDir() { + continue + } + if strings.HasSuffix(entry.Name(), ".conf") { + n++ + } + } + return n + } + + qemuCount = countInDir(filepath.Join(base, "qemu-server")) + lxcCount = countInDir(filepath.Join(base, "lxc")) + return qemuCount, lxcCount +} + +func promptExportNodeSelection(ctx context.Context, reader *bufio.Reader, exportRoot, currentNode string, exportNodes []string) (string, error) { + for { + fmt.Println() + fmt.Printf("WARNING: VM/CT configs in this backup are stored under different node names.\n") + fmt.Printf("Current node: %s\n", currentNode) + fmt.Println("Select which exported node to import VM/CT configs from (they will be applied to the current node):") + for idx, node := range exportNodes { + qemuCount, lxcCount := countVMConfigsForNode(exportRoot, node) + fmt.Printf(" [%d] %s (qemu=%d, lxc=%d)\n", idx+1, node, qemuCount, lxcCount) + } + fmt.Println(" [0] Skip VM/CT apply") + + fmt.Print("Choice: ") + line, err := input.ReadLineWithContext(ctx, reader) + if err != nil { + return "", err + } + trimmed := strings.TrimSpace(line) + if trimmed == "0" { + return "", nil + } + if trimmed == "" { + continue + } + idx, err := parseMenuIndex(trimmed, len(exportNodes)) + if err != nil { + fmt.Println(err) + continue + } + return exportNodes[idx], nil + } +} + +func stringSliceContains(items []string, want string) bool { + for _, item := range items { + if item == want { + return true + } + } + return false +} + +func readVMName(confPath string) string { + data, err := restoreFS.ReadFile(confPath) + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + t := strings.TrimSpace(line) + if strings.HasPrefix(t, "name:") { + return strings.TrimSpace(strings.TrimPrefix(t, "name:")) + } + if strings.HasPrefix(t, "hostname:") { + return strings.TrimSpace(strings.TrimPrefix(t, "hostname:")) + } + } + return "" +} + +func applyVMConfigs(ctx context.Context, entries []vmEntry, logger *logging.Logger) (applied, failed int) { + for _, vm := range entries { + if err := ctx.Err(); err != nil { + logger.Warning("VM apply aborted: %v", err) + return applied, failed + } + target := fmt.Sprintf("/nodes/%s/%s/%s/config", detectNodeForVM(), vm.Kind, vm.VMID) + args := []string{"set", target, "--filename", vm.Path} + if err := runPvesh(ctx, logger, args); err != nil { + logger.Warning("Failed to apply %s (vmid=%s kind=%s): %v", target, vm.VMID, vm.Kind, err) + failed++ + } else { + display := vm.VMID + if vm.Name != "" { + display = fmt.Sprintf("%s (%s)", vm.VMID, vm.Name) + } + logger.Info("Applied VM/CT config %s", display) + applied++ + } + } + return applied, failed +} + +func detectNodeForVM() string { + host, _ := os.Hostname() + host = shortHost(host) + if host != "" { + return host + } + return "localhost" +} + +type storageBlock struct { + ID string + data []string +} + +func applyStorageCfg(ctx context.Context, cfgPath string, logger *logging.Logger) (applied, failed int, err error) { + blocks, perr := parseStorageBlocks(cfgPath) + if perr != nil { + return 0, 0, perr + } + if len(blocks) == 0 { + logger.Info("No storage definitions detected in storage.cfg") + return 0, 0, nil + } + + for _, blk := range blocks { + tmp, tmpErr := restoreFS.CreateTemp("", fmt.Sprintf("pve-storage-%s-*.cfg", sanitizeID(blk.ID))) + if tmpErr != nil { + failed++ + continue + } + tmpName := tmp.Name() + if _, werr := tmp.WriteString(strings.Join(blk.data, "\n") + "\n"); werr != nil { + _ = tmp.Close() + _ = restoreFS.Remove(tmpName) + failed++ + continue + } + _ = tmp.Close() + + args := []string{"set", fmt.Sprintf("/cluster/storage/%s", blk.ID), "-conf", tmpName} + if runErr := runPvesh(ctx, logger, args); runErr != nil { + logger.Warning("Failed to apply storage %s: %v", blk.ID, runErr) + failed++ + } else { + logger.Info("Applied storage definition %s", blk.ID) + applied++ + } + + _ = restoreFS.Remove(tmpName) + + if err := ctx.Err(); err != nil { + return applied, failed, err + } + } + + return applied, failed, nil +} + +func parseStorageBlocks(cfgPath string) ([]storageBlock, error) { + data, err := restoreFS.ReadFile(cfgPath) + if err != nil { + return nil, err + } + + var blocks []storageBlock + var current *storageBlock + + flush := func() { + if current != nil { + blocks = append(blocks, *current) + current = nil + } + } + + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + flush() + continue + } + + // storage.cfg blocks use `: ` (e.g. `dir: local`, `nfs: backup`). + // Older exports may still use `storage: ` blocks. + _, name, ok := parseProxmoxNotificationHeader(trimmed) + if ok { + flush() + current = &storageBlock{ID: name, data: []string{line}} + continue + } + if current != nil { + current.data = append(current.data, line) + } + } + flush() + + return blocks, nil +} + +func runPvesh(ctx context.Context, logger *logging.Logger, args []string) error { + output, err := restoreCmd.Run(ctx, "pvesh", args...) + if len(output) > 0 { + logger.Debug("pvesh %v output: %s", args, strings.TrimSpace(string(output))) + } + if err != nil { + return fmt.Errorf("pvesh %v failed: %w", args, err) + } + return nil +} + +func shortHost(host string) string { + if idx := strings.Index(host, "."); idx > 0 { + return host[:idx] + } + return host +} + +func sanitizeID(id string) string { + var b strings.Builder + for _, r := range id { + if isSafeIDRune(r) { + b.WriteRune(r) + } else { + b.WriteRune('_') + } + } + return b.String() +} + +func isSafeIDRune(r rune) bool { + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' +} + +// promptClusterRestoreMode asks how to handle cluster database restore (safe export vs full recovery). +func promptClusterRestoreMode(ctx context.Context, reader *bufio.Reader) (int, error) { + fmt.Println() + fmt.Println("Cluster backup detected. Choose how to restore the cluster database:") + fmt.Println(" [1] SAFE: Do NOT write /var/lib/pve-cluster/config.db. Export cluster files only (manual/apply via API).") + fmt.Println(" [2] RECOVERY: Restore full cluster database (/var/lib/pve-cluster). Use only when cluster is offline/isolated.") + fmt.Println(" [0] Exit") + + for { + fmt.Print("Choice: ") + choiceLine, err := input.ReadLineWithContext(ctx, reader) + if err != nil { + return 0, err + } + switch strings.TrimSpace(choiceLine) { + case "1": + return 1, nil + case "2": + return 2, nil + case "0": + return 0, nil + default: + fmt.Println("Please enter 1, 2, or 0.") + } + } +} diff --git a/internal/orchestrator/restore_decompression.go b/internal/orchestrator/restore_decompression.go new file mode 100644 index 00000000..c6fb1d0b --- /dev/null +++ b/internal/orchestrator/restore_decompression.go @@ -0,0 +1,94 @@ +// Package orchestrator coordinates backup, restore, decrypt, and related workflows. +package orchestrator + +import ( + "compress/gzip" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/tis24dev/proxsave/internal/safeexec" +) + +type restoreDecompressionFormat struct { + matches func(string) bool + open func(context.Context, *os.File) (io.Reader, error) +} + +// createDecompressionReader creates appropriate decompression reader based on file extension +func createDecompressionReader(ctx context.Context, file *os.File, archivePath string) (io.Reader, error) { + for _, format := range restoreDecompressionFormats() { + if format.matches(archivePath) { + return format.open(ctx, file) + } + } + return nil, fmt.Errorf("unsupported archive format: %s", filepath.Base(archivePath)) +} + +func restoreDecompressionFormats() []restoreDecompressionFormat { + return []restoreDecompressionFormat{ + { + matches: func(path string) bool { return strings.HasSuffix(path, ".tar.gz") || strings.HasSuffix(path, ".tgz") }, + open: func(_ context.Context, file *os.File) (io.Reader, error) { return gzip.NewReader(file) }, + }, + {matches: func(path string) bool { return strings.HasSuffix(path, ".tar.xz") }, open: createXZReader}, + { + matches: func(path string) bool { + return strings.HasSuffix(path, ".tar.zst") || strings.HasSuffix(path, ".tar.zstd") + }, + open: createZstdReader, + }, + {matches: func(path string) bool { return strings.HasSuffix(path, ".tar.bz2") }, open: createBzip2Reader}, + {matches: func(path string) bool { return strings.HasSuffix(path, ".tar.lzma") }, open: createLzmaReader}, + {matches: func(path string) bool { return strings.HasSuffix(path, ".tar") }, open: func(_ context.Context, file *os.File) (io.Reader, error) { return file, nil }}, + } +} + +// createXZReader creates an XZ decompression reader using injectable command runner +func createXZReader(ctx context.Context, file *os.File) (io.Reader, error) { + return runRestoreCommandStream(ctx, "xz", file, "-d", "-c") +} + +// createZstdReader creates a Zstd decompression reader using injectable command runner +func createZstdReader(ctx context.Context, file *os.File) (io.Reader, error) { + return runRestoreCommandStream(ctx, "zstd", file, "-d", "-c") +} + +// createBzip2Reader creates a Bzip2 decompression reader using injectable command runner +func createBzip2Reader(ctx context.Context, file *os.File) (io.Reader, error) { + return runRestoreCommandStream(ctx, "bzip2", file, "-d", "-c") +} + +// createLzmaReader creates an LZMA decompression reader using injectable command runner +func createLzmaReader(ctx context.Context, file *os.File) (io.Reader, error) { + return runRestoreCommandStream(ctx, "lzma", file, "-d", "-c") +} + +// runRestoreCommandStream starts a command that reads from stdin and exposes stdout as a ReadCloser. +// It prefers an injectable streaming runner when available; otherwise falls back to safeexec. +func runRestoreCommandStream(ctx context.Context, name string, stdin io.Reader, args ...string) (io.Reader, error) { + type streamingRunner interface { + RunStream(ctx context.Context, name string, stdin io.Reader, args ...string) (io.ReadCloser, error) + } + if sr, ok := restoreCmd.(streamingRunner); ok && sr != nil { + return sr.RunStream(ctx, name, stdin, args...) + } + + cmd, err := safeexec.CommandContext(ctx, name, args...) + if err != nil { + return nil, err + } + cmd.Stdin = stdin + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("create %s pipe: %w", name, err) + } + if err := cmd.Start(); err != nil { + stdout.Close() + return nil, fmt.Errorf("start %s: %w", name, err) + } + return &waitReadCloser{ReadCloser: stdout, wait: cmd.Wait}, nil +} diff --git a/internal/orchestrator/restore_errors_test.go b/internal/orchestrator/restore_errors_test.go index 313cfa91..2df7c3a7 100644 --- a/internal/orchestrator/restore_errors_test.go +++ b/internal/orchestrator/restore_errors_test.go @@ -1906,7 +1906,12 @@ func TestExtractArchiveNative_OpenError(t *testing.T) { restoreFS = osFS{} logger := logging.New(logging.GetDefaultLogger().GetLevel(), false) - err := extractArchiveNative(context.Background(), "/nonexistent/archive.tar", "/tmp", logger, nil, RestoreModeFull, nil, "", nil) + err := extractArchiveNative(context.Background(), restoreArchiveOptions{ + archivePath: "/nonexistent/archive.tar", + destRoot: "/tmp", + logger: logger, + mode: RestoreModeFull, + }) if err == nil || !strings.Contains(err.Error(), "open archive") { t.Fatalf("expected open error, got: %v", err) } diff --git a/internal/orchestrator/restore_filesystem_test.go b/internal/orchestrator/restore_filesystem_test.go index 97d8a448..b627a727 100644 --- a/internal/orchestrator/restore_filesystem_test.go +++ b/internal/orchestrator/restore_filesystem_test.go @@ -276,7 +276,13 @@ func TestExtractArchiveNative_SkipFnSkipsFstab(t *testing.T) { return name == "etc/fstab" } - if err := extractArchiveNative(context.Background(), archivePath, destRoot, newTestLogger(), nil, RestoreModeFull, nil, "", skipFn); err != nil { + if err := extractArchiveNative(context.Background(), restoreArchiveOptions{ + archivePath: archivePath, + destRoot: destRoot, + logger: newTestLogger(), + mode: RestoreModeFull, + skipFn: skipFn, + }); err != nil { t.Fatalf("extractArchiveNative error: %v", err) } diff --git a/internal/orchestrator/restore_services.go b/internal/orchestrator/restore_services.go new file mode 100644 index 00000000..1590a325 --- /dev/null +++ b/internal/orchestrator/restore_services.go @@ -0,0 +1,473 @@ +// Package orchestrator coordinates backup, restore, decrypt, and related workflows. +package orchestrator + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/tis24dev/proxsave/internal/logging" +) + +var ( + serviceStopTimeout = 45 * time.Second + serviceStopNoBlockTimeout = 15 * time.Second + serviceStartTimeout = 30 * time.Second + serviceVerifyTimeout = 30 * time.Second + serviceStatusCheckTimeout = 5 * time.Second + servicePollInterval = 500 * time.Millisecond + serviceRetryDelay = 500 * time.Millisecond +) + +type restoreCommandResult struct { + out []byte + err error +} + +type restoreCommandProgress struct { + enabled bool + service string + action string + deadline time.Time +} + +type serviceInactiveWaiter struct { + ctx context.Context + logger *logging.Logger + service string + timeout time.Duration + deadline time.Time + progressEnabled bool + ticker *time.Ticker +} + +func stopPVEClusterServices(ctx context.Context, logger *logging.Logger) error { + services := []string{"pve-cluster", "pvedaemon", "pveproxy", "pvestatd"} + for _, service := range services { + if err := stopServiceWithRetries(ctx, logger, service); err != nil { + return fmt.Errorf("failed to stop PVE services (%s): %w", service, err) + } + } + return nil +} + +func startPVEClusterServices(ctx context.Context, logger *logging.Logger) error { + services := []string{"pve-cluster", "pvedaemon", "pveproxy", "pvestatd"} + for _, service := range services { + if err := startServiceWithRetries(ctx, logger, service); err != nil { + return fmt.Errorf("failed to start PVE services (%s): %w", service, err) + } + } + return nil +} + +func stopPBSServices(ctx context.Context, logger *logging.Logger) error { + if _, err := restoreCmd.Run(ctx, "which", "systemctl"); err != nil { + return fmt.Errorf("systemctl not available: %w", err) + } + services := []string{"proxmox-backup-proxy", "proxmox-backup"} + var failures []string + for _, service := range services { + if err := stopServiceWithRetries(ctx, logger, service); err != nil { + failures = append(failures, fmt.Sprintf("%s: %v", service, err)) + } + } + if len(failures) > 0 { + return errors.New(strings.Join(failures, "; ")) + } + return nil +} + +func startPBSServices(ctx context.Context, logger *logging.Logger) error { + if _, err := restoreCmd.Run(ctx, "which", "systemctl"); err != nil { + return fmt.Errorf("systemctl not available: %w", err) + } + services := []string{"proxmox-backup", "proxmox-backup-proxy"} + var failures []string + for _, service := range services { + if err := startServiceWithRetries(ctx, logger, service); err != nil { + failures = append(failures, fmt.Sprintf("%s: %v", service, err)) + } + } + if len(failures) > 0 { + return errors.New(strings.Join(failures, "; ")) + } + return nil +} + +func unmountEtcPVE(ctx context.Context, logger *logging.Logger) error { + output, err := restoreCmd.Run(ctx, "umount", "/etc/pve") + msg := strings.TrimSpace(string(output)) + if err != nil { + if strings.Contains(msg, "not mounted") { + logger.Info("Skipping umount /etc/pve (already unmounted)") + return nil + } + if msg != "" { + return fmt.Errorf("umount /etc/pve failed: %s", msg) + } + return fmt.Errorf("umount /etc/pve failed: %w", err) + } + if msg != "" { + logger.Debug("umount /etc/pve output: %s", msg) + } + return nil +} + +func runCommandWithTimeout(ctx context.Context, logger *logging.Logger, timeout time.Duration, name string, args ...string) error { + return execCommand(ctx, logger, timeout, name, args...) +} + +func execCommand(ctx context.Context, logger *logging.Logger, timeout time.Duration, name string, args ...string) error { + execCtx, cancel := commandContextWithTimeout(ctx, timeout) + defer cancel() + output, err := restoreCmd.Run(execCtx, name, args...) + msg := strings.TrimSpace(string(output)) + if err != nil { + return restoreCommandError(execCtx, timeout, name, args, msg, err) + } + logRestoreCommandOutput(logger, name, args, msg) + return nil +} + +func commandContextWithTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { + if timeout <= 0 { + return ctx, func() {} + } + return context.WithTimeout(ctx, timeout) +} + +func restoreCommandError(execCtx context.Context, timeout time.Duration, name string, args []string, msg string, err error) error { + command := fmt.Sprintf("%s %s", name, strings.Join(args, " ")) + if timeout > 0 && (errors.Is(execCtx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded)) { + return fmt.Errorf("%s timed out after %s", command, timeout) + } + if msg != "" { + return fmt.Errorf("%s failed: %s", command, msg) + } + return fmt.Errorf("%s failed: %w", command, err) +} + +func logRestoreCommandOutput(logger *logging.Logger, name string, args []string, msg string) { + if msg != "" && logger != nil { + logger.Debug("%s %s: %s", name, strings.Join(args, " "), msg) + } +} + +func stopServiceWithRetries(ctx context.Context, logger *logging.Logger, service string) error { + attempts := []struct { + description string + args []string + timeout time.Duration + }{ + {"stop (no-block)", []string{"stop", "--no-block", service}, serviceStopNoBlockTimeout}, + {"stop (blocking)", []string{"stop", service}, serviceStopTimeout}, + {"aggressive stop", []string{"kill", "--signal=SIGTERM", "--kill-who=all", service}, serviceStopTimeout}, + {"force kill", []string{"kill", "--signal=SIGKILL", "--kill-who=all", service}, serviceStopTimeout}, + } + + var lastErr error + for i, attempt := range attempts { + if i > 0 { + if err := sleepWithContext(ctx, serviceRetryDelay); err != nil { + return err + } + } + + if logger != nil { + logger.Debug("Attempting %s for %s (%d/%d)", attempt.description, service, i+1, len(attempts)) + } + + if err := runCommandWithTimeoutCountdown(ctx, logger, attempt.timeout, service, attempt.description, "systemctl", attempt.args...); err != nil { + lastErr = err + continue + } + if err := waitForServiceInactive(ctx, logger, service, serviceVerifyTimeout); err != nil { + lastErr = err + continue + } + resetFailedService(ctx, logger, service) + return nil + } + + if lastErr == nil { + lastErr = fmt.Errorf("unable to stop %s", service) + } + return lastErr +} + +func startServiceWithRetries(ctx context.Context, logger *logging.Logger, service string) error { + attempts := []struct { + description string + args []string + }{ + {"start", []string{"start", service}}, + {"retry start", []string{"start", service}}, + {"aggressive restart", []string{"restart", service}}, + } + + var lastErr error + for i, attempt := range attempts { + if i > 0 { + if err := sleepWithContext(ctx, serviceRetryDelay); err != nil { + return err + } + } + + if logger != nil { + logger.Debug("Attempting %s for %s (%d/%d)", attempt.description, service, i+1, len(attempts)) + } + + if err := runCommandWithTimeout(ctx, logger, serviceStartTimeout, "systemctl", attempt.args...); err != nil { + lastErr = err + continue + } + return nil + } + + if lastErr == nil { + lastErr = fmt.Errorf("unable to start %s", service) + } + return lastErr +} + +func runCommandWithTimeoutCountdown(ctx context.Context, logger *logging.Logger, timeout time.Duration, service, action, name string, args ...string) error { + if timeout <= 0 { + return execCommand(ctx, logger, timeout, name, args...) + } + + execCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + resultCh := startRestoreCommand(execCtx, name, args...) + progress := newRestoreCommandProgress(service, action, timeout) + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case r := <-resultCh: + progress.clear() + return finishRestoreCommandResult(execCtx, logger, timeout, name, args, r) + case <-ticker.C: + progress.write(time.Until(progress.deadline)) + case <-execCtx.Done(): + return finishRestoreCommandTimeout(logger, name, args, timeout, resultCh, progress) + } + } +} + +func startRestoreCommand(ctx context.Context, name string, args ...string) <-chan restoreCommandResult { + resultCh := make(chan restoreCommandResult, 1) + go func() { + out, err := restoreCmd.Run(ctx, name, args...) + resultCh <- restoreCommandResult{out: out, err: err} + }() + return resultCh +} + +func newRestoreCommandProgress(service, action string, timeout time.Duration) restoreCommandProgress { + return restoreCommandProgress{ + enabled: isTerminal(int(os.Stderr.Fd())), + service: service, + action: action, + deadline: time.Now().Add(timeout), + } +} + +func (progress restoreCommandProgress) write(left time.Duration) { + if !progress.enabled { + return + } + seconds := int(left.Round(time.Second).Seconds()) + if seconds < 0 { + seconds = 0 + } + fmt.Fprintf(os.Stderr, "\rStopping %s: %s (attempt timeout in %ds)...", progress.service, progress.action, seconds) +} + +func (progress restoreCommandProgress) clear() { + if !progress.enabled { + return + } + fmt.Fprint(os.Stderr, "\r") + fmt.Fprintln(os.Stderr, strings.Repeat(" ", 80)) + fmt.Fprint(os.Stderr, "\r") +} + +func (progress restoreCommandProgress) newline() { + if progress.enabled { + fmt.Fprintln(os.Stderr) + } +} + +func finishRestoreCommandResult(execCtx context.Context, logger *logging.Logger, timeout time.Duration, name string, args []string, result restoreCommandResult) error { + msg := strings.TrimSpace(string(result.out)) + if result.err != nil { + return restoreCommandError(execCtx, timeout, name, args, msg, result.err) + } + logRestoreCommandOutput(logger, name, args, msg) + return nil +} + +func finishRestoreCommandTimeout(logger *logging.Logger, name string, args []string, timeout time.Duration, resultCh <-chan restoreCommandResult, progress restoreCommandProgress) error { + progress.write(0) + progress.newline() + select { + case result := <-resultCh: + logRestoreCommandOutput(logger, name, args, strings.TrimSpace(string(result.out))) + default: + } + return fmt.Errorf("%s %s timed out after %s", name, strings.Join(args, " "), timeout) +} + +func waitForServiceInactive(ctx context.Context, logger *logging.Logger, service string, timeout time.Duration) error { + if timeout <= 0 { + return nil + } + waiter := newServiceInactiveWaiter(ctx, logger, service, timeout) + defer waiter.ticker.Stop() + for { + remaining := time.Until(waiter.deadline) + if err := waiter.ensureTimeRemaining(remaining); err != nil { + return err + } + active, err := isServiceActive(ctx, service, minDuration(remaining, serviceStatusCheckTimeout)) + if err != nil { + return err + } + if !active { + waiter.logStopped() + return nil + } + if err := waiter.sleepOrCancel(remaining); err != nil { + return err + } + waiter.writeProgress(remaining) + } +} + +func newServiceInactiveWaiter(ctx context.Context, logger *logging.Logger, service string, timeout time.Duration) serviceInactiveWaiter { + return serviceInactiveWaiter{ + ctx: ctx, + logger: logger, + service: service, + timeout: timeout, + deadline: time.Now().Add(timeout), + progressEnabled: isTerminal(int(os.Stderr.Fd())), + ticker: time.NewTicker(1 * time.Second), + } +} + +func (waiter serviceInactiveWaiter) ensureTimeRemaining(remaining time.Duration) error { + if remaining > 0 { + return nil + } + waiter.writeNewline() + return fmt.Errorf("%s still active after %s", waiter.service, waiter.timeout) +} + +func (waiter serviceInactiveWaiter) logStopped() { + if waiter.logger != nil { + waiter.logger.Debug("%s stopped successfully", waiter.service) + } +} + +func (waiter serviceInactiveWaiter) sleepOrCancel(remaining time.Duration) error { + timer := time.NewTimer(minDuration(remaining, servicePollInterval)) + defer timer.Stop() + select { + case <-waiter.ctx.Done(): + waiter.writeNewline() + return waiter.ctx.Err() + case <-timer.C: + return nil + } +} + +func (waiter serviceInactiveWaiter) writeProgress(remaining time.Duration) { + select { + case <-waiter.ticker.C: + if waiter.progressEnabled { + seconds := int(remaining.Round(time.Second).Seconds()) + if seconds < 0 { + seconds = 0 + } + fmt.Fprintf(os.Stderr, "\rWaiting for %s to stop (%ds remaining)...", waiter.service, seconds) + } + default: + } +} + +func (waiter serviceInactiveWaiter) writeNewline() { + if waiter.progressEnabled { + fmt.Fprintln(os.Stderr) + } +} + +func resetFailedService(ctx context.Context, logger *logging.Logger, service string) { + resetCtx, cancel := context.WithTimeout(ctx, serviceStatusCheckTimeout) + defer cancel() + + if _, err := restoreCmd.Run(resetCtx, "systemctl", "reset-failed", service); err != nil { + if logger != nil { + logger.Debug("systemctl reset-failed %s ignored: %v", service, err) + } + } +} + +func isServiceActive(ctx context.Context, service string, timeout time.Duration) (bool, error) { + if timeout <= 0 { + timeout = serviceStatusCheckTimeout + } + checkCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + output, err := restoreCmd.Run(checkCtx, "systemctl", "is-active", service) + msg := strings.TrimSpace(string(output)) + if err == nil { + return true, nil + } + if errors.Is(checkCtx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) { + return false, fmt.Errorf("systemctl is-active %s timed out after %s", service, timeout) + } + if msg == "" { + msg = err.Error() + } + return parseSystemctlActiveState(service, msg) +} + +func parseSystemctlActiveState(service, msg string) (bool, error) { + lower := strings.ToLower(msg) + if strings.Contains(lower, "deactivating") || strings.Contains(lower, "activating") { + return true, nil + } + if strings.Contains(lower, "inactive") || strings.Contains(lower, "failed") || strings.Contains(lower, "dead") { + return false, nil + } + return false, fmt.Errorf("systemctl is-active %s failed: %s", service, msg) +} + +func minDuration(a, b time.Duration) time.Duration { + if a < b { + return a + } + return b +} + +func sleepWithContext(ctx context.Context, d time.Duration) error { + if d <= 0 { + return nil + } + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/internal/orchestrator/restore_workflow_ui.go b/internal/orchestrator/restore_workflow_ui.go index c5dd7c27..eb5a96f4 100644 --- a/internal/orchestrator/restore_workflow_ui.go +++ b/internal/orchestrator/restore_workflow_ui.go @@ -1,3 +1,4 @@ +// Package orchestrator coordinates backup, restore, decrypt, and related workflows. package orchestrator import ( @@ -453,7 +454,13 @@ func runRestoreWorkflowWithUI(ctx context.Context, cfg *config.Config, logger *l "./var/lib/proxsave-info/commands/pbs/pbs_datastore_inventory.json", }, }} - if err := extractArchiveNative(ctx, prepared.ArchivePath, fsTempDir, logger, invCategory, RestoreModeCustom, nil, "", nil); err != nil { + if err := extractArchiveNative(ctx, restoreArchiveOptions{ + archivePath: prepared.ArchivePath, + destRoot: fsTempDir, + logger: logger, + categories: invCategory, + mode: RestoreModeCustom, + }); err != nil { logger.Debug("Failed to extract fstab inventory data (continuing): %v", err) } @@ -509,7 +516,13 @@ func runRestoreWorkflowWithUI(ctx context.Context, cfg *config.Config, logger *l "./var/lib/proxsave-info/commands/pve/mapping_dir.json", }, }} - if err := extractArchiveNative(ctx, prepared.ArchivePath, exportRoot, logger, safeInvCategory, RestoreModeCustom, nil, "", nil); err != nil { + if err := extractArchiveNative(ctx, restoreArchiveOptions{ + archivePath: prepared.ArchivePath, + destRoot: exportRoot, + logger: logger, + categories: safeInvCategory, + mode: RestoreModeCustom, + }); err != nil { logger.Debug("Failed to extract SAFE apply inventory (continuing): %v", err) } @@ -978,7 +991,13 @@ func runFullRestoreWithUI(ctx context.Context, ui RestoreWorkflowUI, candidate * "./etc/fstab", }, }} - if err := extractArchiveNative(ctx, prepared.ArchivePath, fsTempDir, logger, fsCategory, RestoreModeCustom, nil, "", nil); err != nil { + if err := extractArchiveNative(ctx, restoreArchiveOptions{ + archivePath: prepared.ArchivePath, + destRoot: fsTempDir, + logger: logger, + categories: fsCategory, + mode: RestoreModeCustom, + }); err != nil { logger.Warning("Failed to extract filesystem config for merge: %v", err) } else { currentFstab := filepath.Join(destRoot, "etc", "fstab") diff --git a/internal/orchestrator/restore_zfs.go b/internal/orchestrator/restore_zfs.go new file mode 100644 index 00000000..ebf65dd2 --- /dev/null +++ b/internal/orchestrator/restore_zfs.go @@ -0,0 +1,209 @@ +// Package orchestrator coordinates backup, restore, decrypt, and related workflows. +package orchestrator + +import ( + "bufio" + "context" + "path/filepath" + "sort" + "strings" + + "github.com/tis24dev/proxsave/internal/logging" +) + +var restoreGlob = filepath.Glob + +// checkZFSPoolsAfterRestore checks if ZFS pools need to be imported after restore +func checkZFSPoolsAfterRestore(logger *logging.Logger) error { + if _, err := restoreCmd.Run(context.Background(), "which", "zpool"); err != nil { + // zpool utility not available -> no ZFS tooling installed + return nil + } + + logger.Info("Checking ZFS pool status...") + + configuredPools := detectConfiguredZFSPools() + importablePools, importOutput, importErr := detectImportableZFSPools() + + logConfiguredZFSPools(logger, configuredPools) + logImportableZFSPools(logger, importablePools, importOutput, importErr) + + if len(importablePools) == 0 { + logNoImportableZFSPools(logger, configuredPools) + return nil + } + + logManualZFSImportInstructions(logger, importablePools) + return nil +} + +func logConfiguredZFSPools(logger *logging.Logger, configuredPools []string) { + if len(configuredPools) == 0 { + return + } + logger.Warning("Found %d ZFS pool(s) configured for automatic import:", len(configuredPools)) + for _, pool := range configuredPools { + logger.Warning(" - %s", pool) + } + logger.Info("") +} + +func logImportableZFSPools(logger *logging.Logger, importablePools []string, importOutput string, importErr error) { + if importErr != nil { + logger.Warning("`zpool import` command returned an error: %v", importErr) + if strings.TrimSpace(importOutput) != "" { + logger.Warning("`zpool import` output:\n%s", importOutput) + } + return + } + if len(importablePools) > 0 { + logger.Warning("`zpool import` reports pools waiting to be imported:") + for _, pool := range importablePools { + logger.Warning(" - %s", pool) + } + logger.Info("") + } +} + +func logNoImportableZFSPools(logger *logging.Logger, configuredPools []string) { + logger.Info("`zpool import` did not report pools waiting for import.") + if len(configuredPools) == 0 { + return + } + logger.Info("") + for _, pool := range configuredPools { + if _, err := restoreCmd.Run(context.Background(), "zpool", "status", pool); err == nil { + logger.Info("Pool %s is already imported (no manual action needed)", pool) + } else { + logger.Warning("Systemd expects pool %s, but `zpool import` and `zpool status` did not report it. Check disk visibility and pool status.", pool) + } + } +} + +func logManualZFSImportInstructions(logger *logging.Logger, importablePools []string) { + logger.Info("⚠ IMPORTANT: ZFS pools may need manual import after restore!") + logger.Info(" Before rebooting, run these commands:") + logger.Info(" 1. Check available pools: zpool import") + for _, pool := range importablePools { + logger.Info(" 2. Import pool manually: zpool import %s", pool) + } + logger.Info(" 3. Verify pool status: zpool status") + logger.Info("") + logger.Info(" If pools fail to import, check:") + logger.Info(" - journalctl -u zfs-import@.service oppure import@.service") + logger.Info(" - zpool import -d /dev/disk/by-id") + logger.Info("") +} + +func detectConfiguredZFSPools() []string { + pools := make(map[string]struct{}) + addConfiguredZFSPoolsFromDirs(pools) + addConfiguredZFSPoolsFromGlobPatterns(pools) + return sortedPoolNames(pools) +} + +func addConfiguredZFSPoolsFromDirs(pools map[string]struct{}) { + directories := []string{ + "/etc/systemd/system/zfs-import.target.wants", + "/etc/systemd/system/multi-user.target.wants", + } + + for _, dir := range directories { + entries, err := restoreFS.ReadDir(dir) + if err != nil { + continue + } + + for _, entry := range entries { + if pool := parsePoolNameFromUnit(entry.Name()); pool != "" { + pools[pool] = struct{}{} + } + } + } +} + +func addConfiguredZFSPoolsFromGlobPatterns(pools map[string]struct{}) { + globPatterns := []string{ + "/etc/systemd/system/zfs-import@*.service", + "/etc/systemd/system/import@*.service", + } + + for _, pattern := range globPatterns { + matches, err := restoreGlob(pattern) + if err != nil { + continue + } + for _, match := range matches { + if pool := parsePoolNameFromUnit(filepath.Base(match)); pool != "" { + pools[pool] = struct{}{} + } + } + } +} + +func sortedPoolNames(pools map[string]struct{}) []string { + var poolNames []string + for pool := range pools { + poolNames = append(poolNames, pool) + } + sort.Strings(poolNames) + return poolNames +} + +func parsePoolNameFromUnit(unitName string) string { + switch { + case strings.HasPrefix(unitName, "zfs-import@") && strings.HasSuffix(unitName, ".service"): + pool := strings.TrimPrefix(unitName, "zfs-import@") + return strings.TrimSuffix(pool, ".service") + case strings.HasPrefix(unitName, "import@") && strings.HasSuffix(unitName, ".service"): + pool := strings.TrimPrefix(unitName, "import@") + return strings.TrimSuffix(pool, ".service") + default: + return "" + } +} + +func detectImportableZFSPools() ([]string, string, error) { + output, err := restoreCmd.Run(context.Background(), "zpool", "import") + poolNames := parseZpoolImportOutput(string(output)) + if err != nil { + return poolNames, string(output), err + } + return poolNames, string(output), nil +} + +func parseZpoolImportOutput(output string) []string { + var pools []string + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(strings.ToLower(line), "pool:") { + pool := strings.TrimSpace(line[len("pool:"):]) + if pool != "" { + pools = append(pools, pool) + } + } + } + return pools +} + +func combinePoolNames(a, b []string) []string { + merged := make(map[string]struct{}) + for _, pool := range a { + merged[pool] = struct{}{} + } + for _, pool := range b { + merged[pool] = struct{}{} + } + + if len(merged) == 0 { + return nil + } + + names := make([]string, 0, len(merged)) + for pool := range merged { + names = append(names, pool) + } + sort.Strings(names) + return names +} From e1db44b9ff173a1a7d059c7bdc56c0836262e04b Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Tue, 5 May 2026 13:14:18 +0200 Subject: [PATCH 13/35] Encode email bodies as quoted-printable Use quoted-printable encoding for text and HTML parts to ensure 7-bit-safe emails and avoid 8bit transfer encoding. Add encodeQuotedPrintableBody helper and required imports, update Content-Transfer-Encoding headers in buildEmailMessage for both plain/text and html parts, and add a test (TestEmailNotifierBuildEmailMessage_EncodesUTF8BodiesAsSevenBitSafe) that verifies quoted-printable is used and no raw non-ASCII bytes remain in the message. --- internal/notify/email.go | 26 +++++++++---- .../notify/email_delivery_methods_test.go | 37 +++++++++++++++++++ 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/internal/notify/email.go b/internal/notify/email.go index ac7675ae..6a6ccdac 100644 --- a/internal/notify/email.go +++ b/internal/notify/email.go @@ -1,11 +1,13 @@ package notify import ( + "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" + "mime/quotedprintable" "os" "os/exec" "path/filepath" @@ -1110,6 +1112,14 @@ func summarizeSendmailTranscript(transcript string) (highlights []string, remote return highlights, remoteID, localQueueID } +func encodeQuotedPrintableBody(body string) string { + var encoded bytes.Buffer + writer := quotedprintable.NewWriter(&encoded) + _, _ = writer.Write([]byte(body)) + _ = writer.Close() + return encoded.String() +} + func (e *EmailNotifier) buildEmailMessage(recipient, subject, htmlBody, textBody string, data *NotificationData) (emailMessage, toHeader string) { e.logger.Debug("=== Building email message ===") @@ -1152,17 +1162,17 @@ func (e *EmailNotifier) buildEmailMessage(recipient, subject, htmlBody, textBody // Plain text part email.WriteString(fmt.Sprintf("--%s\n", altBoundary)) email.WriteString("Content-Type: text/plain; charset=UTF-8\n") - email.WriteString("Content-Transfer-Encoding: 8bit\n") + email.WriteString("Content-Transfer-Encoding: quoted-printable\n") email.WriteString("\n") - email.WriteString(textBody) + email.WriteString(encodeQuotedPrintableBody(textBody)) email.WriteString("\n\n") // HTML part email.WriteString(fmt.Sprintf("--%s\n", altBoundary)) email.WriteString("Content-Type: text/html; charset=UTF-8\n") - email.WriteString("Content-Transfer-Encoding: 8bit\n") + email.WriteString("Content-Transfer-Encoding: quoted-printable\n") email.WriteString("\n") - email.WriteString(htmlBody) + email.WriteString(encodeQuotedPrintableBody(htmlBody)) email.WriteString("\n\n") email.WriteString(fmt.Sprintf("--%s--\n", altBoundary)) @@ -1204,17 +1214,17 @@ func (e *EmailNotifier) buildEmailMessage(recipient, subject, htmlBody, textBody // Plain text part email.WriteString(fmt.Sprintf("--%s\n", altBoundary)) email.WriteString("Content-Type: text/plain; charset=UTF-8\n") - email.WriteString("Content-Transfer-Encoding: 8bit\n") + email.WriteString("Content-Transfer-Encoding: quoted-printable\n") email.WriteString("\n") - email.WriteString(textBody) + email.WriteString(encodeQuotedPrintableBody(textBody)) email.WriteString("\n\n") // HTML part email.WriteString(fmt.Sprintf("--%s\n", altBoundary)) email.WriteString("Content-Type: text/html; charset=UTF-8\n") - email.WriteString("Content-Transfer-Encoding: 8bit\n") + email.WriteString("Content-Transfer-Encoding: quoted-printable\n") email.WriteString("\n") - email.WriteString(htmlBody) + email.WriteString(encodeQuotedPrintableBody(htmlBody)) email.WriteString("\n\n") email.WriteString(fmt.Sprintf("--%s--\n", altBoundary)) diff --git a/internal/notify/email_delivery_methods_test.go b/internal/notify/email_delivery_methods_test.go index 41c42765..10a73fea 100644 --- a/internal/notify/email_delivery_methods_test.go +++ b/internal/notify/email_delivery_methods_test.go @@ -377,6 +377,43 @@ func TestEmailNotifierBuildEmailMessage_FallsBackWhenLogUnreadable(t *testing.T) } } +func TestEmailNotifierBuildEmailMessage_EncodesUTF8BodiesAsSevenBitSafe(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + logger.SetOutput(io.Discard) + + notifier, err := NewEmailNotifier(EmailConfig{ + Enabled: true, + DeliveryMethod: EmailDeliveryPMF, + From: "no-reply@proxmox.example.com", + }, types.ProxmoxBS, logger) + if err != nil { + t.Fatalf("NewEmailNotifier() error = %v", err) + } + + emailMessage, _ := notifier.buildEmailMessage( + "admin@example.com", + "✅ PVE Backup à", + "

Backup completato ✅ con avvisi: è pieno

", + "Backup completato ✅ con avvisi: è pieno", + createTestNotificationData(), + ) + + if strings.Contains(emailMessage, "Content-Transfer-Encoding: 8bit") { + t.Fatalf("email message must not use 8bit transfer encoding:\n%s", emailMessage) + } + if count := strings.Count(emailMessage, "Content-Transfer-Encoding: quoted-printable"); count != 2 { + t.Fatalf("expected two quoted-printable body parts, got %d:\n%s", count, emailMessage) + } + if strings.Contains(emailMessage, "✅") || strings.Contains(emailMessage, "è") || strings.Contains(emailMessage, "à") { + t.Fatalf("email message contains raw non-ASCII body/subject characters:\n%s", emailMessage) + } + for i, b := range []byte(emailMessage) { + if b > 0x7f { + t.Fatalf("email message contains non-ASCII byte 0x%x at offset %d", b, i) + } + } +} + func TestEmailNotifierIsMTAServiceActive_SystemctlMissing(t *testing.T) { logger := logging.New(types.LogLevelDebug, false) logger.SetOutput(io.Discard) From 743b24e6676ccb8edf09b92df95b23568394c084 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 13:40:14 +0200 Subject: [PATCH 14/35] deps(deps): bump github.com/gdamore/tcell/v2 from 2.13.8 to 2.13.9 in the security-patches group across 1 directory (#200) deps(deps): bump github.com/gdamore/tcell/v2 Bumps the security-patches group with 1 update in the / directory: [github.com/gdamore/tcell/v2](https://github.com/gdamore/tcell). Updates `github.com/gdamore/tcell/v2` from 2.13.8 to 2.13.9 - [Release notes](https://github.com/gdamore/tcell/releases) - [Changelog](https://github.com/gdamore/tcell/blob/main/CHANGESv3.md) - [Commits](https://github.com/gdamore/tcell/compare/v2.13.8...v2.13.9) --- updated-dependencies: - dependency-name: github.com/gdamore/tcell/v2 dependency-version: 2.13.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: security-patches ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 21b4cdd9..8bc70ffe 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.25.9 require ( filippo.io/age v1.3.1 - github.com/gdamore/tcell/v2 v2.13.8 + github.com/gdamore/tcell/v2 v2.13.9 github.com/rivo/tview v0.42.0 golang.org/x/crypto v0.50.0 golang.org/x/term v0.42.0 diff --git a/go.sum b/go.sum index ac725be0..ca48a79f 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= -github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= +github.com/gdamore/tcell/v2 v2.13.9 h1:uI5l3DYPcFvHINKlGft+en23evOKL+dwtD21QR8ejVA= +github.com/gdamore/tcell/v2 v2.13.9/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= From 323b45e4f8f7efe3dd6150aecb5a1982dd940d8b Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Tue, 5 May 2026 13:54:26 +0200 Subject: [PATCH 15/35] Validate and resolve hardlink targets Reject empty or absolute hardlink Linkname values and normalize them with filepath.FromSlash. Resolve the hardlink target via resolvePathWithinRootFS and use the resolved path for creation to prevent links escaping the extraction root. Add tests: recordingLinkFS to capture Link() args, TestExtractHardlink_UsesResolvedTargetPath to ensure the resolved target is used, and TestExtractHardlink_RejectsSymlinkEscapeTarget to verify symlink-escape targets are rejected. --- .../orchestrator/restore_archive_entries.go | 12 ++- internal/orchestrator/restore_test.go | 92 +++++++++++++++++++ 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/internal/orchestrator/restore_archive_entries.go b/internal/orchestrator/restore_archive_entries.go index f77e4946..487dde72 100644 --- a/internal/orchestrator/restore_archive_entries.go +++ b/internal/orchestrator/restore_archive_entries.go @@ -230,20 +230,22 @@ func extractSymlink(target string, header *tar.Header, destRoot string, logger * // extractHardlink creates a hard link func extractHardlink(target string, header *tar.Header, destRoot string) error { // Validate hard link target - linkName := header.Linkname + linkName := filepath.FromSlash(header.Linkname) + if linkName == "" || filepath.Clean(linkName) == "." { + return fmt.Errorf("empty hardlink target not allowed") + } // Reject absolute hard link targets immediately if filepath.IsAbs(linkName) { return fmt.Errorf("absolute hardlink target not allowed: %s", linkName) } - // Validate the hard link target stays within extraction root - if _, err := resolvePathWithinRootFS(restoreFS, destRoot, linkName); err != nil { + // Resolve and validate the hard link target stays within extraction root. + linkTarget, err := resolvePathWithinRootFS(restoreFS, destRoot, linkName) + if err != nil { return fmt.Errorf("hardlink target escapes root: %s -> %s: %w", header.Name, linkName, err) } - linkTarget := filepath.Join(destRoot, linkName) - // Remove existing file/link if it exists _ = restoreFS.Remove(target) diff --git a/internal/orchestrator/restore_test.go b/internal/orchestrator/restore_test.go index 2a9c37a5..9dcdfc00 100644 --- a/internal/orchestrator/restore_test.go +++ b/internal/orchestrator/restore_test.go @@ -748,6 +748,18 @@ func TestExtractDirectory_Success(t *testing.T) { // extractHardlink tests // -------------------------------------------------------------------------- +type recordingLinkFS struct { + *FakeFS + oldname string + newname string +} + +func (f *recordingLinkFS) Link(oldname, newname string) error { + f.oldname = oldname + f.newname = newname + return f.FakeFS.Link(oldname, newname) +} + func TestExtractHardlink_AbsoluteTargetRejected(t *testing.T) { header := &tar.Header{ Name: "link", @@ -774,6 +786,86 @@ func TestExtractHardlink_EscapesRoot(t *testing.T) { } } +func TestExtractHardlink_UsesResolvedTargetPath(t *testing.T) { + orig := restoreFS + fakeFS := NewFakeFS() + recordingFS := &recordingLinkFS{FakeFS: fakeFS} + restoreFS = recordingFS + t.Cleanup(func() { + restoreFS = orig + _ = fakeFS.Cleanup() + }) + + destRoot := fakeFS.Root + realDir := filepath.Join(destRoot, "real") + if err := fakeFS.MkdirAll(realDir, 0o755); err != nil { + t.Fatalf("mkdir real dir: %v", err) + } + realTarget := filepath.Join(realDir, "target.txt") + if err := fakeFS.WriteFile(realTarget, []byte("test"), 0o644); err != nil { + t.Fatalf("write real target: %v", err) + } + if err := os.Symlink("real", filepath.Join(destRoot, "alias")); err != nil { + t.Fatalf("create alias symlink: %v", err) + } + + header := &tar.Header{ + Name: "hardlink.txt", + Linkname: filepath.Join("alias", "target.txt"), + Typeflag: tar.TypeLink, + } + linkFile := filepath.Join(destRoot, header.Name) + + if err := extractHardlink(linkFile, header, destRoot); err != nil { + t.Fatalf("extractHardlink failed: %v", err) + } + if recordingFS.oldname != realTarget { + t.Fatalf("hardlink source = %q, want resolved target %q", recordingFS.oldname, realTarget) + } + if recordingFS.newname != linkFile { + t.Fatalf("hardlink destination = %q, want %q", recordingFS.newname, linkFile) + } + + realInfo, err := os.Stat(realTarget) + if err != nil { + t.Fatalf("stat real target: %v", err) + } + linkInfo, err := os.Stat(linkFile) + if err != nil { + t.Fatalf("stat hardlink: %v", err) + } + if !os.SameFile(realInfo, linkInfo) { + t.Fatalf("hardlink does not point to resolved target") + } +} + +func TestExtractHardlink_RejectsSymlinkEscapeTarget(t *testing.T) { + orig := restoreFS + restoreFS = osFS{} + t.Cleanup(func() { restoreFS = orig }) + + destRoot := t.TempDir() + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(destRoot, "escape-link")); err != nil { + t.Fatalf("create escape symlink: %v", err) + } + + header := &tar.Header{ + Name: "link.txt", + Linkname: filepath.Join("escape-link", "target.txt"), + Typeflag: tar.TypeLink, + } + linkFile := filepath.Join(destRoot, header.Name) + + err := extractHardlink(linkFile, header, destRoot) + if err == nil || !strings.Contains(err.Error(), "escapes root") { + t.Fatalf("expected escapes root error, got: %v", err) + } + if _, err := os.Lstat(linkFile); !os.IsNotExist(err) { + t.Fatalf("hardlink should not be created, got err=%v", err) + } +} + func TestExtractHardlink_Success(t *testing.T) { orig := restoreFS t.Cleanup(func() { restoreFS = orig }) From f89bdce5d438fcb39412adcdfd783761b086a599 Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Tue, 5 May 2026 14:05:29 +0200 Subject: [PATCH 16/35] Add additional orchestrator tests Add new unit tests for the orchestrator package. Tests cover estimatedBackupSizeGB scaling and minimum, BackupError wrapping and default messages for disk validation, backup metrics exit code logic, filling backup timing fields, and building backup collector config merging runtime excludes and blacklist. Also add restore tests for extractPlainArchive honoring skip functions and for runSafeClusterApplyWithUI ensuring storage/datacenter apply is skipped when storage_pve is staged. --- .../backup_run_helpers_additional_test.go | 130 ++++++++++++++++++ .../restore_archive_additional_test.go | 40 ++++++ .../restore_cluster_apply_additional_test.go | 60 ++++++++ 3 files changed, 230 insertions(+) create mode 100644 internal/orchestrator/backup_run_helpers_additional_test.go create mode 100644 internal/orchestrator/restore_archive_additional_test.go create mode 100644 internal/orchestrator/restore_cluster_apply_additional_test.go diff --git a/internal/orchestrator/backup_run_helpers_additional_test.go b/internal/orchestrator/backup_run_helpers_additional_test.go new file mode 100644 index 00000000..49c33b93 --- /dev/null +++ b/internal/orchestrator/backup_run_helpers_additional_test.go @@ -0,0 +1,130 @@ +package orchestrator + +import ( + "errors" + "math" + "strings" + "testing" + "time" + + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/types" +) + +func TestEstimatedBackupSizeGBMinimumAndScaling(t *testing.T) { + tests := []struct { + name string + bytes int64 + want float64 + }{ + {name: "zero uses minimum", bytes: 0, want: 0.001}, + {name: "below minimum uses minimum", bytes: 512, want: 0.001}, + {name: "one gibibyte", bytes: 1024 * 1024 * 1024, want: 1}, + {name: "two and a half gibibytes", bytes: 5 * 1024 * 1024 * 1024 / 2, want: 2.5}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := estimatedBackupSizeGB(tt.bytes); math.Abs(got-tt.want) > 0.0000001 { + t.Fatalf("estimatedBackupSizeGB(%d)=%f want %f", tt.bytes, got, tt.want) + } + }) + } +} + +func TestBackupDiskValidationErrorWrapsDiskError(t *testing.T) { + diskErr := errors.New("need 3.5 GB free") + err := backupDiskValidationError("", diskErr) + + var backupErr *BackupError + if !errors.As(err, &backupErr) { + t.Fatalf("expected BackupError, got %T", err) + } + if backupErr.Phase != "disk" || backupErr.Code != types.ExitDiskSpaceError { + t.Fatalf("unexpected BackupError fields: phase=%q code=%v", backupErr.Phase, backupErr.Code) + } + if !errors.Is(err, diskErr) { + t.Fatalf("expected disk error to be wrapped, got %v", err) + } +} + +func TestBackupDiskValidationErrorUsesDefaultMessage(t *testing.T) { + err := backupDiskValidationError("", nil) + + var backupErr *BackupError + if !errors.As(err, &backupErr) { + t.Fatalf("expected BackupError, got %T", err) + } + if !strings.Contains(err.Error(), "insufficient disk space") { + t.Fatalf("expected default disk space message, got %q", err.Error()) + } +} + +func TestBackupMetricsExitCode(t *testing.T) { + if got := backupMetricsExitCode(&BackupStats{}, nil); got != types.ExitSuccess.Int() { + t.Fatalf("success exit code=%d want %d", got, types.ExitSuccess.Int()) + } + if got := backupMetricsExitCode(&BackupStats{ExitCode: 77}, nil); got != 77 { + t.Fatalf("stats exit code=%d want 77", got) + } + + runErr := &BackupError{Phase: "disk", Err: errors.New("full"), Code: types.ExitDiskSpaceError} + if got := backupMetricsExitCode(&BackupStats{}, runErr); got != types.ExitDiskSpaceError.Int() { + t.Fatalf("backup error exit code=%d want %d", got, types.ExitDiskSpaceError.Int()) + } + if got := backupMetricsExitCode(&BackupStats{}, errors.New("boom")); got != types.ExitGenericError.Int() { + t.Fatalf("generic error exit code=%d want %d", got, types.ExitGenericError.Int()) + } +} + +func TestEnsureBackupStatsTimingFillsEndAndDuration(t *testing.T) { + now := time.Date(2026, 5, 5, 10, 30, 0, 0, time.UTC) + orch := New(newTestLogger(), false) + orch.clock = &FakeTime{Current: now} + + stats := &BackupStats{StartTime: now.Add(-90 * time.Second)} + orch.ensureBackupStatsTiming(stats) + + if !stats.EndTime.Equal(now) { + t.Fatalf("EndTime=%v want %v", stats.EndTime, now) + } + if stats.Duration != 90*time.Second { + t.Fatalf("Duration=%v want %v", stats.Duration, 90*time.Second) + } +} + +func TestBuildBackupCollectorConfigMergesRuntimeExcludesAndBlacklist(t *testing.T) { + orch := New(newTestLogger(), false) + orch.SetBackupConfig("/backup", "/logs", types.CompressionZstd, 3, 2, "fast", []string{"runtime/**"}) + orch.SetConfig(&config.Config{ + BackupBlacklist: []string{"/secret", "/tmp/cache"}, + CustomBackupPaths: []string{"/srv/app"}, + BaseDir: "/opt/proxsave", + ConfigPath: "/etc/proxsave/backup.env", + }) + + cfg := orch.buildBackupCollectorConfig() + for _, want := range []string{"runtime/**", "/secret", "/tmp/cache"} { + if !containsString(cfg.ExcludePatterns, want) { + t.Fatalf("ExcludePatterns missing %q: %#v", want, cfg.ExcludePatterns) + } + } + if len(cfg.BackupBlacklist) != 2 || cfg.BackupBlacklist[0] != "/secret" || cfg.BackupBlacklist[1] != "/tmp/cache" { + t.Fatalf("BackupBlacklist not copied: %#v", cfg.BackupBlacklist) + } + if len(cfg.CustomBackupPaths) != 1 || cfg.CustomBackupPaths[0] != "/srv/app" { + t.Fatalf("CustomBackupPaths not copied: %#v", cfg.CustomBackupPaths) + } + if cfg.ScriptRepositoryPath != "/opt/proxsave" || cfg.ConfigFilePath != "/etc/proxsave/backup.env" { + t.Fatalf("paths not copied: script=%q config=%q", cfg.ScriptRepositoryPath, cfg.ConfigFilePath) + } +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/internal/orchestrator/restore_archive_additional_test.go b/internal/orchestrator/restore_archive_additional_test.go new file mode 100644 index 00000000..f8fda96d --- /dev/null +++ b/internal/orchestrator/restore_archive_additional_test.go @@ -0,0 +1,40 @@ +package orchestrator + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestExtractPlainArchiveHonorsSkipFn(t *testing.T) { + origFS := restoreFS + t.Cleanup(func() { restoreFS = origFS }) + restoreFS = osFS{} + + tmpDir := t.TempDir() + archivePath := filepath.Join(tmpDir, "backup.tar") + if err := writeTarFile(archivePath, map[string]string{ + "etc/fstab": "/dev/sda1 / ext4 defaults 0 1\n", + "etc/hosts": "127.0.0.1 localhost\n", + }); err != nil { + t.Fatalf("write archive: %v", err) + } + + destRoot := filepath.Join(tmpDir, "restore") + if err := extractPlainArchive(context.Background(), archivePath, destRoot, newTestLogger(), fullRestoreSkipFn(true)); err != nil { + t.Fatalf("extractPlainArchive error: %v", err) + } + + if _, err := os.Stat(filepath.Join(destRoot, "etc", "fstab")); !os.IsNotExist(err) { + t.Fatalf("expected skipped fstab to be absent, stat err=%v", err) + } + + hosts, err := os.ReadFile(filepath.Join(destRoot, "etc", "hosts")) + if err != nil { + t.Fatalf("expected hosts to be extracted: %v", err) + } + if string(hosts) != "127.0.0.1 localhost\n" { + t.Fatalf("hosts content=%q", string(hosts)) + } +} diff --git a/internal/orchestrator/restore_cluster_apply_additional_test.go b/internal/orchestrator/restore_cluster_apply_additional_test.go new file mode 100644 index 00000000..06a61e68 --- /dev/null +++ b/internal/orchestrator/restore_cluster_apply_additional_test.go @@ -0,0 +1,60 @@ +package orchestrator + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunSafeClusterApplyWithUI_SkipsStorageDatacenterWhenStoragePVEStaged(t *testing.T) { + origCmd := restoreCmd + origFS := restoreFS + t.Cleanup(func() { + restoreCmd = origCmd + restoreFS = origFS + }) + restoreFS = osFS{} + + pathDir := t.TempDir() + pveshPath := filepath.Join(pathDir, "pvesh") + if err := os.WriteFile(pveshPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write pvesh: %v", err) + } + t.Setenv("PATH", pathDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + runner := &recordingRunner{} + restoreCmd = runner + + exportRoot := t.TempDir() + pveDir := filepath.Join(exportRoot, "etc", "pve") + if err := os.MkdirAll(pveDir, 0o755); err != nil { + t.Fatalf("mkdir pve dir: %v", err) + } + if err := os.WriteFile(filepath.Join(pveDir, "storage.cfg"), []byte("storage: local\n type dir\n"), 0o640); err != nil { + t.Fatalf("write storage.cfg: %v", err) + } + if err := os.WriteFile(filepath.Join(pveDir, "datacenter.cfg"), []byte("keyboard: it\n"), 0o640); err != nil { + t.Fatalf("write datacenter.cfg: %v", err) + } + + plan := &RestorePlan{ + SystemType: SystemTypePVE, + StagedCategories: []Category{{ID: "storage_pve", Type: CategoryTypePVE}}, + } + ui := &fakeRestoreWorkflowUI{ + applyStorageCfg: true, + applyDatacenterCfg: true, + } + + if err := runSafeClusterApplyWithUI(context.Background(), ui, exportRoot, newTestLogger(), plan); err != nil { + t.Fatalf("runSafeClusterApplyWithUI error: %v", err) + } + + for _, call := range runner.calls { + if strings.Contains(call, "/cluster/storage") || strings.Contains(call, "/cluster/config") { + t.Fatalf("storage/datacenter apply should be skipped for storage_pve staged restore; calls=%#v", runner.calls) + } + } +} From 481a2d63d08248accc438068d6a1eaf1c3b3cd64 Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Tue, 5 May 2026 14:37:47 +0200 Subject: [PATCH 17/35] Improve PVE collection, mail path & restore abort Return the first capture error from PVE collection loops (so collection continues but callers get notified) and surface errors from schedule captures. Add tests to assert capture-error behavior for backup history, replication status, and schedule collection. Introduce lookupAbsolutePath and use it to resolve systemctl/mailq paths (and use TrustedCommandContext/commandForMailTool) to make mail/service checks more robust. Clear stale restore abort info at start of runRestoreWorkflowWithUI and add a test ensuring abort info is cleared before validation. --- internal/backup/collector_pve.go | 30 +++-- .../collector_pve_capture_errors_test.go | 106 ++++++++++++++++++ internal/notify/email.go | 36 ++++-- .../restore_workflow_abort_test.go | 12 ++ internal/orchestrator/restore_workflow_ui.go | 1 + 5 files changed, 163 insertions(+), 22 deletions(-) create mode 100644 internal/backup/collector_pve_capture_errors_test.go diff --git a/internal/backup/collector_pve.go b/internal/backup/collector_pve.go index 815e88fb..60af6575 100644 --- a/internal/backup/collector_pve.go +++ b/internal/backup/collector_pve.go @@ -790,6 +790,7 @@ func (c *Collector) collectPVEBackupJobHistory(ctx context.Context, nodes []stri } seen := make(map[string]struct{}) + var firstErr error for _, node := range nodes { if err := ctx.Err(); err != nil { return err @@ -803,13 +804,15 @@ func (c *Collector) collectPVEBackupJobHistory(ctx context.Context, nodes []stri } seen[node] = struct{}{} outputPath := filepath.Join(jobsDir, fmt.Sprintf("%s_backup_history.json", node)) - c.captureCommandOutput(ctx, + if _, err := c.captureCommandOutput(ctx, commandSpec("pvesh", "get", fmt.Sprintf("/nodes/%s/tasks", node), "--output-format=json", "--typefilter=vzdump"), outputPath, fmt.Sprintf("%s backup history", node), - false) + false); err != nil && firstErr == nil { + firstErr = err + } } - return nil + return firstErr } func (c *Collector) collectPVEVZDumpCronSnapshot(ctx context.Context) error { @@ -888,11 +891,13 @@ func (c *Collector) collectPVEScheduleCrontab(ctx context.Context) error { return fmt.Errorf("failed to create schedules directory: %w", err) } - c.captureCommandOutput(ctx, + if _, err := c.captureCommandOutput(ctx, commandSpec("crontab", "-l"), filepath.Join(schedulesDir, "root_crontab.txt"), "root crontab", - false) + false); err != nil { + return fmt.Errorf("collectPVEScheduleCrontab: %w", err) + } return nil } @@ -904,11 +909,13 @@ func (c *Collector) collectPVEScheduleTimers(ctx context.Context) error { if err := c.ensureDir(schedulesDir); err != nil { return fmt.Errorf("failed to create schedules directory: %w", err) } - c.captureCommandOutput(ctx, + if _, err := c.captureCommandOutput(ctx, commandSpec("systemctl", "list-timers", "--all", "--no-pager"), filepath.Join(schedulesDir, "systemd_timers.txt"), "systemd timers", - false) + false); err != nil { + return fmt.Errorf("collectPVEScheduleTimers: %w", err) + } return nil } @@ -965,6 +972,7 @@ func (c *Collector) collectPVEReplicationStatus(ctx context.Context, nodes []str } seen := make(map[string]struct{}) + var firstErr error for _, node := range nodes { if err := ctx.Err(); err != nil { return err @@ -978,13 +986,15 @@ func (c *Collector) collectPVEReplicationStatus(ctx context.Context, nodes []str } seen[node] = struct{}{} outputPath := filepath.Join(repDir, fmt.Sprintf("%s_replication_status.json", node)) - c.captureCommandOutput(ctx, + if _, err := c.captureCommandOutput(ctx, commandSpec("pvesh", "get", fmt.Sprintf("/nodes/%s/replication", node), "--output-format=json"), outputPath, fmt.Sprintf("%s replication status", node), - false) + false); err != nil && firstErr == nil { + firstErr = err + } } - return nil + return firstErr } func (c *Collector) resolvePVEStorages(storages []pveStorageEntry) []pveStorageEntry { diff --git a/internal/backup/collector_pve_capture_errors_test.go b/internal/backup/collector_pve_capture_errors_test.go new file mode 100644 index 00000000..4626b4b1 --- /dev/null +++ b/internal/backup/collector_pve_capture_errors_test.go @@ -0,0 +1,106 @@ +package backup + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func newPVECollectorWithSuccessfulCommands(t *testing.T, calls *[]string) *Collector { + t.Helper() + return newPVECollectorWithDeps(t, CollectorDeps{ + LookPath: func(name string) (string, error) { + return "/bin/true", nil + }, + RunCommand: func(ctx context.Context, name string, args ...string) ([]byte, error) { + *calls = append(*calls, commandSpec(name, args...).String()) + return []byte("[]"), nil + }, + }) +} + +func TestCollectPVEBackupJobHistoryReturnsFirstCaptureError(t *testing.T) { + var calls []string + collector := newPVECollectorWithSuccessfulCommands(t, &calls) + + jobsDir := collector.pveJobsDir() + if err := os.MkdirAll(filepath.Join(jobsDir, "node-a_backup_history.json"), 0o755); err != nil { + t.Fatalf("create output collision: %v", err) + } + + err := collector.collectPVEBackupJobHistory(context.Background(), []string{"node-a", "node-b"}) + if err == nil { + t.Fatalf("expected capture error") + } + if !strings.Contains(err.Error(), "failed to write report") { + t.Fatalf("unexpected error: %v", err) + } + if len(calls) != 2 { + t.Fatalf("expected collection to continue after first capture error, calls=%#v", calls) + } +} + +func TestCollectPVEReplicationStatusReturnsFirstCaptureError(t *testing.T) { + var calls []string + collector := newPVECollectorWithSuccessfulCommands(t, &calls) + + repDir := collector.pveReplicationDir() + if err := os.MkdirAll(filepath.Join(repDir, "node-a_replication_status.json"), 0o755); err != nil { + t.Fatalf("create output collision: %v", err) + } + + err := collector.collectPVEReplicationStatus(context.Background(), []string{"node-a", "node-b"}) + if err == nil { + t.Fatalf("expected capture error") + } + if !strings.Contains(err.Error(), "failed to write report") { + t.Fatalf("unexpected error: %v", err) + } + if len(calls) != 2 { + t.Fatalf("expected collection to continue after first capture error, calls=%#v", calls) + } +} + +func TestCollectPVEScheduleCrontabReturnsCaptureError(t *testing.T) { + var calls []string + collector := newPVECollectorWithSuccessfulCommands(t, &calls) + + schedulesDir := collector.pveSchedulesDir() + if err := os.MkdirAll(filepath.Join(schedulesDir, "root_crontab.txt"), 0o755); err != nil { + t.Fatalf("create output collision: %v", err) + } + + err := collector.collectPVEScheduleCrontab(context.Background()) + if err == nil { + t.Fatalf("expected capture error") + } + if !strings.Contains(err.Error(), "collectPVEScheduleCrontab:") { + t.Fatalf("expected function context in error, got %v", err) + } + if len(calls) != 1 { + t.Fatalf("expected one command call, calls=%#v", calls) + } +} + +func TestCollectPVEScheduleTimersReturnsCaptureError(t *testing.T) { + var calls []string + collector := newPVECollectorWithSuccessfulCommands(t, &calls) + + schedulesDir := collector.pveSchedulesDir() + if err := os.MkdirAll(filepath.Join(schedulesDir, "systemd_timers.txt"), 0o755); err != nil { + t.Fatalf("create output collision: %v", err) + } + + err := collector.collectPVEScheduleTimers(context.Background()) + if err == nil { + t.Fatalf("expected capture error") + } + if !strings.Contains(err.Error(), "collectPVEScheduleTimers:") { + t.Fatalf("expected function context in error, got %v", err) + } + if len(calls) != 1 { + t.Fatalf("expected one command call, calls=%#v", calls) + } +} diff --git a/internal/notify/email.go b/internal/notify/email.go index 6a6ccdac..cefe2380 100644 --- a/internal/notify/email.go +++ b/internal/notify/email.go @@ -684,6 +684,17 @@ func commandForMailTool(ctx context.Context, pathOrName string, args ...string) return safeexec.CommandContext(ctx, pathOrName, args...) } +func lookupAbsolutePath(name string) (string, error) { + execPath, err := exec.LookPath(name) + if err != nil { + return "", err + } + if filepath.IsAbs(execPath) { + return execPath, nil + } + return filepath.Abs(execPath) +} + // sendViaRelay sends email via cloud relay func (e *EmailNotifier) sendViaRelay(ctx context.Context, recipient, subject, htmlBody, textBody string, data *NotificationData) error { // Build payload @@ -705,12 +716,13 @@ func (e *EmailNotifier) sendViaRelay(ctx context.Context, recipient, subject, ht func (e *EmailNotifier) isMTAServiceActive(ctx context.Context) (bool, string) { services := []string{"postfix", "sendmail", "exim4"} - if _, err := exec.LookPath("systemctl"); err != nil { + systemctlPath, err := lookupAbsolutePath("systemctl") + if err != nil { return false, "systemctl not available" } for _, service := range services { - cmd, err := safeexec.CommandContext(ctx, "systemctl", "is-active", service) + cmd, err := safeexec.TrustedCommandContext(ctx, systemctlPath, "is-active", service) if err != nil { return false, err.Error() } @@ -775,13 +787,12 @@ func (e *EmailNotifier) checkRelayHostConfigured(ctx context.Context) (bool, str // checkMailQueue checks the mail queue status func (e *EmailNotifier) checkMailQueue(ctx context.Context) (int, error) { // Try mailq command (works for both Postfix and Sendmail) - mailqPath := "/usr/bin/mailq" - if _, err := exec.LookPath("mailq"); err != nil { - if _, err := exec.LookPath(mailqPath); err != nil { + mailqPath, err := lookupAbsolutePath("mailq") + if err != nil { + mailqPath, err = lookupAbsolutePath("/usr/bin/mailq") + if err != nil { return 0, fmt.Errorf("mailq command not found") } - } else { - mailqPath = "mailq" } cmd, err := commandForMailTool(ctx, mailqPath) @@ -822,11 +833,12 @@ func (e *EmailNotifier) checkMailQueue(ctx context.Context) (int, error) { // detectQueueEntry scans the mail queue for a recipient and returns the latest queue ID. func (e *EmailNotifier) detectQueueEntry(ctx context.Context, recipient string) (string, string, error) { - mailqPath := "/usr/bin/mailq" - if _, err := exec.LookPath("mailq"); err == nil { - mailqPath = "mailq" - } else if _, err := exec.LookPath(mailqPath); err != nil { - return "", "", fmt.Errorf("mailq command not found") + mailqPath, err := lookupAbsolutePath("mailq") + if err != nil { + mailqPath, err = lookupAbsolutePath("/usr/bin/mailq") + if err != nil { + return "", "", fmt.Errorf("mailq command not found") + } } cmd, err := commandForMailTool(ctx, mailqPath) diff --git a/internal/orchestrator/restore_workflow_abort_test.go b/internal/orchestrator/restore_workflow_abort_test.go index 032330a2..27215e07 100644 --- a/internal/orchestrator/restore_workflow_abort_test.go +++ b/internal/orchestrator/restore_workflow_abort_test.go @@ -14,6 +14,18 @@ import ( "github.com/tis24dev/proxsave/internal/types" ) +func TestRunRestoreWorkflowWithUIClearsStaleAbortInfoBeforeValidation(t *testing.T) { + lastRestoreAbortInfo = &RestoreAbortInfo{NetworkRollbackArmed: true} + t.Cleanup(ClearRestoreAbortInfo) + + if err := runRestoreWorkflowWithUI(context.Background(), nil, nil, "vtest", nil); err == nil { + t.Fatalf("expected configuration error") + } + if got := GetLastRestoreAbortInfo(); got != nil { + t.Fatalf("expected stale abort info to be cleared, got %#v", got) + } +} + func TestRunRestoreWorkflow_FstabPromptInputAborted_AbortsWorkflow(t *testing.T) { origRestoreFS := restoreFS origRestoreCmd := restoreCmd diff --git a/internal/orchestrator/restore_workflow_ui.go b/internal/orchestrator/restore_workflow_ui.go index eb5a96f4..2ed294be 100644 --- a/internal/orchestrator/restore_workflow_ui.go +++ b/internal/orchestrator/restore_workflow_ui.go @@ -47,6 +47,7 @@ func prepareRestoreBundleWithUI(ctx context.Context, cfg *config.Config, logger } func runRestoreWorkflowWithUI(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (err error) { + ClearRestoreAbortInfo() if cfg == nil { return fmt.Errorf("configuration not available") } From 28071ad6bb3ddb1151daa22d2bd580b00d0b07ba Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Tue, 5 May 2026 15:05:02 +0200 Subject: [PATCH 18/35] Strip build metadata in version compare; doc fixes Ignore build metadata when comparing semantic versions by trimming any '+' suffix in isNewerVersion, and add unit tests covering build-metadata cases. Also update related docs and metadata: fix Codacy instructions YAML frontmatter formatting, update the PayPal donate link in README, and rename a config key in EXAMPLES.md (BACKUP_PXAR_FILES -> PXAR_SCAN_ENABLE). --- .github/instructions/codacy.instructions.md | 7 +++---- README.md | 2 +- cmd/proxsave/main_update.go | 5 ++++- cmd/proxsave/version_helpers_test.go | 2 ++ docs/EXAMPLES.md | 2 +- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/instructions/codacy.instructions.md b/.github/instructions/codacy.instructions.md index cb073c46..d42429a0 100644 --- a/.github/instructions/codacy.instructions.md +++ b/.github/instructions/codacy.instructions.md @@ -1,7 +1,6 @@ --- - description: Configuration for AI behavior when interacting with Codacy's MCP Server - applyTo: '**' ---- +description: Configuration for AI behavior when interacting with Codacy's MCP Server +applyTo: '**' --- # Codacy Rules Configuration for AI behavior when interacting with Codacy's MCP Server @@ -69,4 +68,4 @@ Configuration for AI behavior when interacting with Codacy's MCP Server - If the user accepts, run the `codacy_setup_repository` tool - Do not ever try to run the `codacy_setup_repository` tool on your own - After setup, immediately retry the action that failed (only retry once) ---- \ No newline at end of file +--- diff --git a/README.md b/README.md index dbc0e391..1823c9fd 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Proxmox PBS & PVE System Files Backup [![rclone](https://img.shields.io/badge/rclone-1.60+-136C9E.svg)](https://rclone.org/) [![💖 Sponsor](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-pink?logo=github)](https://github.com/sponsors/tis24dev) [![☕ Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-tis24dev-yellow?logo=buymeacoffee)](https://github.com/sponsors/tis24dev) -[![💸 Donate](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://www.paypal.com/donate/?hosted_button_id=&cmd=_donations&business=damigioanna%40gmail.com) +[![💸 Donate](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=damigioanna%40gmail.com) ## About the Project diff --git a/cmd/proxsave/main_update.go b/cmd/proxsave/main_update.go index 759c6105..6ce50983 100644 --- a/cmd/proxsave/main_update.go +++ b/cmd/proxsave/main_update.go @@ -81,7 +81,7 @@ func checkForUpdates(ctx context.Context, logger *logging.Logger, currentVersion } // isNewerVersion returns true if latest is strictly newer than current, -// comparing MAJOR.MINOR.PATCH (ignoring any leading 'v' and pre-release suffixes). +// comparing MAJOR.MINOR.PATCH (ignoring any leading 'v', pre-release suffixes, and build metadata). func isNewerVersion(current, latest string) bool { parse := func(v string) (int, int, int) { v = strings.TrimSpace(v) @@ -89,6 +89,9 @@ func isNewerVersion(current, latest string) bool { if i := strings.IndexByte(v, '-'); i >= 0 { v = v[:i] } + if i := strings.IndexByte(v, '+'); i >= 0 { + v = v[:i] + } parts := strings.Split(v, ".") toInt := func(s string) int { diff --git a/cmd/proxsave/version_helpers_test.go b/cmd/proxsave/version_helpers_test.go index 2b14c64e..bfa0890f 100644 --- a/cmd/proxsave/version_helpers_test.go +++ b/cmd/proxsave/version_helpers_test.go @@ -43,6 +43,8 @@ func TestIsNewerVersion(t *testing.T) { {"major newer", "1.9.9", "2.0.0", true}, {"strip leading v", "v1.2.3", "1.2.4", true}, {"ignore prerelease", "1.2.3-rc1", "1.2.3", false}, + {"ignore build metadata", "v1.2.3+current", "v1.2.4+latest", true}, + {"build metadata does not zero patch", "v1.2.3+current", "v1.2.3+latest", false}, {"missing patch treated as 0", "1.2", "1.2.0", false}, } diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 9aa85865..61b2bdae 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -940,7 +940,7 @@ BACKUP_PBS_NOTIFICATIONS=true BACKUP_PBS_NODE_CONFIG=true # Recommended for dual labs: keep diagnostics -BACKUP_PXAR_FILES=true +PXAR_SCAN_ENABLE=true ``` ### Expected Behavior From 140222ec460c76ea8dc561081c17e710fb8dad47 Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Tue, 5 May 2026 15:17:27 +0200 Subject: [PATCH 19/35] Improve restore extraction summary and guards Report failed files in restore summary and refine logging/guards. The restore summary now prints the number of failed files and includes them in the total archive count; summary log messages omit the "see detailed log" hint when no log path is configured. shouldSkipRestoreEntryTarget was tightened to match the /etc/pve boundary (exact match or trailing slash) to avoid false positives. Also changed a PBS user-list parse log from Debug to Warning and adjusted a couple of tests to account for whitespace-only config path handling. Added tests covering the restore summary and /etc/pve boundary behavior. --- internal/backup/collector_bricks_pbs.go | 2 +- .../backup/collector_pve_patterns_test.go | 2 +- internal/backup/collector_pve_util_test.go | 2 +- .../orchestrator/restore_archive_entries.go | 2 +- .../orchestrator/restore_archive_extract.go | 15 ++++- .../restore_archive_extract_summary_test.go | 65 +++++++++++++++++++ internal/orchestrator/restore_test.go | 23 +++++++ 7 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 internal/orchestrator/restore_archive_extract_summary_test.go diff --git a/internal/backup/collector_bricks_pbs.go b/internal/backup/collector_bricks_pbs.go index 098f14bc..87a96d3f 100644 --- a/internal/backup/collector_bricks_pbs.go +++ b/internal/backup/collector_bricks_pbs.go @@ -115,7 +115,7 @@ func newPBSUserConfigRecipe() recipe { state.pbs.userIDs = nil return nil } - state.collector.logger.Debug("Failed to parse user list for token export: %v", err) + state.collector.logger.Warning("Failed to parse user list for token export: %v", err) state.pbs.userIDs = nil return nil } diff --git a/internal/backup/collector_pve_patterns_test.go b/internal/backup/collector_pve_patterns_test.go index 9876f8f0..da7e6f84 100644 --- a/internal/backup/collector_pve_patterns_test.go +++ b/internal/backup/collector_pve_patterns_test.go @@ -208,7 +208,7 @@ func TestEffectivePVEClusterPath(t *testing.T) { }, { name: "whitespace only uses default", - configPath: "", + configPath: " ", expected: "/var/lib/pve-cluster", }, { diff --git a/internal/backup/collector_pve_util_test.go b/internal/backup/collector_pve_util_test.go index 7dccf8ae..b6451873 100644 --- a/internal/backup/collector_pve_util_test.go +++ b/internal/backup/collector_pve_util_test.go @@ -1122,7 +1122,7 @@ func TestEffectivePVEConfigPathDetailed(t *testing.T) { }, { name: "whitespace only uses default", - configPath: "", + configPath: " ", expected: "/etc/pve", }, { diff --git a/internal/orchestrator/restore_archive_entries.go b/internal/orchestrator/restore_archive_entries.go index 487dde72..0c9a9744 100644 --- a/internal/orchestrator/restore_archive_entries.go +++ b/internal/orchestrator/restore_archive_entries.go @@ -44,7 +44,7 @@ func shouldSkipRestoreEntryTarget(header *tar.Header, target, cleanDestRoot stri return false, nil } // Hard guard: never write directly into /etc/pve when restoring to system root - if strings.HasPrefix(target, "/etc/pve") { + if target == "/etc/pve" || strings.HasPrefix(target, "/etc/pve/") { logger.Warning("Skipping restore to %s (writes to /etc/pve are prohibited)", target) return true, nil } diff --git a/internal/orchestrator/restore_archive_extract.go b/internal/orchestrator/restore_archive_extract.go index f8964cee..521073ca 100644 --- a/internal/orchestrator/restore_archive_extract.go +++ b/internal/orchestrator/restore_archive_extract.go @@ -206,7 +206,8 @@ func (log *restoreExtractionLog) writeSummary(stats restoreExtractionStats) { fmt.Fprintf(log.logFile, "=== SUMMARY ===\n") fmt.Fprintf(log.logFile, "Total files extracted: %d\n", stats.filesExtracted) fmt.Fprintf(log.logFile, "Total files skipped: %d\n", stats.filesSkipped) - fmt.Fprintf(log.logFile, "Total files in archive: %d\n", stats.filesExtracted+stats.filesSkipped) + fmt.Fprintf(log.logFile, "Total files failed: %d\n", stats.filesFailed) + fmt.Fprintf(log.logFile, "Total files in archive: %d\n", stats.filesExtracted+stats.filesSkipped+stats.filesFailed) } func (log *restoreExtractionLog) copyTempEntries(tempFile *os.File, label string) { @@ -228,11 +229,19 @@ func logRestoreExtractionSummary(opts restoreArchiveOptions, stats restoreExtrac opts.logger.Info("Successfully restored all %d files/directories", stats.filesExtracted) } } else { - opts.logger.Warning("Restored %d files/directories; %d item(s) failed (see detailed log)", stats.filesExtracted, stats.filesFailed) + if opts.logFilePath != "" { + opts.logger.Warning("Restored %d files/directories; %d item(s) failed (see detailed log)", stats.filesExtracted, stats.filesFailed) + } else { + opts.logger.Warning("Restored %d files/directories; %d item(s) failed", stats.filesExtracted, stats.filesFailed) + } } if stats.filesSkipped > 0 { - opts.logger.Info("%d additional archive entries (logs, diagnostics, system defaults) were left unchanged on this system; see detailed log for details", stats.filesSkipped) + if opts.logFilePath != "" { + opts.logger.Info("%d additional archive entries (logs, diagnostics, system defaults) were left unchanged on this system; see detailed log for details", stats.filesSkipped) + } else { + opts.logger.Info("%d additional archive entries (logs, diagnostics, system defaults) were left unchanged on this system", stats.filesSkipped) + } } if opts.logFilePath != "" { diff --git a/internal/orchestrator/restore_archive_extract_summary_test.go b/internal/orchestrator/restore_archive_extract_summary_test.go new file mode 100644 index 00000000..5c1caaf6 --- /dev/null +++ b/internal/orchestrator/restore_archive_extract_summary_test.go @@ -0,0 +1,65 @@ +package orchestrator + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/types" +) + +func TestRestoreExtractionLogWriteSummaryIncludesFailedFiles(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "restore.log") + logFile, err := os.Create(logPath) + if err != nil { + t.Fatalf("create log file: %v", err) + } + + extractionLog := &restoreExtractionLog{ + logger: newTestLogger(), + logFile: logFile, + } + extractionLog.writeSummary(restoreExtractionStats{ + filesExtracted: 2, + filesSkipped: 3, + filesFailed: 4, + }) + if err := logFile.Close(); err != nil { + t.Fatalf("close log file: %v", err) + } + + content, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read log file: %v", err) + } + text := string(content) + if !strings.Contains(text, "Total files failed: 4") { + t.Fatalf("summary missing failed count:\n%s", text) + } + if !strings.Contains(text, "Total files in archive: 9") { + t.Fatalf("summary total should include failed files:\n%s", text) + } +} + +func TestLogRestoreExtractionSummaryOmitsDetailedLogHintWithoutLogPath(t *testing.T) { + var buf bytes.Buffer + logger := logging.New(types.LogLevelInfo, false) + logger.SetOutput(&buf) + + logRestoreExtractionSummary(restoreArchiveOptions{logger: logger}, restoreExtractionStats{ + filesExtracted: 2, + filesSkipped: 1, + filesFailed: 1, + }) + + output := buf.String() + if strings.Contains(output, "see detailed log") { + t.Fatalf("did not expect detailed log hint without log path:\n%s", output) + } + if !strings.Contains(output, "1 item(s) failed") { + t.Fatalf("expected failed count in summary:\n%s", output) + } +} diff --git a/internal/orchestrator/restore_test.go b/internal/orchestrator/restore_test.go index 9dcdfc00..36d2387b 100644 --- a/internal/orchestrator/restore_test.go +++ b/internal/orchestrator/restore_test.go @@ -63,6 +63,29 @@ func TestExtractTarEntry_BlocksPathTraversal(t *testing.T) { } } +func TestShouldSkipRestoreEntryTargetEtcPVEBoundary(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + header := &tar.Header{Name: "etc/pveuser.conf"} + + for _, target := range []string{"/etc/pve", "/etc/pve/local.cfg"} { + skip, err := shouldSkipRestoreEntryTarget(header, target, string(os.PathSeparator), logger) + if err != nil { + t.Fatalf("shouldSkipRestoreEntryTarget(%q) error: %v", target, err) + } + if !skip { + t.Fatalf("expected %q to be skipped", target) + } + } + + skip, err := shouldSkipRestoreEntryTarget(header, "/etc/pveuser.conf", string(os.PathSeparator), logger) + if err != nil { + t.Fatalf("shouldSkipRestoreEntryTarget false-positive path error: %v", err) + } + if skip { + t.Fatalf("did not expect /etc/pveuser.conf to match /etc/pve guard") + } +} + func TestExtractPlainArchive_WithFakeFS_RestoresFiles(t *testing.T) { origRestoreFS := restoreFS fakeFS := NewFakeFS() From 2529ebd172abfec558c0f8ee0840606ddcfc1f76 Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Tue, 5 May 2026 15:57:03 +0200 Subject: [PATCH 20/35] Add FS utimes/lchown and refactor command handling Add Lchown and UtimesNano to the FS interface and implement them in osFS and test fakes; update restore code to use restoreFS.Lchown/UtimesNano instead of direct os/syscall calls. Simplify environment runCommand to use safeexec.TrustedCommandContext and update tests to call echo via LookPath. Refactor Proxmox notification header parsing: introduce parseSectionHeader with a regexp-based validation and keep parseProxmoxNotificationHeader as a wrapper; update callers. Move panic handling in main lifecycle into a deferred function and remove an extra success print in config apply. Also include minor docs/formatting tweaks and add a test simulating execCommand failure in namespaces tests. --- .github/instructions/codacy.instructions.md | 40 +++++++++---------- cmd/proxsave/main_config_modes.go | 1 - cmd/proxsave/main_lifecycle.go | 18 +++++---- docs/RESTORE_GUIDE.md | 2 +- internal/environment/detect.go | 13 +----- .../environment/detect_additional_test.go | 7 +++- internal/orchestrator/deps.go | 7 ++++ internal/orchestrator/deps_test.go | 9 +++++ .../orchestrator/restore_archive_entries.go | 4 +- .../orchestrator/restore_cluster_apply.go | 2 +- internal/orchestrator/restore_errors_test.go | 7 ++++ .../orchestrator/restore_notifications.go | 20 +++++----- internal/pbs/namespaces_test.go | 17 ++++++++ 13 files changed, 92 insertions(+), 55 deletions(-) diff --git a/.github/instructions/codacy.instructions.md b/.github/instructions/codacy.instructions.md index d42429a0..9e27a080 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/cmd/proxsave/main_config_modes.go b/cmd/proxsave/main_config_modes.go index e47179ee..3ee7383c 100644 --- a/cmd/proxsave/main_config_modes.go +++ b/cmd/proxsave/main_config_modes.go @@ -153,7 +153,6 @@ func printConfigUpgradeDryRunResult(bootstrap *logging.BootstrapLogger, result * } func printConfigUpgradeApplyResult(bootstrap *logging.BootstrapLogger, result *config.UpgradeResult) { - bootstrap.Println("Configuration upgraded successfully!") if len(result.MissingKeys) > 0 { bootstrap.Printf("- Added %d missing key(s): %s", len(result.MissingKeys), strings.Join(result.MissingKeys, ", ")) diff --git a/cmd/proxsave/main_lifecycle.go b/cmd/proxsave/main_lifecycle.go index 54d82fae..449d2b63 100644 --- a/cmd/proxsave/main_lifecycle.go +++ b/cmd/proxsave/main_lifecycle.go @@ -32,14 +32,16 @@ func startMainRun() runBootstrap { } func finishMainRun(run runBootstrap) { - logging.DebugStepBootstrap(run.bootstrap, "main run", "exit_code=%d", run.state.finalExitCode) - run.runDone(nil) - if r := recover(); r != nil { - stack := debug.Stack() - run.bootstrap.Error("PANIC: %v", r) - fmt.Fprintf(os.Stderr, "panic: %v\n%s\n", r, stack) - os.Exit(types.ExitPanicError.Int()) - } + defer func() { + if r := recover(); r != nil { + stack := debug.Stack() + run.bootstrap.Error("PANIC: %v", r) + fmt.Fprintf(os.Stderr, "panic: %v\n%s\n", r, stack) + os.Exit(types.ExitPanicError.Int()) + } + logging.DebugStepBootstrap(run.bootstrap, "main run", "exit_code=%d", run.state.finalExitCode) + run.runDone(nil) + }() } func preparePreRuntimeArgs(ctx context.Context, bootstrap *logging.BootstrapLogger, toolVersion string) (*cli.Args, int, bool) { diff --git a/docs/RESTORE_GUIDE.md b/docs/RESTORE_GUIDE.md index 6c11f31b..05bc7172 100644 --- a/docs/RESTORE_GUIDE.md +++ b/docs/RESTORE_GUIDE.md @@ -510,7 +510,7 @@ Backup system type: Proxmox Virtual Environment (PVE) ``` **Partial-compatibility warning**: -``` +```text ⚠ WARNING: Partial compatibility detected Current system: Proxmox Virtual Environment (PVE) diff --git a/internal/environment/detect.go b/internal/environment/detect.go index 49ef60b9..a6fd841d 100644 --- a/internal/environment/detect.go +++ b/internal/environment/detect.go @@ -47,8 +47,7 @@ var ( "/etc/apt/sources.list.d/proxmox.list", } - lookPathFunc = exec.LookPath - commandContextFunc = safeexec.TrustedCommandContext + lookPathFunc = exec.LookPath readFileFunc = os.ReadFile statFunc = os.Stat @@ -342,15 +341,7 @@ func runCommand(command string, args ...string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) defer cancel() - var ( - cmd *exec.Cmd - cmdErr error - ) - if filepath.IsAbs(command) { - cmd, cmdErr = commandContextFunc(ctx, command, args...) - } else { - cmd, cmdErr = safeexec.CommandContext(ctx, command, args...) - } + cmd, cmdErr := safeexec.TrustedCommandContext(ctx, command, args...) if cmdErr != nil { return "", cmdErr } diff --git a/internal/environment/detect_additional_test.go b/internal/environment/detect_additional_test.go index 11e5c583..d8d347c9 100644 --- a/internal/environment/detect_additional_test.go +++ b/internal/environment/detect_additional_test.go @@ -3,6 +3,7 @@ package environment import ( "context" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -151,7 +152,11 @@ func TestContainsAny(t *testing.T) { // TestRunCommand tests command execution with timeout func TestRunCommand(t *testing.T) { // Test successful command - output, err := runCommand("echo", "test") + echoPath, err := exec.LookPath("echo") + if err != nil { + t.Fatalf("LookPath(echo) failed: %v", err) + } + output, err := runCommand(echoPath, "test") if err != nil { t.Errorf("runCommand() error = %v", err) } diff --git a/internal/orchestrator/deps.go b/internal/orchestrator/deps.go index 3e7d1f56..9a5099a4 100644 --- a/internal/orchestrator/deps.go +++ b/internal/orchestrator/deps.go @@ -7,6 +7,7 @@ import ( "io/fs" "os" "os/exec" + "syscall" "time" "github.com/tis24dev/proxsave/internal/config" @@ -33,6 +34,8 @@ type FS interface { CreateTemp(dir, pattern string) (*os.File, error) MkdirTemp(dir, pattern string) (string, error) Rename(oldpath, newpath string) error + Lchown(path string, uid, gid int) error + UtimesNano(path string, times []syscall.Timespec) error } // Prompter encapsulates interactive prompts. @@ -94,6 +97,10 @@ func (osFS) CreateTemp(dir, pattern string) (*os.File, error) { } func (osFS) MkdirTemp(dir, pattern string) (string, error) { return os.MkdirTemp(dir, pattern) } func (osFS) Rename(oldpath, newpath string) error { return os.Rename(oldpath, newpath) } +func (osFS) Lchown(path string, uid, gid int) error { return os.Lchown(path, uid, gid) } +func (osFS) UtimesNano(path string, times []syscall.Timespec) error { + return syscall.UtimesNano(path, times) +} type consolePrompter struct{} diff --git a/internal/orchestrator/deps_test.go b/internal/orchestrator/deps_test.go index 076220fe..dea9b1e3 100644 --- a/internal/orchestrator/deps_test.go +++ b/internal/orchestrator/deps_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strings" + "syscall" "testing" "time" @@ -186,6 +187,14 @@ func (f *FakeFS) Rename(oldpath, newpath string) error { return os.Rename(f.onDisk(oldpath), f.onDisk(newpath)) } +func (f *FakeFS) Lchown(path string, uid, gid int) error { + return os.Lchown(f.onDisk(path), uid, gid) +} + +func (f *FakeFS) UtimesNano(path string, times []syscall.Timespec) error { + return syscall.UtimesNano(f.onDisk(path), times) +} + // FakeTime provides deterministic time. type FakeTime struct { Current time.Time diff --git a/internal/orchestrator/restore_archive_entries.go b/internal/orchestrator/restore_archive_entries.go index 0c9a9744..0411c84b 100644 --- a/internal/orchestrator/restore_archive_entries.go +++ b/internal/orchestrator/restore_archive_entries.go @@ -219,7 +219,7 @@ func extractSymlink(target string, header *tar.Header, destRoot string, logger * } // Set ownership (on the symlink itself, not the target) - if err := os.Lchown(target, header.Uid, header.Gid); err != nil { + if err := restoreFS.Lchown(target, header.Uid, header.Gid); err != nil { logger.Debug("Failed to lchown symlink %s: %v", target, err) } @@ -269,7 +269,7 @@ func setTimestamps(target string, header *tar.Header) error { {Sec: mtime.Unix(), Nsec: int64(mtime.Nanosecond())}, } - if err := syscall.UtimesNano(target, times); err != nil { + if err := restoreFS.UtimesNano(target, times); err != nil { return fmt.Errorf("set atime/mtime: %w", err) } diff --git a/internal/orchestrator/restore_cluster_apply.go b/internal/orchestrator/restore_cluster_apply.go index 3445ba09..227331b4 100644 --- a/internal/orchestrator/restore_cluster_apply.go +++ b/internal/orchestrator/restore_cluster_apply.go @@ -289,7 +289,7 @@ func parseStorageBlocks(cfgPath string) ([]storageBlock, error) { // storage.cfg blocks use `: ` (e.g. `dir: local`, `nfs: backup`). // Older exports may still use `storage: ` blocks. - _, name, ok := parseProxmoxNotificationHeader(trimmed) + _, name, ok := parseSectionHeader(trimmed) if ok { flush() current = &storageBlock{ID: name, data: []string{line}} diff --git a/internal/orchestrator/restore_errors_test.go b/internal/orchestrator/restore_errors_test.go index 2df7c3a7..c570ca30 100644 --- a/internal/orchestrator/restore_errors_test.go +++ b/internal/orchestrator/restore_errors_test.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "strings" + "syscall" "testing" "time" @@ -882,6 +883,12 @@ func (f *ErrorInjectingFS) MkdirTemp(dir, pattern string) (string, error) { func (f *ErrorInjectingFS) Rename(oldpath, newpath string) error { return f.base.Rename(oldpath, newpath) } +func (f *ErrorInjectingFS) Lchown(path string, uid, gid int) error { + return f.base.Lchown(path, uid, gid) +} +func (f *ErrorInjectingFS) UtimesNano(path string, times []syscall.Timespec) error { + return f.base.UtimesNano(path, times) +} func (f *ErrorInjectingFS) MkdirAll(path string, perm os.FileMode) error { if f.mkdirAllErr != nil { diff --git a/internal/orchestrator/restore_notifications.go b/internal/orchestrator/restore_notifications.go index df841569..8c6305d7 100644 --- a/internal/orchestrator/restore_notifications.go +++ b/internal/orchestrator/restore_notifications.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" "github.com/tis24dev/proxsave/internal/logging" @@ -23,6 +24,8 @@ type proxmoxNotificationSection struct { RedactFlags []string } +var sectionHeaderTypePattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + func maybeApplyNotificationsFromStage(ctx context.Context, logger *logging.Logger, plan *RestorePlan, stageRoot string, dryRun bool) (err error) { if plan == nil { return nil @@ -401,7 +404,7 @@ func parseProxmoxNotificationSections(content string) ([]proxmoxNotificationSect return out, nil } -func parseProxmoxNotificationHeader(line string) (typ, name string, ok bool) { +func parseSectionHeader(line string) (typ, name string, ok bool) { idx := strings.Index(line, ":") if idx <= 0 { return "", "", false @@ -411,19 +414,16 @@ func parseProxmoxNotificationHeader(line string) (typ, name string, ok bool) { if typ == "" || name == "" { return "", "", false } - for _, r := range typ { - switch { - case r >= 'a' && r <= 'z': - case r >= 'A' && r <= 'Z': - case r >= '0' && r <= '9': - case r == '-' || r == '_': - default: - return "", "", false - } + if !sectionHeaderTypePattern.MatchString(typ) { + return "", "", false } return typ, name, true } +func parseProxmoxNotificationHeader(line string) (typ, name string, ok bool) { + return parseSectionHeader(line) +} + func parseProxmoxNotificationKV(line string) (key, value string) { fields := strings.Fields(line) if len(fields) == 0 { diff --git a/internal/pbs/namespaces_test.go b/internal/pbs/namespaces_test.go index 2ff65c1a..22e7a473 100644 --- a/internal/pbs/namespaces_test.go +++ b/internal/pbs/namespaces_test.go @@ -3,6 +3,7 @@ package pbs import ( "context" "encoding/json" + "errors" "fmt" "os" "os/exec" @@ -192,6 +193,13 @@ func TestListNamespacesViaCLI_ErrorIncludesStderr(t *testing.T) { } } +func TestListNamespacesViaCLI_ExecCommandError(t *testing.T) { + setExecCommandStub(t, "cmd-failure") + if _, err := listNamespacesViaCLI(context.Background(), "dummy"); err == nil || !strings.Contains(err.Error(), "simulated execCommand failure") { + t.Fatalf("expected execCommand error, got %v", err) + } +} + func TestHelperProcess(t *testing.T) { if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { return @@ -213,6 +221,15 @@ func TestHelperProcess(t *testing.T) { func setExecCommandStub(t *testing.T, scenario string) { t.Helper() original := execCommand + if scenario == "cmd-failure" { + execCommand = func(context.Context, string, ...string) (*exec.Cmd, error) { + return nil, errors.New("simulated execCommand failure") + } + t.Cleanup(func() { + execCommand = original + }) + return + } execCommand = func(context.Context, string, ...string) (*exec.Cmd, error) { cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcess", "--") cmd.Env = append(os.Environ(), From 3048e3cf27265f5de9dcdda0801dcf9b085d79b1 Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Tue, 5 May 2026 21:35:35 +0200 Subject: [PATCH 21/35] Refactor command execution and runtime handling Centralize and harden command execution: introduce runAndClassifyCommand with commandRunOptions/result and classification enums; refactor safeCmdOutput and captureCommandOutput to reuse this logic, improving logging, output summarization, unprivileged-container handling, systemctl special cases, and dry-run/lookpath handling. Runtime and signal updates: detect unprivileged container earlier in bootstrap (rt.unprivilegedInfo), remove duplicate detection in logging, stop signal notification on exit, and make stdin-close handling safe via a scoped sync.Once. Also fix heap profiling to defer file close. Other changes: make pveContext methods use pointer receivers and nil-checks for safety; enforce Pushover webhook auth (require Token and User) and add tests; expand safeexec tests to validate the list of allowed commands. --- cmd/proxsave/main_defers.go | 8 +- cmd/proxsave/main_runtime.go | 1 + cmd/proxsave/main_runtime_log.go | 2 - cmd/proxsave/main_signals.go | 25 +- internal/backup/collector.go | 270 +++++++++--------- .../backup/collector_bricks_pve_finalize.go | 12 +- internal/notify/webhook.go | 10 + internal/notify/webhook_test.go | 40 +++ internal/safeexec/safeexec_test.go | 19 +- 9 files changed, 228 insertions(+), 159 deletions(-) diff --git a/cmd/proxsave/main_defers.go b/cmd/proxsave/main_defers.go index 0ed07345..e138610a 100644 --- a/cmd/proxsave/main_defers.go +++ b/cmd/proxsave/main_defers.go @@ -14,6 +14,12 @@ import ( type runDeferredAction func() func runDeferredActions(rt *appRuntime, state *appRunState) []runDeferredAction { + // runRuntime defers each returned action while iterating this slice, so these + // entries execute in reverse (LIFO) order. Keep the ordering intentional: + // dispatchDeferredEarlyErrorNotification must run before sendDeferredSupportEmail + // because it sets state.pendingSupportStat, which sendDeferredSupportEmail + // reads. Do not reorder these entries or change the defer pattern without + // preserving that dependency. return []runDeferredAction{ func() { if state.showSummary { @@ -79,8 +85,8 @@ func closeRunProfiling(rt *appRuntime) { logging.Warning("Failed to create heap profile file: %v", err) return } + defer f.Close() if err := pprof.WriteHeapProfile(f); err != nil { logging.Warning("Failed to write heap profile: %v", err) } - _ = f.Close() } diff --git a/cmd/proxsave/main_runtime.go b/cmd/proxsave/main_runtime.go index a30818a4..90f4e648 100644 --- a/cmd/proxsave/main_runtime.go +++ b/cmd/proxsave/main_runtime.go @@ -78,6 +78,7 @@ func bootstrapRuntime(ctx context.Context, args *cli.Args, bootstrap *logging.Bo rt.updateInfo = checkForUpdates(ctx, rt.logger, toolVersion) applyRunPermissions(rt) initializeRunProfiling(rt) + rt.unprivilegedInfo = environment.DetectUnprivilegedContainer() return rt, types.ExitSuccess.Int(), true } diff --git a/cmd/proxsave/main_runtime_log.go b/cmd/proxsave/main_runtime_log.go index df4513f1..2031a558 100644 --- a/cmd/proxsave/main_runtime_log.go +++ b/cmd/proxsave/main_runtime_log.go @@ -4,7 +4,6 @@ package main import ( "strings" - "github.com/tis24dev/proxsave/internal/environment" "github.com/tis24dev/proxsave/internal/logging" ) @@ -12,7 +11,6 @@ func logRunContext(rt *appRuntime) { logRunDryRunStatus(rt) baseDirSource := runBaseDirSource(rt) logging.Info("Environment: %s %s", rt.envInfo.Type, rt.envInfo.Version) - rt.unprivilegedInfo = environment.DetectUnprivilegedContainer() logUserNamespaceContext(rt.logger, rt.unprivilegedInfo) logging.Info("Backup enabled: %v", rt.cfg.BackupEnabled) logging.Info("Debug level: %s", rt.logLevel.String()) diff --git a/cmd/proxsave/main_signals.go b/cmd/proxsave/main_signals.go index d01d1532..1274ba91 100644 --- a/cmd/proxsave/main_signals.go +++ b/cmd/proxsave/main_signals.go @@ -12,24 +12,27 @@ import ( "github.com/tis24dev/proxsave/internal/tui" ) -var closeStdinOnce sync.Once - func setupRunContext(bootstrap *logging.BootstrapLogger) (context.Context, context.CancelFunc) { ctx, cancel := context.WithCancel(context.Background()) tui.SetAbortContext(ctx) + var closeStdinOnce sync.Once sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) go func() { - sig := <-sigChan - logging.DebugStepBootstrap(bootstrap, "signal", "received=%v", sig) - bootstrap.Info("\nReceived signal %v, initiating graceful shutdown...", sig) - cancel() - closeStdinOnce.Do(func() { - if file := os.Stdin; file != nil { - _ = file.Close() - } - }) + defer signal.Stop(sigChan) + select { + case sig := <-sigChan: + logging.DebugStepBootstrap(bootstrap, "signal", "received=%v", sig) + bootstrap.Info("\nReceived signal %v, initiating graceful shutdown...", sig) + cancel() + closeStdinOnce.Do(func() { + if file := os.Stdin; file != nil { + _ = file.Close() + } + }) + case <-ctx.Done(): + } }() return ctx, cancel diff --git a/internal/backup/collector.go b/internal/backup/collector.go index 7bd627bc..da2d7faf 100644 --- a/internal/backup/collector.go +++ b/internal/backup/collector.go @@ -968,38 +968,68 @@ func (c *Collector) safeCopyDir(ctx context.Context, src, dest, description stri return nil } -func (c *Collector) safeCmdOutput(ctx context.Context, spec CommandSpec, output, description string, critical bool) error { +type commandRunClassification int + +const ( + commandRunSucceeded commandRunClassification = iota + commandRunSkipped + commandRunNonCriticalFailure + commandRunDowngradedToSkip + commandRunCriticalFailure +) + +type commandRunOptions struct { + output string + description string + caller string + critical bool + logCollection bool + handleSystemctlStatus bool +} + +type commandRunResult struct { + output []byte + classification commandRunClassification + exitCode int + outputSummary string + contextInfo unprivilegedContainerContext +} + +func (c *Collector) runAndClassifyCommand(ctx context.Context, spec CommandSpec, opts commandRunOptions) (commandRunResult, error) { + result := commandRunResult{classification: commandRunSkipped, exitCode: -1} if err := ctx.Err(); err != nil { - return err + return result, err } if err := spec.validate(); err != nil { - return err + return result, err } - if output != "" && c.shouldExclude(output) { - c.logger.Debug("Skipping %s: output %s excluded by pattern", description, output) + if opts.output != "" && c.shouldExclude(opts.output) { + c.logger.Debug("Skipping %s: output %s excluded by pattern", opts.description, opts.output) c.incFilesSkipped() - return nil + return result, nil } - c.logger.Debug("Collecting %s via command: %s > %s", description, spec.String(), output) + cmdString := spec.String() + if opts.logCollection { + c.logger.Debug("Collecting %s via command: %s > %s", opts.description, cmdString, opts.output) + } - // Check if command exists if _, err := c.depLookPath(spec.Name); err != nil { - if critical { + if opts.critical { c.incFilesFailed() - return fmt.Errorf("critical command not available: %s", spec.Name) + result.classification = commandRunCriticalFailure + return result, fmt.Errorf("critical command not available: %s", spec.Name) } - c.logger.Debug("Command not available: %s (skipping %s)", spec.Name, description) - return nil + c.logger.Debug("Command not available: %s (skipping %s)", spec.Name, opts.description) + return result, nil } if c.dryRun { - c.logger.Debug("[DRY RUN] Would execute command: %s > %s", spec.String(), output) - return nil + c.logger.Debug("[DRY RUN] Would execute command: %s > %s", cmdString, opts.output) + return result, nil } - cmdString := spec.String() runCtx := ctx var cancel context.CancelFunc if spec.Name == "pvesh" && c.config != nil && c.config.PveshTimeoutSeconds > 0 { @@ -1010,10 +1040,13 @@ func (c *Collector) safeCmdOutput(ctx context.Context, spec CommandSpec, output, } out, err := c.depRunCommand(runCtx, spec.Name, spec.Args...) + result.output = out if err != nil { - if critical { + result.outputSummary = summarizeCommandOutputText(string(out)) + if opts.critical { c.incFilesFailed() - return fmt.Errorf("critical command `%s` failed for %s: %w (output: %s)", cmdString, description, err, summarizeCommandOutputText(string(out))) + result.classification = commandRunCriticalFailure + return result, fmt.Errorf("critical command `%s` failed for %s: %w (output: %s)", cmdString, opts.description, err, result.outputSummary) } exitCode := -1 var exitErr *exec.ExitError @@ -1021,11 +1054,15 @@ func (c *Collector) safeCmdOutput(ctx context.Context, spec CommandSpec, output, exitCode = exitErr.ExitCode() } outputText := strings.TrimSpace(string(out)) + result.exitCode = exitCode + result.outputSummary = summarizeCommandOutputText(outputText) + result.classification = commandRunNonCriticalFailure - c.logger.Debug("Non-critical command failed (safeCmdOutput): description=%q cmd=%q exitCode=%d err=%v", description, cmdString, exitCode, err) - c.logger.Debug("Non-critical command output summary (safeCmdOutput): %s", summarizeCommandOutputText(outputText)) + c.logger.Debug("Non-critical command failed (%s): description=%q cmd=%q exitCode=%d err=%v", opts.caller, opts.description, cmdString, exitCode, err) + c.logger.Debug("Non-critical command output summary (%s): %s", opts.caller, result.outputSummary) ctxInfo := c.depDetectUnprivilegedContainer() + result.contextInfo = ctxInfo c.logger.Debug("Privilege context evaluation: detected=%t details=%q", ctxInfo.Detected, strings.TrimSpace(ctxInfo.Details)) reason := "" @@ -1039,32 +1076,83 @@ func (c *Collector) safeCmdOutput(ctx context.Context, spec CommandSpec, output, } if ctxInfo.Detected && reason != "" { - c.logger.Debug("Downgrading WARNING->SKIP: description=%q cmd=%q exitCode=%d", description, cmdString, exitCode) + result.classification = commandRunDowngradedToSkip + c.logger.Debug("Downgrading WARNING->SKIP: description=%q cmd=%q exitCode=%d", opts.description, cmdString, exitCode) - c.logger.Skip("Skipping %s: %s (Expected with limited privileges).", description, reason) - c.logger.Debug("SKIP context (privilege-sensitive): description=%q cmd=%q exitCode=%d err=%v contextDetails=%q", description, cmdString, exitCode, err, strings.TrimSpace(ctxInfo.Details)) - c.logger.Debug("SKIP output summary for %s: %s", description, summarizeCommandOutputText(outputText)) - return nil + c.logger.Skip("Skipping %s: %s (Expected with limited privileges).", opts.description, reason) + c.logger.Debug("SKIP context (privilege-sensitive): description=%q cmd=%q exitCode=%d err=%v contextDetails=%q", opts.description, cmdString, exitCode, err, strings.TrimSpace(ctxInfo.Details)) + c.logger.Debug("SKIP output summary for %s: %s", opts.description, result.outputSummary) + return result, nil } if ctxInfo.Detected { - c.logger.Debug("No privilege-sensitive downgrade applied: command=%q did not match known patterns; emitting WARNING", spec.Name) + if opts.handleSystemctlStatus { + c.logger.Debug("No privilege-sensitive downgrade applied: command=%q did not match known patterns; continuing with standard handling", spec.Name) + } else { + c.logger.Debug("No privilege-sensitive downgrade applied: command=%q did not match known patterns; emitting WARNING", spec.Name) + } } - 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", - description, - cmdString, - err, - summarizeCommandOutputText(outputText), - ) - return nil // Non-critical failure + if opts.handleSystemctlStatus && spec.Name == "systemctl" && len(spec.Args) >= 2 && spec.Args[0] == "status" { + unit := spec.Args[len(spec.Args)-1] + if exitCode == 4 || strings.Contains(outputText, "could not be found") { + c.logger.Warning("Skipping %s: %s.service not found (not installed?). Set BACKUP_FIREWALL_RULES=false to disable.", + opts.description, + unit, + ) + return result, nil + } + if strings.Contains(outputText, "Failed to connect to system scope bus") || strings.Contains(outputText, "System has not been booted with systemd") { + c.logger.Warning("Skipping %s: systemd is not available/accessible in this environment. Non-critical; backup continues. Output: %s", + opts.description, + result.outputSummary, + ) + return result, nil + } + } + + if opts.handleSystemctlStatus { + c.logger.Warning("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, + cmdString, + err, + result.outputSummary, + ) + } + return result, nil } - if err := c.writeReportFile(output, out); err != nil { + result.classification = commandRunSucceeded + return result, nil +} + +func (c *Collector) safeCmdOutput(ctx context.Context, spec CommandSpec, output, description string, critical bool) error { + result, err := c.runAndClassifyCommand(ctx, spec, commandRunOptions{ + output: output, + description: description, + caller: "safeCmdOutput", + critical: critical, + logCollection: true, + }) + if err != nil { return err } + if result.classification != commandRunSucceeded { + return nil + } - c.logger.Debug("Successfully collected %s via command: %s", description, cmdString) + 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 } @@ -1331,117 +1419,25 @@ func (c *Collector) writeReportFile(path string, data []byte) error { } func (c *Collector) captureCommandOutput(ctx context.Context, spec CommandSpec, output, description string, critical bool) ([]byte, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - if err := spec.validate(); err != nil { + result, err := c.runAndClassifyCommand(ctx, spec, commandRunOptions{ + output: output, + description: description, + caller: "captureCommandOutput", + critical: critical, + handleSystemctlStatus: true, + }) + if err != nil { return nil, err } - - if output != "" && c.shouldExclude(output) { - c.logger.Debug("Skipping %s: output %s excluded by pattern", description, output) - c.incFilesSkipped() - return nil, nil - } - - if _, err := c.depLookPath(spec.Name); err != nil { - if critical { - c.incFilesFailed() - return nil, fmt.Errorf("critical command not available: %s", spec.Name) - } - c.logger.Debug("Command not available: %s (skipping %s)", spec.Name, description) - return nil, nil - } - - if c.dryRun { - c.logger.Debug("[DRY RUN] Would execute command: %s > %s", spec.String(), output) - return nil, nil - } - - runCtx := ctx - var cancel context.CancelFunc - if spec.Name == "pvesh" && c.config != nil && c.config.PveshTimeoutSeconds > 0 { - runCtx, cancel = context.WithTimeout(ctx, time.Duration(c.config.PveshTimeoutSeconds)*time.Second) - } - if cancel != nil { - defer cancel() - } - - out, err := c.depRunCommand(runCtx, spec.Name, spec.Args...) - if err != nil { - cmdString := spec.String() - if critical { - c.incFilesFailed() - return nil, fmt.Errorf("critical command `%s` failed for %s: %w (output: %s)", cmdString, description, err, summarizeCommandOutputText(string(out))) - } - exitCode := -1 - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - exitCode = exitErr.ExitCode() - } - outputText := strings.TrimSpace(string(out)) - - c.logger.Debug("Non-critical command failed (captureCommandOutput): description=%q cmd=%q exitCode=%d err=%v", description, cmdString, exitCode, err) - c.logger.Debug("Non-critical command output summary (captureCommandOutput): %s", summarizeCommandOutputText(outputText)) - - ctxInfo := c.depDetectUnprivilegedContainer() - c.logger.Debug("Privilege context evaluation: detected=%t details=%q", ctxInfo.Detected, strings.TrimSpace(ctxInfo.Details)) - - reason := "" - if ctxInfo.Detected { - c.logger.Debug("Privilege-sensitive allowlist: command=%q allowlisted=%t", spec.Name, isPrivilegeSensitiveCommand(spec.Name)) - match := privilegeSensitiveFailureMatch(spec.Name, exitCode, outputText) - reason = match.Reason - c.logger.Debug("Privilege-sensitive classification: command=%q matched=%t match=%q reason=%q", spec.Name, reason != "", match.Match, reason) - } else { - c.logger.Debug("Privilege-sensitive downgrade not considered: limited-privilege context not detected (command=%q)", spec.Name) - } - - if ctxInfo.Detected && reason != "" { - c.logger.Debug("Downgrading WARNING->SKIP: description=%q cmd=%q exitCode=%d", description, cmdString, exitCode) - - c.logger.Skip("Skipping %s: %s (Expected with limited privileges).", description, reason) - c.logger.Debug("SKIP context (privilege-sensitive): description=%q cmd=%q exitCode=%d err=%v contextDetails=%q", description, cmdString, exitCode, err, strings.TrimSpace(ctxInfo.Details)) - c.logger.Debug("SKIP output summary for %s: %s", description, summarizeCommandOutputText(outputText)) - return nil, nil - } - - if ctxInfo.Detected { - c.logger.Debug("No privilege-sensitive downgrade applied: command=%q did not match known patterns; continuing with standard handling", spec.Name) - } - - if spec.Name == "systemctl" && len(spec.Args) >= 2 && spec.Args[0] == "status" { - unit := spec.Args[len(spec.Args)-1] - if exitCode == 4 || strings.Contains(outputText, "could not be found") { - c.logger.Warning("Skipping %s: %s.service not found (not installed?). Set BACKUP_FIREWALL_RULES=false to disable.", - description, - unit, - ) - return nil, nil - } - if strings.Contains(outputText, "Failed to connect to system scope bus") || strings.Contains(outputText, "System has not been booted with systemd") { - c.logger.Warning("Skipping %s: systemd is not available/accessible in this environment. Non-critical; backup continues. Output: %s", - description, - summarizeCommandOutputText(outputText), - ) - return nil, nil - } - } - - c.logger.Warning("Skipping %s: command `%s` failed (%v). Non-critical; backup continues. Output: %s", - description, - cmdString, - err, - summarizeCommandOutputText(outputText), - ) + if result.classification != commandRunSucceeded { return nil, nil } - if err := c.writeReportFile(output, out); err != nil { + if err := c.writeReportFile(output, result.output); err != nil { return nil, err } - return out, nil + return result.output, nil } func (c *Collector) collectCommandMulti(ctx context.Context, spec CommandSpec, output, description string, critical bool, mirrors ...string) error { diff --git a/internal/backup/collector_bricks_pve_finalize.go b/internal/backup/collector_bricks_pve_finalize.go index 1585ff9b..9753a921 100644 --- a/internal/backup/collector_bricks_pve_finalize.go +++ b/internal/backup/collector_bricks_pve_finalize.go @@ -127,15 +127,15 @@ func newPVEManifestBricks() []collectionBrick { } } -func (p pveContext) runtimeNodes() []string { - if p.runtimeInfo == nil { +func (p *pveContext) runtimeNodes() []string { + if p == nil || p.runtimeInfo == nil { return nil } return p.runtimeInfo.Nodes } -func (p pveContext) runtimeStorages() []pveStorageEntry { - if p.runtimeInfo == nil { +func (p *pveContext) runtimeStorages() []pveStorageEntry { + if p == nil || p.runtimeInfo == nil { return nil } return p.runtimeInfo.Storages @@ -148,8 +148,8 @@ func (p *pveContext) ensureStorageScanResults() map[string]*pveStorageScanResult return p.storageScanResults } -func (p pveContext) storageResult(storage pveStorageEntry) *pveStorageScanResult { - if p.storageScanResults == nil { +func (p *pveContext) storageResult(storage pveStorageEntry) *pveStorageScanResult { + if p == nil || p.storageScanResults == nil { return nil } return p.storageScanResults[storage.pathKey()] diff --git a/internal/notify/webhook.go b/internal/notify/webhook.go index a6b35d79..08c5f629 100644 --- a/internal/notify/webhook.go +++ b/internal/notify/webhook.go @@ -81,6 +81,16 @@ func NewWebhookNotifier(webhookConfig *config.WebhookConfig, logger *logging.Log format := resolveWebhookFormat(ep.Format, webhookConfig.DefaultFormat) method := resolveWebhookMethod(ep.Method) if strings.EqualFold(format, "pushover") { + missing := []string{} + if ep.Auth.Token == "" { + missing = append(missing, "token") + } + if ep.Auth.User == "" { + missing = append(missing, "user") + } + if len(missing) > 0 { + return nil, fmt.Errorf("webhook endpoint %q: Pushover requires Auth.Token and Auth.User; missing %s", ep.Name, strings.Join(missing, "/")) + } if ep.Priority < -2 || ep.Priority > 1 { return nil, fmt.Errorf("webhook endpoint %q: PRIORITY must be in range -2..1 (got %d); priority 2 (emergency) is not supported", ep.Name, ep.Priority) } diff --git a/internal/notify/webhook_test.go b/internal/notify/webhook_test.go index ad639690..da918043 100644 --- a/internal/notify/webhook_test.go +++ b/internal/notify/webhook_test.go @@ -1318,3 +1318,43 @@ func TestNewWebhookNotifier_PushoverMethod(t *testing.T) { }) } } + +func TestNewWebhookNotifier_PushoverAuthRequired(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + + tests := []struct { + name string + token string + user string + missing string + }{ + {name: "missing token", token: "", user: "user-key-xyz", missing: "missing token"}, + {name: "missing user", token: "app-token-abc", user: "", missing: "missing user"}, + {name: "missing both", token: "", user: "", missing: "missing token/user"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ep := pushoverTestEndpoint(0) + ep.Auth.Token = tt.token + ep.Auth.User = tt.user + + cfg := &config.WebhookConfig{ + Enabled: true, + Timeout: 30, + Endpoints: []config.WebhookEndpoint{ep}, + } + + _, err := NewWebhookNotifier(cfg, logger) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "Pushover requires Auth.Token and Auth.User") { + t.Fatalf("error %q does not mention Pushover auth requirement", err.Error()) + } + if !strings.Contains(err.Error(), tt.missing) { + t.Fatalf("error %q does not mention %q", err.Error(), tt.missing) + } + }) + } +} diff --git a/internal/safeexec/safeexec_test.go b/internal/safeexec/safeexec_test.go index 96bee49b..3144ad80 100644 --- a/internal/safeexec/safeexec_test.go +++ b/internal/safeexec/safeexec_test.go @@ -9,8 +9,23 @@ import ( ) func TestCommandContextAllowlist(t *testing.T) { - if _, err := CommandContext(context.Background(), "rclone", "lsf", "remote:"); err != nil { - t.Fatalf("CommandContext allowed command error: %v", err) + allowedCommands := []string{ + "rclone", + "tar", + "xz", + "zstd", + "systemctl", + "mailq", + "tail", + "journalctl", + "pvesh", + "pveum", + "proxmox-backup-manager", + } + for _, command := range allowedCommands { + if _, err := CommandContext(context.Background(), command); err != nil { + t.Fatalf("CommandContext(%q) allowed command error: %v", command, err) + } } if _, err := CommandContext(context.Background(), "not-a-proxsave-command"); !errors.Is(err, ErrCommandNotAllowed) { t.Fatalf("CommandContext unknown command error = %v, want ErrCommandNotAllowed", err) From a165abace8488696a8979ebe3aa44ff1ce4bcff8 Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Tue, 5 May 2026 21:41:32 +0200 Subject: [PATCH 22/35] Return cloud FS errors and collect mode errors Propagate filesystem detection errors for cloud backends so callers get diagnostics (detectFilesystemInfo now returns an error for LocationCloud and backup init logs include the failure reason). Adjust backup storage init logging to include the detection reason and mark cloud disabled. Change validateModeCompatibility to accumulate and return all compatibility violations (with a new test added). Rename helper 'newer' to 'meetsMinimum' for clarity and normalize directory mode literal to 0o755. Tests updated/added to cover the new behaviors. --- cmd/proxsave/backup_storage.go | 12 ++++++++---- cmd/proxsave/main_modes.go | 5 +++-- cmd/proxsave/main_modes_test.go | 11 +++++++++++ cmd/proxsave/main_runtime.go | 4 ++-- cmd/proxsave/runtime_helpers.go | 3 +++ cmd/proxsave/runtime_helpers_more_test.go | 14 ++++++++++++++ internal/orchestrator/restore_archive.go | 2 +- 7 files changed, 42 insertions(+), 9 deletions(-) diff --git a/cmd/proxsave/backup_storage.go b/cmd/proxsave/backup_storage.go index 02e4135d..5cfb621f 100644 --- a/cmd/proxsave/backup_storage.go +++ b/cmd/proxsave/backup_storage.go @@ -129,16 +129,20 @@ func initializeCloudStorage(opts backupModeOptions, orch *orchestrator.Orchestra return nil } - cloudFS, _ := detectFilesystemInfo(opts.ctx, cloudBackend, cfg.CloudRemote, logger) + cloudFS, err := detectFilesystemInfo(opts.ctx, cloudBackend, cfg.CloudRemote, logger) if cloudFS == nil { - logging.DebugStep(logger, "storage init", "cloud unavailable, disabling") + reason := "filesystem detection unavailable" + if err != nil { + reason = fmt.Sprintf("filesystem detection failed: %v", err) + } + logging.DebugStep(logger, "storage init", "cloud unavailable, disabling: %s", reason) cfg.CloudEnabled = false cfg.CloudLogPath = "" if checker != nil { checker.DisableCloud() } - logStorageInitSummary(formatStorageInitSummary("Cloud storage", cfg, storage.LocationCloud, nil, nil)) - logging.Skip("Path Cloud: disabled") + logStorageInitSummary(fmt.Sprintf("%s; %s", formatStorageInitSummary("Cloud storage", cfg, storage.LocationCloud, nil, nil), reason)) + logging.Skip("Path Cloud: disabled (%s)", reason) return nil } diff --git a/cmd/proxsave/main_modes.go b/cmd/proxsave/main_modes.go index c8d96912..ed2740a2 100644 --- a/cmd/proxsave/main_modes.go +++ b/cmd/proxsave/main_modes.go @@ -27,6 +27,7 @@ func validateModeCompatibility(args *cli.Args) []string { return []string{"command-line arguments are required"} } + var allMessages []string for _, rule := range []modeCompatibilityRule{ validateCleanupGuardsCompatibility, validateSupportCompatibility, @@ -34,10 +35,10 @@ func validateModeCompatibility(args *cli.Args) []string { validateUpgradeCompatibility, } { if messages := rule(args); len(messages) > 0 { - return messages + allMessages = append(allMessages, messages...) } } - return nil + return allMessages } func validateCleanupGuardsCompatibility(args *cli.Args) []string { diff --git a/cmd/proxsave/main_modes_test.go b/cmd/proxsave/main_modes_test.go index 3b9d5b59..1608eb49 100644 --- a/cmd/proxsave/main_modes_test.go +++ b/cmd/proxsave/main_modes_test.go @@ -52,6 +52,17 @@ func TestValidateModeCompatibility(t *testing.T) { args: &cli.Args{Upgrade: true, Install: true}, want: []string{"Cannot use --upgrade together with --install or --new-install."}, }, + { + name: "accumulates all compatibility violations", + args: &cli.Args{CleanupGuards: true, Support: true, Decrypt: true, Install: true, NewInstall: true, Upgrade: true}, + want: []string{ + "--cleanup-guards cannot be combined with: --support, --decrypt, --install, --new-install, --upgrade", + "Support mode cannot be combined with: --decrypt, --install, --new-install", + "--support is only available for the standard backup run or --restore.", + "Cannot use --install and --new-install together. Choose one installation mode.", + "Cannot use --upgrade together with --install or --new-install.", + }, + }, } for _, tt := range tests { diff --git a/cmd/proxsave/main_runtime.go b/cmd/proxsave/main_runtime.go index 90f4e648..f1a174d8 100644 --- a/cmd/proxsave/main_runtime.go +++ b/cmd/proxsave/main_runtime.go @@ -288,7 +288,7 @@ func checkGoRuntimeVersion(minimum string) error { rtMaj, rtMin, rtPatch := parse(rt) minMaj, minMin, minPatch := parse(minimum) - newer := func(aMaj, aMin, aPatch, bMaj, bMin, bPatch int) bool { + meetsMinimum := func(aMaj, aMin, aPatch, bMaj, bMin, bPatch int) bool { if aMaj != bMaj { return aMaj > bMaj } @@ -298,7 +298,7 @@ func checkGoRuntimeVersion(minimum string) error { return aPatch >= bPatch } - if !newer(rtMaj, rtMin, rtPatch, minMaj, minMin, minPatch) { + if !meetsMinimum(rtMaj, rtMin, rtPatch, minMaj, minMin, minPatch) { return fmt.Errorf("go runtime version %s is below required %s — rebuild with go %s or set GOTOOLCHAIN=auto", rt, "go"+minimum, "go"+minimum) } return nil diff --git a/cmd/proxsave/runtime_helpers.go b/cmd/proxsave/runtime_helpers.go index 12fc9f9d..0a52b206 100644 --- a/cmd/proxsave/runtime_helpers.go +++ b/cmd/proxsave/runtime_helpers.go @@ -279,6 +279,9 @@ func detectFilesystemInfo(ctx context.Context, backend storage.Storage, path str return nil, err } logger.Debug("WARNING: %s filesystem detection failed: %v", backend.Name(), err) + if backend.Location() == storage.LocationCloud { + return nil, err + } return nil, nil } diff --git a/cmd/proxsave/runtime_helpers_more_test.go b/cmd/proxsave/runtime_helpers_more_test.go index f71a813b..c7581281 100644 --- a/cmd/proxsave/runtime_helpers_more_test.go +++ b/cmd/proxsave/runtime_helpers_more_test.go @@ -161,6 +161,20 @@ func TestDetectFilesystemInfo(t *testing.T) { } }) + t.Run("cloud error is returned for caller diagnostics", func(t *testing.T) { + backend := &fakeStorageBackend{ + name: "cloud", + location: storage.LocationCloud, + enabled: true, + critical: false, + fsErr: errors.New("cloud detect failed"), + } + info, err := detectFilesystemInfo(ctx, backend, "remote:path", logger) + if err == nil || info != nil { + t.Fatalf("detectFilesystemInfo() = (%v,%v), want (nil,error)", info, err) + } + }) + t.Run("critical error is returned", func(t *testing.T) { backend := &fakeStorageBackend{ name: "primary", diff --git a/internal/orchestrator/restore_archive.go b/internal/orchestrator/restore_archive.go index e812ff29..363b7387 100644 --- a/internal/orchestrator/restore_archive.go +++ b/internal/orchestrator/restore_archive.go @@ -254,7 +254,7 @@ func extractSelectiveArchive(ctx context.Context, archivePath, destRoot string, // Create detailed log directory logDir := "/tmp/proxsave" - if err := restoreFS.MkdirAll(logDir, 0755); err != nil { + if err := restoreFS.MkdirAll(logDir, 0o755); err != nil { logger.Warning("Could not create log directory: %v", err) } From 5457ec55df28ae6920cc81cbf646e26a65c4e900 Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Tue, 5 May 2026 21:49:44 +0200 Subject: [PATCH 23/35] Propagate context to ZFS restore checks Pass context.Context through ZFS restore helper functions and tests so operations can be cancelled and use caller-provided contexts. checkZFSPoolsAfterRestore, detectImportableZFSPools and logNoImportableZFSPools now accept a context, check ctx.Err, and pass ctx to restoreCmd.Run. The UI caller was updated to forward the workflow context. Tests were updated to supply contexts and two new tests verify context propagation and behavior on canceled contexts. FakeCommandRunner was extended to record contexts for assertions. --- internal/orchestrator/deps_test.go | 8 +-- .../restore_coverage_extra_test.go | 54 +++++++++++++++++-- internal/orchestrator/restore_workflow_ui.go | 2 +- internal/orchestrator/restore_zfs.go | 36 +++++++++---- 4 files changed, 81 insertions(+), 19 deletions(-) diff --git a/internal/orchestrator/deps_test.go b/internal/orchestrator/deps_test.go index dea9b1e3..fa7af74f 100644 --- a/internal/orchestrator/deps_test.go +++ b/internal/orchestrator/deps_test.go @@ -210,14 +210,16 @@ func (f *FakeTime) Advance(d time.Duration) { // FakeCommandRunner records invocations and returns predefined outputs/errors. type FakeCommandRunner struct { - Outputs map[string][]byte - Errors map[string]error - Calls []string + Outputs map[string][]byte + Errors map[string]error + Calls []string + Contexts []context.Context } func (f *FakeCommandRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { key := commandKey(name, args) f.Calls = append(f.Calls, key) + f.Contexts = append(f.Contexts, ctx) var out []byte if f.Outputs != nil { out = f.Outputs[key] diff --git a/internal/orchestrator/restore_coverage_extra_test.go b/internal/orchestrator/restore_coverage_extra_test.go index 15bdd2b9..47d6c60b 100644 --- a/internal/orchestrator/restore_coverage_extra_test.go +++ b/internal/orchestrator/restore_coverage_extra_test.go @@ -21,6 +21,8 @@ func (runOnlyRunner) Run(ctx context.Context, name string, args ...string) ([]by return nil, fmt.Errorf("unexpected command: %s", commandKey(name, args)) } +type zfsContextTestKey struct{} + type recordingRunner struct { calls []string } @@ -63,7 +65,7 @@ func TestDetectImportableZFSPools_ReturnsPoolsAndErrorWhenCommandFails(t *testin } restoreCmd = fake - pools, output, err := detectImportableZFSPools() + pools, output, err := detectImportableZFSPools(context.Background()) if err == nil { t.Fatalf("expected error") } @@ -86,7 +88,7 @@ func TestCheckZFSPoolsAfterRestore_ReturnsNilWhenZpoolMissing(t *testing.T) { } restoreCmd = fake - if err := checkZFSPoolsAfterRestore(newTestLogger()); err != nil { + if err := checkZFSPoolsAfterRestore(context.Background(), newTestLogger()); err != nil { t.Fatalf("expected nil error when zpool missing, got %v", err) } if len(fake.Calls) != 1 || fake.Calls[0] != "which zpool" { @@ -94,6 +96,50 @@ func TestCheckZFSPoolsAfterRestore_ReturnsNilWhenZpoolMissing(t *testing.T) { } } +func TestCheckZFSPoolsAfterRestore_UsesProvidedContext(t *testing.T) { + orig := restoreCmd + t.Cleanup(func() { restoreCmd = orig }) + + fake := &FakeCommandRunner{ + Outputs: map[string][]byte{ + "which zpool": []byte("/sbin/zpool\n"), + "zpool import": []byte(""), + }, + } + restoreCmd = fake + + ctx := context.WithValue(context.Background(), zfsContextTestKey{}, "restore") + if err := checkZFSPoolsAfterRestore(ctx, newTestLogger()); err != nil { + t.Fatalf("checkZFSPoolsAfterRestore error: %v", err) + } + + if len(fake.Contexts) == 0 { + t.Fatalf("expected command contexts to be recorded") + } + for i, got := range fake.Contexts { + if got.Value(zfsContextTestKey{}) != "restore" { + t.Fatalf("command context %d did not use restore context", i) + } + } +} + +func TestCheckZFSPoolsAfterRestore_ReturnsCanceledContext(t *testing.T) { + orig := restoreCmd + t.Cleanup(func() { restoreCmd = orig }) + + fake := &FakeCommandRunner{} + restoreCmd = fake + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := checkZFSPoolsAfterRestore(ctx, newTestLogger()); err != context.Canceled { + t.Fatalf("checkZFSPoolsAfterRestore error = %v, want context.Canceled", err) + } + if len(fake.Calls) != 0 { + t.Fatalf("expected no commands after canceled context, got %#v", fake.Calls) + } +} + func TestCheckZFSPoolsAfterRestore_ConfiguredPools_NoImportables(t *testing.T) { origCmd := restoreCmd origFS := restoreFS @@ -131,7 +177,7 @@ func TestCheckZFSPoolsAfterRestore_ConfiguredPools_NoImportables(t *testing.T) { } restoreCmd = fake - if err := checkZFSPoolsAfterRestore(newTestLogger()); err != nil { + if err := checkZFSPoolsAfterRestore(context.Background(), newTestLogger()); err != nil { t.Fatalf("checkZFSPoolsAfterRestore error: %v", err) } @@ -172,7 +218,7 @@ func TestCheckZFSPoolsAfterRestore_ReportsImportablePools(t *testing.T) { } restoreCmd = fake - if err := checkZFSPoolsAfterRestore(newTestLogger()); err != nil { + if err := checkZFSPoolsAfterRestore(context.Background(), newTestLogger()); err != nil { t.Fatalf("checkZFSPoolsAfterRestore error: %v", err) } diff --git a/internal/orchestrator/restore_workflow_ui.go b/internal/orchestrator/restore_workflow_ui.go index 2ed294be..0fd91a27 100644 --- a/internal/orchestrator/restore_workflow_ui.go +++ b/internal/orchestrator/restore_workflow_ui.go @@ -855,7 +855,7 @@ func runRestoreWorkflowWithUI(ctx context.Context, cfg *config.Config, logger *l if hasCategoryID(plan.NormalCategories, "zfs") { logger.Info("") - if err := checkZFSPoolsAfterRestore(logger); err != nil { + if err := checkZFSPoolsAfterRestore(ctx, logger); err != nil { logger.Warning("ZFS pool check: %v", err) } } else { diff --git a/internal/orchestrator/restore_zfs.go b/internal/orchestrator/restore_zfs.go index ebf65dd2..e4bfbf12 100644 --- a/internal/orchestrator/restore_zfs.go +++ b/internal/orchestrator/restore_zfs.go @@ -13,9 +13,15 @@ import ( var restoreGlob = filepath.Glob -// checkZFSPoolsAfterRestore checks if ZFS pools need to be imported after restore -func checkZFSPoolsAfterRestore(logger *logging.Logger) error { - if _, err := restoreCmd.Run(context.Background(), "which", "zpool"); err != nil { +// checkZFSPoolsAfterRestore checks if ZFS pools need to be imported after restore. +func checkZFSPoolsAfterRestore(ctx context.Context, logger *logging.Logger) error { + if err := ctx.Err(); err != nil { + return err + } + if _, err := restoreCmd.Run(ctx, "which", "zpool"); err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } // zpool utility not available -> no ZFS tooling installed return nil } @@ -23,14 +29,18 @@ func checkZFSPoolsAfterRestore(logger *logging.Logger) error { logger.Info("Checking ZFS pool status...") configuredPools := detectConfiguredZFSPools() - importablePools, importOutput, importErr := detectImportableZFSPools() + importablePools, importOutput, importErr := detectImportableZFSPools(ctx) + if importErr != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + } logConfiguredZFSPools(logger, configuredPools) logImportableZFSPools(logger, importablePools, importOutput, importErr) if len(importablePools) == 0 { - logNoImportableZFSPools(logger, configuredPools) - return nil + return logNoImportableZFSPools(ctx, logger, configuredPools) } logManualZFSImportInstructions(logger, importablePools) @@ -65,19 +75,23 @@ func logImportableZFSPools(logger *logging.Logger, importablePools []string, imp } } -func logNoImportableZFSPools(logger *logging.Logger, configuredPools []string) { +func logNoImportableZFSPools(ctx context.Context, logger *logging.Logger, configuredPools []string) error { logger.Info("`zpool import` did not report pools waiting for import.") if len(configuredPools) == 0 { - return + return nil } logger.Info("") for _, pool := range configuredPools { - if _, err := restoreCmd.Run(context.Background(), "zpool", "status", pool); err == nil { + if _, err := restoreCmd.Run(ctx, "zpool", "status", pool); err == nil { logger.Info("Pool %s is already imported (no manual action needed)", pool) } else { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } logger.Warning("Systemd expects pool %s, but `zpool import` and `zpool status` did not report it. Check disk visibility and pool status.", pool) } } + return nil } func logManualZFSImportInstructions(logger *logging.Logger, importablePools []string) { @@ -163,8 +177,8 @@ func parsePoolNameFromUnit(unitName string) string { } } -func detectImportableZFSPools() ([]string, string, error) { - output, err := restoreCmd.Run(context.Background(), "zpool", "import") +func detectImportableZFSPools(ctx context.Context) ([]string, string, error) { + output, err := restoreCmd.Run(ctx, "zpool", "import") poolNames := parseZpoolImportOutput(string(output)) if err != nil { return poolNames, string(output), err From 5ea720d328d0ba88dbdd524f50f2853b42871999 Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Tue, 5 May 2026 22:00:36 +0200 Subject: [PATCH 24/35] Treat decompression readers as ReadClosers Change decompression reader APIs to return io.ReadCloser and ensure readers are closed with errors propagated. Introduce closeDecompressionReader helper to defer Close() and convert close errors into the function's error return. Update callers to use the helper and simplify test cleanup; add a test (TestExtractArchiveNativeReturnsDecompressionCloseError) that verifies decompressor Close() errors surface. Also adjust imports and minor test fixes to read directly from readers. --- .../orchestrator/decompress_reader_test.go | 72 +++++++++++++++++-- internal/orchestrator/nic_mapping.go | 6 +- internal/orchestrator/resolv_conf_repair.go | 6 +- .../orchestrator/restore_archive_extract.go | 6 +- .../restore_coverage_extra_test.go | 8 +-- internal/orchestrator/restore_decision.go | 14 ++-- .../orchestrator/restore_decompression.go | 27 ++++--- internal/orchestrator/restore_errors_test.go | 2 +- 8 files changed, 98 insertions(+), 43 deletions(-) diff --git a/internal/orchestrator/decompress_reader_test.go b/internal/orchestrator/decompress_reader_test.go index 542c7bc8..ddf86fef 100644 --- a/internal/orchestrator/decompress_reader_test.go +++ b/internal/orchestrator/decompress_reader_test.go @@ -1,9 +1,12 @@ package orchestrator import ( + "bytes" "context" + "errors" "io" "os" + "path/filepath" "strings" "testing" ) @@ -36,6 +39,7 @@ func TestCreateDecompressionReaderTar(t *testing.T) { if reader == nil { t.Fatalf("reader should not be nil for tar") } + _ = reader.Close() } type fakeStreamCommandRunner struct { @@ -59,6 +63,28 @@ func (f *fakeStreamCommandRunner) RunStream(ctx context.Context, name string, st return io.NopCloser(strings.NewReader("")), nil } +type extractionCloseErrorReadCloser struct { + *bytes.Reader + err error +} + +func (r *extractionCloseErrorReadCloser) Close() error { + return r.err +} + +type closeErrorStreamCommandRunner struct { + data []byte + closeErr error +} + +func (f *closeErrorStreamCommandRunner) Run(context.Context, string, ...string) ([]byte, error) { + return nil, nil +} + +func (f *closeErrorStreamCommandRunner) RunStream(context.Context, string, io.Reader, ...string) (io.ReadCloser, error) { + return &extractionCloseErrorReadCloser{Reader: bytes.NewReader(f.data), err: f.closeErr}, nil +} + func TestCreateDecompressionReaderUsesStreamingRunnerForCompressedFormats(t *testing.T) { orig := restoreCmd t.Cleanup(func() { restoreCmd = orig }) @@ -98,14 +124,9 @@ func TestCreateDecompressionReaderUsesStreamingRunnerForCompressedFormats(t *tes if err != nil { t.Fatalf("createDecompressionReader(%s) error: %v", tt.ext, err) } + defer reader.Close() - rc, ok := reader.(io.ReadCloser) - if !ok { - t.Fatalf("expected io.ReadCloser, got %T", reader) - } - defer rc.Close() - - out, err := io.ReadAll(rc) + out, err := io.ReadAll(reader) if err != nil { t.Fatalf("ReadAll: %v", err) } @@ -118,3 +139,40 @@ func TestCreateDecompressionReaderUsesStreamingRunnerForCompressedFormats(t *tes }) } } + +func TestExtractArchiveNativeReturnsDecompressionCloseError(t *testing.T) { + origCmd := restoreCmd + origFS := restoreFS + t.Cleanup(func() { + restoreCmd = origCmd + restoreFS = origFS + }) + + dir := t.TempDir() + tarPath := filepath.Join(dir, "source.tar") + if err := writeTarFile(tarPath, map[string]string{"etc/example.conf": "ok\n"}); err != nil { + t.Fatalf("writeTarFile: %v", err) + } + tarData, err := os.ReadFile(tarPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + + closeErr := errors.New("decompressor exited 2") + restoreCmd = &closeErrorStreamCommandRunner{data: tarData, closeErr: closeErr} + restoreFS = osFS{} + + archivePath := filepath.Join(dir, "archive.tar.zst") + if err := os.WriteFile(archivePath, []byte("compressed"), 0o640); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + err = extractArchiveNative(context.Background(), restoreArchiveOptions{ + archivePath: archivePath, + destRoot: filepath.Join(dir, "dest"), + logger: newTestLogger(), + }) + if !errors.Is(err, closeErr) { + t.Fatalf("extractArchiveNative error = %v, want close error %v", err, closeErr) + } +} diff --git a/internal/orchestrator/nic_mapping.go b/internal/orchestrator/nic_mapping.go index f2a0372c..b77dc273 100644 --- a/internal/orchestrator/nic_mapping.go +++ b/internal/orchestrator/nic_mapping.go @@ -312,7 +312,7 @@ func loadBackupNetworkInventoryFromArchive(ctx context.Context, archivePath stri return &inv, used, nil } -func readArchiveEntry(ctx context.Context, archivePath string, candidates []string, maxBytes int64) ([]byte, string, error) { +func readArchiveEntry(ctx context.Context, archivePath string, candidates []string, maxBytes int64) (data []byte, used string, err error) { file, err := restoreFS.Open(archivePath) if err != nil { return nil, "", err @@ -323,9 +323,7 @@ func readArchiveEntry(ctx context.Context, archivePath string, candidates []stri if err != nil { return nil, "", err } - if closer, ok := reader.(io.Closer); ok { - defer closer.Close() - } + defer closeDecompressionReader(reader, &err, "close decompression reader") tr := tar.NewReader(reader) diff --git a/internal/orchestrator/resolv_conf_repair.go b/internal/orchestrator/resolv_conf_repair.go index 396e6415..bce82238 100644 --- a/internal/orchestrator/resolv_conf_repair.go +++ b/internal/orchestrator/resolv_conf_repair.go @@ -148,7 +148,7 @@ func repairResolvConfWithSystemdResolved(logger *logging.Logger) (bool, error) { return false, nil } -func readTarEntry(ctx context.Context, archivePath, name string, maxBytes int64) ([]byte, error) { +func readTarEntry(ctx context.Context, archivePath, name string, maxBytes int64) (data []byte, err error) { file, err := restoreFS.Open(archivePath) if err != nil { return nil, fmt.Errorf("open archive: %w", err) @@ -159,9 +159,7 @@ func readTarEntry(ctx context.Context, archivePath, name string, maxBytes int64) if err != nil { return nil, fmt.Errorf("create decompression reader: %w", err) } - if closer, ok := reader.(io.Closer); ok { - defer closer.Close() - } + defer closeDecompressionReader(reader, &err, "close decompression reader") wantA := strings.TrimPrefix(strings.TrimSpace(name), "./") wantB := "./" + wantA diff --git a/internal/orchestrator/restore_archive_extract.go b/internal/orchestrator/restore_archive_extract.go index 521073ca..ea16abb4 100644 --- a/internal/orchestrator/restore_archive_extract.go +++ b/internal/orchestrator/restore_archive_extract.go @@ -38,7 +38,7 @@ type restoreExtractionLog struct { } // extractArchiveNative extracts TAR archives natively in Go, preserving all timestamps. -func extractArchiveNative(ctx context.Context, opts restoreArchiveOptions) error { +func extractArchiveNative(ctx context.Context, opts restoreArchiveOptions) (err error) { file, err := restoreFS.Open(opts.archivePath) if err != nil { return fmt.Errorf("open archive: %w", err) @@ -49,9 +49,7 @@ func extractArchiveNative(ctx context.Context, opts restoreArchiveOptions) error if err != nil { return fmt.Errorf("create decompression reader: %w", err) } - if closer, ok := reader.(io.Closer); ok { - defer closer.Close() - } + defer closeDecompressionReader(reader, &err, "close decompression reader") extractionLog := newRestoreExtractionLog(opts) defer extractionLog.close() diff --git a/internal/orchestrator/restore_coverage_extra_test.go b/internal/orchestrator/restore_coverage_extra_test.go index 47d6c60b..8225f75a 100644 --- a/internal/orchestrator/restore_coverage_extra_test.go +++ b/internal/orchestrator/restore_coverage_extra_test.go @@ -658,13 +658,9 @@ func TestRunRestoreCommandStream_FallsBackToExecCommand(t *testing.T) { if err != nil { t.Fatalf("runRestoreCommandStream error: %v", err) } - rc, ok := reader.(io.ReadCloser) - if !ok { - t.Fatalf("expected io.ReadCloser, got %T", reader) - } - defer rc.Close() + defer reader.Close() - out, err := io.ReadAll(rc) + out, err := io.ReadAll(reader) if err != nil { t.Fatalf("read: %v", err) } diff --git a/internal/orchestrator/restore_decision.go b/internal/orchestrator/restore_decision.go index dafa20a6..903984f6 100644 --- a/internal/orchestrator/restore_decision.go +++ b/internal/orchestrator/restore_decision.go @@ -91,14 +91,12 @@ func inspectRestoreArchiveContents(archivePath string, logger *logging.Logger) ( if err != nil { return nil, err } - if closer, ok := reader.(interface{ Close() error }); ok { - defer func() { - if closeErr := closer.Close(); closeErr != nil && err == nil { - inspection = nil - err = fmt.Errorf("inspect archive: %w", closeErr) - } - }() - } + defer func() { + if closeErr := reader.Close(); closeErr != nil && err == nil { + inspection = nil + err = fmt.Errorf("inspect archive: %w", closeErr) + } + }() tarReader := tar.NewReader(reader) archivePaths, metadata, metadataErr, collectErr := collectRestoreArchiveFacts(tarReader) diff --git a/internal/orchestrator/restore_decompression.go b/internal/orchestrator/restore_decompression.go index c6fb1d0b..224fea52 100644 --- a/internal/orchestrator/restore_decompression.go +++ b/internal/orchestrator/restore_decompression.go @@ -15,11 +15,11 @@ import ( type restoreDecompressionFormat struct { matches func(string) bool - open func(context.Context, *os.File) (io.Reader, error) + open func(context.Context, *os.File) (io.ReadCloser, error) } // createDecompressionReader creates appropriate decompression reader based on file extension -func createDecompressionReader(ctx context.Context, file *os.File, archivePath string) (io.Reader, error) { +func createDecompressionReader(ctx context.Context, file *os.File, archivePath string) (io.ReadCloser, error) { for _, format := range restoreDecompressionFormats() { if format.matches(archivePath) { return format.open(ctx, file) @@ -28,11 +28,20 @@ func createDecompressionReader(ctx context.Context, file *os.File, archivePath s return nil, fmt.Errorf("unsupported archive format: %s", filepath.Base(archivePath)) } +func closeDecompressionReader(reader io.Closer, errp *error, operation string) { + if reader == nil || errp == nil { + return + } + if closeErr := reader.Close(); closeErr != nil && *errp == nil { + *errp = fmt.Errorf("%s: %w", operation, closeErr) + } +} + func restoreDecompressionFormats() []restoreDecompressionFormat { return []restoreDecompressionFormat{ { matches: func(path string) bool { return strings.HasSuffix(path, ".tar.gz") || strings.HasSuffix(path, ".tgz") }, - open: func(_ context.Context, file *os.File) (io.Reader, error) { return gzip.NewReader(file) }, + open: func(_ context.Context, file *os.File) (io.ReadCloser, error) { return gzip.NewReader(file) }, }, {matches: func(path string) bool { return strings.HasSuffix(path, ".tar.xz") }, open: createXZReader}, { @@ -43,33 +52,33 @@ func restoreDecompressionFormats() []restoreDecompressionFormat { }, {matches: func(path string) bool { return strings.HasSuffix(path, ".tar.bz2") }, open: createBzip2Reader}, {matches: func(path string) bool { return strings.HasSuffix(path, ".tar.lzma") }, open: createLzmaReader}, - {matches: func(path string) bool { return strings.HasSuffix(path, ".tar") }, open: func(_ context.Context, file *os.File) (io.Reader, error) { return file, nil }}, + {matches: func(path string) bool { return strings.HasSuffix(path, ".tar") }, open: func(_ context.Context, file *os.File) (io.ReadCloser, error) { return file, nil }}, } } // createXZReader creates an XZ decompression reader using injectable command runner -func createXZReader(ctx context.Context, file *os.File) (io.Reader, error) { +func createXZReader(ctx context.Context, file *os.File) (io.ReadCloser, error) { return runRestoreCommandStream(ctx, "xz", file, "-d", "-c") } // createZstdReader creates a Zstd decompression reader using injectable command runner -func createZstdReader(ctx context.Context, file *os.File) (io.Reader, error) { +func createZstdReader(ctx context.Context, file *os.File) (io.ReadCloser, error) { return runRestoreCommandStream(ctx, "zstd", file, "-d", "-c") } // createBzip2Reader creates a Bzip2 decompression reader using injectable command runner -func createBzip2Reader(ctx context.Context, file *os.File) (io.Reader, error) { +func createBzip2Reader(ctx context.Context, file *os.File) (io.ReadCloser, error) { return runRestoreCommandStream(ctx, "bzip2", file, "-d", "-c") } // createLzmaReader creates an LZMA decompression reader using injectable command runner -func createLzmaReader(ctx context.Context, file *os.File) (io.Reader, error) { +func createLzmaReader(ctx context.Context, file *os.File) (io.ReadCloser, error) { return runRestoreCommandStream(ctx, "lzma", file, "-d", "-c") } // runRestoreCommandStream starts a command that reads from stdin and exposes stdout as a ReadCloser. // It prefers an injectable streaming runner when available; otherwise falls back to safeexec. -func runRestoreCommandStream(ctx context.Context, name string, stdin io.Reader, args ...string) (io.Reader, error) { +func runRestoreCommandStream(ctx context.Context, name string, stdin io.Reader, args ...string) (io.ReadCloser, error) { type streamingRunner interface { RunStream(ctx context.Context, name string, stdin io.Reader, args ...string) (io.ReadCloser, error) } diff --git a/internal/orchestrator/restore_errors_test.go b/internal/orchestrator/restore_errors_test.go index c570ca30..bcc34990 100644 --- a/internal/orchestrator/restore_errors_test.go +++ b/internal/orchestrator/restore_errors_test.go @@ -55,7 +55,7 @@ func TestRunRestoreCommandStream_UsesStreamingRunner(t *testing.T) { if err != nil { t.Fatalf("createXZReader: %v", err) } - defer reader.(io.Closer).Close() + defer reader.Close() buf, err := io.ReadAll(reader) if err != nil { From 1e1610d0cb91cfdd2d408d513f0b149de5d240aa Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Tue, 5 May 2026 22:15:54 +0200 Subject: [PATCH 25/35] Convert storage/VM config apply to pvesh flag args Refactor how VM and storage configs are applied: storageBlock now stores Type and proxmoxNotificationEntry entries instead of raw data, and VM/storage config files are parsed into --key=value pvesh arguments rather than written to temp files and passed via --filename/-conf. Added parsing helpers (parseColonConfigLine, pveshArgsFromColonConfigFile/Lines, pveshArgsFromProxmoxEntries, storageBlockPveshArgs, storageEntryValue) and updated applyVMConfigs/applyStorageCfg to build proper pvesh calls (e.g. "pvesh create /storage --storage=... --type=... ..."). Tests were updated to reflect the new argument format and to assert that deprecated flags (--filename, -conf) are no longer used. --- .../orchestrator/additional_helpers_test.go | 4 +- .../orchestrator/restore_cluster_apply.go | 125 +++++++++++++++--- .../restore_cluster_apply_additional_test.go | 2 +- .../restore_coverage_extra_test.go | 26 ++-- internal/orchestrator/restore_errors_test.go | 20 +++ internal/orchestrator/restore_test.go | 6 + 6 files changed, 151 insertions(+), 32 deletions(-) diff --git a/internal/orchestrator/additional_helpers_test.go b/internal/orchestrator/additional_helpers_test.go index 9e44f7f4..39286f81 100644 --- a/internal/orchestrator/additional_helpers_test.go +++ b/internal/orchestrator/additional_helpers_test.go @@ -828,8 +828,8 @@ storage: backup if blocks[0].ID != "local" || blocks[1].ID != "backup" { t.Fatalf("unexpected IDs: %+v", blocks) } - if len(blocks[0].data) == 0 || len(blocks[1].data) == 0 { - t.Fatalf("expected data in blocks") + if len(blocks[0].entries) == 0 || len(blocks[1].entries) == 0 { + t.Fatalf("expected entries in blocks") } // Empty file -> zero blocks diff --git a/internal/orchestrator/restore_cluster_apply.go b/internal/orchestrator/restore_cluster_apply.go index 227331b4..bf9e3853 100644 --- a/internal/orchestrator/restore_cluster_apply.go +++ b/internal/orchestrator/restore_cluster_apply.go @@ -190,7 +190,13 @@ func applyVMConfigs(ctx context.Context, entries []vmEntry, logger *logging.Logg return applied, failed } target := fmt.Sprintf("/nodes/%s/%s/%s/config", detectNodeForVM(), vm.Kind, vm.VMID) - args := []string{"set", target, "--filename", vm.Path} + configArgs, err := pveshArgsFromColonConfigFile(vm.Path) + if err != nil { + logger.Warning("Failed to read %s (vmid=%s kind=%s): %v", vm.Path, vm.VMID, vm.Kind, err) + failed++ + continue + } + args := append([]string{"set", target}, configArgs...) if err := runPvesh(ctx, logger, args); err != nil { logger.Warning("Failed to apply %s (vmid=%s kind=%s): %v", target, vm.VMID, vm.Kind, err) failed++ @@ -216,8 +222,90 @@ func detectNodeForVM() string { } type storageBlock struct { - ID string - data []string + ID string + Type string + entries []proxmoxNotificationEntry +} + +func pveshArgsFromColonConfigFile(path string) ([]string, error) { + data, err := restoreFS.ReadFile(path) + if err != nil { + return nil, err + } + return pveshArgsFromColonConfigLines(strings.Split(string(data), "\n")), nil +} + +func pveshArgsFromColonConfigLines(lines []string) []string { + args := make([]string, 0, len(lines)*2) + for _, line := range lines { + key, value, ok := parseColonConfigLine(line) + if !ok { + continue + } + args = append(args, fmt.Sprintf("--%s=%s", key, value)) + } + return args +} + +func pveshArgsFromProxmoxEntries(entries []proxmoxNotificationEntry) []string { + args := make([]string, 0, len(entries)*2) + for _, entry := range entries { + key := strings.TrimSpace(entry.Key) + value := strings.TrimSpace(entry.Value) + if key == "" || value == "" { + continue + } + args = append(args, fmt.Sprintf("--%s=%s", key, value)) + } + return args +} + +func storageBlockPveshArgs(block storageBlock) ([]string, bool) { + storageType := strings.TrimSpace(block.Type) + if storageType == "" { + storageType = storageEntryValue(block.entries, "type") + } + if storageType == "" { + return nil, false + } + + args := []string{ + fmt.Sprintf("--storage=%s", block.ID), + fmt.Sprintf("--type=%s", storageType), + } + for _, entry := range block.entries { + if strings.EqualFold(strings.TrimSpace(entry.Key), "type") { + continue + } + args = append(args, pveshArgsFromProxmoxEntries([]proxmoxNotificationEntry{entry})...) + } + return args, true +} + +func storageEntryValue(entries []proxmoxNotificationEntry, want string) string { + for _, entry := range entries { + if strings.EqualFold(strings.TrimSpace(entry.Key), want) { + return strings.TrimSpace(entry.Value) + } + } + return "" +} + +func parseColonConfigLine(line string) (key, value string, ok bool) { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + return "", "", false + } + idx := strings.Index(trimmed, ":") + if idx <= 0 { + return "", "", false + } + key = strings.TrimSpace(trimmed[:idx]) + value = strings.TrimSpace(trimmed[idx+1:]) + if key == "" || value == "" { + return "", "", false + } + return key, value, true } func applyStorageCfg(ctx context.Context, cfgPath string, logger *logging.Logger) (applied, failed int, err error) { @@ -231,21 +319,14 @@ func applyStorageCfg(ctx context.Context, cfgPath string, logger *logging.Logger } for _, blk := range blocks { - tmp, tmpErr := restoreFS.CreateTemp("", fmt.Sprintf("pve-storage-%s-*.cfg", sanitizeID(blk.ID))) - if tmpErr != nil { + createArgs, ok := storageBlockPveshArgs(blk) + if !ok { + logger.Warning("Skipping storage %s: storage type missing", blk.ID) failed++ continue } - tmpName := tmp.Name() - if _, werr := tmp.WriteString(strings.Join(blk.data, "\n") + "\n"); werr != nil { - _ = tmp.Close() - _ = restoreFS.Remove(tmpName) - failed++ - continue - } - _ = tmp.Close() + args := append([]string{"create", "/storage"}, createArgs...) - args := []string{"set", fmt.Sprintf("/cluster/storage/%s", blk.ID), "-conf", tmpName} if runErr := runPvesh(ctx, logger, args); runErr != nil { logger.Warning("Failed to apply storage %s: %v", blk.ID, runErr) failed++ @@ -254,8 +335,6 @@ func applyStorageCfg(ctx context.Context, cfgPath string, logger *logging.Logger applied++ } - _ = restoreFS.Remove(tmpName) - if err := ctx.Err(); err != nil { return applied, failed, err } @@ -289,14 +368,22 @@ func parseStorageBlocks(cfgPath string) ([]storageBlock, error) { // storage.cfg blocks use `: ` (e.g. `dir: local`, `nfs: backup`). // Older exports may still use `storage: ` blocks. - _, name, ok := parseSectionHeader(trimmed) + typ, name, ok := parseSectionHeader(trimmed) if ok { flush() - current = &storageBlock{ID: name, data: []string{line}} + storageType := "" + if !strings.EqualFold(typ, "storage") { + storageType = typ + } + current = &storageBlock{ID: name, Type: storageType} continue } if current != nil { - current.data = append(current.data, line) + key, value := parseProxmoxNotificationKV(trimmed) + if strings.TrimSpace(key) == "" { + continue + } + current.entries = append(current.entries, proxmoxNotificationEntry{Key: key, Value: value}) } } flush() diff --git a/internal/orchestrator/restore_cluster_apply_additional_test.go b/internal/orchestrator/restore_cluster_apply_additional_test.go index 06a61e68..d9d5ac62 100644 --- a/internal/orchestrator/restore_cluster_apply_additional_test.go +++ b/internal/orchestrator/restore_cluster_apply_additional_test.go @@ -53,7 +53,7 @@ func TestRunSafeClusterApplyWithUI_SkipsStorageDatacenterWhenStoragePVEStaged(t } for _, call := range runner.calls { - if strings.Contains(call, "/cluster/storage") || strings.Contains(call, "/cluster/config") { + if strings.Contains(call, "pvesh create /storage") || strings.Contains(call, "pvesh set /storage") || strings.Contains(call, "/cluster/config") { t.Fatalf("storage/datacenter apply should be skipped for storage_pve staged restore; calls=%#v", runner.calls) } } diff --git a/internal/orchestrator/restore_coverage_extra_test.go b/internal/orchestrator/restore_coverage_extra_test.go index 8225f75a..e21566cc 100644 --- a/internal/orchestrator/restore_coverage_extra_test.go +++ b/internal/orchestrator/restore_coverage_extra_test.go @@ -359,10 +359,10 @@ func TestRunSafeClusterApply_AppliesVMStorageAndDatacenterConfigs(t *testing.T) } wantPrefixes := []string{ - "pvesh set /nodes/" + node + "/qemu/100/config --filename ", - "pvesh set /nodes/" + node + "/lxc/101/config --filename ", - "pvesh set /cluster/storage/local -conf ", - "pvesh set /cluster/storage/backup_ext -conf ", + "pvesh set /nodes/" + node + "/qemu/100/config --name=vm100", + "pvesh set /nodes/" + node + "/lxc/101/config --hostname=ct101", + "pvesh create /storage --storage=local --type=dir --path=/var/lib/vz", + "pvesh create /storage --storage=backup_ext --type=nfs --server=10.0.0.1", "pvesh set /cluster/config -conf ", } for _, prefix := range wantPrefixes { @@ -377,6 +377,14 @@ func TestRunSafeClusterApply_AppliesVMStorageAndDatacenterConfigs(t *testing.T) t.Fatalf("expected a call with prefix %q; calls=%#v", prefix, runner.calls) } } + for _, call := range runner.calls { + if strings.Contains(call, "--filename") { + t.Fatalf("VM/CT apply must not use invalid --filename flag; calls=%#v", runner.calls) + } + if strings.Contains(call, "/cluster/storage/") || (strings.Contains(call, " -conf ") && strings.Contains(call, "storage")) { + t.Fatalf("storage apply must not use invalid cluster storage path or -conf flag; calls=%#v", runner.calls) + } + } } func TestRunSafeClusterApply_AppliesPoolsFromUserCfg(t *testing.T) { @@ -531,11 +539,10 @@ func TestRunSafeClusterApply_UsesSingleExportedNodeWhenHostnameMismatch(t *testi t.Fatalf("runSafeClusterApply error: %v", err) } - wantPrefix := "pvesh set /nodes/" + targetNode + "/qemu/100/config --filename " - wantSourceSuffix := filepath.Join("etc", "pve", "nodes", sourceNode, "qemu-server", "100.conf") + wantPrefix := "pvesh set /nodes/" + targetNode + "/qemu/100/config --name=vm100" found := false for _, call := range runner.calls { - if strings.HasPrefix(call, wantPrefix) && strings.Contains(call, wantSourceSuffix) { + if strings.HasPrefix(call, wantPrefix) { found = true break } @@ -593,11 +600,10 @@ func TestRunSafeClusterApply_PromptsForSourceNodeWhenMultipleExportNodes(t *test t.Fatalf("runSafeClusterApply error: %v", err) } - wantPrefix := "pvesh set /nodes/" + targetNode + "/qemu/101/config --filename " - wantSourceSuffix := filepath.Join("etc", "pve", "nodes", sourceNode2, "qemu-server", "101.conf") + wantPrefix := "pvesh set /nodes/" + targetNode + "/qemu/101/config --name=vm101" found := false for _, call := range runner.calls { - if strings.HasPrefix(call, wantPrefix) && strings.Contains(call, wantSourceSuffix) { + if strings.HasPrefix(call, wantPrefix) { found = true break } diff --git a/internal/orchestrator/restore_errors_test.go b/internal/orchestrator/restore_errors_test.go index bcc34990..31a2d9ce 100644 --- a/internal/orchestrator/restore_errors_test.go +++ b/internal/orchestrator/restore_errors_test.go @@ -551,9 +551,11 @@ func TestApplyStorageCfg_WithMultipleBlocks(t *testing.T) { // Write storage config with multiple blocks cfgPath := filepath.Join(t.TempDir(), "storage.cfg") content := `storage: local + type dir path /var/lib/vz storage: backup + type nfs path /mnt/backup ` if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil { @@ -570,6 +572,16 @@ storage: backup if applied != 2 { t.Fatalf("expected 2 applied, got %d (failed=%d)", applied, failed) } + calls := strings.Join(restoreCmd.(*FakeCommandRunner).CallsList(), "\n") + if strings.Contains(calls, " -conf ") { + t.Fatalf("storage apply must not use -conf; calls=%s", calls) + } + if !strings.Contains(calls, "pvesh create /storage --storage=local --type=dir --path=/var/lib/vz") { + t.Fatalf("missing local storage args; calls=%s", calls) + } + if !strings.Contains(calls, "pvesh create /storage --storage=backup --type=nfs --path=/mnt/backup") { + t.Fatalf("missing backup storage args; calls=%s", calls) + } } func TestApplyStorageCfg_PveshError(t *testing.T) { @@ -583,6 +595,7 @@ func TestApplyStorageCfg_PveshError(t *testing.T) { cfgPath := filepath.Join(t.TempDir(), "storage.cfg") content := `storage: local + type dir path /var/lib/vz ` if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil { @@ -1711,6 +1724,13 @@ func TestApplyVMConfigs_SuccessfulApply(t *testing.T) { if applied != 1 || failed != 0 { t.Fatalf("expected (1,0), got (%d,%d)", applied, failed) } + calls := strings.Join(fake.CallsList(), "\n") + if strings.Contains(calls, "--filename") { + t.Fatalf("VM apply must not use --filename; calls=%s", calls) + } + if !strings.Contains(calls, "pvesh set /nodes/") || !strings.Contains(calls, "/qemu/100/config --name=test-vm") { + t.Fatalf("missing VM config args; calls=%s", calls) + } } // -------------------------------------------------------------------------- diff --git a/internal/orchestrator/restore_test.go b/internal/orchestrator/restore_test.go index 36d2387b..7ad785b3 100644 --- a/internal/orchestrator/restore_test.go +++ b/internal/orchestrator/restore_test.go @@ -1180,6 +1180,9 @@ nfs: nfs-backup if blocks[0].ID != "local" || blocks[1].ID != "nfs-backup" { t.Fatalf("unexpected block IDs: %v, %v", blocks[0].ID, blocks[1].ID) } + if blocks[0].Type != "dir" || blocks[1].Type != "nfs" { + t.Fatalf("unexpected block types: %v, %v", blocks[0].Type, blocks[1].Type) + } } func TestParseStorageBlocks_LegacyStoragePrefix(t *testing.T) { @@ -1207,6 +1210,9 @@ func TestParseStorageBlocks_LegacyStoragePrefix(t *testing.T) { if blocks[0].ID != "local" { t.Fatalf("unexpected block ID: %v", blocks[0].ID) } + if blocks[0].Type != "" { + t.Fatalf("legacy storage block type = %q, want empty because type is in entries", blocks[0].Type) + } } // -------------------------------------------------------------------------- From 156b14e2519e105b76e3293eb1b3d9a8532207cf Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Wed, 6 May 2026 00:18:09 +0200 Subject: [PATCH 26/35] Add ExitEncryptionError and classify encryption failures Add a new ExitEncryptionError exit code and string mapping to represent encryption setup/processing failures. Update createBackupArchive to report the "encryption" phase and use ExitEncryptionError when age recipient preparation fails. Add tests: backup_run_phases_test verifies age-recipient failures are classified as encryption, and exit_codes_test updated to include the new code. Also fix RunGoBackup to return the collected stats value when prepareBackupWorkspace fails so callers receive consistent results. --- internal/orchestrator/backup_run_phases.go | 2 +- .../orchestrator/backup_run_phases_test.go | 36 +++++++++++++++++++ internal/orchestrator/orchestrator.go | 2 +- internal/types/exit_codes.go | 5 +++ internal/types/exit_codes_test.go | 2 ++ 5 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 internal/orchestrator/backup_run_phases_test.go diff --git a/internal/orchestrator/backup_run_phases.go b/internal/orchestrator/backup_run_phases.go index d2e5d47e..959ad204 100644 --- a/internal/orchestrator/backup_run_phases.go +++ b/internal/orchestrator/backup_run_phases.go @@ -226,7 +226,7 @@ func (o *Orchestrator) createBackupArchive(run *backupRunContext, workspace *bac ageRecipients, err := o.prepareAgeRecipients(run.ctx) if err != nil { - return nil, &BackupError{Phase: "config", Err: err, Code: types.ExitConfigError} + return nil, &BackupError{Phase: "encryption", Err: err, Code: types.ExitEncryptionError} } archiverConfig := o.buildBackupArchiverConfig(run, ageRecipients) diff --git a/internal/orchestrator/backup_run_phases_test.go b/internal/orchestrator/backup_run_phases_test.go new file mode 100644 index 00000000..581a408a --- /dev/null +++ b/internal/orchestrator/backup_run_phases_test.go @@ -0,0 +1,36 @@ +package orchestrator + +import ( + "context" + "errors" + "testing" + + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/types" +) + +func TestCreateBackupArchiveClassifiesAgeRecipientFailureAsEncryption(t *testing.T) { + orch := New(newTestLogger(), false) + orch.SetConfig(&config.Config{ + EncryptArchive: true, + BaseDir: t.TempDir(), + }) + orch.SetBackupConfig(t.TempDir(), t.TempDir(), types.CompressionNone, 0, 0, "standard", nil) + + run := orch.newBackupRunContext(context.Background(), nil, "test-host") + _, err := orch.createBackupArchive(run, &backupWorkspace{tempDir: t.TempDir()}) + if err == nil { + t.Fatal("expected createBackupArchive error") + } + + var backupErr *BackupError + if !errors.As(err, &backupErr) { + t.Fatalf("expected BackupError, got %T: %v", err, err) + } + if backupErr.Phase != "encryption" { + t.Fatalf("Phase=%q; want encryption", backupErr.Phase) + } + if backupErr.Code != types.ExitEncryptionError { + t.Fatalf("Code=%v; want %v", backupErr.Code, types.ExitEncryptionError) + } +} diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 985d1fdb..de9a9548 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -521,7 +521,7 @@ func (o *Orchestrator) RunGoBackup(ctx context.Context, envInfo *environment.Env }() if err := o.prepareBackupWorkspace(run, workspace); err != nil { - return nil, err + return stats, err } defer func() { o.cleanupBackupWorkspace(workspace) diff --git a/internal/types/exit_codes.go b/internal/types/exit_codes.go index 439c5f58..b91b84ac 100644 --- a/internal/types/exit_codes.go +++ b/internal/types/exit_codes.go @@ -49,6 +49,9 @@ const ( // ExitSecurityError - Errors detected by the security check. ExitSecurityError ExitCode = 14 + + // ExitEncryptionError - Error during encryption setup or processing. + ExitEncryptionError ExitCode = 15 ) // String returns a human-readable description of the exit code. @@ -84,6 +87,8 @@ func (e ExitCode) String() string { return "panic error" case ExitSecurityError: return "security error" + case ExitEncryptionError: + return "encryption error" default: return "unknown error" } diff --git a/internal/types/exit_codes_test.go b/internal/types/exit_codes_test.go index bda518c5..2cb8f7f6 100644 --- a/internal/types/exit_codes_test.go +++ b/internal/types/exit_codes_test.go @@ -17,6 +17,7 @@ func TestExitCodeString(t *testing.T) { {"network error", ExitNetworkError, "network error"}, {"permission error", ExitPermissionError, "permission error"}, {"verification error", ExitVerificationError, "verification error"}, + {"encryption error", ExitEncryptionError, "encryption error"}, {"unknown", ExitCode(99), "unknown error"}, } @@ -45,6 +46,7 @@ func TestExitCodeInt(t *testing.T) { {"network error", ExitNetworkError, 6}, {"permission error", ExitPermissionError, 7}, {"verification error", ExitVerificationError, 8}, + {"encryption error", ExitEncryptionError, 15}, } for _, tt := range tests { From 2b70b8093002620a1366d6b257fe038a1da86d41 Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Wed, 6 May 2026 00:26:35 +0200 Subject: [PATCH 27/35] Return error from checksum write; add tests Change writeArchiveChecksum to return an error and propagate failures: writeArchiveChecksum now returns a wrapped error on write failure (uses 0o640 file mode) and verifyAndWriteBackupArtifacts returns a BackupError when checksum writing fails. Make server identity detection injectable for testing by introducing runtimeServerIdentityDetector and only run detection when ServerID is not configured; add unit tests for initializeServerIdentity. Add unit test ensuring checksum write errors are propagated. Also add Node.js 24 setup to the Dependabot automerge workflow so Dependabot metadata step runs under Node 24. --- .github/workflows/dependabot-automerge.yml | 5 ++ cmd/proxsave/main_identity.go | 16 ++++-- cmd/proxsave/main_identity_test.go | 54 +++++++++++++++++++ internal/orchestrator/backup_run_helpers.go | 10 ++-- internal/orchestrator/backup_run_phases.go | 8 ++- .../orchestrator/backup_run_phases_test.go | 24 +++++++++ 6 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 cmd/proxsave/main_identity_test.go diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index 74f957de..c5264029 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -16,6 +16,11 @@ jobs: if: github.actor == 'dependabot[bot]' steps: + - name: Set up Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: '24' + - name: Fetch Dependabot metadata id: metadata uses: dependabot/fetch-metadata@v3 diff --git a/cmd/proxsave/main_identity.go b/cmd/proxsave/main_identity.go index 397f09bd..ca53fc12 100644 --- a/cmd/proxsave/main_identity.go +++ b/cmd/proxsave/main_identity.go @@ -10,15 +10,21 @@ import ( "github.com/tis24dev/proxsave/internal/notify" ) +var runtimeServerIdentityDetector = detectServerIdentity + func initializeServerIdentity(rt *appRuntime) { - identityInfo := detectServerIdentity(rt) rt.serverIDValue = strings.TrimSpace(rt.cfg.ServerID) rt.serverMACValue = "" - if identityInfo != nil { - applyDetectedIdentity(rt, identityInfo) - } - if rt.serverIDValue != "" && rt.cfg.ServerID == "" { + if rt.serverIDValue != "" { rt.cfg.ServerID = rt.serverIDValue + } else { + identityInfo := runtimeServerIdentityDetector(rt) + if identityInfo != nil { + applyDetectedIdentity(rt, identityInfo) + } + if rt.serverIDValue != "" { + rt.cfg.ServerID = rt.serverIDValue + } } logServerIdentityValues(rt.serverIDValue, rt.serverMACValue) diff --git a/cmd/proxsave/main_identity_test.go b/cmd/proxsave/main_identity_test.go new file mode 100644 index 00000000..fd89a782 --- /dev/null +++ b/cmd/proxsave/main_identity_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "testing" + + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/identity" +) + +func TestInitializeServerIdentityKeepsConfiguredServerID(t *testing.T) { + origDetector := runtimeServerIdentityDetector + t.Cleanup(func() { runtimeServerIdentityDetector = origDetector }) + + called := false + runtimeServerIdentityDetector = func(*appRuntime) *identity.Info { + called = true + return &identity.Info{ServerID: "detected", PrimaryMAC: "00:11:22:33:44:55"} + } + + rt := &appRuntime{cfg: &config.Config{ServerID: " configured "}} + initializeServerIdentity(rt) + + if called { + t.Fatal("detector should not run when ServerID is explicitly configured") + } + if rt.serverIDValue != "configured" { + t.Fatalf("serverIDValue=%q; want configured", rt.serverIDValue) + } + if rt.cfg.ServerID != "configured" { + t.Fatalf("cfg.ServerID=%q; want trimmed configured", rt.cfg.ServerID) + } +} + +func TestInitializeServerIdentityStoresDetectedServerID(t *testing.T) { + origDetector := runtimeServerIdentityDetector + t.Cleanup(func() { runtimeServerIdentityDetector = origDetector }) + + runtimeServerIdentityDetector = func(*appRuntime) *identity.Info { + return &identity.Info{ServerID: "detected", PrimaryMAC: "00:11:22:33:44:55"} + } + + rt := &appRuntime{cfg: &config.Config{}} + initializeServerIdentity(rt) + + if rt.serverIDValue != "detected" { + t.Fatalf("serverIDValue=%q; want detected", rt.serverIDValue) + } + if rt.cfg.ServerID != "detected" { + t.Fatalf("cfg.ServerID=%q; want detected", rt.cfg.ServerID) + } + if rt.serverMACValue != "00:11:22:33:44:55" { + t.Fatalf("serverMACValue=%q; want detected MAC", rt.serverMACValue) + } +} diff --git a/internal/orchestrator/backup_run_helpers.go b/internal/orchestrator/backup_run_helpers.go index 8591bd7a..32103742 100644 --- a/internal/orchestrator/backup_run_helpers.go +++ b/internal/orchestrator/backup_run_helpers.go @@ -259,13 +259,13 @@ func (o *Orchestrator) generateArchiveChecksum(ctx context.Context, archivePath return checksum, nil } -func (o *Orchestrator) writeArchiveChecksum(workspace *backupWorkspace, artifacts *backupArtifacts, checksum string) { +func (o *Orchestrator) writeArchiveChecksum(workspace *backupWorkspace, artifacts *backupArtifacts, checksum string) error { checksumContent := fmt.Sprintf("%s %s\n", checksum, filepath.Base(artifacts.archivePath)) - if err := workspace.fs.WriteFile(artifacts.checksumPath, []byte(checksumContent), 0640); err != nil { - o.logger.Warning("Failed to write checksum file %s: %v", artifacts.checksumPath, err) - } else { - o.logger.Debug("Checksum file written to %s", artifacts.checksumPath) + if err := workspace.fs.WriteFile(artifacts.checksumPath, []byte(checksumContent), 0o640); err != nil { + return fmt.Errorf("write checksum file %s: %w", artifacts.checksumPath, err) } + o.logger.Debug("Checksum file written to %s", artifacts.checksumPath) + return nil } func (o *Orchestrator) writeArchiveManifest(run *backupRunContext, artifacts *backupArtifacts, checksum string) error { diff --git a/internal/orchestrator/backup_run_phases.go b/internal/orchestrator/backup_run_phases.go index 959ad204..0e681583 100644 --- a/internal/orchestrator/backup_run_phases.go +++ b/internal/orchestrator/backup_run_phases.go @@ -271,7 +271,13 @@ func (o *Orchestrator) verifyAndWriteBackupArtifacts(run *backupRunContext, work } stats.Checksum = checksum - o.writeArchiveChecksum(workspace, artifacts, checksum) + if err := o.writeArchiveChecksum(workspace, artifacts, checksum); err != nil { + return &BackupError{ + Phase: "verification", + Err: err, + Code: types.ExitVerificationError, + } + } if err := o.writeArchiveManifest(run, artifacts, checksum); err != nil { return err } diff --git a/internal/orchestrator/backup_run_phases_test.go b/internal/orchestrator/backup_run_phases_test.go index 581a408a..dcfc270c 100644 --- a/internal/orchestrator/backup_run_phases_test.go +++ b/internal/orchestrator/backup_run_phases_test.go @@ -3,6 +3,7 @@ package orchestrator import ( "context" "errors" + "strings" "testing" "github.com/tis24dev/proxsave/internal/config" @@ -34,3 +35,26 @@ func TestCreateBackupArchiveClassifiesAgeRecipientFailureAsEncryption(t *testing t.Fatalf("Code=%v; want %v", backupErr.Code, types.ExitEncryptionError) } } + +func TestWriteArchiveChecksumPropagatesWriteError(t *testing.T) { + orch := New(newTestLogger(), false) + checksumPath := "/backups/test.tar.sha256" + writeErr := errors.New("disk full") + fakeFS := NewFakeFS() + t.Cleanup(func() { _ = fakeFS.Cleanup() }) + + err := orch.writeArchiveChecksum( + &backupWorkspace{fs: writeFileFailFS{FS: fakeFS, failPath: checksumPath, err: writeErr}}, + &backupArtifacts{archivePath: "/backups/test.tar", checksumPath: checksumPath}, + "abc123", + ) + if err == nil { + t.Fatal("expected writeArchiveChecksum error") + } + if !errors.Is(err, writeErr) { + t.Fatalf("expected wrapped write error, got %v", err) + } + if !strings.Contains(err.Error(), checksumPath) { + t.Fatalf("expected checksum path in error, got %q", err.Error()) + } +} From 532ebe49dfd45cb910053ac66a198dcaf0ab6020 Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Wed, 6 May 2026 00:39:06 +0200 Subject: [PATCH 28/35] Propagate context cancellation in collectors Add isContextCancellationError helper and use it across PVE collection bricks and storage/metadata steps so context cancellations are propagated (returning the ctx error) instead of being treated as regular failures. Add tests ensuring guest and storage bricks propagate cancellations. Include new standalone collection flags (BackupPBSNotificationsPriv, BackupRootHome, BackupScriptRepository, BackupUserHomes) in collectionOptionFlags and add tests validating they are accepted. Change validateCloudSettings to accept absolute cloud remote refs with a new isAbsoluteCloudRemoteRef helper and add tests for absolute refs and path validation. Remove unused setBackupResult from main_state.go and update related imports in tests. --- cmd/proxsave/main_state.go | 6 --- internal/backup/collector.go | 4 +- internal/backup/collector_bricks.go | 15 +++++++ internal/backup/collector_bricks_pve.go | 9 ++++ .../backup/collector_bricks_pve_finalize.go | 18 ++++++++ internal/backup/collector_bricks_pve_jobs.go | 24 +++++++++++ .../backup/collector_bricks_pve_storage.go | 15 +++++++ internal/backup/collector_bricks_test.go | 43 +++++++++++++++++++ .../backup/collector_config_extra_test.go | 20 +++++++++ internal/backup/collector_pve.go | 12 ++++++ internal/config/config.go | 25 +++++++++-- internal/config/config_test.go | 24 +++++++++++ 12 files changed, 205 insertions(+), 10 deletions(-) diff --git a/cmd/proxsave/main_state.go b/cmd/proxsave/main_state.go index b577e6e8..cef9c602 100644 --- a/cmd/proxsave/main_state.go +++ b/cmd/proxsave/main_state.go @@ -72,12 +72,6 @@ func (state *appRunState) finalize(code int) int { return code } -func (state *appRunState) setBackupResult(result backupModeResult) { - state.orch = result.orch - state.earlyErrorState = result.earlyErrorState - state.pendingSupportStat = result.supportStats -} - func (state *appRunState) applyModeResult(result modeResult) { state.orch = result.orch state.earlyErrorState = result.earlyErrorState diff --git a/internal/backup/collector.go b/internal/backup/collector.go index da2d7faf..7bcbfad7 100644 --- a/internal/backup/collector.go +++ b/internal/backup/collector.go @@ -313,7 +313,8 @@ func (c *CollectorConfig) collectionOptionFlags() []bool { c.BackupPVEBackupFiles, c.BackupCephConfig, c.BackupDatastoreConfigs, c.BackupPBSS3Endpoints, c.BackupPBSNodeConfig, c.BackupPBSAcmeAccounts, c.BackupPBSAcmePlugins, c.BackupPBSMetricServers, - c.BackupPBSTrafficControl, c.BackupPBSNotifications, c.BackupUserConfigs, c.BackupRemoteConfigs, + c.BackupPBSTrafficControl, c.BackupPBSNotifications, c.BackupPBSNotificationsPriv, + c.BackupUserConfigs, c.BackupRemoteConfigs, c.BackupSyncJobs, c.BackupVerificationJobs, c.BackupTapeConfigs, c.BackupPBSNetworkConfig, c.BackupPruneSchedules, c.BackupPxarFiles, c.BackupNetworkConfigs, c.BackupAptSources, c.BackupCronJobs, @@ -321,6 +322,7 @@ func (c *CollectorConfig) collectionOptionFlags() []bool { c.BackupKernelModules, c.BackupFirewallRules, c.BackupInstalledPackages, c.BackupScriptDir, c.BackupCriticalFiles, c.BackupSSHKeys, c.BackupZFSConfig, c.BackupConfigFile, + c.BackupRootHome, c.BackupScriptRepository, c.BackupUserHomes, } } diff --git a/internal/backup/collector_bricks.go b/internal/backup/collector_bricks.go index 7b2136af..a08c530c 100644 --- a/internal/backup/collector_bricks.go +++ b/internal/backup/collector_bricks.go @@ -3,6 +3,7 @@ package backup import ( "context" + "errors" "fmt" ) @@ -306,6 +307,20 @@ func runRecipe(ctx context.Context, r recipe, state *collectionState) error { return nil } +func isContextCancellationError(ctx context.Context, err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return true + } + if ctx == nil { + return false + } + ctxErr := ctx.Err() + return ctxErr != nil && errors.Is(err, ctxErr) +} + func (s *collectionState) ensurePVECommandsDir() (string, error) { if s.pve.commandsDir != "" { return s.pve.commandsDir, nil diff --git a/internal/backup/collector_bricks_pve.go b/internal/backup/collector_bricks_pve.go index b2488546..4218cd0c 100644 --- a/internal/backup/collector_bricks_pve.go +++ b/internal/backup/collector_bricks_pve.go @@ -175,6 +175,9 @@ func newPVEGuestBricks() []collectionBrick { } c.logger.Info("Collecting VM and container configurations") if err := c.collectPVEQEMUConfigs(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Failed to collect QEMU VM configs: %v", err) state.pve.guestCollectionAborted = true } @@ -190,6 +193,9 @@ func newPVEGuestBricks() []collectionBrick { return nil } if err := c.collectPVELXCConfigs(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Failed to collect LXC configs: %v", err) state.pve.guestCollectionAborted = true } @@ -205,6 +211,9 @@ func newPVEGuestBricks() []collectionBrick { return nil } if err := c.collectPVEGuestInventory(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Failed to collect guest inventory: %v", err) state.pve.guestCollectionAborted = true } diff --git a/internal/backup/collector_bricks_pve_finalize.go b/internal/backup/collector_bricks_pve_finalize.go index 9753a921..0c647af4 100644 --- a/internal/backup/collector_bricks_pve_finalize.go +++ b/internal/backup/collector_bricks_pve_finalize.go @@ -15,6 +15,9 @@ func newPVECephBricks() []collectionBrick { } c.logger.Debug("Collecting Ceph configuration and status") if err := c.collectPVECephConfigSnapshot(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Failed to collect Ceph configuration snapshot: %v", err) state.pve.cephCollectionAborted = true } @@ -30,6 +33,9 @@ func newPVECephBricks() []collectionBrick { return nil } if err := c.collectPVECephRuntime(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Failed to collect Ceph runtime information: %v", err) state.pve.cephCollectionAborted = true } else { @@ -50,6 +56,9 @@ func newPVEAliasBricks() []collectionBrick { c := state.collector c.logger.Debug("Creating PVE info aliases under /var/lib/pve-cluster/info") if err := c.createPVECoreAliases(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Failed to create PVE core aliases: %v", err) state.pve.finalizeCollectionAborted = true } @@ -70,6 +79,9 @@ func newPVEAggregateBricks() []collectionBrick { return nil } if err := c.createPVEBackupHistoryAggregate(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Failed to aggregate PVE backup history: %v", err) state.pve.finalizeCollectionAborted = true } @@ -85,6 +97,9 @@ func newPVEAggregateBricks() []collectionBrick { return nil } if err := c.createPVEReplicationAggregate(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Failed to aggregate PVE replication status: %v", err) state.pve.finalizeCollectionAborted = true } @@ -105,6 +120,9 @@ func newPVEVersionBricks() []collectionBrick { return nil } if err := c.createPVEVersionInfo(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Failed to write PVE version info: %v", err) state.pve.finalizeCollectionAborted = true } diff --git a/internal/backup/collector_bricks_pve_jobs.go b/internal/backup/collector_bricks_pve_jobs.go index 826b03dd..69e6cb26 100644 --- a/internal/backup/collector_bricks_pve_jobs.go +++ b/internal/backup/collector_bricks_pve_jobs.go @@ -15,6 +15,9 @@ func newPVEBackupJobBricks() []collectionBrick { } c.logger.Debug("Collecting PVE job definitions for nodes: %v", state.pve.runtimeNodes()) if err := c.collectPVEBackupJobDefinitions(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Failed to collect PVE backup job definitions: %v", err) state.pve.jobCollectionAborted = true } @@ -30,6 +33,9 @@ func newPVEBackupJobBricks() []collectionBrick { return nil } if err := c.collectPVEBackupJobHistory(ctx, state.pve.runtimeNodes()); err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Failed to collect PVE backup history: %v", err) state.pve.jobCollectionAborted = true } @@ -45,6 +51,9 @@ func newPVEBackupJobBricks() []collectionBrick { return nil } if err := c.collectPVEVZDumpCronSnapshot(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Failed to collect VZDump cron snapshot: %v", err) state.pve.jobCollectionAborted = true } @@ -65,6 +74,9 @@ func newPVEScheduleBricks() []collectionBrick { return nil } if err := c.collectPVEScheduleCrontab(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Failed to collect PVE crontab schedules: %v", err) state.pve.scheduleCollectionAborted = true } @@ -80,6 +92,9 @@ func newPVEScheduleBricks() []collectionBrick { return nil } if err := c.collectPVEScheduleTimers(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Failed to collect PVE timer schedules: %v", err) state.pve.scheduleCollectionAborted = true } @@ -95,6 +110,9 @@ func newPVEScheduleBricks() []collectionBrick { return nil } if err := c.collectPVEScheduleCronFiles(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Failed to collect PVE cron schedule files: %v", err) state.pve.scheduleCollectionAborted = true } @@ -116,6 +134,9 @@ func newPVEReplicationBricks() []collectionBrick { } c.logger.Debug("Collecting PVE replication settings for nodes: %v", state.pve.runtimeNodes()) if err := c.collectPVEReplicationDefinitions(ctx); err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Failed to collect PVE replication definitions: %v", err) state.pve.replicationCollectionAborted = true } @@ -131,6 +152,9 @@ func newPVEReplicationBricks() []collectionBrick { return nil } if err := c.collectPVEReplicationStatus(ctx, state.pve.runtimeNodes()); err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Failed to collect PVE replication status: %v", err) state.pve.replicationCollectionAborted = true } diff --git a/internal/backup/collector_bricks_pve_storage.go b/internal/backup/collector_bricks_pve_storage.go index 9299ce3f..8cd6c0c6 100644 --- a/internal/backup/collector_bricks_pve_storage.go +++ b/internal/backup/collector_bricks_pve_storage.go @@ -56,6 +56,9 @@ func newPVEStorageProbeBricks() []collectionBrick { for _, storage := range state.pve.resolvedStorages { result, err := c.preparePVEStorageScan(ctx, storage, baseDir, ioTimeout) if err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Failed to probe PVE datastore %s: %v", storage.Name, err) state.pve.storageCollectionAborted = true return nil @@ -89,6 +92,9 @@ func newPVEStorageMetadataJSONBricks() []collectionBrick { continue } if err := c.collectPVEStorageMetadataJSONStep(ctx, result, ioTimeout); err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Failed to write PVE datastore JSON metadata for %s: %v", storage.Name, err) state.pve.storageCollectionAborted = true return nil @@ -117,6 +123,9 @@ func newPVEStorageMetadataTextBricks() []collectionBrick { continue } if err := c.collectPVEStorageMetadataTextStep(ctx, result, ioTimeout); err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Failed to write PVE datastore text metadata for %s: %v", storage.Name, err) state.pve.storageCollectionAborted = true return nil @@ -145,6 +154,9 @@ func newPVEStorageAnalysisBricks() []collectionBrick { continue } if err := c.collectPVEStorageBackupAnalysisStep(ctx, result, ioTimeout); err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Detailed backup analysis for %s failed: %v", storage.Name, err) } } @@ -165,6 +177,9 @@ func newPVEStorageSummaryBricks() []collectionBrick { return nil } if err := c.writePVEStorageSummary(ctx, state.pve.probedStorages); err != nil { + if isContextCancellationError(ctx, err) { + return err + } c.logger.Warning("Failed to write PVE datastore summary: %v", err) state.pve.storageCollectionAborted = true return nil diff --git a/internal/backup/collector_bricks_test.go b/internal/backup/collector_bricks_test.go index 6270107c..0f559a40 100644 --- a/internal/backup/collector_bricks_test.go +++ b/internal/backup/collector_bricks_test.go @@ -10,6 +10,9 @@ import ( "sort" "strings" "testing" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/types" ) func TestRunRecipeRunsBricksInOrder(t *testing.T) { @@ -120,6 +123,46 @@ func TestRunRecipePropagatesContextCancellation(t *testing.T) { } } +func TestPVEGuestBrickPropagatesQEMUContextCancellation(t *testing.T) { + cfg := &CollectorConfig{ + BackupVMConfigs: true, + PVEConfigPath: filepath.Join(t.TempDir(), "etc", "pve"), + } + if err := os.MkdirAll(filepath.Join(cfg.PVEConfigPath, "qemu-server"), 0o755); err != nil { + t.Fatalf("mkdir qemu-server: %v", err) + } + + collector := NewCollector(logging.New(types.LogLevelError, false), cfg, t.TempDir(), types.ProxmoxVE, false) + state := newCollectionState(collector) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := newPVEGuestBricks()[0].Run(ctx, state) + if !errors.Is(err, context.Canceled) { + t.Fatalf("guest brick error = %v, want %v", err, context.Canceled) + } + if state.pve.guestCollectionAborted { + t.Fatalf("guest collection should not be marked aborted for context cancellation") + } +} + +func TestPVEStorageProbeBrickPropagatesContextCancellation(t *testing.T) { + cfg := &CollectorConfig{BackupPVEBackupFiles: true} + collector := NewCollector(logging.New(types.LogLevelError, false), cfg, t.TempDir(), types.ProxmoxVE, false) + state := newCollectionState(collector) + state.pve.resolvedStorages = []pveStorageEntry{{Name: "local", Path: t.TempDir()}} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := newPVEStorageProbeBricks()[0].Run(ctx, state) + if !errors.Is(err, context.Canceled) { + t.Fatalf("storage probe brick error = %v, want %v", err, context.Canceled) + } + if state.pve.storageCollectionAborted { + t.Fatalf("storage collection should not be marked aborted for context cancellation") + } +} + func recipeBrickIDs(r recipe) []BrickID { ids := make([]BrickID, 0, len(r.Bricks)) for _, brick := range r.Bricks { diff --git a/internal/backup/collector_config_extra_test.go b/internal/backup/collector_config_extra_test.go index e13f6cdd..06e64f5b 100644 --- a/internal/backup/collector_config_extra_test.go +++ b/internal/backup/collector_config_extra_test.go @@ -33,6 +33,26 @@ func TestCollectorConfigValidateDefaultsAndErrors(t *testing.T) { } } +func TestCollectorConfigValidateAcceptsNewStandaloneCollectionOptions(t *testing.T) { + tests := []struct { + name string + cfg *CollectorConfig + }{ + {name: "pbs notification priv", cfg: &CollectorConfig{BackupPBSNotificationsPriv: true}}, + {name: "root home", cfg: &CollectorConfig{BackupRootHome: true}}, + {name: "script repository", cfg: &CollectorConfig{BackupScriptRepository: true}}, + {name: "user homes", cfg: &CollectorConfig{BackupUserHomes: true}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } + }) + } +} + func TestGlobHelpers(t *testing.T) { cases := []struct { pattern string diff --git a/internal/backup/collector_pve.go b/internal/backup/collector_pve.go index 60af6575..18313254 100644 --- a/internal/backup/collector_pve.go +++ b/internal/backup/collector_pve.go @@ -1171,6 +1171,9 @@ func (c *Collector) collectPVEStorageMetadataJSONStep(ctx context.Context, resul result.SkipRemaining = true return nil } + if isContextCancellationError(ctx, dirSampleErr) { + return dirSampleErr + } if dirSampleErr != nil { c.logger.Debug("Directory sample for datastore %s failed: %v", storage.Name, dirSampleErr) } @@ -1186,6 +1189,9 @@ func (c *Collector) collectPVEStorageMetadataJSONStep(ctx context.Context, resul result.SkipRemaining = true return nil } + if isContextCancellationError(ctx, diskUsageErr) { + return diskUsageErr + } if diskUsageErr != nil { c.logger.Debug("Disk usage summary for %s failed: %v", storage.Name, diskUsageErr) } else { @@ -1210,6 +1216,9 @@ func (c *Collector) collectPVEStorageMetadataJSONStep(ctx context.Context, resul result.SkipRemaining = true return nil } + if isContextCancellationError(ctx, sampleFileErr) { + return sampleFileErr + } if sampleFileErr != nil { c.logger.Debug("Backup file sample for %s failed: %v", storage.Name, sampleFileErr) } else if len(fileSummaries) > 0 { @@ -1243,6 +1252,9 @@ func (c *Collector) collectPVEStorageMetadataTextStep(ctx context.Context, resul result.SkipRemaining = true return nil } + if isContextCancellationError(ctx, fileSampleErr) { + return fileSampleErr + } if fileSampleErr != nil { c.logger.Debug("General file sampling for %s failed: %v", storage.Name, fileSampleErr) } diff --git a/internal/config/config.go b/internal/config/config.go index ae3c5e4c..07532c9d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -405,9 +405,12 @@ func (c *Config) validateCloudSettings() error { if !c.CloudEnabled { return nil } - remoteName, basePath := splitCloudRemoteRef(strings.TrimSpace(c.CloudRemote)) - if err := safeexec.ValidateRcloneRemoteName(remoteName); err != nil { - return fmt.Errorf("CLOUD_REMOTE invalid: %w", err) + cloudRemote := strings.TrimSpace(c.CloudRemote) + remoteName, basePath := splitCloudRemoteRef(cloudRemote) + if !isAbsoluteCloudRemoteRef(remoteName, basePath) { + if err := safeexec.ValidateRcloneRemoteName(remoteName); err != nil { + return fmt.Errorf("CLOUD_REMOTE invalid: %w", err) + } } if err := safeexec.ValidateRemoteRelativePath(strings.Trim(strings.TrimSpace(basePath), "/"), "CLOUD_REMOTE path"); err != nil { return err @@ -418,6 +421,22 @@ func (c *Config) validateCloudSettings() error { return nil } +func isAbsoluteCloudRemoteRef(remoteName, basePath string) bool { + remoteName = strings.TrimSpace(remoteName) + basePath = strings.TrimSpace(basePath) + if filepath.IsAbs(remoteName) { + return true + } + if len(remoteName) != 1 { + return false + } + drive := remoteName[0] + if (drive < 'A' || drive > 'Z') && (drive < 'a' || drive > 'z') { + return false + } + return strings.HasPrefix(basePath, `\`) || strings.HasPrefix(basePath, "/") +} + func splitCloudRemoteRef(ref string) (remoteName, relPath string) { parts := strings.SplitN(ref, ":", 2) if len(parts) < 2 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6db74d9a..e9e6bce0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -370,6 +370,30 @@ SECONDARY_LOG_PATH=remote:/logs } } +func TestValidateCloudSettingsAllowsAbsoluteCloudRemote(t *testing.T) { + tests := []string{ + "/mnt/cloud", + `C:\cloud`, + "C:/cloud", + } + + for _, remote := range tests { + t.Run(remote, func(t *testing.T) { + cfg := &Config{CloudEnabled: true, CloudRemote: remote} + if err := cfg.validateCloudSettings(); err != nil { + t.Fatalf("validateCloudSettings() error = %v", err) + } + }) + } +} + +func TestValidateCloudSettingsStillValidatesAbsoluteCloudRemotePath(t *testing.T) { + cfg := &Config{CloudEnabled: true, CloudRemote: "/mnt/cloud:../escape"} + if err := cfg.validateCloudSettings(); err == nil { + t.Fatal("expected validateCloudSettings to reject traversal in CLOUD_REMOTE path") + } +} + func TestLoadConfigWithQuotes(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "test_quotes.env") From 16a7b694ede24e32cdbb643340a273165a5f586d Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Wed, 6 May 2026 01:00:01 +0200 Subject: [PATCH 29/35] Add completion timeout to TUI simulation tests Introduce simAppCompletionTimeout and group timing constants. Improve test timeout handling by reporting errors and stopping the app if the initial draw doesn't occur or the simulation doesn't finish within the completion timeout after injecting keys. Also properly stop and reset the timer to avoid races. --- internal/orchestrator/tui_simulation_test.go | 21 +++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/internal/orchestrator/tui_simulation_test.go b/internal/orchestrator/tui_simulation_test.go index b846286d..82f9f247 100644 --- a/internal/orchestrator/tui_simulation_test.go +++ b/internal/orchestrator/tui_simulation_test.go @@ -13,7 +13,10 @@ import ( "github.com/tis24dev/proxsave/internal/tui" ) -const simAppInitialDrawTimeout = 2 * time.Second +const ( + simAppInitialDrawTimeout = 2 * time.Second + simAppCompletionTimeout = 10 * time.Second +) type simKey struct { Key tcell.Key @@ -68,6 +71,8 @@ func withSimAppSequence(t *testing.T, keys []simKey) <-chan struct{} { case <-done: return case <-timer.C: + t.Errorf("TUI simulation did not render its initial draw within %s", simAppInitialDrawTimeout) + app.Stop() return } @@ -83,6 +88,20 @@ func withSimAppSequence(t *testing.T, keys []simKey) <-chan struct{} { } screen.InjectKey(k.Key, k.R, mod) } + + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(simAppCompletionTimeout) + select { + case <-done: + case <-timer.C: + t.Errorf("TUI simulation did not finish within %s after injecting %d key(s)", simAppCompletionTimeout, len(keys)) + app.Stop() + } }() }) return app From daddefa6210e146c2e65b98310d3049cc7383c39 Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Wed, 6 May 2026 12:17:12 +0200 Subject: [PATCH 30/35] Update decrypt_tui_e2e_helpers_test.go --- .../decrypt_tui_e2e_helpers_test.go | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/internal/orchestrator/decrypt_tui_e2e_helpers_test.go b/internal/orchestrator/decrypt_tui_e2e_helpers_test.go index e15fe555..e82f7136 100644 --- a/internal/orchestrator/decrypt_tui_e2e_helpers_test.go +++ b/internal/orchestrator/decrypt_tui_e2e_helpers_test.go @@ -27,6 +27,11 @@ import ( var decryptTUIE2EMu sync.Mutex +const ( + timedSimScreenWaitTimeout = 5 * time.Second + timedSimCompletionTimeout = 10 * time.Second +) + type notifyingSimulationScreen struct { tcell.SimulationScreen mu sync.Mutex @@ -137,7 +142,20 @@ func withTimedSimAppSequence(t *testing.T, keys []timedSimKey) { orig := newTUIApp done := make(chan struct{}) var injectWG sync.WaitGroup + var appMu sync.RWMutex + var currentApp *tui.App + + stopCurrentApp := func() { + appMu.RLock() + app := currentApp + appMu.RUnlock() + if app != nil { + app.Stop() + } + } + t.Cleanup(func() { + stopCurrentApp() close(done) injectWG.Wait() newTUIApp = orig @@ -156,8 +174,6 @@ func withTimedSimAppSequence(t *testing.T, keys []timedSimKey) { } screenStateCh := make(chan struct{}, 1) - var appMu sync.RWMutex - var currentApp *tui.App screen := ¬ifyingSimulationScreen{ SimulationScreen: baseScreen, notify: func() { @@ -201,6 +217,8 @@ func withTimedSimAppSequence(t *testing.T, keys []timedSimKey) { waitForScreenText := func(expected string) bool { expected = strings.TrimSpace(expected) + timer := time.NewTimer(timedSimScreenWaitTimeout) + defer timer.Stop() for { current := currentScreenState() if current.signature != "" { @@ -214,6 +232,10 @@ func withTimedSimAppSequence(t *testing.T, keys []timedSimKey) { case <-done: return false case <-screenStateCh: + case <-timer.C: + t.Errorf("TUI simulation did not render expected text %q within %s", expected, timedSimScreenWaitTimeout) + stopCurrentApp() + return false } } } @@ -237,6 +259,15 @@ func withTimedSimAppSequence(t *testing.T, keys []timedSimKey) { screen.InjectKey(k.Key, k.R, mod) lastInjectedState = current.signature } + + timer := time.NewTimer(timedSimCompletionTimeout) + defer timer.Stop() + select { + case <-done: + case <-timer.C: + t.Errorf("TUI simulation did not finish within %s after injecting %d key(s)", timedSimCompletionTimeout, len(keys)) + stopCurrentApp() + } }() }) From da7a7e7a90add09c7e3b4c0a65f87318a42ef70c Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Wed, 6 May 2026 13:24:22 +0200 Subject: [PATCH 31/35] tui: handle Stop-before-Run and test helpers Ensure App.Stop is safe when called before Run by adding run state tracking, a mutex and a stopRequested flag; implement Run and markRunningAndStopIfRequested to defer stopping until the event loop starts. Add synchronized test helpers (setTimedSimAppStopper, stopTimedSimAppForTest) and protect simulated apps with mutexes so tests can reliably stop current TUI instances. Increase several simulation timeouts and update runDecryptWorkflowTUIForTest to use a cancellable context and trigger a timed shutdown path. Also add a test verifying pre-run Stop terminates RunWithContext as expected. --- .../decrypt_tui_e2e_helpers_test.go | 52 +++++++++++++-- internal/orchestrator/tui_simulation_test.go | 19 +++++- internal/tui/abort_context_test.go | 19 ++++++ internal/tui/app.go | 64 ++++++++++++++++++- 4 files changed, 145 insertions(+), 9 deletions(-) diff --git a/internal/orchestrator/decrypt_tui_e2e_helpers_test.go b/internal/orchestrator/decrypt_tui_e2e_helpers_test.go index e82f7136..5378b724 100644 --- a/internal/orchestrator/decrypt_tui_e2e_helpers_test.go +++ b/internal/orchestrator/decrypt_tui_e2e_helpers_test.go @@ -25,11 +25,15 @@ import ( "github.com/tis24dev/proxsave/internal/types" ) -var decryptTUIE2EMu sync.Mutex +var ( + decryptTUIE2EMu sync.Mutex + timedSimAppStopperMu sync.Mutex + timedSimAppStopper func() +) const ( - timedSimScreenWaitTimeout = 5 * time.Second - timedSimCompletionTimeout = 10 * time.Second + timedSimScreenWaitTimeout = 10 * time.Second + timedSimCompletionTimeout = 15 * time.Second ) type notifyingSimulationScreen struct { @@ -153,9 +157,11 @@ func withTimedSimAppSequence(t *testing.T, keys []timedSimKey) { app.Stop() } } + restoreStopper := setTimedSimAppStopper(stopCurrentApp) t.Cleanup(func() { stopCurrentApp() + restoreStopper() close(done) injectWG.Wait() newTUIApp = orig @@ -275,6 +281,28 @@ func withTimedSimAppSequence(t *testing.T, keys []timedSimKey) { } } +func setTimedSimAppStopper(stop func()) func() { + timedSimAppStopperMu.Lock() + previous := timedSimAppStopper + timedSimAppStopper = stop + timedSimAppStopperMu.Unlock() + + return func() { + timedSimAppStopperMu.Lock() + timedSimAppStopper = previous + timedSimAppStopperMu.Unlock() + } +} + +func stopTimedSimAppForTest() { + timedSimAppStopperMu.Lock() + stop := timedSimAppStopper + timedSimAppStopperMu.Unlock() + if stop != nil { + stop() + } +} + func timedSimScreenStateSignature(snapshot timedSimScreenSnapshot, focus any) string { if !snapshot.ready || snapshot.width <= 0 || snapshot.height <= 0 || len(snapshot.cells) < snapshot.width*snapshot.height { return "" @@ -442,12 +470,15 @@ func abortDecryptTUISequence() []timedSimKey { func runDecryptWorkflowTUIForTest(t *testing.T, ctx context.Context, cfg *config.Config, configPath string) error { t.Helper() + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + logger := logging.New(types.LogLevelError, false) logger.SetOutput(io.Discard) errCh := make(chan error, 1) go func() { - errCh <- RunDecryptWorkflowTUI(ctx, cfg, logger, "1.0.0", configPath, "test-build") + errCh <- RunDecryptWorkflowTUI(runCtx, cfg, logger, "1.0.0", configPath, "test-build") }() waitTimeout := 30 * time.Second @@ -464,7 +495,18 @@ func runDecryptWorkflowTUIForTest(t *testing.T, ctx context.Context, cfg *config case err := <-errCh: return err case <-timer.C: - if err := ctx.Err(); err != nil { + cancel() + stopTimedSimAppForTest() + + shutdownTimer := time.NewTimer(2 * time.Second) + defer shutdownTimer.Stop() + select { + case err := <-errCh: + return err + case <-shutdownTimer.C: + } + + if err := runCtx.Err(); err != nil { t.Fatalf("RunDecryptWorkflowTUI did not return within %s (context state: %v)", waitTimeout, err) return nil } diff --git a/internal/orchestrator/tui_simulation_test.go b/internal/orchestrator/tui_simulation_test.go index 82f9f247..20d2cfdd 100644 --- a/internal/orchestrator/tui_simulation_test.go +++ b/internal/orchestrator/tui_simulation_test.go @@ -38,9 +38,23 @@ func withSimAppSequence(t *testing.T, keys []simKey) <-chan struct{} { done := make(chan struct{}) var injectOnce sync.Once var injectWG sync.WaitGroup + var appMu sync.RWMutex + var currentApp *tui.App + + stopCurrentApp := func() { + appMu.RLock() + app := currentApp + appMu.RUnlock() + if app != nil { + app.Stop() + } + } newTUIApp = func() *tui.App { app := tui.NewApp() + appMu.Lock() + currentApp = app + appMu.Unlock() app.SetScreen(screen) readyCh := make(chan struct{}) var readyOnce sync.Once @@ -72,7 +86,7 @@ func withSimAppSequence(t *testing.T, keys []simKey) <-chan struct{} { return case <-timer.C: t.Errorf("TUI simulation did not render its initial draw within %s", simAppInitialDrawTimeout) - app.Stop() + stopCurrentApp() return } @@ -100,7 +114,7 @@ func withSimAppSequence(t *testing.T, keys []simKey) <-chan struct{} { case <-done: case <-timer.C: t.Errorf("TUI simulation did not finish within %s after injecting %d key(s)", simAppCompletionTimeout, len(keys)) - app.Stop() + stopCurrentApp() } }() }) @@ -108,6 +122,7 @@ func withSimAppSequence(t *testing.T, keys []simKey) <-chan struct{} { } t.Cleanup(func() { + stopCurrentApp() close(done) injectWG.Wait() newTUIApp = orig diff --git a/internal/tui/abort_context_test.go b/internal/tui/abort_context_test.go index 93778c1c..2654353f 100644 --- a/internal/tui/abort_context_test.go +++ b/internal/tui/abort_context_test.go @@ -167,6 +167,25 @@ func TestAppRunWithContext_NilContextRunsUntilStopped(t *testing.T) { } } +func TestAppRunWithContext_StopBeforeRunStopsWhenRunStarts(t *testing.T) { + app, _, _ := newSimulationApp(t) + app.Stop() + + done := make(chan error, 1) + go func() { + done <- app.RunWithContext(context.Background()) + }() + + select { + case err := <-done: + if err != nil { + t.Fatalf("err=%v want nil", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for pre-run Stop to end RunWithContext") + } +} + func TestAppRunWithContext_ReturnsNilWhenStoppedWithoutCancellation(t *testing.T) { app, _, started := newSimulationApp(t) done := make(chan error, 1) diff --git a/internal/tui/app.go b/internal/tui/app.go index e190f67f..f1d480e9 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -2,16 +2,27 @@ package tui import ( "context" + "sync" "sync/atomic" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) +const ( + appRunStateIdle = iota + appRunStateStarting + appRunStateRunning + appRunStateFinished +) + // App wraps tview.Application with Proxmox-specific configuration type App struct { *tview.Application - stopHook func() + stopHook func() + runMu sync.Mutex + runState int + stopRequested bool } // NewApp creates a new TUI application with Proxmox theme @@ -49,8 +60,57 @@ func (a *App) Stop() { return } if a.Application != nil { - a.Application.Stop() + a.runMu.Lock() + switch a.runState { + case appRunStateIdle, appRunStateStarting: + // tview.Stop before Run clears the configured screen; apply it once + // the event loop can process the request instead. + a.stopRequested = true + a.runMu.Unlock() + return + case appRunStateRunning: + a.runMu.Unlock() + a.Application.Stop() + return + default: + a.runMu.Unlock() + } + } +} + +func (a *App) Run() error { + if a == nil || a.Application == nil { + return nil } + + a.runMu.Lock() + a.runState = appRunStateStarting + a.runMu.Unlock() + + go a.markRunningAndStopIfRequested() + + err := a.Application.Run() + + a.runMu.Lock() + a.runState = appRunStateFinished + a.stopRequested = false + a.runMu.Unlock() + + return err +} + +func (a *App) markRunningAndStopIfRequested() { + a.QueueUpdate(func() { + a.runMu.Lock() + a.runState = appRunStateRunning + stopRequested := a.stopRequested + a.stopRequested = false + a.runMu.Unlock() + + if stopRequested { + a.Application.Stop() + } + }) } func (a *App) RunWithContext(ctx context.Context) error { From 2a44bbab813adb098ad1b7ee225cbb7cdc142a65 Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Wed, 6 May 2026 13:50:39 +0200 Subject: [PATCH 32/35] Refactor TUI test harness and improve task start/stop Replace ad-hoc timed simulation plumbing with a reusable timedSimHarness for decrypt TUI e2e tests. Introduce timedSimHarness, timedSimAppState, and richer timedSimKey fields (RequireNewApp, SettleAfterMatch) plus new timing constants to make key injection and app lifecycle more deterministic and to improve diagnostics on timeouts. Update helper functions and tests to return/use the harness and tighten shutdown logic (StopAll, better timeout/error messages). Also change RunTask startup/shutdown in workflow_ui_tui_decrypt.go to trigger the background task after the first draw (SetAfterDrawFunc), add start synchronization (started, startOnce) and queueProgressUpdate to safely schedule UI updates and ensure proper waiting for task completion on exit. Minor import adjustments (sync) included. --- .../decrypt_tui_e2e_helpers_test.go | 403 ++++++++++-------- internal/orchestrator/decrypt_tui_e2e_test.go | 8 +- .../orchestrator/workflow_ui_tui_decrypt.go | 51 ++- 3 files changed, 276 insertions(+), 186 deletions(-) diff --git a/internal/orchestrator/decrypt_tui_e2e_helpers_test.go b/internal/orchestrator/decrypt_tui_e2e_helpers_test.go index 5378b724..538e7beb 100644 --- a/internal/orchestrator/decrypt_tui_e2e_helpers_test.go +++ b/internal/orchestrator/decrypt_tui_e2e_helpers_test.go @@ -25,15 +25,13 @@ import ( "github.com/tis24dev/proxsave/internal/types" ) -var ( - decryptTUIE2EMu sync.Mutex - timedSimAppStopperMu sync.Mutex - timedSimAppStopper func() -) +var decryptTUIE2EMu sync.Mutex const ( timedSimScreenWaitTimeout = 10 * time.Second timedSimCompletionTimeout = 15 * time.Second + timedSimDefaultSettle = 15 * time.Millisecond + timedSimKeyDelay = 15 * time.Millisecond ) type notifyingSimulationScreen struct { @@ -118,11 +116,38 @@ func cloneSimCells(cells []tcell.SimCell) []tcell.SimCell { } type timedSimKey struct { - Key tcell.Key - R rune - Mod tcell.ModMask - Wait time.Duration - WaitForText string + Key tcell.Key + R rune + Mod tcell.ModMask + WaitForText string + RequireNewApp bool + SettleAfterMatch time.Duration +} + +type timedSimHarness struct { + t *testing.T + done chan struct{} + closeDoneOnce sync.Once + injectWG sync.WaitGroup + screenStateCh chan struct{} + + appMu sync.RWMutex + apps []*tui.App + current *timedSimAppState +} + +type timedSimAppState struct { + generation int + app *tui.App + screen *notifyingSimulationScreen +} + +type timedSimScreenState struct { + generation int + text string + focusType string + ready bool + screen *notifyingSimulationScreen } type decryptTUIFixture struct { @@ -139,182 +164,205 @@ type decryptTUIFixture struct { ExpectedChecksum string } -func withTimedSimAppSequence(t *testing.T, keys []timedSimKey) { +func withTimedSimAppSequence(t *testing.T, keys []timedSimKey) *timedSimHarness { t.Helper() decryptTUIE2EMu.Lock() orig := newTUIApp - done := make(chan struct{}) - var injectWG sync.WaitGroup - var appMu sync.RWMutex - var currentApp *tui.App - - stopCurrentApp := func() { - appMu.RLock() - app := currentApp - appMu.RUnlock() - if app != nil { - app.Stop() - } + h := &timedSimHarness{ + t: t, + done: make(chan struct{}), + screenStateCh: make(chan struct{}, 1), } - restoreStopper := setTimedSimAppStopper(stopCurrentApp) t.Cleanup(func() { - stopCurrentApp() - restoreStopper() - close(done) - injectWG.Wait() + h.stop() newTUIApp = orig decryptTUIE2EMu.Unlock() }) - baseScreen := tcell.NewSimulationScreen("UTF-8") - if err := baseScreen.Init(); err != nil { - t.Fatalf("screen.Init: %v", err) + newTUIApp = func() *tui.App { + app := tui.NewApp() + + baseScreen := tcell.NewSimulationScreen("UTF-8") + if err := baseScreen.Init(); err != nil { + t.Fatalf("screen.Init: %v", err) + } + baseScreen.SetSize(120, 40) + + screen := ¬ifyingSimulationScreen{ + SimulationScreen: baseScreen, + notify: h.notifyScreenStateChanged, + } + + h.appMu.Lock() + state := &timedSimAppState{ + generation: len(h.apps) + 1, + app: app, + screen: screen, + } + h.apps = append(h.apps, app) + h.current = state + h.appMu.Unlock() + + app.SetScreen(screen) + h.notifyScreenStateChanged() + return app } - baseScreen.SetSize(120, 40) - type timedSimScreenState struct { - signature string - text string + h.injectWG.Add(1) + go h.run(keys) + + return h +} + +func (h *timedSimHarness) notifyScreenStateChanged() { + select { + case h.screenStateCh <- struct{}{}: + default: } +} - screenStateCh := make(chan struct{}, 1) - screen := ¬ifyingSimulationScreen{ - SimulationScreen: baseScreen, - notify: func() { - select { - case screenStateCh <- struct{}{}: - default: - } - }, +func (h *timedSimHarness) stop() { + if h == nil { + return } + h.closeDoneOnce.Do(func() { + close(h.done) + }) + h.StopAll() + h.injectWG.Wait() +} - var once sync.Once - newTUIApp = func() *tui.App { - app := tui.NewApp() - appMu.Lock() - currentApp = app - appMu.Unlock() - app.SetScreen(screen) +func (h *timedSimHarness) StopAll() { + if h == nil { + return + } + h.appMu.RLock() + apps := append([]*tui.App(nil), h.apps...) + h.appMu.RUnlock() + for i := len(apps) - 1; i >= 0; i-- { + apps[i].Stop() + } +} - once.Do(func() { - injectWG.Add(1) - go func() { - defer injectWG.Done() - var lastInjectedState string - - currentScreenState := func() timedSimScreenState { - appMu.RLock() - app := currentApp - appMu.RUnlock() - - var focus any - if app != nil { - focus = app.GetFocus() - } - snapshot := screen.snapshotState() - - return timedSimScreenState{ - signature: timedSimScreenStateSignature(snapshot, focus), - text: timedSimScreenText(snapshot), - } - } - - waitForScreenText := func(expected string) bool { - expected = strings.TrimSpace(expected) - timer := time.NewTimer(timedSimScreenWaitTimeout) - defer timer.Stop() - for { - current := currentScreenState() - if current.signature != "" { - if (expected == "" || strings.Contains(current.text, expected)) && - (lastInjectedState == "" || current.signature != lastInjectedState) { - return true - } - } - - select { - case <-done: - return false - case <-screenStateCh: - case <-timer.C: - t.Errorf("TUI simulation did not render expected text %q within %s", expected, timedSimScreenWaitTimeout) - stopCurrentApp() - return false - } - } - } - - for _, k := range keys { - if k.Wait > 0 { - if !waitForScreenText(k.WaitForText) { - return - } - } - current := currentScreenState() - mod := k.Mod - if mod == 0 { - mod = tcell.ModNone - } - select { - case <-done: - return - default: - } - screen.InjectKey(k.Key, k.R, mod) - lastInjectedState = current.signature - } - - timer := time.NewTimer(timedSimCompletionTimeout) - defer timer.Stop() - select { - case <-done: - case <-timer.C: - t.Errorf("TUI simulation did not finish within %s after injecting %d key(s)", timedSimCompletionTimeout, len(keys)) - stopCurrentApp() - } - }() - }) +func (h *timedSimHarness) run(keys []timedSimKey) { + defer h.injectWG.Done() - return app + generation := 0 + for idx, key := range keys { + minGeneration := generation + if minGeneration == 0 || key.RequireNewApp { + minGeneration++ + } + + state, ok := h.waitForScreenText(idx, key, minGeneration) + if !ok { + return + } + generation = state.generation + + settle := key.SettleAfterMatch + if settle <= 0 { + settle = timedSimDefaultSettle + } + if !h.sleepOrDone(settle) { + return + } + + mod := key.Mod + if mod == 0 { + mod = tcell.ModNone + } + state.screen.InjectKey(key.Key, key.R, mod) + if !h.sleepOrDone(timedSimKeyDelay) { + return + } + } + + timer := time.NewTimer(timedSimCompletionTimeout) + defer timer.Stop() + select { + case <-h.done: + case <-timer.C: + h.t.Errorf("TUI simulation did not finish within %s after injecting %d key(s)\n%s", timedSimCompletionTimeout, len(keys), h.describeCurrentState()) + h.StopAll() } } -func setTimedSimAppStopper(stop func()) func() { - timedSimAppStopperMu.Lock() - previous := timedSimAppStopper - timedSimAppStopper = stop - timedSimAppStopperMu.Unlock() +func (h *timedSimHarness) waitForScreenText(index int, key timedSimKey, minGeneration int) (timedSimScreenState, bool) { + expected := strings.TrimSpace(key.WaitForText) + timer := time.NewTimer(timedSimScreenWaitTimeout) + defer timer.Stop() + + for { + state := h.currentScreenState() + if state.ready && state.generation >= minGeneration && (expected == "" || strings.Contains(state.text, expected)) { + return state, true + } - return func() { - timedSimAppStopperMu.Lock() - timedSimAppStopper = previous - timedSimAppStopperMu.Unlock() + select { + case <-h.done: + return timedSimScreenState{}, false + case <-h.screenStateCh: + case <-timer.C: + h.t.Errorf( + "TUI simulation timed out at action %d waiting for text %q within %s (min generation=%d, current generation=%d, focus=%s)\nCurrent screen:\n%s", + index, + expected, + timedSimScreenWaitTimeout, + minGeneration, + state.generation, + state.focusType, + state.text, + ) + h.StopAll() + return state, false + } } } -func stopTimedSimAppForTest() { - timedSimAppStopperMu.Lock() - stop := timedSimAppStopper - timedSimAppStopperMu.Unlock() - if stop != nil { - stop() +func (h *timedSimHarness) currentScreenState() timedSimScreenState { + h.appMu.RLock() + current := h.current + h.appMu.RUnlock() + if current == nil || current.screen == nil { + return timedSimScreenState{} } -} -func timedSimScreenStateSignature(snapshot timedSimScreenSnapshot, focus any) string { - if !snapshot.ready || snapshot.width <= 0 || snapshot.height <= 0 || len(snapshot.cells) < snapshot.width*snapshot.height { - return "" + focusType := "" + if current.app != nil { + if focus := current.app.GetFocus(); focus != nil { + focusType = fmt.Sprintf("%T", focus) + } } + snapshot := current.screen.snapshotState() + return timedSimScreenState{ + generation: current.generation, + text: timedSimScreenText(snapshot), + focusType: focusType, + ready: snapshot.ready, + screen: current.screen, + } +} - sum := sha256.New() - fmt.Fprintf(sum, "size:%d:%d cursor:%d:%d:%t focus:%T:%p\n", snapshot.width, snapshot.height, snapshot.cursorX, snapshot.cursorY, snapshot.cursorVisible, focus, focus) - for _, cell := range snapshot.cells { - fg, bg, attr := cell.Style.Decompose() - fmt.Fprintf(sum, "%x/%d/%d/%d;", cell.Bytes, fg, bg, attr) +func (h *timedSimHarness) describeCurrentState() string { + state := h.currentScreenState() + return fmt.Sprintf("current generation=%d focus=%s ready=%t\nCurrent screen:\n%s", state.generation, state.focusType, state.ready, state.text) +} + +func (h *timedSimHarness) sleepOrDone(d time.Duration) bool { + if d <= 0 { + return true + } + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-h.done: + return false + case <-timer.C: + return true } - return hex.EncodeToString(sum.Sum(nil)) } func timedSimScreenText(snapshot timedSimScreenSnapshot) string { @@ -434,24 +482,25 @@ func createDecryptTUIEncryptedFixture(t *testing.T) *decryptTUIFixture { func successDecryptTUISequence(secret string) []timedSimKey { keys := []timedSimKey{ - {Key: tcell.KeyEnter, Wait: 1 * time.Second, WaitForText: "Select backup source"}, - {Key: tcell.KeyEnter, Wait: 750 * time.Millisecond, WaitForText: "Select backup"}, + {Key: tcell.KeyEnter, WaitForText: "Select backup source", RequireNewApp: true}, + {Key: tcell.KeyEnter, WaitForText: "Select backup", RequireNewApp: true}, } - for _, r := range secret { + for idx, r := range secret { keys = append(keys, timedSimKey{ - Key: tcell.KeyRune, - R: r, - Wait: 35 * time.Millisecond, - WaitForText: "Decrypt key", + Key: tcell.KeyRune, + R: r, + WaitForText: "Decrypt key", + RequireNewApp: idx == 0, + SettleAfterMatch: 5 * time.Millisecond, }) } keys = append(keys, - timedSimKey{Key: tcell.KeyTab, Wait: 150 * time.Millisecond, WaitForText: "Decrypt key"}, - timedSimKey{Key: tcell.KeyEnter, Wait: 100 * time.Millisecond, WaitForText: "Decrypt key"}, - timedSimKey{Key: tcell.KeyTab, Wait: 500 * time.Millisecond, WaitForText: "Destination directory"}, - timedSimKey{Key: tcell.KeyEnter, Wait: 100 * time.Millisecond, WaitForText: "Destination directory"}, + timedSimKey{Key: tcell.KeyTab, WaitForText: "Decrypt key"}, + timedSimKey{Key: tcell.KeyEnter, WaitForText: "Decrypt key"}, + timedSimKey{Key: tcell.KeyTab, WaitForText: "Destination directory", RequireNewApp: true}, + timedSimKey{Key: tcell.KeyEnter, WaitForText: "Destination directory"}, ) return keys @@ -459,15 +508,15 @@ func successDecryptTUISequence(secret string) []timedSimKey { func abortDecryptTUISequence() []timedSimKey { return []timedSimKey{ - {Key: tcell.KeyEnter, Wait: 1 * time.Second, WaitForText: "Select backup source"}, - {Key: tcell.KeyEnter, Wait: 750 * time.Millisecond, WaitForText: "Select backup"}, - {Key: tcell.KeyRune, R: '0', Wait: 500 * time.Millisecond, WaitForText: "Decrypt key"}, - {Key: tcell.KeyTab, Wait: 150 * time.Millisecond, WaitForText: "Decrypt key"}, - {Key: tcell.KeyEnter, Wait: 100 * time.Millisecond, WaitForText: "Decrypt key"}, + {Key: tcell.KeyEnter, WaitForText: "Select backup source", RequireNewApp: true}, + {Key: tcell.KeyEnter, WaitForText: "Select backup", RequireNewApp: true}, + {Key: tcell.KeyRune, R: '0', WaitForText: "Decrypt key", RequireNewApp: true}, + {Key: tcell.KeyTab, WaitForText: "Decrypt key"}, + {Key: tcell.KeyEnter, WaitForText: "Decrypt key"}, } } -func runDecryptWorkflowTUIForTest(t *testing.T, ctx context.Context, cfg *config.Config, configPath string) error { +func runDecryptWorkflowTUIForTest(t *testing.T, sim *timedSimHarness, ctx context.Context, cfg *config.Config, configPath string) error { t.Helper() runCtx, cancel := context.WithCancel(ctx) @@ -496,7 +545,9 @@ func runDecryptWorkflowTUIForTest(t *testing.T, ctx context.Context, cfg *config return err case <-timer.C: cancel() - stopTimedSimAppForTest() + if sim != nil { + sim.StopAll() + } shutdownTimer := time.NewTimer(2 * time.Second) defer shutdownTimer.Stop() @@ -507,9 +558,15 @@ func runDecryptWorkflowTUIForTest(t *testing.T, ctx context.Context, cfg *config } if err := runCtx.Err(); err != nil { + if sim != nil { + t.Fatalf("RunDecryptWorkflowTUI did not return within %s (context state: %v)\n%s", waitTimeout, err, sim.describeCurrentState()) + } t.Fatalf("RunDecryptWorkflowTUI did not return within %s (context state: %v)", waitTimeout, err) return nil } + if sim != nil { + t.Fatalf("RunDecryptWorkflowTUI did not return within %s\n%s", waitTimeout, sim.describeCurrentState()) + } t.Fatalf("RunDecryptWorkflowTUI did not return within %s", waitTimeout) return nil } diff --git a/internal/orchestrator/decrypt_tui_e2e_test.go b/internal/orchestrator/decrypt_tui_e2e_test.go index 925b81d0..bea6a525 100644 --- a/internal/orchestrator/decrypt_tui_e2e_test.go +++ b/internal/orchestrator/decrypt_tui_e2e_test.go @@ -18,12 +18,12 @@ func TestRunDecryptWorkflowTUI_SuccessLocalEncrypted(t *testing.T) { t.Cleanup(func() { restoreFS = origFS }) fixture := createDecryptTUIEncryptedFixture(t) - withTimedSimAppSequence(t, successDecryptTUISequence(fixture.Secret)) + sim := withTimedSimAppSequence(t, successDecryptTUISequence(fixture.Secret)) ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second) defer cancel() - if err := runDecryptWorkflowTUIForTest(t, ctx, fixture.Config, fixture.ConfigPath); err != nil { + if err := runDecryptWorkflowTUIForTest(t, sim, ctx, fixture.Config, fixture.ConfigPath); err != nil { t.Fatalf("RunDecryptWorkflowTUI error: %v", err) } @@ -79,12 +79,12 @@ func TestRunDecryptWorkflowTUI_AbortAtSecretPrompt(t *testing.T) { t.Cleanup(func() { restoreFS = origFS }) fixture := createDecryptTUIEncryptedFixture(t) - withTimedSimAppSequence(t, abortDecryptTUISequence()) + sim := withTimedSimAppSequence(t, abortDecryptTUISequence()) ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second) defer cancel() - err := runDecryptWorkflowTUIForTest(t, ctx, fixture.Config, fixture.ConfigPath) + err := runDecryptWorkflowTUIForTest(t, sim, ctx, fixture.Config, fixture.ConfigPath) if !errors.Is(err, ErrDecryptAborted) { t.Fatalf("RunDecryptWorkflowTUI error=%v; want %v", err, ErrDecryptAborted) } diff --git a/internal/orchestrator/workflow_ui_tui_decrypt.go b/internal/orchestrator/workflow_ui_tui_decrypt.go index 531571d5..fa0483a5 100644 --- a/internal/orchestrator/workflow_ui_tui_decrypt.go +++ b/internal/orchestrator/workflow_ui_tui_decrypt.go @@ -5,6 +5,7 @@ import ( "fmt" "path/filepath" "strings" + "sync" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -86,35 +87,67 @@ func (u *tuiWorkflowUI) RunTask(ctx context.Context, title, initialMessage strin form.SetParentView(page) done := make(chan struct{}) + started := make(chan struct{}) + var startOnce sync.Once var runErr error + queueProgressUpdate := func(update func()) { + select { + case <-taskCtx.Done(): + return + default: + } + go func() { + select { + case <-taskCtx.Done(): + return + default: + } + app.QueueUpdateDraw(update) + }() + } + report := func(message string) { message = strings.TrimSpace(message) if message == "" { return } - app.QueueUpdateDraw(func() { + queueProgressUpdate(func() { messageView.SetText(tview.Escape(message)) }) } - go func() { - runErr = run(taskCtx, report) - close(done) - app.QueueUpdateDraw(func() { - app.Stop() + startTask := func() { + startOnce.Do(func() { + close(started) + go func() { + runErr = run(taskCtx, report) + close(done) + app.Stop() + }() }) - }() + } app.SetRoot(page, true).SetFocus(form.Form) + app.SetAfterDrawFunc(func(screen tcell.Screen) { + startTask() + }) if err := app.RunWithContext(taskCtx); err != nil { cancel() - <-done + select { + case <-started: + <-done + default: + } return err } cancel() - <-done + select { + case <-started: + <-done + default: + } return runErr } From 5e36f471a78c323707d3a366c327165144303031 Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Wed, 6 May 2026 15:05:32 +0200 Subject: [PATCH 33/35] Pin actions; fix update check, PBS & tests Pin GitHub Actions steps to specific SHAs for reproducible runs. Add a nil-context guard in checkForUpdates to avoid panics when ctx is nil. Include PBS user config collection by running newPBSUserConfigRecipe(). Change lookupAbsolutePath to return an error if exec.LookPath returns a non-absolute path rather than attempting to make it absolute. Enhance TUI e2e test helpers by adding a Wait field to timedSimKey and using it to control timeouts/sleeps. Update test FakeFS to simulate ownership changes via an Ownership map and a FakeOwnership struct in Lchown instead of calling os.Lchown. --- .github/workflows/dependabot-automerge.yml | 4 ++-- cmd/proxsave/main_update.go | 3 +++ internal/backup/collector_pbs.go | 3 +++ internal/notify/email.go | 2 +- .../decrypt_tui_e2e_helpers_test.go | 14 ++++++++++++-- internal/orchestrator/deps_test.go | 17 ++++++++++++++++- 6 files changed, 37 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index c5264029..a4676472 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -17,13 +17,13 @@ jobs: steps: - name: Set up Node.js 24 - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # pinned from actions/setup-node@v4 with: node-version: '24' - name: Fetch Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@v3 + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # pinned from dependabot/fetch-metadata@v3 with: github-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/cmd/proxsave/main_update.go b/cmd/proxsave/main_update.go index 6ce50983..f016fe48 100644 --- a/cmd/proxsave/main_update.go +++ b/cmd/proxsave/main_update.go @@ -34,6 +34,9 @@ func checkForUpdates(ctx context.Context, logger *logging.Logger, currentVersion logger.Debug("Update check skipped: current version is empty") return nil } + if ctx == nil { + ctx = context.Background() + } checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() diff --git a/internal/backup/collector_pbs.go b/internal/backup/collector_pbs.go index 61c12077..44cd9553 100644 --- a/internal/backup/collector_pbs.go +++ b/internal/backup/collector_pbs.go @@ -80,6 +80,9 @@ func (c *Collector) CollectPBSConfigs(ctx context.Context) error { if err := runRecipe(ctx, newPBSRecipe(), state); err != nil { return err } + if err := runRecipe(ctx, newPBSUserConfigRecipe(), state); err != nil { + return err + } c.logger.Info("PBS configuration collection completed") return nil diff --git a/internal/notify/email.go b/internal/notify/email.go index cefe2380..61fe6cb9 100644 --- a/internal/notify/email.go +++ b/internal/notify/email.go @@ -692,7 +692,7 @@ func lookupAbsolutePath(name string) (string, error) { if filepath.IsAbs(execPath) { return execPath, nil } - return filepath.Abs(execPath) + return "", fmt.Errorf("exec.LookPath returned non-absolute path %q", execPath) } // sendViaRelay sends email via cloud relay diff --git a/internal/orchestrator/decrypt_tui_e2e_helpers_test.go b/internal/orchestrator/decrypt_tui_e2e_helpers_test.go index 538e7beb..4278b2f2 100644 --- a/internal/orchestrator/decrypt_tui_e2e_helpers_test.go +++ b/internal/orchestrator/decrypt_tui_e2e_helpers_test.go @@ -120,6 +120,7 @@ type timedSimKey struct { R rune Mod tcell.ModMask WaitForText string + Wait time.Duration RequireNewApp bool SettleAfterMatch time.Duration } @@ -261,6 +262,11 @@ func (h *timedSimHarness) run(keys []timedSimKey) { return } generation = state.generation + if key.Wait > 0 && strings.TrimSpace(key.WaitForText) == "" { + if !h.sleepOrDone(key.Wait) { + return + } + } settle := key.SettleAfterMatch if settle <= 0 { @@ -292,7 +298,11 @@ func (h *timedSimHarness) run(keys []timedSimKey) { func (h *timedSimHarness) waitForScreenText(index int, key timedSimKey, minGeneration int) (timedSimScreenState, bool) { expected := strings.TrimSpace(key.WaitForText) - timer := time.NewTimer(timedSimScreenWaitTimeout) + timeout := timedSimScreenWaitTimeout + if key.Wait > 0 { + timeout = key.Wait + } + timer := time.NewTimer(timeout) defer timer.Stop() for { @@ -310,7 +320,7 @@ func (h *timedSimHarness) waitForScreenText(index int, key timedSimKey, minGener "TUI simulation timed out at action %d waiting for text %q within %s (min generation=%d, current generation=%d, focus=%s)\nCurrent screen:\n%s", index, expected, - timedSimScreenWaitTimeout, + timeout, minGeneration, state.generation, state.focusType, diff --git a/internal/orchestrator/deps_test.go b/internal/orchestrator/deps_test.go index fa7af74f..6154e075 100644 --- a/internal/orchestrator/deps_test.go +++ b/internal/orchestrator/deps_test.go @@ -24,6 +24,12 @@ type FakeFS struct { MkdirAllErr error MkdirTempErr error OpenFileErr map[string]error + Ownership map[string]FakeOwnership +} + +type FakeOwnership struct { + UID int + GID int } func NewFakeFS() *FakeFS { @@ -33,6 +39,7 @@ func NewFakeFS() *FakeFS { StatErr: make(map[string]error), StatErrors: make(map[string]error), OpenFileErr: make(map[string]error), + Ownership: make(map[string]FakeOwnership), } } @@ -188,7 +195,15 @@ func (f *FakeFS) Rename(oldpath, newpath string) error { } func (f *FakeFS) Lchown(path string, uid, gid int) error { - return os.Lchown(f.onDisk(path), uid, gid) + diskPath := f.onDisk(path) + if _, err := os.Lstat(diskPath); err != nil { + return err + } + if f.Ownership == nil { + f.Ownership = make(map[string]FakeOwnership) + } + f.Ownership[diskPath] = FakeOwnership{UID: uid, GID: gid} + return nil } func (f *FakeFS) UtimesNano(path string, times []syscall.Timespec) error { From df70bb452a2bbb227dc2589ad5a5a3cfb5f06766 Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Wed, 6 May 2026 15:38:09 +0200 Subject: [PATCH 34/35] Add PBS/restore helpers, improve panic handling & tests Multiple fixes and feature additions across the codebase: update README PayPal link; improve finishMainRun panic handling and ensure runDone/logging are called; allow --install to choose CLI vs TUI; make isNewerVersion treat stable > prerelease and add tests; add timeout to hostname resolution. Backup collector: honor CustomBackupPaths, add pbsRepositoryWithDatastore helper and use it when building PBS_REPOSITORY, plus new tests. Email notifier: centralize mailq lookup (findMailqPath) and use it for queue checks. Orchestrator/restore: introduce localNodeName and helpers (pveshGuestExists, pveshCreateGuestArgs, pveshArgValue, isPveshNotFoundError), ensure missing guests are created before applying configs, stop parsing at section headers, and add tests for these behaviors. TUI tests: add runCompleted signaling to timedSimHarness and ensure RunDecryptWorkflowTUI signals completion. Misc: small test and brick-call refactors. --- README.md | 2 +- cmd/proxsave/main_lifecycle.go | 31 +++++-- cmd/proxsave/main_modes.go | 4 +- cmd/proxsave/main_update.go | 24 ++++-- cmd/proxsave/runtime_helpers.go | 5 +- cmd/proxsave/version_helpers_test.go | 3 +- internal/backup/collector.go | 38 ++++++--- internal/backup/collector_bricks_test.go | 6 +- .../backup/collector_config_extra_test.go | 10 +++ internal/backup/collector_pbs_auth_test.go | 24 ++++++ internal/notify/email.go | 27 +++--- .../decrypt_tui_e2e_helpers_test.go | 22 ++++- .../orchestrator/restore_cluster_apply.go | 84 ++++++++++++++++++- internal/orchestrator/restore_errors_test.go | 36 ++++++++ internal/orchestrator/restore_test.go | 36 +++++++- 15 files changed, 302 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 1823c9fd..8c5d8329 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Proxmox PBS & PVE System Files Backup [![rclone](https://img.shields.io/badge/rclone-1.60+-136C9E.svg)](https://rclone.org/) [![💖 Sponsor](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-pink?logo=github)](https://github.com/sponsors/tis24dev) [![☕ Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-tis24dev-yellow?logo=buymeacoffee)](https://github.com/sponsors/tis24dev) -[![💸 Donate](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=damigioanna%40gmail.com) +[![💸 Donate](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://paypal.me/DNoventa) ## About the Project diff --git a/cmd/proxsave/main_lifecycle.go b/cmd/proxsave/main_lifecycle.go index 449d2b63..b589a07a 100644 --- a/cmd/proxsave/main_lifecycle.go +++ b/cmd/proxsave/main_lifecycle.go @@ -32,16 +32,35 @@ func startMainRun() runBootstrap { } func finishMainRun(run runBootstrap) { + var panicErr error + exitAfterCleanup := false defer func() { - if r := recover(); r != nil { - stack := debug.Stack() - run.bootstrap.Error("PANIC: %v", r) - fmt.Fprintf(os.Stderr, "panic: %v\n%s\n", r, stack) + if run.bootstrap != nil && run.state != nil { + logging.DebugStepBootstrap(run.bootstrap, "main run", "exit_code=%d", run.state.finalExitCode) + } + if run.runDone != nil { + run.runDone(panicErr) + } + if exitAfterCleanup { os.Exit(types.ExitPanicError.Int()) } - logging.DebugStepBootstrap(run.bootstrap, "main run", "exit_code=%d", run.state.finalExitCode) - run.runDone(nil) }() + + r := recover() + if r == nil { + return + } + + stack := debug.Stack() + panicErr = fmt.Errorf("panic: %v", r) + exitAfterCleanup = true + if run.state != nil { + run.state.finalExitCode = types.ExitPanicError.Int() + } + if run.bootstrap != nil { + run.bootstrap.Error("PANIC: %v\n%s", r, stack) + } + fmt.Fprintf(os.Stderr, "panic: %v\n%s\n", r, stack) } func preparePreRuntimeArgs(ctx context.Context, bootstrap *logging.BootstrapLogger, toolVersion string) (*cli.Args, int, bool) { diff --git a/cmd/proxsave/main_modes.go b/cmd/proxsave/main_modes.go index ed2740a2..18fd2a2c 100644 --- a/cmd/proxsave/main_modes.go +++ b/cmd/proxsave/main_modes.go @@ -224,9 +224,11 @@ func runInstallMode(ctx context.Context, args *cli.Args, bootstrap *logging.Boot sessionLogger.Info("Starting --install (config=%s)", args.ConfigPath) } - err := runInstallTUI(ctx, args.ConfigPath, bootstrap) + var err error if args.ForceCLI { err = runInstall(ctx, args.ConfigPath, bootstrap) + } else { + err = runInstallTUI(ctx, args.ConfigPath, bootstrap) } if err != nil { diff --git a/cmd/proxsave/main_update.go b/cmd/proxsave/main_update.go index f016fe48..68596e46 100644 --- a/cmd/proxsave/main_update.go +++ b/cmd/proxsave/main_update.go @@ -83,16 +83,19 @@ func checkForUpdates(ctx context.Context, logger *logging.Logger, currentVersion } } -// isNewerVersion returns true if latest is strictly newer than current, -// comparing MAJOR.MINOR.PATCH (ignoring any leading 'v', pre-release suffixes, and build metadata). +// isNewerVersion returns true if latest is strictly newer than current. +// It compares MAJOR.MINOR.PATCH, ignores build metadata, and treats a stable +// release as newer than a prerelease with the same numeric version. func isNewerVersion(current, latest string) bool { - parse := func(v string) (int, int, int) { + parse := func(v string) (int, int, int, bool) { v = strings.TrimSpace(v) v = strings.TrimPrefix(v, "v") - if i := strings.IndexByte(v, '-'); i >= 0 { + if i := strings.IndexByte(v, '+'); i >= 0 { v = v[:i] } - if i := strings.IndexByte(v, '+'); i >= 0 { + hasPrerelease := false + if i := strings.IndexByte(v, '-'); i >= 0 { + hasPrerelease = true v = v[:i] } @@ -112,11 +115,11 @@ func isNewerVersion(current, latest string) bool { if len(parts) > 2 { patch = toInt(parts[2]) } - return major, minor, patch + return major, minor, patch, hasPrerelease } - curMaj, curMin, curPatch := parse(current) - latMaj, latMin, latPatch := parse(latest) + curMaj, curMin, curPatch, curPrerelease := parse(current) + latMaj, latMin, latPatch, latPrerelease := parse(latest) if latMaj != curMaj { return latMaj > curMaj @@ -124,5 +127,8 @@ func isNewerVersion(current, latest string) bool { if latMin != curMin { return latMin > curMin } - return latPatch > curPatch + if latPatch != curPatch { + return latPatch > curPatch + } + return curPrerelease && !latPrerelease } diff --git a/cmd/proxsave/runtime_helpers.go b/cmd/proxsave/runtime_helpers.go index 0a52b206..7a778d66 100644 --- a/cmd/proxsave/runtime_helpers.go +++ b/cmd/proxsave/runtime_helpers.go @@ -220,7 +220,10 @@ func logServerIdentityValues(serverID, mac string) { func resolveHostname() string { if path, err := exec.LookPath("hostname"); err == nil { - cmd, cmdErr := safeexec.TrustedCommandContext(context.Background(), path, "-f") + cmdCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + cmd, cmdErr := safeexec.TrustedCommandContext(cmdCtx, path, "-f") if cmdErr == nil { if out, err := cmd.Output(); err == nil { if fqdn := strings.TrimSpace(string(out)); fqdn != "" { diff --git a/cmd/proxsave/version_helpers_test.go b/cmd/proxsave/version_helpers_test.go index bfa0890f..39399a8a 100644 --- a/cmd/proxsave/version_helpers_test.go +++ b/cmd/proxsave/version_helpers_test.go @@ -42,7 +42,8 @@ func TestIsNewerVersion(t *testing.T) { {"minor newer", "0.1.9", "0.2.0", true}, {"major newer", "1.9.9", "2.0.0", true}, {"strip leading v", "v1.2.3", "1.2.4", true}, - {"ignore prerelease", "1.2.3-rc1", "1.2.3", false}, + {"stable newer than prerelease", "1.2.3-rc1", "1.2.3", true}, + {"prerelease not newer than stable", "1.2.3", "1.2.3-rc1", false}, {"ignore build metadata", "v1.2.3+current", "v1.2.4+latest", true}, {"build metadata does not zero patch", "v1.2.3+current", "v1.2.3+latest", false}, {"missing patch treated as 0", "1.2", "1.2.0", false}, diff --git a/internal/backup/collector.go b/internal/backup/collector.go index 7bcbfad7..b87366a3 100644 --- a/internal/backup/collector.go +++ b/internal/backup/collector.go @@ -297,6 +297,9 @@ func (c *CollectorConfig) validateExcludePatterns() error { } func (c *CollectorConfig) hasCollectionOptionEnabled() bool { + if len(c.CustomBackupPaths) > 0 { + return true + } for _, enabled := range c.collectionOptionFlags() { if enabled { return true @@ -1233,6 +1236,29 @@ func (c *Collector) safeCmdOutputWithPBSAuth(ctx context.Context, spec CommandSp return nil } +func pbsRepositoryWithDatastore(repository, datastoreName string) string { + separator := -1 + bracketDepth := 0 + for i, r := range repository { + switch r { + case '[': + bracketDepth++ + case ']': + if bracketDepth > 0 { + bracketDepth-- + } + case ':': + if bracketDepth == 0 { + separator = i + } + } + } + if separator >= 0 { + return repository[:separator+1] + datastoreName + } + return repository + ":" + datastoreName +} + // safeCmdOutputWithPBSAuthForDatastore executes a command with PBS authentication for a specific datastore // This function appends the datastore name to the PBS_REPOSITORY environment variable func (c *Collector) safeCmdOutputWithPBSAuthForDatastore(ctx context.Context, spec CommandSpec, output, description, datastoreName string, critical bool) error { @@ -1274,17 +1300,7 @@ func (c *Collector) safeCmdOutputWithPBSAuthForDatastore(ctx context.Context, sp // Build PBS_REPOSITORY with datastore repoWithDatastore := "" if c.config.PBSRepository != "" { - // If repository already has a datastore (contains :), replace it - // Otherwise append the datastore name - repoWithDatastore = c.config.PBSRepository - if strings.Contains(repoWithDatastore, ":") { - // Replace existing datastore: "user@host:oldds" -> "user@host:newds" - parts := strings.SplitN(repoWithDatastore, ":", 2) - repoWithDatastore = fmt.Sprintf("%s:%s", parts[0], datastoreName) - } else { - // Append datastore: "user@host" -> "user@host:datastore" - repoWithDatastore = fmt.Sprintf("%s:%s", repoWithDatastore, datastoreName) - } + repoWithDatastore = pbsRepositoryWithDatastore(c.config.PBSRepository, datastoreName) } else { // No repository configured but we have password - use root@pam as default user repoWithDatastore = fmt.Sprintf("root@pam@localhost:%s", datastoreName) diff --git a/internal/backup/collector_bricks_test.go b/internal/backup/collector_bricks_test.go index 0f559a40..778e233b 100644 --- a/internal/backup/collector_bricks_test.go +++ b/internal/backup/collector_bricks_test.go @@ -137,7 +137,8 @@ func TestPVEGuestBrickPropagatesQEMUContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - err := newPVEGuestBricks()[0].Run(ctx, state) + brick := requireBrick(t, recipe{Name: "pve-guest", Bricks: newPVEGuestBricks()}, brickPVEVMQEMUConfigs) + err := brick.Run(ctx, state) if !errors.Is(err, context.Canceled) { t.Fatalf("guest brick error = %v, want %v", err, context.Canceled) } @@ -154,7 +155,8 @@ func TestPVEStorageProbeBrickPropagatesContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - err := newPVEStorageProbeBricks()[0].Run(ctx, state) + brick := requireBrick(t, recipe{Name: "pve-storage-probe", Bricks: newPVEStorageProbeBricks()}, brickPVEStorageProbe) + err := brick.Run(ctx, state) if !errors.Is(err, context.Canceled) { t.Fatalf("storage probe brick error = %v, want %v", err, context.Canceled) } diff --git a/internal/backup/collector_config_extra_test.go b/internal/backup/collector_config_extra_test.go index 06e64f5b..e88dcf54 100644 --- a/internal/backup/collector_config_extra_test.go +++ b/internal/backup/collector_config_extra_test.go @@ -49,10 +49,20 @@ func TestCollectorConfigValidateAcceptsNewStandaloneCollectionOptions(t *testing if err := tt.cfg.Validate(); err != nil { t.Fatalf("Validate() error = %v", err) } + if tt.cfg.PxarDatastoreConcurrency != 3 { + t.Fatalf("PxarDatastoreConcurrency = %d, want 3", tt.cfg.PxarDatastoreConcurrency) + } }) } } +func TestCollectorConfigValidateAcceptsCustomBackupPathsOnly(t *testing.T) { + cfg := &CollectorConfig{CustomBackupPaths: []string{"/opt/custom"}} + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } +} + func TestGlobHelpers(t *testing.T) { cases := []struct { pattern string diff --git a/internal/backup/collector_pbs_auth_test.go b/internal/backup/collector_pbs_auth_test.go index 42da4656..3c9f17ae 100644 --- a/internal/backup/collector_pbs_auth_test.go +++ b/internal/backup/collector_pbs_auth_test.go @@ -84,6 +84,30 @@ func TestSafeCmdOutputWithPBSAuthForDatastoreBuildsRepo(t *testing.T) { } } +func TestPBSRepositoryWithDatastorePreservesHostPortAndIPv6(t *testing.T) { + tests := []struct { + name string + repo string + datastore string + want string + }{ + {name: "host only", repo: "user@host", datastore: "newds", want: "user@host:newds"}, + {name: "existing datastore", repo: "user@host:oldds", datastore: "newds", want: "user@host:newds"}, + {name: "host port", repo: "user@host:8007:oldds", datastore: "newds", want: "user@host:8007:newds"}, + {name: "bracketed ipv6", repo: "[2001:db8::1]:oldds", datastore: "newds", want: "[2001:db8::1]:newds"}, + {name: "user bracketed ipv6", repo: "user@[2001:db8::1]:oldds", datastore: "newds", want: "user@[2001:db8::1]:newds"}, + {name: "bracketed ipv6 without datastore", repo: "[2001:db8::1]", datastore: "newds", want: "[2001:db8::1]:newds"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := pbsRepositoryWithDatastore(tt.repo, tt.datastore); got != tt.want { + t.Fatalf("pbsRepositoryWithDatastore(%q, %q) = %q, want %q", tt.repo, tt.datastore, got, tt.want) + } + }) + } +} + func TestSafeCmdOutputWithPBSAuthForDatastoreSkipsWhenNoCredentials(t *testing.T) { origLookPath := execLookPath origRun := runCommandWithEnv diff --git a/internal/notify/email.go b/internal/notify/email.go index 61fe6cb9..da44380f 100644 --- a/internal/notify/email.go +++ b/internal/notify/email.go @@ -695,6 +695,19 @@ func lookupAbsolutePath(name string) (string, error) { return "", fmt.Errorf("exec.LookPath returned non-absolute path %q", execPath) } +func findMailqPath() (string, error) { + candidates := []string{"mailq", "/usr/bin/mailq"} + errs := make([]error, 0, len(candidates)) + for _, candidate := range candidates { + path, err := lookupAbsolutePath(candidate) + if err == nil { + return path, nil + } + errs = append(errs, fmt.Errorf("%s: %w", candidate, err)) + } + return "", fmt.Errorf("mailq command not found: %w", errors.Join(errs...)) +} + // sendViaRelay sends email via cloud relay func (e *EmailNotifier) sendViaRelay(ctx context.Context, recipient, subject, htmlBody, textBody string, data *NotificationData) error { // Build payload @@ -787,12 +800,9 @@ func (e *EmailNotifier) checkRelayHostConfigured(ctx context.Context) (bool, str // checkMailQueue checks the mail queue status func (e *EmailNotifier) checkMailQueue(ctx context.Context) (int, error) { // Try mailq command (works for both Postfix and Sendmail) - mailqPath, err := lookupAbsolutePath("mailq") + mailqPath, err := findMailqPath() if err != nil { - mailqPath, err = lookupAbsolutePath("/usr/bin/mailq") - if err != nil { - return 0, fmt.Errorf("mailq command not found") - } + return 0, err } cmd, err := commandForMailTool(ctx, mailqPath) @@ -833,12 +843,9 @@ func (e *EmailNotifier) checkMailQueue(ctx context.Context) (int, error) { // detectQueueEntry scans the mail queue for a recipient and returns the latest queue ID. func (e *EmailNotifier) detectQueueEntry(ctx context.Context, recipient string) (string, string, error) { - mailqPath, err := lookupAbsolutePath("mailq") + mailqPath, err := findMailqPath() if err != nil { - mailqPath, err = lookupAbsolutePath("/usr/bin/mailq") - if err != nil { - return "", "", fmt.Errorf("mailq command not found") - } + return "", "", err } cmd, err := commandForMailTool(ctx, mailqPath) diff --git a/internal/orchestrator/decrypt_tui_e2e_helpers_test.go b/internal/orchestrator/decrypt_tui_e2e_helpers_test.go index 4278b2f2..9dd6dc32 100644 --- a/internal/orchestrator/decrypt_tui_e2e_helpers_test.go +++ b/internal/orchestrator/decrypt_tui_e2e_helpers_test.go @@ -131,6 +131,8 @@ type timedSimHarness struct { closeDoneOnce sync.Once injectWG sync.WaitGroup screenStateCh chan struct{} + runCompleted chan struct{} + closeRunOnce sync.Once appMu sync.RWMutex apps []*tui.App @@ -174,6 +176,7 @@ func withTimedSimAppSequence(t *testing.T, keys []timedSimKey) *timedSimHarness t: t, done: make(chan struct{}), screenStateCh: make(chan struct{}, 1), + runCompleted: make(chan struct{}), } t.Cleanup(func() { @@ -224,6 +227,18 @@ func (h *timedSimHarness) notifyScreenStateChanged() { } } +func (h *timedSimHarness) markRunCompleted() { + if h == nil { + return + } + if h.runCompleted == nil { + return + } + h.closeRunOnce.Do(func() { + close(h.runCompleted) + }) +} + func (h *timedSimHarness) stop() { if h == nil { return @@ -289,6 +304,7 @@ func (h *timedSimHarness) run(keys []timedSimKey) { timer := time.NewTimer(timedSimCompletionTimeout) defer timer.Stop() select { + case <-h.runCompleted: case <-h.done: case <-timer.C: h.t.Errorf("TUI simulation did not finish within %s after injecting %d key(s)\n%s", timedSimCompletionTimeout, len(keys), h.describeCurrentState()) @@ -537,7 +553,11 @@ func runDecryptWorkflowTUIForTest(t *testing.T, sim *timedSimHarness, ctx contex errCh := make(chan error, 1) go func() { - errCh <- RunDecryptWorkflowTUI(runCtx, cfg, logger, "1.0.0", configPath, "test-build") + err := RunDecryptWorkflowTUI(runCtx, cfg, logger, "1.0.0", configPath, "test-build") + if sim != nil { + sim.markRunCompleted() + } + errCh <- err }() waitTimeout := 30 * time.Second diff --git a/internal/orchestrator/restore_cluster_apply.go b/internal/orchestrator/restore_cluster_apply.go index bf9e3853..a2fbac64 100644 --- a/internal/orchestrator/restore_cluster_apply.go +++ b/internal/orchestrator/restore_cluster_apply.go @@ -78,7 +78,7 @@ func listExportNodeDirs(exportRoot string) ([]string, error) { nodesRoot := filepath.Join(exportRoot, "etc/pve/nodes") entries, err := restoreFS.ReadDir(nodesRoot) if err != nil { - if errors.Is(err, os.ErrNotExist) || os.IsNotExist(err) { + if errors.Is(err, os.ErrNotExist) { return nil, nil } return nil, err @@ -184,18 +184,40 @@ func readVMName(confPath string) string { } func applyVMConfigs(ctx context.Context, entries []vmEntry, logger *logging.Logger) (applied, failed int) { + node := localNodeName() for _, vm := range entries { if err := ctx.Err(); err != nil { logger.Warning("VM apply aborted: %v", err) return applied, failed } - target := fmt.Sprintf("/nodes/%s/%s/%s/config", detectNodeForVM(), vm.Kind, vm.VMID) + target := fmt.Sprintf("/nodes/%s/%s/%s/config", node, vm.Kind, vm.VMID) configArgs, err := pveshArgsFromColonConfigFile(vm.Path) if err != nil { logger.Warning("Failed to read %s (vmid=%s kind=%s): %v", vm.Path, vm.VMID, vm.Kind, err) failed++ continue } + + exists, err := pveshGuestExists(ctx, logger, target) + if err != nil { + logger.Warning("Failed to check existing VM/CT config %s (vmid=%s kind=%s): %v", target, vm.VMID, vm.Kind, err) + failed++ + continue + } + if !exists { + createArgs, err := pveshCreateGuestArgs(node, vm, configArgs) + if err != nil { + logger.Warning("Failed to prepare VM/CT create for %s (vmid=%s kind=%s): %v", target, vm.VMID, vm.Kind, err) + failed++ + continue + } + if err := runPvesh(ctx, logger, createArgs); err != nil { + logger.Warning("Failed to create VM/CT config %s (vmid=%s kind=%s): %v", target, vm.VMID, vm.Kind, err) + failed++ + continue + } + } + args := append([]string{"set", target}, configArgs...) if err := runPvesh(ctx, logger, args); err != nil { logger.Warning("Failed to apply %s (vmid=%s kind=%s): %v", target, vm.VMID, vm.Kind, err) @@ -212,7 +234,7 @@ func applyVMConfigs(ctx context.Context, entries []vmEntry, logger *logging.Logg return applied, failed } -func detectNodeForVM() string { +func localNodeName() string { host, _ := os.Hostname() host = shortHost(host) if host != "" { @@ -221,6 +243,59 @@ func detectNodeForVM() string { return "localhost" } +func pveshGuestExists(ctx context.Context, logger *logging.Logger, target string) (bool, error) { + if err := runPvesh(ctx, logger, []string{"get", target}); err != nil { + if isPveshNotFoundError(err) { + return false, nil + } + return false, err + } + return true, nil +} + +func pveshCreateGuestArgs(node string, vm vmEntry, configArgs []string) ([]string, error) { + args := []string{ + "create", + fmt.Sprintf("/nodes/%s/%s", node, vm.Kind), + fmt.Sprintf("--vmid=%s", vm.VMID), + } + switch vm.Kind { + case "qemu": + return args, nil + case "lxc": + ostemplate, ok := pveshArgValue(configArgs, "ostemplate") + if !ok { + return nil, fmt.Errorf("missing ostemplate in LXC config") + } + return append(args, fmt.Sprintf("--ostemplate=%s", ostemplate)), nil + default: + return nil, fmt.Errorf("unsupported guest kind %q", vm.Kind) + } +} + +func pveshArgValue(args []string, key string) (string, bool) { + prefix := "--" + key + "=" + for _, arg := range args { + if strings.HasPrefix(arg, prefix) { + return strings.TrimPrefix(arg, prefix), true + } + } + return "", false +} + +func isPveshNotFoundError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + for _, marker := range []string{"not found", "does not exist", "no such", "unable to find", "404"} { + if strings.Contains(msg, marker) { + return true + } + } + return false +} + type storageBlock struct { ID string Type string @@ -238,6 +313,9 @@ func pveshArgsFromColonConfigFile(path string) ([]string, error) { func pveshArgsFromColonConfigLines(lines []string) []string { args := make([]string, 0, len(lines)*2) for _, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "[") { + break + } key, value, ok := parseColonConfigLine(line) if !ok { continue diff --git a/internal/orchestrator/restore_errors_test.go b/internal/orchestrator/restore_errors_test.go index 31a2d9ce..bfe6c39e 100644 --- a/internal/orchestrator/restore_errors_test.go +++ b/internal/orchestrator/restore_errors_test.go @@ -1733,6 +1733,42 @@ func TestApplyVMConfigs_SuccessfulApply(t *testing.T) { } } +func TestApplyVMConfigs_CreatesMissingGuestBeforeSet(t *testing.T) { + orig := restoreCmd + t.Cleanup(func() { restoreCmd = orig }) + + node := localNodeName() + getCall := fmt.Sprintf("pvesh get /nodes/%s/qemu/100/config", node) + fake := &FakeCommandRunner{ + Outputs: map[string][]byte{}, + Errors: map[string]error{ + getCall: fmt.Errorf("not found"), + }, + } + restoreCmd = fake + + dir := t.TempDir() + configPath := filepath.Join(dir, "100.conf") + if err := os.WriteFile(configPath, []byte("name: test-vm"), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + entries := []vmEntry{{VMID: "100", Kind: "qemu", Path: configPath}} + logger := logging.New(logging.GetDefaultLogger().GetLevel(), false) + applied, failed := applyVMConfigs(context.Background(), entries, logger) + + if applied != 1 || failed != 0 { + t.Fatalf("expected (1,0), got (%d,%d)", applied, failed) + } + calls := strings.Join(fake.CallsList(), "\n") + if !strings.Contains(calls, fmt.Sprintf("pvesh create /nodes/%s/qemu --vmid=100", node)) { + t.Fatalf("missing create call; calls=%s", calls) + } + if !strings.Contains(calls, fmt.Sprintf("pvesh set /nodes/%s/qemu/100/config --name=test-vm", node)) { + t.Fatalf("missing set call; calls=%s", calls) + } +} + // -------------------------------------------------------------------------- // extractDirectory success path test // -------------------------------------------------------------------------- diff --git a/internal/orchestrator/restore_test.go b/internal/orchestrator/restore_test.go index 7ad785b3..622057ca 100644 --- a/internal/orchestrator/restore_test.go +++ b/internal/orchestrator/restore_test.go @@ -1353,17 +1353,45 @@ func TestReadVMName_FileNotFound(t *testing.T) { } // -------------------------------------------------------------------------- -// detectNodeForVM tests +// localNodeName tests // -------------------------------------------------------------------------- -func TestDetectNodeForVM_ReturnsHostname(t *testing.T) { - node := detectNodeForVM() - // detectNodeForVM returns the current hostname, not the node from path +func TestLocalNodeName_ReturnsHostname(t *testing.T) { + node := localNodeName() if node == "" { t.Fatalf("expected non-empty node from hostname") } } +func TestPveshArgsFromColonConfigLinesStopsAtSectionHeader(t *testing.T) { + args := pveshArgsFromColonConfigLines([]string{ + "name: vm100", + "memory: 2048", + "[snapshot]", + "parent: base", + "snaptime: 123", + }) + + got := strings.Join(args, " ") + if !strings.Contains(got, "--name=vm100") || !strings.Contains(got, "--memory=2048") { + t.Fatalf("expected pre-section args, got %v", args) + } + if strings.Contains(got, "parent") || strings.Contains(got, "snaptime") { + t.Fatalf("snapshot section args must be ignored, got %v", args) + } +} + +func TestPveshCreateGuestArgsIncludesLXCOstemplate(t *testing.T) { + args, err := pveshCreateGuestArgs("node1", vmEntry{VMID: "101", Kind: "lxc"}, []string{"--hostname=ct101", "--ostemplate=local:vztmpl/debian.tar.zst"}) + if err != nil { + t.Fatalf("pveshCreateGuestArgs error = %v", err) + } + got := strings.Join(args, " ") + if !strings.Contains(got, "create /nodes/node1/lxc --vmid=101") || !strings.Contains(got, "--ostemplate=local:vztmpl/debian.tar.zst") { + t.Fatalf("unexpected create args: %v", args) + } +} + // -------------------------------------------------------------------------- // detectConfiguredZFSPools tests // -------------------------------------------------------------------------- From 60de173146527a1cf9b52ed0d497227436d0c287 Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Wed, 6 May 2026 15:56:00 +0200 Subject: [PATCH 35/35] Refactor webhook endpoint validation Extract pushover-specific validation from NewWebhookNotifier into a new WebhookNotifier.validateEndpoint method and instantiate the notifier earlier. NewWebhookNotifier now assigns the HTTP client to the notifier and returns it, improving readability and isolating endpoint validation. Also move the nosemgrep annotation to the end of the return statement in TrustedCommandContext to satisfy linters. --- internal/notify/webhook.go | 60 ++++++++++++++++++++--------------- internal/safeexec/safeexec.go | 3 +- 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/internal/notify/webhook.go b/internal/notify/webhook.go index 08c5f629..9a020bc3 100644 --- a/internal/notify/webhook.go +++ b/internal/notify/webhook.go @@ -63,6 +63,11 @@ func NewWebhookNotifier(webhookConfig *config.WebhookConfig, logger *logging.Log return nil, fmt.Errorf("webhook notifications enabled but no endpoints configured") } + notifier := &WebhookNotifier{ + config: webhookConfig, + logger: logger, + } + // Log each endpoint configuration (with masked sensitive data) for i, ep := range webhookConfig.Endpoints { logger.Debug("Endpoint #%d configuration:", i+1) @@ -77,26 +82,8 @@ func NewWebhookNotifier(webhookConfig *config.WebhookConfig, logger *logging.Log logger.Debug(" Header: %s (value masked)", k) } } - - format := resolveWebhookFormat(ep.Format, webhookConfig.DefaultFormat) - method := resolveWebhookMethod(ep.Method) - if strings.EqualFold(format, "pushover") { - missing := []string{} - if ep.Auth.Token == "" { - missing = append(missing, "token") - } - if ep.Auth.User == "" { - missing = append(missing, "user") - } - if len(missing) > 0 { - return nil, fmt.Errorf("webhook endpoint %q: Pushover requires Auth.Token and Auth.User; missing %s", ep.Name, strings.Join(missing, "/")) - } - if ep.Priority < -2 || ep.Priority > 1 { - return nil, fmt.Errorf("webhook endpoint %q: PRIORITY must be in range -2..1 (got %d); priority 2 (emergency) is not supported", ep.Name, ep.Priority) - } - if method != http.MethodPost { - return nil, fmt.Errorf("webhook endpoint %q: METHOD must be POST for pushover (got %s)", ep.Name, method) - } + if err := notifier.validateEndpoint(ep); err != nil { + return nil, err } } @@ -114,11 +101,34 @@ func NewWebhookNotifier(webhookConfig *config.WebhookConfig, logger *logging.Log logger.Info("✅ WebhookNotifier initialized successfully with %d endpoint(s)", len(webhookConfig.Endpoints)) - return &WebhookNotifier{ - config: webhookConfig, - logger: logger, - client: client, - }, nil + notifier.client = client + return notifier, nil +} + +func (w *WebhookNotifier) validateEndpoint(ep config.WebhookEndpoint) error { + format := resolveWebhookFormat(ep.Format, w.config.DefaultFormat) + method := resolveWebhookMethod(ep.Method) + if !strings.EqualFold(format, "pushover") { + return nil + } + + missing := []string{} + if ep.Auth.Token == "" { + missing = append(missing, "token") + } + if ep.Auth.User == "" { + missing = append(missing, "user") + } + if len(missing) > 0 { + return fmt.Errorf("webhook endpoint %q: Pushover requires Auth.Token and Auth.User; missing %s", ep.Name, strings.Join(missing, "/")) + } + if ep.Priority < -2 || ep.Priority > 1 { + return fmt.Errorf("webhook endpoint %q: PRIORITY must be in range -2..1 (got %d); priority 2 (emergency) is not supported", ep.Name, ep.Priority) + } + if method != http.MethodPost { + return fmt.Errorf("webhook endpoint %q: METHOD must be POST for pushover (got %s)", ep.Name, method) + } + return nil } // Name returns the notifier name diff --git a/internal/safeexec/safeexec.go b/internal/safeexec/safeexec.go index ed0e9dfd..b4960255 100644 --- a/internal/safeexec/safeexec.go +++ b/internal/safeexec/safeexec.go @@ -199,8 +199,7 @@ func TrustedCommandContext(ctx context.Context, execPath string, args ...string) return nil, err } // #nosec G204 -- execPath is absolute, regular, executable, and not world-writable. - // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command - return exec.CommandContext(ctx, execPath, args...), nil + return exec.CommandContext(ctx, execPath, args...), nil // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command } func ValidateTrustedExecutablePath(execPath string) error {