From ab08944aa7507e7824183896533886c8fbc2adba Mon Sep 17 00:00:00 2001 From: Stephen J Day Date: Mon, 6 Feb 2017 19:26:07 -0800 Subject: [PATCH] snapshot: clarify active and committed snapshots After receiving feedback on the `snapshot.Driver` interface, it was found that the behavior of active and committed snapshots was confusing. We attempt to clean this up by doing the following: 1. Define the concept of "active" and "committed" snapshots and their lifecycle relationship. Active snapshots can be created from a parent. Committed snapshots can only be created from active snapshots. 2. Only committed snapshots can be a parent. 3. Unify the keyspace of snapshots. For common operations, such as removal and stat, we only have a single method that works for both active and committed snapshots. For methods that take one or the other, the restriction is called out. `Remove` and `Delete` are consolidated for this purpose. 4. Define the `Info` data type to include name, parent, kind and readonly state. This allows us to collect `Exists` and `Parent` into a single method `Stat` and simplifies the `Walk` method, eliding `Active`. 5. The `Driver` has been renamed to `Snapshotter` due to the overuse of the term `Driver`. Effectively, we now have snapshots that are either active or committed. Signed-off-by: Stephen J Day --- snapshot/driver.go | 302 ---------------------------------- snapshot/snapshotter.go | 347 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 347 insertions(+), 302 deletions(-) delete mode 100644 snapshot/driver.go create mode 100644 snapshot/snapshotter.go diff --git a/snapshot/driver.go b/snapshot/driver.go deleted file mode 100644 index e195511..0000000 --- a/snapshot/driver.go +++ /dev/null @@ -1,302 +0,0 @@ -package snapshot - -import ( - "io/ioutil" - "os" - "path/filepath" - "testing" - - "github.com/docker/containerd" - "github.com/docker/containerd/testutil" - "github.com/stretchr/testify/assert" -) - -// Driver defines the methods required to implement a snapshot driver for -// allocating, snapshotting and mounting abstract filesystems. The model works -// by building up sets of changes with parent-child relationships. -// -// These differ from the concept of the graphdriver in that the Manager has no -// knowledge of images, layers or containers. Users simply prepare and commit -// directories. We also avoid the integration between graph driver's and the -// tar format used to represent the changesets. -// -// A snapshot represents a filesystem state. Every snapshot has a parent, where -// the empty parent is represented by the empty string. A diff can be taken -// between a parent and its snapshot to generate a classic layer. -// -// For convention, we define the following terms to be used throughout this -// interface for driver implementations: -// -// `name` - refers to a forkable snapshot, typically read only -// `key` - refers to an active transaction, either a prepare or view -// `parent` - refers to the parent in relation to a name -// -// TODO(stevvooe): Update this description when things settle. -// -// Importing a Layer -// -// To import a layer, we simply have the Manager provide a list of -// mounts to be applied such that our dst will capture a changeset. We start -// out by getting a path to the layer tar file and creating a temp location to -// unpack it to: -// -// layerPath, tmpLocation := getLayerPath(), mkTmpDir() // just a path to layer tar file. -// -// We then use a Manager to prepare the temporary location as a -// snapshot point: -// -// sm := NewManager() -// mounts, err := sm.Prepare(tmpLocation, "") -// if err != nil { ... } -// -// Note that we provide "" as the parent, since we are applying the diff to an -// empty directory. We get back a list of mounts from Manager.Prepare. -// Before proceeding, we perform all these mounts: -// -// if err := MountAll(mounts); err != nil { ... } -// -// Once the mounts are performed, our temporary location is ready to capture -// a diff. In practice, this works similar to a filesystem transaction. The -// next step is to unpack the layer. We have a special function unpackLayer -// that applies the contents of the layer to target location and calculates the -// DiffID of the unpacked layer (this is a requirement for docker -// implementation): -// -// layer, err := os.Open(layerPath) -// if err != nil { ... } -// digest, err := unpackLayer(tmpLocation, layer) // unpack into layer location -// if err != nil { ... } -// -// When the above completes, we should have a filesystem the represents the -// contents of the layer. Careful implementations should verify that digest -// matches the expected DiffID. When completed, we unmount the mounts: -// -// unmount(mounts) // optional, for now -// -// Now that we've verified and unpacked our layer, we create a location to -// commit the actual diff. For this example, we are just going to use the layer -// digest, but in practice, this will probably be the ChainID: -// -// diffPath := filepath.Join("/layers", digest) // name location for the uncompressed layer digest -// if err := sm.Commit(diffPath, tmpLocation); err != nil { ... } -// -// Now, we have a layer in the Manager that can be accessed with the -// opaque diffPath provided during commit. -// -// Importing the Next Layer -// -// Making a layer depend on the above is identical to the process described -// above except that the parent is provided as diffPath when calling -// Manager.Prepare: -// -// mounts, err := sm.Prepare(tmpLocation, parentDiffPath) -// -// The diff will be captured at tmpLocation, as the layer is applied. -// -// Running a Container -// -// To run a container, we simply provide Manager.Prepare the diffPath -// of the image we want to start the container from. After mounting, the -// prepared path can be used directly as the container's filesystem: -// -// mounts, err := sm.Prepare(containerRootFS, imageDiffPath) -// -// The returned mounts can then be passed directly to the container runtime. If -// one would like to create a new image from the filesystem, -// Manager.Commit is called: -// -// if err := sm.Commit(newImageDiff, containerRootFS); err != nil { ... } -// -// Alternatively, for most container runs, Manager.Rollback will be -// called to signal Manager to abandon the changes. -type Driver interface { - // Prepare returns a set of mounts corresponding to an active snapshot - // transaction, identified by the provided transaction key. - // - // If a parent is provided, after performing the mounts, the destination - // will start with the content of the parent. Changes to the mounted - // destination will be captured in relation to the provided parent. The - // default parent, "", is an empty directory. - // - // The changes may be saved to a new snapshot by calling Commit. When one - // is done with the transaction, Remove should be called on the key. - // - // Multiple calls to Prepare or View with the same key should fail. - Prepare(key, parent string) ([]containerd.Mount, error) - - // View behaves identically to Prepare except the result may not be committed - // back to the snapshot manager. View returns a readonly view on the - // parent, with the transaction tracked by the given key. - // - // This method operates identically to Prepare, except that Mounts returned - // may have the readonly flag set. Any modifications to the underlying - // filesystem will be ignored. - // - // Commit may not be called on the provided key. To collect the resources - // associated with key, Remove must be called with key as the argument. - View(key, parent string) ([]containerd.Mount, error) - - // Commit captures the changes between key and its parent into a snapshot - // identified by name. The name can then be used with the driver's other - // methods to create subsequent snapshots. - // - // A snapshot will be created under name with the parent that started the - // transaction. - // - // Commit may be called multiple times on the same key. Snapshots created - // in this manner will all reference the parent used to start the - // transaction. - Commit(name, key string) error - - // Mounts returns the mounts for the transaction identified by key. Can be - // called on an read-write or readonly transaction. - // - // This can be used to recover mounts after calling View or Prepare. - Mounts(key string) ([]containerd.Mount, error) - - // Remove abandons the transaction identified by key. All resources - // associated with the key will be removed. - Remove(key string) error - - // Parent returns the parent of snapshot identified by name. - Parent(name string) (string, error) - - // Exists returns true if the snapshot with name exists. - Exists(name string) bool - - // Delete the snapshot idenfitied by name. - // - // If name has children, the operation will fail. - Delete(name string) error - - // TODO(stevvooe): The methods below are still in flux. We'll need to work - // out the roles of active and committed snapshots for this to become more - // clear. - - // Walk the committed snapshots. - Walk(fn func(name string) error) error - - // Active will call fn for each active transaction. - Active(fn func(key string) error) error -} - -// DriverSuite runs a test suite on the driver given a factory function. -func DriverSuite(t *testing.T, name string, driverFn func(root string) (Driver, func(), error)) { - t.Run("Basic", makeTest(t, name, driverFn, checkDriverBasic)) -} - -func makeTest(t *testing.T, name string, driverFn func(root string) (Driver, func(), error), fn func(t *testing.T, driver Driver, work string)) func(t *testing.T) { - return func(t *testing.T) { - // Make two directories: a driver root and a play area for the tests: - // - // /tmp - // work/ -> passed to test functions - // root/ -> passed to driver - // - tmpDir, err := ioutil.TempDir("", "snapshot-suite-"+name+"-") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tmpDir) - - root := filepath.Join(tmpDir, "root") - if err := os.MkdirAll(root, 0777); err != nil { - t.Fatal(err) - } - - driver, cleanup, err := driverFn(root) - if err != nil { - t.Fatal(err) - } - defer cleanup() - - work := filepath.Join(tmpDir, "work") - if err := os.MkdirAll(work, 0777); err != nil { - t.Fatal(err) - } - - defer testutil.DumpDir(t, tmpDir) - fn(t, driver, work) - } -} - -// checkDriverBasic tests the basic workflow of a snapshot driver. -func checkDriverBasic(t *testing.T, driver Driver, work string) { - preparing := filepath.Join(work, "preparing") - if err := os.MkdirAll(preparing, 0777); err != nil { - t.Fatal(err) - } - - mounts, err := driver.Prepare(preparing, "") - if err != nil { - t.Fatal(err) - } - - if len(mounts) < 1 { - t.Fatal("expected mounts to have entries") - } - - if err := containerd.MountAll(mounts, preparing); err != nil { - t.Fatal(err) - } - defer testutil.Unmount(t, preparing) - - if err := ioutil.WriteFile(filepath.Join(preparing, "foo"), []byte("foo\n"), 0777); err != nil { - t.Fatal(err) - } - - if err := os.MkdirAll(filepath.Join(preparing, "a", "b", "c"), 0755); err != nil { - t.Fatal(err) - } - - committed := filepath.Join(work, "committed") - if err := driver.Commit(committed, preparing); err != nil { - t.Fatal(err) - } - - parent, err := driver.Parent(committed) - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, parent, "") - - next := filepath.Join(work, "nextlayer") - if err := os.MkdirAll(next, 0777); err != nil { - t.Fatal(err) - } - - mounts, err = driver.Prepare(next, committed) - if err != nil { - t.Fatal(err) - } - if err := containerd.MountAll(mounts, next); err != nil { - t.Fatal(err) - } - defer testutil.Unmount(t, next) - - if err := ioutil.WriteFile(filepath.Join(next, "bar"), []byte("bar\n"), 0777); err != nil { - t.Fatal(err) - } - - // also, change content of foo to bar - if err := ioutil.WriteFile(filepath.Join(next, "foo"), []byte("bar\n"), 0777); err != nil { - t.Fatal(err) - } - - if err := os.RemoveAll(filepath.Join(next, "a", "b")); err != nil { - t.Log(err) - } - - nextCommitted := filepath.Join(work, "committed-next") - if err := driver.Commit(nextCommitted, next); err != nil { - t.Fatal(err) - } - - parent, err = driver.Parent(nextCommitted) - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, parent, committed) -} diff --git a/snapshot/snapshotter.go b/snapshot/snapshotter.go new file mode 100644 index 0000000..b8cadfc --- /dev/null +++ b/snapshot/snapshotter.go @@ -0,0 +1,347 @@ +package snapshot + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/docker/containerd" + "github.com/docker/containerd/testutil" + "github.com/stretchr/testify/assert" +) + +// Kind identifies the kind of snapshot. +type Kind int + +// definitions of snapshot kinds +const ( + KindActive Kind = iota + KindCommitted +) + +// Info provides information about a particular snapshot. +type Info struct { + Name string // name or key of snapshot + Parent string // name of parent snapshot + Kind Kind // active or committed snapshot + Readonly bool // true if readonly, only valid for active +} + +// Snapshotter defines the methods required to implement a snapshot snapshotter for +// allocating, snapshotting and mounting filesystem changesets. The model works +// by building up sets of changes with parent-child relationships. +// +// A snapshot represents a filesystem state. Every snapshot has a parent, where +// the empty parent is represented by the empty string. A diff can be taken +// between a parent and its snapshot to generate a classic layer. +// +// An active snapshot is created by calling `Prepare`. After mounting, changes +// can be made to the snapshot. The act of commiting creates a committed +// snapshot. The committed snapshot will get the parent of active snapshot. The +// committed snapshot can then be used as a parent. Active snapshots can never +// act as a parent. +// +// Snapshots are best understood by their lifecycle. Active snapshots are +// always created with Prepare or View. Committed snapshots are always created +// with Commit. Active snapshots never become committed snapshots and vice +// versa. All snapshots may be removed. +// +// For consistency, we define the following terms to be used throughout this +// interface for snapshotter implementations: +// +// `key` - refers to an active snapshot +// `name` - refers to a committed snapshot +// `parent` - refers to the parent in relation +// +// Most methods take various combinations of these identifiers. Typically, +// `name` and `parent` will be used in cases where a method *only* takes +// committed snapshots. `key` will be used to refer to active snapshots in most +// cases, except where noted. All variables used to access snapshots use the +// same key space. For example, an active snapshot may not share the same key +// with a committed snapshot. +// +// We cover several examples below to demonstrate the utility of a snapshot +// snapshotter. +// +// Importing a Layer +// +// To import a layer, we simply have the Manager provide a list of +// mounts to be applied such that our dst will capture a changeset. We start +// out by getting a path to the layer tar file and creating a temp location to +// unpack it to: +// +// layerPath, tmpDir := getLayerPath(), mkTmpDir() // just a path to layer tar file. +// +// We start by using a Snapshotter to Prepare a new snapshot transaction, using a +// key and descending from the empty parent "": +// +// mounts, err := snapshotter.Prepare(key, "") +// if err != nil { ... } +// +// We get back a list of mounts from Snapshotter.Prepare, with the key identifying +// the active snapshot. Mount this to the temporary location with the +// following: +// +// if err := MountAll(mounts, tmpDir); err != nil { ... } +// +// Once the mounts are performed, our temporary location is ready to capture +// a diff. In practice, this works similar to a filesystem transaction. The +// next step is to unpack the layer. We have a special function unpackLayer +// that applies the contents of the layer to target location and calculates the +// DiffID of the unpacked layer (this is a requirement for docker +// implementation): +// +// layer, err := os.Open(layerPath) +// if err != nil { ... } +// digest, err := unpackLayer(tmpLocation, layer) // unpack into layer location +// if err != nil { ... } +// +// When the above completes, we should have a filesystem the represents the +// contents of the layer. Careful implementations should verify that digest +// matches the expected DiffID. When completed, we unmount the mounts: +// +// unmount(mounts) // optional, for now +// +// Now that we've verified and unpacked our layer, we commit the active +// snapshot to a name. For this example, we are just going to use the layer +// digest, but in practice, this will probably be the ChainID: +// +// if err := snapshotter.Commit(digest.String(), key); err != nil { ... } +// +// Now, we have a layer in the Snapshotter that can be accessed with the digest +// provided during commit. Once you have committed the snapshot, the active +// snapshot can be removed with the following: +// +// snapshotter.Remove(key) +// +// Importing the Next Layer +// +// Making a layer depend on the above is identical to the process described +// above except that the parent is provided as parent when calling +// Manager.Prepare, assuming a clean tmpLocation: +// +// mounts, err := sm.Prepare(tmpLocation, parentDigest) +// +// We then mount, apply and commit, as we did above. The new snapshot will be +// based on the content of the previous one. +// +// Running a Container +// +// To run a container, we simply provide Snapshotter.Prepare the committed image +// snapshot as the parent. After mounting, the prepared path can +// be used directly as the container's filesystem: +// +// mounts, err := sm.Prepare(containerKey, imageRootFSChainID) +// +// The returned mounts can then be passed directly to the container runtime. If +// one would like to create a new image from the filesystem, Manager.Commit is +// called: +// +// if err := sm.Commit(newImageSnapshot, containerKey); err != nil { ... } +// +// Alternatively, for most container runs, Manager.Remove will be called to +// signal the Snapshotter to abandon the changes. +type Snapshotter interface { + // Stat returns the info for an active or committed snapshot by name or + // key. + // + // Should be used for parent resolution, existence checks and to discern + // the kind of snapshot. + Stat(key string) (Info, error) + + // Mounts returns the mounts for the active snapshot transaction identified + // by key. Can be called on an read-write or readonly transaction. This is + // available only for active snapshots. + // + // This can be used to recover mounts after calling View or Prepare. + Mounts(key string) ([]containerd.Mount, error) + + // Prepare creates an active snapshot identified by key descending from the + // provided parent. The returned mounts can be used to mount the snapshot + // to capture changes. + // + // If a parent is provided, after performing the mounts, the destination + // will start with the content of the parent. The parent must be a + // committed snapshot. Changes to the mounted destination will be captured + // in relation to the parent. The default parent, "", is an empty + // directory. + // + // The changes may be saved to a committed snapshot by calling Commit. When + // one is done with the transaction, Remove should be called on the key. + // + // Multiple calls to Prepare or View with the same key should fail. + Prepare(key, parent string) ([]containerd.Mount, error) + + // View behaves identically to Prepare except the result may not be + // committed back to the snapshot snapshotter. View returns a readonly view on + // the parent, with the active snapshot being tracked by the given key. + // + // This method operates identically to Prepare, except that Mounts returned + // may have the readonly flag set. Any modifications to the underlying + // filesystem will be ignored. Implementations may perform this in a more + // efficient manner that differs from what would be attempted with + // `Prepare`. + // + // Commit may not be called on the provided key and will return an error. + // To collect the resources associated with key, Remove must be called with + // key as the argument. + View(key, parent string) ([]containerd.Mount, error) + + // Commit captures the changes between key and its parent into a snapshot + // identified by name. The name can then be used with the snapshotter's other + // methods to create subsequent snapshots. + // + // A committed snapshot will be created under name with the parent of the + // active snapshot. + // + // Commit may be called multiple times on the same key. Snapshots created + // in this manner will all reference the parent used to start the + // transaction. + Commit(name, key string) error + + // Remove the committed or active snapshot by the provided key. + // + // All resources associated with the key will be removed. + // + // If the snapshot is a parent of another snapshot, its children must be + // removed before proceeding. + Remove(key string) error + + // Walk the committed snapshots. For each snapshot in the snapshotter, the + // function will be called. + Walk(fn func(Info) error) error +} + +// SnapshotterSuite runs a test suite on the snapshotter given a factory function. +func SnapshotterSuite(t *testing.T, name string, snapshotterFn func(root string) (Snapshotter, func(), error)) { + t.Run("Basic", makeTest(t, name, snapshotterFn, checkSnapshotterBasic)) +} + +func makeTest(t *testing.T, name string, snapshotterFn func(root string) (Snapshotter, func(), error), fn func(t *testing.T, snapshotter Snapshotter, work string)) func(t *testing.T) { + return func(t *testing.T) { + // Make two directories: a snapshotter root and a play area for the tests: + // + // /tmp + // work/ -> passed to test functions + // root/ -> passed to snapshotter + // + tmpDir, err := ioutil.TempDir("", "snapshot-suite-"+name+"-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + root := filepath.Join(tmpDir, "root") + if err := os.MkdirAll(root, 0777); err != nil { + t.Fatal(err) + } + + snapshotter, cleanup, err := snapshotterFn(root) + if err != nil { + t.Fatal(err) + } + defer cleanup() + + work := filepath.Join(tmpDir, "work") + if err := os.MkdirAll(work, 0777); err != nil { + t.Fatal(err) + } + + defer testutil.DumpDir(t, tmpDir) + fn(t, snapshotter, work) + } +} + +// checkSnapshotterBasic tests the basic workflow of a snapshot snapshotter. +func checkSnapshotterBasic(t *testing.T, snapshotter Snapshotter, work string) { + preparing := filepath.Join(work, "preparing") + if err := os.MkdirAll(preparing, 0777); err != nil { + t.Fatal(err) + } + + mounts, err := snapshotter.Prepare(preparing, "") + if err != nil { + t.Fatal(err) + } + + if len(mounts) < 1 { + t.Fatal("expected mounts to have entries") + } + + if err := containerd.MountAll(mounts, preparing); err != nil { + t.Fatal(err) + } + defer testutil.Unmount(t, preparing) + + if err := ioutil.WriteFile(filepath.Join(preparing, "foo"), []byte("foo\n"), 0777); err != nil { + t.Fatal(err) + } + + if err := os.MkdirAll(filepath.Join(preparing, "a", "b", "c"), 0755); err != nil { + t.Fatal(err) + } + + committed := filepath.Join(work, "committed") + if err := snapshotter.Commit(committed, preparing); err != nil { + t.Fatal(err) + } + + si, err := snapshotter.Stat(committed) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, si.Parent, "") + + next := filepath.Join(work, "nextlayer") + if err := os.MkdirAll(next, 0777); err != nil { + t.Fatal(err) + } + + mounts, err = snapshotter.Prepare(next, committed) + if err != nil { + t.Fatal(err) + } + if err := containerd.MountAll(mounts, next); err != nil { + t.Fatal(err) + } + defer testutil.Unmount(t, next) + + if err := ioutil.WriteFile(filepath.Join(next, "bar"), []byte("bar\n"), 0777); err != nil { + t.Fatal(err) + } + + // also, change content of foo to bar + if err := ioutil.WriteFile(filepath.Join(next, "foo"), []byte("bar\n"), 0777); err != nil { + t.Fatal(err) + } + + if err := os.RemoveAll(filepath.Join(next, "a", "b")); err != nil { + t.Log(err) + } + + nextCommitted := filepath.Join(work, "committed-next") + if err := snapshotter.Commit(nextCommitted, next); err != nil { + t.Fatal(err) + } + + si2, err := snapshotter.Stat(nextCommitted) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, si2.Parent, committed) + + expected := map[string]Info{ + si.Name: si, + si2.Name: si2, + } + walked := map[string]Info{} // walk is not ordered + assert.NoError(t, snapshotter.Walk(func(si Info) error { + walked[si.Name] = si + return nil + })) + + assert.Equal(t, expected, walked) +}