From 2aaf7f0de37c70a3504b5834c91a85affa83df77 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Tue, 3 Feb 2026 21:27:16 +0100 Subject: [PATCH 1/2] Improve env config parsing and upgrade logic Refactor config parsing and upgrade flow to better handle comments, casing and multiline values. - setEnvValue: skip commented lines, preserve indentation and inline comments when replacing values. - parseEnvFile/parseEnvValues: ignore comment-only lines inside multi-line block values; return comment text and a caseMap (UPPER->original) for case-insensitive matching; add comment storage to envValue. - splitKeyValueRaw: return parsed inline comment, handle legacy "export " prefixes, and more robustly extract quoted/unquoted values and trailing comments. - computeConfigUpgrade: use case-insensitive lookups via caseMap, track processed user keys, preserve user key casing for extras, count preserved values correctly, and detect no-op upgrades by comparing newContent with originalContent. - renderEnvValue: include preserved inline comments when rendering single-line values. - Added import of utils.IsComment where needed. Also updated internal/orchestrator/.backup.lock (pid/time) as part of the changeset. --- cmd/proxsave/config_helpers.go | 42 +++++++++--- internal/config/config.go | 4 +- internal/config/upgrade.go | 105 ++++++++++++++++++++++------- internal/orchestrator/.backup.lock | 4 +- 4 files changed, 118 insertions(+), 37 deletions(-) diff --git a/cmd/proxsave/config_helpers.go b/cmd/proxsave/config_helpers.go index 9644de0..e5dca47 100644 --- a/cmd/proxsave/config_helpers.go +++ b/cmd/proxsave/config_helpers.go @@ -5,6 +5,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/tis24dev/proxsave/pkg/utils" ) type configStatusLogger interface { @@ -47,27 +49,44 @@ func ensureConfigExists(path string, logger configStatusLogger) error { } func setEnvValue(template, key, value string) string { - target := key + "=" lines := strings.Split(template, "\n") replaced := false for i, line := range lines { trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, target) { + if utils.IsComment(trimmed) { + continue + } + + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) >= 1 && strings.TrimSpace(parts[0]) == key { + // Found match! + // We try to preserve the indentation and comments from the original line. leadingLen := len(line) - len(strings.TrimLeft(line, " \t")) leading := "" if leadingLen > 0 { leading = line[:leadingLen] } - rest := line[leadingLen:] - commentSpacing := "" + + // Extract comment if present in the original line logic + // The original logic extracted comment from 'rest' after target match. + // Here we can re-parse the line specifically for comment. comment := "" - if idx := strings.Index(rest, "#"); idx >= 0 { - before := rest[:idx] - comment = rest[idx:] - trimmedBefore := strings.TrimRight(before, " \t") - commentSpacing = before[len(trimmedBefore):] - rest = trimmedBefore + commentSpacing := "" + + if idx := strings.Index(line, "#"); idx >= 0 { + // Verify # is not part of the key or value? + // Assuming standard comment + commentPart := line[idx:] + // Ensure it's not inside quotes? The original logic didn't check quotes carefully but let's be safe(r). + // For setEnvValue we are replacing the value, so we just want to keep the comment at the end. + comment = commentPart + + // Find spacing before comment + beforeComment := line[:idx] + trimmedBefore := strings.TrimRight(beforeComment, " \t") + commentSpacing = beforeComment[len(trimmedBefore):] } + newLine := leading + key + "=" + value if comment != "" { spacing := commentSpacing @@ -78,6 +97,9 @@ func setEnvValue(template, key, value string) string { } lines[i] = newLine replaced = true + // We stop after first match? Original code didn't break, but typically keys are unique. + // Let's break to avoid multiple replacements if file is messy (or continue to fix all?) + // Original didn't break. Let's not break to match behavior. } } if !replaced { diff --git a/internal/config/config.go b/internal/config/config.go index a566c72..171784c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1228,7 +1228,9 @@ func parseEnvFile(path string) (map[string]string, error) { terminated = true break } - blockLines = append(blockLines, next) + if !utils.IsComment(strings.TrimSpace(next)) { + blockLines = append(blockLines, next) + } } if !terminated { return nil, fmt.Errorf("unterminated multi-line value for %s", key) diff --git a/internal/config/upgrade.go b/internal/config/upgrade.go index 2b59107..76774cc 100644 --- a/internal/config/upgrade.go +++ b/internal/config/upgrade.go @@ -20,6 +20,7 @@ type envValue struct { kind envValueKind rawValue string blockLines []string + comment string } // UpgradeResult describes the outcome of a configuration upgrade. @@ -155,7 +156,7 @@ 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, userKeyOrder, err := parseEnvValues(originalLines) + userValues, userKeyOrder, caseMap, err := parseEnvValues(originalLines) if err != nil { return result, "", originalContent, fmt.Errorf("failed to parse config %s: %w", configPath, err) } @@ -168,6 +169,7 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er templateKeys := make(map[string]bool) missingKeys := make([]string, 0) newLines := make([]string, 0, len(templateLines)+len(userValues)) + processedUserKeys := make(map[string]bool) // Track which user keys (original case) have been used for i := 0; i < len(templateLines); i++ { line := templateLines[i] @@ -177,7 +179,7 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er continue } - key, _, ok := splitKeyValueRaw(line) + key, _, _, ok := splitKeyValueRaw(line) if !ok || key == "" { newLines = append(newLines, line) continue @@ -185,14 +187,27 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er templateKeys[key] = true + // Logic to find the user's values for this key. + // 1. Try exact match + targetUserKey := key + if _, ok := userValues[key]; !ok { + // 2. Try case-insensitive match + if mappedKey, ok := caseMap[strings.ToUpper(key)]; ok { + targetUserKey = mappedKey + } + } + + // Handle block values 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 { + if values, ok := userValues[targetUserKey]; ok && len(values) > 0 { + processedUserKeys[targetUserKey] = true for _, v := range values { + // Use TEMPLATE Key casing to enforce consistency newLines = append(newLines, renderEnvValue(key, v)...) } } else { @@ -204,8 +219,10 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er continue } - if values, ok := userValues[key]; ok && len(values) > 0 { + if values, ok := userValues[targetUserKey]; ok && len(values) > 0 { + processedUserKeys[targetUserKey] = true for _, v := range values { + // Use TEMPLATE Key casing to enforce consistency newLines = append(newLines, renderEnvValue(key, v)...) } } else { @@ -220,15 +237,28 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er extraLines := make([]string, 0) for _, key := range userKeyOrder { + if processedUserKeys[key] { + continue + } + // If exact match was in template keys (should have been processed above), skip if templateKeys[key] { continue } + // If case-insensitive match was in template keys (should have been processed), skip + // Check by upper casing the user key and seeing if it exists in templateKeys? + // But wait, templateKeys stores exact keys. + // If user has "Backup_Enabled", and template has "BACKUP_ENABLED". + // We processed "BACKUP_ENABLED", found "Backup_Enabled" via caseMap, and marked "Backup_Enabled" as processed. + // So `processedUserKeys["Backup_Enabled"]` is true. We skip. + // Correct. + values := userValues[key] if len(values) == 0 { continue } extraKeys = append(extraKeys, key) for _, v := range values { + // Preserve USER's original key casing for extras extraLines = append(extraLines, renderEnvValue(key, v)...) } } @@ -243,20 +273,17 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er newLines = append(newLines, extraLines...) } - // Count preserved values: key=value pairs coming from user config for - // keys that exist in the template. + // Count preserved values preserved := 0 - for key, values := range userValues { - if templateKeys[key] { - preserved += len(values) - } + for key := range processedUserKeys { + preserved += len(userValues[key]) } // If nothing changed (no missing keys and no extras), we can return early. - if len(missingKeys) == 0 && len(extraKeys) == 0 { - result.PreservedValues = preserved - return result, "", originalContent, nil - } + // BUT checking "nothing changed" is harder now because we might have renamed keys. + // If we renamed a key, the content CHANGED. + // So we should compare normalized content? + // Or just assume if we parsed everything and re-rendered, and it matches original string... newContent := strings.Join(newLines, lineEnding) // Preserve trailing newline if template had one. @@ -264,6 +291,12 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er newContent += lineEnding } + if newContent == string(originalContent) { + result.Changed = false + result.PreservedValues = preserved + return result, "", originalContent, nil + } + result.MissingKeys = missingKeys result.ExtraKeys = extraKeys result.PreservedValues = preserved @@ -271,9 +304,10 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er return result, newContent, originalContent, nil } -func parseEnvValues(lines []string) (map[string][]envValue, []string, error) { +func parseEnvValues(lines []string) (map[string][]envValue, []string, map[string]string, error) { userValues := make(map[string][]envValue) userKeyOrder := make([]string, 0) + caseMap := make(map[string]string) // UPPER -> original for i := 0; i < len(lines); i++ { line := lines[i] @@ -282,7 +316,7 @@ func parseEnvValues(lines []string) (map[string][]envValue, []string, error) { continue } - key, rawValue, ok := splitKeyValueRaw(line) + key, rawValue, comment, ok := splitKeyValueRaw(line) if !ok || key == "" { continue } @@ -291,12 +325,13 @@ func parseEnvValues(lines []string) (map[string][]envValue, []string, error) { 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) + return nil, 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) + caseMap[strings.ToUpper(key)] = key } userValues[key] = append(userValues[key], envValue{kind: envValueKindBlock, blockLines: blockLines}) @@ -306,39 +341,57 @@ func parseEnvValues(lines []string) (map[string][]envValue, []string, error) { if _, seen := userValues[key]; !seen { userKeyOrder = append(userKeyOrder, key) + caseMap[strings.ToUpper(key)] = key } - userValues[key] = append(userValues[key], envValue{kind: envValueKindLine, rawValue: rawValue}) + userValues[key] = append(userValues[key], envValue{kind: envValueKindLine, rawValue: rawValue, comment: comment}) } - return userValues, userKeyOrder, nil + return userValues, userKeyOrder, caseMap, nil } -func splitKeyValueRaw(line string) (string, string, bool) { +func splitKeyValueRaw(line string) (string, string, string, bool) { parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { - return "", "", false + return "", "", "", false } key := strings.TrimSpace(parts[0]) + // Handle legacy "export KEY=VALUE" lines + if strings.HasPrefix(key, "export ") { + key = strings.TrimSpace(strings.TrimPrefix(key, "export ")) + } + // Also handle tab separation just in case "export\tKEY" + if strings.HasPrefix(key, "export\t") { + key = strings.TrimSpace(strings.TrimPrefix(key, "export\t")) + } + valuePart := strings.TrimSpace(parts[1]) // Remove inline comments (but respect quotes) value := valuePart + comment := "" + if strings.HasPrefix(valuePart, "\"") || strings.HasPrefix(valuePart, "'") { quote := valuePart[0] endIdx := strings.IndexByte(valuePart[1:], quote) if endIdx >= 0 { value = valuePart[:endIdx+2] + // Check for comment after quote + rest := valuePart[endIdx+2:] + if idx := strings.Index(rest, "#"); idx >= 0 { + comment = strings.TrimSpace(rest[idx:]) + } } - return key, value, true + return key, value, comment, true } // Not quoted, remove everything after # if idx := strings.Index(valuePart, "#"); idx >= 0 { value = strings.TrimSpace(valuePart[:idx]) + comment = strings.TrimSpace(valuePart[idx:]) } - return key, value, true + return key, value, comment, true } func findClosingQuoteLine(lines []string, start int) (int, error) { @@ -357,5 +410,9 @@ func renderEnvValue(key string, value envValue) []string { lines = append(lines, "\"") return lines } - return []string{fmt.Sprintf("%s=%s", key, value.rawValue)} + line := fmt.Sprintf("%s=%s", key, value.rawValue) + if value.comment != "" { + line += " " + value.comment + } + return []string{line} } diff --git a/internal/orchestrator/.backup.lock b/internal/orchestrator/.backup.lock index 2893e45..fffdac2 100644 --- a/internal/orchestrator/.backup.lock +++ b/internal/orchestrator/.backup.lock @@ -1,3 +1,3 @@ -pid=17956 +pid=99930 host=pve -time=2026-02-03T00:07:31+01:00 +time=2026-02-03T19:42:03+01:00 From da176df22d3f1dab77cad0a2bd59cecdac83d7bd Mon Sep 17 00:00:00 2001 From: tis24dev Date: Tue, 3 Feb 2026 22:02:42 +0100 Subject: [PATCH 2/2] Report config upgrade warnings Add non-fatal warnings support to the config upgrade flow. Introduce a Warnings []string field on UpgradeResult and propagate warning messages from parseEnvValues through computeConfigUpgrade. Detect and warn about duplicate template keys that differ only by case, case-collision between user keys and template keys, and ignored non-KEY=VALUE lines; preserve behavior for multi-line values. Surface these warnings in the CLI (cmd/proxsave/main.go and cmd/proxsave/upgrade.go) so they are printed during plan/upgrade operations. Update parseEnvValues signature to return case conflict info and warnings, and add tests verifying case-collision and ignored-line warnings. --- cmd/proxsave/main.go | 12 +++++++++ cmd/proxsave/upgrade.go | 8 ++++++ internal/config/upgrade.go | 43 ++++++++++++++++++++++++++----- internal/config/upgrade_test.go | 45 +++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 6 deletions(-) diff --git a/cmd/proxsave/main.go b/cmd/proxsave/main.go index d7e9c34..0daa06d 100644 --- a/cmd/proxsave/main.go +++ b/cmd/proxsave/main.go @@ -334,6 +334,12 @@ func run() int { 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() @@ -437,6 +443,12 @@ func run() int { 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() diff --git a/cmd/proxsave/upgrade.go b/cmd/proxsave/upgrade.go index 9a09107..6a35f7b 100644 --- a/cmd/proxsave/upgrade.go +++ b/cmd/proxsave/upgrade.go @@ -589,6 +589,14 @@ func printUpgradeFooter(upgradeErr error, version, configPath, baseDir, telegram } } + if cfgUpgradeResult != nil && len(cfgUpgradeResult.Warnings) > 0 { + fmt.Printf("Configuration warnings (%d):\n", len(cfgUpgradeResult.Warnings)) + for _, warning := range cfgUpgradeResult.Warnings { + fmt.Printf(" - %s\n", warning) + } + fmt.Println() + } + fmt.Println("Next steps:") if strings.TrimSpace(configPath) != "" { fmt.Printf("1. Verify configuration: %s\n", configPath) diff --git a/internal/config/upgrade.go b/internal/config/upgrade.go index 76774cc..ac736a4 100644 --- a/internal/config/upgrade.go +++ b/internal/config/upgrade.go @@ -33,6 +33,8 @@ type UpgradeResult struct { // ExtraKeys are keys that were present in the user's config but not in the // template. They are preserved in a dedicated "Custom keys" section. ExtraKeys []string + // Warnings includes non-fatal parsing or merge issues detected while upgrading. + Warnings []string // PreservedValues is the number of existing key=value pairs from the user's // configuration that were kept during the merge for keys present in the // template. @@ -156,7 +158,7 @@ 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, userKeyOrder, caseMap, err := parseEnvValues(originalLines) + userValues, userKeyOrder, caseMap, caseConflicts, warnings, err := parseEnvValues(originalLines) if err != nil { return result, "", originalContent, fmt.Errorf("failed to parse config %s: %w", configPath, err) } @@ -167,6 +169,7 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er templateLines := strings.Split(normalizedTemplate, "\n") templateKeys := make(map[string]bool) + templateKeyByUpper := make(map[string]string) missingKeys := make([]string, 0) newLines := make([]string, 0, len(templateLines)+len(userValues)) processedUserKeys := make(map[string]bool) // Track which user keys (original case) have been used @@ -186,6 +189,14 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er } templateKeys[key] = true + upperKey := strings.ToUpper(key) + if existing, ok := templateKeyByUpper[upperKey]; ok { + if existing != key { + warnings = append(warnings, fmt.Sprintf("Template contains duplicate keys differing only by case: %q and %q", existing, key)) + } + } else { + templateKeyByUpper[upperKey] = key + } // Logic to find the user's values for this key. // 1. Try exact match @@ -252,6 +263,13 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er // So `processedUserKeys["Backup_Enabled"]` is true. We skip. // Correct. + upperKey := strings.ToUpper(key) + if templateKey, ok := templateKeyByUpper[upperKey]; ok && templateKey != key { + if caseConflicts == nil || !caseConflicts[upperKey] { + warnings = append(warnings, fmt.Sprintf("Key %q differs only by case from template key %q; preserved as custom entry", key, templateKey)) + } + } + values := userValues[key] if len(values) == 0 { continue @@ -293,6 +311,7 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er if newContent == string(originalContent) { result.Changed = false + result.Warnings = warnings result.PreservedValues = preserved return result, "", originalContent, nil } @@ -300,14 +319,17 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er result.MissingKeys = missingKeys result.ExtraKeys = extraKeys result.PreservedValues = preserved + result.Warnings = warnings result.Changed = true return result, newContent, originalContent, nil } -func parseEnvValues(lines []string) (map[string][]envValue, []string, map[string]string, error) { +func parseEnvValues(lines []string) (map[string][]envValue, []string, map[string]string, map[string]bool, []string, error) { userValues := make(map[string][]envValue) userKeyOrder := make([]string, 0) caseMap := make(map[string]string) // UPPER -> original + caseConflicts := make(map[string]bool) + warnings := make([]string, 0) for i := 0; i < len(lines); i++ { line := lines[i] @@ -318,20 +340,30 @@ func parseEnvValues(lines []string) (map[string][]envValue, []string, map[string key, rawValue, comment, ok := splitKeyValueRaw(line) if !ok || key == "" { + if trimmed != "" { + warnings = append(warnings, fmt.Sprintf("Ignored line %d: not a KEY=VALUE entry", i+1)) + } continue } + upperKey := strings.ToUpper(key) + if existing, ok := caseMap[upperKey]; ok && existing != key { + caseConflicts[upperKey] = true + warnings = append(warnings, fmt.Sprintf("Duplicate keys differ only by case: %q and %q (using last occurrence %q)", existing, key, key)) + } + + caseMap[upperKey] = key + if blockValueKeys[key] && trimmed == fmt.Sprintf("%s=\"", key) { blockLines := make([]string, 0) blockEnd, err := findClosingQuoteLine(lines, i+1) if err != nil { - return nil, nil, nil, fmt.Errorf("unterminated multi-line value for %s starting at line %d", key, i+1) + return nil, nil, nil, 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) - caseMap[strings.ToUpper(key)] = key } userValues[key] = append(userValues[key], envValue{kind: envValueKindBlock, blockLines: blockLines}) @@ -341,12 +373,11 @@ func parseEnvValues(lines []string) (map[string][]envValue, []string, map[string if _, seen := userValues[key]; !seen { userKeyOrder = append(userKeyOrder, key) - caseMap[strings.ToUpper(key)] = key } userValues[key] = append(userValues[key], envValue{kind: envValueKindLine, rawValue: rawValue, comment: comment}) } - return userValues, userKeyOrder, caseMap, nil + return userValues, userKeyOrder, caseMap, caseConflicts, warnings, nil } func splitKeyValueRaw(line string) (string, string, string, bool) { diff --git a/internal/config/upgrade_test.go b/internal/config/upgrade_test.go index 822e4da..9b9eed4 100644 --- a/internal/config/upgrade_test.go +++ b/internal/config/upgrade_test.go @@ -232,3 +232,48 @@ CUSTOM_BACKUP_PATHS=" } }) } + +func TestPlanUpgradeConfigWarnsOnCaseCollision(t *testing.T) { + template := "BACKUP_PATH=/default/backup\n" + withTemplate(t, template, func() { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "backup.env") + content := "backup_path=/lower\nBACKUP_PATH=/upper\n" + if err := os.WriteFile(configPath, []byte(content), 0600); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + result, err := PlanUpgradeConfigFile(configPath) + if err != nil { + t.Fatalf("PlanUpgradeConfigFile returned error: %v", err) + } + if len(result.ExtraKeys) != 1 || result.ExtraKeys[0] != "backup_path" { + t.Fatalf("ExtraKeys = %v; want [backup_path]", result.ExtraKeys) + } + warnings := strings.Join(result.Warnings, "\n") + if !strings.Contains(warnings, "Duplicate keys differ only by case") { + t.Fatalf("expected case-collision warning, got: %s", warnings) + } + }) +} + +func TestPlanUpgradeConfigWarnsOnIgnoredLine(t *testing.T) { + template := "BACKUP_PATH=/default/backup\n" + withTemplate(t, template, func() { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "backup.env") + content := "BACKUP_PATH=/legacy\nNOT_A_KEY\n" + if err := os.WriteFile(configPath, []byte(content), 0600); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + result, err := PlanUpgradeConfigFile(configPath) + if err != nil { + t.Fatalf("PlanUpgradeConfigFile returned error: %v", err) + } + warnings := strings.Join(result.Warnings, "\n") + if !strings.Contains(warnings, "Ignored line 2") { + t.Fatalf("expected ignored-line warning, got: %s", warnings) + } + }) +}