diff --git a/cmd/analyze.go b/cmd/analyze.go index 0e451390d..26b6fc243 100644 --- a/cmd/analyze.go +++ b/cmd/analyze.go @@ -10,9 +10,9 @@ import ( "github.com/wagoodman/dive/utils" ) -// analyze takes a docker image tag, digest, or id and displays the +// doAnalyzeCmd takes a docker image tag, digest, or id and displays the // image analysis to the screen -func analyze(cmd *cobra.Command, args []string) { +func doAnalyzeCmd(cmd *cobra.Command, args []string) { defer utils.Cleanup() if len(args) == 0 { printVersionFlag, err := cmd.PersistentFlags().GetBool("version") @@ -33,6 +33,26 @@ func analyze(cmd *cobra.Command, args []string) { utils.Exit(1) } color.New(color.Bold).Println("Analyzing Image") - manifest, refTrees, efficiency, inefficiencies := image.InitializeData(userImage) - ui.Run(manifest, refTrees, efficiency, inefficiencies) + + ui.Run(fetchAndAnalyze(userImage)) +} + + +func fetchAndAnalyze(imageID string) *image.AnalysisResult { + analyzer := image.GetAnalyzer(imageID) + + fmt.Println(" Fetching image...") + err := analyzer.Parse(imageID) + if err != nil { + fmt.Printf("cannot fetch image: %v\n", err) + utils.Exit(1) + } + + fmt.Println(" Analyzing image...") + result, err := analyzer.Analyze() + if err != nil { + fmt.Printf("cannot doAnalyzeCmd image: %v\n", err) + utils.Exit(1) + } + return result } diff --git a/cmd/build.go b/cmd/build.go index c982df8f6..680fc5fe1 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -4,7 +4,6 @@ import ( "github.com/fatih/color" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" - "github.com/wagoodman/dive/image" "github.com/wagoodman/dive/ui" "github.com/wagoodman/dive/utils" "io/ioutil" @@ -16,15 +15,15 @@ var buildCmd = &cobra.Command{ Use: "build [any valid `docker build` arguments]", Short: "Builds and analyzes a docker image from a Dockerfile (this is a thin wrapper for the `docker build` command).", DisableFlagParsing: true, - Run: doBuild, + Run: doBuildCmd, } func init() { rootCmd.AddCommand(buildCmd) } -// doBuild implements the steps taken for the build command -func doBuild(cmd *cobra.Command, args []string) { +// doBuildCmd implements the steps taken for the build command +func doBuildCmd(cmd *cobra.Command, args []string) { defer utils.Cleanup() iidfile, err := ioutil.TempFile("/tmp", "dive.*.iid") if err != nil { @@ -47,6 +46,6 @@ func doBuild(cmd *cobra.Command, args []string) { } color.New(color.Bold).Println("Analyzing Image") - manifest, refTrees, efficiency, inefficiencies := image.InitializeData(string(imageId)) - ui.Run(manifest, refTrees, efficiency, inefficiencies) + + ui.Run(fetchAndAnalyze(string(imageId))) } diff --git a/cmd/root.go b/cmd/root.go index 5e27ac6c1..1ec4d3fd8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,7 +22,7 @@ var rootCmd = &cobra.Command{ Long: `This tool provides a way to discover and explore the contents of a docker image. Additionally the tool estimates the amount of wasted space and identifies the offending files from the image.`, Args: cobra.MaximumNArgs(1), - Run: analyze, + Run: doAnalyzeCmd, } // Execute adds all child commands to the root command and sets flags appropriately. diff --git a/image/docker_image.go b/image/docker_image.go new file mode 100644 index 000000000..773129a66 --- /dev/null +++ b/image/docker_image.go @@ -0,0 +1,266 @@ +package image + +import ( + "archive/tar" + "encoding/json" + "fmt" + "github.com/docker/docker/client" + "github.com/sirupsen/logrus" + "io" + "strings" + "github.com/wagoodman/dive/filetree" + "github.com/wagoodman/dive/utils" + "golang.org/x/net/context" +) + +var dockerVersion string + +func check(e error) { + if e != nil { + panic(e) + } +} + +func newDockerImageAnalyzer() Analyzer { + return &dockerImageAnalyzer{ + } +} + +func newDockerImageManifest(manifestBytes []byte) dockerImageManifest { + var manifest []dockerImageManifest + err := json.Unmarshal(manifestBytes, &manifest) + if err != nil { + logrus.Panic(err) + } + return manifest[0] +} + +func newDockerImageConfig(configBytes []byte) dockerImageConfig { + var imageConfig dockerImageConfig + err := json.Unmarshal(configBytes, &imageConfig) + if err != nil { + logrus.Panic(err) + } + + layerIdx := 0 + for idx := range imageConfig.History { + if imageConfig.History[idx].EmptyLayer { + imageConfig.History[idx].ID = "" + } else { + imageConfig.History[idx].ID = imageConfig.RootFs.DiffIds[layerIdx] + layerIdx++ + } + } + + return imageConfig +} + + +func (image *dockerImageAnalyzer) Parse(imageID string) error { + var err error + image.id = imageID + // store discovered json files in a map so we can read the image in one pass + image.jsonFiles = make(map[string][]byte) + image.layerMap = make(map[string]*filetree.FileTree) + + // pull the image if it does not exist + ctx := context.Background() + image.client, err = client.NewClientWithOpts(client.WithVersion(dockerVersion), client.FromEnv) + if err != nil { + return err + } + _, _, err = image.client.ImageInspectWithRaw(ctx, imageID) + if err != nil { + // don't use the API, the CLI has more informative output + fmt.Println("Image not available locally. Trying to pull '" + imageID + "'...") + utils.RunDockerCmd("pull", imageID) + } + + tarFile, _, err := image.getReader(imageID) + if err != nil { + return err + } + defer tarFile.Close() + + err = image.read(tarFile) + if err != nil { + return err + } + return nil +} + + +// todo: it is bad that this is printing out to the screen +func (image *dockerImageAnalyzer) read(tarFile io.ReadCloser) error { + tarReader := tar.NewReader(tarFile) + + var currentLayer uint + for { + header, err := tarReader.Next() + + if err == io.EOF { + fmt.Println(" ╧") + break + } + + if err != nil { + fmt.Println(err) + utils.Exit(1) + } + + layerProgress := fmt.Sprintf("[layer: %2d]", currentLayer) + + name := header.Name + var n int + + // some layer tars can be relative layer symlinks to other layer tars + if header.Typeflag == tar.TypeSymlink || header.Typeflag == tar.TypeReg { + + if strings.HasSuffix(name, "layer.tar") { + currentLayer++ + if err != nil { + logrus.Panic(err) + } + message := fmt.Sprintf(" ├─ %s %s ", layerProgress, "working...") + fmt.Printf("\r%s", message) + + layerReader := tar.NewReader(tarReader) + image.processLayerTar(name, layerReader, layerProgress) + } else if strings.HasSuffix(name, ".json") { + var fileBuffer = make([]byte, header.Size) + n, err = tarReader.Read(fileBuffer) + if err != nil && err != io.EOF || int64(n) != header.Size { + return err + } + image.jsonFiles[name] = fileBuffer + } + } + } + + return nil +} + +func (image *dockerImageAnalyzer) Analyze() (*AnalysisResult, error) { + image.trees = make([]*filetree.FileTree, 0) + + manifest := newDockerImageManifest(image.jsonFiles["manifest.json"]) + config := newDockerImageConfig(image.jsonFiles[manifest.ConfigPath]) + + // build the content tree + for _, treeName := range manifest.LayerTarPaths { + image.trees = append(image.trees, image.layerMap[treeName]) + } + + // build the layers array + image.layers = make([]*dockerLayer, len(image.trees)) + + // note that the image config stores images in reverse chronological order, so iterate backwards through layers + // as you iterate chronologically through history (ignoring history items that have no layer contents) + layerIdx := len(image.trees) - 1 + tarPathIdx := 0 + for idx := 0; idx < len(config.History); idx++ { + // ignore empty layers, we are only observing layers with content + if config.History[idx].EmptyLayer { + continue + } + + tree := image.trees[(len(image.trees)-1)-layerIdx] + config.History[idx].Size = uint64(tree.FileSize) + + image.layers[layerIdx] = &dockerLayer{ + history: config.History[idx], + index: layerIdx, + tree: image.trees[layerIdx], + tarPath: manifest.LayerTarPaths[tarPathIdx], + } + + layerIdx-- + tarPathIdx++ + } + + + efficiency, inefficiencies := filetree.Efficiency(image.trees) + + layers := make([]Layer, len(image.layers)) + for i, v := range image.layers { + layers[i] = v + } + + return &AnalysisResult{ + Layers: layers, + RefTrees: image.trees, + Efficiency: efficiency, + Inefficiencies: inefficiencies, + }, nil +} + + +func (image *dockerImageAnalyzer) getReader(imageID string) (io.ReadCloser, int64, error) { + + ctx := context.Background() + result, _, err := image.client.ImageInspectWithRaw(ctx, imageID) + if err != nil { + return nil, -1, err + } + totalSize := result.Size + + readCloser, err := image.client.ImageSave(ctx, []string{imageID}) + check(err) + + return readCloser, totalSize, nil +} + +// todo: it is bad that this is printing out to the screen +func (image *dockerImageAnalyzer) processLayerTar(name string, reader *tar.Reader, layerProgress string) { + tree := filetree.NewFileTree() + tree.Name = name + + fileInfos := image.getFileList(reader) + + shortName := name[:15] + pb := utils.NewProgressBar(int64(len(fileInfos)), 30) + for idx, element := range fileInfos { + tree.FileSize += uint64(element.TarHeader.FileInfo().Size()) + tree.AddPath(element.Path, element) + + if pb.Update(int64(idx)) { + message := fmt.Sprintf(" ├─ %s %s : %s", layerProgress, shortName, pb.String()) + fmt.Printf("\r%s", message) + } + } + pb.Done() + message := fmt.Sprintf(" ├─ %s %s : %s", layerProgress, shortName, pb.String()) + fmt.Printf("\r%s\n", message) + + image.layerMap[tree.Name] = tree +} + +// todo: it is bad that this is printing out to the screen +func (image *dockerImageAnalyzer) getFileList(tarReader *tar.Reader) []filetree.FileInfo { + var files []filetree.FileInfo + + for { + header, err := tarReader.Next() + + if err == io.EOF { + break + } + + if err != nil { + fmt.Println(err) + utils.Exit(1) + } + + name := header.Name + + switch header.Typeflag { + case tar.TypeXGlobalHeader: + fmt.Printf("ERRG: XGlobalHeader: %v: %s\n", header.Typeflag, name) + case tar.TypeXHeader: + fmt.Printf("ERRG: XHeader: %v: %s\n", header.Typeflag, name) + default: + files = append(files, filetree.NewFileInfo(tarReader, header, name)) + } + } + return files +} diff --git a/image/docker_layer.go b/image/docker_layer.go new file mode 100644 index 000000000..21de92fb0 --- /dev/null +++ b/image/docker_layer.go @@ -0,0 +1,77 @@ +package image + +import ( + "fmt" + "github.com/dustin/go-humanize" + "github.com/wagoodman/dive/filetree" + "strings" +) + +const ( + LayerFormat = "%-25s %7s %s" +) + + +// ShortId returns the truncated id of the current layer. +func (layer *dockerLayer) TarId() string { + return strings.TrimSuffix(layer.tarPath, "/layer.tar") +} + +// ShortId returns the truncated id of the current layer. +func (layer *dockerLayer) Id() string { + return layer.history.ID +} + +// index returns the relative position of the layer within the image. +func (layer *dockerLayer) Index() int { + return layer.index +} + +// Size returns the number of bytes that this image is. +func (layer *dockerLayer) Size() uint64 { + return layer.history.Size +} + +// Tree returns the file tree representing the current layer. +func (layer *dockerLayer) Tree() *filetree.FileTree { + return layer.tree +} + +// ShortId returns the truncated id of the current layer. +func (layer *dockerLayer) Command() string { + return strings.TrimPrefix(layer.history.CreatedBy, "/bin/sh -c ") +} + + +// ShortId returns the truncated id of the current layer. +func (layer *dockerLayer) ShortId() string { + rangeBound := 25 + id := layer.Id() + if length := len(id); length < 25 { + rangeBound = length + } + id = id[0:rangeBound] + + // show the tagged image as the last layer + // if len(layer.History.Tags) > 0 { + // id = "[" + strings.Join(layer.History.Tags, ",") + "]" + // } + + return id +} + +// String represents a layer in a columnar format. +func (layer *dockerLayer) String() string { + + if layer.index == 0 { + return fmt.Sprintf(LayerFormat, + layer.ShortId(), + humanize.Bytes(layer.Size()), + "FROM "+layer.ShortId()) + } + return fmt.Sprintf(LayerFormat, + layer.ShortId(), + humanize.Bytes(layer.Size()), + layer.Command()) +} + diff --git a/image/image.go b/image/image.go deleted file mode 100644 index 2ceee51cb..000000000 --- a/image/image.go +++ /dev/null @@ -1,312 +0,0 @@ -package image - -import ( - "archive/tar" - "encoding/json" - "fmt" - "io" - "strings" - - "github.com/sirupsen/logrus" - - "github.com/docker/docker/client" - "github.com/wagoodman/dive/filetree" - "github.com/wagoodman/dive/utils" - "golang.org/x/net/context" -) - -// TODO: this file should be rethought... but since it's only for preprocessing it'll be tech debt for now. -var dockerVersion string - -func check(e error) { - if e != nil { - panic(e) - } -} - -type ProgressBar struct { - percent int - rawTotal int64 - rawCurrent int64 -} - -func NewProgressBar(total int64) *ProgressBar { - return &ProgressBar{ - rawTotal: total, - } -} - -func (pb *ProgressBar) Done() { - pb.rawCurrent = pb.rawTotal - pb.percent = 100 -} - -func (pb *ProgressBar) Update(currentValue int64) (hasChanged bool) { - pb.rawCurrent = currentValue - percent := int(100.0 * (float64(pb.rawCurrent) / float64(pb.rawTotal))) - if percent != pb.percent { - hasChanged = true - } - pb.percent = percent - return hasChanged -} - -func (pb *ProgressBar) String() string { - width := 40 - done := int((pb.percent * width) / 100.0) - if done > width { - done = width - } - todo := width - done - if todo < 0 { - todo = 0 - } - head := 1 - - return "[" + strings.Repeat("=", done) + strings.Repeat(">", head) + strings.Repeat(" ", todo) + "]" + fmt.Sprintf(" %d %% (%d/%d)", pb.percent, pb.rawCurrent, pb.rawTotal) -} - -type ImageManifest struct { - ConfigPath string `json:"Config"` - RepoTags []string `json:"RepoTags"` - LayerTarPaths []string `json:"Layers"` -} - -type ImageConfig struct { - History []ImageHistoryEntry `json:"history"` - RootFs RootFs `json:"rootfs"` -} - -type RootFs struct { - Type string `json:"type"` - DiffIds []string `json:"diff_ids"` -} - -type ImageHistoryEntry struct { - ID string - Size uint64 - Created string `json:"created"` - Author string `json:"author"` - CreatedBy string `json:"created_by"` - EmptyLayer bool `json:"empty_layer"` -} - -func NewImageManifest(manifestBytes []byte) ImageManifest { - var manifest []ImageManifest - err := json.Unmarshal(manifestBytes, &manifest) - if err != nil { - logrus.Panic(err) - } - return manifest[0] -} - -func NewImageConfig(configBytes []byte) ImageConfig { - var imageConfig ImageConfig - err := json.Unmarshal(configBytes, &imageConfig) - if err != nil { - logrus.Panic(err) - } - - layerIdx := 0 - for idx := range imageConfig.History { - if imageConfig.History[idx].EmptyLayer { - imageConfig.History[idx].ID = "" - } else { - imageConfig.History[idx].ID = imageConfig.RootFs.DiffIds[layerIdx] - layerIdx++ - } - } - - return imageConfig -} - -func processLayerTar(layerMap map[string]*filetree.FileTree, name string, reader *tar.Reader, layerProgress string) { - tree := filetree.NewFileTree() - tree.Name = name - - fileInfos := getFileList(reader) - - shortName := name[:15] - pb := NewProgressBar(int64(len(fileInfos))) - for idx, element := range fileInfos { - tree.FileSize += uint64(element.TarHeader.FileInfo().Size()) - tree.AddPath(element.Path, element) - - if pb.Update(int64(idx)) { - message := fmt.Sprintf(" ├─ %s %s : %s", layerProgress, shortName, pb.String()) - fmt.Printf("\r%s", message) - } - } - pb.Done() - message := fmt.Sprintf(" ├─ %s %s : %s", layerProgress, shortName, pb.String()) - fmt.Printf("\r%s\n", message) - - layerMap[tree.Name] = tree -} - -func InitializeData(imageID string) ([]*Layer, []*filetree.FileTree, float64, filetree.EfficiencySlice) { - var layerMap = make(map[string]*filetree.FileTree) - var trees = make([]*filetree.FileTree, 0) - - // pull the image if it does not exist - ctx := context.Background() - dockerClient, err := client.NewClientWithOpts(client.WithVersion(dockerVersion), client.FromEnv) - if err != nil { - fmt.Println("Could not connect to the Docker daemon:" + err.Error()) - utils.Exit(1) - } - _, _, err = dockerClient.ImageInspectWithRaw(ctx, imageID) - if err != nil { - // don't use the API, the CLI has more informative output - fmt.Println("Image not available locally. Trying to pull '" + imageID + "'...") - utils.RunDockerCmd("pull", imageID) - } - - tarFile, _ := getImageReader(imageID) - defer tarFile.Close() - - var currentLayer uint - - tarReader := tar.NewReader(tarFile) - - // json files are small. Let's store the in a map so we can read the image in one pass - jsonFiles := make(map[string][]byte) - - for { - header, err := tarReader.Next() - - if err == io.EOF { - fmt.Println(" ╧") - break - } - - if err != nil { - fmt.Println(err) - utils.Exit(1) - } - - layerProgress := fmt.Sprintf("[layer: %2d]", currentLayer) - - name := header.Name - var n int - - // some layer tars can be relative layer symlinks to other layer tars - if header.Typeflag == tar.TypeSymlink || header.Typeflag == tar.TypeReg { - - if strings.HasSuffix(name, "layer.tar") { - currentLayer++ - if err != nil { - logrus.Panic(err) - } - message := fmt.Sprintf(" ├─ %s %s ", layerProgress, "working...") - fmt.Printf("\r%s", message) - - layerReader := tar.NewReader(tarReader) - processLayerTar(layerMap, name, layerReader, layerProgress) - } else if strings.HasSuffix(name, ".json") { - var fileBuffer = make([]byte, header.Size) - n, err = tarReader.Read(fileBuffer) - if err != nil && err != io.EOF || int64(n) != header.Size { - logrus.Panic(err) - } - jsonFiles[name] = fileBuffer - } - } - } - - manifest := NewImageManifest(jsonFiles["manifest.json"]) - config := NewImageConfig(jsonFiles[manifest.ConfigPath]) - - // build the content tree - fmt.Println(" Building tree...") - for _, treeName := range manifest.LayerTarPaths { - trees = append(trees, layerMap[treeName]) - } - - // build the layers array - layers := make([]*Layer, len(trees)) - - // note that the image config stores images in reverse chronological order, so iterate backwards through layers - // as you iterate chronologically through history (ignoring history items that have no layer contents) - layerIdx := len(trees) - 1 - tarPathIdx := 0 - for idx := 0; idx < len(config.History); idx++ { - // ignore empty layers, we are only observing layers with content - if config.History[idx].EmptyLayer { - continue - } - - tree := trees[(len(trees)-1)-layerIdx] - config.History[idx].Size = uint64(tree.FileSize) - - layers[layerIdx] = &Layer{ - History: config.History[idx], - Index: layerIdx, - Tree: trees[layerIdx], - RefTrees: trees, - TarPath: manifest.LayerTarPaths[tarPathIdx], - } - - layerIdx-- - tarPathIdx++ - } - - fmt.Println(" Analyzing layers...") - efficiency, inefficiencies := filetree.Efficiency(trees) - - return layers, trees, efficiency, inefficiencies -} - -func getImageReader(imageID string) (io.ReadCloser, int64) { - ctx := context.Background() - dockerClient, err := client.NewClientWithOpts(client.WithVersion(dockerVersion), client.FromEnv) - if err != nil { - fmt.Println("Could not connect to the Docker daemon:" + err.Error()) - utils.Exit(1) - } - - fmt.Println(" Fetching metadata...") - - result, _, err := dockerClient.ImageInspectWithRaw(ctx, imageID) - if err != nil { - fmt.Println(err.Error()) - utils.Exit(1) - } - totalSize := result.Size - - fmt.Println(" Fetching image...") - - readCloser, err := dockerClient.ImageSave(ctx, []string{imageID}) - check(err) - - return readCloser, totalSize -} - -func getFileList(tarReader *tar.Reader) []filetree.FileInfo { - var files []filetree.FileInfo - - for { - header, err := tarReader.Next() - - if err == io.EOF { - break - } - - if err != nil { - fmt.Println(err) - utils.Exit(1) - } - - name := header.Name - - switch header.Typeflag { - case tar.TypeXGlobalHeader: - fmt.Printf("ERRG: XGlobalHeader: %v: %s\n", header.Typeflag, name) - case tar.TypeXHeader: - fmt.Printf("ERRG: XHeader: %v: %s\n", header.Typeflag, name) - default: - files = append(files, filetree.NewFileInfo(tarReader, header, name)) - } - } - return files -} diff --git a/image/layer.go b/image/layer.go deleted file mode 100644 index 2e8a4d1a5..000000000 --- a/image/layer.go +++ /dev/null @@ -1,57 +0,0 @@ -package image - -import ( - "fmt" - "github.com/dustin/go-humanize" - "github.com/wagoodman/dive/filetree" - "strings" -) - -const ( - LayerFormat = "%-25s %7s %s" -) - -// Layer represents a Docker image layer and metadata -type Layer struct { - TarPath string - History ImageHistoryEntry - Index int - Tree *filetree.FileTree - RefTrees []*filetree.FileTree -} - -// ShortId returns the truncated id of the current layer. -func (layer *Layer) TarId() string { - return strings.TrimSuffix(layer.TarPath, "/layer.tar") -} - -// ShortId returns the truncated id of the current layer. -func (layer *Layer) Id() string { - return layer.History.ID -} - -// ShortId returns the truncated id of the current layer. -func (layer *Layer) ShortId() string { - rangeBound := 25 - id := layer.Id() - if length := len(id); length < 25 { - rangeBound = length - } - id = id[0:rangeBound] - - // show the tagged image as the last layer - // if len(layer.History.Tags) > 0 { - // id = "[" + strings.Join(layer.History.Tags, ",") + "]" - // } - - return id -} - -// String represents a layer in a columnar format. -func (layer *Layer) String() string { - - return fmt.Sprintf(LayerFormat, - layer.ShortId(), - humanize.Bytes(uint64(layer.History.Size)), - strings.TrimPrefix(layer.History.CreatedBy, "/bin/sh -c ")) -} diff --git a/image/root.go b/image/root.go new file mode 100644 index 000000000..f190cf76b --- /dev/null +++ b/image/root.go @@ -0,0 +1,10 @@ +package image + +type AnalyzerFactory func() Analyzer + +func GetAnalyzer(imaegID string) Analyzer { + // todo: add ability to have multiple image formats... for the meantime only use docker + var factory AnalyzerFactory = newDockerImageAnalyzer + + return factory() +} diff --git a/image/types.go b/image/types.go new file mode 100644 index 000000000..e829210ef --- /dev/null +++ b/image/types.go @@ -0,0 +1,78 @@ +package image + +import ( + "github.com/docker/docker/client" + "github.com/wagoodman/dive/filetree" +) + +type Parser interface { + +} + +type Analyzer interface { + Parse(id string) error + Analyze() (*AnalysisResult, error) +} + + +type Layer interface { + Id() string + ShortId() string + Index() int + Command() string + Size() uint64 + Tree() *filetree.FileTree + String() string +} + +type AnalysisResult struct { + Layers []Layer + RefTrees []*filetree.FileTree + Efficiency float64 + Inefficiencies filetree.EfficiencySlice +} + + +type dockerImageAnalyzer struct { + id string + client *client.Client + jsonFiles map[string][]byte + trees []*filetree.FileTree + layerMap map[string]*filetree.FileTree + layers []*dockerLayer +} + + +type dockerImageHistoryEntry struct { + ID string + Size uint64 + Created string `json:"created"` + Author string `json:"author"` + CreatedBy string `json:"created_by"` + EmptyLayer bool `json:"empty_layer"` +} + + +type dockerImageManifest struct { + ConfigPath string `json:"Config"` + RepoTags []string `json:"RepoTags"` + LayerTarPaths []string `json:"Layers"` +} + +type dockerImageConfig struct { + History []dockerImageHistoryEntry `json:"history"` + RootFs dockerRootFs `json:"rootfs"` +} + +type dockerRootFs struct { + Type string `json:"type"` + DiffIds []string `json:"diff_ids"` +} + +// Layer represents a Docker image layer and metadata +type dockerLayer struct { + tarPath string + history dockerImageHistoryEntry + index int + tree *filetree.FileTree +} diff --git a/ui/detailsview.go b/ui/detailsview.go index 2eaa80223..f3877037d 100644 --- a/ui/detailsview.go +++ b/ui/detailsview.go @@ -128,9 +128,10 @@ func (view *DetailsView) Render() error { // update contents view.view.Clear() fmt.Fprintln(view.view, Formatting.Header("Digest: ")+currentLayer.Id()) - fmt.Fprintln(view.view, Formatting.Header("Tar ID: ")+currentLayer.TarId()) + // TODO: add back in with view model + // fmt.Fprintln(view.view, Formatting.Header("Tar ID: ")+currentLayer.TarId()) fmt.Fprintln(view.view, Formatting.Header("Command:")) - fmt.Fprintln(view.view, currentLayer.History.CreatedBy) + fmt.Fprintln(view.view, currentLayer.Command()) fmt.Fprintln(view.view, "\n"+Formatting.Header(vtclean.Clean(imageHeaderStr, false))) diff --git a/ui/layerview.go b/ui/layerview.go index a77adfd0f..b29b87427 100644 --- a/ui/layerview.go +++ b/ui/layerview.go @@ -5,7 +5,6 @@ import ( "github.com/spf13/viper" "github.com/wagoodman/dive/utils" - "github.com/dustin/go-humanize" "github.com/jroimartin/gocui" "github.com/lunixbochs/vtclean" "github.com/wagoodman/dive/image" @@ -20,7 +19,7 @@ type LayerView struct { view *gocui.View header *gocui.View LayerIndex int - Layers []*image.Layer + Layers []image.Layer CompareMode CompareType CompareStartIndex int ImageSize uint64 @@ -30,7 +29,7 @@ type LayerView struct { } // NewDetailsView creates a new view object attached the the global [gocui] screen object. -func NewLayerView(name string, gui *gocui.Gui, layers []*image.Layer) (layerView *LayerView) { +func NewLayerView(name string, gui *gocui.Gui, layers []image.Layer) (layerView *LayerView) { layerView = new(LayerView) // populate main fields @@ -131,7 +130,7 @@ func (view *LayerView) SetCursor(layer int) error { } // currentLayer returns the Layer object currently selected. -func (view *LayerView) currentLayer() *image.Layer { +func (view *LayerView) currentLayer() image.Layer { return view.Layers[(len(view.Layers)-1)-view.LayerIndex] } @@ -181,7 +180,7 @@ func (view *LayerView) renderCompareBar(layerIdx int) string { func (view *LayerView) Update() error { view.ImageSize = 0 for idx := 0; idx < len(view.Layers); idx++ { - view.ImageSize += view.Layers[idx].History.Size + view.ImageSize += view.Layers[idx].Size() } return nil } @@ -212,17 +211,6 @@ func (view *LayerView) Render() error { idx := (len(view.Layers) - 1) - revIdx layerStr := layer.String() - if idx == 0 { - var layerId string - if len(layer.History.ID) >= 25 { - layerId = layer.History.ID[0:25] - } else { - layerId = fmt.Sprintf("%-25s", layer.History.ID) - } - - layerStr = fmt.Sprintf(image.LayerFormat, layerId, humanize.Bytes(uint64(layer.History.Size)), "FROM "+layer.ShortId()) - } - compareBar := view.renderCompareBar(idx) if idx == view.LayerIndex { diff --git a/ui/ui.go b/ui/ui.go index 8b3e81588..6ad30e8c3 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -301,7 +301,7 @@ func renderStatusOption(control, title string, selected bool) string { } // Run is the UI entrypoint. -func Run(layers []*image.Layer, refTrees []*filetree.FileTree, efficiency float64, inefficiencies filetree.EfficiencySlice) { +func Run(analysis *image.AnalysisResult) { Formatting.Selected = color.New(color.ReverseVideo, color.Bold).SprintFunc() Formatting.Header = color.New(color.Bold).SprintFunc() @@ -325,10 +325,10 @@ func Run(layers []*image.Layer, refTrees []*filetree.FileTree, efficiency float6 Views.lookup = make(map[string]View) - Views.Layer = NewLayerView("side", g, layers) + Views.Layer = NewLayerView("side", g, analysis.Layers) Views.lookup[Views.Layer.Name] = Views.Layer - Views.Tree = NewFileTreeView("main", g, filetree.StackRange(refTrees, 0, 0), refTrees) + Views.Tree = NewFileTreeView("main", g, filetree.StackRange(analysis.RefTrees, 0, 0), analysis.RefTrees) Views.lookup[Views.Tree.Name] = Views.Tree Views.Status = NewStatusView("status", g) @@ -337,7 +337,7 @@ func Run(layers []*image.Layer, refTrees []*filetree.FileTree, efficiency float6 Views.Filter = NewFilterView("command", g) Views.lookup[Views.Filter.Name] = Views.Filter - Views.Details = NewDetailsView("details", g, efficiency, inefficiencies) + Views.Details = NewDetailsView("details", g, analysis.Efficiency, analysis.Inefficiencies) Views.lookup[Views.Details.Name] = Views.Details g.Cursor = false diff --git a/utils/progress.go b/utils/progress.go new file mode 100644 index 000000000..82b32eea4 --- /dev/null +++ b/utils/progress.go @@ -0,0 +1,50 @@ +package utils + +import ( + "fmt" + "strings" +) + +type progressBar struct { + width int + percent int + rawTotal int64 + rawCurrent int64 +} + +func NewProgressBar(total int64, width int) *progressBar { + return &progressBar{ + rawTotal: total, + width: width, + } +} + +func (pb *progressBar) Done() { + pb.rawCurrent = pb.rawTotal + pb.percent = 100 +} + +func (pb *progressBar) Update(currentValue int64) (hasChanged bool) { + pb.rawCurrent = currentValue + percent := int(100.0 * (float64(pb.rawCurrent) / float64(pb.rawTotal))) + if percent != pb.percent { + hasChanged = true + } + pb.percent = percent + return hasChanged +} + +func (pb *progressBar) String() string { + done := int((pb.percent * pb.width) / 100.0) + if done > pb.width { + done = pb.width + } + todo := pb.width - done + if todo < 0 { + todo = 0 + } + head := 1 + + return "[" + strings.Repeat("=", done) + strings.Repeat(">", head) + strings.Repeat(" ", todo) + "]" + fmt.Sprintf(" %d %% (%d/%d)", pb.percent, pb.rawCurrent, pb.rawTotal) +} +