diff --git a/cmd/proxsave/install.go b/cmd/proxsave/install.go index 675fecd..d52e6dd 100644 --- a/cmd/proxsave/install.go +++ b/cmd/proxsave/install.go @@ -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)") diff --git a/cmd/proxsave/main.go b/cmd/proxsave/main.go index d4184a0..d7e9c34 100644 --- a/cmd/proxsave/main.go +++ b/cmd/proxsave/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -147,8 +148,8 @@ func run() int { 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 { @@ -188,7 +189,7 @@ func run() int { 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 { @@ -223,7 +224,30 @@ func run() int { } 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) @@ -1582,7 +1606,7 @@ func printFinalSummary(finalExitCode int) { 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)") diff --git a/cmd/proxsave/upgrade.go b/cmd/proxsave/upgrade.go index bc24874..9a09107 100644 --- a/cmd/proxsave/upgrade.go +++ b/cmd/proxsave/upgrade.go @@ -14,6 +14,7 @@ import ( "io" "net/http" "os" + "os/exec" "path/filepath" "runtime" "strconv" @@ -30,6 +31,8 @@ import ( const ( githubRepo = "tis24dev/proxsave" + + maxUpgradeConfigJSONPreviewLength = 4000 ) type releaseInfo struct { @@ -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)) @@ -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 @@ -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) @@ -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() @@ -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" @@ -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") @@ -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)") @@ -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 +} diff --git a/internal/cli/args.go b/internal/cli/args.go index 33222bb..8c182ed 100644 --- a/internal/cli/args.go +++ b/internal/cli/args.go @@ -37,6 +37,7 @@ type Args struct { NewInstall bool UpgradeConfig bool UpgradeConfigDry bool + UpgradeConfigJSON bool EnvMigration bool EnvMigrationDry bool CleanupGuards bool @@ -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, @@ -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]) diff --git a/internal/config/upgrade.go b/internal/config/upgrade.go index 6284499..2b59107 100644 --- a/internal/config/upgrade.go +++ b/internal/config/upgrade.go @@ -9,6 +9,19 @@ import ( "github.com/tis24dev/proxsave/pkg/utils" ) +type envValueKind int + +const ( + envValueKindLine envValueKind = iota + envValueKindBlock +) + +type envValue struct { + kind envValueKind + rawValue string + blockLines []string +} + // UpgradeResult describes the outcome of a configuration upgrade. type UpgradeResult struct { // BackupPath is the path of the backup created from the previous config. @@ -142,21 +155,9 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er originalLines := strings.Split(normalizedOriginal, "\n") // 1. Collect user values: for each KEY we store all VALUE entries in order. - userValues := make(map[string][]string) - userKeyOrder := make([]string, 0) - - for _, line := range originalLines { - if utils.IsComment(line) { - continue - } - key, value, ok := utils.SplitKeyValue(line) - if !ok || key == "" { - continue - } - if _, seen := userValues[key]; !seen { - userKeyOrder = append(userKeyOrder, key) - } - userValues[key] = append(userValues[key], value) + userValues, userKeyOrder, err := parseEnvValues(originalLines) + if err != nil { + return result, "", originalContent, fmt.Errorf("failed to parse config %s: %w", configPath, err) } // 2. Walk the template line-by-line, merging values. @@ -168,14 +169,15 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er missingKeys := make([]string, 0) newLines := make([]string, 0, len(templateLines)+len(userValues)) - for _, line := range templateLines { + for i := 0; i < len(templateLines); i++ { + line := templateLines[i] trimmed := strings.TrimSpace(line) if utils.IsComment(trimmed) { newLines = append(newLines, line) continue } - key, _, ok := utils.SplitKeyValue(line) + key, _, ok := splitKeyValueRaw(line) if !ok || key == "" { newLines = append(newLines, line) continue @@ -183,10 +185,28 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er templateKeys[key] = true + if blockValueKeys[key] && trimmed == fmt.Sprintf("%s=\"", key) { + blockEnd, err := findClosingQuoteLine(templateLines, i+1) + if err != nil { + return result, "", originalContent, fmt.Errorf("template %s block invalid: %w", key, err) + } + + if values, ok := userValues[key]; ok && len(values) > 0 { + for _, v := range values { + newLines = append(newLines, renderEnvValue(key, v)...) + } + } else { + missingKeys = append(missingKeys, key) + newLines = append(newLines, templateLines[i:blockEnd+1]...) + } + + i = blockEnd + continue + } + if values, ok := userValues[key]; ok && len(values) > 0 { - // Preserve all user-defined values for this key. for _, v := range values { - newLines = append(newLines, fmt.Sprintf("%s=%s", key, v)) + newLines = append(newLines, renderEnvValue(key, v)...) } } else { // Key missing in user config: keep template default and record it. @@ -209,7 +229,7 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er } extraKeys = append(extraKeys, key) for _, v := range values { - extraLines = append(extraLines, fmt.Sprintf("%s=%s", key, v)) + extraLines = append(extraLines, renderEnvValue(key, v)...) } } @@ -250,3 +270,92 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er result.Changed = true return result, newContent, originalContent, nil } + +func parseEnvValues(lines []string) (map[string][]envValue, []string, error) { + userValues := make(map[string][]envValue) + userKeyOrder := make([]string, 0) + + for i := 0; i < len(lines); i++ { + line := lines[i] + trimmed := strings.TrimSpace(line) + if utils.IsComment(trimmed) { + continue + } + + key, rawValue, ok := splitKeyValueRaw(line) + if !ok || key == "" { + continue + } + + if blockValueKeys[key] && trimmed == fmt.Sprintf("%s=\"", key) { + blockLines := make([]string, 0) + blockEnd, err := findClosingQuoteLine(lines, i+1) + if err != nil { + return nil, nil, fmt.Errorf("unterminated multi-line value for %s starting at line %d", key, i+1) + } + blockLines = append(blockLines, lines[i+1:blockEnd]...) + + if _, seen := userValues[key]; !seen { + userKeyOrder = append(userKeyOrder, key) + } + userValues[key] = append(userValues[key], envValue{kind: envValueKindBlock, blockLines: blockLines}) + + i = blockEnd + continue + } + + if _, seen := userValues[key]; !seen { + userKeyOrder = append(userKeyOrder, key) + } + userValues[key] = append(userValues[key], envValue{kind: envValueKindLine, rawValue: rawValue}) + } + + return userValues, userKeyOrder, nil +} + +func splitKeyValueRaw(line string) (string, string, bool) { + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + return "", "", false + } + + key := strings.TrimSpace(parts[0]) + valuePart := strings.TrimSpace(parts[1]) + + // Remove inline comments (but respect quotes) + value := valuePart + if strings.HasPrefix(valuePart, "\"") || strings.HasPrefix(valuePart, "'") { + quote := valuePart[0] + endIdx := strings.IndexByte(valuePart[1:], quote) + if endIdx >= 0 { + value = valuePart[:endIdx+2] + } + return key, value, true + } + + // Not quoted, remove everything after # + if idx := strings.Index(valuePart, "#"); idx >= 0 { + value = strings.TrimSpace(valuePart[:idx]) + } + + return key, value, true +} + +func findClosingQuoteLine(lines []string, start int) (int, error) { + for i := start; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "\"" { + return i, nil + } + } + return 0, fmt.Errorf("closing quote not found") +} + +func renderEnvValue(key string, value envValue) []string { + if value.kind == envValueKindBlock { + lines := []string{fmt.Sprintf("%s=\"", key)} + lines = append(lines, value.blockLines...) + lines = append(lines, "\"") + return lines + } + return []string{fmt.Sprintf("%s=%s", key, value.rawValue)} +} diff --git a/internal/config/upgrade_test.go b/internal/config/upgrade_test.go index 1f53b2d..822e4da 100644 --- a/internal/config/upgrade_test.go +++ b/internal/config/upgrade_test.go @@ -154,3 +154,81 @@ KEY3=default3 } }) } + +func TestUpgradeConfigPreservesBlockValues(t *testing.T) { + template := `BACKUP_PATH=/default/backup +LOG_PATH=/default/log +CUSTOM_BACKUP_PATHS=" +# /template/example +" +` + withTemplate(t, template, func() { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "backup.env") + userConfig := `BACKUP_PATH=/legacy/backup +CUSTOM_BACKUP_PATHS=" +/etc/custom.conf +" +` + if err := os.WriteFile(configPath, []byte(userConfig), 0600); err != nil { + t.Fatalf("failed to seed config: %v", err) + } + + result, err := UpgradeConfigFile(configPath) + if err != nil { + t.Fatalf("UpgradeConfigFile returned error: %v", err) + } + if !result.Changed { + t.Fatal("expected result.Changed=true when template has missing keys") + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read upgraded config: %v", err) + } + content := strings.ReplaceAll(string(data), "\r\n", "\n") + if !strings.Contains(content, "BACKUP_PATH=/legacy/backup") { + t.Fatalf("upgraded config missing preserved BACKUP_PATH:\n%s", content) + } + if !strings.Contains(content, "CUSTOM_BACKUP_PATHS=\"\n/etc/custom.conf\n\"\n") { + t.Fatalf("upgraded config missing preserved CUSTOM_BACKUP_PATHS block:\n%s", content) + } + if strings.Contains(content, "# /template/example") { + t.Fatalf("template example unexpectedly present in preserved block:\n%s", content) + } + }) +} + +func TestPlanUpgradeConfigTracksMissingBlockKey(t *testing.T) { + template := `BACKUP_PATH=/default/backup +CUSTOM_BACKUP_PATHS=" +# /template/example +" +` + withTemplate(t, template, func() { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "backup.env") + userConfig := "BACKUP_PATH=/legacy/backup\n" + if err := os.WriteFile(configPath, []byte(userConfig), 0600); err != nil { + t.Fatalf("failed to seed config: %v", err) + } + + result, err := PlanUpgradeConfigFile(configPath) + if err != nil { + t.Fatalf("PlanUpgradeConfigFile returned error: %v", err) + } + if !result.Changed { + t.Fatal("expected result.Changed=true when keys are missing") + } + found := false + for _, key := range result.MissingKeys { + if key == "CUSTOM_BACKUP_PATHS" { + found = true + break + } + } + if !found { + t.Fatalf("MissingKeys=%v; expected CUSTOM_BACKUP_PATHS", result.MissingKeys) + } + }) +} diff --git a/internal/orchestrator/.backup.lock b/internal/orchestrator/.backup.lock index 4c24bc1..2893e45 100644 --- a/internal/orchestrator/.backup.lock +++ b/internal/orchestrator/.backup.lock @@ -1,3 +1,3 @@ -pid=568974 +pid=17956 host=pve -time=2026-01-31T07:18:45+01:00 +time=2026-02-03T00:07:31+01:00