-
Notifications
You must be signed in to change notification settings - Fork 265
OCPBUGS-84258: fsync static pod cert and manifest writes for crash durability #2176
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,43 +8,44 @@ import ( | |
| "k8s.io/klog/v2" | ||
|
|
||
| "github.com/openshift/library-go/pkg/operator/staticpod/internal/atomicdir/types" | ||
| "github.com/openshift/library-go/pkg/operator/staticpod/internal/fsutil" | ||
| ) | ||
|
|
||
| // Sync can be used to atomically synchronize target directory with the given file content map. | ||
| // This is done by populating a staging directory, then atomically swapping it with the target directory. | ||
| // This effectively means that any extra files in the target directory are pruned. | ||
| // | ||
| // The staging directory needs to be explicitly specified. It is initially created using os.MkdirAll with targetDirPerm. | ||
| // It is then populated using files with filePerm. Once the atomic swap is performed, the staging directory | ||
| // (which is now the original target directory) is removed. | ||
| // Sync atomically and durably replaces the contents of targetDir with the given files. | ||
| // It writes files to a staging directory with fsync, then atomically swaps it with | ||
| // the target directory via renameat2(RENAME_EXCHANGE), fsyncing parent directories | ||
| // to ensure the swap is persisted. Extra files in targetDir are pruned. | ||
| // The old target directory (now at stagingDir) is removed after the swap. | ||
| func Sync(targetDir string, targetDirPerm os.FileMode, stagingDir string, files map[string]types.File) error { | ||
| return sync(&realFS, targetDir, targetDirPerm, stagingDir, files) | ||
| } | ||
|
|
||
| type fileSystem struct { | ||
| MkdirAll func(path string, perm os.FileMode) error | ||
| RemoveAll func(path string) error | ||
| WriteFile func(name string, data []byte, perm os.FileMode) error | ||
| WriteFileFsync func(name string, data []byte, perm os.FileMode) error | ||
| SwapDirectories func(dirA, dirB string) error | ||
| Fsync func(name string) error | ||
| } | ||
|
|
||
| var realFS = fileSystem{ | ||
| MkdirAll: os.MkdirAll, | ||
| RemoveAll: os.RemoveAll, | ||
| WriteFile: os.WriteFile, | ||
| WriteFileFsync: fsutil.WriteFileFsync, | ||
| SwapDirectories: swap, | ||
| Fsync: fsutil.Fsync, | ||
| } | ||
|
|
||
| // sync prepares a tmp directory and writes all files into that directory. | ||
| // Then it atomically swap the tmp directory for the target one. | ||
| // This is currently implemented as really atomically swapping directories. | ||
| // sync writes files into the staging directory, then durably swaps it with the target. | ||
| // Each file write is individually fsynced (including its parent directory entry) via | ||
| // fs.WriteFileFsync. After the swap, parent directories are fsynced to persist the exchange. | ||
| // | ||
| // The same goal of atomic swap could be implemented using symlinks much like AtomicWriter does in | ||
| // Note: the upstream Kubernetes AtomicWriter uses symlinks for atomic updates but does | ||
| // not fsync, leaving file data in the page cache with no crash durability guarantee: | ||
| // https://github.com/kubernetes/kubernetes/blob/v1.34.0/pkg/volume/util/atomic_writer.go#L58 | ||
| // The reason we don't do that is that we already have a directory populated and watched that needs to we swapped. | ||
| // In other words, it's for compatibility reasons. And if we were to migrate to the symlink approach, | ||
| // we would anyway need to atomically turn the current data directory into a symlink. | ||
| // This would all just increase complexity and require atomic swap on the OS level anyway. | ||
| // We use renameat2(RENAME_EXCHANGE) instead of symlinks because we need to swap an | ||
| // existing directory that is already being watched. Migrating to symlinks would still | ||
| // require an atomic swap at the OS level, adding complexity for no benefit. | ||
| func sync(fs *fileSystem, targetDir string, targetDirPerm os.FileMode, stagingDir string, files map[string]types.File) (retErr error) { | ||
| klog.Infof("Ensuring target directory %q exists ...", targetDir) | ||
| if err := fs.MkdirAll(targetDir, targetDirPerm); err != nil { | ||
|
|
@@ -75,7 +76,7 @@ func sync(fs *fileSystem, targetDir string, targetDirPerm os.FileMode, stagingDi | |
| fullFilename := filepath.Join(stagingDir, filename) | ||
| klog.Infof("Writing file %q ...", fullFilename) | ||
|
|
||
| if err := fs.WriteFile(fullFilename, file.Content, file.Perm); err != nil { | ||
| if err := fs.WriteFileFsync(fullFilename, file.Content, file.Perm); err != nil { | ||
| return fmt.Errorf("failed writing %q: %w", fullFilename, err) | ||
| } | ||
| } | ||
|
|
@@ -84,5 +85,16 @@ func sync(fs *fileSystem, targetDir string, targetDirPerm os.FileMode, stagingDi | |
| if err := fs.SwapDirectories(targetDir, stagingDir); err != nil { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. actually, given that Maybe
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I almost did that but |
||
| return fmt.Errorf("failed swapping target directory %q with staging directory %q: %w", targetDir, stagingDir, err) | ||
| } | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. then we use Fsync : |
||
| // fsync parent directories to ensure the swap is durable on disk. | ||
| // renameat2(RENAME_EXCHANGE) modifies parent directory entries, so the | ||
| // parents must be fsynced to persist which inode each name points to. | ||
| if err := fs.Fsync(filepath.Dir(targetDir)); err != nil { | ||
| return fmt.Errorf("failed syncing parent directory of %q: %w", targetDir, err) | ||
| } | ||
| if err := fs.Fsync(filepath.Dir(stagingDir)); err != nil { | ||
| return fmt.Errorf("failed syncing parent directory of %q: %w", stagingDir, err) | ||
| } | ||
|
|
||
| return | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| package fsutil | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| ) | ||
|
|
||
| // Fsync fsyncs a file or directory to ensure it is durable on disk. | ||
| func Fsync(name string) error { | ||
| f, err := os.Open(name) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| syncErr := f.Sync() | ||
| // close(2) can surface errors not caught by fsync(2), e.g. on NFS. | ||
| closeErr := f.Close() | ||
| if syncErr != nil { | ||
| return syncErr | ||
| } | ||
| return closeErr | ||
| } | ||
|
|
||
| // WriteFileFsync writes data to a file and fsyncs both the file and its | ||
| // parent directory to ensure the write is durable on disk. | ||
| func WriteFileFsync(name string, data []byte, perm os.FileMode) error { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. then this could be simplified to: |
||
| if err := os.WriteFile(name, data, perm); err != nil { | ||
| return err | ||
| } | ||
| if err := Fsync(name); err != nil { | ||
| return err | ||
| } | ||
| return Fsync(filepath.Dir(name)) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| package fsutil | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestWriteFileFsync(t *testing.T) { | ||
| testCases := []struct { | ||
| name string | ||
| setup func(t *testing.T) string | ||
| data []byte | ||
| perm os.FileMode | ||
| expectError bool | ||
| }{ | ||
| { | ||
| name: "writes file with correct content and permissions", | ||
| setup: func(t *testing.T) string { | ||
| return filepath.Join(t.TempDir(), "test.txt") | ||
| }, | ||
| data: []byte("hello world"), | ||
| perm: 0600, | ||
| }, | ||
| { | ||
| name: "creates file in nonexistent directory fails", | ||
| setup: func(t *testing.T) string { | ||
| return filepath.Join(t.TempDir(), "nodir", "test.txt") | ||
| }, | ||
| data: []byte("hello"), | ||
| perm: 0600, | ||
| expectError: true, | ||
| }, | ||
| { | ||
| name: "overwrites existing file", | ||
| setup: func(t *testing.T) string { | ||
| p := filepath.Join(t.TempDir(), "test.txt") | ||
| if err := os.WriteFile(p, []byte("old content"), 0600); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| return p | ||
| }, | ||
| data: []byte("new content"), | ||
| perm: 0600, | ||
| }, | ||
| { | ||
| name: "writes empty file", | ||
| setup: func(t *testing.T) string { | ||
| return filepath.Join(t.TempDir(), "empty.txt") | ||
| }, | ||
| data: []byte{}, | ||
| perm: 0644, | ||
| }, | ||
| } | ||
|
|
||
| for _, tc := range testCases { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| path := tc.setup(t) | ||
| err := WriteFileFsync(path, tc.data, tc.perm) | ||
|
|
||
| if tc.expectError { | ||
| if err == nil { | ||
| t.Fatal("expected error, got nil") | ||
| } | ||
| return | ||
| } | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
|
|
||
| got, err := os.ReadFile(path) | ||
| if err != nil { | ||
| t.Fatalf("failed to read back file: %v", err) | ||
| } | ||
| if string(got) != string(tc.data) { | ||
| t.Errorf("content mismatch: got %q, want %q", got, tc.data) | ||
| } | ||
|
|
||
| info, err := os.Stat(path) | ||
| if err != nil { | ||
| t.Fatalf("failed to stat file: %v", err) | ||
| } | ||
| if info.Mode().Perm() != tc.perm { | ||
| t.Errorf("permission mismatch: got %o, want %o", info.Mode().Perm(), tc.perm) | ||
| } | ||
|
Comment on lines
+79
to
+85
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid exact permission assertions unless the test controls
🤖 Prompt for AI Agents |
||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestFsync(t *testing.T) { | ||
| t.Run("syncs existing file", func(t *testing.T) { | ||
| path := filepath.Join(t.TempDir(), "test.txt") | ||
| if err := os.WriteFile(path, []byte("data"), 0600); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if err := Fsync(path); err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("syncs existing directory", func(t *testing.T) { | ||
| dir := t.TempDir() | ||
| if err := Fsync(dir); err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("fails on nonexistent path", func(t *testing.T) { | ||
| if err := Fsync(filepath.Join(t.TempDir(), "nonexistent")); err == nil { | ||
| t.Fatal("expected error, got nil") | ||
| } | ||
| }) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.