Skip to content

Commit 9505c08

Browse files
authored
Sync dev to main (#129)
* Add --upgrade-config-json and auto-config upgrade Introduce a machine-readable config-upgrade mode (--upgrade-config-json) and wire automatic configuration upgrades into the binary upgrade flow. The upgrade command now attempts to run the newly installed binary to merge template changes into backup.env (adding missing keys while preserving existing values). Implemented robust parsing and rendering for multi-line (block) env values, refactored value parsing into helpers (parseEnvValues, splitKeyValueRaw, renderEnvValue, findClosingQuoteLine) and updated computeConfigUpgrade to preserve block entries and multiple values per key. Added tests covering block preservation and missing-block detection. printUpgradeFooter now reports detailed config-upgrade results or errors, and installer/help text was updated to reflect the behavior change. Error handling for the JSON mode and subprocess invocation was added to ensure clear diagnostics. * Refine upgrade prompts, previews, and errors Make the upgrade UX and diagnostics clearer: update the interactive prompt to inform users a backup will be created and backup.env may be updated; extract maxUpgradeConfigJSONPreviewLength constant and use it when truncating invalid JSON preview. Improve error messages to include the config path when parsing fails and the starting line number for unterminated multi-line values. Update tests to normalize CRLF vs LF so assertions are deterministic across platforms.
1 parent fbd01e9 commit 9505c08

7 files changed

Lines changed: 330 additions & 37 deletions

File tree

cmd/proxsave/install.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ func printInstallFooter(installErr error, configPath, baseDir, telegramCode, per
223223
fmt.Println(" --dry-run - Test without changes")
224224
fmt.Println(" --install - Re-run interactive installation/setup")
225225
fmt.Println(" --new-install - Wipe installation directory (keep env/identity) then run installer")
226-
fmt.Println(" --upgrade - Update proxsave binary to latest release (no config changes)")
226+
fmt.Println(" --upgrade - Update proxsave binary to latest release (also adds missing keys to backup.env)")
227227
fmt.Println(" --newkey - Generate a new encryption key for backups")
228228
fmt.Println(" --decrypt - Decrypt an existing backup archive")
229229
fmt.Println(" --restore - Run interactive restore workflow (select bundle, decrypt if needed, apply to system)")

cmd/proxsave/main.go

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"fmt"
78
"os"
@@ -147,8 +148,8 @@ func run() int {
147148
if args.EnvMigration || args.EnvMigrationDry {
148149
incompatible = append(incompatible, "--env-migration/--env-migration-dry-run")
149150
}
150-
if args.UpgradeConfig || args.UpgradeConfigDry {
151-
incompatible = append(incompatible, "--upgrade-config/--upgrade-config-dry-run")
151+
if args.UpgradeConfig || args.UpgradeConfigDry || args.UpgradeConfigJSON {
152+
incompatible = append(incompatible, "--upgrade-config/--upgrade-config-dry-run/--upgrade-config-json")
152153
}
153154

154155
if len(incompatible) > 0 {
@@ -188,7 +189,7 @@ func run() int {
188189
if args.EnvMigration || args.EnvMigrationDry {
189190
incompatible = append(incompatible, "--env-migration")
190191
}
191-
if args.UpgradeConfig || args.UpgradeConfigDry {
192+
if args.UpgradeConfig || args.UpgradeConfigDry || args.UpgradeConfigJSON {
192193
incompatible = append(incompatible, "--upgrade-config")
193194
}
194195
if args.ForceNewKey {
@@ -223,7 +224,30 @@ func run() int {
223224
}
224225
args.ConfigPath = resolvedConfigPath
225226

226-
// Dedicated upgrade mode (download latest binary, no config changes)
227+
if args.UpgradeConfigJSON {
228+
if _, err := os.Stat(args.ConfigPath); err != nil {
229+
fmt.Fprintf(os.Stderr, "ERROR: configuration file not found: %v\n", err)
230+
return types.ExitConfigError.Int()
231+
}
232+
233+
result, err := config.UpgradeConfigFile(args.ConfigPath)
234+
if err != nil {
235+
fmt.Fprintf(os.Stderr, "ERROR: Failed to upgrade configuration: %v\n", err)
236+
return types.ExitConfigError.Int()
237+
}
238+
if result == nil {
239+
result = &config.UpgradeResult{}
240+
}
241+
242+
enc := json.NewEncoder(os.Stdout)
243+
if err := enc.Encode(result); err != nil {
244+
fmt.Fprintf(os.Stderr, "ERROR: Failed to encode JSON: %v\n", err)
245+
return types.ExitGenericError.Int()
246+
}
247+
return types.ExitSuccess.Int()
248+
}
249+
250+
// Dedicated upgrade mode (download latest binary and upgrade config keys)
227251
if args.Upgrade {
228252
logging.DebugStepBootstrap(bootstrap, "main run", "mode=upgrade")
229253
return runUpgrade(ctx, args, bootstrap)
@@ -1582,7 +1606,7 @@ func printFinalSummary(finalExitCode int) {
15821606
fmt.Println(" --new-install - Wipe installation directory (keep env/identity) then run installer")
15831607
fmt.Println(" --env-migration - Run installer and migrate legacy Bash backup.env to Go template")
15841608
fmt.Println(" --env-migration-dry-run - Preview installer/migration without writing files")
1585-
fmt.Println(" --upgrade - Update proxsave binary to latest release (no config changes)")
1609+
fmt.Println(" --upgrade - Update proxsave binary to latest release (also adds missing keys to backup.env)")
15861610
fmt.Println(" --newkey - Generate a new encryption key for backups")
15871611
fmt.Println(" --decrypt - Decrypt an existing backup archive")
15881612
fmt.Println(" --restore - Run interactive restore workflow (select bundle, decrypt if needed, apply to system)")

cmd/proxsave/upgrade.go

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"io"
1515
"net/http"
1616
"os"
17+
"os/exec"
1718
"path/filepath"
1819
"runtime"
1920
"strconv"
@@ -30,6 +31,8 @@ import (
3031

3132
const (
3233
githubRepo = "tis24dev/proxsave"
34+
35+
maxUpgradeConfigJSONPreviewLength = 4000
3336
)
3437

3538
type releaseInfo struct {
@@ -38,7 +41,7 @@ type releaseInfo struct {
3841

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

126129
reader := bufio.NewReader(os.Stdin)
127-
confirm, err := promptYesNo(ctx, reader, "Do you want to download and install this version now? [y/N]: ", false)
130+
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)
128131
if err != nil {
129132
bootstrap.Error("ERROR: %v", err)
130133
workflowErr = err
@@ -147,7 +150,17 @@ func runUpgrade(ctx context.Context, args *cli.Args, bootstrap *logging.Bootstra
147150
workflowErr = upgradeErr
148151
}
149152

150-
// Refresh docs/symlinks/cron/identity without touching backup.env
153+
var cfgUpgradeResult *config.UpgradeResult
154+
var cfgUpgradeErr error
155+
if upgradeErr == nil {
156+
logging.DebugStepBootstrap(bootstrap, "upgrade workflow", "upgrading configuration with newly installed binary")
157+
cfgUpgradeResult, cfgUpgradeErr = upgradeConfigWithBinary(ctx, execPath, args.ConfigPath)
158+
if cfgUpgradeErr != nil {
159+
bootstrap.Warning("Upgrade: configuration upgrade failed: %v", cfgUpgradeErr)
160+
}
161+
}
162+
163+
// Refresh docs/symlinks/cron/identity (configuration upgrade is handled separately)
151164
logging.DebugStepBootstrap(bootstrap, "upgrade workflow", "refreshing docs and symlinks")
152165
if err := installSupportDocs(baseDir, bootstrap); err != nil {
153166
bootstrap.Warning("Upgrade: failed to refresh documentation: %v", err)
@@ -174,7 +187,7 @@ func runUpgrade(ctx context.Context, args *cli.Args, bootstrap *logging.Bootstra
174187
logging.DebugStepBootstrap(bootstrap, "upgrade workflow", "normalizing permissions")
175188
permStatus, permMessage := fixPermissionsAfterInstall(ctx, args.ConfigPath, baseDir, bootstrap)
176189

177-
printUpgradeFooter(upgradeErr, versionInstalled, args.ConfigPath, baseDir, telegramCode, permStatus, permMessage)
190+
printUpgradeFooter(upgradeErr, versionInstalled, args.ConfigPath, baseDir, telegramCode, permStatus, permMessage, cfgUpgradeResult, cfgUpgradeErr)
178191

179192
if upgradeErr != nil {
180193
return types.ExitGenericError.Int()
@@ -512,7 +525,7 @@ func installBinary(srcPath, destPath string, bootstrap *logging.BootstrapLogger)
512525
return nil
513526
}
514527

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

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

565+
if cfgUpgradeErr != nil {
566+
fmt.Printf("Configuration: ERROR - failed to upgrade %s\n", configPath)
567+
fmt.Printf(" Details: %v\n", cfgUpgradeErr)
568+
fmt.Println(" Action: Review the configuration file and run: proxsave --upgrade-config")
569+
fmt.Println()
570+
} else if cfgUpgradeResult != nil {
571+
if cfgUpgradeResult.Changed {
572+
if len(cfgUpgradeResult.MissingKeys) > 0 {
573+
fmt.Printf("Configuration: updated (added %d missing key(s))\n", len(cfgUpgradeResult.MissingKeys))
574+
fmt.Printf(" Added keys: %s\n", strings.Join(cfgUpgradeResult.MissingKeys, ", "))
575+
fmt.Println(" Action: Review these keys in backup.env and adjust values as needed.")
576+
} else {
577+
fmt.Println("Configuration: updated (no new keys were required)")
578+
if len(cfgUpgradeResult.ExtraKeys) > 0 {
579+
fmt.Printf(" Preserved %d custom key(s) not present in the template.\n", len(cfgUpgradeResult.ExtraKeys))
580+
}
581+
}
582+
if cfgUpgradeResult.BackupPath != "" {
583+
fmt.Printf(" Backup saved to: %s\n", cfgUpgradeResult.BackupPath)
584+
}
585+
fmt.Println()
586+
} else {
587+
fmt.Println("Configuration: already up to date with the latest template (no changes).")
588+
fmt.Println()
589+
}
590+
}
591+
552592
fmt.Println("Next steps:")
553593
if strings.TrimSpace(configPath) != "" {
554-
fmt.Printf("1. Verify configuration (unchanged): %s\n", configPath)
594+
fmt.Printf("1. Verify configuration: %s\n", configPath)
555595
} else {
556-
fmt.Println("1. Verify configuration (unchanged)")
596+
fmt.Println("1. Verify configuration")
557597
}
558598
if strings.TrimSpace(baseDir) != "" {
559599
fmt.Println("2. Run backup: proxsave")
@@ -569,7 +609,7 @@ func printUpgradeFooter(upgradeErr error, version, configPath, baseDir, telegram
569609

570610
fmt.Println("Commands:")
571611
fmt.Println(" proxsave (alias: proxmox-backup) - Start backup")
572-
fmt.Println(" --upgrade - Update proxsave binary to latest release (no config changes)")
612+
fmt.Println(" --upgrade - Update proxsave binary to latest release (also adds missing keys to backup.env)")
573613
fmt.Println(" --install - Re-run interactive installation/setup")
574614
fmt.Println(" --new-install - Wipe installation directory (keep env/identity) then run installer")
575615
fmt.Println(" --upgrade-config - Upgrade configuration file using the embedded template (run after installing a new binary)")
@@ -579,3 +619,41 @@ func printUpgradeFooter(upgradeErr error, version, configPath, baseDir, telegram
579619
fmt.Println("Upgrade reported an error; please review the log above.")
580620
}
581621
}
622+
623+
func upgradeConfigWithBinary(ctx context.Context, execPath, configPath string) (*config.UpgradeResult, error) {
624+
execPath = strings.TrimSpace(execPath)
625+
configPath = strings.TrimSpace(configPath)
626+
if execPath == "" {
627+
return nil, fmt.Errorf("exec path is empty")
628+
}
629+
if configPath == "" {
630+
return nil, fmt.Errorf("configuration path is empty")
631+
}
632+
633+
cmd := exec.CommandContext(ctx, execPath, "--config", configPath, "--upgrade-config-json")
634+
var stdout bytes.Buffer
635+
var stderr bytes.Buffer
636+
cmd.Stdout = &stdout
637+
cmd.Stderr = &stderr
638+
639+
if err := cmd.Run(); err != nil {
640+
details := strings.TrimSpace(stderr.String())
641+
if details == "" {
642+
details = strings.TrimSpace(stdout.String())
643+
}
644+
if details != "" {
645+
return nil, fmt.Errorf("upgrade-config-json failed: %w: %s", err, details)
646+
}
647+
return nil, fmt.Errorf("upgrade-config-json failed: %w", err)
648+
}
649+
650+
var result config.UpgradeResult
651+
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
652+
preview := strings.TrimSpace(stdout.String())
653+
if len(preview) > maxUpgradeConfigJSONPreviewLength {
654+
preview = preview[:maxUpgradeConfigJSONPreviewLength] + "…"
655+
}
656+
return nil, fmt.Errorf("invalid JSON from upgrade-config-json: %w (stdout=%q)", err, preview)
657+
}
658+
return &result, nil
659+
}

internal/cli/args.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type Args struct {
3737
NewInstall bool
3838
UpgradeConfig bool
3939
UpgradeConfigDry bool
40+
UpgradeConfigJSON bool
4041
EnvMigration bool
4142
EnvMigrationDry bool
4243
CleanupGuards bool
@@ -95,7 +96,7 @@ func Parse() *Args {
9596
flag.BoolVar(&args.NewInstall, "new-install", false,
9697
"Reset the installation directory (preserving env/identity) and launch the interactive installer")
9798
flag.BoolVar(&args.Upgrade, "upgrade", false,
98-
"Download and install the latest ProxSave binary (without modifying backup.env)")
99+
"Download and install the latest ProxSave binary (also upgrades backup.env by adding missing keys from the new template)")
99100
flag.BoolVar(&args.EnvMigration, "env-migration", false,
100101
"Run the installer and migrate a legacy Bash backup.env to the Go template")
101102
flag.BoolVar(&args.EnvMigrationDry, "env-migration-dry-run", false,
@@ -111,6 +112,9 @@ func Parse() *Args {
111112
flag.BoolVar(&args.UpgradeConfigDry, "upgrade-config-dry-run", false,
112113
"Plan configuration upgrade using the embedded template without modifying the file (reports missing and custom keys)")
113114

115+
flag.BoolVar(&args.UpgradeConfigJSON, "upgrade-config-json", false,
116+
"Upgrade configuration file using the embedded template and print JSON summary to stdout (for internal use by --upgrade)")
117+
114118
// Custom usage message
115119
flag.Usage = func() {
116120
printHelp(os.Stderr, os.Args[0])

0 commit comments

Comments
 (0)