diff --git a/pkg/operator/staticpod/installerpod/cmd.go b/pkg/operator/staticpod/installerpod/cmd.go index 5d065b40fc..31afadff86 100644 --- a/pkg/operator/staticpod/installerpod/cmd.go +++ b/pkg/operator/staticpod/installerpod/cmd.go @@ -3,7 +3,6 @@ package installerpod import ( "context" "fmt" - "k8s.io/utils/clock" "os" "path" "sort" @@ -11,6 +10,8 @@ import ( "strings" "time" + "k8s.io/utils/clock" + "k8s.io/apimachinery/pkg/util/wait" "github.com/blang/semver/v4" diff --git a/pkg/operator/staticpod/internal/atomicdir/swap_linux.go b/pkg/operator/staticpod/internal/atomicdir/swap_linux.go new file mode 100644 index 0000000000..9ce912af76 --- /dev/null +++ b/pkg/operator/staticpod/internal/atomicdir/swap_linux.go @@ -0,0 +1,22 @@ +//go:build linux + +package atomicdir + +import ( + "golang.org/x/sys/unix" +) + +// swap can be used to exchange two directories atomically. +func swap(firstDir, secondDir string) error { + // Renameat2 can be used to exchange two directories atomically when RENAME_EXCHANGE flag is specified. + // The paths to be exchanged can be specified in multiple ways: + // + // * You can specify a file descriptor and a relative path to that descriptor. + // * You can specify an absolute path, in which case the file descriptor is ignored. + // + // We use AT_FDCWD special file descriptor so that when any of the paths is relative, + // it's relative to the current working directory. + // + // For more details, see `man renameat2` as that is the associated C library function. + return unix.Renameat2(unix.AT_FDCWD, firstDir, unix.AT_FDCWD, secondDir, unix.RENAME_EXCHANGE) +} diff --git a/pkg/operator/staticpod/internal/atomicdir/swap_linux_test.go b/pkg/operator/staticpod/internal/atomicdir/swap_linux_test.go new file mode 100644 index 0000000000..ef4370180e --- /dev/null +++ b/pkg/operator/staticpod/internal/atomicdir/swap_linux_test.go @@ -0,0 +1,251 @@ +//go:build linux + +package atomicdir + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + + "k8s.io/apimachinery/pkg/util/sets" +) + +func TestSwap(t *testing.T) { + stateFirst := directoryState{ + "1.txt": { + Content: []byte("hello 1 world"), + Perm: 0600, + }, + "2.txt": { + Content: []byte("hello 2 world"), + Perm: 0400, + }, + } + stateSecond := directoryState{ + "a.txt": { + Content: []byte("hello a world"), + Perm: 0600, + }, + } + stateEmpty := directoryState{} + + expectNoError := func(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + } + + checkSuccess := func(t *testing.T, pathFirst string, stateFirst directoryState, pathSecond string, stateSecond directoryState, err error) { + t.Helper() + expectNoError(t, err) + + // Make sure the contents are swapped. + stateFirst.CheckDirectoryMatches(t, pathSecond) + stateSecond.CheckDirectoryMatches(t, pathFirst) + } + + testCases := []struct { + name string + setup func(t *testing.T, tmpDir string) (pathFirst string, stateFirst directoryState, pathSecond string, stateSecond directoryState) + checkResult func(t *testing.T, pathFirst string, stateFirst directoryState, pathSecond string, stateSecond directoryState, err error) + }{ + { + name: "success with absolute paths", + setup: func(t *testing.T, tmpDir string) (string, directoryState, string, directoryState) { + pathFirst := filepath.Join(tmpDir, "first") + pathSecond := filepath.Join(tmpDir, "second") + + stateFirst.Write(t, pathFirst) + stateSecond.Write(t, pathSecond) + + return pathFirst, stateFirst, pathSecond, stateSecond + }, + checkResult: checkSuccess, + }, + { + name: "success with the first path relative", + setup: func(t *testing.T, tmpDir string) (string, directoryState, string, directoryState) { + pathFirst := filepath.Join(tmpDir, "first") + pathSecond := filepath.Join(tmpDir, "second") + + stateFirst.Write(t, pathFirst) + stateSecond.Write(t, pathSecond) + + cwd, err := os.Getwd() + expectNoError(t, err) + + relFirst, err := filepath.Rel(cwd, pathFirst) + expectNoError(t, err) + + return relFirst, stateFirst, pathSecond, stateSecond + }, + checkResult: checkSuccess, + }, + { + name: "success with the second path relative", + setup: func(t *testing.T, tmpDir string) (string, directoryState, string, directoryState) { + pathFirst := filepath.Join(tmpDir, "first") + pathSecond := filepath.Join(tmpDir, "second") + + stateFirst.Write(t, pathFirst) + stateSecond.Write(t, pathSecond) + + cwd, err := os.Getwd() + expectNoError(t, err) + + relSecond, err := filepath.Rel(cwd, pathSecond) + expectNoError(t, err) + + return pathFirst, stateFirst, relSecond, stateSecond + }, + checkResult: checkSuccess, + }, + { + name: "success with an empty directory", + setup: func(t *testing.T, tmpDir string) (string, directoryState, string, directoryState) { + pathFirst := filepath.Join(tmpDir, "first") + pathSecond := filepath.Join(tmpDir, "second") + + stateFirst.Write(t, pathFirst) + stateEmpty.Write(t, pathSecond) + + return pathFirst, stateFirst, pathSecond, stateEmpty + }, + checkResult: checkSuccess, + }, + { + name: "success with both directories empty", + setup: func(t *testing.T, tmpDir string) (string, directoryState, string, directoryState) { + pathFirst := filepath.Join(tmpDir, "first") + pathSecond := filepath.Join(tmpDir, "second") + + stateEmpty.Write(t, pathFirst) + stateEmpty.Write(t, pathSecond) + + return pathFirst, stateEmpty, pathSecond, stateEmpty + }, + checkResult: checkSuccess, + }, + { + name: "error with the first directory not existing", + setup: func(t *testing.T, tmpDir string) (string, directoryState, string, directoryState) { + pathFirst := filepath.Join(tmpDir, "first") + pathSecond := filepath.Join(tmpDir, "second") + + expectNoError(t, os.Mkdir(pathSecond, 0755)) + + return pathFirst, stateEmpty, pathSecond, stateEmpty + }, + checkResult: func(t *testing.T, pathFirst string, stateFirst directoryState, pathSecond string, stateSecond directoryState, err error) { + if !os.IsNotExist(err) { + t.Errorf("Expected a directory not exists error, got %v", err) + } + }, + }, + { + name: "error with the second directory not existing", + setup: func(t *testing.T, tmpDir string) (string, directoryState, string, directoryState) { + pathFirst := filepath.Join(tmpDir, "first") + pathSecond := filepath.Join(tmpDir, "second") + + expectNoError(t, os.Mkdir(pathFirst, 0755)) + + return pathFirst, stateEmpty, pathSecond, stateEmpty + }, + checkResult: func(t *testing.T, pathFirst string, stateFirst directoryState, pathSecond string, stateSecond directoryState, err error) { + if !os.IsNotExist(err) { + t.Errorf("Expected a directory not exists error, got %v", err) + } + }, + }, + { + name: "error with no directory existing", + setup: func(t *testing.T, tmpDir string) (string, directoryState, string, directoryState) { + pathFirst := filepath.Join(tmpDir, "first") + pathSecond := filepath.Join(tmpDir, "second") + + return pathFirst, stateEmpty, pathSecond, stateEmpty + }, + checkResult: func(t *testing.T, pathFirst string, stateFirst directoryState, pathSecond string, stateSecond directoryState, err error) { + if !os.IsNotExist(err) { + t.Errorf("Expected a directory not exists error, got %v", err) + } + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + pathFirst, stateFirst, pathSecond, stateSecond := tc.setup(t, t.TempDir()) + tc.checkResult(t, pathFirst, stateFirst, pathSecond, stateSecond, swap(pathFirst, pathSecond)) + }) + } +} + +type fileState struct { + Content []byte + Perm os.FileMode +} + +type directoryState map[string]fileState + +func (dir directoryState) Write(t *testing.T, path string) { + if err := os.MkdirAll(path, 0755); err != nil && !os.IsExist(err) { + t.Fatalf("Failed to create directory %q: %v", path, err) + } + + for filename, state := range dir { + fullFilename := filepath.Join(path, filename) + if err := os.WriteFile(fullFilename, state.Content, state.Perm); err != nil { + t.Fatalf("Failed to write file %q: %v", fullFilename, err) + } + } +} + +func (dir directoryState) CheckDirectoryMatches(t *testing.T, path string) { + entries, err := os.ReadDir(path) + if err != nil { + t.Fatalf("Failed to read directory %q: %v", path, err) + } + + expectedFiles := sets.KeySet(dir) + for _, entry := range entries { + // Mark the file as visited. + expectedFiles.Delete(entry.Name()) + + // Get the expected state. + state, ok := dir[entry.Name()] + if !ok { + t.Errorf("Directory %q contains unexpected file %q", path, entry.Name()) + continue + } + + // Check permissions. + info, err := entry.Info() + if err != nil { + t.Errorf("Failed to stat file %q: %v", entry.Name(), err) + continue + } + + if info.Mode() != state.Perm { + t.Errorf("Unexpected permissions on file %q: expected %v, got %v", entry.Name(), state.Perm, info.Mode()) + } + + // Check file content. + content, err := os.ReadFile(filepath.Join(path, entry.Name())) + if err != nil { + t.Errorf("Failed to read file %q: %v", entry.Name(), err) + continue + } + if !bytes.Equal(state.Content, content) { + t.Errorf("Unexpected content in file %q:\n%v", entry.Name(), cmp.Diff(string(state.Content), string(content))) + } + } + if expectedFiles.Len() != 0 { + t.Errorf("Some expected files were not found in directory %q: %s", path, expectedFiles.UnsortedList()) + } +} diff --git a/pkg/operator/staticpod/internal/atomicdir/swap_other.go b/pkg/operator/staticpod/internal/atomicdir/swap_other.go new file mode 100644 index 0000000000..55c54891c8 --- /dev/null +++ b/pkg/operator/staticpod/internal/atomicdir/swap_other.go @@ -0,0 +1,10 @@ +//go:build !linux + +package atomicdir + +// swap can be used to exchange two directories atomically. +// +// This function is only implemented for Linux and returns an error on other platforms. +func swap(firstDir, secondDir string) error { + return errors.New("swap is not supported on this platform") +}