From b750ea678fd8774e85e465110c6bc9ab3634e45c Mon Sep 17 00:00:00 2001 From: Paul Morie Date: Sat, 30 Jan 2016 00:39:36 -0500 Subject: [PATCH] Generalize atomically updating projections into volumes --- pkg/volume/util/atomic_writer.go | 457 ++++++++++++++ pkg/volume/util/atomic_writer_test.go | 852 ++++++++++++++++++++++++++ 2 files changed, 1309 insertions(+) create mode 100644 pkg/volume/util/atomic_writer.go create mode 100644 pkg/volume/util/atomic_writer_test.go diff --git a/pkg/volume/util/atomic_writer.go b/pkg/volume/util/atomic_writer.go new file mode 100644 index 00000000000..6059837ed03 --- /dev/null +++ b/pkg/volume/util/atomic_writer.go @@ -0,0 +1,457 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/golang/glog" + + "k8s.io/kubernetes/pkg/util/sets" +) + +const ( + maxFileNameLength = 255 + maxPathLength = 4096 +) + +// AtomicWriter handles atomically projecting content for a set of files into +// a target directory. AtomicWriter maintains a sentinel file named +// "..sentinel" in the target directory which is updated after new data is +// projected into the target directory, allowing consumers of the data to +// listen for updates by monitoring the sentinel file with inotify or +// fanotify. +// +// Note: +// +// 1. AtomicWriter reserves the set of pathnames starting with `..`. +// 2. AtomicWriter offers no concurrency guarantees and must be synchronized +// by the caller. +// +// The visible files in this volume are symlinks to files in the writer's data +// directory. Actual files are stored in a hidden timestamped directory which +// is symlinked to by the data directory. The timestamped directory and +// data directory symlink are created in the writer's target dir.  This scheme +// allows the files to be atomically updated by changing the target of the +// data directory symlink. +type AtomicWriter struct { + targetDir string + logContext string +} + +// NewAtomicWriter creates a new AtomicWriter configured to write to the given +// target directory, or returns an error if the target directory does not exist. +func NewAtomicWriter(targetDir, logContext string) (*AtomicWriter, error) { + _, err := os.Stat(targetDir) + if os.IsNotExist(err) { + return nil, err + } + + return &AtomicWriter{targetDir: targetDir, logContext: logContext}, nil +} + +const ( + sentinelFileName = "..sentinel" + dataDirName = "..data" + newDataDirName = "..data_tmp" +) + +// Write does an atomic projection of the given payload into the writer's target +// directory. Input paths must not begin with '..'. +// +// The Write algorithm is: +// +// 1. The payload is validated; if the payload is invalid, the function returns +// 2. The user-visible portion of the volume is walked to determine whether any +// portion of the payload was deleted and is still present on disk. +// If the payload is already present on disk and there are no deleted files, +// the function returns +// 3. A check is made to determine whether data present in the payload has changed +// 4.  A new timestamped dir is created +// 5. The payload is written to the new timestamped directory +// 6.  Symlinks and directory for new user-visible files are created (if needed). +// +// For example consider the files: +// /podName +// /user/labels +// /k8s/annotations +// +// The user visible files are symbolic links into the internal data directory: +// /podName -> ..data/podName +// /usr/labels -> ../..data/usr/labels +// /k8s/annotations -> ../..data/k8s/annotations +// +// Relative links are created into the data directory for files in subdirectories. +// +// The data directory itself is a link to a timestamped directory with +// the real data: +// /..data -> ..2016_02_01_15_04_05.12345678/ +// 7.  The current timestamped directory is detected by reading the data directory +// symlink +// 8.  A symlink to the new timestamped directory ..data_tmp is created that will +// become the new data directory +// 9.  The new data directory symlink is renamed to the data directory; rename is atomic +// 10. The sentinel file modification and access times are updated (file is created if it does not +// already exist) +// 11. Old paths are removed from the user-visible portion of the target directory +// 12.  The previous timestamped directory is removed, if it exists +func (w *AtomicWriter) Write(payload map[string][]byte) error { + // (1) + cleanPayload, err := validatePayload(payload) + if err != nil { + glog.Errorf("%s: invalid payload: %v", w.logContext, err) + return err + } + + // (2) + pathsToRemove, err := w.pathsToRemove(cleanPayload) + if err != nil { + glog.Errorf("%s: error determining user-visible files to remove: %v", w.logContext, err) + return err + } + + // (3) + if should, err := w.shouldWritePayload(cleanPayload); err != nil { + glog.Errorf("%s: error determining whether payload should be written to disk: %v", w.logContext, err) + return err + } else if !should && len(pathsToRemove) == 0 { + glog.V(5).Infof("%s: no update required for target directory %v", w.logContext, w.targetDir) + return nil + } + + // (4) + tsDir, err := w.newTimestampDir() + if err != nil { + glog.V(5).Infof("%s: error creating new ts data directory: %v", w.logContext, err) + return err + } + + // (5) + if err = w.writePayloadToDir(cleanPayload, tsDir); err != nil { + glog.Errorf("%s: error writing payload to ts data directory %s: %v", w.logContext, tsDir, err) + return err + } + + // (6) + if err = w.createUserVisibleFiles(cleanPayload); err != nil { + glog.Errorf("%s: error creating visible symlinks in %s: %v", w.logContext, w.targetDir, err) + return err + } + + // (7) + _, tsDirName := filepath.Split(tsDir) + dataDirPath := path.Join(w.targetDir, dataDirName) + oldTsDir, err := os.Readlink(dataDirPath) + if err != nil && !os.IsNotExist(err) { + glog.Errorf("%s: error reading link for data directory: %v", w.logContext, err) + return err + } + + // (8) + newDataDirPath := path.Join(w.targetDir, newDataDirName) + if err = os.Symlink(tsDirName, newDataDirPath); err != nil { + os.RemoveAll(tsDir) + glog.Errorf("%s: error creating symbolic link for atomic update: %v", w.logContext, err) + return err + } + + // (9) + if err = os.Rename(newDataDirPath, dataDirPath); err != nil { + os.Remove(newDataDirPath) + os.RemoveAll(tsDir) + glog.Errorf("%s: error renaming symbolic link for data directory %s: %v", w.logContext, newDataDirPath, err) + return err + } + + // (10) + if err = w.touchSentinelFile(); err != nil { + glog.Errorf("%s: error touching sentinel file: %v", w.logContext, err) + return err + } + + // (11) + if err = w.removeUserVisiblePaths(pathsToRemove); err != nil { + glog.Errorf("%s: error removing old visible symlinks: %v", w.logContext, err) + return err + } + + // (12) + if len(oldTsDir) > 0 { + if err = os.RemoveAll(path.Join(w.targetDir, oldTsDir)); err != nil { + glog.Errorf("%s: error removing old data directory %s: %v", w.logContext, oldTsDir, err) + return err + } + } + + return nil +} + +// validatePayload returns an error if any path in the payload returns a copy of the payload with the paths cleaned. +func validatePayload(payload map[string][]byte) (map[string][]byte, error) { + cleanPayload := make(map[string][]byte) + for k, content := range payload { + if err := validatePath(k); err != nil { + return nil, err + } + + cleanPayload[path.Clean(k)] = content + } + + return cleanPayload, nil +} + +// validatePath validates a single path, returning an error if the path is +// invalid. paths may not: +// +// 1. be absolute +// 2. contain '..' as an element +// 3. start with '..' +// 4. contain filenames larger than 255 characters +// 5. be longer than 4096 characters +func validatePath(targetPath string) error { + // TODO: somehow unify this with the similar api validation, + // validateVolumeSourcePath; the error semantics are just different enough + // from this that it was time-prohibitive trying to find the right + // refactoring to re-use. + if targetPath == "" { + return fmt.Errorf("invalid path: must not be empty: %q", targetPath) + } + if path.IsAbs(targetPath) { + return fmt.Errorf("invalid path: must be relative path: %s", targetPath) + } + + if len(targetPath) > maxPathLength { + return fmt.Errorf("invalid path: must be less than %d characters", maxPathLength) + } + + items := strings.Split(targetPath, string(os.PathSeparator)) + for _, item := range items { + if item == ".." { + return fmt.Errorf("invalid path: must not contain '..': %s", targetPath) + } + if len(item) > maxFileNameLength { + return fmt.Errorf("invalid path: filenames must be less than %d characters", maxFileNameLength) + } + } + if strings.HasPrefix(items[0], "..") && len(items[0]) > 2 { + return fmt.Errorf("invalid path: must not start with '..': %s", targetPath) + } + + return nil +} + +// shouldWritePayload returns whether the payload should be written to disk. +func (w *AtomicWriter) shouldWritePayload(payload map[string][]byte) (bool, error) { + for userVisiblePath, content := range payload { + shouldWrite, err := w.shouldWriteFile(path.Join(w.targetDir, userVisiblePath), content) + if err != nil { + return false, err + } + + if shouldWrite { + return true, nil + } + } + + return false, nil +} + +// shouldWriteFile returns whether a new version of a file should be written to disk. +func (w *AtomicWriter) shouldWriteFile(path string, content []byte) (bool, error) { + _, err := os.Lstat(path) + if os.IsNotExist(err) { + return true, nil + } + + contentOnFs, err := ioutil.ReadFile(path) + if err != nil { + return false, err + } + + return (bytes.Compare(content, contentOnFs) != 0), nil +} + +// pathsToRemove walks the user-visible portion of the target directory and +// determines which paths should be removed (if any) after the payload is +// written to the target directory. +func (w *AtomicWriter) pathsToRemove(payload map[string][]byte) (sets.String, error) { + paths := sets.NewString() + visitor := func(path string, info os.FileInfo, err error) error { + if path == w.targetDir { + return nil + } + + relativePath := strings.TrimPrefix(path, w.targetDir) + relativePath = strings.TrimPrefix(relativePath, "/") + if strings.HasPrefix(relativePath, "..") { + return nil + } + + paths.Insert(relativePath) + return nil + } + + err := filepath.Walk(w.targetDir, visitor) + if os.IsNotExist(err) { + return nil, nil + } else if err != nil { + return nil, err + } + glog.V(5).Infof("%s: current paths: %+v", w.targetDir, paths.List()) + + newPaths := sets.NewString() + for file := range payload { + // add all subpaths for the payload to the set of new paths + // to avoid attempting to remove non-empty dirs + for subPath := file; subPath != ""; { + newPaths.Insert(subPath) + subPath, _ = filepath.Split(subPath) + subPath = strings.TrimSuffix(subPath, "/") + } + } + glog.V(5).Infof("%s: new paths: %+v", w.targetDir, newPaths.List()) + + result := paths.Difference(newPaths) + glog.V(5).Infof("%s: paths to remove: %+v", w.targetDir, result) + + return result, nil +} + +// newTimestampDir creates a new timestamp directory +func (w *AtomicWriter) newTimestampDir() (string, error) { + tsDir, err := ioutil.TempDir(w.targetDir, fmt.Sprintf("..%s.", time.Now().Format("1981_02_01_15_04_05"))) + if err != nil { + glog.Errorf("%s: unable to create new temp directory: %v", w.logContext, err) + return "", err + } + + return tsDir, nil +} + +// writePayloadToDir writes the given payload to the given directory. The +// directory must exist. +func (w *AtomicWriter) writePayloadToDir(payload map[string][]byte, dir string) error { + for userVisiblePath, content := range payload { + fullPath := path.Join(dir, userVisiblePath) + baseDir, _ := filepath.Split(fullPath) + + err := os.MkdirAll(baseDir, os.ModePerm) + if err != nil { + glog.Errorf("%s: unable to create directory %s: %v", w.logContext, baseDir, err) + return err + } + + err = ioutil.WriteFile(fullPath, content, 0644) + if err != nil { + glog.Errorf("%s: unable to write file %s: %v", w.logContext, fullPath, err) + return err + } + } + + return nil +} + +// createUserVisibleFiles creates the relative symlinks for all the +// files configured in the payload. If the directory in a file path does not +// exist, it is created. +// +// Viz: +// For files: "bar", "foo/bar", "baz/bar", "foo/baz/blah" +// the following symlinks and subdirectories are created: +// bar -> ..data/bar +// foo/bar -> ../..data/foo/bar +// baz/bar -> ../..data/baz/bar +// foo/baz/blah -> ../../..data/foo/baz/blah +func (w *AtomicWriter) createUserVisibleFiles(payload map[string][]byte) error { + for userVisiblePath := range payload { + dir, _ := filepath.Split(userVisiblePath) + subDirs := 0 + if len(dir) > 0 { + // If dir is not empty, the projection path contains at least one + // subdirectory (example: userVisiblePath := "foo/bar"). + // Since filepath.Split leaves a trailing path separator, in this + // example, dir = "foo/". In order to calculate the number of + // subdirectories, we must subtract 1 from the number returned by split. + subDirs = len(strings.Split(dir, "/")) - 1 + err := os.MkdirAll(path.Join(w.targetDir, dir), os.ModePerm) + if err != nil { + return err + } + } + _, err := os.Readlink(path.Join(w.targetDir, userVisiblePath)) + if err != nil && os.IsNotExist(err) { + // The link into the data directory for this path doesn't exist; create it, + // respecting the number of subdirectories necessary to link + // correctly back into the data directory. + visibleFile := path.Join(w.targetDir, userVisiblePath) + dataDirFile := path.Join(strings.Repeat("../", subDirs), dataDirName, userVisiblePath) + + err = os.Symlink(dataDirFile, visibleFile) + if err != nil { + return err + } + } + } + + return nil +} + +// removeUserVisiblePaths removes the set of paths from the user-visible +// portion of the writer's target directory. +func (w *AtomicWriter) removeUserVisiblePaths(paths sets.String) error { + orderedPaths := paths.List() + for ii := len(orderedPaths) - 1; ii >= 0; ii-- { + if err := os.Remove(path.Join(w.targetDir, orderedPaths[ii])); err != nil { + glog.Errorf("%s: error pruning old user-visible path %s: %v", w.logContext, orderedPaths[ii], err) + return err + } + } + + return nil +} + +// touchSentinelFile touches the sentinel file or creates it if it doesn't exist. +func (w *AtomicWriter) touchSentinelFile() error { + sentinelFilePath := path.Join(w.targetDir, sentinelFileName) + _, err := os.Stat(sentinelFilePath) + if err != nil && os.IsNotExist(err) { + file, err := os.Create(sentinelFilePath) + if err != nil { + glog.Errorf("%s: unexpected error creating sentinel file %s: %v", w.logContext, sentinelFilePath, err) + return err + } + file.Close() + } else if err != nil { + return err + } + + ts := time.Now() + err = os.Chtimes(sentinelFilePath, ts, ts) + if err != nil { + glog.Errorf("%s: error updating sentinel file mod time: %v", w.logContext, err) + return err + } + + return nil +} diff --git a/pkg/volume/util/atomic_writer_test.go b/pkg/volume/util/atomic_writer_test.go new file mode 100644 index 00000000000..3caf195e4b7 --- /dev/null +++ b/pkg/volume/util/atomic_writer_test.go @@ -0,0 +1,852 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "encoding/base64" + "io/ioutil" + "os" + "path" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "k8s.io/kubernetes/pkg/util/sets" + utiltesting "k8s.io/kubernetes/pkg/util/testing" +) + +func TestNewAtomicWriter(t *testing.T) { + targetDir, err := utiltesting.MkTmpdir("atomic-write") + if err != nil { + t.Fatalf("unexpected error creating tmp dir: %v", err) + } + + _, err = NewAtomicWriter(targetDir, "-test-") + if err != nil { + t.Fatalf("unexpected error creating writer for existing target dir: %v", err) + } + + nonExistentDir, err := utiltesting.MkTmpdir("atomic-write") + if err != nil { + t.Fatalf("unexpected error creating tmp dir: %v", err) + } + err = os.Remove(nonExistentDir) + if err != nil { + t.Fatalf("unexpected error ensuring dir %v does not exist: %v", nonExistentDir, err) + } + + _, err = NewAtomicWriter(nonExistentDir, "-test-") + if err == nil { + t.Fatalf("unexpected success creating writer for nonexistent target dir: %v", err) + } +} + +func TestValidatePath(t *testing.T) { + maxPath := strings.Repeat("a", maxPathLength+1) + maxFile := strings.Repeat("a", maxFileNameLength+1) + + cases := []struct { + name string + path string + valid bool + }{ + { + name: "valid 1", + path: "i/am/well/behaved.txt", + valid: true, + }, + { + name: "valid 2", + path: "keepyourheaddownandfollowtherules.txt", + valid: true, + }, + { + name: "max path length", + path: maxPath, + valid: false, + }, + { + name: "max file length", + path: maxFile, + valid: false, + }, + { + name: "absolute failure", + path: "/dev/null", + valid: false, + }, + { + name: "reserved path", + path: "..sneaky.txt", + valid: false, + }, + { + name: "contains doubledot 1", + path: "hello/there/../../../../../../etc/passwd", + valid: false, + }, + { + name: "contains doubledot 2", + path: "hello/../etc/somethingbad", + valid: false, + }, + { + name: "empty", + path: "", + valid: false, + }, + } + + for _, tc := range cases { + err := validatePath(tc.path) + if tc.valid && err != nil { + t.Errorf("%v: unexpected failure: %v", tc.name, err) + continue + } + + if !tc.valid && err == nil { + t.Errorf("%v: unexpected success", tc.name) + } + } +} + +func TestPathsToRemove(t *testing.T) { + cases := []struct { + name string + payload1 map[string][]byte + payload2 map[string][]byte + expected sets.String + }{ + { + name: "simple", + payload1: map[string][]byte{ + "foo.txt": []byte("foo"), + "bar.txt": []byte("bar"), + }, + payload2: map[string][]byte{ + "foo.txt": []byte("foo"), + }, + expected: sets.NewString("bar.txt"), + }, + { + name: "simple 2", + payload1: map[string][]byte{ + "foo.txt": []byte("foo"), + "zip/bar.txt": []byte("zip/bar"), + }, + payload2: map[string][]byte{ + "foo.txt": []byte("foo"), + }, + expected: sets.NewString("zip/bar.txt", "zip"), + }, + { + name: "subdirs 1", + payload1: map[string][]byte{ + "foo.txt": []byte("foo"), + "zip/zap/bar.txt": []byte("zip/bar"), + }, + payload2: map[string][]byte{ + "foo.txt": []byte("foo"), + }, + expected: sets.NewString("zip/zap/bar.txt", "zip", "zip/zap"), + }, + { + name: "subdirs 2", + payload1: map[string][]byte{ + "foo.txt": []byte("foo"), + "zip/1/2/3/4/bar.txt": []byte("zip/bar"), + }, + payload2: map[string][]byte{ + "foo.txt": []byte("foo"), + }, + expected: sets.NewString("zip/1/2/3/4/bar.txt", "zip", "zip/1", "zip/1/2", "zip/1/2/3", "zip/1/2/3/4"), + }, + { + name: "subdirs 3", + payload1: map[string][]byte{ + "foo.txt": []byte("foo"), + "zip/1/2/3/4/bar.txt": []byte("zip/bar"), + "zap/a/b/c/bar.txt": []byte("zap/bar"), + }, + payload2: map[string][]byte{ + "foo.txt": []byte("foo"), + }, + expected: sets.NewString("zip/1/2/3/4/bar.txt", "zip", "zip/1", "zip/1/2", "zip/1/2/3", "zip/1/2/3/4", "zap", "zap/a", "zap/a/b", "zap/a/b/c", "zap/a/b/c/bar.txt"), + }, + { + name: "subdirs 4", + payload1: map[string][]byte{ + "foo.txt": []byte("foo"), + "zap/1/2/3/4/bar.txt": []byte("zip/bar"), + "zap/1/2/c/bar.txt": []byte("zap/bar"), + "zap/1/2/magic.txt": []byte("indigo"), + }, + payload2: map[string][]byte{ + "foo.txt": []byte("foo"), + "zap/1/2/magic.txt": []byte("indigo"), + }, + expected: sets.NewString("zap/1/2/3/4/bar.txt", "zap/1/2/3", "zap/1/2/3/4", "zap/1/2/3/4/bar.txt", "zap/1/2/c", "zap/1/2/c/bar.txt"), + }, + { + name: "subdirs 5", + payload1: map[string][]byte{ + "foo.txt": []byte("foo"), + "zap/1/2/3/4/bar.txt": []byte("zip/bar"), + "zap/1/2/c/bar.txt": []byte("zap/bar"), + }, + payload2: map[string][]byte{ + "foo.txt": []byte("foo"), + "zap/1/2/magic.txt": []byte("indigo"), + }, + expected: sets.NewString("zap/1/2/3/4/bar.txt", "zap/1/2/3", "zap/1/2/3/4", "zap/1/2/3/4/bar.txt", "zap/1/2/c", "zap/1/2/c/bar.txt"), + }, + } + + for _, tc := range cases { + targetDir, err := utiltesting.MkTmpdir("atomic-write") + if err != nil { + t.Errorf("%v: unexpected error creating tmp dir: %v", tc.name, err) + continue + } + + writer := &AtomicWriter{targetDir: targetDir, logContext: "-test-"} + err = writer.Write(tc.payload1) + if err != nil { + t.Errorf("%v: unexpected error writing: %v", tc.name, err) + continue + } + + actual, err := writer.pathsToRemove(tc.payload2) + if err != nil { + t.Errorf("%v: unexpected error determining paths to remove: %v", tc.name, err) + continue + } + + if e, a := tc.expected, actual; !e.Equal(a) { + t.Errorf("%v: unexpected paths to remove:\nexpected: %v\n got: %v", tc.name, e, a) + } + } +} + +func TestWriteOnce(t *testing.T) { + // $1 if you can tell me what this binary is + encodedMysteryBinary := `f0VMRgIBAQAAAAAAAAAAAAIAPgABAAAAeABAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAEAAOAAB +AAAAAAAAAAEAAAAFAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAAAAfQAAAAAAAAB9AAAAAAAAAAAA +IAAAAAAAsDyZDwU=` + + mysteryBinaryBytes := make([]byte, base64.StdEncoding.DecodedLen(len(encodedMysteryBinary))) + numBytes, err := base64.StdEncoding.Decode(mysteryBinaryBytes, []byte(encodedMysteryBinary)) + if err != nil { + t.Fatalf("Unexpected error decoding binary payload: %v", err) + } + + if numBytes != 125 { + t.Fatalf("Unexpected decoded binary size: expected 125, got %v", numBytes) + } + + cases := []struct { + name string + payload map[string][]byte + success bool + }{ + { + name: "invalid payload 1", + payload: map[string][]byte{ + "foo": []byte("foo"), + "..bar": []byte("bar"), + "binary.bin": mysteryBinaryBytes, + }, + success: false, + }, + { + name: "invalid payload 2", + payload: map[string][]byte{ + "foo/../bar": []byte("foo"), + }, + success: false, + }, + { + name: "basic 1", + payload: map[string][]byte{ + "foo": []byte("foo"), + "bar": []byte("bar"), + }, + success: true, + }, + { + name: "basic 2", + payload: map[string][]byte{ + "binary.bin": mysteryBinaryBytes, + ".binary.bin": mysteryBinaryBytes, + }, + success: true, + }, + { + name: "dotfiles", + payload: map[string][]byte{ + "foo": []byte("foo"), + "bar": []byte("bar"), + ".dotfile": []byte("dotfile"), + ".dotfile.file": []byte("dotfile.file"), + }, + success: true, + }, + { + name: "subdirectories 1", + payload: map[string][]byte{ + "foo/bar.txt": []byte("foo/bar"), + "bar/zab.txt": []byte("bar/zab.txt"), + }, + success: true, + }, + { + name: "subdirectories 2", + payload: map[string][]byte{ + "foo//bar.txt": []byte("foo//bar"), + "bar///bar/zab.txt": []byte("bar/../bar/zab.txt"), + }, + success: true, + }, + { + name: "subdirectories 3", + payload: map[string][]byte{ + "foo/bar.txt": []byte("foo/bar"), + "bar/zab.txt": []byte("bar/zab.txt"), + "foo/blaz/bar.txt": []byte("foo/blaz/bar"), + "bar/zib/zab.txt": []byte("bar/zib/zab.txt"), + }, + success: true, + }, + { + name: "kitchen sink", + payload: map[string][]byte{ + "foo.log": []byte("foo"), + "bar.zap": []byte("bar"), + ".dotfile": []byte("dotfile"), + "foo/bar.txt": []byte("foo/bar"), + "bar/zab.txt": []byte("bar/zab.txt"), + "foo/blaz/bar.txt": []byte("foo/blaz/bar"), + "bar/zib/zab.txt": []byte("bar/zib/zab.txt"), + "1/2/3/4/5/6/7/8/9/10/.dotfile.lib": []byte("1-2-3-dotfile"), + }, + success: true, + }, + } + + for _, tc := range cases { + targetDir, err := utiltesting.MkTmpdir("atomic-write") + if err != nil { + t.Errorf("%v: unexpected error creating tmp dir: %v", tc.name, err) + continue + } + + writer := &AtomicWriter{targetDir: targetDir, logContext: "-test-"} + err = writer.Write(tc.payload) + if err != nil && tc.success { + t.Errorf("%v: unexpected error writing payload: %v", tc.name, err) + continue + } else if err == nil && !tc.success { + t.Errorf("%v: unexpected success", tc.name) + continue + } else if err != nil { + continue + } + + checkVolumeContents(targetDir, tc.name, tc.payload, t) + checkSentinelFile(targetDir, t) + } +} + +func TestUpdate(t *testing.T) { + cases := []struct { + name string + first map[string][]byte + next map[string][]byte + shouldWrite bool + }{ + { + name: "update", + first: map[string][]byte{ + "foo": []byte("foo"), + "bar": []byte("bar"), + }, + next: map[string][]byte{ + "foo": []byte("foo2"), + "bar": []byte("bar2"), + }, + shouldWrite: true, + }, + { + name: "no update", + first: map[string][]byte{ + "foo": []byte("foo"), + "bar": []byte("bar"), + }, + next: map[string][]byte{ + "foo": []byte("foo"), + "bar": []byte("bar"), + }, + shouldWrite: false, + }, + { + name: "no update 2", + first: map[string][]byte{ + "foo/bar.txt": []byte("foo"), + "bar/zab.txt": []byte("bar"), + }, + next: map[string][]byte{ + "foo/bar.txt": []byte("foo"), + "bar/zab.txt": []byte("bar"), + }, + shouldWrite: false, + }, + { + name: "add 1", + first: map[string][]byte{ + "foo/bar.txt": []byte("foo"), + "bar/zab.txt": []byte("bar"), + }, + next: map[string][]byte{ + "foo/bar.txt": []byte("foo"), + "bar/zab.txt": []byte("bar"), + "blu/zip.txt": []byte("zip"), + }, + shouldWrite: true, + }, + { + name: "add 2", + first: map[string][]byte{ + "foo/bar.txt": []byte("foo"), + "bar/zab.txt": []byte("bar"), + }, + next: map[string][]byte{ + "foo/bar.txt": []byte("foo"), + "bar/zab.txt": []byte("bar"), + "blu/two/2/3/4/5/zip.txt": []byte("zip"), + }, + shouldWrite: true, + }, + { + name: "add 3", + first: map[string][]byte{ + "foo/bar.txt": []byte("foo"), + "bar/zab.txt": []byte("bar"), + }, + next: map[string][]byte{ + "foo/bar.txt": []byte("foo"), + "bar/zab.txt": []byte("bar"), + "bar/2/3/4/5/zip.txt": []byte("zip"), + }, + shouldWrite: true, + }, + { + name: "delete 1", + first: map[string][]byte{ + "foo/bar.txt": []byte("foo"), + "bar/zab.txt": []byte("bar"), + }, + next: map[string][]byte{ + "foo/bar.txt": []byte("foo"), + }, + shouldWrite: true, + }, + { + name: "delete 2", + first: map[string][]byte{ + "foo/bar.txt": []byte("foo"), + "bar/1/2/3/zab.txt": []byte("bar"), + }, + next: map[string][]byte{ + "foo/bar.txt": []byte("foo"), + }, + shouldWrite: true, + }, + { + name: "delete 3", + first: map[string][]byte{ + "foo/bar.txt": []byte("foo"), + "bar/1/2/sip.txt": []byte("sip"), + "bar/1/2/3/zab.txt": []byte("bar"), + }, + next: map[string][]byte{ + "foo/bar.txt": []byte("foo"), + "bar/1/2/sip.txt": []byte("sip"), + }, + shouldWrite: true, + }, + { + name: "delete 4", + first: map[string][]byte{ + "foo/bar.txt": []byte("foo"), + "bar/1/2/sip.txt": []byte("sip"), + "bar/1/2/3/4/5/6zab.txt": []byte("bar"), + }, + next: map[string][]byte{ + "foo/bar.txt": []byte("foo"), + "bar/1/2/sip.txt": []byte("sip"), + }, + shouldWrite: true, + }, + { + name: "delete all", + first: map[string][]byte{ + "foo/bar.txt": []byte("foo"), + "bar/1/2/sip.txt": []byte("sip"), + "bar/1/2/3/4/5/6zab.txt": []byte("bar"), + }, + next: map[string][]byte{}, + shouldWrite: true, + }, + { + name: "add and delete 1", + first: map[string][]byte{ + "foo/bar.txt": []byte("foo"), + }, + next: map[string][]byte{ + "bar/baz.txt": []byte("baz"), + }, + shouldWrite: true, + }, + } + + for _, tc := range cases { + targetDir, err := utiltesting.MkTmpdir("atomic-write") + if err != nil { + t.Errorf("%v: unexpected error creating tmp dir: %v", tc.name, err) + continue + } + + writer := &AtomicWriter{targetDir: targetDir, logContext: "-test-"} + + err = writer.Write(tc.first) + if err != nil { + t.Errorf("%v: unexpected error writing: %v", tc.name, err) + continue + } + + checkVolumeContents(targetDir, tc.name, tc.first, t) + if !tc.shouldWrite { + continue + } + + oldTs := checkSentinelFile(targetDir, t) + + err = writer.Write(tc.next) + if err != nil { + if tc.shouldWrite { + t.Errorf("%v: unexpected error writing: %v", tc.name, err) + continue + } + } else if !tc.shouldWrite { + t.Errorf("%v: unexpected success", tc.name) + continue + } + + checkVolumeContents(targetDir, tc.name, tc.next, t) + + ts := checkSentinelFile(targetDir, t) + if !ts.After(oldTs) { + t.Errorf("Unexpected timestamp on sentinel file; expected %v to be after %v", ts, oldTs) + } + } +} + +func TestMultipleUpdates(t *testing.T) { + cases := []struct { + name string + payloads []map[string][]byte + clearSentinel bool + }{ + { + name: "update 1", + payloads: []map[string][]byte{ + { + "foo": []byte("foo"), + "bar": []byte("bar"), + }, + { + "foo": []byte("foo2"), + "bar": []byte("bar2"), + }, + { + "foo": []byte("foo3"), + "bar": []byte("bar3"), + }, + }, + }, + { + name: "update 2", + payloads: []map[string][]byte{ + { + "foo/bar.txt": []byte("foo/bar"), + "bar/zab.txt": []byte("bar/zab.txt"), + }, + { + "foo/bar.txt": []byte("foo/bar2"), + "bar/zab.txt": []byte("bar/zab.txt2"), + }, + }, + }, + { + name: "clear sentinel", + payloads: []map[string][]byte{ + { + "foo": []byte("foo"), + "bar": []byte("bar"), + }, + { + "foo": []byte("foo2"), + "bar": []byte("bar2"), + }, + { + "foo": []byte("foo3"), + "bar": []byte("bar3"), + }, + { + "foo": []byte("foo4"), + "bar": []byte("bar4"), + }, + }, + clearSentinel: true, + }, + { + name: "subdirectories 2", + payloads: []map[string][]byte{ + { + "foo/bar.txt": []byte("foo/bar"), + "bar/zab.txt": []byte("bar/zab.txt"), + "foo/blaz/bar.txt": []byte("foo/blaz/bar"), + "bar/zib/zab.txt": []byte("bar/zib/zab.txt"), + }, + { + "foo/bar.txt": []byte("foo/bar2"), + "bar/zab.txt": []byte("bar/zab.txt2"), + "foo/blaz/bar.txt": []byte("foo/blaz/bar2"), + "bar/zib/zab.txt": []byte("bar/zib/zab.txt2"), + }, + }, + }, + { + name: "add 1", + payloads: []map[string][]byte{ + { + "foo/bar.txt": []byte("foo/bar"), + "bar//zab.txt": []byte("bar/zab.txt"), + "foo/blaz/bar.txt": []byte("foo/blaz/bar"), + "bar/zib////zib/zab.txt": []byte("bar/zib/zab.txt"), + }, + { + "foo/bar.txt": []byte("foo/bar2"), + "bar/zab.txt": []byte("bar/zab.txt2"), + "foo/blaz/bar.txt": []byte("foo/blaz/bar2"), + "bar/zib/zab.txt": []byte("bar/zib/zab.txt2"), + "add/new/keys.txt": []byte("addNewKeys"), + }, + }, + }, + { + name: "add 2", + payloads: []map[string][]byte{ + { + "foo/bar.txt": []byte("foo/bar2"), + "bar/zab.txt": []byte("bar/zab.txt2"), + "foo/blaz/bar.txt": []byte("foo/blaz/bar2"), + "bar/zib/zab.txt": []byte("bar/zib/zab.txt2"), + "add/new/keys.txt": []byte("addNewKeys"), + }, + { + "foo/bar.txt": []byte("foo/bar2"), + "bar/zab.txt": []byte("bar/zab.txt2"), + "foo/blaz/bar.txt": []byte("foo/blaz/bar2"), + "bar/zib/zab.txt": []byte("bar/zib/zab.txt2"), + "add/new/keys.txt": []byte("addNewKeys"), + "add/new/keys2.txt": []byte("addNewKeys2"), + "add/new/keys3.txt": []byte("addNewKeys3"), + }, + }, + }, + { + name: "remove 1", + payloads: []map[string][]byte{ + { + "foo/bar.txt": []byte("foo/bar"), + "bar//zab.txt": []byte("bar/zab.txt"), + "foo/blaz/bar.txt": []byte("foo/blaz/bar"), + "zip/zap/zup/fop.txt": []byte("zip/zap/zup/fop.txt"), + }, + { + "foo/bar.txt": []byte("foo/bar2"), + "bar/zab.txt": []byte("bar/zab.txt2"), + }, + { + "foo/bar.txt": []byte("foo/bar"), + }, + }, + }, + } + + for _, tc := range cases { + targetDir, err := utiltesting.MkTmpdir("atomic-write") + if err != nil { + t.Errorf("%v: unexpected error creating tmp dir: %v", tc.name, err) + continue + } + + var oldTs *time.Time = nil + writer := &AtomicWriter{targetDir: targetDir, logContext: "-test-"} + + for ii, payload := range tc.payloads { + writer.Write(payload) + + checkVolumeContents(targetDir, tc.name, payload, t) + ts := checkSentinelFile(targetDir, t) + + if oldTs != nil && !ts.After(*oldTs) { + t.Errorf("%v[%v] unexpected timestamp on sentinel file; expected %v to be after %v", tc.name, ii, ts, oldTs) + } + oldTs = &ts + + if tc.clearSentinel { + clearSentinelFile(targetDir, t) + } + } + } +} + +func TestSentinelFileModTimeIncreasing(t *testing.T) { + cases := []struct { + name string + iterations int + clearSentinelFile bool + }{ + { + name: "5 iters", + iterations: 5, + }, + { + name: "50 iters", + iterations: 50, + }, + { + name: "1000 iters", + iterations: 1000, + }, + { + name: "1000 clear sentinel", + iterations: 1000, + clearSentinelFile: true, + }, + { + name: "10000 clear sentinel", + iterations: 10000, + clearSentinelFile: true, + }, + } + + for _, tc := range cases { + targetDir, err := utiltesting.MkTmpdir("atomic-write") + if err != nil { + t.Errorf("%v: unexpected error creating tmp dir: %v", tc.name, err) + continue + } + + var oldTs *time.Time = nil + writer := &AtomicWriter{targetDir: targetDir, logContext: "-test-"} + + for i := 0; i < tc.iterations; i++ { + err = writer.touchSentinelFile() + if err != nil { + t.Errorf("%v: unexpected error touching sentinel file: %v", tc.name, err) + continue + } + + ts := checkSentinelFile(targetDir, t) + if oldTs != nil && !ts.After(*oldTs) { + t.Errorf("%v: unexpected timestamp on sentinel file; expected %v to be after %v", tc.name, ts, oldTs) + continue + } + oldTs = &ts + + if tc.clearSentinelFile { + clearSentinelFile(targetDir, t) + } + } + } +} + +func checkVolumeContents(targetDir, tcName string, payload map[string][]byte, t *testing.T) { + // use filepath.Walk to reconstruct the payload, then deep equal + observedPayload := map[string][]byte{} + visitor := func(path string, info os.FileInfo, err error) error { + if info.Mode().IsRegular() || info.IsDir() { + return nil + } + + relativePath := strings.TrimPrefix(path, targetDir) + relativePath = strings.TrimPrefix(relativePath, "/") + if strings.HasPrefix(relativePath, "..") { + return nil + } + + content, err := ioutil.ReadFile(path) + if err != nil { + return err + } + observedPayload[relativePath] = content + + return nil + } + + err := filepath.Walk(targetDir, visitor) + if err != nil { + t.Errorf("%v: unexpected error walking directory: %v", tcName, err) + } + + cleanPathPayload := make(map[string][]byte, len(payload)) + for k, v := range payload { + cleanPathPayload[path.Clean(k)] = v + } + + if !reflect.DeepEqual(cleanPathPayload, observedPayload) { + t.Errorf("%v: payload and observed payload do not match.", tcName) + } +} + +func checkSentinelFile(targetDir string, t *testing.T) time.Time { + sentinelFilePath := filepath.Join(targetDir, sentinelFileName) + info, err := os.Stat(sentinelFilePath) + if err != nil { + t.Errorf("Couldn't stat sentinel file for dir %v: %v", targetDir, err) + return time.Now() + } + + return info.ModTime() +} + +func clearSentinelFile(targetDir string, t *testing.T) { + sentinelFilePath := filepath.Join(targetDir, sentinelFileName) + _, err := os.Stat(sentinelFilePath) + if err != nil { + t.Errorf("Couldn't stat sentinel file for dir %v: %v", targetDir, err) + } + err = os.Remove(sentinelFilePath) + if err != nil { + t.Errorf("Error removing sentinel file: %v", err) + } +}