diff --git a/cmd/root.go b/cmd/root.go index 5074bbeb..a230ff38 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -93,6 +93,7 @@ func initConfig() { viper.SetDefault("keybinding.toggle-modified-files", "ctrl+m") viper.SetDefault("keybinding.toggle-unmodified-files", "ctrl+u") viper.SetDefault("keybinding.toggle-wrap-tree", "ctrl+p") + viper.SetDefault("keybinding.extract-file", "ctrl+e") viper.SetDefault("keybinding.page-up", "pgup") viper.SetDefault("keybinding.page-down", "pgdn") diff --git a/dive/image/docker/archive_resolver.go b/dive/image/docker/archive_resolver.go index 8baf4ac3..60d1ec61 100644 --- a/dive/image/docker/archive_resolver.go +++ b/dive/image/docker/archive_resolver.go @@ -30,3 +30,7 @@ func (r *archiveResolver) Fetch(path string) (*image.Image, error) { func (r *archiveResolver) Build(args []string) (*image.Image, error) { return nil, fmt.Errorf("build option not supported for docker archive resolver") } + +func (r *archiveResolver) Extract(id string, l string, p string) error { + return fmt.Errorf("not implemented") +} diff --git a/dive/image/docker/engine_resolver.go b/dive/image/docker/engine_resolver.go index cf8b109a..affc3884 100644 --- a/dive/image/docker/engine_resolver.go +++ b/dive/image/docker/engine_resolver.go @@ -42,6 +42,19 @@ func (r *engineResolver) Build(args []string) (*image.Image, error) { return r.Fetch(id) } +func (r *engineResolver) Extract(id string, l string, p string) error { + reader, err := r.fetchArchive(id) + if err != nil { + return err + } + + if err := ExtractFromImage(io.NopCloser(reader), l, p); err == nil { + return nil + } + + return fmt.Errorf("unable to extract from image '%s': %+v", id, err) +} + func (r *engineResolver) fetchArchive(id string) (io.ReadCloser, error) { var err error var dockerClient *client.Client diff --git a/dive/image/docker/image_archive.go b/dive/image/docker/image_archive.go index 3a818737..32ffcc90 100644 --- a/dive/image/docker/image_archive.go +++ b/dive/image/docker/image_archive.go @@ -7,6 +7,7 @@ import ( "io" "os" "path" + "path/filepath" "strings" "github.com/wagoodman/dive/dive/filetree" @@ -204,3 +205,80 @@ func (img *ImageArchive) ToImage() (*image.Image, error) { Layers: layers, }, nil } + +func ExtractFromImage(tarFile io.ReadCloser, l string, p string) error { + tarReader := tar.NewReader(tarFile) + + for { + header, err := tarReader.Next() + + if err == io.EOF { + break + } + + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + name := header.Name + + switch header.Typeflag { + case tar.TypeReg: + if name == l { + err = extractInner(tar.NewReader(tarReader), p) + if err != nil { + return err + } + return nil + } + default: + continue + } + } + + return nil +} + +func extractInner(reader *tar.Reader, p string) error { + target := strings.TrimPrefix(p, "/") + + for { + header, err := reader.Next() + + if err == io.EOF { + break + } + + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + name := header.Name + + switch header.Typeflag { + case tar.TypeReg: + if strings.HasPrefix(name, target) { + err := os.MkdirAll(filepath.Dir(name), 0755) + if err != nil { + return err + } + + out, err := os.Create(name) + if err != nil { + return err + } + + _, err = io.Copy(out, reader) + if err != nil { + return err + } + } + default: + continue + } + } + + return nil +} diff --git a/dive/image/podman/resolver.go b/dive/image/podman/resolver.go index e34efd63..6ee77bd5 100644 --- a/dive/image/podman/resolver.go +++ b/dive/image/podman/resolver.go @@ -36,6 +36,21 @@ func (r *resolver) Fetch(id string) (*image.Image, error) { return nil, fmt.Errorf("unable to resolve image '%s': %+v", id, err) } +func (r *resolver) Extract(id string, l string, p string) error { + // todo: add podman fetch attempt via varlink first... + + err, reader := streamPodmanCmd("image", "save", id) + if err != nil { + return err + } + + if err := docker.ExtractFromImage(io.NopCloser(reader), l, p); err == nil { + return nil + } + + return fmt.Errorf("unable to extract from image '%s': %+v", id, err) +} + func (r *resolver) resolveFromDockerArchive(id string) (*image.Image, error) { err, reader := streamPodmanCmd("image", "save", id) if err != nil { diff --git a/dive/image/podman/resolver_unsupported.go b/dive/image/podman/resolver_unsupported.go index 4834e1d4..7358b600 100644 --- a/dive/image/podman/resolver_unsupported.go +++ b/dive/image/podman/resolver_unsupported.go @@ -22,3 +22,7 @@ func (r *resolver) Build(args []string) (*image.Image, error) { func (r *resolver) Fetch(id string) (*image.Image, error) { return nil, fmt.Errorf("unsupported platform") } + +func (r *resolver) Extract(id string, l string, p string) error { + return fmt.Errorf("unsupported platform") +} diff --git a/dive/image/resolver.go b/dive/image/resolver.go index aaa24074..4433a967 100644 --- a/dive/image/resolver.go +++ b/dive/image/resolver.go @@ -3,4 +3,5 @@ package image type Resolver interface { Fetch(id string) (*Image, error) Build(options []string) (*Image, error) + Extract(id string, layer string, path string) error } diff --git a/go.mod b/go.mod index aeac34ed..2830e83f 100644 --- a/go.mod +++ b/go.mod @@ -55,6 +55,7 @@ require ( golang.org/x/sys v0.9.0 // indirect golang.org/x/term v0.9.0 // indirect golang.org/x/text v0.10.0 // indirect + golang.org/x/time v0.0.0-20191024005414-555d28b269f0 // indirect gopkg.in/yaml.v2 v2.2.8 // indirect gotest.tools v2.2.0+incompatible // indirect gotest.tools/v3 v3.5.0 // indirect diff --git a/go.sum b/go.sum index 7cc6fe0b..14ab328e 100644 --- a/go.sum +++ b/go.sum @@ -240,8 +240,9 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58= golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/runtime/run.go b/runtime/run.go index 37c8dd26..818cf305 100644 --- a/runtime/run.go +++ b/runtime/run.go @@ -108,7 +108,7 @@ func run(enableUi bool, options Options, imageResolver image.Resolver, events ev // enough sleep will prevent this behavior (todo: remove this hack) time.Sleep(100 * time.Millisecond) - err = ui.Run(options.Image, analysis, treeStack) + err = ui.Run(options.Image, imageResolver, analysis, treeStack) if err != nil { events.exitWithError(err) return diff --git a/runtime/run_test.go b/runtime/run_test.go index 1565ab5d..8adc768b 100644 --- a/runtime/run_test.go +++ b/runtime/run_test.go @@ -16,6 +16,10 @@ import ( type defaultResolver struct{} +func (r *defaultResolver) Extract(id string, l string, p string) error { + return nil +} + func (r *defaultResolver) Fetch(id string) (*image.Image, error) { archive, err := docker.TestLoadArchive("../.data/test-docker-image.tar") if err != nil { @@ -30,6 +34,10 @@ func (r *defaultResolver) Build(args []string) (*image.Image, error) { type failedBuildResolver struct{} +func (r *failedBuildResolver) Extract(id string, l string, p string) error { + return fmt.Errorf("some extract failure") +} + func (r *failedBuildResolver) Fetch(id string) (*image.Image, error) { archive, err := docker.TestLoadArchive("../.data/test-docker-image.tar") if err != nil { @@ -44,6 +52,10 @@ func (r *failedBuildResolver) Build(args []string) (*image.Image, error) { type failedFetchResolver struct{} +func (r *failedFetchResolver) Extract(id string, l string, p string) error { + return fmt.Errorf("some extract failure") +} + func (r *failedFetchResolver) Fetch(id string) (*image.Image, error) { return nil, fmt.Errorf("some fetch failure") } diff --git a/runtime/ui/app.go b/runtime/ui/app.go index 608a6648..85ce5845 100644 --- a/runtime/ui/app.go +++ b/runtime/ui/app.go @@ -27,13 +27,13 @@ var ( appSingleton *app ) -func newApp(gui *gocui.Gui, imageName string, analysis *image.AnalysisResult, cache filetree.Comparer) (*app, error) { +func newApp(gui *gocui.Gui, imageName string, resolver image.Resolver, analysis *image.AnalysisResult, cache filetree.Comparer) (*app, error) { var err error once.Do(func() { var controller *Controller var globalHelpKeys []*key.Binding - controller, err = NewCollection(gui, imageName, analysis, cache) + controller, err = NewCollection(gui, imageName, resolver, analysis, cache) if err != nil { return } @@ -134,7 +134,7 @@ func (a *app) quit() error { } // Run is the UI entrypoint. -func Run(imageName string, analysis *image.AnalysisResult, treeStack filetree.Comparer) error { +func Run(imageName string, resolver image.Resolver, analysis *image.AnalysisResult, treeStack filetree.Comparer) error { var err error g, err := gocui.NewGui(gocui.OutputNormal, true) @@ -143,7 +143,7 @@ func Run(imageName string, analysis *image.AnalysisResult, treeStack filetree.Co } defer g.Close() - _, err = newApp(g, imageName, analysis, treeStack) + _, err = newApp(g, imageName, resolver, analysis, treeStack) if err != nil { return err } diff --git a/runtime/ui/controller.go b/runtime/ui/controller.go index 031955df..9f3fbe6a 100644 --- a/runtime/ui/controller.go +++ b/runtime/ui/controller.go @@ -13,19 +13,23 @@ import ( ) type Controller struct { - gui *gocui.Gui - views *view.Views + gui *gocui.Gui + views *view.Views + resolver image.Resolver + imageName string } -func NewCollection(g *gocui.Gui, imageName string, analysis *image.AnalysisResult, cache filetree.Comparer) (*Controller, error) { +func NewCollection(g *gocui.Gui, imageName string, resolver image.Resolver, analysis *image.AnalysisResult, cache filetree.Comparer) (*Controller, error) { views, err := view.NewViews(g, imageName, analysis, cache) if err != nil { return nil, err } controller := &Controller{ - gui: g, - views: views, + gui: g, + views: views, + resolver: resolver, + imageName: imageName, } // layer view cursor down event should trigger an update in the file tree @@ -34,6 +38,9 @@ func NewCollection(g *gocui.Gui, imageName string, analysis *image.AnalysisResul // update the status pane when a filetree option is changed by the user controller.views.Tree.AddViewOptionChangeListener(controller.onFileTreeViewOptionChange) + // update the status pane when a filetree option is changed by the user + controller.views.Tree.AddViewExtractListener(controller.onFileTreeViewExtract) + // update the tree view while the user types into the filter view controller.views.Filter.AddFilterEditListener(controller.onFilterEdit) @@ -53,6 +60,10 @@ func NewCollection(g *gocui.Gui, imageName string, analysis *image.AnalysisResul return controller, nil } +func (c *Controller) onFileTreeViewExtract(p string) error { + return c.resolver.Extract(c.imageName, c.views.LayerDetails.CurrentLayer.Id, p) +} + func (c *Controller) onFileTreeViewOptionChange() error { err := c.views.Status.Update() if err != nil { diff --git a/runtime/ui/view/filetree.go b/runtime/ui/view/filetree.go index e92be37d..9d391348 100644 --- a/runtime/ui/view/filetree.go +++ b/runtime/ui/view/filetree.go @@ -17,6 +17,8 @@ import ( type ViewOptionChangeListener func() error +type ViewExtractListener func(string) error + // FileTree holds the UI objects and data models for populating the right pane. Specifically the pane that // shows selected layer or aggregate file ASCII tree. type FileTree struct { @@ -29,6 +31,7 @@ type FileTree struct { filterRegex *regexp.Regexp listeners []ViewOptionChangeListener + extractListeners []ViewExtractListener helpKeys []*key.Binding requestedWidthRatio float64 } @@ -60,6 +63,10 @@ func (v *FileTree) AddViewOptionChangeListener(listener ...ViewOptionChangeListe v.listeners = append(v.listeners, listener...) } +func (v *FileTree) AddViewExtractListener(listener ...ViewExtractListener) { + v.extractListeners = append(v.extractListeners, listener...) +} + func (v *FileTree) SetTitle(title string) { v.title = title } @@ -103,6 +110,11 @@ func (v *FileTree) Setup(view, header *gocui.View) error { OnAction: v.toggleSortOrder, Display: "Toggle sort order", }, + { + ConfigKeys: []string{"keybinding.extract-file"}, + OnAction: v.extractFile, + Display: "Extract File", + }, { ConfigKeys: []string{"keybinding.toggle-added-files"}, OnAction: func() error { return v.toggleShowDiffType(filetree.Added) }, @@ -303,6 +315,18 @@ func (v *FileTree) toggleSortOrder() error { return v.Render() } +func (v *FileTree) extractFile() error { + node := v.vm.CurrentNode(v.filterRegex) + for _, listener := range v.extractListeners { + err := listener(node.Path()) + if err != nil { + return err + } + } + + return nil +} + func (v *FileTree) toggleWrapTree() error { v.view.Wrap = !v.view.Wrap return nil diff --git a/runtime/ui/viewmodel/filetree.go b/runtime/ui/viewmodel/filetree.go index 8734eaf8..e2829ea2 100644 --- a/runtime/ui/viewmodel/filetree.go +++ b/runtime/ui/viewmodel/filetree.go @@ -161,6 +161,11 @@ func (vm *FileTreeViewModel) CursorDown() bool { return true } +// CursorLeft moves the cursor up until we reach the Parent Node or top of the tree +func (vm *FileTreeViewModel) CurrentNode(filterRegex *regexp.Regexp) *filetree.FileNode { + return vm.getAbsPositionNode(filterRegex) +} + // CursorLeft moves the cursor up until we reach the Parent Node or top of the tree func (vm *FileTreeViewModel) CursorLeft(filterRegex *regexp.Regexp) error { var visitor func(*filetree.FileNode) error