Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/proxsave/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ func printInstallFooter(installErr error, configPath, baseDir, telegramCode, per
fmt.Println(" --dry-run - Test without changes")
fmt.Println(" --install - Re-run interactive installation/setup")
fmt.Println(" --new-install - Wipe installation directory (keep env/identity) then run installer")
fmt.Println(" --upgrade - Update proxsave binary to latest release (no config changes)")
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)")
Expand Down
34 changes: 29 additions & 5 deletions cmd/proxsave/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
Expand Down Expand Up @@ -147,8 +148,8 @@
if args.EnvMigration || args.EnvMigrationDry {
incompatible = append(incompatible, "--env-migration/--env-migration-dry-run")
}
if args.UpgradeConfig || args.UpgradeConfigDry {
incompatible = append(incompatible, "--upgrade-config/--upgrade-config-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 {
Expand Down Expand Up @@ -188,7 +189,7 @@
if args.EnvMigration || args.EnvMigrationDry {
incompatible = append(incompatible, "--env-migration")
}
if args.UpgradeConfig || args.UpgradeConfigDry {
if args.UpgradeConfig || args.UpgradeConfigDry || args.UpgradeConfigJSON {
incompatible = append(incompatible, "--upgrade-config")
}
if args.ForceNewKey {
Expand Down Expand Up @@ -223,7 +224,30 @@
}
args.ConfigPath = resolvedConfigPath

// Dedicated upgrade mode (download latest binary, no config changes)
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)
Expand Down Expand Up @@ -1525,7 +1549,7 @@
}
fmt.Printf("\r Remaining: %ds ", int(remaining.Seconds()))

select {

Check failure on line 1552 in cmd/proxsave/main.go

View workflow job for this annotation

GitHub Actions / security

should use a simple channel send/receive instead of select with a single case (S1000)

Check failure on line 1552 in cmd/proxsave/main.go

View workflow job for this annotation

GitHub Actions / security

should use a simple channel send/receive instead of select with a single case (S1000)

Check failure on line 1552 in cmd/proxsave/main.go

View workflow job for this annotation

GitHub Actions / security

should use a simple channel send/receive instead of select with a single case (S1000)
case <-ticker.C:
continue
}
Expand Down Expand Up @@ -1582,7 +1606,7 @@
fmt.Println(" --new-install - Wipe installation directory (keep 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 (no config changes)")
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)")
Expand Down
94 changes: 86 additions & 8 deletions cmd/proxsave/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
Expand All @@ -30,6 +31,8 @@ import (

const (
githubRepo = "tis24dev/proxsave"

maxUpgradeConfigJSONPreviewLength = 4000
)

type releaseInfo struct {
Expand All @@ -38,7 +41,7 @@ type releaseInfo struct {

// runUpgrade orchestrates the upgrade flow:
// - downloads and installs the latest binary release
// - keeps the existing backup.env untouched
// - upgrades backup.env by adding missing keys from the new template (preserving existing values)
// - refreshes symlinks/cron/docs and normalizes permissions/ownership
func runUpgrade(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger) int {
baseDir := filepath.Dir(filepath.Dir(args.ConfigPath))
Expand Down Expand Up @@ -124,7 +127,7 @@ func runUpgrade(ctx context.Context, args *cli.Args, bootstrap *logging.Bootstra
bootstrap.Printf("Latest available version: %s (current: %s)", latestVersion, currentVersion)

reader := bufio.NewReader(os.Stdin)
confirm, err := promptYesNo(ctx, reader, "Do you want to download and install this version now? [y/N]: ", false)
confirm, err := promptYesNo(ctx, reader, "Do you want to download and install this version now? (backup.env will be updated with any missing keys; a backup will be created) [y/N]: ", false)
if err != nil {
bootstrap.Error("ERROR: %v", err)
workflowErr = err
Expand All @@ -147,7 +150,17 @@ func runUpgrade(ctx context.Context, args *cli.Args, bootstrap *logging.Bootstra
workflowErr = upgradeErr
}

// Refresh docs/symlinks/cron/identity without touching backup.env
var cfgUpgradeResult *config.UpgradeResult
var cfgUpgradeErr error
if upgradeErr == nil {
logging.DebugStepBootstrap(bootstrap, "upgrade workflow", "upgrading configuration with newly installed binary")
cfgUpgradeResult, cfgUpgradeErr = upgradeConfigWithBinary(ctx, execPath, args.ConfigPath)
if cfgUpgradeErr != nil {
bootstrap.Warning("Upgrade: configuration upgrade failed: %v", cfgUpgradeErr)
}
}

// Refresh docs/symlinks/cron/identity (configuration upgrade is handled separately)
logging.DebugStepBootstrap(bootstrap, "upgrade workflow", "refreshing docs and symlinks")
if err := installSupportDocs(baseDir, bootstrap); err != nil {
bootstrap.Warning("Upgrade: failed to refresh documentation: %v", err)
Expand All @@ -174,7 +187,7 @@ func runUpgrade(ctx context.Context, args *cli.Args, bootstrap *logging.Bootstra
logging.DebugStepBootstrap(bootstrap, "upgrade workflow", "normalizing permissions")
permStatus, permMessage := fixPermissionsAfterInstall(ctx, args.ConfigPath, baseDir, bootstrap)

printUpgradeFooter(upgradeErr, versionInstalled, args.ConfigPath, baseDir, telegramCode, permStatus, permMessage)
printUpgradeFooter(upgradeErr, versionInstalled, args.ConfigPath, baseDir, telegramCode, permStatus, permMessage, cfgUpgradeResult, cfgUpgradeErr)

if upgradeErr != nil {
return types.ExitGenericError.Int()
Expand Down Expand Up @@ -512,7 +525,7 @@ func installBinary(srcPath, destPath string, bootstrap *logging.BootstrapLogger)
return nil
}

func printUpgradeFooter(upgradeErr error, version, configPath, baseDir, telegramCode, permStatus, permMessage string) {
func printUpgradeFooter(upgradeErr error, version, configPath, baseDir, telegramCode, permStatus, permMessage string, cfgUpgradeResult *config.UpgradeResult, cfgUpgradeErr error) {
colorReset := "\033[0m"

title := "Go-based upgrade completed"
Expand Down Expand Up @@ -549,11 +562,38 @@ func printUpgradeFooter(upgradeErr error, version, configPath, baseDir, telegram
fmt.Println()
}

if cfgUpgradeErr != nil {
fmt.Printf("Configuration: ERROR - failed to upgrade %s\n", configPath)
fmt.Printf(" Details: %v\n", cfgUpgradeErr)
fmt.Println(" Action: Review the configuration file and run: proxsave --upgrade-config")
fmt.Println()
} else if cfgUpgradeResult != nil {
if cfgUpgradeResult.Changed {
if len(cfgUpgradeResult.MissingKeys) > 0 {
fmt.Printf("Configuration: updated (added %d missing key(s))\n", len(cfgUpgradeResult.MissingKeys))
fmt.Printf(" Added keys: %s\n", strings.Join(cfgUpgradeResult.MissingKeys, ", "))
fmt.Println(" Action: Review these keys in backup.env and adjust values as needed.")
} else {
fmt.Println("Configuration: updated (no new keys were required)")
if len(cfgUpgradeResult.ExtraKeys) > 0 {
fmt.Printf(" Preserved %d custom key(s) not present in the template.\n", len(cfgUpgradeResult.ExtraKeys))
}
}
if cfgUpgradeResult.BackupPath != "" {
fmt.Printf(" Backup saved to: %s\n", cfgUpgradeResult.BackupPath)
}
fmt.Println()
} else {
fmt.Println("Configuration: already up to date with the latest template (no changes).")
fmt.Println()
}
}

fmt.Println("Next steps:")
if strings.TrimSpace(configPath) != "" {
fmt.Printf("1. Verify configuration (unchanged): %s\n", configPath)
fmt.Printf("1. Verify configuration: %s\n", configPath)
} else {
fmt.Println("1. Verify configuration (unchanged)")
fmt.Println("1. Verify configuration")
}
if strings.TrimSpace(baseDir) != "" {
fmt.Println("2. Run backup: proxsave")
Expand All @@ -569,7 +609,7 @@ func printUpgradeFooter(upgradeErr error, version, configPath, baseDir, telegram

fmt.Println("Commands:")
fmt.Println(" proxsave (alias: proxmox-backup) - Start backup")
fmt.Println(" --upgrade - Update proxsave binary to latest release (no config changes)")
fmt.Println(" --upgrade - Update proxsave binary to latest release (also adds missing keys to backup.env)")
fmt.Println(" --install - Re-run interactive installation/setup")
fmt.Println(" --new-install - Wipe installation directory (keep env/identity) then run installer")
fmt.Println(" --upgrade-config - Upgrade configuration file using the embedded template (run after installing a new binary)")
Expand All @@ -579,3 +619,41 @@ func printUpgradeFooter(upgradeErr error, version, configPath, baseDir, telegram
fmt.Println("Upgrade reported an error; please review the log above.")
}
}

func upgradeConfigWithBinary(ctx context.Context, execPath, configPath string) (*config.UpgradeResult, error) {
execPath = strings.TrimSpace(execPath)
configPath = strings.TrimSpace(configPath)
if execPath == "" {
return nil, fmt.Errorf("exec path is empty")
}
if configPath == "" {
return nil, fmt.Errorf("configuration path is empty")
}

cmd := exec.CommandContext(ctx, execPath, "--config", configPath, "--upgrade-config-json")
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

if err := cmd.Run(); err != nil {
details := strings.TrimSpace(stderr.String())
if details == "" {
details = strings.TrimSpace(stdout.String())
}
if details != "" {
return nil, fmt.Errorf("upgrade-config-json failed: %w: %s", err, details)
}
return nil, fmt.Errorf("upgrade-config-json failed: %w", err)
}

var result config.UpgradeResult
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
preview := strings.TrimSpace(stdout.String())
if len(preview) > maxUpgradeConfigJSONPreviewLength {
preview = preview[:maxUpgradeConfigJSONPreviewLength] + "…"
}
return nil, fmt.Errorf("invalid JSON from upgrade-config-json: %w (stdout=%q)", err, preview)
}
return &result, nil
}
6 changes: 5 additions & 1 deletion internal/cli/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type Args struct {
NewInstall bool
UpgradeConfig bool
UpgradeConfigDry bool
UpgradeConfigJSON bool
EnvMigration bool
EnvMigrationDry bool
CleanupGuards bool
Expand Down Expand Up @@ -95,7 +96,7 @@ func Parse() *Args {
flag.BoolVar(&args.NewInstall, "new-install", false,
"Reset the installation directory (preserving env/identity) and launch the interactive installer")
flag.BoolVar(&args.Upgrade, "upgrade", false,
"Download and install the latest ProxSave binary (without modifying backup.env)")
"Download and install the latest ProxSave binary (also upgrades backup.env by adding missing keys from the new template)")
flag.BoolVar(&args.EnvMigration, "env-migration", false,
"Run the installer and migrate a legacy Bash backup.env to the Go template")
flag.BoolVar(&args.EnvMigrationDry, "env-migration-dry-run", false,
Expand All @@ -111,6 +112,9 @@ func Parse() *Args {
flag.BoolVar(&args.UpgradeConfigDry, "upgrade-config-dry-run", false,
"Plan configuration upgrade using the embedded template without modifying the file (reports missing and custom keys)")

flag.BoolVar(&args.UpgradeConfigJSON, "upgrade-config-json", false,
"Upgrade configuration file using the embedded template and print JSON summary to stdout (for internal use by --upgrade)")

// Custom usage message
flag.Usage = func() {
printHelp(os.Stderr, os.Args[0])
Expand Down
Loading
Loading