| /* |
| Copyright The containerd Authors. |
| |
| 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 snapshots |
| |
| import ( |
| "context" |
| "errors" |
| "fmt" |
| "io" |
| "os" |
| "path/filepath" |
| "strconv" |
| "strings" |
| "text/tabwriter" |
| "time" |
| |
| "github.com/containerd/errdefs" |
| "github.com/containerd/log" |
| digest "github.com/opencontainers/go-digest" |
| ocispec "github.com/opencontainers/image-spec/specs-go/v1" |
| "github.com/urfave/cli/v2" |
| |
| containerd "github.com/containerd/containerd/v2/client" |
| "github.com/containerd/containerd/v2/cmd/ctr/commands" |
| "github.com/containerd/containerd/v2/core/content" |
| "github.com/containerd/containerd/v2/core/diff" |
| "github.com/containerd/containerd/v2/core/mount" |
| "github.com/containerd/containerd/v2/core/snapshots" |
| "github.com/containerd/containerd/v2/pkg/progress" |
| "github.com/containerd/containerd/v2/pkg/rootfs" |
| ) |
| |
| // Command is the cli command for managing snapshots |
| var Command = &cli.Command{ |
| Name: "snapshots", |
| Aliases: []string{"snapshot"}, |
| Usage: "Manage snapshots", |
| Flags: commands.SnapshotterFlags, |
| Subcommands: cli.Commands{ |
| commitCommand, |
| diffCommand, |
| infoCommand, |
| listCommand, |
| mountCommand, |
| prepareCommand, |
| removeCommand, |
| setLabelCommand, |
| treeCommand, |
| unpackCommand, |
| usageCommand, |
| viewCommand, |
| }, |
| } |
| |
| var listCommand = &cli.Command{ |
| Name: "list", |
| Aliases: []string{"ls"}, |
| Usage: "List snapshots", |
| Action: func(cliContext *cli.Context) error { |
| client, ctx, cancel, err := commands.NewClient(cliContext) |
| if err != nil { |
| return err |
| } |
| defer cancel() |
| var ( |
| snapshotter = client.SnapshotService(cliContext.String("snapshotter")) |
| tw = tabwriter.NewWriter(os.Stdout, 1, 8, 1, ' ', 0) |
| ) |
| fmt.Fprintln(tw, "KEY\tPARENT\tKIND\t") |
| if err := snapshotter.Walk(ctx, func(ctx context.Context, info snapshots.Info) error { |
| fmt.Fprintf(tw, "%v\t%v\t%v\t\n", |
| info.Name, |
| info.Parent, |
| info.Kind) |
| return nil |
| }); err != nil { |
| return err |
| } |
| |
| return tw.Flush() |
| }, |
| } |
| |
| var diffCommand = &cli.Command{ |
| Name: "diff", |
| Usage: "Get the diff of two snapshots. the default second snapshot is the first snapshot's parent.", |
| ArgsUsage: "[flags] <idA> [<idB>]", |
| Flags: append([]cli.Flag{ |
| &cli.StringFlag{ |
| Name: "media-type", |
| Usage: "Media type to use for creating diff", |
| Value: ocispec.MediaTypeImageLayerGzip, |
| }, |
| &cli.StringFlag{ |
| Name: "ref", |
| Usage: "Content upload reference to use", |
| }, |
| &cli.BoolFlag{ |
| Name: "keep", |
| Usage: "Keep diff content. up to creator to delete it.", |
| }, |
| }, commands.LabelFlag), |
| Action: func(cliContext *cli.Context) error { |
| var ( |
| idA = cliContext.Args().First() |
| idB = cliContext.Args().Get(1) |
| ) |
| if idA == "" { |
| return errors.New("snapshot id must be provided") |
| } |
| client, ctx, cancel, err := commands.NewClient(cliContext) |
| if err != nil { |
| return err |
| } |
| defer cancel() |
| |
| ctx, done, err := client.WithLease(ctx) |
| if err != nil { |
| return err |
| } |
| defer done(ctx) |
| |
| var desc ocispec.Descriptor |
| labels := commands.LabelArgs(cliContext.StringSlice("label")) |
| snapshotter := client.SnapshotService(cliContext.String("snapshotter")) |
| |
| if cliContext.Bool("keep") { |
| labels["containerd.io/gc.root"] = time.Now().UTC().Format(time.RFC3339) |
| } |
| opts := []diff.Opt{ |
| diff.WithMediaType(cliContext.String("media-type")), |
| diff.WithReference(cliContext.String("ref")), |
| diff.WithLabels(labels), |
| } |
| // SOURCE_DATE_EPOCH is propagated via the ctx, so no need to specify diff.WithSourceDateEpoch here |
| |
| if idB == "" { |
| desc, err = rootfs.CreateDiff(ctx, idA, snapshotter, client.DiffService(), opts...) |
| if err != nil { |
| return err |
| } |
| } else { |
| desc, err = withMounts(ctx, idA, snapshotter, func(a []mount.Mount) (ocispec.Descriptor, error) { |
| return withMounts(ctx, idB, snapshotter, func(b []mount.Mount) (ocispec.Descriptor, error) { |
| return client.DiffService().Compare(ctx, a, b, opts...) |
| }) |
| }) |
| if err != nil { |
| return err |
| } |
| } |
| |
| ra, err := client.ContentStore().ReaderAt(ctx, desc) |
| if err != nil { |
| return err |
| } |
| defer ra.Close() |
| _, err = io.Copy(os.Stdout, content.NewReader(ra)) |
| |
| return err |
| }, |
| } |
| |
| func withMounts(ctx context.Context, id string, sn snapshots.Snapshotter, f func(mounts []mount.Mount) (ocispec.Descriptor, error)) (ocispec.Descriptor, error) { |
| var mounts []mount.Mount |
| info, err := sn.Stat(ctx, id) |
| if err != nil { |
| return ocispec.Descriptor{}, err |
| } |
| if info.Kind == snapshots.KindActive { |
| mounts, err = sn.Mounts(ctx, id) |
| if err != nil { |
| return ocispec.Descriptor{}, err |
| } |
| } else { |
| key := fmt.Sprintf("%s-view-key", id) |
| mounts, err = sn.View(ctx, key, id) |
| if err != nil { |
| return ocispec.Descriptor{}, err |
| } |
| defer sn.Remove(ctx, key) |
| } |
| return f(mounts) |
| } |
| |
| var usageCommand = &cli.Command{ |
| Name: "usage", |
| Usage: "Usage snapshots", |
| ArgsUsage: "[flags] [<key>, ...]", |
| Flags: []cli.Flag{ |
| &cli.BoolFlag{ |
| Name: "b", |
| Usage: "Display size in bytes", |
| }, |
| }, |
| Action: func(cliContext *cli.Context) error { |
| var displaySize func(int64) string |
| if cliContext.Bool("b") { |
| displaySize = func(s int64) string { |
| return strconv.FormatInt(s, 10) |
| } |
| } else { |
| displaySize = func(s int64) string { |
| return progress.Bytes(s).String() |
| } |
| } |
| client, ctx, cancel, err := commands.NewClient(cliContext) |
| if err != nil { |
| return err |
| } |
| defer cancel() |
| var ( |
| snapshotter = client.SnapshotService(cliContext.String("snapshotter")) |
| tw = tabwriter.NewWriter(os.Stdout, 1, 8, 1, ' ', 0) |
| ) |
| fmt.Fprintln(tw, "KEY\tSIZE\tINODES\t") |
| if cliContext.NArg() == 0 { |
| if err := snapshotter.Walk(ctx, func(ctx context.Context, info snapshots.Info) error { |
| usage, err := snapshotter.Usage(ctx, info.Name) |
| if err != nil { |
| return err |
| } |
| fmt.Fprintf(tw, "%v\t%s\t%d\t\n", info.Name, displaySize(usage.Size), usage.Inodes) |
| return nil |
| }); err != nil { |
| return err |
| } |
| } else { |
| for _, id := range cliContext.Args().Slice() { |
| usage, err := snapshotter.Usage(ctx, id) |
| if err != nil { |
| return err |
| } |
| fmt.Fprintf(tw, "%v\t%s\t%d\t\n", id, displaySize(usage.Size), usage.Inodes) |
| } |
| } |
| |
| return tw.Flush() |
| }, |
| } |
| |
| var removeCommand = &cli.Command{ |
| Name: "delete", |
| Aliases: []string{"del", "remove", "rm"}, |
| ArgsUsage: "<key> [<key>, ...]", |
| Usage: "Remove snapshots", |
| Action: func(cliContext *cli.Context) error { |
| client, ctx, cancel, err := commands.NewClient(cliContext) |
| if err != nil { |
| return err |
| } |
| defer cancel() |
| snapshotter := client.SnapshotService(cliContext.String("snapshotter")) |
| for _, key := range cliContext.Args().Slice() { |
| err = snapshotter.Remove(ctx, key) |
| if err != nil { |
| return fmt.Errorf("failed to remove %q: %w", key, err) |
| } |
| } |
| |
| return nil |
| }, |
| } |
| |
| var prepareCommand = &cli.Command{ |
| Name: "prepare", |
| Usage: "Prepare a snapshot from a committed snapshot", |
| ArgsUsage: "[flags] <key> [<parent>]", |
| Flags: []cli.Flag{ |
| &cli.StringFlag{ |
| Name: "target", |
| Aliases: []string{"t"}, |
| Usage: "Mount target path, will print mount, if provided", |
| }, |
| &cli.BoolFlag{ |
| Name: "mounts", |
| Usage: "Print out snapshot mounts as JSON", |
| }, |
| }, |
| Action: func(cliContext *cli.Context) error { |
| if narg := cliContext.NArg(); narg < 1 || narg > 2 { |
| return cli.ShowSubcommandHelp(cliContext) |
| } |
| var ( |
| target = cliContext.String("target") |
| key = cliContext.Args().Get(0) |
| parent = cliContext.Args().Get(1) |
| ) |
| client, ctx, cancel, err := commands.NewClient(cliContext) |
| if err != nil { |
| return err |
| } |
| defer cancel() |
| |
| snapshotter := client.SnapshotService(cliContext.String("snapshotter")) |
| labels := map[string]string{ |
| "containerd.io/gc.root": time.Now().UTC().Format(time.RFC3339), |
| } |
| |
| mounts, err := snapshotter.Prepare(ctx, key, parent, snapshots.WithLabels(labels)) |
| if err != nil { |
| return err |
| } |
| |
| if target != "" { |
| printMounts(target, mounts) |
| } |
| |
| if cliContext.Bool("mounts") { |
| commands.PrintAsJSON(mounts) |
| } |
| |
| return nil |
| }, |
| } |
| |
| var viewCommand = &cli.Command{ |
| Name: "view", |
| Usage: "Create a read-only snapshot from a committed snapshot", |
| ArgsUsage: "[flags] <key> [<parent>]", |
| Flags: []cli.Flag{ |
| &cli.StringFlag{ |
| Name: "target", |
| Aliases: []string{"t"}, |
| Usage: "Mount target path, will print mount, if provided", |
| }, |
| &cli.BoolFlag{ |
| Name: "mounts", |
| Usage: "Print out snapshot mounts as JSON", |
| }, |
| }, |
| Action: func(cliContext *cli.Context) error { |
| if narg := cliContext.NArg(); narg < 1 || narg > 2 { |
| return cli.ShowSubcommandHelp(cliContext) |
| } |
| var ( |
| target = cliContext.String("target") |
| key = cliContext.Args().Get(0) |
| parent = cliContext.Args().Get(1) |
| ) |
| client, ctx, cancel, err := commands.NewClient(cliContext) |
| if err != nil { |
| return err |
| } |
| defer cancel() |
| |
| snapshotter := client.SnapshotService(cliContext.String("snapshotter")) |
| mounts, err := snapshotter.View(ctx, key, parent) |
| if err != nil { |
| return err |
| } |
| |
| if target != "" { |
| printMounts(target, mounts) |
| } |
| |
| if cliContext.Bool("mounts") { |
| commands.PrintAsJSON(mounts) |
| } |
| |
| return nil |
| }, |
| } |
| |
| var mountCommand = &cli.Command{ |
| Name: "mounts", |
| Aliases: []string{"m", "mount"}, |
| Usage: "Mount gets mount commands for the snapshots", |
| ArgsUsage: "<target> <key>", |
| Flags: []cli.Flag{ |
| &cli.BoolFlag{ |
| Name: "temp", |
| Usage: "Mounts the snapshot mounts as a temp mount and returns a bind mount", |
| }, |
| }, |
| Action: func(cliContext *cli.Context) error { |
| if cliContext.NArg() != 2 { |
| return cli.ShowSubcommandHelp(cliContext) |
| } |
| var ( |
| target = cliContext.Args().Get(0) |
| key = cliContext.Args().Get(1) |
| ) |
| client, ctx, cancel, err := commands.NewClient(cliContext) |
| if err != nil { |
| return err |
| } |
| defer cancel() |
| snapshotter := client.SnapshotService(cliContext.String("snapshotter")) |
| mounts, err := snapshotter.Mounts(ctx, key) |
| if err != nil { |
| return err |
| } |
| |
| mm := client.MountManager() |
| |
| var opts []mount.ActivateOpt |
| if cliContext.Bool("temp") { |
| opts = append(opts, mount.WithTemporary) |
| } |
| info, err := mm.Activate(ctx, "ctr"+key, mounts, opts...) |
| if err == nil { |
| mounts = info.System |
| } else if !errdefs.IsNotImplemented(err) { |
| return fmt.Errorf("activate error: %w", err) |
| } |
| |
| printMounts(target, mounts) |
| |
| return nil |
| }, |
| } |
| |
| var commitCommand = &cli.Command{ |
| Name: "commit", |
| Usage: "Commit an active snapshot into the provided name", |
| ArgsUsage: "<key> <active>", |
| Action: func(cliContext *cli.Context) error { |
| if cliContext.NArg() != 2 { |
| return cli.ShowSubcommandHelp(cliContext) |
| } |
| var ( |
| key = cliContext.Args().Get(0) |
| active = cliContext.Args().Get(1) |
| ) |
| client, ctx, cancel, err := commands.NewClient(cliContext) |
| if err != nil { |
| return err |
| } |
| defer cancel() |
| snapshotter := client.SnapshotService(cliContext.String("snapshotter")) |
| labels := map[string]string{ |
| "containerd.io/gc.root": time.Now().UTC().Format(time.RFC3339), |
| } |
| return snapshotter.Commit(ctx, key, active, snapshots.WithLabels(labels)) |
| }, |
| } |
| |
| var treeCommand = &cli.Command{ |
| Name: "tree", |
| Usage: "Display tree view of snapshot branches", |
| Action: func(cliContext *cli.Context) error { |
| client, ctx, cancel, err := commands.NewClient(cliContext) |
| if err != nil { |
| return err |
| } |
| defer cancel() |
| var ( |
| snapshotter = client.SnapshotService(cliContext.String("snapshotter")) |
| tree = newSnapshotTree() |
| ) |
| |
| if err := snapshotter.Walk(ctx, func(ctx context.Context, info snapshots.Info) error { |
| // Get or create node and add node details |
| tree.add(info) |
| return nil |
| }); err != nil { |
| return err |
| } |
| |
| printTree(tree) |
| |
| return nil |
| }, |
| } |
| |
| var infoCommand = &cli.Command{ |
| Name: "info", |
| Usage: "Get info about a snapshot", |
| ArgsUsage: "<key>", |
| Action: func(cliContext *cli.Context) error { |
| if cliContext.NArg() != 1 { |
| return cli.ShowSubcommandHelp(cliContext) |
| } |
| |
| key := cliContext.Args().Get(0) |
| client, ctx, cancel, err := commands.NewClient(cliContext) |
| if err != nil { |
| return err |
| } |
| defer cancel() |
| snapshotter := client.SnapshotService(cliContext.String("snapshotter")) |
| info, err := snapshotter.Stat(ctx, key) |
| if err != nil { |
| return err |
| } |
| |
| commands.PrintAsJSON(info) |
| |
| return nil |
| }, |
| } |
| |
| var setLabelCommand = &cli.Command{ |
| Name: "label", |
| Usage: "Add labels to content", |
| ArgsUsage: "<name> [<label>=<value> ...]", |
| Description: "labels snapshots in the snapshotter", |
| Action: func(cliContext *cli.Context) error { |
| key, labels := commands.ObjectWithLabelArgs(cliContext) |
| client, ctx, cancel, err := commands.NewClient(cliContext) |
| if err != nil { |
| return err |
| } |
| defer cancel() |
| |
| snapshotter := client.SnapshotService(cliContext.String("snapshotter")) |
| |
| info := snapshots.Info{ |
| Name: key, |
| Labels: map[string]string{}, |
| } |
| |
| var paths []string |
| for k, v := range labels { |
| paths = append(paths, fmt.Sprintf("labels.%s", k)) |
| if v != "" { |
| info.Labels[k] = v |
| } |
| } |
| |
| // Nothing updated, do no clear |
| if len(paths) == 0 { |
| info, err = snapshotter.Stat(ctx, info.Name) |
| } else { |
| info, err = snapshotter.Update(ctx, info, paths...) |
| } |
| if err != nil { |
| return err |
| } |
| |
| var labelStrings []string |
| for k, v := range info.Labels { |
| labelStrings = append(labelStrings, fmt.Sprintf("%s=%s", k, v)) |
| } |
| |
| fmt.Println(strings.Join(labelStrings, ",")) |
| |
| return nil |
| }, |
| } |
| |
| var unpackCommand = &cli.Command{ |
| Name: "unpack", |
| Usage: "Unpack applies layers from a manifest to a snapshot", |
| ArgsUsage: "[flags] <digest>", |
| Flags: append([]cli.Flag{ |
| &cli.BoolFlag{ |
| Name: "sync-fs", |
| Usage: "Synchronize the underlying filesystem containing files when unpack images, false by default", |
| }, |
| }, commands.SnapshotterFlags...), |
| Action: func(cliContext *cli.Context) error { |
| dgst, err := digest.Parse(cliContext.Args().First()) |
| if err != nil { |
| return err |
| } |
| client, ctx, cancel, err := commands.NewClient(cliContext) |
| if err != nil { |
| return err |
| } |
| defer cancel() |
| log.G(ctx).Debugf("unpacking layers from manifest %s", dgst.String()) |
| // TODO: Support unpack by name |
| images, err := client.ListImages(ctx) |
| if err != nil { |
| return err |
| } |
| var unpacked bool |
| for _, image := range images { |
| if image.Target().Digest == dgst { |
| fmt.Printf("unpacking %s (%s)...", dgst, image.Target().MediaType) |
| if err := image.Unpack(ctx, cliContext.String("snapshotter"), containerd.WithUnpackApplyOpts(diff.WithSyncFs(cliContext.Bool("sync-fs")))); err != nil { |
| fmt.Println() |
| return err |
| } |
| fmt.Println("done") |
| unpacked = true |
| break |
| } |
| } |
| if !unpacked { |
| return errors.New("manifest not found") |
| } |
| // TODO: Get rootfs from Image |
| // log.G(ctx).Infof("chain ID: %s", chainID.String()) |
| return nil |
| }, |
| } |
| |
| type snapshotTree struct { |
| nodes []*snapshotTreeNode |
| index map[string]*snapshotTreeNode |
| } |
| |
| func newSnapshotTree() *snapshotTree { |
| return &snapshotTree{ |
| index: make(map[string]*snapshotTreeNode), |
| } |
| } |
| |
| type snapshotTreeNode struct { |
| info snapshots.Info |
| children []string |
| } |
| |
| func (st *snapshotTree) add(info snapshots.Info) *snapshotTreeNode { |
| entry, ok := st.index[info.Name] |
| if !ok { |
| entry = &snapshotTreeNode{info: info} |
| st.nodes = append(st.nodes, entry) |
| st.index[info.Name] = entry |
| } else { |
| entry.info = info // update info if we created placeholder |
| } |
| |
| if info.Parent != "" { |
| pn := st.get(info.Parent) |
| if pn == nil { |
| // create a placeholder |
| pn = st.add(snapshots.Info{Name: info.Parent}) |
| } |
| |
| pn.children = append(pn.children, info.Name) |
| } |
| return entry |
| } |
| |
| func (st *snapshotTree) get(name string) *snapshotTreeNode { |
| return st.index[name] |
| } |
| |
| func printTree(st *snapshotTree) { |
| for _, node := range st.nodes { |
| // Print for root(parent-less) nodes only |
| if node.info.Parent == "" { |
| printNode(node.info.Name, st, 0) |
| } |
| } |
| } |
| |
| func printNode(name string, tree *snapshotTree, level int) { |
| node := tree.index[name] |
| prefix := strings.Repeat(" ", level) |
| |
| if level > 0 { |
| prefix += "\\_" |
| } |
| |
| fmt.Printf(prefix+" %s\n", node.info.Name) |
| level++ |
| for _, child := range node.children { |
| printNode(child, tree, level) |
| } |
| } |
| |
| func printMounts(target string, mounts []mount.Mount) { |
| // FIXME: This is specific to Unix |
| for _, m := range mounts { |
| var opt string |
| if len(m.Options) > 0 { |
| opt = fmt.Sprintf(" -o %s", strings.Join(m.Options, ",")) |
| } |
| fmt.Printf("mount -t %s %s %s%s\n", m.Type, m.Source, filepath.Join(target, m.Target), opt) |
| } |
| } |