Files
helmfile/pkg/filesystem/fs.go
T
yxxhero fc31dbfc5e fix: eliminate race condition in rewriteChartDependencies (#2541)
* fix: eliminate race condition in rewriteChartDependencies by copying chart before modifying

Instead of modifying the original Chart.yaml in-place (which causes race
conditions when multiple releases reference the same local chart), copy the
chart to a temporary directory and rewrite the copy's dependencies. This
eliminates the need for per-chart mutex locks and prevents file corruption
when concurrent goroutines process releases sharing the same local chart.

Fixes #2502

Signed-off-by: yxxhero <aiopsclub@163.com>

* fix: address PR review comments for rewriteChartDependencies

- Handle non-NotExist errors from st.fs.Stat to surface permission/IO failures
- Reword function doc to clarify temp copy is conditional on rewrite being needed
- Assert rewrittenPath vs tempDir based on expectModified in test table

Signed-off-by: yxxhero <aiopsclub@163.com>

* test: add integration test for issue #2502 race condition with shared local chart

Signed-off-by: yxxhero <aiopsclub@163.com>

* fix: separate environments and releases with --- in helmfile.yaml

Signed-off-by: yxxhero <aiopsclub@163.com>

* fix: correct file:// path and remove --skip-deps for dependency build

Signed-off-by: yxxhero <aiopsclub@163.com>

* fix: correct file:// dependency path (5 levels up to test/integration/)

Signed-off-by: yxxhero <aiopsclub@163.com>

* fix: remove output validation from race condition test

Signed-off-by: yxxhero <aiopsclub@163.com>

* fix: assert WriteFile/MkdirTemp/RemoveAll/CopyDir in DefaultFileSystem test

Signed-off-by: yxxhero <aiopsclub@163.com>

* fix: add strategicMergePatches to trigger chartify in race condition test

Signed-off-by: yxxhero <aiopsclub@163.com>

* fix: scope test values under raw subchart and align ConfigMap name with strategic merge patches

The race condition test values.yaml had templates at the top level instead
of scoped under the raw subchart key, causing helm template to produce no
output and chartify's ReplaceWithRendered to fail with an empty
helmx.1.rendered directory. Also align the ConfigMap name to match the
strategicMergePatches target.

Signed-off-by: yxxhero <aiopsclub@163.com>

---------

Signed-off-by: yxxhero <aiopsclub@163.com>
2026-04-20 10:15:47 +08:00

210 lines
5.1 KiB
Go

package filesystem
import (
"io"
"io/fs"
"os"
"path/filepath"
"time"
copyDir "github.com/otiai10/copy"
)
type fileStat struct {
name string
size int64
mode fs.FileMode
modTime time.Time
}
func (fs fileStat) Name() string { return fs.name }
func (fs fileStat) Size() int64 { return fs.size }
func (fs fileStat) Mode() fs.FileMode { return fs.mode }
func (fs fileStat) ModTime() time.Time { return fs.modTime }
func (fs fileStat) IsDir() bool { return fs.mode.IsDir() }
func (fs fileStat) Sys() any { return nil }
type FileSystem struct {
ReadFile func(string) ([]byte, error)
ReadDir func(string) ([]fs.DirEntry, error)
WriteFile func(string, []byte, fs.FileMode) error
DeleteFile func(string) error
MkdirTemp func(string, string) (string, error)
RemoveAll func(string) error
FileExists func(string) (bool, error)
Glob func(string) ([]string, error)
FileExistsAt func(string) bool
DirectoryExistsAt func(string) bool
Dir func(string) string
Stat func(string) (os.FileInfo, error)
Getwd func() (string, error)
Chdir func(string) error
Abs func(string) (string, error)
EvalSymlinks func(string) (string, error)
CopyDir func(src, dst string) error
}
func DefaultFileSystem() *FileSystem {
dfs := FileSystem{
ReadDir: os.ReadDir,
WriteFile: os.WriteFile,
DeleteFile: os.Remove,
MkdirTemp: os.MkdirTemp,
RemoveAll: os.RemoveAll,
Glob: filepath.Glob,
Getwd: os.Getwd,
Chdir: os.Chdir,
EvalSymlinks: filepath.EvalSymlinks,
Dir: filepath.Dir,
}
dfs.Stat = dfs.stat
dfs.ReadFile = dfs.readFile
dfs.FileExistsAt = dfs.fileExistsAtDefault
dfs.DirectoryExistsAt = dfs.directoryExistsDefault
dfs.FileExists = dfs.fileExistsDefault
dfs.Abs = dfs.absDefault
dfs.CopyDir = dfs.copyDirDefault
return &dfs
}
func FromFileSystem(params FileSystem) *FileSystem {
dfs := DefaultFileSystem()
if params.ReadFile != nil {
dfs.ReadFile = params.ReadFile
}
if params.ReadDir != nil {
dfs.ReadDir = params.ReadDir
}
if params.DeleteFile != nil {
dfs.DeleteFile = params.DeleteFile
}
if params.FileExists != nil {
dfs.FileExists = params.FileExists
}
if params.Glob != nil {
dfs.Glob = params.Glob
}
if params.FileExistsAt != nil {
dfs.FileExistsAt = params.FileExistsAt
}
if params.DirectoryExistsAt != nil {
dfs.DirectoryExistsAt = params.DirectoryExistsAt
}
if params.Stat != nil {
dfs.Stat = params.Stat
}
if params.Getwd != nil {
dfs.Getwd = params.Getwd
}
if params.Chdir != nil {
dfs.Chdir = params.Chdir
}
if params.Abs != nil {
dfs.Abs = params.Abs
}
if params.EvalSymlinks != nil {
dfs.EvalSymlinks = params.EvalSymlinks
}
if params.Dir != nil {
dfs.Dir = params.Dir
}
if params.CopyDir != nil {
dfs.CopyDir = params.CopyDir
}
if params.WriteFile != nil {
dfs.WriteFile = params.WriteFile
}
if params.MkdirTemp != nil {
dfs.MkdirTemp = params.MkdirTemp
}
if params.RemoveAll != nil {
dfs.RemoveAll = params.RemoveAll
}
return dfs
}
func (filesystem *FileSystem) stat(name string) (os.FileInfo, error) {
if name == "-" {
return fileStat{mode: 0}, nil
}
return os.Stat(name)
}
func (filesystem *FileSystem) readFile(name string) ([]byte, error) {
if name == "-" {
return io.ReadAll(os.Stdin)
}
return os.ReadFile(name)
}
func (filesystem *FileSystem) fileExistsAtDefault(path string) bool {
path, err := filesystem.resolveSymlinks(path)
if err != nil {
return false
}
fileInfo, err := filesystem.Stat(path)
return err == nil && fileInfo.Mode().IsRegular()
}
func (filesystem *FileSystem) fileExistsDefault(path string) (bool, error) {
path, err := filesystem.resolveSymlinks(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
_, err = filesystem.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return true, nil
}
func (filesystem *FileSystem) directoryExistsDefault(path string) bool {
path, err := filesystem.resolveSymlinks(path)
if err != nil {
return false
}
fileInfo, err := filesystem.Stat(path)
return err == nil && fileInfo.Mode().IsDir()
}
func (filesystem *FileSystem) resolveSymlinks(path string) (string, error) {
if !filepath.IsAbs(path) && !filepath.IsLocal(path) {
basePath, err := filesystem.Getwd()
if err != nil {
return "", err
}
basePath, err = filesystem.EvalSymlinks(basePath)
if err != nil {
return "", err
}
path, err := filesystem.EvalSymlinks(filepath.Join(basePath, path))
if err != nil {
return "", err
}
return path, nil
}
return path, nil
}
func (filesystem *FileSystem) absDefault(path string) (string, error) {
path, err := filesystem.resolveSymlinks(path)
if err != nil {
return "", err
}
return filepath.Abs(path)
}
// copyDirDefault recursively copies a directory tree, preserving permissions.
func (filesystem *FileSystem) copyDirDefault(src string, dst string) error {
return copyDir.Copy(src, dst, copyDir.Options{Sync: true})
}