diff --git a/deploy/.goreleaser.yaml b/deploy/.goreleaser.yaml index dff7efb2f..8e31bc1f2 100644 --- a/deploy/.goreleaser.yaml +++ b/deploy/.goreleaser.yaml @@ -8,7 +8,7 @@ builds: - id: preflight main: ./cmd/preflight/main.go env: [CGO_ENABLED=0] - goos: [linux, darwin] + goos: [linux, darwin, windows] goarch: [amd64, arm, arm64] ignore: - goos: windows @@ -31,7 +31,7 @@ builds: - id: support-bundle main: ./cmd/troubleshoot/main.go env: [CGO_ENABLED=0] - goos: [linux, darwin] + goos: [linux, darwin, windows] goarch: [amd64, arm, arm64] ignore: - goos: windows diff --git a/internal/specs/specs.go b/internal/specs/specs.go index 1f3ccae19..578d7e256 100644 --- a/internal/specs/specs.go +++ b/internal/specs/specs.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "os" + "path/filepath" "reflect" "strings" "time" @@ -137,18 +138,27 @@ func LoadFromCLIArgs(ctx context.Context, client kubernetes.Interface, args []st rawSpecs = append(rawSpecs, spec) } } - } else if _, err := os.Stat(v); err == nil { + } else if v == "-" { + b, err := io.ReadAll(os.Stdin) + if err != nil { + return nil, types.NewExitCodeError(constants.EXIT_CODE_CATCH_ALL, err) + } + rawSpecs = append(rawSpecs, string(b)) + } else if filepath.IsAbs(v) { + // Check if it's an absolute path (handles both Unix and Windows paths) + // This must come before URL parsing because Windows paths like C:\... + // would be parsed as having scheme "c" by url.Parse b, err := os.ReadFile(v) if err != nil { return nil, types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, err) } - rawSpecs = append(rawSpecs, string(b)) - } else if v == "-" { - b, err := io.ReadAll(os.Stdin) + } else if _, err := os.Stat(v); err == nil { + b, err := os.ReadFile(v) if err != nil { - return nil, types.NewExitCodeError(constants.EXIT_CODE_CATCH_ALL, err) + return nil, types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, err) } + rawSpecs = append(rawSpecs, string(b)) } else { u, err := url.Parse(v) diff --git a/pkg/analyze/download.go b/pkg/analyze/download.go index 0aeaadad5..88d7c5b74 100644 --- a/pkg/analyze/download.go +++ b/pkg/analyze/download.go @@ -8,6 +8,7 @@ import ( "io/fs" "os" "path/filepath" + "runtime" getter "github.com/hashicorp/go-getter" "github.com/pkg/errors" @@ -91,7 +92,21 @@ func DownloadAndAnalyze(bundleURL string, analyzersSpec string) ([]*AnalyzeResul } func DownloadAndExtractSupportBundle(bundleURL string) (string, string, error) { - tmpDir, err := os.MkdirTemp("", "troubleshoot-k8s") + // Windows-only: Use working directory to avoid antivirus file locking + // Linux/macOS: Use system temp (original behavior) + var tempDir string + if runtime.GOOS == "windows" { + cwd, err := os.Getwd() + if err != nil { + tempDir = "" // Fallback to system temp + } else { + tempDir = cwd + } + } else { + tempDir = "" // Linux/macOS: system temp (unchanged) + } + + tmpDir, err := os.MkdirTemp(tempDir, "troubleshoot-k8s-") if err != nil { return "", "", errors.Wrap(err, "failed to create temp dir") } @@ -132,7 +147,16 @@ func downloadTroubleshootBundle(bundleURL string, destDir string) error { return errors.Wrap(err, "failed to get workdir") } - tmpDir, err := os.MkdirTemp("", "troubleshoot") + // Windows-only: Use working directory to avoid antivirus file locking + // Linux/macOS: Use system temp (original behavior) + var tempDir string + if runtime.GOOS == "windows" { + tempDir = pwd // Use working directory for Windows + } else { + tempDir = "" // Linux/macOS: system temp (unchanged) + } + + tmpDir, err := os.MkdirTemp(tempDir, "troubleshoot-") if err != nil { return errors.Wrap(err, "failed to create tmp dir") } diff --git a/pkg/collect/redact.go b/pkg/collect/redact.go index 095729de8..b6c5d4dfb 100644 --- a/pkg/collect/redact.go +++ b/pkg/collect/redact.go @@ -135,7 +135,9 @@ func RedactResult(bundlePath string, input CollectorResult, additionalRedactors return } - redacted, err := redact.Redact(reader, file, additionalRedactors) + // Normalize the path for redaction (convert Windows backslashes to forward slashes) + normalizedFile := filepath.ToSlash(file) + redacted, err := redact.Redact(reader, normalizedFile, additionalRedactors) if err != nil { errorCh <- errors.Wrap(err, "failed to redact io stream") return diff --git a/pkg/collect/result.go b/pkg/collect/result.go index 80e76faf1..474ff868c 100644 --- a/pkg/collect/result.go +++ b/pkg/collect/result.go @@ -9,7 +9,9 @@ import ( "os" "path" "path/filepath" + "runtime" "strings" + "time" "github.com/pkg/errors" "k8s.io/klog/v2" @@ -189,7 +191,20 @@ func (r CollectorResult) ReplaceResult(bundlePath string, relativePath string, r } // Create a temporary file in the same directory as the target file to prevent cross-device issues - tmpFile, err := os.CreateTemp("", "replace-") + var tmpFile *os.File + var err error + + if runtime.GOOS == "windows" { + // Windows-only: Use destination directory to avoid antivirus issues + destDir := filepath.Dir(filepath.Join(bundlePath, relativePath)) + if err := os.MkdirAll(destDir, 0755); err != nil { + return errors.Wrap(err, "failed to create destination directory") + } + tmpFile, err = os.CreateTemp(destDir, "replace-") + } else { + // Linux/macOS: EXACT original behavior - system temp + tmpFile, err = os.CreateTemp("", "replace-") + } if err != nil { return errors.Wrap(err, "failed to create temp file") } @@ -204,9 +219,21 @@ func (r CollectorResult) ReplaceResult(bundlePath string, relativePath string, r tmpFile.Close() // This rename should always be in /tmp, so no cross-partition copying will happen - err = os.Rename(tmpFile.Name(), filepath.Join(bundlePath, relativePath)) + if runtime.GOOS == "windows" { + // Windows-specific handling with delete-first approach + finalPath := filepath.Join(bundlePath, relativePath) + + // Delete target file first (Windows requirement to release locks) + os.Remove(finalPath) + + // Use copy+delete instead of rename (more reliable on Windows) + err = copyFileWindows(tmpFile.Name(), finalPath) + } else { + // Linux/macOS: EXACT original behavior - DO NOT CHANGE + err = os.Rename(tmpFile.Name(), filepath.Join(bundlePath, relativePath)) + } if err != nil { - return errors.Wrap(err, "failed to rename tmp file") + return errors.Wrap(err, "failed to replace file") } return nil @@ -417,3 +444,62 @@ func TarSupportBundleDir(bundlePath string, input CollectorResult, outputFilenam // Is this used anywhere external anyway? return input.ArchiveBundle(bundlePath, outputFilename) } + +// copyFileWindows performs safer file replacement for Windows +// It writes to a temporary file first to avoid data loss if copy fails +func copyFileWindows(src, dst string) error { + // Step 1: Write to temp file with unique name (not touching original) + tmpDst := dst + ".tmp" + + srcFile, err := os.Open(src) + if err != nil { + return err + } + + tmpFile, err := os.Create(tmpDst) + if err != nil { + srcFile.Close() + return err + } + + // Copy data to temp file + _, err = io.Copy(tmpFile, srcFile) + + // Close both files explicitly + srcFile.Close() + tmpFile.Close() + + if err != nil { + // Copy failed - original file is still intact! + os.Remove(tmpDst) + return err + } + + // Step 2: Replace original with temp (with retry for file locking) + // Try up to 5 times with increasing delays for antivirus/locking issues + maxRetries := 5 + for attempt := 0; attempt < maxRetries; attempt++ { + // Delete original to release locks + os.Remove(dst) + + // Rename temp to final + err = os.Rename(tmpDst, dst) + if err == nil { + // Success! Clean up source + os.Remove(src) + return nil + } + + // If not last attempt, wait with exponential backoff + if attempt < maxRetries-1 { + delay := time.Duration(10*(attempt+1)) * time.Millisecond + time.Sleep(delay) + } + } + + // All retries failed - clean up dst temp file but keep src + // We intentionally leave src (the source temp file) to prevent data loss + // It will be cleaned up when the parent temp directory is removed + os.Remove(tmpDst) + return errors.Wrap(err, "failed to replace file after retries") +} diff --git a/pkg/redact/redact.go b/pkg/redact/redact.go index 4242b0ebc..85d69f13e 100644 --- a/pkg/redact/redact.go +++ b/pkg/redact/redact.go @@ -4,6 +4,7 @@ import ( "bufio" "fmt" "io" + "path/filepath" "regexp" "sync" @@ -170,6 +171,8 @@ func buildAdditionalRedactors(path string, redacts []*troubleshootv1beta2.Redact } func redactMatchesPath(path string, redact *troubleshootv1beta2.Redact) (bool, error) { + normalizedPath := filepath.ToSlash(path) + if redact.FileSelector.File == "" && len(redact.FileSelector.Files) == 0 { return true, nil } @@ -177,7 +180,8 @@ func redactMatchesPath(path string, redact *troubleshootv1beta2.Redact) (bool, e globs := []glob.Glob{} if redact.FileSelector.File != "" { - newGlob, err := glob.Compile(redact.FileSelector.File, '/') + normalizedGlob := filepath.ToSlash(redact.FileSelector.File) + newGlob, err := glob.Compile(normalizedGlob, '/') if err != nil { return false, errors.Wrapf(err, "invalid file glob string %q", redact.FileSelector.File) } @@ -185,7 +189,8 @@ func redactMatchesPath(path string, redact *troubleshootv1beta2.Redact) (bool, e } for i, fileGlobString := range redact.FileSelector.Files { - newGlob, err := glob.Compile(fileGlobString, '/') + normalizedGlob := filepath.ToSlash(fileGlobString) + newGlob, err := glob.Compile(normalizedGlob, '/') if err != nil { return false, errors.Wrapf(err, "invalid file glob string %d %q", i, fileGlobString) } @@ -193,7 +198,7 @@ func redactMatchesPath(path string, redact *troubleshootv1beta2.Redact) (bool, e } for _, thisGlob := range globs { - if thisGlob.Match(path) { + if thisGlob.Match(normalizedPath) { return true, nil } } diff --git a/pkg/redact/yaml.go b/pkg/redact/yaml.go index 7c8b26c63..6ee0ce82d 100644 --- a/pkg/redact/yaml.go +++ b/pkg/redact/yaml.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "io" + "path" "path/filepath" "strconv" "strings" @@ -22,20 +23,22 @@ type YamlRedactor struct { func NewYamlRedactor(yamlPath, filePath, name string) *YamlRedactor { pathComponents := strings.Split(yamlPath, ".") - return &YamlRedactor{maskPath: pathComponents, filePath: filePath, redactName: name} + return &YamlRedactor{maskPath: pathComponents, filePath: filepath.ToSlash(filePath), redactName: name} } -func (r *YamlRedactor) Redact(input io.Reader, path string) io.Reader { +func (r *YamlRedactor) Redact(input io.Reader, targetPath string) io.Reader { if r.filePath != "" { - match, err := filepath.Match(r.filePath, path) + normalizedTarget := filepath.ToSlash(targetPath) + match, err := path.Match(r.filePath, normalizedTarget) if err != nil { - klog.Errorf("Failed to match %q and %q: %v", r.filePath, path, err) + klog.Errorf("Failed to match %q and %q: %v", r.filePath, normalizedTarget, err) return input } if !match { return input } } + r.foundMatch = false reader, writer := io.Pipe() go func() { var err error @@ -59,7 +62,8 @@ func (r *YamlRedactor) Redact(input io.Reader, path string) io.Reader { return } - newYaml := r.redactYaml(yamlInterface, r.maskPath) + processedPath := filepath.ToSlash(targetPath) + newYaml := r.redactYaml(yamlInterface, r.maskPath, processedPath) if !r.foundMatch { // no match found, so make no changes buf := bytes.NewBuffer(doc) @@ -80,14 +84,14 @@ func (r *YamlRedactor) Redact(input io.Reader, path string) io.Reader { RedactorName: r.redactName, CharactersRemoved: len(doc) - len(newBytes), Line: 0, // line 0 because we have no way to tell what line was impacted - File: path, + File: targetPath, IsDefaultRedactor: r.isDefault, }) }() return reader } -func (r *YamlRedactor) redactYaml(in interface{}, path []string) interface{} { +func (r *YamlRedactor) redactYaml(in interface{}, path []string, targetPath string) interface{} { if len(path) == 0 { r.foundMatch = true @@ -97,7 +101,7 @@ func (r *YamlRedactor) redactYaml(in interface{}, path []string) interface{} { // Convert the value to string and tokenize it if valueStr, ok := in.(string); ok && valueStr != "" { context := r.redactName - return tokenizer.TokenizeValueWithPath(valueStr, context, r.filePath) + return tokenizer.TokenizeValueWithPath(valueStr, context, targetPath) } } @@ -109,7 +113,7 @@ func (r *YamlRedactor) redactYaml(in interface{}, path []string) interface{} { if path[0] == "*" { var newArr []interface{} for _, child := range typed { - newChild := r.redactYaml(child, path[1:]) + newChild := r.redactYaml(child, path[1:], targetPath) newArr = append(newArr, newChild) } return newArr @@ -121,7 +125,7 @@ func (r *YamlRedactor) redactYaml(in interface{}, path []string) interface{} { } if len(typed) > pathIdx { child := typed[pathIdx] - typed[pathIdx] = r.redactYaml(child, path[1:]) + typed[pathIdx] = r.redactYaml(child, path[1:], targetPath) return typed } return typed @@ -129,14 +133,14 @@ func (r *YamlRedactor) redactYaml(in interface{}, path []string) interface{} { if path[0] == "*" && len(typed) > 0 { newMap := map[interface{}]interface{}{} for key, child := range typed { - newMap[key] = r.redactYaml(child, path[1:]) + newMap[key] = r.redactYaml(child, path[1:], targetPath) } return newMap } child, ok := typed[path[0]] if ok { - newChild := r.redactYaml(child, path[1:]) + newChild := r.redactYaml(child, path[1:], targetPath) typed[path[0]] = newChild } return typed