├── .binny.yaml ├── .bouncer.yaml ├── .data ├── .dive-ci ├── Dockerfile.example ├── Dockerfile.minimal ├── Dockerfile.test-image ├── demo-ci.png ├── demo.gif ├── test-docker-image.tar ├── test-estargz-image.tar ├── test-gzip-image.tar ├── test-kaniko-image.tar ├── test-oci-docker-image.tar ├── test-oci-estargz-image.tar ├── test-oci-gzip-image.tar ├── test-oci-uncompressed-image.tar ├── test-oci-zstd-image.tar ├── test-uncompressed-image.tar └── test-zstd-image.tar ├── .dockerignore ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── actions │ └── bootstrap │ │ └── action.yaml ├── dependabot.yaml ├── scripts │ ├── coverage.py │ └── trigger-release.sh └── workflows │ ├── release.yaml │ └── validations.yaml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yaml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── RELEASE.md ├── Taskfile.yaml ├── cmd └── dive │ ├── cli │ ├── cli.go │ ├── cli_build_test.go │ ├── cli_ci_test.go │ ├── cli_config_test.go │ ├── cli_json_test.go │ ├── cli_load_test.go │ ├── cli_test.go │ ├── internal │ │ ├── command │ │ │ ├── adapter │ │ │ │ ├── analyzer.go │ │ │ │ ├── evaluator.go │ │ │ │ ├── exporter.go │ │ │ │ └── resolver.go │ │ │ ├── build.go │ │ │ ├── ci │ │ │ │ ├── evaluator.go │ │ │ │ ├── evaluator_test.go │ │ │ │ ├── rule.go │ │ │ │ └── rules.go │ │ │ ├── export │ │ │ │ ├── export.go │ │ │ │ ├── export_test.go │ │ │ │ ├── main_test.go │ │ │ │ └── testdata │ │ │ │ │ └── snapshots │ │ │ │ │ └── export_test.snap │ │ │ └── root.go │ │ ├── options │ │ │ ├── analysis.go │ │ │ ├── application.go │ │ │ ├── ci.go │ │ │ ├── ci_rules.go │ │ │ ├── export.go │ │ │ ├── ui.go │ │ │ ├── ui_diff.go │ │ │ ├── ui_filetree.go │ │ │ ├── ui_keybindings.go │ │ │ └── ui_layers.go │ │ └── ui │ │ │ ├── no_ui.go │ │ │ ├── v1.go │ │ │ └── v1 │ │ │ ├── app │ │ │ ├── app.go │ │ │ ├── controller.go │ │ │ ├── job_control_other.go │ │ │ └── job_control_unix.go │ │ │ ├── config.go │ │ │ ├── format │ │ │ └── format.go │ │ │ ├── key │ │ │ ├── binding.go │ │ │ └── config.go │ │ │ ├── layout │ │ │ ├── area.go │ │ │ ├── compound │ │ │ │ └── layer_details_column.go │ │ │ ├── layout.go │ │ │ ├── location.go │ │ │ ├── manager.go │ │ │ └── manager_test.go │ │ │ ├── view │ │ │ ├── cursor.go │ │ │ ├── debug.go │ │ │ ├── filetree.go │ │ │ ├── filter.go │ │ │ ├── image_details.go │ │ │ ├── layer.go │ │ │ ├── layer_change_listener.go │ │ │ ├── layer_details.go │ │ │ ├── renderer.go │ │ │ ├── status.go │ │ │ └── views.go │ │ │ └── viewmodel │ │ │ ├── config.go │ │ │ ├── filetree.go │ │ │ ├── filetree_test.go │ │ │ ├── layer_compare.go │ │ │ ├── layer_selection.go │ │ │ ├── layer_set_state.go │ │ │ ├── layer_set_state_test.go │ │ │ └── testdata │ │ │ ├── TestFileShowAggregateChanges.txt │ │ │ ├── TestFileTreeDirCollapse.txt │ │ │ ├── TestFileTreeDirCollapseAll.txt │ │ │ ├── TestFileTreeDirCursorRight.txt │ │ │ ├── TestFileTreeFilterTree.txt │ │ │ ├── TestFileTreeGoCase.txt │ │ │ ├── TestFileTreeHideAddedRemovedModified.txt │ │ │ ├── TestFileTreeHideTypeWithFilter.txt │ │ │ ├── TestFileTreeHideUnmodified.txt │ │ │ ├── TestFileTreeNoAttributes.txt │ │ │ ├── TestFileTreePageDown.txt │ │ │ ├── TestFileTreePageUp.txt │ │ │ ├── TestFileTreeRestrictedHeight.txt │ │ │ └── TestFileTreeSelectLayer.txt │ └── testdata │ │ ├── config │ │ └── dive-ci-legacy.yaml │ │ ├── default-ci-config │ │ └── .dive-ci │ │ ├── dive-enable-ci.yaml │ │ ├── image-multi-layer-containerfile │ │ ├── Containerfile │ │ ├── dive-pass.yaml │ │ ├── example.md │ │ └── overwrite.md │ │ ├── image-multi-layer-dockerfile │ │ ├── Dockerfile │ │ ├── dive-fail.yaml │ │ ├── dive-pass.yaml │ │ ├── example.md │ │ └── overwrite.md │ │ ├── invalid │ │ └── Dockerfile │ │ └── snapshots │ │ ├── cli_build_test.snap │ │ ├── cli_ci_test.snap │ │ ├── cli_config_test.snap │ │ ├── cli_json_test.snap │ │ └── cli_load_test.snap │ └── main.go ├── dive ├── filetree │ ├── comparer.go │ ├── diff.go │ ├── efficiency.go │ ├── efficiency_test.go │ ├── file_info.go │ ├── file_node.go │ ├── file_node_test.go │ ├── file_tree.go │ ├── file_tree_test.go │ ├── node_data.go │ ├── node_data_test.go │ ├── order_strategy.go │ ├── path_error.go │ └── view_info.go ├── get_image_resolver.go └── image │ ├── analysis.go │ ├── docker │ ├── archive_resolver.go │ ├── build.go │ ├── build_test.go │ ├── cli.go │ ├── config.go │ ├── docker_host_unix.go │ ├── docker_host_windows.go │ ├── engine_resolver.go │ ├── image_archive.go │ ├── image_archive_analysis_test.go │ ├── layer.go │ ├── manifest.go │ └── testing.go │ ├── image.go │ ├── layer.go │ ├── podman │ ├── build.go │ ├── cli.go │ ├── resolver.go │ └── resolver_unsupported.go │ └── resolver.go ├── go.mod ├── go.sum └── internal ├── bus ├── bus.go ├── event │ ├── event.go │ ├── parser │ │ └── parsers.go │ └── payload │ │ ├── explore.go │ │ └── generic.go └── helpers.go ├── log └── log.go └── utils ├── format.go └── view.go /.binny.yaml: -------------------------------------------------------------------------------- 1 | tools: 2 | # we want to use a pinned version of binny to manage the toolchain (so binny manages itself!) 3 | - name: binny 4 | version: 5 | want: v0.9.0 6 | method: github-release 7 | with: 8 | repo: anchore/binny 9 | 10 | # used for linting 11 | - name: golangci-lint 12 | version: 13 | want: v1.64.8 14 | method: github-release 15 | with: 16 | repo: golangci/golangci-lint 17 | 18 | # used for showing the changelog at release 19 | - name: glow 20 | version: 21 | want: v2.1.0 22 | method: github-release 23 | with: 24 | repo: charmbracelet/glow 25 | 26 | # used to release all artifacts 27 | - name: goreleaser 28 | version: 29 | want: v2.8.1 30 | method: github-release 31 | with: 32 | repo: goreleaser/goreleaser 33 | 34 | # used at release to generate the changelog 35 | - name: chronicle 36 | version: 37 | want: v0.8.0 38 | method: github-release 39 | with: 40 | repo: anchore/chronicle 41 | 42 | # used during static analysis for license compliance 43 | - name: bouncer 44 | version: 45 | want: v0.4.0 46 | method: github-release 47 | with: 48 | repo: wagoodman/go-bouncer 49 | 50 | # used for running all local and CI tasks 51 | - name: task 52 | version: 53 | want: v3.42.1 54 | method: github-release 55 | with: 56 | repo: go-task/task 57 | 58 | # used for triggering a release 59 | - name: gh 60 | version: 61 | want: v2.69.0 62 | method: github-release 63 | with: 64 | repo: cli/cli 65 | -------------------------------------------------------------------------------- /.bouncer.yaml: -------------------------------------------------------------------------------- 1 | permit: 2 | - BSD.* 3 | - MIT.* 4 | - Apache.* 5 | - MPL.* 6 | - ISC 7 | - WTFPL 8 | - Unlicense 9 | 10 | ignore-packages: 11 | # crypto/internal/boring is released under the openSSL license as a part of the Golang Standard Library 12 | - crypto/internal/boring 13 | 14 | -------------------------------------------------------------------------------- /.data/.dive-ci: -------------------------------------------------------------------------------- 1 | --- 2 | plugins: 3 | - plugin1 4 | 5 | rules: 6 | # If the efficiency is measured below X%, mark as failed. (expressed as a percentage between 0-1) 7 | lowestEfficiency: 0.95 8 | 9 | # If the amount of wasted space is at least X or larger than X, mark as failed. (expressed in B, KB, MB, and GB) 10 | highestWastedBytes: 20Mb 11 | 12 | # If the amount of wasted space makes up for X% of the image, mark as failed. (fail if the threshold is met or crossed; expressed as a percentage between 0-1) 13 | highestUserWastedPercent: 0.5 14 | 15 | plugin1/rule1: error 16 | -------------------------------------------------------------------------------- /.data/Dockerfile.example: -------------------------------------------------------------------------------- 1 | FROM busybox:latest 2 | ADD README.md /somefile.txt 3 | RUN mkdir -p /root/example/really/nested 4 | RUN cp /somefile.txt /root/example/somefile1.txt 5 | RUN chmod 444 /root/example/somefile1.txt 6 | RUN cp /somefile.txt /root/example/somefile2.txt 7 | RUN cp /somefile.txt /root/example/somefile3.txt 8 | RUN mv /root/example/somefile3.txt /root/saved.txt 9 | RUN cp /root/saved.txt /root/.saved.txt 10 | RUN rm -rf /root/example/ 11 | ADD .scripts/ /root/.data/ 12 | RUN cp /root/saved.txt /tmp/saved.again1.txt 13 | RUN cp /root/saved.txt /root/.data/saved.again2.txt 14 | RUN chmod +x /root/saved.txt 15 | RUN chmod 421 /root 16 | -------------------------------------------------------------------------------- /.data/Dockerfile.minimal: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | COPY README.md /README.md 3 | -------------------------------------------------------------------------------- /.data/Dockerfile.test-image: -------------------------------------------------------------------------------- 1 | FROM busybox:latest 2 | ADD README.md /somefile.txt 3 | RUN mkdir -p /root/example/really/nested 4 | RUN cp /somefile.txt /root/example/somefile1.txt 5 | RUN chmod 444 /root/example/somefile1.txt 6 | RUN cp /somefile.txt /root/example/somefile2.txt 7 | RUN cp /somefile.txt /root/example/somefile3.txt 8 | RUN mv /root/example/somefile3.txt /root/saved.txt 9 | RUN cp /root/saved.txt /root/.saved.txt 10 | RUN rm -rf /root/example/ 11 | ADD .scripts/ /root/.data/ 12 | RUN cp /root/saved.txt /tmp/saved.again1.txt 13 | RUN cp /root/saved.txt /root/.data/saved.again2.txt 14 | RUN chmod +x /root/saved.txt 15 | -------------------------------------------------------------------------------- /.data/demo-ci.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wagoodman/dive/d6c691947f8fda635c952a17ee3b7555379d58f0/.data/demo-ci.png -------------------------------------------------------------------------------- /.data/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wagoodman/dive/d6c691947f8fda635c952a17ee3b7555379d58f0/.data/demo.gif -------------------------------------------------------------------------------- /.data/test-docker-image.tar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wagoodman/dive/d6c691947f8fda635c952a17ee3b7555379d58f0/.data/test-docker-image.tar -------------------------------------------------------------------------------- /.data/test-kaniko-image.tar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wagoodman/dive/d6c691947f8fda635c952a17ee3b7555379d58f0/.data/test-kaniko-image.tar -------------------------------------------------------------------------------- /.data/test-oci-docker-image.tar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wagoodman/dive/d6c691947f8fda635c952a17ee3b7555379d58f0/.data/test-oci-docker-image.tar -------------------------------------------------------------------------------- /.data/test-oci-estargz-image.tar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wagoodman/dive/d6c691947f8fda635c952a17ee3b7555379d58f0/.data/test-oci-estargz-image.tar -------------------------------------------------------------------------------- /.data/test-oci-gzip-image.tar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wagoodman/dive/d6c691947f8fda635c952a17ee3b7555379d58f0/.data/test-oci-gzip-image.tar -------------------------------------------------------------------------------- /.data/test-oci-zstd-image.tar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wagoodman/dive/d6c691947f8fda635c952a17ee3b7555379d58f0/.data/test-oci-zstd-image.tar -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /.git 2 | /.data 3 | /.cover 4 | /dist 5 | !/dist/dive_linux_amd64 6 | /ui 7 | /internal/utils 8 | /image 9 | /cmd 10 | /build 11 | coverage.txt 12 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ['wagoodman'] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Something isn't working as expected 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What happened**: 11 | 12 | **What you expected to happen**: 13 | 14 | **How to reproduce it (as minimally and precisely as possible)**: 15 | 16 | **Anything else we need to know?**: 17 | 18 | **Environment**: 19 | - OS version 20 | - Docker version (if applicable) 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Got an idea for a new feature? Let us know! 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What would you like to be added**: 11 | 12 | **Why is this needed**: 13 | 14 | **Additional context**: 15 | 16 | -------------------------------------------------------------------------------- /.github/actions/bootstrap/action.yaml: -------------------------------------------------------------------------------- 1 | name: "Bootstrap" 2 | description: "Bootstrap all tools and dependencies" 3 | inputs: 4 | go-version: 5 | description: "Go version to install" 6 | required: true 7 | default: "1.24.x" 8 | cache-key-prefix: 9 | description: "Prefix all cache keys with this value" 10 | required: true 11 | default: "efa04b89c1b1" 12 | bootstrap-apt-packages: 13 | description: "Space delimited list of tools to install via apt" 14 | default: "" 15 | 16 | runs: 17 | using: "composite" 18 | steps: 19 | - uses: actions/setup-go@v5 20 | with: 21 | go-version: ${{ inputs.go-version }} 22 | 23 | - name: Restore tool cache 24 | id: tool-cache 25 | uses: actions/cache@v4 26 | with: 27 | path: ${{ github.workspace }}/.tmp 28 | key: ${{ inputs.cache-key-prefix }}-${{ runner.os }}-tool-${{ hashFiles('Makefile') }} 29 | 30 | - name: (cache-miss) Bootstrap project tools 31 | shell: bash 32 | if: steps.tool-cache.outputs.cache-hit != 'true' 33 | run: make tools 34 | 35 | - name: (cache-miss) Bootstrap go dependencies 36 | shell: bash 37 | if: steps.go-mod-cache.outputs.cache-hit != 'true' 38 | run: go mod download -x 39 | 40 | - name: Install apt packages 41 | if: inputs.bootstrap-apt-packages != '' 42 | shell: bash 43 | run: | 44 | DEBIAN_FRONTEND=noninteractive sudo apt update && sudo -E apt install -y ${{ inputs.bootstrap-apt-packages }} 45 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "docker" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" 15 | -------------------------------------------------------------------------------- /.github/scripts/coverage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import subprocess 3 | import sys 4 | import shlex 5 | 6 | 7 | class bcolors: 8 | HEADER = '\033[95m' 9 | OKBLUE = '\033[94m' 10 | OKCYAN = '\033[96m' 11 | OKGREEN = '\033[92m' 12 | WARNING = '\033[93m' 13 | FAIL = '\033[91m' 14 | ENDC = '\033[0m' 15 | BOLD = '\033[1m' 16 | UNDERLINE = '\033[4m' 17 | 18 | 19 | if len(sys.argv) < 3: 20 | print("Usage: coverage.py [threshold] [go-coverage-report]") 21 | sys.exit(1) 22 | 23 | 24 | threshold = float(sys.argv[1]) 25 | report = sys.argv[2] 26 | 27 | 28 | args = shlex.split(f"go tool cover -func {report}") 29 | p = subprocess.run(args, capture_output=True, text=True) 30 | 31 | percent_coverage = float(p.stdout.splitlines()[-1].split()[-1].replace("%", "")) 32 | print(f"{bcolors.BOLD}Coverage: {percent_coverage}%{bcolors.ENDC}") 33 | 34 | if percent_coverage < threshold: 35 | print(f"{bcolors.BOLD}{bcolors.FAIL}Coverage below threshold of {threshold}%{bcolors.ENDC}") 36 | sys.exit(1) 37 | -------------------------------------------------------------------------------- /.github/scripts/trigger-release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eu 3 | 4 | bold=$(tput bold) 5 | normal=$(tput sgr0) 6 | 7 | if ! [ -x "$(command -v gh)" ]; then 8 | echo "The GitHub CLI could not be found. To continue follow the instructions at https://github.com/cli/cli#installation" 9 | exit 1 10 | fi 11 | 12 | gh auth status 13 | 14 | # we need all of the git state to determine the next version. Since tagging is done by 15 | # the release pipeline it is possible to not have all of the tags from previous releases. 16 | git fetch --tags 17 | 18 | # populates the CHANGELOG.md and VERSION files 19 | echo "${bold}Generating changelog...${normal}" 20 | make changelog 2> /dev/null 21 | 22 | NEXT_VERSION=$(cat VERSION) 23 | 24 | if [[ "$NEXT_VERSION" == "" || "${NEXT_VERSION}" == "(Unreleased)" ]]; then 25 | echo "Could not determine the next version to release. Exiting..." 26 | exit 1 27 | fi 28 | 29 | while true; do 30 | read -p "${bold}Do you want to trigger a release for version '${NEXT_VERSION}'?${normal} [y/n] " yn 31 | case $yn in 32 | [Yy]* ) echo; break;; 33 | [Nn]* ) echo; echo "Cancelling release..."; exit;; 34 | * ) echo "Please answer yes or no.";; 35 | esac 36 | done 37 | 38 | echo "${bold}Kicking off release for ${NEXT_VERSION}${normal}..." 39 | echo 40 | gh workflow run release.yaml -f version=${NEXT_VERSION} 41 | 42 | echo 43 | echo "${bold}Waiting for release to start...${normal}" 44 | sleep 10 45 | 46 | set +e 47 | 48 | echo "${bold}Head to the release workflow to monitor the release:${normal} $(gh run list --workflow=release.yaml --limit=1 --json url --jq '.[].url')" 49 | id=$(gh run list --workflow=release.yaml --limit=1 --json databaseId --jq '.[].databaseId') 50 | gh run watch $id --exit-status || (echo ; echo "${bold}Logs of failed step:${normal}" && GH_PAGER="" gh run view $id --log-failed) 51 | -------------------------------------------------------------------------------- /.github/workflows/validations.yaml: -------------------------------------------------------------------------------- 1 | name: "Validations" 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | Static-Analysis: 11 | # Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline 12 | name: "Static analysis" 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Bootstrap environment 18 | uses: ./.github/actions/bootstrap 19 | 20 | - name: Run static analysis 21 | run: make static-analysis 22 | 23 | Unit-Test: 24 | # Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline 25 | name: "Unit tests" 26 | strategy: 27 | matrix: 28 | platform: 29 | - ubuntu-latest 30 | # - macos-latest # todo: mac runners are expensive minute-wise 31 | # - windows-latest # todo: support windows 32 | 33 | runs-on: ${{ matrix.platform }} 34 | steps: 35 | - uses: actions/checkout@v4 36 | 37 | - name: Bootstrap environment 38 | uses: ./.github/actions/bootstrap 39 | 40 | - name: Run unit tests 41 | run: make unit 42 | 43 | Build-Snapshot-Artifacts: 44 | name: "Build snapshot artifacts" 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/checkout@v4 48 | 49 | - name: Bootstrap environment 50 | uses: ./.github/actions/bootstrap 51 | 52 | - name: Set up QEMU 53 | uses: docker/setup-qemu-action@v3 54 | 55 | - name: Set up Docker Buildx 56 | uses: docker/setup-buildx-action@v3 57 | 58 | - name: Build snapshot artifacts 59 | run: make snapshot 60 | 61 | - run: docker images wagoodman/dive 62 | 63 | # todo: compare against known json output in shared volume 64 | - name: Test production image 65 | run: make ci-test-docker-image 66 | 67 | # why not use actions/upload-artifact? It is very slow (3 minutes to upload ~600MB of data, vs 10 seconds with this approach). 68 | # see https://github.com/actions/upload-artifact/issues/199 for more info 69 | - name: Upload snapshot artifacts 70 | uses: actions/cache/save@v4 71 | with: 72 | path: snapshot 73 | key: snapshot-build-${{ github.run_id }} 74 | 75 | # ... however the cache trick doesn't work on windows :( 76 | - uses: actions/upload-artifact@v4 77 | with: 78 | name: windows-artifacts 79 | path: snapshot/dive_windows_amd64_v1/dive.exe 80 | 81 | Acceptance-Linux: 82 | name: "Acceptance tests (Linux)" 83 | needs: [Build-Snapshot-Artifacts] 84 | runs-on: ubuntu-latest 85 | steps: 86 | - uses: actions/checkout@master 87 | 88 | - name: Download snapshot build 89 | uses: actions/cache/restore@v4 90 | with: 91 | path: snapshot 92 | key: snapshot-build-${{ github.run_id }} 93 | 94 | - name: Test linux run 95 | run: make ci-test-linux-run 96 | 97 | - name: Test DEB package installation 98 | run: make ci-test-deb-package-install 99 | 100 | - name: Test RPM package installation 101 | run: make ci-test-rpm-package-install 102 | 103 | Acceptance-Mac: 104 | name: "Acceptance tests (Mac)" 105 | needs: [Build-Snapshot-Artifacts] 106 | runs-on: macos-latest 107 | steps: 108 | - uses: actions/checkout@master 109 | 110 | - name: Download snapshot build 111 | uses: actions/cache/restore@v4 112 | with: 113 | path: snapshot 114 | key: snapshot-build-${{ github.run_id }} 115 | 116 | - name: Test darwin run 117 | run: make ci-test-mac-run 118 | 119 | Acceptance-Windows: 120 | name: "Acceptance tests (Windows)" 121 | needs: [Build-Snapshot-Artifacts] 122 | runs-on: windows-latest 123 | steps: 124 | - uses: actions/checkout@master 125 | 126 | - uses: actions/download-artifact@v4 127 | with: 128 | name: windows-artifacts 129 | 130 | - name: Test windows run 131 | run: make ci-test-windows-run 132 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # app configs 2 | .dive.yaml 3 | 4 | # misc 5 | /.image 6 | *.log 7 | CHANGELOG.md 8 | VERSION 9 | 10 | # IDEs 11 | /.idea 12 | /.vscode 13 | 14 | # tooling 15 | /bin 16 | /.tool-versions 17 | /.tmp 18 | /.tool 19 | /.mise.toml 20 | /.task 21 | /go.work 22 | /go.work.sum 23 | 24 | # builds 25 | /dist 26 | /snapshot 27 | 28 | # testing 29 | .cover 30 | coverage.txt 31 | 32 | # Binaries for programs and plugins 33 | *.exe 34 | *.exe~ 35 | *.dll 36 | *.so 37 | *.dylib 38 | 39 | # Test binary, build with `go test -c` 40 | *.test 41 | 42 | # Output of the go coverage tool, specifically when used with LiteIDE 43 | *.out 44 | /tmp 45 | /build 46 | /_vendor* 47 | /vendor 48 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | # TODO: enable this when we have coverage on docstring comments 2 | #issues: 3 | # # The list of ids of default excludes to include or disable. 4 | # include: 5 | # - EXC0002 # disable excluding of issues about comments from golint 6 | 7 | linters-settings: 8 | funlen: 9 | # Checks the number of lines in a function. 10 | # If lower than 0, disable the check. 11 | # Default: 60 12 | # TODO: drop this down over time... 13 | lines: 110 14 | # Checks the number of statements in a function. 15 | # If lower than 0, disable the check. 16 | # Default: 40 17 | statements: 60 18 | 19 | # TODO: use the default linters for now, but include these over time 20 | #linters: 21 | # # inverted configuration with `enable-all` and `disable` is not scalable during updates of golangci-lint 22 | # disable-all: true 23 | # enable: 24 | # - asciicheck 25 | # - bodyclose 26 | # - depguard 27 | # - dogsled 28 | # - dupl 29 | # - errcheck 30 | # - exportloopref 31 | # - funlen 32 | # - gocognit 33 | # - goconst 34 | # - gocritic 35 | # - gocyclo 36 | # - gofmt 37 | # - goimports 38 | # - goprintffuncname 39 | # - gosec 40 | # - gosimple 41 | # - govet 42 | # - ineffassign 43 | # - misspell 44 | # - nakedret 45 | # - nolintlint 46 | # - revive 47 | # - staticcheck 48 | # - stylecheck 49 | # - typecheck 50 | # - unconvert 51 | # - unparam 52 | # - unused 53 | # - whitespace 54 | 55 | # do not enable... 56 | # - gochecknoglobals 57 | # - gochecknoinits # this is too aggressive 58 | # - godot 59 | # - godox 60 | # - goerr113 61 | # - golint # deprecated 62 | # - gomnd # this is too aggressive 63 | # - interfacer # this is a good idea, but is no longer supported and is prone to false positives 64 | # - lll # without a way to specify per-line exception cases, this is not usable 65 | # - maligned # this is an excellent linter, but tricky to optimize and we are not sensitive to memory layout optimizations 66 | # - nestif 67 | # - prealloc # following this rule isn't consistently a good idea, as it sometimes forces unnecessary allocations that result in less idiomatic code 68 | # - scopelint # deprecated 69 | # - testpackage 70 | # - wsl # this doesn't have an auto-fixer yet and is pretty noisy (https://github.com/bombsimon/wsl/issues/90) 71 | # - varcheck # deprecated (since v1.49.0) due to: The owner seems to have abandoned the linter. Replaced by unused. 72 | # - deadcode # deprecated (since v1.49.0) due to: The owner seems to have abandoned the linter. Replaced by unused. 73 | # - structcheck # deprecated (since v1.49.0) due to: The owner seems to have abandoned the linter. Replaced by unused. 74 | # - rowserrcheck # we're not using sql.Rows at all in the codebase 75 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | release: 4 | # If set to auto, will mark the release as not ready for production in case there is an indicator for this in the 5 | # tag e.g. v1.0.0-rc1 .If set to true, will mark the release as not ready for production. 6 | prerelease: auto 7 | 8 | # If set to true, will not auto-publish the release. This is done to allow us to review the changelog before publishing. 9 | draft: false 10 | 11 | env: 12 | # required to support multi architecture docker builds 13 | - DOCKER_CLI_EXPERIMENTAL=enabled 14 | - CGO_ENABLED=0 15 | 16 | builds: 17 | - binary: dive 18 | dir: ./cmd/dive 19 | env: 20 | - CGO_ENABLED=0 21 | goos: 22 | - windows 23 | - darwin 24 | - linux 25 | goarch: 26 | - amd64 27 | - arm64 28 | - ppc64le 29 | ldflags: 30 | -w 31 | -s 32 | -extldflags '-static' 33 | -X main.version={{.Version}} 34 | -X main.gitCommit={{.Commit}} 35 | -X main.buildDate={{.Date}} 36 | -X main.gitDescription={{.Summary}} 37 | 38 | brews: 39 | - repository: 40 | owner: wagoodman 41 | name: homebrew-dive 42 | token: "{{.Env.TAP_GITHUB_TOKEN}}" 43 | homepage: &project_url "https://github.com/wagoodman/dive/" 44 | description: &description "A tool for exploring layers in a docker image" 45 | 46 | archives: 47 | - format: tar.gz 48 | format_overrides: 49 | - goos: windows 50 | format: zip 51 | 52 | nfpms: 53 | - license: MIT 54 | maintainer: Alex Goodman 55 | homepage: *project_url 56 | description: *description 57 | formats: 58 | - rpm 59 | - deb 60 | 61 | dockers: 62 | # docker.io amd64 63 | - &dockerhub_amd64 64 | id: docker-amd64 65 | ids: 66 | - dive 67 | use: buildx 68 | goarch: amd64 69 | image_templates: 70 | - docker.io/wagoodman/dive:latest 71 | - docker.io/wagoodman/dive:v{{.Version}}-amd64 72 | build_flag_templates: 73 | - "--build-arg=DOCKER_CLI_VERSION={{.Env.DOCKER_CLI_VERSION}}" 74 | - "--platform=linux/amd64" 75 | - "--label=org.opencontainers.image.created={{.Date}}" 76 | - "--label=org.opencontainers.image.title={{.ProjectName}}" 77 | - "--label=org.opencontainers.image.description=A tool for exploring layers in a docker image" 78 | - "--label=org.opencontainers.image.url={{.GitURL}}" 79 | - "--label=org.opencontainers.image.source={{.GitURL}}" 80 | - "--label=org.opencontainers.image.version={{.Version}}" 81 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 82 | - "--label=org.opencontainers.image.licenses=MIT" 83 | - "--label=org.opencontainers.image.authors=Alex Goodman <@wagoodman>" 84 | 85 | # docker.io arm64 86 | - &dockerhub_arm64 87 | id: docker-arm64 88 | ids: 89 | - dive 90 | use: buildx 91 | goarch: arm64 92 | image_templates: 93 | - docker.io/wagoodman/dive:v{{.Version}}-arm64 94 | build_flag_templates: 95 | - "--build-arg=DOCKER_CLI_VERSION={{.Env.DOCKER_CLI_VERSION}}" 96 | - "--platform=linux/arm64/v8" 97 | - "--label=org.opencontainers.image.created={{.Date}}" 98 | - "--label=org.opencontainers.image.title={{.ProjectName}}" 99 | - "--label=org.opencontainers.image.description=A tool for exploring layers in a docker image" 100 | - "--label=org.opencontainers.image.url={{.GitURL}}" 101 | - "--label=org.opencontainers.image.source={{.GitURL}}" 102 | - "--label=org.opencontainers.image.version={{.Version}}" 103 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 104 | - "--label=org.opencontainers.image.licenses=MIT" 105 | - "--label=org.opencontainers.image.authors=Alex Goodman <@wagoodman>" 106 | 107 | # ghcr.io amd64 108 | - id: ghcr-amd64 109 | <<: *dockerhub_amd64 110 | image_templates: 111 | - ghcr.io/wagoodman/dive:v{{.Version}}-amd64 112 | 113 | # ghcr.io arm64 114 | - id: ghcr-arm64 115 | <<: *dockerhub_arm64 116 | image_templates: 117 | - ghcr.io/wagoodman/dive:v{{.Version}}-arm64 118 | 119 | docker_manifests: 120 | # docker.io manifests 121 | - name_template: docker.io/wagoodman/dive:latest 122 | image_templates: &dockerhub_images 123 | - docker.io/wagoodman/dive:v{{.Version}}-amd64 124 | - docker.io/wagoodman/dive:v{{.Version}}-arm64 125 | 126 | - name_template: docker.io/wagoodman/dive:v{{.Major}} 127 | image_templates: *dockerhub_images 128 | 129 | - name_template: docker.io/wagoodman/dive:v{{.Major}}.{{.Minor}} 130 | image_templates: *dockerhub_images 131 | 132 | - name_template: docker.io/wagoodman/dive:v{{.Version}} 133 | image_templates: *dockerhub_images 134 | 135 | # ghcr.io manifests 136 | - name_template: ghcr.io/wagoodman/dive:latest 137 | image_templates: &ghcr_images 138 | - ghcr.io/wagoodman/dive:v{{.Version}}-amd64 139 | - ghcr.io/wagoodman/dive:v{{.Version}}-arm64 140 | 141 | - name_template: ghcr.io/wagoodman/dive:v{{.Major}} 142 | image_templates: *ghcr_images 143 | 144 | - name_template: ghcr.io/wagoodman/dive:v{{.Major}}.{{.Minor}} 145 | image_templates: *ghcr_images 146 | 147 | - name_template: ghcr.io/wagoodman/dive:v{{.Version}} 148 | image_templates: *ghcr_images 149 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.21 AS base 2 | 3 | ARG DOCKER_CLI_VERSION=${DOCKER_CLI_VERSION} 4 | RUN wget -O- https://download.docker.com/linux/static/stable/$(uname -m)/docker-${DOCKER_CLI_VERSION}.tgz | \ 5 | tar -xzf - docker/docker --strip-component=1 -C /usr/local/bin 6 | 7 | COPY dive /usr/local/bin/ 8 | 9 | # though we could make this a multi-stage image and copy the binary to scratch, this image is small enough 10 | # and users are expecting to be able to exec into it 11 | ENTRYPOINT ["/usr/local/bin/dive"] 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Alex Goodman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | OWNER = wagoodman 2 | PROJECT = dive 3 | 4 | TOOL_DIR = .tool 5 | BINNY = $(TOOL_DIR)/binny 6 | TASK = $(TOOL_DIR)/task 7 | 8 | .DEFAULT_GOAL := make-default 9 | 10 | ## Bootstrapping targets ################################# 11 | 12 | # note: we need to assume that binny and task have not already been installed 13 | $(BINNY): 14 | @mkdir -p $(TOOL_DIR) 15 | @curl -sSfL https://raw.githubusercontent.com/anchore/binny/main/install.sh | sh -s -- -b $(TOOL_DIR) 16 | 17 | # note: we need to assume that binny and task have not already been installed 18 | .PHONY: task 19 | $(TASK) task: $(BINNY) 20 | @$(BINNY) install task -q 21 | 22 | # this is a bootstrapping catch-all, where if the target doesn't exist, we'll ensure the tools are installed and then try again 23 | %: 24 | @make --silent $(TASK) 25 | @$(TASK) $@ 26 | 27 | ## Shim targets ################################# 28 | 29 | .PHONY: make-default 30 | make-default: $(TASK) 31 | @# run the default task in the taskfile 32 | @$(TASK) 33 | 34 | # for those of us that can't seem to kick the habit of typing `make ...` lets wrap the superior `task` tool 35 | TASKS := $(shell bash -c "test -f $(TASK) && $(TASK) -l | grep '^\* ' | cut -d' ' -f2 | tr -d ':' | tr '\n' ' '" ) $(shell bash -c "test -f $(TASK) && $(TASK) -l | grep 'aliases:' | cut -d ':' -f 3 | tr '\n' ' ' | tr -d ','") 36 | 37 | .PHONY: $(TASKS) 38 | $(TASKS): $(TASK) 39 | @$(TASK) $@ 40 | 41 | ## actual targets 42 | 43 | ci-test-windows-run: 44 | dive.exe --source docker-archive .data/test-docker-image.tar --ci --ci-config .data/.dive-ci 45 | 46 | help: $(TASK) 47 | @$(TASK) -l 48 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release process 2 | 3 | 4 | ## Creating a release 5 | 6 | **Trigger a new release with `make release`**. 7 | 8 | At this point you'll see a preview changelog in the terminal. If you're happy with the 9 | changelog, press `y` to continue, otherwise you can abort and adjust the labels on the 10 | PRs and issues to be included in the release and re-run the release trigger command. 11 | 12 | 13 | ## Retracting a release 14 | 15 | If a release is found to be problematic, it can be retracted with the following steps: 16 | 17 | - Deleting the GitHub Release 18 | - Untag the docker images in the `docker.io` registry 19 | - Revert the brew formula in [`wagoodman/homebrew-dive`](https://github.com/wagoodman/homebrew-dive) to point to the previous release 20 | - Add a new `retract` entry in the go.mod for the versioned release 21 | 22 | **Note**: do not delete release tags from the git repository since there may already be references to the release 23 | in the go proxy, which will cause confusion when trying to reuse the tag later (the H1 hash will not match and there 24 | will be a warning when users try to pull the new release). 25 | -------------------------------------------------------------------------------- /cmd/dive/cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/anchore/clio" 5 | "github.com/spf13/cobra" 6 | "github.com/wagoodman/dive/cmd/dive/cli/internal/command" 7 | "github.com/wagoodman/dive/cmd/dive/cli/internal/ui" 8 | "github.com/wagoodman/dive/internal/bus" 9 | "github.com/wagoodman/dive/internal/log" 10 | ) 11 | 12 | func Application(id clio.Identification) clio.Application { 13 | app, _ := create(id) 14 | return app 15 | } 16 | 17 | func Command(id clio.Identification) *cobra.Command { 18 | _, cmd := create(id) 19 | return cmd 20 | } 21 | 22 | func create(id clio.Identification) (clio.Application, *cobra.Command) { 23 | clioCfg := clio.NewSetupConfig(id). 24 | WithGlobalConfigFlag(). // add persistent -c for reading an application config from 25 | WithGlobalLoggingFlags(). // add persistent -v and -q flags tied to the logging config 26 | WithConfigInRootHelp(). // --help on the root command renders the full application config in the help text 27 | WithUI(ui.None()). 28 | WithInitializers( 29 | func(state *clio.State) error { 30 | bus.Set(state.Bus) 31 | log.Set(state.Logger) 32 | 33 | //stereoscope.SetBus(state.Bus) 34 | //stereoscope.SetLogger(state.Logger) 35 | return nil 36 | }, 37 | ) 38 | //WithPostRuns(func(_ *clio.State, _ error) { 39 | // stereoscope.Cleanup() 40 | //}) 41 | 42 | app := clio.New(*clioCfg) 43 | 44 | rootCmd := command.Root(app) 45 | 46 | rootCmd.AddCommand( 47 | clio.VersionCommand(id), 48 | clio.ConfigCommand(app, nil), 49 | command.Build(app), 50 | ) 51 | 52 | return app, rootCmd 53 | } 54 | -------------------------------------------------------------------------------- /cmd/dive/cli/cli_build_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "github.com/stretchr/testify/require" 6 | "regexp" 7 | "testing" 8 | ) 9 | 10 | func Test_Build_Dockerfile(t *testing.T) { 11 | t.Setenv("DIVE_CONFIG", "./testdata/image-multi-layer-dockerfile/dive-pass.yaml") 12 | 13 | t.Run("implicit dockerfile", func(t *testing.T) { 14 | rootCmd := getTestCommand(t, "build testdata/image-multi-layer-dockerfile") 15 | stdout := Capture().WithStdout().WithSuppress().Run(t, func() { 16 | require.NoError(t, rootCmd.Execute()) 17 | }) 18 | snaps.MatchSnapshot(t, stdout) 19 | }) 20 | 21 | t.Run("explicit file flag", func(t *testing.T) { 22 | rootCmd := getTestCommand(t, "build testdata/image-multi-layer-dockerfile -f testdata/image-multi-layer-dockerfile/Dockerfile") 23 | stdout := Capture().WithStdout().WithSuppress().Run(t, func() { 24 | require.NoError(t, rootCmd.Execute()) 25 | }) 26 | snaps.MatchSnapshot(t, stdout) 27 | }) 28 | } 29 | 30 | func Test_Build_Containerfile(t *testing.T) { 31 | t.Setenv("DIVE_CONFIG", "./testdata/image-multi-layer-containerfile/dive-pass.yaml") 32 | 33 | t.Run("implicit containerfile", func(t *testing.T) { 34 | rootCmd := getTestCommand(t, "build testdata/image-multi-layer-containerfile") 35 | stdout := Capture().WithStdout().WithSuppress().Run(t, func() { 36 | require.NoError(t, rootCmd.Execute()) 37 | }) 38 | snaps.MatchSnapshot(t, stdout) 39 | }) 40 | 41 | t.Run("explicit file flag", func(t *testing.T) { 42 | rootCmd := getTestCommand(t, "build testdata/image-multi-layer-containerfile -f testdata/image-multi-layer-containerfile/Containerfile") 43 | stdout := Capture().WithStdout().WithSuppress().Run(t, func() { 44 | require.NoError(t, rootCmd.Execute()) 45 | }) 46 | snaps.MatchSnapshot(t, stdout) 47 | }) 48 | } 49 | 50 | func Test_Build_CI_gate_fail(t *testing.T) { 51 | t.Setenv("DIVE_CONFIG", "./testdata/image-multi-layer-dockerfile/dive-fail.yaml") 52 | 53 | rootCmd := getTestCommand(t, "build testdata/image-multi-layer-dockerfile") 54 | stdout := Capture().WithStdout().WithSuppress().Run(t, func() { 55 | // failing gate should result in a non-zero exit code 56 | require.Error(t, rootCmd.Execute()) 57 | }) 58 | snaps.MatchSnapshot(t, stdout) 59 | 60 | } 61 | 62 | func Test_BuildFailure(t *testing.T) { 63 | 64 | t.Run("nonexistent directory", func(t *testing.T) { 65 | rootCmd := getTestCommand(t, "build ./path/does/not/exist") 66 | combined := Capture().WithStdout().WithStderr().Run(t, func() { 67 | require.ErrorContains(t, rootCmd.Execute(), "could not find Containerfile or Dockerfile") 68 | }) 69 | 70 | assert.Contains(t, combined, "Building image") 71 | 72 | snaps.MatchSnapshot(t, combined) 73 | }) 74 | 75 | t.Run("invalid dockerfile", func(t *testing.T) { 76 | rootCmd := getTestCommand(t, "build ./testdata/invalid") 77 | combined := Capture().WithStdout().WithStderr().WithSuppress().Run(t, func() { 78 | 79 | require.ErrorContains(t, rootCmd.Execute(), "cannot build image: exit status 1") 80 | }) 81 | 82 | assert.Contains(t, combined, "Building image") 83 | // ensure we're passing through docker feedback 84 | assert.Contains(t, combined, "unknown instruction: INVALID") 85 | 86 | // replace anything starting with "docker-desktop://", like "docker-desktop://dashboard/build/desktop-linux/desktop-linux/ujdmhgkwo0sqqpopsnum3xakd" 87 | combined = regexp.MustCompile("docker-desktop://[^ ]+").ReplaceAllString(combined, "docker-desktop://") 88 | 89 | snaps.MatchSnapshot(t, combined) 90 | }) 91 | } 92 | -------------------------------------------------------------------------------- /cmd/dive/cli/cli_ci_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "github.com/stretchr/testify/require" 6 | "testing" 7 | ) 8 | 9 | func Test_CI_DefaultCIConfig(t *testing.T) { 10 | // this lets the test harness to unset any DIVE_CONFIG env var 11 | t.Setenv("DIVE_CONFIG", "-") 12 | 13 | rootCmd := getTestCommand(t, repoPath(t, ".data/test-docker-image.tar")+" -vv") 14 | cd(t, "testdata/default-ci-config") 15 | combined := Capture().WithStdout().WithStderr().Run(t, func() { 16 | // failing gate should result in a non-zero exit code 17 | require.Error(t, rootCmd.Execute()) 18 | }) 19 | 20 | assert.Contains(t, combined, "lowest-efficiency: \"0.96\"", "missing lowest-efficiency rule") 21 | assert.Contains(t, combined, "highest-wasted-bytes: 19Mb", "missing highest-wasted-bytes rule") 22 | assert.Contains(t, combined, "highest-user-wasted-percent: \"0.6\"", "missing highest-user-wasted-percent rule") 23 | 24 | snaps.MatchSnapshot(t, combined) 25 | } 26 | 27 | func Test_CI_Fail(t *testing.T) { 28 | t.Setenv("DIVE_CONFIG", "./testdata/image-multi-layer-dockerfile/dive-fail.yaml") 29 | 30 | rootCmd := getTestCommand(t, "build testdata/image-multi-layer-dockerfile") 31 | stdout := Capture().WithStdout().WithSuppress().Run(t, func() { 32 | // failing gate should result in a non-zero exit code 33 | require.Error(t, rootCmd.Execute()) 34 | }) 35 | snaps.MatchSnapshot(t, stdout) 36 | 37 | } 38 | 39 | func Test_CI_LegacyRules(t *testing.T) { 40 | t.Setenv("DIVE_CONFIG", "./testdata/config/dive-ci-legacy.yaml") 41 | 42 | rootCmd := getTestCommand(t, "config --load") 43 | all := Capture().All().Run(t, func() { 44 | require.NoError(t, rootCmd.Execute()) 45 | }) 46 | 47 | // this proves that we can load the legacy rules and map them to the standard rules 48 | assert.Contains(t, all, "lowest-efficiency: '0.95'", "missing lowest-efficiency legacy rule") 49 | assert.Contains(t, all, "highest-wasted-bytes: '20MB'", "missing highest-wasted-bytes legacy rule") 50 | assert.Contains(t, all, "highest-user-wasted-percent: '0.2'", "missing highest-user-wasted-percent legacy rule") 51 | } 52 | -------------------------------------------------------------------------------- /cmd/dive/cli/cli_config_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/stretchr/testify/require" 5 | "testing" 6 | ) 7 | 8 | func Test_Config(t *testing.T) { 9 | t.Setenv("DIVE_CONFIG", "./testdata/image-multi-layer-dockerfile/dive-pass.yaml") 10 | 11 | rootCmd := getTestCommand(t, "config --load") 12 | all := Capture().All().Run(t, func() { 13 | require.NoError(t, rootCmd.Execute()) 14 | }) 15 | 16 | snaps.MatchSnapshot(t, all) 17 | } 18 | -------------------------------------------------------------------------------- /cmd/dive/cli/cli_json_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "github.com/stretchr/testify/require" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | ) 10 | 11 | func Test_JsonOutput(t *testing.T) { 12 | 13 | t.Run("json output", func(t *testing.T) { 14 | dest := t.TempDir() 15 | file := filepath.Join(dest, "output.json") 16 | rootCmd := getTestCommand(t, "busybox:1.37.0@sha256:ad9fa4d07136a83e69a54ef00102f579d04eba431932de3b0f098cc5d5948f9f --json "+file) 17 | combined := Capture().WithStdout().WithStderr().Run(t, func() { 18 | require.NoError(t, rootCmd.Execute()) 19 | }) 20 | 21 | assert.Contains(t, combined, "Exporting details") 22 | assert.Contains(t, combined, "file") 23 | 24 | contents, err := os.ReadFile(file) 25 | require.NoError(t, err) 26 | 27 | snaps.MatchJSON(t, contents) 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /cmd/dive/cli/cli_load_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/stretchr/testify/require" 7 | "os" 8 | "os/exec" 9 | "testing" 10 | ) 11 | 12 | func Test_LoadImage(t *testing.T) { 13 | image := "busybox:1.37.0@sha256:ad9fa4d07136a83e69a54ef00102f579d04eba431932de3b0f098cc5d5948f9f" 14 | archive := repoPath(t, ".data/test-docker-image.tar") 15 | 16 | t.Run("from docker engine", func(t *testing.T) { 17 | runWithCombinedOutput(t, fmt.Sprintf("docker://%s", image)) 18 | }) 19 | 20 | t.Run("from docker engine (flag)", func(t *testing.T) { 21 | 22 | runWithCombinedOutput(t, fmt.Sprintf("--source docker %s", image)) 23 | }) 24 | 25 | t.Run("from podman engine", func(t *testing.T) { 26 | if _, err := exec.LookPath("podman"); err != nil { 27 | t.Skip("podman not installed, skipping test") 28 | } 29 | // pull the image from podman first 30 | require.NoError(t, exec.Command("podman", "pull", image).Run()) 31 | 32 | runWithCombinedOutput(t, fmt.Sprintf("podman://%s", image)) 33 | }) 34 | 35 | t.Run("from podman engine (flag)", func(t *testing.T) { 36 | if _, err := exec.LookPath("podman"); err != nil { 37 | t.Skip("podman not installed, skipping test") 38 | } 39 | 40 | // pull the image from podman first 41 | require.NoError(t, exec.Command("podman", "pull", image).Run()) 42 | 43 | runWithCombinedOutput(t, fmt.Sprintf("--source podman %s", image)) 44 | }) 45 | 46 | t.Run("from archive", func(t *testing.T) { 47 | runWithCombinedOutput(t, fmt.Sprintf("docker-archive://%s", archive)) 48 | }) 49 | 50 | t.Run("from archive (flag)", func(t *testing.T) { 51 | runWithCombinedOutput(t, fmt.Sprintf("--source docker-archive %s", archive)) 52 | }) 53 | } 54 | 55 | func runWithCombinedOutput(t testing.TB, cmd string) { 56 | t.Helper() 57 | rootCmd := getTestCommand(t, cmd) 58 | combined := Capture().WithStdout().WithStderr().Run(t, func() { 59 | require.NoError(t, rootCmd.Execute()) 60 | }) 61 | 62 | assertLoadOutput(t, combined) 63 | } 64 | 65 | func assertLoadOutput(t testing.TB, combined string) { 66 | t.Helper() 67 | assert.Contains(t, combined, "Loading image") 68 | assert.Contains(t, combined, "Analyzing image") 69 | assert.Contains(t, combined, "Evaluating image") 70 | snaps.MatchSnapshot(t, combined) 71 | } 72 | 73 | func Test_FetchFailure(t *testing.T) { 74 | t.Run("nonexistent image", func(t *testing.T) { 75 | rootCmd := getTestCommand(t, "docker:wagoodman/nonexistent/image:tag") 76 | combined := Capture().WithStdout().WithStderr().Run(t, func() { 77 | require.ErrorContains(t, rootCmd.Execute(), "cannot load image: Error response from daemon: invalid reference format") 78 | }) 79 | 80 | assert.Contains(t, combined, "Loading image") 81 | 82 | snaps.MatchSnapshot(t, combined) 83 | }) 84 | 85 | t.Run("invalid image name", func(t *testing.T) { 86 | rootCmd := getTestCommand(t, "docker:///wagoodman/invalid:image:format") 87 | combined := Capture().WithStdout().WithStderr().Run(t, func() { 88 | require.ErrorContains(t, rootCmd.Execute(), "cannot load image: Error response from daemon: invalid reference format") 89 | }) 90 | 91 | assert.Contains(t, combined, "Loading image") 92 | 93 | snaps.MatchSnapshot(t, combined) 94 | }) 95 | } 96 | 97 | func cd(t testing.TB, to string) { 98 | t.Helper() 99 | from, err := os.Getwd() 100 | require.NoError(t, err) 101 | require.NoError(t, os.Chdir(to)) 102 | t.Cleanup(func() { 103 | require.NoError(t, os.Chdir(from)) 104 | }) 105 | } 106 | -------------------------------------------------------------------------------- /cmd/dive/cli/cli_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "flag" 6 | "github.com/anchore/clio" 7 | "github.com/charmbracelet/lipgloss" 8 | snapsPkg "github.com/gkampitakis/go-snaps/snaps" 9 | "github.com/google/shlex" 10 | "github.com/muesli/termenv" 11 | "github.com/spf13/cobra" 12 | "github.com/stretchr/testify/require" 13 | "go.uber.org/atomic" 14 | "io" 15 | "os" 16 | "os/exec" 17 | "path/filepath" 18 | "strings" 19 | "testing" 20 | ) 21 | 22 | var ( 23 | updateSnapshot = flag.Bool("update", false, "update any test snapshots") 24 | snaps *snapsPkg.Config 25 | repoRootCache atomic.String 26 | ) 27 | 28 | func TestMain(m *testing.M) { 29 | // flags are not parsed until after test.Main is called... 30 | flag.Parse() 31 | 32 | os.Unsetenv("DIVE_CONFIG") 33 | 34 | // disable colors 35 | lipgloss.SetColorProfile(termenv.Ascii) 36 | 37 | snaps = snapsPkg.WithConfig( 38 | snapsPkg.Update(*updateSnapshot), 39 | snapsPkg.Dir("testdata/snapshots"), 40 | ) 41 | 42 | v := m.Run() 43 | 44 | snapsPkg.Clean(m) 45 | 46 | os.Exit(v) 47 | } 48 | 49 | func TestUpdateSnapshotDisabled(t *testing.T) { 50 | require.False(t, *updateSnapshot, "update snapshot flag should be disabled") 51 | } 52 | 53 | func repoPath(t testing.TB, path string) string { 54 | t.Helper() 55 | root := repoRoot(t) 56 | return filepath.Join(root, path) 57 | } 58 | 59 | func repoRoot(t testing.TB) string { 60 | val := repoRootCache.Load() 61 | if val != "" { 62 | return val 63 | } 64 | t.Helper() 65 | // use git to find the root of the repo 66 | out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() 67 | if err != nil { 68 | t.Fatalf("failed to get repo root: %v", err) 69 | } 70 | val = strings.TrimSpace(string(out)) 71 | repoRootCache.Store(val) 72 | return val 73 | } 74 | 75 | func getTestCommand(t testing.TB, cmd string) *cobra.Command { 76 | switch os.Getenv("DIVE_CONFIG") { 77 | case "": 78 | t.Setenv("DIVE_CONFIG", "./testdata/dive-enable-ci.yaml") 79 | case "-": 80 | t.Setenv("DIVE_CONFIG", "") 81 | } 82 | 83 | // need basic output to logger for testing... 84 | //l, err := logrus.New(logrus.DefaultConfig()) 85 | //require.NoError(t, err) 86 | //log.Set(l) 87 | 88 | // get the root command 89 | c := Command(clio.Identification{ 90 | Name: "dive", 91 | Version: "testing", 92 | }) 93 | 94 | args, err := shlex.Split(cmd) 95 | require.NoError(t, err, "failed to parse command line %q", cmd) 96 | 97 | c.SetArgs(args) 98 | 99 | return c 100 | } 101 | 102 | type capturer struct { 103 | stdout bool 104 | stderr bool 105 | suppress bool 106 | } 107 | 108 | func Capture() *capturer { 109 | return &capturer{} 110 | } 111 | 112 | func (c *capturer) WithSuppress() *capturer { 113 | c.suppress = true 114 | return c 115 | } 116 | 117 | func (c *capturer) All() *capturer { 118 | c.stdout = true 119 | c.stderr = true 120 | return c 121 | } 122 | 123 | func (c *capturer) WithStdout() *capturer { 124 | c.stdout = true 125 | return c 126 | } 127 | 128 | func (c *capturer) WithStderr() *capturer { 129 | c.stderr = true 130 | return c 131 | } 132 | 133 | func (c *capturer) Run(t testing.TB, f func()) string { 134 | t.Helper() 135 | 136 | r, w, err := os.Pipe() 137 | if err != nil { 138 | panic(err) 139 | } 140 | 141 | devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) 142 | if err != nil { 143 | panic(err) 144 | } 145 | defer devNull.Close() 146 | 147 | oldStdout := os.Stdout 148 | oldStderr := os.Stderr 149 | 150 | if c.stdout { 151 | os.Stdout = w 152 | } else if c.suppress { 153 | os.Stdout = devNull 154 | } 155 | 156 | if c.stderr { 157 | os.Stderr = w 158 | } else if c.suppress { 159 | os.Stderr = devNull 160 | } 161 | 162 | defer func() { 163 | os.Stdout = oldStdout 164 | os.Stderr = oldStderr 165 | }() 166 | 167 | f() 168 | require.NoError(t, w.Close()) 169 | 170 | var buf bytes.Buffer 171 | _, err = io.Copy(&buf, r) 172 | require.NoError(t, err) 173 | 174 | return buf.String() 175 | } 176 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/command/adapter/analyzer.go: -------------------------------------------------------------------------------- 1 | package adapter 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/dustin/go-humanize" 7 | "github.com/wagoodman/dive/dive/image" 8 | "github.com/wagoodman/dive/internal/bus" 9 | "github.com/wagoodman/dive/internal/bus/event/payload" 10 | "github.com/wagoodman/dive/internal/log" 11 | ) 12 | 13 | type Analyzer interface { 14 | Analyze(ctx context.Context, img *image.Image) (*image.Analysis, error) 15 | } 16 | 17 | type analysisActionObserver struct { 18 | Analyzer func(context.Context, *image.Image) (*image.Analysis, error) 19 | } 20 | 21 | func NewAnalyzer() Analyzer { 22 | return analysisActionObserver{ 23 | Analyzer: image.Analyze, 24 | } 25 | } 26 | 27 | func (a analysisActionObserver) Analyze(ctx context.Context, img *image.Image) (*image.Analysis, error) { 28 | log.WithFields("image", img.Request).Infof("analyzing") 29 | 30 | layers := len(img.Layers) 31 | var files int 32 | var fileSize uint64 33 | for _, layer := range img.Layers { 34 | files += layer.Tree.Size 35 | fileSize += layer.Tree.FileSize 36 | } 37 | fileSizeStr := humanize.Bytes(fileSize) 38 | filesStr := humanize.Comma(int64(files)) 39 | 40 | log.Debugf("├── layers: %d", layers) 41 | log.Debugf("├── files: %s", filesStr) 42 | log.Debugf("└── file size: %s", fileSizeStr) 43 | 44 | mon := bus.StartTask(payload.GenericTask{ 45 | Title: payload.Title{ 46 | Default: "Analyzing image", 47 | WhileRunning: "Analyzing image", 48 | OnSuccess: "Analyzed image", 49 | }, 50 | HideOnSuccess: false, 51 | HideStageOnSuccess: false, 52 | ID: img.Request, 53 | Context: fmt.Sprintf("[layers:%d files:%s size:%s]", layers, filesStr, fileSizeStr), 54 | }) 55 | 56 | analysis, err := a.Analyzer(ctx, img) 57 | if err != nil { 58 | mon.SetError(err) 59 | } else { 60 | mon.SetCompleted() 61 | } 62 | 63 | if err == nil && analysis == nil { 64 | err = fmt.Errorf("no results returned") 65 | } 66 | 67 | return analysis, err 68 | } 69 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/command/adapter/evaluator.go: -------------------------------------------------------------------------------- 1 | package adapter 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/wagoodman/dive/cmd/dive/cli/internal/command/ci" 7 | "github.com/wagoodman/dive/dive/image" 8 | "github.com/wagoodman/dive/internal/bus" 9 | "github.com/wagoodman/dive/internal/bus/event/payload" 10 | "github.com/wagoodman/dive/internal/log" 11 | ) 12 | 13 | type Evaluator interface { 14 | Evaluate(ctx context.Context, analysis *image.Analysis) ci.Evaluation 15 | } 16 | 17 | type evaluationActionObserver struct { 18 | ci.Evaluator 19 | } 20 | 21 | func NewEvaluator(rules []ci.Rule) Evaluator { 22 | return evaluationActionObserver{ 23 | Evaluator: ci.NewEvaluator(rules), 24 | } 25 | } 26 | 27 | func (c evaluationActionObserver) Evaluate(ctx context.Context, analysis *image.Analysis) ci.Evaluation { 28 | log.WithFields("image", analysis.Image).Infof("evaluating image") 29 | mon := bus.StartTask(payload.GenericTask{ 30 | Title: payload.Title{ 31 | Default: "Evaluating image", 32 | WhileRunning: "Evaluating image", 33 | OnSuccess: "Evaluated image", 34 | }, 35 | HideOnSuccess: false, 36 | HideStageOnSuccess: false, 37 | ID: analysis.Image, 38 | Context: fmt.Sprintf("[rules: %d]", len(c.Rules)), 39 | }) 40 | eval := c.Evaluator.Evaluate(ctx, analysis) 41 | if eval.Pass { 42 | mon.SetCompleted() 43 | } else { 44 | mon.SetError(fmt.Errorf("failed evaluation")) 45 | } 46 | bus.Report(eval.Report) 47 | return eval 48 | } 49 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/command/adapter/exporter.go: -------------------------------------------------------------------------------- 1 | package adapter 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/spf13/afero" 7 | "github.com/wagoodman/dive/cmd/dive/cli/internal/command/export" 8 | "github.com/wagoodman/dive/dive/image" 9 | "github.com/wagoodman/dive/internal/bus" 10 | "github.com/wagoodman/dive/internal/bus/event/payload" 11 | "github.com/wagoodman/dive/internal/log" 12 | "os" 13 | ) 14 | 15 | type Exporter interface { 16 | ExportTo(ctx context.Context, img *image.Analysis, path string) error 17 | } 18 | 19 | type jsonExporter struct { 20 | filesystem afero.Fs 21 | } 22 | 23 | func NewExporter(fs afero.Fs) Exporter { 24 | return &jsonExporter{ 25 | filesystem: fs, 26 | } 27 | } 28 | 29 | func (e *jsonExporter) ExportTo(ctx context.Context, analysis *image.Analysis, path string) error { 30 | log.WithFields("path", path).Infof("exporting analysis") 31 | 32 | mon := bus.StartTask(payload.GenericTask{ 33 | Title: payload.Title{ 34 | Default: "Exporting details", 35 | WhileRunning: "Exporting details", 36 | OnSuccess: "Exported details", 37 | }, 38 | HideOnSuccess: false, 39 | HideStageOnSuccess: false, 40 | ID: analysis.Image, 41 | Context: fmt.Sprintf("[file: %s]", path), 42 | }) 43 | 44 | bytes, err := export.NewExport(analysis).Marshal() 45 | if err != nil { 46 | mon.SetError(err) 47 | return fmt.Errorf("cannot marshal export payload: %w", err) 48 | } else { 49 | mon.SetCompleted() 50 | } 51 | 52 | file, err := e.filesystem.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644) 53 | if err != nil { 54 | return fmt.Errorf("cannot open export file: %w", err) 55 | } 56 | defer file.Close() 57 | 58 | _, err = file.Write(bytes) 59 | if err != nil { 60 | return fmt.Errorf("cannot write to export file: %w", err) 61 | } 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/command/adapter/resolver.go: -------------------------------------------------------------------------------- 1 | package adapter 2 | 3 | import ( 4 | "context" 5 | "github.com/wagoodman/dive/dive/image" 6 | "github.com/wagoodman/dive/internal/bus" 7 | "github.com/wagoodman/dive/internal/bus/event/payload" 8 | "github.com/wagoodman/dive/internal/log" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | type imageActionObserver struct { 14 | image.Resolver 15 | } 16 | 17 | func ImageResolver(resolver image.Resolver) image.Resolver { 18 | return imageActionObserver{ 19 | Resolver: resolver, 20 | } 21 | } 22 | 23 | func (i imageActionObserver) Build(ctx context.Context, options []string) (*image.Image, error) { 24 | log.Info("building image") 25 | log.Debugf("└── %s", strings.Join(options, " ")) 26 | 27 | mon := bus.StartTask(payload.GenericTask{ 28 | Title: payload.Title{ 29 | Default: "Building image", 30 | WhileRunning: "Building image", 31 | OnSuccess: "Built image", 32 | }, 33 | HideOnSuccess: false, 34 | HideStageOnSuccess: false, 35 | Context: "... " + strings.Join(options, " "), 36 | }) 37 | 38 | ctx = payload.SetGenericProgressToContext(ctx, mon) 39 | 40 | img, err := i.Resolver.Build(ctx, options) 41 | if err != nil { 42 | mon.SetError(err) 43 | } else { 44 | mon.SetCompleted() 45 | } 46 | return img, err 47 | } 48 | 49 | func (i imageActionObserver) Fetch(ctx context.Context, id string) (*image.Image, error) { 50 | log.WithFields("image", id).Info("fetching") 51 | log.Debugf("└── resolver: %s", i.Resolver.Name()) 52 | 53 | ctx, cancel := context.WithCancel(ctx) 54 | defer cancel() 55 | 56 | mon := bus.StartTask(payload.GenericTask{ 57 | Title: payload.Title{ 58 | Default: "Loading image", 59 | WhileRunning: "Loading image", 60 | OnSuccess: "Fetched image", 61 | }, 62 | HideOnSuccess: false, 63 | HideStageOnSuccess: false, 64 | ID: id, 65 | Context: id, 66 | }) 67 | 68 | ctx = payload.SetGenericProgressToContext(ctx, mon) 69 | 70 | go func() { 71 | // in 5 seconds if the context is not cancelled, log the message 72 | select { // nolint:gosimple 73 | case <-time.After(3 * time.Second): 74 | if ctx.Err() == nil { 75 | bus.Notify(" • this can take a while for large images...") 76 | mon.AtomicStage.Set("(this can take a while for large images)") 77 | 78 | // TODO: default level should be error for this to work when using the UI 79 | //log.Warn("this can take a while for large images") 80 | } 81 | } 82 | }() 83 | 84 | img, err := i.Resolver.Fetch(ctx, id) 85 | if err != nil { 86 | mon.SetError(err) 87 | } else { 88 | mon.SetCompleted() 89 | } 90 | return img, err 91 | } 92 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/command/build.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | "github.com/anchore/clio" 6 | "github.com/spf13/cobra" 7 | "github.com/wagoodman/dive/cmd/dive/cli/internal/command/adapter" 8 | "github.com/wagoodman/dive/cmd/dive/cli/internal/options" 9 | "github.com/wagoodman/dive/dive" 10 | ) 11 | 12 | type buildOptions struct { 13 | options.Application `yaml:",inline" mapstructure:",squash"` 14 | 15 | // reserved for future use of build-only flags 16 | } 17 | 18 | func Build(app clio.Application) *cobra.Command { 19 | opts := &buildOptions{ 20 | Application: options.DefaultApplication(), 21 | } 22 | return app.SetupCommand(&cobra.Command{ 23 | Use: "build [any valid `docker build` arguments]", 24 | Short: "Builds and analyzes a docker image from a Dockerfile (this is a thin wrapper for the `docker build` command).", 25 | DisableFlagParsing: true, 26 | RunE: func(cmd *cobra.Command, args []string) error { 27 | if err := setUI(app, opts.Application); err != nil { 28 | return fmt.Errorf("failed to set UI: %w", err) 29 | } 30 | 31 | resolver, err := dive.GetImageResolver(opts.Analysis.Source) 32 | if err != nil { 33 | return fmt.Errorf("cannot determine image provider for build: %w", err) 34 | } 35 | 36 | ctx := cmd.Context() 37 | 38 | img, err := adapter.ImageResolver(resolver).Build(ctx, args) 39 | if err != nil { 40 | return fmt.Errorf("cannot build image: %w", err) 41 | } 42 | 43 | return run(cmd.Context(), opts.Application, img, resolver) 44 | }, 45 | }, opts) 46 | } 47 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/command/ci/rule.go: -------------------------------------------------------------------------------- 1 | package ci 2 | 3 | import ( 4 | "github.com/wagoodman/dive/dive/image" 5 | ) 6 | 7 | const ( 8 | RuleUnknown = iota 9 | RulePassed 10 | RuleFailed 11 | RuleWarning 12 | RuleDisabled 13 | RuleMisconfigured 14 | RuleConfigured 15 | ) 16 | 17 | type Rule interface { 18 | Key() string 19 | Configuration() string 20 | Evaluate(result *image.Analysis) (RuleStatus, string) 21 | } 22 | 23 | type RuleStatus int 24 | 25 | type RuleResult struct { 26 | status RuleStatus 27 | message string 28 | } 29 | 30 | func (status RuleStatus) String(f format) string { 31 | switch status { 32 | case RulePassed: 33 | return f.Success.Render("PASS") 34 | case RuleFailed: 35 | return f.Failure.Render("FAIL") 36 | case RuleWarning: 37 | return f.Warning.Render("WARN") 38 | case RuleDisabled: 39 | return f.Disabled.Render("SKIP") 40 | case RuleMisconfigured: 41 | return f.Warning.Render("MISCONFIGURED") 42 | case RuleConfigured: 43 | return "CONFIGURED " 44 | default: 45 | return f.Warning.Render("Unknown") 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/command/export/export.go: -------------------------------------------------------------------------------- 1 | package export 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/wagoodman/dive/dive/filetree" 6 | diveImage "github.com/wagoodman/dive/dive/image" 7 | "github.com/wagoodman/dive/internal/log" 8 | ) 9 | 10 | type Export struct { 11 | Layer []Layer `json:"layer"` 12 | Image Image `json:"image"` 13 | } 14 | 15 | type Layer struct { 16 | Index int `json:"index"` 17 | ID string `json:"id"` 18 | DigestID string `json:"digestId"` 19 | SizeBytes uint64 `json:"sizeBytes"` 20 | Command string `json:"command"` 21 | FileList []filetree.FileInfo `json:"fileList"` 22 | } 23 | 24 | type Image struct { 25 | SizeBytes uint64 `json:"sizeBytes"` 26 | InefficientBytes uint64 `json:"inefficientBytes"` 27 | EfficiencyScore float64 `json:"efficiencyScore"` 28 | InefficientFiles []FileReference `json:"fileReference"` 29 | } 30 | 31 | type FileReference struct { 32 | References int `json:"count"` 33 | SizeBytes uint64 `json:"sizeBytes"` 34 | Path string `json:"file"` 35 | } 36 | 37 | // NewExport exports the analysis to a JSON 38 | func NewExport(analysis *diveImage.Analysis) *Export { 39 | data := Export{ 40 | Layer: make([]Layer, len(analysis.Layers)), 41 | Image: Image{ 42 | InefficientFiles: make([]FileReference, len(analysis.Inefficiencies)), 43 | SizeBytes: analysis.SizeBytes, 44 | EfficiencyScore: analysis.Efficiency, 45 | InefficientBytes: analysis.WastedBytes, 46 | }, 47 | } 48 | 49 | // export layers in order 50 | for idx, curLayer := range analysis.Layers { 51 | layerFileList := make([]filetree.FileInfo, 0) 52 | visitor := func(node *filetree.FileNode) error { 53 | layerFileList = append(layerFileList, node.Data.FileInfo) 54 | return nil 55 | } 56 | err := curLayer.Tree.VisitDepthChildFirst(visitor, nil) 57 | if err != nil { 58 | log.WithFields("layer", curLayer.Id, "error", err).Debug("unable to propagate layer tree") 59 | } 60 | data.Layer[idx] = Layer{ 61 | Index: curLayer.Index, 62 | ID: curLayer.Id, 63 | DigestID: curLayer.Digest, 64 | SizeBytes: curLayer.Size, 65 | Command: curLayer.Command, 66 | FileList: layerFileList, 67 | } 68 | } 69 | 70 | // add file references 71 | for idx := 0; idx < len(analysis.Inefficiencies); idx++ { 72 | fileData := analysis.Inefficiencies[len(analysis.Inefficiencies)-1-idx] 73 | 74 | data.Image.InefficientFiles[idx] = FileReference{ 75 | References: len(fileData.Nodes), 76 | SizeBytes: uint64(fileData.CumulativeSize), 77 | Path: fileData.Path, 78 | } 79 | } 80 | 81 | return &data 82 | } 83 | 84 | func (exp *Export) Marshal() ([]byte, error) { 85 | return json.MarshalIndent(&exp, "", " ") 86 | } 87 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/command/export/export_test.go: -------------------------------------------------------------------------------- 1 | package export 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/wagoodman/dive/dive/image/docker" 7 | ) 8 | 9 | func Test_Export(t *testing.T) { 10 | result := docker.TestAnalysisFromArchive(t, repoPath(t, ".data/test-docker-image.tar")) 11 | 12 | export := NewExport(result) 13 | payload, err := export.Marshal() 14 | if err != nil { 15 | t.Errorf("Test_Export: unable to export analysis: %v", err) 16 | } 17 | 18 | snaps.MatchJSON(t, payload) 19 | } 20 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/command/export/main_test.go: -------------------------------------------------------------------------------- 1 | package export 2 | 3 | import ( 4 | "flag" 5 | "github.com/charmbracelet/lipgloss" 6 | snapsPkg "github.com/gkampitakis/go-snaps/snaps" 7 | "github.com/muesli/termenv" 8 | "github.com/stretchr/testify/require" 9 | "go.uber.org/atomic" 10 | "os" 11 | "os/exec" 12 | "path/filepath" 13 | "strings" 14 | "testing" 15 | ) 16 | 17 | var ( 18 | updateSnapshot = flag.Bool("update", false, "update any test snapshots") 19 | snaps *snapsPkg.Config 20 | repoRootCache atomic.String 21 | ) 22 | 23 | func TestMain(m *testing.M) { 24 | // flags are not parsed until after test.Main is called... 25 | flag.Parse() 26 | 27 | os.Unsetenv("DIVE_CONFIG") 28 | 29 | // disable colors 30 | lipgloss.SetColorProfile(termenv.Ascii) 31 | 32 | snaps = snapsPkg.WithConfig( 33 | snapsPkg.Update(*updateSnapshot), 34 | snapsPkg.Dir("testdata/snapshots"), 35 | ) 36 | 37 | v := m.Run() 38 | 39 | snapsPkg.Clean(m) 40 | 41 | os.Exit(v) 42 | } 43 | 44 | func TestUpdateSnapshotDisabled(t *testing.T) { 45 | require.False(t, *updateSnapshot, "update snapshot flag should be disabled") 46 | } 47 | 48 | func repoPath(t testing.TB, path string) string { 49 | t.Helper() 50 | root := repoRoot(t) 51 | return filepath.Join(root, path) 52 | } 53 | 54 | func repoRoot(t testing.TB) string { 55 | val := repoRootCache.Load() 56 | if val != "" { 57 | return val 58 | } 59 | t.Helper() 60 | // use git to find the root of the repo 61 | out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() 62 | if err != nil { 63 | t.Fatalf("failed to get repo root: %v", err) 64 | } 65 | val = strings.TrimSpace(string(out)) 66 | repoRootCache.Store(val) 67 | return val 68 | } 69 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/command/root.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/anchore/clio" 8 | "github.com/spf13/afero" 9 | "github.com/spf13/cobra" 10 | "github.com/wagoodman/dive/cmd/dive/cli/internal/command/adapter" 11 | "github.com/wagoodman/dive/cmd/dive/cli/internal/options" 12 | "github.com/wagoodman/dive/cmd/dive/cli/internal/ui" 13 | "github.com/wagoodman/dive/dive" 14 | "github.com/wagoodman/dive/dive/image" 15 | "github.com/wagoodman/dive/internal/bus" 16 | "os" 17 | ) 18 | 19 | type rootOptions struct { 20 | options.Application `yaml:",inline" mapstructure:",squash"` 21 | 22 | // reserved for future use of root-only flags 23 | } 24 | 25 | func Root(app clio.Application) *cobra.Command { 26 | opts := &rootOptions{ 27 | Application: options.DefaultApplication(), 28 | } 29 | return app.SetupRootCommand(&cobra.Command{ 30 | Use: "dive [IMAGE]", 31 | Short: "Docker Image Visualizer & Explorer", 32 | Long: `This tool provides a way to discover and explore the contents of a docker image. Additionally the tool estimates 33 | the amount of wasted space and identifies the offending files from the image.`, 34 | Args: func(cmd *cobra.Command, args []string) error { 35 | if len(args) != 1 { 36 | return fmt.Errorf("exactly one argument is required") 37 | } 38 | opts.Analysis.Image = args[0] 39 | return nil 40 | }, 41 | RunE: func(cmd *cobra.Command, _ []string) error { 42 | if err := setUI(app, opts.Application); err != nil { 43 | return fmt.Errorf("failed to set UI: %w", err) 44 | } 45 | 46 | resolver, err := dive.GetImageResolver(opts.Analysis.Source) 47 | if err != nil { 48 | return fmt.Errorf("cannot determine image provider to fetch from: %w", err) 49 | } 50 | 51 | ctx := cmd.Context() 52 | 53 | img, err := adapter.ImageResolver(resolver).Fetch(ctx, opts.Analysis.Image) 54 | if err != nil { 55 | return fmt.Errorf("cannot load image: %w", err) 56 | } 57 | 58 | return run(ctx, opts.Application, img, resolver) 59 | }, 60 | }, opts) 61 | } 62 | 63 | func setUI(app clio.Application, opts options.Application) error { 64 | type Stater interface { 65 | State() *clio.State 66 | } 67 | 68 | state := app.(Stater).State() 69 | 70 | ux := ui.NewV1UI(opts.V1Preferences(), os.Stdout, state.Config.Log.Quiet, state.Config.Log.Verbosity) 71 | return state.UI.Replace(ux) 72 | } 73 | 74 | func run(ctx context.Context, opts options.Application, img *image.Image, content image.ContentReader) error { 75 | analysis, err := adapter.NewAnalyzer().Analyze(ctx, img) 76 | if err != nil { 77 | return fmt.Errorf("cannot analyze image: %w", err) 78 | } 79 | 80 | if opts.Export.JsonPath != "" { 81 | if err := adapter.NewExporter(afero.NewOsFs()).ExportTo(ctx, analysis, opts.Export.JsonPath); err != nil { 82 | return fmt.Errorf("cannot export analysis: %w", err) 83 | } 84 | return nil 85 | } 86 | 87 | if opts.CI.Enabled { 88 | eval := adapter.NewEvaluator(opts.CI.Rules.List).Evaluate(ctx, analysis) 89 | 90 | if !eval.Pass { 91 | return errors.New("evaluation failed") 92 | } 93 | return nil 94 | } 95 | 96 | bus.ExploreAnalysis(*analysis, content) 97 | 98 | return nil 99 | } 100 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/options/analysis.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "fmt" 5 | "github.com/anchore/clio" 6 | "github.com/scylladb/go-set/strset" 7 | "github.com/wagoodman/dive/dive" 8 | "github.com/wagoodman/dive/internal/log" 9 | "strings" 10 | ) 11 | 12 | const defaultContainerEngine = "docker" 13 | 14 | var _ interface { 15 | clio.PostLoader 16 | clio.FieldDescriber 17 | } = (*Analysis)(nil) 18 | 19 | // Analysis provides configuration for the image analysis behavior 20 | type Analysis struct { 21 | Image string `yaml:"image" mapstructure:"-"` 22 | ContainerEngine string `yaml:"container-engine" mapstructure:"container-engine"` 23 | Source dive.ImageSource `yaml:"-" mapstructure:"-"` 24 | IgnoreErrors bool `yaml:"ignore-errors" mapstructure:"ignore-errors"` 25 | AvailableContainerEngines []string `yaml:"-" mapstructure:"-"` 26 | } 27 | 28 | func DefaultAnalysis() Analysis { 29 | return Analysis{ 30 | ContainerEngine: defaultContainerEngine, 31 | IgnoreErrors: false, 32 | AvailableContainerEngines: dive.ImageSources, 33 | } 34 | } 35 | 36 | func (c *Analysis) DescribeFields(descriptions clio.FieldDescriptionSet) { 37 | descriptions.Add(&c.ContainerEngine, "container engine to use for image analysis (supported options: 'docker' and 'podman')") 38 | descriptions.Add(&c.IgnoreErrors, "continue with analysis even if there are errors parsing the image archive") 39 | } 40 | 41 | func (c *Analysis) AddFlags(flags clio.FlagSet) { 42 | flags.StringVarP(&c.ContainerEngine, "source", "", 43 | fmt.Sprintf("The container engine to fetch the image from. Allowed values: %s", strings.Join(c.AvailableContainerEngines, ", "))) 44 | 45 | flags.BoolVarP(&c.IgnoreErrors, "ignore-errors", "i", "ignore image parsing errors and run the analysis anyway") 46 | } 47 | 48 | func (c *Analysis) PostLoad() error { 49 | validEngines := strset.New(c.AvailableContainerEngines...) 50 | if !validEngines.Has(c.ContainerEngine) { 51 | log.Warnf("invalid container engine: %s (valid options: %s), using default %q", c.ContainerEngine, strings.Join(c.AvailableContainerEngines, ", "), defaultContainerEngine) 52 | c.ContainerEngine = "docker" 53 | } 54 | 55 | if c.Image != "" { 56 | sourceType, imageStr := dive.DeriveImageSource(c.Image) 57 | 58 | if sourceType == dive.SourceUnknown { 59 | sourceType = dive.ParseImageSource(c.ContainerEngine) 60 | if sourceType == dive.SourceUnknown { 61 | return fmt.Errorf("unable to determine image source from %q: %v\n", c.Image, c.ContainerEngine) 62 | } 63 | 64 | // use exactly what the user provided 65 | imageStr = c.Image 66 | } 67 | 68 | c.Image = imageStr 69 | c.Source = sourceType 70 | } else { 71 | c.Source = dive.ParseImageSource(c.ContainerEngine) 72 | } 73 | 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/options/application.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1" 5 | ) 6 | 7 | type Application struct { 8 | Analysis Analysis `yaml:",inline" mapstructure:",squash"` 9 | CI CI `yaml:",inline" mapstructure:",squash"` 10 | Export Export `yaml:",inline" mapstructure:",squash"` 11 | UI UI `yaml:",inline" mapstructure:",squash"` 12 | } 13 | 14 | func DefaultApplication() Application { 15 | return Application{ 16 | Analysis: DefaultAnalysis(), 17 | CI: DefaultCI(), 18 | Export: DefaultExport(), 19 | UI: DefaultUI(), 20 | } 21 | } 22 | 23 | func (c Application) V1Preferences() v1.Preferences { 24 | return v1.Preferences{ 25 | KeyBindings: c.UI.Keybinding.Config, 26 | ShowFiletreeAttributes: c.UI.Filetree.ShowAttributes, 27 | ShowAggregatedLayerChanges: c.UI.Layer.ShowAggregatedChanges, 28 | CollapseFiletreeDirectory: c.UI.Filetree.CollapseDir, 29 | FiletreePaneWidth: c.UI.Filetree.PaneWidth, 30 | FiletreeDiffHide: nil, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/options/ci.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "fmt" 5 | "github.com/anchore/clio" 6 | "gopkg.in/yaml.v3" 7 | "os" 8 | ) 9 | 10 | var _ interface { 11 | clio.PostLoader 12 | clio.FieldDescriber 13 | clio.FlagAdder 14 | } = (*CI)(nil) 15 | 16 | const defaultCIConfigPath = ".dive-ci" 17 | 18 | type CI struct { 19 | Enabled bool `yaml:"ci" mapstructure:"ci"` 20 | ConfigPath string `yaml:"ci-config" mapstructure:"ci-config"` 21 | Rules CIRules `yaml:"rules" mapstructure:"rules"` 22 | } 23 | 24 | func DefaultCI() CI { 25 | return CI{ 26 | Enabled: false, 27 | ConfigPath: defaultCIConfigPath, 28 | Rules: DefaultCIRules(), 29 | } 30 | } 31 | 32 | func (c *CI) DescribeFields(descriptions clio.FieldDescriptionSet) { 33 | descriptions.Add(&c.Enabled, "enable CI mode") 34 | descriptions.Add(&c.ConfigPath, "path to the CI config file") 35 | } 36 | 37 | func (c *CI) AddFlags(flags clio.FlagSet) { 38 | flags.BoolVarP(&c.Enabled, "ci", "", "skip the interactive TUI and validate against CI rules (same as env var CI=true)") 39 | flags.StringVarP(&c.ConfigPath, "ci-config", "", "if CI=true in the environment, use the given yaml to drive validation rules.") 40 | } 41 | 42 | func (c *CI) PostLoad() error { 43 | enabledFromEnv := truthy(os.Getenv("CI")) 44 | if !c.Enabled && enabledFromEnv { 45 | c.Enabled = true 46 | } 47 | 48 | if c.ConfigPath != "" { 49 | if fileExists(c.ConfigPath) { 50 | // if a config file is provided, load it and override any values provided in the application config. 51 | // If we're hitting this case we should pretend that only the config file was provided and applied 52 | // on top of the default config values. 53 | yamlFile, err := os.ReadFile(c.ConfigPath) 54 | if err != nil { 55 | return fmt.Errorf("failed to read CI config file %s: %w", c.ConfigPath, err) 56 | } 57 | def := DefaultCIRules() 58 | r := legacyRuleFile{ 59 | LowestEfficiencyThresholdString: def.LowestEfficiencyThresholdString, 60 | HighestWastedBytesString: def.HighestWastedBytesString, 61 | HighestUserWastedPercentString: def.HighestUserWastedPercentString, 62 | } 63 | wrapper := struct { 64 | Rules *legacyRuleFile `yaml:"rules"` 65 | }{ 66 | Rules: &r, 67 | } 68 | if err := yaml.Unmarshal(yamlFile, &wrapper); err != nil { 69 | return fmt.Errorf("failed to unmarshal CI config file %s: %w", c.ConfigPath, err) 70 | } 71 | // TODO: should this be a deprecated use warning in the future? 72 | c.Rules = CIRules{ 73 | LowestEfficiencyThresholdString: r.LowestEfficiencyThresholdString, 74 | HighestWastedBytesString: r.HighestWastedBytesString, 75 | HighestUserWastedPercentString: r.HighestUserWastedPercentString, 76 | } 77 | } 78 | } 79 | 80 | return nil 81 | } 82 | 83 | type legacyRuleFile struct { 84 | LowestEfficiencyThresholdString string `yaml:"lowestEfficiency"` 85 | HighestWastedBytesString string `yaml:"highestWastedBytes"` 86 | HighestUserWastedPercentString string `yaml:"highestUserWastedPercent"` 87 | } 88 | 89 | func fileExists(path string) bool { 90 | if _, err := os.Stat(path); os.IsNotExist(err) { 91 | return false 92 | } 93 | return true 94 | } 95 | 96 | func truthy(value string) bool { 97 | switch value { 98 | case "true", "1", "yes": 99 | return true 100 | case "false", "0", "no": 101 | return false 102 | default: 103 | return false 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/options/ci_rules.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "github.com/anchore/clio" 5 | "github.com/wagoodman/dive/cmd/dive/cli/internal/command/ci" 6 | "github.com/wagoodman/dive/internal/log" 7 | ) 8 | 9 | type CIRules struct { 10 | LowestEfficiencyThresholdString string `yaml:"lowest-efficiency" mapstructure:"lowest-efficiency"` 11 | LegacyLowestEfficiencyThresholdString string `yaml:"-" mapstructure:"lowestEfficiency"` 12 | 13 | HighestWastedBytesString string `yaml:"highest-wasted-bytes" mapstructure:"highest-wasted-bytes"` 14 | LegacyHighestWastedBytesString string `yaml:"-" mapstructure:"highestWastedBytes"` 15 | 16 | HighestUserWastedPercentString string `yaml:"highest-user-wasted-percent" mapstructure:"highest-user-wasted-percent"` 17 | LegacyHighestUserWastedPercentString string `yaml:"-" mapstructure:"highestUserWastedPercent"` 18 | 19 | List []ci.Rule `yaml:"-" mapstructure:"-"` 20 | } 21 | 22 | func DefaultCIRules() CIRules { 23 | return CIRules{ 24 | LowestEfficiencyThresholdString: "0.9", 25 | HighestWastedBytesString: "disabled", 26 | HighestUserWastedPercentString: "0.1", 27 | } 28 | } 29 | 30 | func (c *CIRules) DescribeFields(descriptions clio.FieldDescriptionSet) { 31 | descriptions.Add(&c.LowestEfficiencyThresholdString, "lowest allowable image efficiency (as a ratio between 0-1), otherwise CI validation will fail.") 32 | descriptions.Add(&c.HighestWastedBytesString, "highest allowable bytes wasted, otherwise CI validation will fail.") 33 | descriptions.Add(&c.HighestUserWastedPercentString, "highest allowable percentage of bytes wasted (as a ratio between 0-1), otherwise CI validation will fail.") 34 | } 35 | 36 | func (c *CIRules) AddFlags(flags clio.FlagSet) { 37 | flags.StringVarP(&c.LowestEfficiencyThresholdString, "lowestEfficiency", "", "(only valid with --ci given) lowest allowable image efficiency (as a ratio between 0-1), otherwise CI validation will fail.") 38 | flags.StringVarP(&c.HighestWastedBytesString, "highestWastedBytes", "", "(only valid with --ci given) highest allowable bytes wasted, otherwise CI validation will fail.") 39 | flags.StringVarP(&c.HighestUserWastedPercentString, "highestUserWastedPercent", "", "(only valid with --ci given) highest allowable percentage of bytes wasted (as a ratio between 0-1), otherwise CI validation will fail.") 40 | } 41 | 42 | func (c CIRules) hasLegacyOptionsInUse() bool { 43 | return c.LegacyLowestEfficiencyThresholdString != "" || c.LegacyHighestWastedBytesString != "" || c.LegacyHighestUserWastedPercentString != "" 44 | } 45 | 46 | func (c *CIRules) PostLoad() error { 47 | // protect against repeated calls 48 | c.List = nil 49 | 50 | if c.hasLegacyOptionsInUse() { 51 | log.Warnf("please specify ci rules in snake-case (the legacy camelCase format is deprecated)") 52 | } 53 | 54 | if c.LegacyLowestEfficiencyThresholdString != "" { 55 | c.LowestEfficiencyThresholdString = c.LegacyLowestEfficiencyThresholdString 56 | } 57 | 58 | if c.LegacyHighestWastedBytesString != "" { 59 | c.HighestWastedBytesString = c.LegacyHighestWastedBytesString 60 | } 61 | 62 | if c.LegacyHighestUserWastedPercentString != "" { 63 | c.HighestUserWastedPercentString = c.LegacyHighestUserWastedPercentString 64 | } 65 | 66 | rules, err := ci.Rules(c.LowestEfficiencyThresholdString, c.HighestWastedBytesString, c.HighestUserWastedPercentString) 67 | if err != nil { 68 | return err 69 | } 70 | c.List = append(c.List, rules...) 71 | 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/options/export.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | 8 | "github.com/anchore/clio" 9 | ) 10 | 11 | var _ interface { 12 | clio.FlagAdder 13 | clio.PostLoader 14 | } = (*Export)(nil) 15 | 16 | // Export provides configuration for data export functionality 17 | type Export struct { 18 | // Path to export analysis results as JSON (empty string = disabled) 19 | JsonPath string `yaml:"json-path" json:"json-path" mapstructure:"json-path"` 20 | } 21 | 22 | func DefaultExport() Export { 23 | return Export{} 24 | } 25 | 26 | func (o *Export) AddFlags(flags clio.FlagSet) { 27 | flags.StringVarP(&o.JsonPath, "json", "j", "Skip the interactive TUI and write the layer analysis statistics to a given file.") 28 | } 29 | 30 | func (o *Export) PostLoad() error { 31 | 32 | if o.JsonPath != "" { 33 | dir := path.Dir(o.JsonPath) 34 | if _, err := os.Stat(dir); os.IsNotExist(err) { 35 | return fmt.Errorf("directory for JSON export does not exist: %s", dir) 36 | } 37 | } 38 | 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/options/ui.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | // UI combines all UI configuration elements 4 | type UI struct { 5 | Keybinding UIKeybindings `yaml:"keybinding" mapstructure:"keybinding"` 6 | Diff UIDiff `yaml:"diff" mapstructure:"diff"` 7 | Filetree UIFiletree `yaml:"filetree" mapstructure:"filetree"` 8 | Layer UILayers `yaml:"layer" mapstructure:"layer"` 9 | } 10 | 11 | func DefaultUI() UI { 12 | return UI{ 13 | Keybinding: DefaultUIKeybinding(), 14 | Diff: DefaultUIDiff(), 15 | Filetree: DefaultUIFiletree(), 16 | Layer: DefaultUILayers(), 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/options/ui_diff.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "github.com/anchore/clio" 5 | "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1" 6 | "github.com/wagoodman/dive/internal/log" 7 | ) 8 | 9 | var _ interface { 10 | clio.PostLoader 11 | clio.FieldDescriber 12 | } = (*UIDiff)(nil) 13 | 14 | // UIDiff provides configuration for how differences are displayed 15 | type UIDiff struct { 16 | Hide []string `yaml:"hide" mapstructure:"hide"` 17 | } 18 | 19 | func DefaultUIDiff() UIDiff { 20 | prefs := v1.DefaultPreferences() 21 | return UIDiff{ 22 | Hide: prefs.FiletreeDiffHide, 23 | } 24 | } 25 | 26 | func (c *UIDiff) DescribeFields(descriptions clio.FieldDescriptionSet) { 27 | descriptions.Add(&c.Hide, "types of file differences to hide (added, removed, modified, unmodified)") 28 | } 29 | 30 | func (c *UIDiff) PostLoad() error { 31 | validHideValues := map[string]bool{"added": true, "removed": true, "modified": true, "unmodified": true} 32 | for _, value := range c.Hide { 33 | if _, ok := validHideValues[value]; !ok { 34 | log.Warnf("invalid diff hide value: %s (valid values: added, removed, modified, unmodified)", value) 35 | } 36 | } 37 | 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/options/ui_filetree.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "github.com/anchore/clio" 5 | "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1" 6 | "github.com/wagoodman/dive/internal/log" 7 | ) 8 | 9 | var _ interface { 10 | clio.PostLoader 11 | clio.FieldDescriber 12 | } = (*UIFiletree)(nil) 13 | 14 | // UIFiletree provides configuration for the file tree display 15 | type UIFiletree struct { 16 | CollapseDir bool `yaml:"collapse-dir" mapstructure:"collapse-dir"` 17 | PaneWidth float64 `yaml:"pane-width" mapstructure:"pane-width"` 18 | ShowAttributes bool `yaml:"show-attributes" mapstructure:"show-attributes"` 19 | } 20 | 21 | func DefaultUIFiletree() UIFiletree { 22 | prefs := v1.DefaultPreferences() 23 | return UIFiletree{ 24 | CollapseDir: prefs.CollapseFiletreeDirectory, 25 | PaneWidth: prefs.FiletreePaneWidth, 26 | ShowAttributes: prefs.ShowFiletreeAttributes, 27 | } 28 | } 29 | 30 | func (c *UIFiletree) DescribeFields(descriptions clio.FieldDescriptionSet) { 31 | descriptions.Add(&c.CollapseDir, "collapse directories by default in the filetree") 32 | descriptions.Add(&c.PaneWidth, "percentage of screen width for the filetree pane (must be >0 and <1)") 33 | descriptions.Add(&c.ShowAttributes, "show file attributes in the filetree view") 34 | } 35 | 36 | func (c *UIFiletree) PostLoad() error { 37 | // Validate pane width is between 0 and 1 38 | if c.PaneWidth <= 0 || c.PaneWidth >= 1 { 39 | log.Warnf("filetree pane-width must be >0 and <1, got %v, resetting to default 0.5", c.PaneWidth) 40 | c.PaneWidth = 0.5 41 | } 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/options/ui_layers.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import "github.com/anchore/clio" 4 | 5 | var _ clio.FieldDescriber = (*UILayers)(nil) 6 | 7 | // UILayers provides configuration for layer display behavior 8 | type UILayers struct { 9 | ShowAggregatedChanges bool `yaml:"show-aggregated-changes" mapstructure:"show-aggregated-changes"` 10 | } 11 | 12 | func DefaultUILayers() UILayers { 13 | return UILayers{ 14 | ShowAggregatedChanges: false, 15 | } 16 | } 17 | 18 | func (c *UILayers) DescribeFields(descriptions clio.FieldDescriptionSet) { 19 | descriptions.Add(&c.ShowAggregatedChanges, "show aggregated changes across all previous layers") 20 | } 21 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/no_ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/wagoodman/go-partybus" 5 | 6 | "github.com/anchore/clio" 7 | ) 8 | 9 | var _ clio.UI = (*NoUI)(nil) 10 | 11 | type NoUI struct { 12 | subscription partybus.Unsubscribable 13 | } 14 | 15 | func None() *NoUI { 16 | return &NoUI{} 17 | } 18 | 19 | func (n *NoUI) Setup(subscription partybus.Unsubscribable) error { 20 | n.subscription = subscription 21 | return nil 22 | } 23 | 24 | func (n *NoUI) Handle(_ partybus.Event) error { 25 | return nil 26 | } 27 | 28 | func (n NoUI) Teardown(_ bool) error { 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/anchore/clio" 7 | "github.com/anchore/go-logger/adapter/discard" 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/muesli/termenv" 10 | v1 "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1" 11 | "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/app" 12 | "github.com/wagoodman/dive/internal/bus/event" 13 | "github.com/wagoodman/dive/internal/bus/event/parser" 14 | "github.com/wagoodman/dive/internal/log" 15 | "github.com/wagoodman/go-partybus" 16 | "io" 17 | "os" 18 | "strings" 19 | ) 20 | 21 | var _ clio.UI = (*V1UI)(nil) 22 | 23 | type V1UI struct { 24 | cfg v1.Preferences 25 | out io.Writer 26 | err io.Writer 27 | 28 | subscription partybus.Unsubscribable 29 | quiet bool 30 | verbosity int 31 | format format 32 | } 33 | 34 | type format struct { 35 | Title lipgloss.Style 36 | Aux lipgloss.Style 37 | Line lipgloss.Style 38 | Notification lipgloss.Style 39 | } 40 | 41 | func NewV1UI(cfg v1.Preferences, out io.Writer, quiet bool, verbosity int) *V1UI { 42 | return &V1UI{ 43 | cfg: cfg, 44 | out: out, 45 | err: os.Stderr, 46 | quiet: quiet, 47 | verbosity: verbosity, 48 | format: format{ 49 | Title: lipgloss.NewStyle().Bold(true).Width(30), 50 | Aux: lipgloss.NewStyle().Faint(true), 51 | Notification: lipgloss.NewStyle().Foreground(lipgloss.Color("#A77BCA")), 52 | }, 53 | } 54 | } 55 | 56 | func (n *V1UI) Setup(subscription partybus.Unsubscribable) error { 57 | if n.verbosity == 0 || n.quiet { 58 | // we still use the UI, but we want to suppress responding to events that would print out what is already 59 | // being logged. 60 | log.Set(discard.New()) 61 | } 62 | 63 | // remove CI var from consideration when determining if we should use the UI 64 | lipgloss.SetDefaultRenderer(lipgloss.NewRenderer(n.out, termenv.WithEnvironment(environWithoutCI{}))) 65 | 66 | n.subscription = subscription 67 | return nil 68 | } 69 | 70 | var _ termenv.Environ = (*environWithoutCI)(nil) 71 | 72 | type environWithoutCI struct { 73 | } 74 | 75 | func (e environWithoutCI) Environ() []string { 76 | var out []string 77 | for _, s := range os.Environ() { 78 | if strings.HasPrefix(s, "CI=") { 79 | continue 80 | } 81 | out = append(out, s) 82 | } 83 | return out 84 | } 85 | 86 | func (e environWithoutCI) Getenv(s string) string { 87 | if s == "CI" { 88 | return "" 89 | } 90 | return os.Getenv(s) 91 | } 92 | 93 | func (n *V1UI) Handle(e partybus.Event) error { 94 | switch e.Type { 95 | case event.TaskStarted: 96 | if n.quiet { 97 | return nil 98 | } 99 | prog, task, err := parser.ParseTaskStarted(e) 100 | if err != nil { 101 | log.WithFields("error", err, "event", fmt.Sprintf("%#v", e)).Warn("failed to parse event") 102 | } 103 | 104 | var aux string 105 | stage := prog.Stage() 106 | switch { 107 | case task.Context != "": 108 | aux = task.Context 109 | case stage != "": 110 | aux = stage 111 | } 112 | 113 | if aux != "" { 114 | aux = n.format.Aux.Render(aux) 115 | } 116 | 117 | n.writeToStderr(n.format.Title.Render(task.Title.Default) + aux) 118 | case event.Notification: 119 | if n.quiet { 120 | return nil 121 | } 122 | _, text, err := parser.ParseNotification(e) 123 | if err != nil { 124 | log.WithFields("error", err, "event", fmt.Sprintf("%#v", e)).Warn("failed to parse event") 125 | } 126 | 127 | n.writeToStderr(n.format.Notification.Render(text)) 128 | case event.Report: 129 | if n.quiet { 130 | return nil 131 | } 132 | _, text, err := parser.ParseReport(e) 133 | if err != nil { 134 | log.WithFields("error", err, "event", fmt.Sprintf("%#v", e)).Warn("failed to parse event") 135 | } 136 | 137 | n.writeToStderr("") 138 | n.writeToStdout(text) 139 | case event.ExploreAnalysis: 140 | analysis, content, err := parser.ParseExploreAnalysis(e) 141 | if err != nil { 142 | log.WithFields("error", err, "event", fmt.Sprintf("%#v", e)).Warn("failed to parse event") 143 | } 144 | 145 | // ensure the logger will not interfere with the UI 146 | log.Set(discard.New()) 147 | 148 | return app.Run( 149 | // TODO: this is not plumbed through from the command object... 150 | context.Background(), 151 | v1.Config{ 152 | Content: content, 153 | Analysis: analysis, 154 | Preferences: n.cfg, 155 | }, 156 | ) 157 | } 158 | return nil 159 | } 160 | 161 | func (n *V1UI) writeToStdout(s string) { 162 | fmt.Fprintln(n.out, s) 163 | } 164 | 165 | func (n *V1UI) writeToStderr(s string) { 166 | if n.quiet || n.verbosity > 0 { 167 | // we've been told to not report anything or that we're in verbose mode thus the logger should report all info. 168 | // This only applies to status like info on stderr, not to primary reports on stdout. 169 | return 170 | } 171 | fmt.Fprintln(n.err, s) 172 | } 173 | 174 | func (n V1UI) Teardown(_ bool) error { 175 | return nil 176 | } 177 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "errors" 5 | "github.com/awesome-gocui/gocui" 6 | "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1" 7 | "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/key" 8 | "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/layout" 9 | "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/layout/compound" 10 | "golang.org/x/net/context" 11 | "time" 12 | ) 13 | 14 | const debug = false 15 | 16 | type app struct { 17 | gui *gocui.Gui 18 | controller *controller 19 | layout *layout.Manager 20 | } 21 | 22 | // Run is the UI entrypoint. 23 | func Run(ctx context.Context, c v1.Config) error { 24 | var err error 25 | 26 | // it appears there is a race condition where termbox.Init() will 27 | // block nearly indefinitely when running as the first process in 28 | // a Docker container when started within ~25ms of container startup. 29 | // I can't seem to determine the exact root cause, however, a large 30 | // enough sleep will prevent this behavior (todo: remove this hack) 31 | time.Sleep(100 * time.Millisecond) 32 | 33 | g, err := gocui.NewGui(gocui.OutputNormal, true) 34 | if err != nil { 35 | return err 36 | } 37 | defer g.Close() 38 | 39 | _, err = newApp(ctx, g, c) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | k, mod := gocui.MustParse("Ctrl+Z") 45 | if err := g.SetKeybinding("", k, mod, handle_ctrl_z); err != nil { 46 | return err 47 | } 48 | 49 | if err := g.MainLoop(); err != nil && !errors.Is(err, gocui.ErrQuit) { 50 | return err 51 | } 52 | return nil 53 | } 54 | 55 | func newApp(ctx context.Context, gui *gocui.Gui, cfg v1.Config) (*app, error) { 56 | var err error 57 | var c *controller 58 | var globalHelpKeys []*key.Binding 59 | 60 | c, err = newController(ctx, gui, cfg) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | // note: order matters when adding elements to the layout 66 | lm := layout.NewManager() 67 | lm.Add(c.views.Status, layout.LocationFooter) 68 | lm.Add(c.views.Filter, layout.LocationFooter) 69 | lm.Add(compound.NewLayerDetailsCompoundLayout(c.views.Layer, c.views.LayerDetails, c.views.ImageDetails), layout.LocationColumn) 70 | lm.Add(c.views.Tree, layout.LocationColumn) 71 | 72 | // todo: access this more programmatically 73 | if debug { 74 | lm.Add(c.views.Debug, layout.LocationColumn) 75 | } 76 | gui.Cursor = false 77 | // g.Mouse = true 78 | gui.SetManagerFunc(lm.Layout) 79 | 80 | a := &app{ 81 | gui: gui, 82 | controller: c, 83 | layout: lm, 84 | } 85 | 86 | var infos = []key.BindingInfo{ 87 | { 88 | Config: cfg.Preferences.KeyBindings.Global.Quit, 89 | OnAction: a.quit, 90 | Display: "Quit", 91 | }, 92 | { 93 | Config: cfg.Preferences.KeyBindings.Global.ToggleView, 94 | OnAction: c.ToggleView, 95 | Display: "Switch view", 96 | }, 97 | { 98 | Config: cfg.Preferences.KeyBindings.Navigation.Right, 99 | OnAction: c.NextPane, 100 | }, 101 | { 102 | Config: cfg.Preferences.KeyBindings.Navigation.Left, 103 | OnAction: c.PrevPane, 104 | }, 105 | { 106 | Config: cfg.Preferences.KeyBindings.Global.FilterFiles, 107 | OnAction: c.ToggleFilterView, 108 | IsSelected: c.views.Filter.IsVisible, 109 | Display: "Filter", 110 | }, 111 | { 112 | Config: cfg.Preferences.KeyBindings.Global.CloseFilterFiles, 113 | OnAction: c.CloseFilterView, 114 | }, 115 | } 116 | 117 | globalHelpKeys, err = key.GenerateBindings(gui, "", infos) 118 | if err != nil { 119 | return nil, err 120 | } 121 | 122 | c.views.Status.AddHelpKeys(globalHelpKeys...) 123 | 124 | // perform the first update and render now that all resources have been loaded 125 | err = c.UpdateAndRender() 126 | if err != nil { 127 | return nil, err 128 | } 129 | 130 | return a, err 131 | } 132 | 133 | // quit is the gocui callback invoked when the user hits Ctrl+C 134 | func (a *app) quit() error { 135 | return gocui.ErrQuit 136 | } 137 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/app/job_control_other.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package app 5 | 6 | import ( 7 | "github.com/awesome-gocui/gocui" 8 | ) 9 | 10 | // handle ctrl+z not supported on windows 11 | func handle_ctrl_z(_ *gocui.Gui, _ *gocui.View) error { 12 | return nil 13 | } 14 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/app/job_control_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package app 5 | 6 | import ( 7 | "syscall" 8 | 9 | "github.com/awesome-gocui/gocui" 10 | ) 11 | 12 | // handle ctrl+z 13 | func handle_ctrl_z(g *gocui.Gui, v *gocui.View) error { 14 | gocui.Suspend() 15 | if err := syscall.Kill(syscall.Getpid(), syscall.SIGSTOP); err != nil { 16 | return err 17 | } 18 | return gocui.Resume() 19 | } 20 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/config.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/key" 7 | "github.com/wagoodman/dive/dive/filetree" 8 | "github.com/wagoodman/dive/dive/image" 9 | "golang.org/x/net/context" 10 | "sync" 11 | ) 12 | 13 | type Config struct { 14 | // required input 15 | Analysis image.Analysis 16 | Content ContentReader 17 | Preferences Preferences 18 | 19 | stack filetree.Comparer 20 | stackErrs error 21 | do *sync.Once 22 | } 23 | 24 | type Preferences struct { 25 | KeyBindings key.Bindings 26 | IgnoreErrors bool 27 | ShowFiletreeAttributes bool 28 | ShowAggregatedLayerChanges bool 29 | CollapseFiletreeDirectory bool 30 | FiletreePaneWidth float64 31 | FiletreeDiffHide []string 32 | } 33 | 34 | func DefaultPreferences() Preferences { 35 | return Preferences{ 36 | KeyBindings: key.DefaultBindings(), 37 | ShowFiletreeAttributes: true, 38 | ShowAggregatedLayerChanges: true, 39 | CollapseFiletreeDirectory: false, // don't start with collapsed directories 40 | FiletreePaneWidth: 0.5, 41 | FiletreeDiffHide: []string{}, // empty slice means show all 42 | } 43 | } 44 | 45 | func (c *Config) TreeComparer() (filetree.Comparer, error) { 46 | if c.do == nil { 47 | c.do = &sync.Once{} 48 | } 49 | c.do.Do(func() { 50 | treeStack := filetree.NewComparer(c.Analysis.RefTrees) 51 | errs := treeStack.BuildCache() 52 | if errs != nil { 53 | if !c.Preferences.IgnoreErrors { 54 | errs = append(errs, fmt.Errorf("file tree has path errors (use '--ignore-errors' to attempt to continue)")) 55 | c.stackErrs = errors.Join(errs...) 56 | return 57 | } 58 | } 59 | c.stack = treeStack 60 | }) 61 | 62 | return c.stack, c.stackErrs 63 | } 64 | 65 | type ContentReader interface { 66 | Extract(ctx context.Context, id string, layer string, path string) error 67 | } 68 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/format/format.go: -------------------------------------------------------------------------------- 1 | package format 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/fatih/color" 8 | "github.com/lunixbochs/vtclean" 9 | ) 10 | 11 | const ( 12 | // selectedLeftBracketStr = " " 13 | // selectedRightBracketStr = " " 14 | // selectedFillStr = " " 15 | // 16 | //leftBracketStr = "▏" 17 | //rightBracketStr = "▕" 18 | //fillStr = "─" 19 | 20 | // selectedLeftBracketStr = " " 21 | // selectedRightBracketStr = " " 22 | // selectedFillStr = "━" 23 | // 24 | //leftBracketStr = "▏" 25 | //rightBracketStr = "▕" 26 | //fillStr = "─" 27 | 28 | selectedLeftBracketStr = "┃" 29 | selectedRightBracketStr = "┣" 30 | selectedFillStr = "━" 31 | 32 | leftBracketStr = "│" 33 | rightBracketStr = "├" 34 | fillStr = "─" 35 | 36 | selectStr = " ● " 37 | // selectStr = " " 38 | ) 39 | 40 | var ( 41 | Header func(...interface{}) string 42 | Selected func(...interface{}) string 43 | StatusSelected func(...interface{}) string 44 | StatusNormal func(...interface{}) string 45 | StatusControlSelected func(...interface{}) string 46 | StatusControlNormal func(...interface{}) string 47 | CompareTop func(...interface{}) string 48 | CompareBottom func(...interface{}) string 49 | reset = color.New(color.Reset).Sprint("") 50 | ) 51 | 52 | func init() { 53 | wrapper := func(fn func(a ...any) string) func(a ...any) string { 54 | return func(a ...any) string { 55 | // for some reason not all color formatter functions are not applying RESET, we'll add it manually for now 56 | return fn(a...) + reset 57 | } 58 | } 59 | 60 | Selected = wrapper(color.New(color.ReverseVideo, color.Bold).SprintFunc()) 61 | Header = wrapper(color.New(color.Bold).SprintFunc()) 62 | StatusSelected = wrapper(color.New(color.BgMagenta, color.FgWhite).SprintFunc()) 63 | StatusNormal = wrapper(color.New(color.ReverseVideo).SprintFunc()) 64 | StatusControlSelected = wrapper(color.New(color.BgMagenta, color.FgWhite, color.Bold).SprintFunc()) 65 | StatusControlNormal = wrapper(color.New(color.ReverseVideo, color.Bold).SprintFunc()) 66 | CompareTop = wrapper(color.New(color.BgMagenta).SprintFunc()) 67 | CompareBottom = wrapper(color.New(color.BgGreen).SprintFunc()) 68 | } 69 | 70 | func RenderNoHeader(width int, selected bool) string { 71 | if selected { 72 | return strings.Repeat(selectedFillStr, width) 73 | } 74 | return strings.Repeat(fillStr, width) 75 | } 76 | 77 | func RenderHeader(title string, width int, selected bool) string { 78 | if selected { 79 | body := Header(fmt.Sprintf("%s%s ", selectStr, title)) 80 | bodyLen := len(vtclean.Clean(body, false)) 81 | repeatCount := width - bodyLen - 2 82 | if repeatCount < 0 { 83 | repeatCount = 0 84 | } 85 | return fmt.Sprintf("%s%s%s%s\n", selectedLeftBracketStr, body, selectedRightBracketStr, strings.Repeat(selectedFillStr, repeatCount)) 86 | // return fmt.Sprintf("%s%s%s%s\n", Selected(selectedLeftBracketStr), body, Selected(selectedRightBracketStr), Selected(strings.Repeat(selectedFillStr, width-bodyLen-2))) 87 | // return fmt.Sprintf("%s%s%s%s\n", Selected(selectedLeftBracketStr), body, Selected(selectedRightBracketStr), strings.Repeat(selectedFillStr, width-bodyLen-2)) 88 | } 89 | body := Header(fmt.Sprintf(" %s ", title)) 90 | bodyLen := len(vtclean.Clean(body, false)) 91 | repeatCount := width - bodyLen - 2 92 | if repeatCount < 0 { 93 | repeatCount = 0 94 | } 95 | return fmt.Sprintf("%s%s%s%s\n", leftBracketStr, body, rightBracketStr, strings.Repeat(fillStr, repeatCount)) 96 | } 97 | 98 | func RenderHelpKey(control, title string, selected bool) string { 99 | if selected { 100 | return StatusSelected("▏") + StatusControlSelected(control) + StatusSelected(" "+title+" ") 101 | } else { 102 | return StatusNormal("▏") + StatusControlNormal(control) + StatusNormal(" "+title+" ") 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/key/binding.go: -------------------------------------------------------------------------------- 1 | package key 2 | 3 | import ( 4 | "fmt" 5 | "github.com/awesome-gocui/gocui" 6 | "github.com/awesome-gocui/keybinding" 7 | "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/format" 8 | ) 9 | 10 | type BindingInfo struct { 11 | Key gocui.Key 12 | Modifier gocui.Modifier 13 | Config Config 14 | OnAction func() error 15 | IsSelected func() bool 16 | Display string 17 | } 18 | 19 | type Binding struct { 20 | key []keybinding.Key 21 | displayName string 22 | selectedFn func() bool 23 | actionFn func() error 24 | } 25 | 26 | func GenerateBindings(gui *gocui.Gui, influence string, infos []BindingInfo) ([]*Binding, error) { 27 | var result = make([]*Binding, 0) 28 | for _, info := range infos { 29 | if len(info.Config.Keys) == 0 { 30 | return nil, fmt.Errorf("no keybinding configured for '%s'", info.Display) 31 | } 32 | 33 | binding, err := newBinding(gui, influence, info.Config.Keys, info.Display, info.OnAction) 34 | 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | if info.IsSelected != nil { 40 | binding.RegisterSelectionFn(info.IsSelected) 41 | } 42 | if len(info.Display) > 0 { 43 | result = append(result, binding) 44 | } 45 | } 46 | return result, nil 47 | } 48 | 49 | func newBinding(gui *gocui.Gui, influence string, keys []keybinding.Key, displayName string, actionFn func() error) (*Binding, error) { 50 | binding := &Binding{ 51 | key: keys, 52 | displayName: displayName, 53 | actionFn: actionFn, 54 | } 55 | 56 | for _, key := range keys { 57 | if err := gui.SetKeybinding(influence, key.Value, key.Modifier, binding.onAction); err != nil { 58 | return nil, err 59 | } 60 | } 61 | 62 | return binding, nil 63 | } 64 | 65 | func (binding *Binding) RegisterSelectionFn(selectedFn func() bool) { 66 | binding.selectedFn = selectedFn 67 | } 68 | 69 | func (binding *Binding) onAction(*gocui.Gui, *gocui.View) error { 70 | if binding.actionFn == nil { 71 | return fmt.Errorf("no action configured for '%+v'", binding) 72 | } 73 | return binding.actionFn() 74 | } 75 | 76 | func (binding *Binding) isSelected() bool { 77 | if binding.selectedFn == nil { 78 | return false 79 | } 80 | 81 | return binding.selectedFn() 82 | } 83 | 84 | func (binding *Binding) RenderKeyHelp() string { 85 | return format.RenderHelpKey(binding.key[0].String(), binding.displayName, binding.isSelected()) 86 | } 87 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/key/config.go: -------------------------------------------------------------------------------- 1 | package key 2 | 3 | import ( 4 | "fmt" 5 | "github.com/awesome-gocui/keybinding" 6 | ) 7 | 8 | type Config struct { 9 | Input string 10 | Keys []keybinding.Key `yaml:"-" mapstructure:"-"` 11 | } 12 | 13 | func (c *Config) Setup() error { 14 | if len(c.Input) == 0 { 15 | return nil 16 | } 17 | 18 | parsed, err := keybinding.ParseAll(c.Input) 19 | if err != nil { 20 | return fmt.Errorf("failed to parse key %q: %w", c.Input, err) 21 | } 22 | c.Keys = parsed 23 | return nil 24 | } 25 | 26 | type Bindings struct { 27 | Global GlobalBindings `yaml:",inline" mapstructure:",squash"` 28 | Navigation NavigationBindings `yaml:",inline" mapstructure:",squash"` 29 | Layer LayerBindings `yaml:",inline" mapstructure:",squash"` 30 | Filetree FiletreeBindings `yaml:",inline" mapstructure:",squash"` 31 | } 32 | 33 | type GlobalBindings struct { 34 | Quit Config `yaml:"quit" mapstructure:"quit"` 35 | ToggleView Config `yaml:"toggle-view" mapstructure:"toggle-view"` 36 | FilterFiles Config `yaml:"filter-files" mapstructure:"filter-files"` 37 | CloseFilterFiles Config `yaml:"close-filter-files" mapstructure:"close-filter-files"` 38 | } 39 | 40 | type NavigationBindings struct { 41 | Up Config `yaml:"up" mapstructure:"up"` 42 | Down Config `yaml:"down" mapstructure:"down"` 43 | Left Config `yaml:"left" mapstructure:"left"` 44 | Right Config `yaml:"right" mapstructure:"right"` 45 | PageUp Config `yaml:"page-up" mapstructure:"page-up"` 46 | PageDown Config `yaml:"page-down" mapstructure:"page-down"` 47 | } 48 | 49 | type LayerBindings struct { 50 | CompareAll Config `yaml:"compare-all" mapstructure:"compare-all"` 51 | CompareLayer Config `yaml:"compare-layer" mapstructure:"compare-layer"` 52 | } 53 | 54 | type FiletreeBindings struct { 55 | ToggleCollapseDir Config `yaml:"toggle-collapse-dir" mapstructure:"toggle-collapse-dir"` 56 | ToggleCollapseAllDir Config `yaml:"toggle-collapse-all-dir" mapstructure:"toggle-collapse-all-dir"` 57 | ToggleAddedFiles Config `yaml:"toggle-added-files" mapstructure:"toggle-added-files"` 58 | ToggleRemovedFiles Config `yaml:"toggle-removed-files" mapstructure:"toggle-removed-files"` 59 | ToggleModifiedFiles Config `yaml:"toggle-modified-files" mapstructure:"toggle-modified-files"` 60 | ToggleUnmodifiedFiles Config `yaml:"toggle-unmodified-files" mapstructure:"toggle-unmodified-files"` 61 | ToggleTreeAttributes Config `yaml:"toggle-filetree-attributes" mapstructure:"toggle-filetree-attributes"` 62 | ToggleSortOrder Config `yaml:"toggle-sort-order" mapstructure:"toggle-sort-order"` 63 | ToggleWrapTree Config `yaml:"toggle-wrap-tree" mapstructure:"toggle-wrap-tree"` 64 | ExtractFile Config `yaml:"extract-file" mapstructure:"extract-file"` 65 | } 66 | 67 | func DefaultBindings() Bindings { 68 | return Bindings{ 69 | Global: GlobalBindings{ 70 | Quit: Config{Input: "ctrl+c"}, 71 | ToggleView: Config{Input: "tab"}, 72 | FilterFiles: Config{Input: "ctrl+f, ctrl+slash"}, 73 | CloseFilterFiles: Config{Input: "esc"}, 74 | }, 75 | Navigation: NavigationBindings{ 76 | Up: Config{Input: "up,k"}, 77 | Down: Config{Input: "down,j"}, 78 | Left: Config{Input: "left,h"}, 79 | Right: Config{Input: "right,l"}, 80 | PageUp: Config{Input: "pgup,u"}, 81 | PageDown: Config{Input: "pgdn,d"}, 82 | }, 83 | Layer: LayerBindings{ 84 | CompareAll: Config{Input: "ctrl+a"}, 85 | CompareLayer: Config{Input: "ctrl+l"}, 86 | }, 87 | Filetree: FiletreeBindings{ 88 | ToggleCollapseDir: Config{Input: "space"}, 89 | ToggleCollapseAllDir: Config{Input: "ctrl+space"}, 90 | ToggleAddedFiles: Config{Input: "ctrl+a"}, 91 | ToggleRemovedFiles: Config{Input: "ctrl+r"}, 92 | ToggleModifiedFiles: Config{Input: "ctrl+m"}, 93 | ToggleUnmodifiedFiles: Config{Input: "ctrl+u"}, 94 | ToggleTreeAttributes: Config{Input: "ctrl+b"}, 95 | ToggleWrapTree: Config{Input: "ctrl+p"}, 96 | ToggleSortOrder: Config{Input: "ctrl+o"}, 97 | ExtractFile: Config{Input: "ctrl+e"}, 98 | }, 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/layout/area.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | type Area struct { 4 | minX, minY, maxX, maxY int 5 | } 6 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/layout/compound/layer_details_column.go: -------------------------------------------------------------------------------- 1 | package compound 2 | 3 | import ( 4 | "fmt" 5 | "github.com/awesome-gocui/gocui" 6 | "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/view" 7 | "github.com/wagoodman/dive/internal/log" 8 | "github.com/wagoodman/dive/internal/utils" 9 | ) 10 | 11 | type LayerDetailsCompoundLayout struct { 12 | layer *view.Layer 13 | layerDetails *view.LayerDetails 14 | imageDetails *view.ImageDetails 15 | constrainRealEstate bool 16 | } 17 | 18 | func NewLayerDetailsCompoundLayout(layer *view.Layer, layerDetails *view.LayerDetails, imageDetails *view.ImageDetails) *LayerDetailsCompoundLayout { 19 | return &LayerDetailsCompoundLayout{ 20 | layer: layer, 21 | layerDetails: layerDetails, 22 | imageDetails: imageDetails, 23 | } 24 | } 25 | 26 | func (cl *LayerDetailsCompoundLayout) Name() string { 27 | return "layer-details-compound-column" 28 | } 29 | 30 | // OnLayoutChange is called whenever the screen dimensions are changed 31 | func (cl *LayerDetailsCompoundLayout) OnLayoutChange() error { 32 | err := cl.layer.OnLayoutChange() 33 | if err != nil { 34 | return fmt.Errorf("unable to setup layer controller onLayoutChange: %w", err) 35 | } 36 | 37 | err = cl.layerDetails.OnLayoutChange() 38 | if err != nil { 39 | return fmt.Errorf("unable to setup layer details controller onLayoutChange: %w", err) 40 | } 41 | 42 | err = cl.imageDetails.OnLayoutChange() 43 | if err != nil { 44 | return fmt.Errorf("unable to setup image details controller onLayoutChange: %w", err) 45 | } 46 | return nil 47 | } 48 | 49 | func (cl *LayerDetailsCompoundLayout) layoutRow(g *gocui.Gui, minX, minY, maxX, maxY int, viewName string, setup func(*gocui.View, *gocui.View) error) error { 50 | log.WithFields("ui", cl.Name()).Tracef("layoutRow(g, minX: %d, minY: %d, maxX: %d, maxY: %d, viewName: %s, )", minX, minY, maxX, maxY, viewName) 51 | // header + border 52 | headerHeight := 2 53 | 54 | // TODO: investigate overlap 55 | // note: maxY needs to account for the (invisible) border, thus a +1 56 | headerView, headerErr := g.SetView(viewName+"Header", minX, minY, maxX, minY+headerHeight+1, 0) 57 | 58 | // we are going to overlap the view over the (invisible) border (so minY will be one less than expected) 59 | bodyView, bodyErr := g.SetView(viewName, minX, minY+headerHeight, maxX, maxY, 0) 60 | 61 | if utils.IsNewView(bodyErr, headerErr) { 62 | err := setup(bodyView, headerView) 63 | if err != nil { 64 | return fmt.Errorf("unable to setup row layout for %s: %w", viewName, err) 65 | } 66 | } 67 | return nil 68 | } 69 | 70 | func (cl *LayerDetailsCompoundLayout) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error { 71 | log.WithFields("ui", cl.Name()).Tracef("layout(minX: %d, minY: %d, maxX: %d, maxY: %d)", minX, minY, maxX, maxY) 72 | 73 | layouts := []view.View{ 74 | cl.layer, 75 | cl.layerDetails, 76 | cl.imageDetails, 77 | } 78 | 79 | rowHeight := maxY / 3 80 | for i := 0; i < 3; i++ { 81 | if err := cl.layoutRow(g, minX, i*rowHeight, maxX, (i+1)*rowHeight, layouts[i].Name(), layouts[i].Setup); err != nil { 82 | return fmt.Errorf("unable to layout %q: %w", layouts[i].Name(), err) 83 | } 84 | } 85 | 86 | if g.CurrentView() == nil { 87 | if _, err := g.SetCurrentView(cl.layer.Name()); err != nil { 88 | return fmt.Errorf("unable to set view to layer %q: %w", cl.layer.Name(), err) 89 | } 90 | } 91 | return nil 92 | } 93 | 94 | func (cl *LayerDetailsCompoundLayout) RequestedSize(available int) *int { 95 | // "available" is the entire screen real estate, so we can guess when its a bit too small and take action. 96 | // This isn't perfect, but it gets the job done for now without complicated layout constraint solvers 97 | if available < 90 { 98 | cl.layer.ConstrainLayout() 99 | cl.constrainRealEstate = true 100 | size := 8 101 | return &size 102 | } 103 | cl.layer.ExpandLayout() 104 | cl.constrainRealEstate = false 105 | return nil 106 | } 107 | 108 | // todo: make this variable based on the nested views 109 | func (cl *LayerDetailsCompoundLayout) IsVisible() bool { 110 | return true 111 | } 112 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/layout/layout.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import "github.com/awesome-gocui/gocui" 4 | 5 | type Layout interface { 6 | Name() string 7 | Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error 8 | RequestedSize(available int) *int 9 | IsVisible() bool 10 | OnLayoutChange() error 11 | } 12 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/layout/location.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | const ( 4 | LocationFooter Location = iota 5 | LocationHeader 6 | LocationColumn 7 | ) 8 | 9 | type Location int 10 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/view/cursor.go: -------------------------------------------------------------------------------- 1 | package view 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/awesome-gocui/gocui" 7 | ) 8 | 9 | // CursorDown moves the cursor down in the currently selected gocui pane, scrolling the screen as needed. 10 | func CursorDown(g *gocui.Gui, v *gocui.View) error { 11 | return CursorStep(g, v, 1) 12 | } 13 | 14 | // CursorUp moves the cursor up in the currently selected gocui pane, scrolling the screen as needed. 15 | func CursorUp(g *gocui.Gui, v *gocui.View) error { 16 | return CursorStep(g, v, -1) 17 | } 18 | 19 | // Moves the cursor the given step distance, setting the origin to the new cursor line 20 | func CursorStep(g *gocui.Gui, v *gocui.View, step int) error { 21 | cx, cy := v.Cursor() 22 | 23 | // if there isn't a next line 24 | line, err := v.Line(cy + step) 25 | if err != nil { 26 | return err 27 | } 28 | if len(line) == 0 { 29 | return errors.New("unable to move the cursor, empty line") 30 | } 31 | if err := v.SetCursor(cx, cy+step); err != nil { 32 | ox, oy := v.Origin() 33 | if err := v.SetOrigin(ox, oy+step); err != nil { 34 | return err 35 | } 36 | } 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/view/debug.go: -------------------------------------------------------------------------------- 1 | package view 2 | 3 | import ( 4 | "fmt" 5 | "github.com/anchore/go-logger" 6 | "github.com/awesome-gocui/gocui" 7 | "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/format" 8 | "github.com/wagoodman/dive/internal/log" 9 | "github.com/wagoodman/dive/internal/utils" 10 | ) 11 | 12 | // Debug is just for me :) 13 | type Debug struct { 14 | name string 15 | gui *gocui.Gui 16 | view *gocui.View 17 | header *gocui.View 18 | logger logger.Logger 19 | 20 | selectedView Helper 21 | } 22 | 23 | // newDebugView creates a new view object attached the global [gocui] screen object. 24 | func newDebugView(gui *gocui.Gui) *Debug { 25 | c := new(Debug) 26 | 27 | // populate main fields 28 | c.name = "debug" 29 | c.gui = gui 30 | c.logger = log.Nested("ui", "debug") 31 | 32 | return c 33 | } 34 | 35 | func (v *Debug) SetCurrentView(r Helper) { 36 | v.selectedView = r 37 | } 38 | 39 | func (v *Debug) Name() string { 40 | return v.name 41 | } 42 | 43 | // Setup initializes the UI concerns within the context of a global [gocui] view object. 44 | func (v *Debug) Setup(view *gocui.View, header *gocui.View) error { 45 | v.logger.Trace("setup()") 46 | 47 | // set controller options 48 | v.view = view 49 | v.view.Editable = false 50 | v.view.Wrap = false 51 | v.view.Frame = false 52 | 53 | v.header = header 54 | v.header.Editable = false 55 | v.header.Wrap = false 56 | v.header.Frame = false 57 | 58 | return v.Render() 59 | } 60 | 61 | // IsVisible indicates if the status view pane is currently initialized. 62 | func (v *Debug) IsVisible() bool { 63 | return v != nil 64 | } 65 | 66 | // Update refreshes the state objects for future rendering (currently does nothing). 67 | func (v *Debug) Update() error { 68 | return nil 69 | } 70 | 71 | // OnLayoutChange is called whenever the screen dimensions are changed 72 | func (v *Debug) OnLayoutChange() error { 73 | err := v.Update() 74 | if err != nil { 75 | return err 76 | } 77 | return v.Render() 78 | } 79 | 80 | // Render flushes the state objects to the screen. 81 | func (v *Debug) Render() error { 82 | v.logger.Trace("render()") 83 | 84 | v.gui.Update(func(g *gocui.Gui) error { 85 | // update header... 86 | v.header.Clear() 87 | width, _ := g.Size() 88 | headerStr := format.RenderHeader("Debug", width, false) 89 | _, _ = fmt.Fprintln(v.header, headerStr) 90 | 91 | // update view... 92 | v.view.Clear() 93 | _, err := fmt.Fprintln(v.view, "blerg") 94 | if err != nil { 95 | v.logger.WithFields("error", err).Debug("unable to write to buffer") 96 | } 97 | 98 | return nil 99 | }) 100 | return nil 101 | } 102 | 103 | func (v *Debug) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error { 104 | v.logger.Tracef("layout(minX: %d, minY: %d, maxX: %d, maxY: %d)", minX, minY, maxX, maxY) 105 | 106 | // header 107 | headerSize := 1 108 | // note: maxY needs to account for the (invisible) border, thus a +1 109 | header, headerErr := g.SetView(v.Name()+"header", minX, minY, maxX, minY+headerSize+1, 0) 110 | // we are going to overlap the view over the (invisible) border (so minY will be one less than expected). 111 | // additionally, maxY will be bumped by one to include the border 112 | view, viewErr := g.SetView(v.Name(), minX, minY+headerSize, maxX, maxY+1, 0) 113 | if utils.IsNewView(viewErr, headerErr) { 114 | err := v.Setup(view, header) 115 | if err != nil { 116 | return fmt.Errorf("unable to setup debug controller: %w", err) 117 | } 118 | } 119 | return nil 120 | } 121 | 122 | func (v *Debug) RequestedSize(available int) *int { 123 | return nil 124 | } 125 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/view/layer_change_listener.go: -------------------------------------------------------------------------------- 1 | package view 2 | 3 | import ( 4 | "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/viewmodel" 5 | ) 6 | 7 | type LayerChangeListener func(viewmodel.LayerSelection) error 8 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/view/layer_details.go: -------------------------------------------------------------------------------- 1 | package view 2 | 3 | import ( 4 | "fmt" 5 | "github.com/anchore/go-logger" 6 | "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/format" 7 | "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/key" 8 | "github.com/wagoodman/dive/internal/log" 9 | "strings" 10 | 11 | "github.com/awesome-gocui/gocui" 12 | "github.com/dustin/go-humanize" 13 | "github.com/wagoodman/dive/dive/image" 14 | ) 15 | 16 | type LayerDetails struct { 17 | gui *gocui.Gui 18 | header *gocui.View 19 | body *gocui.View 20 | CurrentLayer *image.Layer 21 | kb key.Bindings 22 | logger logger.Logger 23 | } 24 | 25 | func (v *LayerDetails) Name() string { 26 | return "layerDetails" 27 | } 28 | 29 | func (v *LayerDetails) Setup(body, header *gocui.View) error { 30 | v.logger = log.Nested("ui", "layerDetails") 31 | v.logger.Trace("setup()") 32 | 33 | v.body = body 34 | v.body.Editable = false 35 | v.body.Wrap = true 36 | v.body.Highlight = true 37 | v.body.Frame = false 38 | 39 | v.header = header 40 | v.header.Editable = false 41 | v.header.Wrap = true 42 | v.header.Highlight = false 43 | v.header.Frame = false 44 | 45 | var infos = []key.BindingInfo{ 46 | { 47 | Config: v.kb.Navigation.Down, 48 | Modifier: gocui.ModNone, 49 | OnAction: v.CursorDown, 50 | }, 51 | { 52 | Config: v.kb.Navigation.Up, 53 | Modifier: gocui.ModNone, 54 | OnAction: v.CursorUp, 55 | }, 56 | } 57 | 58 | _, err := key.GenerateBindings(v.gui, v.Name(), infos) 59 | if err != nil { 60 | return err 61 | } 62 | return nil 63 | } 64 | 65 | // Render flushes the state objects to the screen. 66 | // The details pane reports the currently selected layer's: 67 | // 1. tags 68 | // 2. ID 69 | // 3. digest 70 | // 4. command 71 | func (v *LayerDetails) Render() error { 72 | v.gui.Update(func(g *gocui.Gui) error { 73 | v.header.Clear() 74 | width, _ := v.body.Size() 75 | 76 | layerHeaderStr := format.RenderHeader("Layer Details", width, v.gui.CurrentView() == v.body) 77 | 78 | _, err := fmt.Fprintln(v.header, layerHeaderStr) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | // this is for layer details 84 | var lines = make([]string, 0) 85 | 86 | tags := "(none)" 87 | if len(v.CurrentLayer.Names) > 0 { 88 | tags = strings.Join(v.CurrentLayer.Names, ", ") 89 | } 90 | 91 | lines = append(lines, []string{ 92 | format.Header("Tags: ") + tags, 93 | format.Header("Id: ") + v.CurrentLayer.Id, 94 | format.Header("Size: ") + humanize.Bytes(v.CurrentLayer.Size), 95 | format.Header("Digest: ") + v.CurrentLayer.Digest, 96 | format.Header("Command:"), 97 | v.CurrentLayer.Command, 98 | }...) 99 | 100 | v.body.Clear() 101 | if _, err = fmt.Fprintln(v.body, strings.Join(lines, "\n")); err != nil { 102 | log.WithFields("layer", v.CurrentLayer.Id, "error", err).Debug("unable to write to buffer") 103 | } 104 | return nil 105 | }) 106 | return nil 107 | } 108 | 109 | func (v *LayerDetails) OnLayoutChange() error { 110 | if err := v.Update(); err != nil { 111 | return err 112 | } 113 | return v.Render() 114 | } 115 | 116 | // IsVisible indicates if the details view pane is currently initialized. 117 | func (v *LayerDetails) IsVisible() bool { 118 | return v.body != nil 119 | } 120 | 121 | // CursorUp moves the cursor up in the details pane 122 | func (v *LayerDetails) CursorUp() error { 123 | if err := CursorUp(v.gui, v.body); err != nil { 124 | v.logger.WithFields("error", err).Debug("couldn't move the cursor up") 125 | } 126 | return nil 127 | } 128 | 129 | // CursorDown moves the cursor up in the details pane 130 | func (v *LayerDetails) CursorDown() error { 131 | if err := CursorDown(v.gui, v.body); err != nil { 132 | v.logger.WithFields("error", err).Debug("couldn't move the cursor down") 133 | } 134 | return nil 135 | } 136 | 137 | // KeyHelp indicates all the possible actions a user can take while the current pane is selected (currently does nothing). 138 | func (v *LayerDetails) KeyHelp() string { 139 | return "" 140 | } 141 | 142 | // Update refreshes the state objects for future rendering. 143 | func (v *LayerDetails) Update() error { 144 | return nil 145 | } 146 | 147 | func (v *LayerDetails) SetCursor(x, y int) error { 148 | return v.body.SetCursor(x, y) 149 | } 150 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/view/renderer.go: -------------------------------------------------------------------------------- 1 | package view 2 | 3 | // Controller defines the a renderable terminal screen pane. 4 | type Renderer interface { 5 | Update() error 6 | Render() error 7 | IsVisible() bool 8 | } 9 | 10 | type Helper interface { 11 | KeyHelp() string 12 | } 13 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/view/status.go: -------------------------------------------------------------------------------- 1 | package view 2 | 3 | import ( 4 | "fmt" 5 | "github.com/anchore/go-logger" 6 | "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/format" 7 | "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/key" 8 | "github.com/wagoodman/dive/internal/log" 9 | "github.com/wagoodman/dive/internal/utils" 10 | "strings" 11 | 12 | "github.com/awesome-gocui/gocui" 13 | ) 14 | 15 | // Status holds the UI objects and data models for populating the bottom-most pane. Specifically the panel 16 | // shows the user a set of possible actions to take in the window and currently selected pane. 17 | type Status struct { 18 | name string 19 | gui *gocui.Gui 20 | view *gocui.View 21 | logger logger.Logger 22 | 23 | selectedView Helper 24 | requestedHeight int 25 | 26 | helpKeys []*key.Binding 27 | } 28 | 29 | // newStatusView creates a new view object attached the global [gocui] screen object. 30 | func newStatusView(gui *gocui.Gui) *Status { 31 | c := new(Status) 32 | 33 | // populate main fields 34 | c.name = "status" 35 | c.gui = gui 36 | c.helpKeys = make([]*key.Binding, 0) 37 | c.requestedHeight = 1 38 | c.logger = log.Nested("ui", "status") 39 | 40 | return c 41 | } 42 | 43 | func (v *Status) SetCurrentView(r Helper) { 44 | v.selectedView = r 45 | } 46 | 47 | func (v *Status) Name() string { 48 | return v.name 49 | } 50 | 51 | func (v *Status) AddHelpKeys(keys ...*key.Binding) { 52 | v.helpKeys = append(v.helpKeys, keys...) 53 | } 54 | 55 | // Setup initializes the UI concerns within the context of a global [gocui] view object. 56 | func (v *Status) Setup(view *gocui.View) error { 57 | v.logger.Trace("setup()") 58 | 59 | // set controller options 60 | v.view = view 61 | v.view.Frame = false 62 | 63 | return v.Render() 64 | } 65 | 66 | // IsVisible indicates if the status view pane is currently initialized. 67 | func (v *Status) IsVisible() bool { 68 | return v != nil 69 | } 70 | 71 | // Update refreshes the state objects for future rendering (currently does nothing). 72 | func (v *Status) Update() error { 73 | return nil 74 | } 75 | 76 | // OnLayoutChange is called whenever the screen dimensions are changed 77 | func (v *Status) OnLayoutChange() error { 78 | err := v.Update() 79 | if err != nil { 80 | return err 81 | } 82 | return v.Render() 83 | } 84 | 85 | // Render flushes the state objects to the screen. 86 | func (v *Status) Render() error { 87 | v.logger.Trace("render()") 88 | 89 | v.gui.Update(func(g *gocui.Gui) error { 90 | v.view.Clear() 91 | 92 | var selectedHelp string 93 | if v.selectedView != nil { 94 | selectedHelp = v.selectedView.KeyHelp() 95 | } 96 | 97 | _, err := fmt.Fprintln(v.view, v.KeyHelp()+selectedHelp+format.StatusNormal("▏"+strings.Repeat(" ", 1000))) 98 | if err != nil { 99 | v.logger.WithFields("error", err).Debug("unable to write to buffer") 100 | } 101 | 102 | return err 103 | }) 104 | return nil 105 | } 106 | 107 | // KeyHelp indicates all the possible global actions a user can take when any pane is selected. 108 | func (v *Status) KeyHelp() string { 109 | var help string 110 | for _, binding := range v.helpKeys { 111 | help += binding.RenderKeyHelp() 112 | } 113 | return help 114 | } 115 | 116 | func (v *Status) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error { 117 | v.logger.Tracef("layout(minX: %d, minY: %d, maxX: %d, maxY: %d)", minX, minY, maxX, maxY) 118 | 119 | view, viewErr := g.SetView(v.Name(), minX, minY, maxX, maxY, 0) 120 | if utils.IsNewView(viewErr) { 121 | err := v.Setup(view) 122 | if err != nil { 123 | return fmt.Errorf("unable to setup status controller: %w", err) 124 | } 125 | } 126 | return nil 127 | } 128 | 129 | func (v *Status) RequestedSize(available int) *int { 130 | return &v.requestedHeight 131 | } 132 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/view/views.go: -------------------------------------------------------------------------------- 1 | package view 2 | 3 | import ( 4 | "github.com/awesome-gocui/gocui" 5 | "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1" 6 | ) 7 | 8 | type View interface { 9 | Setup(*gocui.View, *gocui.View) error 10 | Name() string 11 | IsVisible() bool 12 | } 13 | 14 | type Views struct { 15 | Tree *FileTree 16 | Layer *Layer 17 | Status *Status 18 | Filter *Filter 19 | LayerDetails *LayerDetails 20 | ImageDetails *ImageDetails 21 | Debug *Debug 22 | } 23 | 24 | func NewViews(g *gocui.Gui, cfg v1.Config) (*Views, error) { 25 | layer, err := newLayerView(g, cfg) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | tree, err := newFileTreeView(g, cfg, 0) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | status := newStatusView(g) 36 | 37 | // set the layer view as the first selected view 38 | status.SetCurrentView(layer) 39 | 40 | return &Views{ 41 | Tree: tree, 42 | Layer: layer, 43 | Status: status, 44 | Filter: newFilterView(g), 45 | ImageDetails: &ImageDetails{ 46 | gui: g, 47 | imageName: cfg.Analysis.Image, 48 | imageSize: cfg.Analysis.SizeBytes, 49 | efficiency: cfg.Analysis.Efficiency, 50 | inefficiencies: cfg.Analysis.Inefficiencies, 51 | kb: cfg.Preferences.KeyBindings, 52 | }, 53 | LayerDetails: &LayerDetails{gui: g, kb: cfg.Preferences.KeyBindings}, 54 | Debug: newDebugView(g), 55 | }, nil 56 | } 57 | 58 | func (views *Views) Renderers() []Renderer { 59 | return []Renderer{ 60 | views.Tree, 61 | views.Layer, 62 | views.Status, 63 | views.Filter, 64 | views.LayerDetails, 65 | views.ImageDetails, 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/viewmodel/config.go: -------------------------------------------------------------------------------- 1 | package viewmodel 2 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/viewmodel/layer_compare.go: -------------------------------------------------------------------------------- 1 | package viewmodel 2 | 3 | const ( 4 | CompareSingleLayer LayerCompareMode = iota 5 | CompareAllLayers 6 | ) 7 | 8 | type LayerCompareMode int 9 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/viewmodel/layer_selection.go: -------------------------------------------------------------------------------- 1 | package viewmodel 2 | 3 | import ( 4 | "github.com/wagoodman/dive/dive/image" 5 | ) 6 | 7 | type LayerSelection struct { 8 | Layer *image.Layer 9 | BottomTreeStart, BottomTreeStop, TopTreeStart, TopTreeStop int 10 | } 11 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/viewmodel/layer_set_state.go: -------------------------------------------------------------------------------- 1 | package viewmodel 2 | 3 | import "github.com/wagoodman/dive/dive/image" 4 | 5 | type LayerSetState struct { 6 | LayerIndex int 7 | Layers []*image.Layer 8 | CompareMode LayerCompareMode 9 | CompareStartIndex int 10 | } 11 | 12 | func NewLayerSetState(layers []*image.Layer, compareMode LayerCompareMode) *LayerSetState { 13 | return &LayerSetState{ 14 | Layers: layers, 15 | CompareMode: compareMode, 16 | } 17 | } 18 | 19 | // getCompareIndexes determines the layer boundaries to use for comparison (based on the current compare mode) 20 | func (state *LayerSetState) GetCompareIndexes() (bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) { 21 | bottomTreeStart = state.CompareStartIndex 22 | topTreeStop = state.LayerIndex 23 | 24 | if state.LayerIndex == state.CompareStartIndex { 25 | bottomTreeStop = state.LayerIndex 26 | topTreeStart = state.LayerIndex 27 | } else if state.CompareMode == CompareSingleLayer { 28 | bottomTreeStop = state.LayerIndex - 1 29 | topTreeStart = state.LayerIndex 30 | } else { 31 | bottomTreeStop = state.CompareStartIndex 32 | topTreeStart = state.CompareStartIndex + 1 33 | } 34 | 35 | return bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop 36 | } 37 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/viewmodel/layer_set_state_test.go: -------------------------------------------------------------------------------- 1 | package viewmodel 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGetCompareIndexes(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | layerIndex int 11 | compareMode LayerCompareMode 12 | compareStartIndex int 13 | expected [4]int 14 | }{ 15 | { 16 | name: "LayerIndex equals CompareStartIndex", 17 | layerIndex: 2, 18 | compareMode: CompareSingleLayer, 19 | compareStartIndex: 2, 20 | expected: [4]int{2, 2, 2, 2}, 21 | }, 22 | { 23 | name: "CompareMode is CompareSingleLayer", 24 | layerIndex: 3, 25 | compareMode: CompareSingleLayer, 26 | compareStartIndex: 1, 27 | expected: [4]int{1, 2, 3, 3}, 28 | }, 29 | { 30 | name: "Default CompareMode", 31 | layerIndex: 4, 32 | compareMode: CompareAllLayers, 33 | compareStartIndex: 1, 34 | expected: [4]int{1, 1, 2, 4}, 35 | }, 36 | } 37 | 38 | for _, tt := range tests { 39 | t.Run(tt.name, func(t *testing.T) { 40 | state := &LayerSetState{ 41 | LayerIndex: tt.layerIndex, 42 | CompareMode: tt.compareMode, 43 | CompareStartIndex: tt.compareStartIndex, 44 | } 45 | bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop := state.GetCompareIndexes() 46 | actual := [4]int{bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop} 47 | if actual != tt.expected { 48 | t.Errorf("expected %v, got %v", tt.expected, actual) 49 | } 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileShowAggregateChanges.txt: -------------------------------------------------------------------------------- 1 | drwxr-xr-x 0:0 1.2 MB ├─⊕ bin 2 | drwxr-xr-x 0:0 0 B ├── dev 3 | drwxr-xr-x 0:0 1.0 kB ├── etc 4 | -rw-rw-r-- 0:0 307 B │ ├── group 5 | -rw-r--r-- 0:0 127 B │ ├── localtime 6 | drwxr-xr-x 0:0 0 B │ ├── network 7 | drwxr-xr-x 0:0 0 B │ │ ├── if-down.d 8 | drwxr-xr-x 0:0 0 B │ │ ├── if-post-down.d 9 | drwxr-xr-x 0:0 0 B │ │ ├── if-pre-up.d 10 | drwxr-xr-x 0:0 0 B │ │ └── if-up.d 11 | -rw-r--r-- 0:0 340 B │ ├── passwd 12 | -rw------- 0:0 243 B │ └── shadow 13 | drwxr-xr-x 65534:65534 0 B ├── home 14 | drwx------ 0:0 21 kB ├── root 15 | drwxr-xr-x 0:0 8.6 kB │ ├── .data 16 | -rw-r--r-- 0:0 6.4 kB │ │ ├── saved.again2.txt 17 | -rwxrwxr-x 0:0 917 B │ │ ├── tag.sh 18 | -rwxr-xr-x 0:0 1.3 kB │ │ └── test.sh 19 | -rw-r--r-- 0:0 6.4 kB │ ├── .saved.txt 20 | drwxr-xr-x 0:0 19 kB │ ├── example 21 | drwxr-xr-x 0:0 0 B │ │ ├── really 22 | drwxr-xr-x 0:0 0 B │ │ │ └── nested 23 | -r--r--r-- 0:0 6.4 kB │ │ ├── somefile1.txt 24 | -rw-r--r-- 0:0 6.4 kB │ │ ├── somefile2.txt 25 | -rw-r--r-- 0:0 6.4 kB │ │ └── somefile3.txt 26 | -rwxr-xr-x 0:0 6.4 kB │ └── saved.txt 27 | -rw-rw-r-- 0:0 6.4 kB ├── somefile.txt 28 | drwxrwxrwt 0:0 6.4 kB ├── tmp 29 | -rw-r--r-- 0:0 6.4 kB │ └── saved.again1.txt 30 | drwxr-xr-x 0:0 0 B ├── usr 31 | drwxr-xr-x 1:1 0 B │ └── sbin 32 | drwxr-xr-x 0:0 0 B └── var 33 | drwxr-xr-x 0:0 0 B ├── spool 34 | drwxr-xr-x 8:8 0 B │ └── mail 35 | drwxr-xr-x 0:0 0 B └── www 36 | 37 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreeDirCollapse.txt: -------------------------------------------------------------------------------- 1 | drwxr-xr-x 0:0 1.2 MB ├─⊕ bin 2 | drwxr-xr-x 0:0 0 B ├── dev 3 | drwxr-xr-x 0:0 1.0 kB ├─⊕ etc 4 | drwxr-xr-x 65534:65534 0 B ├── home 5 | drwx------ 0:0 0 B ├── root 6 | drwxrwxrwt 0:0 0 B ├── tmp 7 | drwxr-xr-x 0:0 0 B ├── usr 8 | drwxr-xr-x 1:1 0 B │ └── sbin 9 | drwxr-xr-x 0:0 0 B └── var 10 | drwxr-xr-x 0:0 0 B ├── spool 11 | drwxr-xr-x 8:8 0 B │ └── mail 12 | drwxr-xr-x 0:0 0 B └── www 13 | 14 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreeDirCollapseAll.txt: -------------------------------------------------------------------------------- 1 | drwxr-xr-x 0:0 1.2 MB ├─⊕ bin 2 | drwxr-xr-x 0:0 0 B ├── dev 3 | drwxr-xr-x 0:0 1.0 kB ├─⊕ etc 4 | drwxr-xr-x 65534:65534 0 B ├── home 5 | drwx------ 0:0 0 B ├── root 6 | drwxrwxrwt 0:0 0 B ├── tmp 7 | drwxr-xr-x 0:0 0 B ├─⊕ usr 8 | drwxr-xr-x 0:0 0 B └─⊕ var 9 | 10 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreeDirCursorRight.txt: -------------------------------------------------------------------------------- 1 | drwxr-xr-x 0:0 1.2 MB ├─⊕ bin 2 | drwxr-xr-x 0:0 0 B ├── dev 3 | drwxr-xr-x 0:0 1.0 kB ├── etc 4 | -rw-rw-r-- 0:0 307 B │ ├── group 5 | -rw-r--r-- 0:0 127 B │ ├── localtime 6 | drwxr-xr-x 0:0 0 B │ ├── network 7 | drwxr-xr-x 0:0 0 B │ │ ├── if-down.d 8 | drwxr-xr-x 0:0 0 B │ │ ├── if-post-down.d 9 | drwxr-xr-x 0:0 0 B │ │ ├── if-pre-up.d 10 | drwxr-xr-x 0:0 0 B │ │ └── if-up.d 11 | -rw-r--r-- 0:0 340 B │ ├── passwd 12 | -rw------- 0:0 243 B │ └── shadow 13 | drwxr-xr-x 65534:65534 0 B ├── home 14 | drwx------ 0:0 0 B ├── root 15 | drwxrwxrwt 0:0 0 B ├── tmp 16 | drwxr-xr-x 0:0 0 B ├── usr 17 | drwxr-xr-x 1:1 0 B │ └── sbin 18 | drwxr-xr-x 0:0 0 B └── var 19 | drwxr-xr-x 0:0 0 B ├── spool 20 | drwxr-xr-x 8:8 0 B │ └── mail 21 | drwxr-xr-x 0:0 0 B └── www 22 | 23 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreeFilterTree.txt: -------------------------------------------------------------------------------- 1 | drwxr-xr-x 0:0 0 B └── etc 2 | drwxr-xr-x 0:0 0 B └── network 3 | drwxr-xr-x 0:0 0 B ├── if-down.d 4 | drwxr-xr-x 0:0 0 B ├── if-post-down.d 5 | drwxr-xr-x 0:0 0 B ├── if-pre-up.d 6 | drwxr-xr-x 0:0 0 B └── if-up.d 7 | 8 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreeHideAddedRemovedModified.txt: -------------------------------------------------------------------------------- 1 | drwxr-xr-x 0:0 1.2 MB ├─⊕ bin 2 | drwxr-xr-x 0:0 0 B ├── dev 3 | drwxr-xr-x 0:0 1.0 kB ├── etc 4 | -rw-rw-r-- 0:0 307 B │ ├── group 5 | -rw-r--r-- 0:0 127 B │ ├── localtime 6 | drwxr-xr-x 0:0 0 B │ ├── network 7 | drwxr-xr-x 0:0 0 B │ │ ├── if-down.d 8 | drwxr-xr-x 0:0 0 B │ │ ├── if-post-down.d 9 | drwxr-xr-x 0:0 0 B │ │ ├── if-pre-up.d 10 | drwxr-xr-x 0:0 0 B │ │ └── if-up.d 11 | -rw-r--r-- 0:0 340 B │ ├── passwd 12 | -rw------- 0:0 243 B │ └── shadow 13 | drwxr-xr-x 65534:65534 0 B ├── home 14 | drwxrwxrwt 0:0 0 B ├── tmp 15 | drwxr-xr-x 0:0 0 B ├── usr 16 | drwxr-xr-x 1:1 0 B │ └── sbin 17 | drwxr-xr-x 0:0 0 B └── var 18 | drwxr-xr-x 0:0 0 B ├── spool 19 | drwxr-xr-x 8:8 0 B │ └── mail 20 | drwxr-xr-x 0:0 0 B └── www 21 | 22 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreeHideTypeWithFilter.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreeHideUnmodified.txt: -------------------------------------------------------------------------------- 1 | drwx------ 0:0 19 kB ├── root 2 | drwxr-xr-x 0:0 13 kB │ ├── example 3 | drwxr-xr-x 0:0 0 B │ │ ├── really 4 | drwxr-xr-x 0:0 0 B │ │ │ └── nested 5 | -r--r--r-- 0:0 6.4 kB │ │ ├── somefile1.txt 6 | -rw-r--r-- 0:0 6.4 kB │ │ ├── somefile2.txt 7 | -rw-r--r-- 0:0 6.4 kB │ │ └── somefile3.txt 8 | -rw-r--r-- 0:0 6.4 kB │ └── saved.txt 9 | -rw-rw-r-- 0:0 6.4 kB └── somefile.txt 10 | 11 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreePageDown.txt: -------------------------------------------------------------------------------- 1 | -rwxr-xr-x 0:0 0 B │ ├── cat → bin/[ 2 | -rwxr-xr-x 0:0 0 B │ ├── chat → bin/[ 3 | -rwxr-xr-x 0:0 0 B │ ├── chattr → bin/[ 4 | -rwxr-xr-x 0:0 0 B │ ├── chgrp → bin/[ 5 | -rwxr-xr-x 0:0 0 B │ ├── chmod → bin/[ 6 | -rwxr-xr-x 0:0 0 B │ ├── chown → bin/[ 7 | -rwxr-xr-x 0:0 0 B │ ├── chpasswd → bin/[ 8 | -rwxr-xr-x 0:0 0 B │ ├── chpst → bin/[ 9 | -rwxr-xr-x 0:0 0 B │ ├── chroot → bin/[ 10 | -rwxr-xr-x 0:0 0 B │ ├── chrt → bin/[ 11 | 12 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreePageUp.txt: -------------------------------------------------------------------------------- 1 | -rwxr-xr-x 0:0 0 B │ ├── arch → bin/[ 2 | -rwxr-xr-x 0:0 0 B │ ├── arp → bin/[ 3 | -rwxr-xr-x 0:0 0 B │ ├── arping → bin/[ 4 | -rwxr-xr-x 0:0 0 B │ ├── ash → bin/[ 5 | -rwxr-xr-x 0:0 0 B │ ├── awk → bin/[ 6 | -rwxr-xr-x 0:0 0 B │ ├── base64 → bin/[ 7 | -rwxr-xr-x 0:0 0 B │ ├── basename → bin/[ 8 | -rwxr-xr-x 0:0 0 B │ ├── beep → bin/[ 9 | -rwxr-xr-x 0:0 0 B │ ├── blkdiscard → bin/[ 10 | -rwxr-xr-x 0:0 0 B │ ├── blkid → bin/[ 11 | 12 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreeRestrictedHeight.txt: -------------------------------------------------------------------------------- 1 | ├── bin 2 | │ ├── [ 3 | │ ├── [[ → bin/[ 4 | │ ├── acpid → bin/[ 5 | │ ├── add-shell → bin/[ 6 | │ ├── addgroup → bin/[ 7 | │ ├── adduser → bin/[ 8 | │ ├── adjtimex → bin/[ 9 | │ ├── ar → bin/[ 10 | │ ├── arch → bin/[ 11 | │ ├── arp → bin/[ 12 | │ ├── arping → bin/[ 13 | │ ├── ash → bin/[ 14 | │ ├── awk → bin/[ 15 | │ ├── base64 → bin/[ 16 | │ ├── basename → bin/[ 17 | │ ├── beep → bin/[ 18 | │ ├── blkdiscard → bin/[ 19 | │ ├── blkid → bin/[ 20 | │ ├── blockdev → bin/[ 21 | │ ├── bootchartd → bin/[ 22 | 23 | -------------------------------------------------------------------------------- /cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreeSelectLayer.txt: -------------------------------------------------------------------------------- 1 | drwxr-xr-x 0:0 1.2 MB ├─⊕ bin 2 | drwxr-xr-x 0:0 0 B ├── dev 3 | drwxr-xr-x 0:0 1.0 kB ├── etc 4 | -rw-rw-r-- 0:0 307 B │ ├── group 5 | -rw-r--r-- 0:0 127 B │ ├── localtime 6 | drwxr-xr-x 0:0 0 B │ ├── network 7 | drwxr-xr-x 0:0 0 B │ │ ├── if-down.d 8 | drwxr-xr-x 0:0 0 B │ │ ├── if-post-down.d 9 | drwxr-xr-x 0:0 0 B │ │ ├── if-pre-up.d 10 | drwxr-xr-x 0:0 0 B │ │ └── if-up.d 11 | -rw-r--r-- 0:0 340 B │ ├── passwd 12 | -rw------- 0:0 243 B │ └── shadow 13 | drwxr-xr-x 65534:65534 0 B ├── home 14 | drwx------ 0:0 0 B ├── root 15 | -rw-rw-r-- 0:0 6.4 kB ├── somefile.txt 16 | drwxrwxrwt 0:0 0 B ├── tmp 17 | drwxr-xr-x 0:0 0 B ├── usr 18 | drwxr-xr-x 1:1 0 B │ └── sbin 19 | drwxr-xr-x 0:0 0 B └── var 20 | drwxr-xr-x 0:0 0 B ├── spool 21 | drwxr-xr-x 8:8 0 B │ └── mail 22 | drwxr-xr-x 0:0 0 B └── www 23 | 24 | -------------------------------------------------------------------------------- /cmd/dive/cli/testdata/config/dive-ci-legacy.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | lowestEfficiency: 0.95 3 | highestWastedBytes: 20MB 4 | highestUserWastedPercent: 0.20 5 | -------------------------------------------------------------------------------- /cmd/dive/cli/testdata/default-ci-config/.dive-ci: -------------------------------------------------------------------------------- 1 | rules: 2 | # If the efficiency is measured below X%, mark as failed. (expressed as a percentage between 0-1) 3 | lowestEfficiency: 0.96 4 | 5 | # If the amount of wasted space is at least X or larger than X, mark as failed. (expressed in B, KB, MB, and GB) 6 | highestWastedBytes: 19Mb 7 | 8 | # If the amount of wasted space makes up for X% of the image, mark as failed. (fail if the threshold is met or crossed; expressed as a percentage between 0-1) 9 | highestUserWastedPercent: 0.6 10 | -------------------------------------------------------------------------------- /cmd/dive/cli/testdata/dive-enable-ci.yaml: -------------------------------------------------------------------------------- 1 | ci: true 2 | rules: 3 | # lowest allowable image efficiency (as a ratio between 0-1), otherwise CI validation will fail. (env: DIVE_RULES_LOWEST_EFFICIENCY_THRESHOLD) 4 | lowest-efficiency-threshold: '0.10' 5 | 6 | # highest allowable bytes wasted, otherwise CI validation will fail. (env: DIVE_RULES_HIGHEST_WASTED_BYTES) 7 | highest-wasted-bytes: '20MB' 8 | 9 | # highest allowable percentage of bytes wasted (as a ratio between 0-1), otherwise CI validation will fail. (env: DIVE_RULES_HIGHEST_USER_WASTED_PERCENT) 10 | highest-user-wasted-percent: '0.90' 11 | -------------------------------------------------------------------------------- /cmd/dive/cli/testdata/image-multi-layer-containerfile/Containerfile: -------------------------------------------------------------------------------- 1 | FROM busybox:1.37.0@sha256:37f7b378a29ceb4c551b1b5582e27747b855bbfaa73fa11914fe0df028dc581f 2 | ADD example.md /somefile.txt 3 | RUN mkdir -p /root/example/really/nested 4 | RUN cp /somefile.txt /root/example/somefile1.txt 5 | RUN chmod 444 /root/example/somefile1.txt 6 | RUN cp /somefile.txt /root/example/somefile2.txt 7 | RUN cp /somefile.txt /root/example/somefile3.txt 8 | RUN mv /root/example/somefile3.txt /root/saved.txt 9 | RUN cp /root/saved.txt /root/.saved.txt 10 | RUN chmod +x /root/saved.txt 11 | RUN chmod 421 /root 12 | RUN rm -rf /root/example/ 13 | ADD overwrite.md /root/saved.txt 14 | -------------------------------------------------------------------------------- /cmd/dive/cli/testdata/image-multi-layer-containerfile/dive-pass.yaml: -------------------------------------------------------------------------------- 1 | ci: true 2 | rules: 3 | # lowest allowable image efficiency (as a ratio between 0-1), otherwise CI validation will fail. (env: DIVE_RULES_LOWEST_EFFICIENCY_THRESHOLD) 4 | lowest-efficiency-threshold: '0.10' 5 | 6 | # highest allowable bytes wasted, otherwise CI validation will fail. (env: DIVE_RULES_HIGHEST_WASTED_BYTES) 7 | highest-wasted-bytes: '20MB' 8 | 9 | # highest allowable percentage of bytes wasted (as a ratio between 0-1), otherwise CI validation will fail. (env: DIVE_RULES_HIGHEST_USER_WASTED_PERCENT) 10 | highest-user-wasted-percent: '0.90' 11 | -------------------------------------------------------------------------------- /cmd/dive/cli/testdata/image-multi-layer-containerfile/example.md: -------------------------------------------------------------------------------- 1 | # exmaple! 2 | 3 | woot! -------------------------------------------------------------------------------- /cmd/dive/cli/testdata/image-multi-layer-containerfile/overwrite.md: -------------------------------------------------------------------------------- 1 | # evil! 2 | 3 | this will overwrite the other file... -------------------------------------------------------------------------------- /cmd/dive/cli/testdata/image-multi-layer-dockerfile/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM busybox:1.37.0@sha256:37f7b378a29ceb4c551b1b5582e27747b855bbfaa73fa11914fe0df028dc581f 2 | ADD example.md /somefile.txt 3 | RUN mkdir -p /root/example/really/nested 4 | RUN cp /somefile.txt /root/example/somefile1.txt 5 | RUN chmod 444 /root/example/somefile1.txt 6 | RUN cp /somefile.txt /root/example/somefile2.txt 7 | RUN cp /somefile.txt /root/example/somefile3.txt 8 | RUN mv /root/example/somefile3.txt /root/saved.txt 9 | RUN cp /root/saved.txt /root/.saved.txt 10 | RUN chmod +x /root/saved.txt 11 | RUN chmod 421 /root 12 | RUN rm -rf /root/example/ 13 | ADD overwrite.md /root/saved.txt 14 | -------------------------------------------------------------------------------- /cmd/dive/cli/testdata/image-multi-layer-dockerfile/dive-fail.yaml: -------------------------------------------------------------------------------- 1 | ci: true 2 | rules: 3 | lowest-efficiency-threshold: '0.9' 4 | -------------------------------------------------------------------------------- /cmd/dive/cli/testdata/image-multi-layer-dockerfile/dive-pass.yaml: -------------------------------------------------------------------------------- 1 | ci: true 2 | rules: 3 | lowest-efficiency-threshold: '0.10' 4 | highest-wasted-bytes: '20MB' 5 | highest-user-wasted-percent: '0.90' 6 | -------------------------------------------------------------------------------- /cmd/dive/cli/testdata/image-multi-layer-dockerfile/example.md: -------------------------------------------------------------------------------- 1 | # exmaple! 2 | 3 | woot! -------------------------------------------------------------------------------- /cmd/dive/cli/testdata/image-multi-layer-dockerfile/overwrite.md: -------------------------------------------------------------------------------- 1 | # evil! 2 | 3 | this will overwrite the other file... -------------------------------------------------------------------------------- /cmd/dive/cli/testdata/invalid/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | INVALID woops -------------------------------------------------------------------------------- /cmd/dive/cli/testdata/snapshots/cli_build_test.snap: -------------------------------------------------------------------------------- 1 | 2 | [Test_Build_Dockerfile/implicit_dockerfile - 1] 3 | Analysis: 4 | efficiency: 100.00 % 5 | wastedBytes: 131 bytes (131 B) 6 | userWastedPercent: 71.98 % 7 | 8 | Inefficient Files: 9 | Count Wasted Space File Path 10 | 3 80 B /root/saved.txt 11 | 2 34 B /root/example/somefile1.txt 12 | 2 17 B /root/example/somefile3.txt 13 | 2 0 B /root 14 | 10 0 B /etc 15 | 16 | Evaluation: 17 | PASS highestUserWastedPercent (0.90) 18 | PASS highestWastedBytes (20MB) 19 | PASS lowestEfficiency (0.9) 20 | 21 | PASS [pass:3] 22 | 23 | --- 24 | 25 | [Test_Build_Dockerfile/explicit_file_flag - 1] 26 | Analysis: 27 | efficiency: 100.00 % 28 | wastedBytes: 131 bytes (131 B) 29 | userWastedPercent: 71.98 % 30 | 31 | Inefficient Files: 32 | Count Wasted Space File Path 33 | 3 80 B /root/saved.txt 34 | 2 34 B /root/example/somefile1.txt 35 | 2 17 B /root/example/somefile3.txt 36 | 2 0 B /root 37 | 10 0 B /etc 38 | 39 | Evaluation: 40 | PASS highestUserWastedPercent (0.90) 41 | PASS highestWastedBytes (20MB) 42 | PASS lowestEfficiency (0.9) 43 | 44 | PASS [pass:3] 45 | 46 | --- 47 | 48 | [Test_Build_Containerfile/implicit_containerfile - 1] 49 | Analysis: 50 | efficiency: 100.00 % 51 | wastedBytes: 131 bytes (131 B) 52 | userWastedPercent: 71.98 % 53 | 54 | Inefficient Files: 55 | Count Wasted Space File Path 56 | 3 80 B /root/saved.txt 57 | 2 34 B /root/example/somefile1.txt 58 | 2 17 B /root/example/somefile3.txt 59 | 2 0 B /root 60 | 10 0 B /etc 61 | 62 | Evaluation: 63 | PASS highestUserWastedPercent (0.90) 64 | PASS highestWastedBytes (20MB) 65 | PASS lowestEfficiency (0.9) 66 | 67 | PASS [pass:3] 68 | 69 | --- 70 | 71 | [Test_Build_Containerfile/explicit_file_flag - 1] 72 | Analysis: 73 | efficiency: 100.00 % 74 | wastedBytes: 131 bytes (131 B) 75 | userWastedPercent: 71.98 % 76 | 77 | Inefficient Files: 78 | Count Wasted Space File Path 79 | 3 80 B /root/saved.txt 80 | 2 34 B /root/example/somefile1.txt 81 | 2 17 B /root/example/somefile3.txt 82 | 2 0 B /root 83 | 10 0 B /etc 84 | 85 | Evaluation: 86 | PASS highestUserWastedPercent (0.90) 87 | PASS highestWastedBytes (20MB) 88 | PASS lowestEfficiency (0.9) 89 | 90 | PASS [pass:3] 91 | 92 | --- 93 | 94 | [Test_BuildFailure/nonexistent_directory - 1] 95 | Building image ... ./path/does/not/exist 96 | 97 | --- 98 | 99 | [Test_BuildFailure/invalid_dockerfile - 1] 100 | Building image ... ./testdata/invalid 101 | #0 building with "desktop-linux" instance using docker driver 102 | 103 | #1 [internal] load build definition from Dockerfile 104 | #1 transferring dockerfile: 100B done 105 | #1 DONE 0.0s 106 | Dockerfile:2 107 | -------------------- 108 | 1 | FROM scratch 109 | 2 | >>> INVALID woops 110 | -------------------- 111 | ERROR: failed to solve: dockerfile parse error on line 2: unknown instruction: INVALID 112 | 113 | View build details: docker-desktop:// 114 | --- 115 | 116 | [Test_Build_CI_gate_fail - 1] 117 | Analysis: 118 | efficiency: 100.00 % 119 | wastedBytes: 131 bytes (131 B) 120 | userWastedPercent: 71.98 % 121 | 122 | Inefficient Files: 123 | Count Wasted Space File Path 124 | 3 80 B /root/saved.txt 125 | 2 34 B /root/example/somefile1.txt 126 | 2 17 B /root/example/somefile3.txt 127 | 2 0 B /root 128 | 10 0 B /etc 129 | 130 | Evaluation: 131 | FAIL highestUserWastedPercent (too many bytes wasted, relative to the user bytes added (%-user-wasted-bytes=0.72 > threshold=0.1)) 132 | SKIP highestWastedBytes (disabled) 133 | PASS lowestEfficiency (0.9) 134 | 135 | FAIL [pass:1 fail:1 skip:1] 136 | 137 | --- 138 | -------------------------------------------------------------------------------- /cmd/dive/cli/testdata/snapshots/cli_ci_test.snap: -------------------------------------------------------------------------------- 1 | 2 | [Test_CI_Fail - 1] 3 | Analysis: 4 | efficiency: 100.00 % 5 | wastedBytes: 131 bytes (131 B) 6 | userWastedPercent: 71.98 % 7 | 8 | Inefficient Files: 9 | Count Wasted Space File Path 10 | 3 80 B /root/saved.txt 11 | 2 34 B /root/example/somefile1.txt 12 | 2 17 B /root/example/somefile3.txt 13 | 2 0 B /root 14 | 10 0 B /etc 15 | 16 | Evaluation: 17 | FAIL highestUserWastedPercent (too many bytes wasted, relative to the user bytes added (%-user-wasted-bytes=0.72 > threshold=0.1)) 18 | SKIP highestWastedBytes (disabled) 19 | PASS lowestEfficiency (0.9) 20 | 21 | FAIL [pass:1 fail:1 skip:1] 22 | 23 | --- 24 | 25 | [Test_CI_DefaultCIConfig - 1] 26 | [0001] INFO dive version: testing 27 | [0001] DEBUG config: 28 |  log: 29 | quiet: false 30 | level: debug 31 | file: "" 32 | dev: 33 | profile: none 34 | image: /Users/wagoodman/code/dive/.data/test-docker-image.tar 35 | container-engine: docker 36 | ignore-errors: false 37 | ci: false 38 | ci-config: .dive-ci 39 | rules: 40 | lowest-efficiency: "0.96" 41 | highest-wasted-bytes: 19Mb 42 | highest-user-wasted-percent: "0.6" 43 | json-path: "" 44 | keybinding: 45 | quit: ctrl+c 46 | toggle-view: tab 47 | filter-files: ctrl+f, ctrl+slash 48 | close-filter-files: esc 49 | up: up,k 50 | down: down,j 51 | left: left,h 52 | right: right,l 53 | page-up: pgup,u 54 | page-down: pgdn,d 55 | compare-all: ctrl+a 56 | compare-layer: ctrl+l 57 | toggle-collapse-dir: space 58 | toggle-collapse-all-dir: ctrl+space 59 | toggle-added-files: ctrl+a 60 | toggle-removed-files: ctrl+r 61 | toggle-modified-files: ctrl+m 62 | toggle-unmodified-files: ctrl+u 63 | toggle-filetree-attributes: ctrl+b 64 | toggle-sort-order: ctrl+o 65 | toggle-wrap-tree: ctrl+p 66 | extract-file: ctrl+e 67 | diff: 68 | hide: [] 69 | filetree: 70 | collapse-dir: false 71 | pane-width: 0.5 72 | show-attributes: true 73 | layer: 74 | show-aggregated-changes: false 75 | [0001] INFO fetching image=/Users/wagoodman/code/dive/.data/test-docker-image.tar 76 | [0001] DEBUG └── resolver: docker-engine 77 | 78 | --- 79 | -------------------------------------------------------------------------------- /cmd/dive/cli/testdata/snapshots/cli_config_test.snap: -------------------------------------------------------------------------------- 1 | 2 | [Test_Config - 1] 3 | log: 4 | # suppress all logging output (env: DIVE_LOG_QUIET) 5 | quiet: false 6 | 7 | # explicitly set the logging level (available: [error warn info debug trace]) (env: DIVE_LOG_LEVEL) 8 | level: 'warn' 9 | 10 | # file path to write logs to (env: DIVE_LOG_FILE) 11 | file: '' 12 | 13 | dev: 14 | # capture resource profiling data (available: [cpu, mem]) (env: DIVE_DEV_PROFILE) 15 | profile: 'none' 16 | 17 | # container engine to use for image analysis (supported options: 'docker' and 'podman') (env: DIVE_CONTAINER_ENGINE) 18 | container-engine: 'docker' 19 | 20 | # continue with analysis even if there are errors parsing the image archive (env: DIVE_IGNORE_ERRORS) 21 | ignore-errors: false 22 | 23 | # enable CI mode (env: DIVE_CI) 24 | ci: true 25 | 26 | # path to the CI config file (env: DIVE_CI_CONFIG) 27 | ci-config: '.dive-ci' 28 | 29 | rules: 30 | # lowest allowable image efficiency (as a ratio between 0-1), otherwise CI validation will fail. (env: DIVE_RULES_LOWEST_EFFICIENCY) 31 | lowest-efficiency: '0.9' 32 | 33 | # highest allowable bytes wasted, otherwise CI validation will fail. (env: DIVE_RULES_HIGHEST_WASTED_BYTES) 34 | highest-wasted-bytes: '20MB' 35 | 36 | # highest allowable percentage of bytes wasted (as a ratio between 0-1), otherwise CI validation will fail. (env: DIVE_RULES_HIGHEST_USER_WASTED_PERCENT) 37 | highest-user-wasted-percent: '0.90' 38 | 39 | # Skip the interactive TUI and write the layer analysis statistics to a given file. (env: DIVE_JSON_PATH) 40 | json-path: '' 41 | 42 | keybinding: 43 | # quit the application (global) (env: DIVE_KEYBINDING_QUIT) 44 | quit: 'ctrl+c' 45 | 46 | # toggle between different views (global) (env: DIVE_KEYBINDING_TOGGLE_VIEW) 47 | toggle-view: 'tab' 48 | 49 | # filter files by name (global) (env: DIVE_KEYBINDING_FILTER_FILES) 50 | filter-files: 'ctrl+f, ctrl+slash' 51 | 52 | # close file filtering (global) (env: DIVE_KEYBINDING_CLOSE_FILTER_FILES) 53 | close-filter-files: 'esc' 54 | 55 | # move cursor up (global) (env: DIVE_KEYBINDING_UP) 56 | up: 'up,k' 57 | 58 | # move cursor down (global) (env: DIVE_KEYBINDING_DOWN) 59 | down: 'down,j' 60 | 61 | # move cursor left (global) (env: DIVE_KEYBINDING_LEFT) 62 | left: 'left,h' 63 | 64 | # move cursor right (global) (env: DIVE_KEYBINDING_RIGHT) 65 | right: 'right,l' 66 | 67 | # scroll page up (file view) (env: DIVE_KEYBINDING_PAGE_UP) 68 | page-up: 'pgup,u' 69 | 70 | # scroll page down (file view) (env: DIVE_KEYBINDING_PAGE_DOWN) 71 | page-down: 'pgdn,d' 72 | 73 | # compare all layers (layer view) (env: DIVE_KEYBINDING_COMPARE_ALL) 74 | compare-all: 'ctrl+a' 75 | 76 | # compare specific layer (layer view) (env: DIVE_KEYBINDING_COMPARE_LAYER) 77 | compare-layer: 'ctrl+l' 78 | 79 | # toggle directory collapse (file view) (env: DIVE_KEYBINDING_TOGGLE_COLLAPSE_DIR) 80 | toggle-collapse-dir: 'space' 81 | 82 | # toggle collapse all directories (file view) (env: DIVE_KEYBINDING_TOGGLE_COLLAPSE_ALL_DIR) 83 | toggle-collapse-all-dir: 'ctrl+space' 84 | 85 | # toggle visibility of added files (file view) (env: DIVE_KEYBINDING_TOGGLE_ADDED_FILES) 86 | toggle-added-files: 'ctrl+a' 87 | 88 | # toggle visibility of removed files (file view) (env: DIVE_KEYBINDING_TOGGLE_REMOVED_FILES) 89 | toggle-removed-files: 'ctrl+r' 90 | 91 | # toggle visibility of modified files (file view) (env: DIVE_KEYBINDING_TOGGLE_MODIFIED_FILES) 92 | toggle-modified-files: 'ctrl+m' 93 | 94 | # toggle visibility of unmodified files (file view) (env: DIVE_KEYBINDING_TOGGLE_UNMODIFIED_FILES) 95 | toggle-unmodified-files: 'ctrl+u' 96 | 97 | # toggle display of file attributes (file view) (env: DIVE_KEYBINDING_TOGGLE_FILETREE_ATTRIBUTES) 98 | toggle-filetree-attributes: 'ctrl+b' 99 | 100 | # toggle sort order (file view) (env: DIVE_KEYBINDING_TOGGLE_SORT_ORDER) 101 | toggle-sort-order: 'ctrl+o' 102 | 103 | # (env: DIVE_KEYBINDING_TOGGLE_WRAP_TREE) 104 | toggle-wrap-tree: 'ctrl+p' 105 | 106 | # extract file contents (file view) (env: DIVE_KEYBINDING_EXTRACT_FILE) 107 | extract-file: 'ctrl+e' 108 | 109 | diff: 110 | # types of file differences to hide (added, removed, modified, unmodified) (env: DIVE_DIFF_HIDE) 111 | hide: [] 112 | 113 | filetree: 114 | # collapse directories by default in the filetree (env: DIVE_FILETREE_COLLAPSE_DIR) 115 | collapse-dir: false 116 | 117 | # percentage of screen width for the filetree pane (must be >0 and <1) (env: DIVE_FILETREE_PANE_WIDTH) 118 | pane-width: 0.5 119 | 120 | # show file attributes in the filetree view (env: DIVE_FILETREE_SHOW_ATTRIBUTES) 121 | show-attributes: true 122 | 123 | layer: 124 | # show aggregated changes across all previous layers (env: DIVE_LAYER_SHOW_AGGREGATED_CHANGES) 125 | show-aggregated-changes: false 126 | 127 | --- 128 | -------------------------------------------------------------------------------- /cmd/dive/cli/testdata/snapshots/cli_load_test.snap: -------------------------------------------------------------------------------- 1 | 2 | [Test_LoadImage/from_docker_engine - 1] 3 | Loading image busybox:1.37.0@sha256:ad9fa4d07136a83e69a54ef00102f579d04eba431932de3b0f098cc5d5948f9f 4 | Analyzing image [layers:1 files:441 size:4.3 MB] 5 | Evaluating image [rules: 3] 6 | 7 | Analysis: 8 | efficiency: 100.00 % 9 | wastedBytes: 0 bytes 10 | userWastedPercent: 0 % 11 | 12 | Inefficient Files: (None) 13 | 14 | Evaluation: 15 | PASS highestUserWastedPercent (0.90) 16 | PASS highestWastedBytes (20MB) 17 | PASS lowestEfficiency (0.9) 18 | 19 | PASS [pass:3] 20 | 21 | --- 22 | 23 | [Test_LoadImage/from_docker_engine_(flag) - 1] 24 | Loading image busybox:1.37.0@sha256:ad9fa4d07136a83e69a54ef00102f579d04eba431932de3b0f098cc5d5948f9f 25 | Analyzing image [layers:1 files:441 size:4.3 MB] 26 | Evaluating image [rules: 3] 27 | 28 | Analysis: 29 | efficiency: 100.00 % 30 | wastedBytes: 0 bytes 31 | userWastedPercent: 0 % 32 | 33 | Inefficient Files: (None) 34 | 35 | Evaluation: 36 | PASS highestUserWastedPercent (0.90) 37 | PASS highestWastedBytes (20MB) 38 | PASS lowestEfficiency (0.9) 39 | 40 | PASS [pass:3] 41 | 42 | --- 43 | 44 | [Test_LoadImage/from_podman_engine - 1] 45 | Loading image busybox:1.37.0@sha256:ad9fa4d07136a83e69a54ef00102f579d04eba431932de3b0f098cc5d5948f9f 46 | Analyzing image [layers:1 files:441 size:4.3 MB] 47 | Evaluating image [rules: 3] 48 | 49 | Analysis: 50 | efficiency: 100.00 % 51 | wastedBytes: 0 bytes 52 | userWastedPercent: 0 % 53 | 54 | Inefficient Files: (None) 55 | 56 | Evaluation: 57 | PASS highestUserWastedPercent (0.90) 58 | PASS highestWastedBytes (20MB) 59 | PASS lowestEfficiency (0.9) 60 | 61 | PASS [pass:3] 62 | 63 | --- 64 | 65 | [Test_LoadImage/from_podman_engine_(flag) - 1] 66 | Loading image busybox:1.37.0@sha256:ad9fa4d07136a83e69a54ef00102f579d04eba431932de3b0f098cc5d5948f9f 67 | Analyzing image [layers:1 files:441 size:4.3 MB] 68 | Evaluating image [rules: 3] 69 | 70 | Analysis: 71 | efficiency: 100.00 % 72 | wastedBytes: 0 bytes 73 | userWastedPercent: 0 % 74 | 75 | Inefficient Files: (None) 76 | 77 | Evaluation: 78 | PASS highestUserWastedPercent (0.90) 79 | PASS highestWastedBytes (20MB) 80 | PASS lowestEfficiency (0.9) 81 | 82 | PASS [pass:3] 83 | 84 | --- 85 | 86 | [Test_LoadImage/from_archive - 1] 87 | Loading image /Users/wagoodman/code/dive/.data/test-docker-image.tar 88 | Analyzing image [layers:14 files:451 size:1.2 MB] 89 | Evaluating image [rules: 3] 90 | 91 | Analysis: 92 | efficiency: 98.44 % 93 | wastedBytes: 32025 bytes (32 kB) 94 | userWastedPercent: 48.35 % 95 | 96 | Inefficient Files: 97 | Count Wasted Space File Path 98 | 2 13 kB /root/saved.txt 99 | 2 13 kB /root/example/somefile1.txt 100 | 2 6.4 kB /root/example/somefile3.txt 101 | 102 | Evaluation: 103 | PASS highestUserWastedPercent (0.90) 104 | PASS highestWastedBytes (20MB) 105 | PASS lowestEfficiency (0.9) 106 | 107 | PASS [pass:3] 108 | 109 | --- 110 | 111 | [Test_LoadImage/from_archive_(flag) - 1] 112 | Loading image /Users/wagoodman/code/dive/.data/test-docker-image.tar 113 | Analyzing image [layers:14 files:451 size:1.2 MB] 114 | Evaluating image [rules: 3] 115 | 116 | Analysis: 117 | efficiency: 98.44 % 118 | wastedBytes: 32025 bytes (32 kB) 119 | userWastedPercent: 48.35 % 120 | 121 | Inefficient Files: 122 | Count Wasted Space File Path 123 | 2 13 kB /root/saved.txt 124 | 2 13 kB /root/example/somefile1.txt 125 | 2 6.4 kB /root/example/somefile3.txt 126 | 127 | Evaluation: 128 | PASS highestUserWastedPercent (0.90) 129 | PASS highestWastedBytes (20MB) 130 | PASS lowestEfficiency (0.9) 131 | 132 | PASS [pass:3] 133 | 134 | --- 135 | 136 | [Test_FetchFailure/nonexistent_image - 1] 137 | Loading image docker:wagoodman/nonexistent/image:tag 138 | 139 | --- 140 | 141 | [Test_FetchFailure/invalid_image_name - 1] 142 | Loading image /wagoodman/invalid:image:format 143 | 144 | --- 145 | -------------------------------------------------------------------------------- /cmd/dive/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Copyright © 2018 Alex Goodman 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in 13 | // all copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | // THE SOFTWARE. 22 | 23 | import ( 24 | "github.com/anchore/clio" 25 | "github.com/wagoodman/dive/cmd/dive/cli" 26 | ) 27 | 28 | // applicationName is the non-capitalized name of the application (do not change this) 29 | const ( 30 | applicationName = "dive" 31 | notProvided = "[not provided]" 32 | ) 33 | 34 | // TODO: these need to be wired up to the build flags 35 | // all variables here are provided as build-time arguments, with clear default values 36 | var ( 37 | version = notProvided 38 | buildDate = notProvided 39 | gitCommit = notProvided 40 | gitDescription = notProvided 41 | ) 42 | 43 | func main() { 44 | app := cli.Application( 45 | clio.Identification{ 46 | Name: applicationName, 47 | Version: version, 48 | BuildDate: buildDate, 49 | GitCommit: gitCommit, 50 | GitDescription: gitDescription, 51 | }, 52 | ) 53 | 54 | app.Run() 55 | } 56 | -------------------------------------------------------------------------------- /dive/filetree/diff.go: -------------------------------------------------------------------------------- 1 | package filetree 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | const ( 8 | Unmodified DiffType = iota 9 | Modified 10 | Added 11 | Removed 12 | ) 13 | 14 | // DiffType defines the comparison result between two FileNodes 15 | type DiffType int 16 | 17 | // String of a DiffType 18 | func (diff DiffType) String() string { 19 | switch diff { 20 | case Unmodified: 21 | return "Unmodified" 22 | case Modified: 23 | return "Modified" 24 | case Added: 25 | return "Added" 26 | case Removed: 27 | return "Removed" 28 | default: 29 | return fmt.Sprintf("%d", int(diff)) 30 | } 31 | } 32 | 33 | // merge two DiffTypes into a single result. Essentially, return the given value unless they two values differ, 34 | // in which case we can only determine that there is "a change". 35 | func (diff DiffType) merge(other DiffType) DiffType { 36 | if diff == other { 37 | return diff 38 | } 39 | return Modified 40 | } 41 | -------------------------------------------------------------------------------- /dive/filetree/efficiency.go: -------------------------------------------------------------------------------- 1 | package filetree 2 | 3 | import ( 4 | "fmt" 5 | "github.com/wagoodman/dive/internal/log" 6 | "sort" 7 | ) 8 | 9 | // EfficiencyData represents the storage and reference statistics for a given file tree path. 10 | type EfficiencyData struct { 11 | Path string 12 | Nodes []*FileNode 13 | CumulativeSize int64 14 | minDiscoveredSize int64 15 | } 16 | 17 | // EfficiencySlice represents an ordered set of EfficiencyData data structures. 18 | type EfficiencySlice []*EfficiencyData 19 | 20 | // Len is required for sorting. 21 | func (efs EfficiencySlice) Len() int { 22 | return len(efs) 23 | } 24 | 25 | // Swap operation is required for sorting. 26 | func (efs EfficiencySlice) Swap(i, j int) { 27 | efs[i], efs[j] = efs[j], efs[i] 28 | } 29 | 30 | // Less comparison is required for sorting. 31 | func (efs EfficiencySlice) Less(i, j int) bool { 32 | return efs[i].CumulativeSize < efs[j].CumulativeSize 33 | } 34 | 35 | // Efficiency returns the score and file set of the given set of FileTrees (layers). This is loosely based on: 36 | // 1. Files that are duplicated across layers discounts your score, weighted by file size 37 | // 2. Files that are removed discounts your score, weighted by the original file size 38 | func Efficiency(trees []*FileTree) (float64, EfficiencySlice) { 39 | efficiencyMap := make(map[string]*EfficiencyData) 40 | inefficientMatches := make(EfficiencySlice, 0) 41 | currentTree := 0 42 | 43 | visitor := func(node *FileNode) error { 44 | path := node.Path() 45 | if _, ok := efficiencyMap[path]; !ok { 46 | efficiencyMap[path] = &EfficiencyData{ 47 | Path: path, 48 | Nodes: make([]*FileNode, 0), 49 | minDiscoveredSize: -1, 50 | } 51 | } 52 | data := efficiencyMap[path] 53 | 54 | // this node may have had children that were deleted, however, we won't explicitly list out every child, only 55 | // the top-most parent with the cumulative size. These operations will need to be done on the full (stacked) 56 | // tree. 57 | // Note: whiteout files may also represent directories, so we need to find out if this was previously a file or dir. 58 | var sizeBytes int64 59 | 60 | if node.IsWhiteout() { 61 | sizer := func(curNode *FileNode) error { 62 | sizeBytes += curNode.Data.FileInfo.Size 63 | return nil 64 | } 65 | stackedTree, failedPaths, err := StackTreeRange(trees, 0, currentTree-1) 66 | if len(failedPaths) > 0 { 67 | for _, path := range failedPaths { 68 | log.WithFields("path", path.String()).Debug("unable to include path in stacked tree") 69 | } 70 | } 71 | if err != nil { 72 | return fmt.Errorf("unable to stack tree range: %w", err) 73 | } 74 | 75 | previousTreeNode, err := stackedTree.GetNode(node.Path()) 76 | if err != nil { 77 | return err 78 | } 79 | 80 | if previousTreeNode.Data.FileInfo.IsDir { 81 | err = previousTreeNode.VisitDepthChildFirst(sizer, nil, nil) 82 | if err != nil { 83 | return fmt.Errorf("unable to propagate whiteout dir: %w", err) 84 | } 85 | } 86 | } else { 87 | sizeBytes = node.Data.FileInfo.Size 88 | } 89 | 90 | data.CumulativeSize += sizeBytes 91 | if data.minDiscoveredSize < 0 || sizeBytes < data.minDiscoveredSize { 92 | data.minDiscoveredSize = sizeBytes 93 | } 94 | data.Nodes = append(data.Nodes, node) 95 | 96 | if len(data.Nodes) == 2 { 97 | inefficientMatches = append(inefficientMatches, data) 98 | } 99 | 100 | return nil 101 | } 102 | visitEvaluator := func(node *FileNode) bool { 103 | return node.IsLeaf() 104 | } 105 | for idx, tree := range trees { 106 | currentTree = idx 107 | err := tree.VisitDepthChildFirst(visitor, visitEvaluator) 108 | if err != nil { 109 | log.WithFields("layer", tree.Id, "error", err).Debug("unable to propagate layer tree") 110 | } 111 | } 112 | 113 | // calculate the score 114 | var minimumPathSizes int64 115 | var discoveredPathSizes int64 116 | 117 | for _, value := range efficiencyMap { 118 | minimumPathSizes += value.minDiscoveredSize 119 | discoveredPathSizes += value.CumulativeSize 120 | } 121 | var score float64 122 | if discoveredPathSizes == 0 { 123 | score = 1.0 124 | } else { 125 | score = float64(minimumPathSizes) / float64(discoveredPathSizes) 126 | } 127 | 128 | sort.Sort(inefficientMatches) 129 | 130 | return score, inefficientMatches 131 | } 132 | -------------------------------------------------------------------------------- /dive/filetree/efficiency_test.go: -------------------------------------------------------------------------------- 1 | package filetree 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func checkError(t *testing.T, err error, message string) { 8 | if err != nil { 9 | t.Errorf(message+": %+v", err) 10 | } 11 | } 12 | 13 | func TestEfficiency(t *testing.T) { 14 | trees := make([]*FileTree, 3) 15 | for idx := range trees { 16 | trees[idx] = NewFileTree() 17 | } 18 | 19 | _, _, err := trees[0].AddPath("/etc/nginx/nginx.conf", FileInfo{Size: 2000}) 20 | checkError(t, err, "could not setup test") 21 | 22 | _, _, err = trees[0].AddPath("/etc/nginx/public", FileInfo{Size: 3000}) 23 | checkError(t, err, "could not setup test") 24 | 25 | _, _, err = trees[1].AddPath("/etc/nginx/nginx.conf", FileInfo{Size: 5000}) 26 | checkError(t, err, "could not setup test") 27 | _, _, err = trees[1].AddPath("/etc/athing", FileInfo{Size: 10000}) 28 | checkError(t, err, "could not setup test") 29 | 30 | _, _, err = trees[2].AddPath("/etc/.wh.nginx", *BlankFileChangeInfo("/etc/.wh.nginx")) 31 | checkError(t, err, "could not setup test") 32 | 33 | var expectedScore = 0.75 34 | var expectedMatches = EfficiencySlice{ 35 | &EfficiencyData{Path: "/etc/nginx/nginx.conf", CumulativeSize: 7000}, 36 | } 37 | actualScore, actualMatches := Efficiency(trees) 38 | 39 | if expectedScore != actualScore { 40 | t.Errorf("Expected score of %v but go %v", expectedScore, actualScore) 41 | } 42 | 43 | if len(actualMatches) != len(expectedMatches) { 44 | for _, match := range actualMatches { 45 | t.Logf(" match: %+v", match) 46 | } 47 | t.Fatalf("Expected to find %d inefficient paths, but found %d", len(expectedMatches), len(actualMatches)) 48 | } 49 | 50 | if expectedMatches[0].Path != actualMatches[0].Path { 51 | t.Errorf("Expected path of %s but go %s", expectedMatches[0].Path, actualMatches[0].Path) 52 | } 53 | 54 | if expectedMatches[0].CumulativeSize != actualMatches[0].CumulativeSize { 55 | t.Errorf("Expected cumulative size of %v but go %v", expectedMatches[0].CumulativeSize, actualMatches[0].CumulativeSize) 56 | } 57 | } 58 | 59 | func TestEfficiency_ScratchImage(t *testing.T) { 60 | trees := make([]*FileTree, 3) 61 | for idx := range trees { 62 | trees[idx] = NewFileTree() 63 | } 64 | 65 | _, _, err := trees[0].AddPath("/nothing", FileInfo{Size: 0}) 66 | checkError(t, err, "could not setup test") 67 | 68 | var expectedScore = 1.0 69 | var expectedMatches = EfficiencySlice{} 70 | actualScore, actualMatches := Efficiency(trees) 71 | 72 | if expectedScore != actualScore { 73 | t.Errorf("Expected score of %v but go %v", expectedScore, actualScore) 74 | } 75 | 76 | if len(actualMatches) > 0 { 77 | t.Fatalf("Expected to find %d inefficient paths, but found %d", len(expectedMatches), len(actualMatches)) 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /dive/filetree/file_info.go: -------------------------------------------------------------------------------- 1 | package filetree 2 | 3 | import ( 4 | "archive/tar" 5 | "fmt" 6 | "io" 7 | "os" 8 | 9 | "github.com/cespare/xxhash/v2" 10 | ) 11 | 12 | // FileInfo contains tar metadata for a specific FileNode 13 | type FileInfo struct { 14 | Path string `json:"path"` 15 | TypeFlag byte `json:"typeFlag"` 16 | Linkname string `json:"linkName"` 17 | hash uint64 //`json:"hash"` 18 | Size int64 `json:"size"` 19 | Mode os.FileMode `json:"fileMode"` 20 | Uid int `json:"uid"` 21 | Gid int `json:"gid"` 22 | IsDir bool `json:"isDir"` 23 | } 24 | 25 | // NewFileInfoFromTarHeader extracts the metadata from a tar header and file contents and generates a new FileInfo object. 26 | func NewFileInfoFromTarHeader(reader *tar.Reader, header *tar.Header, path string) FileInfo { 27 | var hash uint64 28 | if header.Typeflag != tar.TypeDir { 29 | hash = getHashFromReader(reader) 30 | } 31 | 32 | return FileInfo{ 33 | Path: path, 34 | TypeFlag: header.Typeflag, 35 | Linkname: header.Linkname, 36 | hash: hash, 37 | Size: header.FileInfo().Size(), 38 | Mode: header.FileInfo().Mode(), 39 | Uid: header.Uid, 40 | Gid: header.Gid, 41 | IsDir: header.FileInfo().IsDir(), 42 | } 43 | } 44 | 45 | func NewFileInfo(realPath, path string, info os.FileInfo) FileInfo { 46 | var err error 47 | 48 | // todo: don't use tar types here, create our own... 49 | var fileType byte 50 | var linkName string 51 | var size int64 52 | 53 | if info.Mode()&os.ModeSymlink != 0 { 54 | fileType = tar.TypeSymlink 55 | 56 | linkName, err = os.Readlink(realPath) 57 | if err != nil { 58 | panic(fmt.Errorf("unable to read symlink %q: %s", realPath, err)) 59 | } 60 | } else if info.IsDir() { 61 | fileType = tar.TypeDir 62 | } else { 63 | fileType = tar.TypeReg 64 | 65 | size = info.Size() 66 | } 67 | 68 | var hash uint64 69 | if fileType != tar.TypeDir { 70 | file, err := os.Open(realPath) 71 | if err != nil { 72 | panic(fmt.Errorf("unable to open file %q: %s", realPath, err)) 73 | } 74 | defer file.Close() 75 | hash = getHashFromReader(file) 76 | } 77 | 78 | return FileInfo{ 79 | Path: path, 80 | TypeFlag: fileType, 81 | Linkname: linkName, 82 | hash: hash, 83 | Size: size, 84 | Mode: info.Mode(), 85 | // todo: support UID/GID 86 | Uid: -1, 87 | Gid: -1, 88 | IsDir: info.IsDir(), 89 | } 90 | } 91 | 92 | // Copy duplicates a FileInfo 93 | func (data *FileInfo) Copy() *FileInfo { 94 | if data == nil { 95 | return nil 96 | } 97 | return &FileInfo{ 98 | Path: data.Path, 99 | TypeFlag: data.TypeFlag, 100 | Linkname: data.Linkname, 101 | hash: data.hash, 102 | Size: data.Size, 103 | Mode: data.Mode, 104 | Uid: data.Uid, 105 | Gid: data.Gid, 106 | IsDir: data.IsDir, 107 | } 108 | } 109 | 110 | // Compare determines the DiffType between two FileInfos based on the type and contents of each given FileInfo 111 | func (data *FileInfo) Compare(other FileInfo) DiffType { 112 | if data.TypeFlag == other.TypeFlag { 113 | if data.hash == other.hash && 114 | data.Mode == other.Mode && 115 | data.Uid == other.Uid && 116 | data.Gid == other.Gid { 117 | return Unmodified 118 | } 119 | } 120 | return Modified 121 | } 122 | 123 | func getHashFromReader(reader io.Reader) uint64 { 124 | h := xxhash.New() 125 | 126 | buf := make([]byte, 1024) 127 | for { 128 | n, err := reader.Read(buf) 129 | if err != nil && err != io.EOF { 130 | panic(fmt.Errorf("unable to read file: %w", err)) 131 | } 132 | if n == 0 { 133 | break 134 | } 135 | 136 | _, err = h.Write(buf[:n]) 137 | if err != nil { 138 | panic(fmt.Errorf("unable to write to hash: %w", err)) 139 | } 140 | } 141 | 142 | return h.Sum64() 143 | } 144 | -------------------------------------------------------------------------------- /dive/filetree/file_node_test.go: -------------------------------------------------------------------------------- 1 | package filetree 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestAddChild(t *testing.T) { 8 | var expected, actual int 9 | tree := NewFileTree() 10 | 11 | payload := FileInfo{ 12 | Path: "stufffffs", 13 | } 14 | 15 | one := tree.Root.AddChild("first node!", payload) 16 | 17 | two := tree.Root.AddChild("nil node!", FileInfo{}) 18 | 19 | tree.Root.AddChild("third node!", FileInfo{}) 20 | two.AddChild("forth, one level down...", FileInfo{}) 21 | two.AddChild("fifth, one level down...", FileInfo{}) 22 | two.AddChild("fifth, one level down...", FileInfo{}) 23 | 24 | expected, actual = 5, tree.Size 25 | if expected != actual { 26 | t.Errorf("Expected a tree size of %d got %d.", expected, actual) 27 | } 28 | 29 | expected, actual = 2, len(two.Children) 30 | if expected != actual { 31 | t.Errorf("Expected 'twos' number of children to be %d got %d.", expected, actual) 32 | } 33 | 34 | expected, actual = 3, len(tree.Root.Children) 35 | if expected != actual { 36 | t.Errorf("Expected 'twos' number of children to be %d got %d.", expected, actual) 37 | } 38 | 39 | expectedFC := FileInfo{ 40 | Path: "stufffffs", 41 | } 42 | actualFC := one.Data.FileInfo 43 | if expectedFC.Path != actualFC.Path { 44 | t.Errorf("Expected 'ones' payload to be %+v got %+v.", expectedFC, actualFC) 45 | } 46 | 47 | } 48 | 49 | func TestRemoveChild(t *testing.T) { 50 | var expected, actual int 51 | 52 | tree := NewFileTree() 53 | tree.Root.AddChild("first", FileInfo{}) 54 | two := tree.Root.AddChild("nil", FileInfo{}) 55 | tree.Root.AddChild("third", FileInfo{}) 56 | forth := two.AddChild("forth", FileInfo{}) 57 | two.AddChild("fifth", FileInfo{}) 58 | 59 | err := forth.Remove() 60 | checkError(t, err, "unable to setup test") 61 | 62 | expected, actual = 4, tree.Size 63 | if expected != actual { 64 | t.Errorf("Expected a tree size of %d got %d.", expected, actual) 65 | } 66 | 67 | if tree.Root.Children["forth"] != nil { 68 | t.Errorf("Expected 'forth' node to be deleted.") 69 | } 70 | 71 | err = two.Remove() 72 | checkError(t, err, "unable to setup test") 73 | 74 | expected, actual = 2, tree.Size 75 | if expected != actual { 76 | t.Errorf("Expected a tree size of %d got %d.", expected, actual) 77 | } 78 | 79 | if tree.Root.Children["nil"] != nil { 80 | t.Errorf("Expected 'nil' node to be deleted.") 81 | } 82 | 83 | } 84 | 85 | func TestPath(t *testing.T) { 86 | expected := "/etc/nginx/nginx.conf" 87 | tree := NewFileTree() 88 | node, _, _ := tree.AddPath(expected, FileInfo{}) 89 | 90 | actual := node.Path() 91 | if expected != actual { 92 | t.Errorf("Expected path '%s' got '%s'", expected, actual) 93 | } 94 | } 95 | 96 | func TestIsWhiteout(t *testing.T) { 97 | tree1 := NewFileTree() 98 | p1, _, _ := tree1.AddPath("/etc/nginx/public1", FileInfo{}) 99 | p2, _, _ := tree1.AddPath("/etc/nginx/.wh.public2", FileInfo{}) 100 | p3, _, _ := tree1.AddPath("/etc/nginx/public3/.wh..wh..opq", FileInfo{}) 101 | 102 | if p1.IsWhiteout() != false { 103 | t.Errorf("Expected path '%s' to **not** be a whiteout file", p1.Name) 104 | } 105 | 106 | if p2.IsWhiteout() != true { 107 | t.Errorf("Expected path '%s' to be a whiteout file", p2.Name) 108 | } 109 | 110 | if p3 != nil { 111 | t.Errorf("Expected to not be able to add path '%s'", p2.Name) 112 | } 113 | } 114 | 115 | func TestDiffTypeFromAddedChildren(t *testing.T) { 116 | tree := NewFileTree() 117 | node, _, _ := tree.AddPath("/usr", *BlankFileChangeInfo("/usr")) 118 | node.Data.DiffType = Unmodified 119 | 120 | node, _, _ = tree.AddPath("/usr/bin", *BlankFileChangeInfo("/usr/bin")) 121 | node.Data.DiffType = Added 122 | 123 | node, _, _ = tree.AddPath("/usr/bin2", *BlankFileChangeInfo("/usr/bin2")) 124 | node.Data.DiffType = Removed 125 | 126 | err := tree.Root.Children["usr"].deriveDiffType(Unmodified) 127 | checkError(t, err, "unable to setup test") 128 | 129 | if tree.Root.Children["usr"].Data.DiffType != Modified { 130 | t.Errorf("Expected Modified but got %v", tree.Root.Children["usr"].Data.DiffType) 131 | } 132 | } 133 | func TestDiffTypeFromRemovedChildren(t *testing.T) { 134 | tree := NewFileTree() 135 | _, _, _ = tree.AddPath("/usr", *BlankFileChangeInfo("/usr")) 136 | 137 | info1 := BlankFileChangeInfo("/usr/.wh.bin") 138 | node, _, _ := tree.AddPath("/usr/.wh.bin", *info1) 139 | node.Data.DiffType = Removed 140 | 141 | info2 := BlankFileChangeInfo("/usr/.wh.bin2") 142 | node, _, _ = tree.AddPath("/usr/.wh.bin2", *info2) 143 | node.Data.DiffType = Removed 144 | 145 | err := tree.Root.Children["usr"].deriveDiffType(Unmodified) 146 | checkError(t, err, "unable to setup test") 147 | 148 | if tree.Root.Children["usr"].Data.DiffType != Modified { 149 | t.Errorf("Expected Modified but got %v", tree.Root.Children["usr"].Data.DiffType) 150 | } 151 | 152 | } 153 | 154 | func TestDirSize(t *testing.T) { 155 | tree1 := NewFileTree() 156 | _, _, err := tree1.AddPath("/etc/nginx/public1", FileInfo{Size: 100}) 157 | checkError(t, err, "unable to setup test") 158 | _, _, err = tree1.AddPath("/etc/nginx/thing1", FileInfo{Size: 200}) 159 | checkError(t, err, "unable to setup test") 160 | _, _, err = tree1.AddPath("/etc/nginx/public3/thing2", FileInfo{Size: 300}) 161 | checkError(t, err, "unable to setup test") 162 | 163 | node, _ := tree1.GetNode("/etc/nginx") 164 | expected, actual := "---------- 0:0 600 B ", node.MetadataString() 165 | if expected != actual { 166 | t.Errorf("Expected metadata '%s' got '%s'", expected, actual) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /dive/filetree/node_data.go: -------------------------------------------------------------------------------- 1 | package filetree 2 | 3 | var GlobalFileTreeCollapse bool 4 | 5 | // NodeData is the payload for a FileNode 6 | type NodeData struct { 7 | ViewInfo ViewInfo 8 | FileInfo FileInfo `json:"fileInfo"` 9 | DiffType DiffType 10 | } 11 | 12 | // NewNodeData creates an empty NodeData struct for a FileNode 13 | func NewNodeData() *NodeData { 14 | return &NodeData{ 15 | ViewInfo: *NewViewInfo(), 16 | FileInfo: FileInfo{}, 17 | DiffType: Unmodified, 18 | } 19 | } 20 | 21 | // Copy duplicates a NodeData 22 | func (data *NodeData) Copy() *NodeData { 23 | return &NodeData{ 24 | ViewInfo: *data.ViewInfo.Copy(), 25 | FileInfo: *data.FileInfo.Copy(), 26 | DiffType: data.DiffType, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /dive/filetree/node_data_test.go: -------------------------------------------------------------------------------- 1 | package filetree 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestAssignDiffType(t *testing.T) { 8 | tree := NewFileTree() 9 | node, _, err := tree.AddPath("/usr", *BlankFileChangeInfo("/usr")) 10 | if err != nil { 11 | t.Errorf("Expected no error from fetching path. got: %v", err) 12 | } 13 | node.Data.DiffType = Modified 14 | if tree.Root.Children["usr"].Data.DiffType != Modified { 15 | t.Fail() 16 | } 17 | } 18 | 19 | func TestMergeDiffTypes(t *testing.T) { 20 | a := Unmodified 21 | b := Unmodified 22 | merged := a.merge(b) 23 | if merged != Unmodified { 24 | t.Errorf("Expected Unchanged (0) but got %v", merged) 25 | } 26 | a = Modified 27 | b = Unmodified 28 | merged = a.merge(b) 29 | if merged != Modified { 30 | t.Errorf("Expected Unchanged (0) but got %v", merged) 31 | } 32 | } 33 | 34 | func BlankFileChangeInfo(path string) (f *FileInfo) { 35 | result := FileInfo{ 36 | Path: path, 37 | TypeFlag: 1, 38 | hash: 123, 39 | } 40 | return &result 41 | } 42 | -------------------------------------------------------------------------------- /dive/filetree/order_strategy.go: -------------------------------------------------------------------------------- 1 | package filetree 2 | 3 | import ( 4 | "sort" 5 | ) 6 | 7 | type SortOrder int 8 | 9 | const ( 10 | ByName = iota 11 | BySizeDesc 12 | 13 | NumSortOrderConventions 14 | ) 15 | 16 | type OrderStrategy interface { 17 | orderKeys(files map[string]*FileNode) []string 18 | } 19 | 20 | func GetSortOrderStrategy(sortOrder SortOrder) OrderStrategy { 21 | switch sortOrder { 22 | case ByName: 23 | return orderByNameStrategy{} 24 | case BySizeDesc: 25 | return orderBySizeDescStrategy{} 26 | } 27 | return orderByNameStrategy{} 28 | } 29 | 30 | type orderByNameStrategy struct{} 31 | 32 | func (orderByNameStrategy) orderKeys(files map[string]*FileNode) []string { 33 | var keys []string 34 | for key := range files { 35 | keys = append(keys, key) 36 | } 37 | 38 | sort.Strings(keys) 39 | 40 | return keys 41 | } 42 | 43 | type orderBySizeDescStrategy struct{} 44 | 45 | func (orderBySizeDescStrategy) orderKeys(files map[string]*FileNode) []string { 46 | var keys []string 47 | for key := range files { 48 | keys = append(keys, key) 49 | } 50 | 51 | sort.Slice(keys, func(i, j int) bool { 52 | ki, kj := keys[i], keys[j] 53 | ni, nj := files[ki], files[kj] 54 | if ni.GetSize() == nj.GetSize() { 55 | return ki < kj 56 | } 57 | return ni.GetSize() > nj.GetSize() 58 | }) 59 | 60 | return keys 61 | } 62 | -------------------------------------------------------------------------------- /dive/filetree/path_error.go: -------------------------------------------------------------------------------- 1 | package filetree 2 | 3 | import "fmt" 4 | 5 | const ( 6 | ActionAdd FileAction = iota 7 | ActionRemove 8 | ) 9 | 10 | type FileAction int 11 | 12 | func (fa FileAction) String() string { 13 | switch fa { 14 | case ActionAdd: 15 | return "add" 16 | case ActionRemove: 17 | return "remove" 18 | default: 19 | return "" 20 | } 21 | } 22 | 23 | type PathError struct { 24 | Path string 25 | Action FileAction 26 | Err error 27 | } 28 | 29 | func NewPathError(path string, action FileAction, err error) PathError { 30 | return PathError{ 31 | Path: path, 32 | Action: action, 33 | Err: err, 34 | } 35 | } 36 | 37 | func (pe PathError) String() string { 38 | return fmt.Sprintf("unable to %s '%s': %+v", pe.Action.String(), pe.Path, pe.Err) 39 | } 40 | -------------------------------------------------------------------------------- /dive/filetree/view_info.go: -------------------------------------------------------------------------------- 1 | package filetree 2 | 3 | // ViewInfo contains UI specific detail for a specific FileNode 4 | type ViewInfo struct { 5 | Collapsed bool 6 | Hidden bool 7 | } 8 | 9 | // NewViewInfo creates a default ViewInfo 10 | func NewViewInfo() (view *ViewInfo) { 11 | return &ViewInfo{ 12 | Collapsed: GlobalFileTreeCollapse, 13 | Hidden: false, 14 | } 15 | } 16 | 17 | // Copy duplicates a ViewInfo 18 | func (view *ViewInfo) Copy() (newView *ViewInfo) { 19 | newView = NewViewInfo() 20 | *newView = *view 21 | return newView 22 | } 23 | -------------------------------------------------------------------------------- /dive/get_image_resolver.go: -------------------------------------------------------------------------------- 1 | package dive 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/wagoodman/dive/dive/image" 8 | "github.com/wagoodman/dive/dive/image/docker" 9 | "github.com/wagoodman/dive/dive/image/podman" 10 | ) 11 | 12 | const ( 13 | SourceUnknown ImageSource = iota 14 | SourceDockerEngine 15 | SourcePodmanEngine 16 | SourceDockerArchive 17 | ) 18 | 19 | type ImageSource int 20 | 21 | var ImageSources = []string{SourceDockerEngine.String(), SourcePodmanEngine.String(), SourceDockerArchive.String()} 22 | 23 | func (r ImageSource) String() string { 24 | return [...]string{"unknown", "docker", "podman", "docker-archive"}[r] 25 | } 26 | 27 | func ParseImageSource(r string) ImageSource { 28 | switch r { 29 | case SourceDockerEngine.String(): 30 | return SourceDockerEngine 31 | case SourcePodmanEngine.String(): 32 | return SourcePodmanEngine 33 | case SourceDockerArchive.String(): 34 | return SourceDockerArchive 35 | case "docker-tar": 36 | return SourceDockerArchive 37 | default: 38 | return SourceUnknown 39 | } 40 | } 41 | 42 | func DeriveImageSource(image string) (ImageSource, string) { 43 | s := strings.SplitN(image, "://", 2) 44 | if len(s) < 2 { 45 | return SourceUnknown, "" 46 | } 47 | scheme, imageSource := s[0], s[1] 48 | 49 | switch scheme { 50 | case SourceDockerEngine.String(): 51 | return SourceDockerEngine, imageSource 52 | case SourcePodmanEngine.String(): 53 | return SourcePodmanEngine, imageSource 54 | case SourceDockerArchive.String(): 55 | return SourceDockerArchive, imageSource 56 | case "docker-tar": 57 | return SourceDockerArchive, imageSource 58 | } 59 | return SourceUnknown, "" 60 | } 61 | 62 | func GetImageResolver(r ImageSource) (image.Resolver, error) { 63 | switch r { 64 | case SourceDockerEngine: 65 | return docker.NewResolverFromEngine(), nil 66 | case SourcePodmanEngine: 67 | return podman.NewResolverFromEngine(), nil 68 | case SourceDockerArchive: 69 | return docker.NewResolverFromArchive(), nil 70 | } 71 | 72 | return nil, fmt.Errorf("unable to determine image resolver") 73 | } 74 | -------------------------------------------------------------------------------- /dive/image/analysis.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "context" 5 | "github.com/wagoodman/dive/dive/filetree" 6 | ) 7 | 8 | type Analysis struct { 9 | Image string 10 | Layers []*Layer 11 | RefTrees []*filetree.FileTree 12 | Efficiency float64 13 | SizeBytes uint64 14 | UserSizeByes uint64 // this is all bytes except for the base image 15 | WastedUserPercent float64 // = wasted-bytes/user-size-bytes 16 | WastedBytes uint64 17 | Inefficiencies filetree.EfficiencySlice 18 | } 19 | 20 | func Analyze(ctx context.Context, img *Image) (*Analysis, error) { 21 | efficiency, inefficiencies := filetree.Efficiency(img.Trees) 22 | var sizeBytes, userSizeBytes uint64 23 | 24 | for i, v := range img.Layers { 25 | sizeBytes += v.Size 26 | if i != 0 { 27 | userSizeBytes += v.Size 28 | } 29 | } 30 | 31 | var wastedBytes uint64 32 | for _, file := range inefficiencies { 33 | wastedBytes += uint64(file.CumulativeSize) 34 | } 35 | 36 | return &Analysis{ 37 | Image: img.Request, 38 | Layers: img.Layers, 39 | RefTrees: img.Trees, 40 | Efficiency: efficiency, 41 | UserSizeByes: userSizeBytes, 42 | SizeBytes: sizeBytes, 43 | WastedBytes: wastedBytes, 44 | WastedUserPercent: float64(wastedBytes) / float64(userSizeBytes), 45 | Inefficiencies: inefficiencies, 46 | }, nil 47 | } 48 | -------------------------------------------------------------------------------- /dive/image/docker/archive_resolver.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/wagoodman/dive/dive/image" 9 | ) 10 | 11 | type archiveResolver struct{} 12 | 13 | func NewResolverFromArchive() *archiveResolver { 14 | return &archiveResolver{} 15 | } 16 | 17 | // Name returns the name of the resolver to display to the user. 18 | func (r *archiveResolver) Name() string { 19 | return "docker-archive" 20 | } 21 | 22 | func (r *archiveResolver) Fetch(ctx context.Context, path string) (*image.Image, error) { 23 | reader, err := os.Open(path) 24 | if err != nil { 25 | return nil, err 26 | } 27 | defer reader.Close() 28 | 29 | img, err := NewImageArchive(reader) 30 | if err != nil { 31 | return nil, err 32 | } 33 | return img.ToImage(path) 34 | } 35 | 36 | func (r *archiveResolver) Build(ctx context.Context, args []string) (*image.Image, error) { 37 | return nil, fmt.Errorf("build option not supported for docker archive resolver") 38 | } 39 | 40 | func (r *archiveResolver) Extract(ctx context.Context, id string, l string, p string) error { 41 | return fmt.Errorf("not implemented") 42 | } 43 | -------------------------------------------------------------------------------- /dive/image/docker/build.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/scylladb/go-set/strset" 9 | "github.com/spf13/afero" 10 | ) 11 | 12 | const ( 13 | defaultDockerfileName = "Dockerfile" 14 | defaultContainerfileName = "Containerfile" 15 | ) 16 | 17 | func buildImageFromCli(fs afero.Fs, buildArgs []string) (string, error) { 18 | iidfile, err := afero.TempFile(fs, "", "dive.*.iid") 19 | if err != nil { 20 | return "", err 21 | } 22 | defer fs.Remove(iidfile.Name()) // nolint:errcheck 23 | defer iidfile.Close() 24 | 25 | var allArgs []string 26 | if isFileFlagsAreSet(buildArgs, "-f", "--file") { 27 | allArgs = append([]string{"--iidfile", iidfile.Name()}, buildArgs...) 28 | } else { 29 | containerFilePath, err := tryFindContainerfile(fs, buildArgs) 30 | if err != nil { 31 | return "", err 32 | } 33 | allArgs = append([]string{"--iidfile", iidfile.Name(), "-f", containerFilePath}, buildArgs...) 34 | } 35 | 36 | err = runDockerCmd("build", allArgs...) 37 | if err != nil { 38 | return "", err 39 | } 40 | 41 | imageId, err := afero.ReadFile(fs, iidfile.Name()) 42 | if err != nil { 43 | return "", err 44 | } 45 | 46 | return string(imageId), nil 47 | } 48 | 49 | // isFileFlagsAreSet Checks if specified flags are present in the argument list. 50 | func isFileFlagsAreSet(args []string, flags ...string) bool { 51 | flagSet := strset.New(flags...) 52 | for i, arg := range args { 53 | if flagSet.Has(arg) && i+1 < len(args) { 54 | return true 55 | } 56 | } 57 | return false 58 | } 59 | 60 | // tryFindContainerfile loops through provided build arguments and tries to find a Containerfile or a Dockerfile. 61 | func tryFindContainerfile(fs afero.Fs, buildArgs []string) (string, error) { 62 | // Look for a build context within the provided build arguments. 63 | // Test build arguments one by one to find a valid path containing default names of `Containerfile` or a `Dockerfile` (in that order). 64 | candidates := []string{ 65 | defaultContainerfileName, // Containerfile 66 | strings.ToLower(defaultContainerfileName), // containerfile 67 | defaultDockerfileName, // Dockerfile 68 | strings.ToLower(defaultDockerfileName), // dockerfile 69 | } 70 | 71 | for _, arg := range buildArgs { 72 | fileInfo, err := fs.Stat(arg) 73 | if err == nil && fileInfo.IsDir() { 74 | for _, candidate := range candidates { 75 | filePath := filepath.Join(arg, candidate) 76 | if exists, _ := afero.Exists(fs, filePath); exists { 77 | return filePath, nil 78 | } 79 | } 80 | } 81 | } 82 | 83 | return "", fmt.Errorf("could not find Containerfile or Dockerfile\n") 84 | } 85 | -------------------------------------------------------------------------------- /dive/image/docker/cli.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "fmt" 5 | "github.com/wagoodman/dive/internal/log" 6 | "github.com/wagoodman/dive/internal/utils" 7 | "os" 8 | "os/exec" 9 | "strings" 10 | ) 11 | 12 | // runDockerCmd runs a given Docker command in the current tty 13 | func runDockerCmd(cmdStr string, args ...string) error { 14 | 15 | if !isDockerClientBinaryAvailable() { 16 | return fmt.Errorf("cannot find docker client executable") 17 | } 18 | 19 | allArgs := utils.CleanArgs(append([]string{cmdStr}, args...)) 20 | 21 | fullCmd := strings.Join(append([]string{"docker"}, allArgs...), " ") 22 | log.WithFields("cmd", fullCmd).Trace("executing") 23 | 24 | cmd := exec.Command("docker", allArgs...) 25 | cmd.Env = os.Environ() 26 | 27 | cmd.Stdout = os.Stdout 28 | cmd.Stderr = os.Stderr 29 | cmd.Stdin = os.Stdin 30 | 31 | return cmd.Run() 32 | } 33 | 34 | func isDockerClientBinaryAvailable() bool { 35 | _, err := exec.LookPath("docker") 36 | return err == nil 37 | } 38 | -------------------------------------------------------------------------------- /dive/image/docker/config.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | type config struct { 9 | History []historyEntry `json:"history"` 10 | RootFs rootFs `json:"rootfs"` 11 | } 12 | 13 | type rootFs struct { 14 | Type string `json:"type"` 15 | DiffIds []string `json:"diff_ids"` 16 | } 17 | 18 | type historyEntry struct { 19 | ID string 20 | Size uint64 21 | Created string `json:"created"` 22 | Author string `json:"author"` 23 | CreatedBy string `json:"created_by"` 24 | EmptyLayer bool `json:"empty_layer"` 25 | } 26 | 27 | func newConfig(configBytes []byte) config { 28 | var imageConfig config 29 | err := json.Unmarshal(configBytes, &imageConfig) 30 | if err != nil { 31 | panic(fmt.Errorf("failed to unmarshal docker config: %w", err)) 32 | } 33 | 34 | layerIdx := 0 35 | for idx := range imageConfig.History { 36 | if imageConfig.History[idx].EmptyLayer { 37 | imageConfig.History[idx].ID = "" 38 | } else { 39 | imageConfig.History[idx].ID = imageConfig.RootFs.DiffIds[layerIdx] 40 | layerIdx++ 41 | } 42 | } 43 | 44 | return imageConfig 45 | } 46 | 47 | func isConfig(configBytes []byte) bool { 48 | var imageConfig config 49 | err := json.Unmarshal(configBytes, &imageConfig) 50 | if err != nil { 51 | return false 52 | } 53 | return imageConfig.RootFs.Type == "layers" 54 | } 55 | -------------------------------------------------------------------------------- /dive/image/docker/docker_host_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package docker 4 | 5 | const ( 6 | defaultDockerHost = "unix:///var/run/docker.sock" 7 | ) 8 | -------------------------------------------------------------------------------- /dive/image/docker/docker_host_windows.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | const ( 4 | defaultDockerHost = "npipe:////.pipe/docker_engine" 5 | ) 6 | -------------------------------------------------------------------------------- /dive/image/docker/image_archive_analysis_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func Test_Analysis(t *testing.T) { 8 | 9 | table := map[string]struct { 10 | efficiency float64 11 | sizeBytes uint64 12 | userSizeBytes uint64 13 | wastedBytes uint64 14 | wastedPercent float64 15 | path string 16 | }{ 17 | "docker-image": {0.9844212134184309, 1220598, 66237, 32025, 0.4834911001404049, "../../../.data/test-docker-image.tar"}, 18 | } 19 | 20 | for name, test := range table { 21 | result := TestAnalysisFromArchive(t, test.path) 22 | 23 | if result.SizeBytes != test.sizeBytes { 24 | t.Errorf("%s.%s: expected sizeBytes=%v, got %v", t.Name(), name, test.sizeBytes, result.SizeBytes) 25 | } 26 | 27 | if result.UserSizeByes != test.userSizeBytes { 28 | t.Errorf("%s.%s: expected userSizeBytes=%v, got %v", t.Name(), name, test.userSizeBytes, result.UserSizeByes) 29 | } 30 | 31 | if result.WastedBytes != test.wastedBytes { 32 | t.Errorf("%s.%s: expected wasterBytes=%v, got %v", t.Name(), name, test.wastedBytes, result.WastedBytes) 33 | } 34 | 35 | if result.WastedUserPercent != test.wastedPercent { 36 | t.Errorf("%s.%s: expected wastedPercent=%v, got %v", t.Name(), name, test.wastedPercent, result.WastedUserPercent) 37 | } 38 | 39 | if result.Efficiency != test.efficiency { 40 | t.Errorf("%s.%s: expected efficiency=%v, got %v", t.Name(), name, test.efficiency, result.Efficiency) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /dive/image/docker/layer.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/wagoodman/dive/dive/filetree" 7 | "github.com/wagoodman/dive/dive/image" 8 | ) 9 | 10 | // Layer represents a Docker image layer and metadata 11 | type layer struct { 12 | history historyEntry 13 | index int 14 | tree *filetree.FileTree 15 | } 16 | 17 | // String represents a layer in a columnar format. 18 | func (l *layer) ToLayer() *image.Layer { 19 | id := strings.Split(l.tree.Name, "/")[0] 20 | return &image.Layer{ 21 | Id: id, 22 | Index: l.index, 23 | Command: strings.TrimPrefix(l.history.CreatedBy, "/bin/sh -c "), 24 | Size: l.history.Size, 25 | Tree: l.tree, 26 | // todo: query docker api for tags 27 | Names: []string{"(unavailable)"}, 28 | Digest: l.history.ID, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /dive/image/docker/manifest.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | type manifest struct { 9 | ConfigPath string `json:"Config"` 10 | RepoTags []string `json:"RepoTags"` 11 | LayerTarPaths []string `json:"Layers"` 12 | } 13 | 14 | func newManifest(manifestBytes []byte) manifest { 15 | var manifest []manifest 16 | err := json.Unmarshal(manifestBytes, &manifest) 17 | if err != nil { 18 | panic(fmt.Errorf("failed to unmarshal manifest: %w", err)) 19 | } 20 | return manifest[0] 21 | } 22 | -------------------------------------------------------------------------------- /dive/image/docker/testing.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "github.com/stretchr/testify/require" 5 | "golang.org/x/net/context" 6 | "os" 7 | "testing" 8 | 9 | "github.com/wagoodman/dive/dive/image" 10 | ) 11 | 12 | func TestLoadArchive(t testing.TB, tarPath string) (*ImageArchive, error) { 13 | t.Helper() 14 | f, err := os.Open(tarPath) 15 | if err != nil { 16 | return nil, err 17 | } 18 | defer f.Close() 19 | 20 | return NewImageArchive(f) 21 | } 22 | 23 | func TestAnalysisFromArchive(t testing.TB, path string) *image.Analysis { 24 | t.Helper() 25 | archive, err := TestLoadArchive(t, path) 26 | require.NoError(t, err, "unable to load archive") 27 | 28 | img, err := archive.ToImage(path) 29 | require.NoError(t, err, "unable to convert archive to image") 30 | 31 | result, err := image.Analyze(context.Background(), img) 32 | require.NoError(t, err, "unable to analyze image") 33 | return result 34 | } 35 | -------------------------------------------------------------------------------- /dive/image/image.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "github.com/wagoodman/dive/dive/filetree" 5 | ) 6 | 7 | type Image struct { 8 | Request string 9 | Trees []*filetree.FileTree 10 | Layers []*Layer 11 | } 12 | -------------------------------------------------------------------------------- /dive/image/layer.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/dustin/go-humanize" 8 | 9 | "github.com/wagoodman/dive/dive/filetree" 10 | ) 11 | 12 | const ( 13 | LayerFormat = "%7s %s" 14 | ) 15 | 16 | type Layer struct { 17 | Id string 18 | Index int 19 | Command string 20 | Size uint64 21 | Tree *filetree.FileTree 22 | Names []string 23 | Digest string 24 | } 25 | 26 | func (l *Layer) ShortId() string { 27 | rangeBound := 15 28 | id := l.Id 29 | if length := len(id); length < 15 { 30 | rangeBound = length 31 | } 32 | id = id[0:rangeBound] 33 | 34 | return id 35 | } 36 | 37 | func (l *Layer) commandPreview() string { 38 | // Layers using heredocs can be multiple lines; rendering relies on 39 | // Layer.String to be a single line. 40 | return strings.Replace(l.Command, "\n", "↵", -1) 41 | } 42 | 43 | func (l *Layer) String() string { 44 | if l.Index == 0 { 45 | return fmt.Sprintf(LayerFormat, 46 | humanize.Bytes(l.Size), 47 | "FROM "+l.ShortId()) 48 | } 49 | return fmt.Sprintf(LayerFormat, 50 | humanize.Bytes(l.Size), 51 | l.commandPreview()) 52 | } 53 | -------------------------------------------------------------------------------- /dive/image/podman/build.go: -------------------------------------------------------------------------------- 1 | //go:build linux || darwin 2 | 3 | package podman 4 | 5 | import ( 6 | "os" 7 | ) 8 | 9 | func buildImageFromCli(buildArgs []string) (string, error) { 10 | iidfile, err := os.CreateTemp("/tmp", "dive.*.iid") 11 | if err != nil { 12 | return "", err 13 | } 14 | defer os.Remove(iidfile.Name()) 15 | defer iidfile.Close() 16 | 17 | allArgs := append([]string{"--iidfile", iidfile.Name()}, buildArgs...) 18 | err = runPodmanCmd("build", allArgs...) 19 | if err != nil { 20 | return "", err 21 | } 22 | 23 | imageId, err := os.ReadFile(iidfile.Name()) 24 | if err != nil { 25 | return "", err 26 | } 27 | 28 | return string(imageId), nil 29 | } 30 | -------------------------------------------------------------------------------- /dive/image/podman/cli.go: -------------------------------------------------------------------------------- 1 | //go:build linux || darwin 2 | 3 | package podman 4 | 5 | import ( 6 | "fmt" 7 | "github.com/wagoodman/dive/internal/log" 8 | "github.com/wagoodman/dive/internal/utils" 9 | "io" 10 | "os" 11 | "os/exec" 12 | "strings" 13 | ) 14 | 15 | // runPodmanCmd runs a given Podman command in the current tty 16 | func runPodmanCmd(cmdStr string, args ...string) error { 17 | if !isPodmanClientBinaryAvailable() { 18 | return fmt.Errorf("cannot find podman client executable") 19 | } 20 | 21 | allArgs := utils.CleanArgs(append([]string{cmdStr}, args...)) 22 | 23 | fullCmd := strings.Join(append([]string{"docker"}, allArgs...), " ") 24 | log.WithFields("cmd", fullCmd).Trace("executing") 25 | 26 | cmd := exec.Command("podman", allArgs...) 27 | cmd.Env = os.Environ() 28 | 29 | cmd.Stdout = os.Stdout 30 | cmd.Stderr = os.Stderr 31 | cmd.Stdin = os.Stdin 32 | 33 | return cmd.Run() 34 | } 35 | 36 | func streamPodmanCmd(args ...string) (error, io.Reader) { 37 | if !isPodmanClientBinaryAvailable() { 38 | return fmt.Errorf("cannot find podman client executable"), nil 39 | } 40 | 41 | allArgs := utils.CleanArgs(args) 42 | fullCmd := strings.Join(append([]string{"docker"}, allArgs...), " ") 43 | log.WithFields("cmd", fullCmd).Trace("executing (streaming)") 44 | 45 | cmd := exec.Command("podman", allArgs...) 46 | cmd.Env = os.Environ() 47 | 48 | reader, writer, err := os.Pipe() 49 | if err != nil { 50 | return err, nil 51 | } 52 | defer writer.Close() 53 | 54 | cmd.Stdout = writer 55 | cmd.Stderr = os.Stderr 56 | 57 | return cmd.Start(), reader 58 | } 59 | 60 | func isPodmanClientBinaryAvailable() bool { 61 | _, err := exec.LookPath("podman") 62 | return err == nil 63 | } 64 | -------------------------------------------------------------------------------- /dive/image/podman/resolver.go: -------------------------------------------------------------------------------- 1 | //go:build linux || darwin 2 | 3 | package podman 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "io" 9 | 10 | "github.com/wagoodman/dive/dive/image" 11 | "github.com/wagoodman/dive/dive/image/docker" 12 | ) 13 | 14 | type resolver struct{} 15 | 16 | func NewResolverFromEngine() *resolver { 17 | return &resolver{} 18 | } 19 | 20 | // Name returns the name of the resolver to display to the user. 21 | func (r *resolver) Name() string { 22 | return "podman" 23 | } 24 | 25 | func (r *resolver) Build(ctx context.Context, args []string) (*image.Image, error) { 26 | id, err := buildImageFromCli(args) 27 | if err != nil { 28 | return nil, err 29 | } 30 | return r.Fetch(ctx, id) 31 | } 32 | 33 | func (r *resolver) Fetch(ctx context.Context, id string) (*image.Image, error) { 34 | // todo: add podman fetch attempt via varlink first... 35 | 36 | img, err := r.resolveFromDockerArchive(id) 37 | if err == nil { 38 | return img, err 39 | } 40 | 41 | return nil, fmt.Errorf("unable to resolve image %q: %+v", id, err) 42 | } 43 | 44 | func (r *resolver) Extract(ctx context.Context, id string, l string, p string) error { 45 | // todo: add podman fetch attempt via varlink first... 46 | 47 | err, reader := streamPodmanCmd("image", "save", id) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | if err := docker.ExtractFromImage(io.NopCloser(reader), l, p); err == nil { 53 | return nil 54 | } 55 | 56 | return fmt.Errorf("unable to extract from image %q: %+v", id, err) 57 | } 58 | 59 | func (r *resolver) resolveFromDockerArchive(id string) (*image.Image, error) { 60 | err, reader := streamPodmanCmd("image", "save", id) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | img, err := docker.NewImageArchive(io.NopCloser(reader)) 66 | if err != nil { 67 | return nil, err 68 | } 69 | return img.ToImage(id) 70 | } 71 | -------------------------------------------------------------------------------- /dive/image/podman/resolver_unsupported.go: -------------------------------------------------------------------------------- 1 | //go:build !linux && !darwin 2 | // +build !linux,!darwin 3 | 4 | package podman 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | 10 | "github.com/wagoodman/dive/dive/image" 11 | ) 12 | 13 | type resolver struct{} 14 | 15 | func NewResolverFromEngine() *resolver { 16 | return &resolver{} 17 | } 18 | 19 | // Name returns the name of the resolver to display to the user. 20 | func (r *resolver) Name() string { 21 | return "podman" 22 | } 23 | func (r *resolver) Build(ctx context.Context, args []string) (*image.Image, error) { 24 | return nil, fmt.Errorf("unsupported platform") 25 | } 26 | 27 | func (r *resolver) Fetch(ctx context.Context, id string) (*image.Image, error) { 28 | return nil, fmt.Errorf("unsupported platform") 29 | } 30 | 31 | func (r *resolver) Extract(ctx context.Context, id string, l string, p string) error { 32 | return fmt.Errorf("unsupported platform") 33 | } 34 | -------------------------------------------------------------------------------- /dive/image/resolver.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import "golang.org/x/net/context" 4 | 5 | type Resolver interface { 6 | Name() string 7 | Fetch(ctx context.Context, id string) (*Image, error) 8 | Build(ctx context.Context, options []string) (*Image, error) 9 | ContentReader 10 | } 11 | 12 | type ContentReader interface { 13 | Extract(ctx context.Context, id string, layer string, path string) error 14 | } 15 | -------------------------------------------------------------------------------- /internal/bus/bus.go: -------------------------------------------------------------------------------- 1 | package bus 2 | 3 | import "github.com/wagoodman/go-partybus" 4 | 5 | var publisher partybus.Publisher 6 | 7 | func Set(p partybus.Publisher) { 8 | publisher = p 9 | } 10 | 11 | func Get() partybus.Publisher { 12 | return publisher 13 | } 14 | 15 | func Publish(e partybus.Event) { 16 | if publisher != nil { 17 | publisher.Publish(e) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /internal/bus/event/event.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "github.com/wagoodman/go-partybus" 5 | ) 6 | 7 | const ( 8 | typePrefix = "dive-cli" 9 | 10 | // TaskStarted encompasses all events that are related to the analysis of a docker image (build, fetch, analyze) 11 | TaskStarted partybus.EventType = typePrefix + "-task-started" 12 | 13 | // ExploreAnalysis is a partybus event that occurs when an analysis result is ready for presentation to stdout 14 | ExploreAnalysis partybus.EventType = typePrefix + "-analysis" 15 | 16 | // Report is a partybus event that occurs when an analysis result is ready for final presentation to stdout 17 | Report partybus.EventType = typePrefix + "-report" 18 | 19 | // Notification is a partybus event that occurs when auxiliary information is ready for presentation to stderr 20 | Notification partybus.EventType = typePrefix + "-notification" 21 | ) 22 | -------------------------------------------------------------------------------- /internal/bus/event/parser/parsers.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "github.com/wagoodman/dive/dive/image" 6 | "github.com/wagoodman/dive/internal/bus/event" 7 | "github.com/wagoodman/dive/internal/bus/event/payload" 8 | "github.com/wagoodman/go-partybus" 9 | "github.com/wagoodman/go-progress" 10 | ) 11 | 12 | type ErrBadPayload struct { 13 | Type partybus.EventType 14 | Field string 15 | Value interface{} 16 | } 17 | 18 | func (e *ErrBadPayload) Error() string { 19 | return fmt.Sprintf("event='%s' has bad event payload field=%q: %q", string(e.Type), e.Field, e.Value) 20 | } 21 | 22 | func newPayloadErr(t partybus.EventType, field string, value interface{}) error { 23 | return &ErrBadPayload{ 24 | Type: t, 25 | Field: field, 26 | Value: value, 27 | } 28 | } 29 | 30 | func checkEventType(actual, expected partybus.EventType) error { 31 | if actual != expected { 32 | return newPayloadErr(expected, "Type", actual) 33 | } 34 | return nil 35 | } 36 | 37 | func ParseTaskStarted(e partybus.Event) (progress.StagedProgressable, *payload.GenericTask, error) { 38 | if err := checkEventType(e.Type, event.TaskStarted); err != nil { 39 | return nil, nil, err 40 | } 41 | 42 | var mon progress.StagedProgressable 43 | 44 | source, ok := e.Source.(payload.GenericTask) 45 | if !ok { 46 | return nil, nil, newPayloadErr(e.Type, "Source", e.Source) 47 | } 48 | 49 | mon, ok = e.Value.(progress.StagedProgressable) 50 | if !ok { 51 | mon = nil 52 | } 53 | 54 | return mon, &source, nil 55 | } 56 | 57 | func ParseExploreAnalysis(e partybus.Event) (image.Analysis, image.ContentReader, error) { 58 | if err := checkEventType(e.Type, event.ExploreAnalysis); err != nil { 59 | return image.Analysis{}, nil, err 60 | } 61 | 62 | ex, ok := e.Value.(payload.Explore) 63 | if !ok { 64 | return image.Analysis{}, nil, newPayloadErr(e.Type, "Value", e.Value) 65 | } 66 | 67 | return ex.Analysis, ex.Content, nil 68 | } 69 | 70 | func ParseReport(e partybus.Event) (string, string, error) { 71 | if err := checkEventType(e.Type, event.Report); err != nil { 72 | return "", "", err 73 | } 74 | 75 | context, ok := e.Source.(string) 76 | if !ok { 77 | // this is optional 78 | context = "" 79 | } 80 | 81 | report, ok := e.Value.(string) 82 | if !ok { 83 | return "", "", newPayloadErr(e.Type, "Value", e.Value) 84 | } 85 | 86 | return context, report, nil 87 | } 88 | 89 | func ParseNotification(e partybus.Event) (string, string, error) { 90 | if err := checkEventType(e.Type, event.Notification); err != nil { 91 | return "", "", err 92 | } 93 | 94 | context, ok := e.Source.(string) 95 | if !ok { 96 | // this is optional 97 | context = "" 98 | } 99 | 100 | notification, ok := e.Value.(string) 101 | if !ok { 102 | return "", "", newPayloadErr(e.Type, "Value", e.Value) 103 | } 104 | 105 | return context, notification, nil 106 | } 107 | -------------------------------------------------------------------------------- /internal/bus/event/payload/explore.go: -------------------------------------------------------------------------------- 1 | package payload 2 | 3 | import "github.com/wagoodman/dive/dive/image" 4 | 5 | type Explore struct { 6 | Analysis image.Analysis 7 | Content image.ContentReader 8 | } 9 | -------------------------------------------------------------------------------- /internal/bus/event/payload/generic.go: -------------------------------------------------------------------------------- 1 | package payload 2 | 3 | import ( 4 | "context" 5 | "github.com/wagoodman/go-progress" 6 | ) 7 | 8 | type genericProgressKey struct{} 9 | 10 | func SetGenericProgressToContext(ctx context.Context, mon *GenericProgress) context.Context { 11 | return context.WithValue(ctx, genericProgressKey{}, mon) 12 | } 13 | 14 | func GetGenericProgressFromContext(ctx context.Context) *GenericProgress { 15 | mon, ok := ctx.Value(genericProgressKey{}).(*GenericProgress) 16 | if !ok { 17 | return nil 18 | } 19 | return mon 20 | } 21 | 22 | type GenericTask struct { 23 | // required fields 24 | 25 | Title Title 26 | 27 | // optional format fields 28 | 29 | HideOnSuccess bool 30 | HideStageOnSuccess bool 31 | 32 | // optional fields 33 | 34 | ID string 35 | ParentID string 36 | Context string 37 | } 38 | 39 | type GenericProgress struct { 40 | *progress.AtomicStage 41 | *progress.Manual 42 | } 43 | 44 | type Title struct { 45 | Default string 46 | WhileRunning string 47 | OnSuccess string 48 | } 49 | -------------------------------------------------------------------------------- /internal/bus/helpers.go: -------------------------------------------------------------------------------- 1 | package bus 2 | 3 | import ( 4 | "github.com/wagoodman/dive/dive/image" 5 | "github.com/wagoodman/dive/internal/bus/event" 6 | "github.com/wagoodman/dive/internal/bus/event/payload" 7 | "github.com/wagoodman/go-partybus" 8 | "github.com/wagoodman/go-progress" 9 | ) 10 | 11 | func Report(report string) { 12 | if len(report) == 0 { 13 | return 14 | } 15 | Publish(partybus.Event{ 16 | Type: event.Report, 17 | Value: report, 18 | }) 19 | } 20 | 21 | func Notify(message string) { 22 | Publish(partybus.Event{ 23 | Type: event.Notification, 24 | Value: message, 25 | }) 26 | } 27 | 28 | func StartTask(info payload.GenericTask) *payload.GenericProgress { 29 | t := &payload.GenericProgress{ 30 | AtomicStage: progress.NewAtomicStage(""), 31 | Manual: progress.NewManual(-1), 32 | } 33 | 34 | Publish(partybus.Event{ 35 | Type: event.TaskStarted, 36 | Source: info, 37 | Value: progress.StagedProgressable(t), 38 | }) 39 | 40 | return t 41 | } 42 | 43 | func StartSizedTask(info payload.GenericTask, size int64, initialStage string) *payload.GenericProgress { 44 | t := &payload.GenericProgress{ 45 | AtomicStage: progress.NewAtomicStage(initialStage), 46 | Manual: progress.NewManual(size), 47 | } 48 | 49 | Publish(partybus.Event{ 50 | Type: event.TaskStarted, 51 | Source: info, 52 | Value: progress.StagedProgressable(t), 53 | }) 54 | 55 | return t 56 | } 57 | 58 | func ExploreAnalysis(analysis image.Analysis, reader image.ContentReader) { 59 | Publish(partybus.Event{ 60 | Type: event.ExploreAnalysis, 61 | Value: payload.Explore{Analysis: analysis, Content: reader}, 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /internal/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "github.com/anchore/go-logger" 5 | "github.com/anchore/go-logger/adapter/discard" 6 | ) 7 | 8 | // log is the singleton used to facilitate logging internally within 9 | var log = discard.New() 10 | 11 | // Set replaces the default logger with the provided logger. 12 | func Set(l logger.Logger) { 13 | log = l 14 | } 15 | 16 | // Get returns the current logger instance. 17 | func Get() logger.Logger { 18 | return log 19 | } 20 | 21 | // Errorf takes a formatted template string and template arguments for the error logging level. 22 | func Errorf(format string, args ...interface{}) { 23 | log.Errorf(format, args...) 24 | } 25 | 26 | // Error logs the given arguments at the error logging level. 27 | func Error(args ...interface{}) { 28 | log.Error(args...) 29 | } 30 | 31 | // Warnf takes a formatted template string and template arguments for the warning logging level. 32 | func Warnf(format string, args ...interface{}) { 33 | log.Warnf(format, args...) 34 | } 35 | 36 | // Warn logs the given arguments at the warning logging level. 37 | func Warn(args ...interface{}) { 38 | log.Warn(args...) 39 | } 40 | 41 | // Infof takes a formatted template string and template arguments for the info logging level. 42 | func Infof(format string, args ...interface{}) { 43 | log.Infof(format, args...) 44 | } 45 | 46 | // Info logs the given arguments at the info logging level. 47 | func Info(args ...interface{}) { 48 | log.Info(args...) 49 | } 50 | 51 | // Debugf takes a formatted template string and template arguments for the debug logging level. 52 | func Debugf(format string, args ...interface{}) { 53 | log.Debugf(format, args...) 54 | } 55 | 56 | // Debug logs the given arguments at the debug logging level. 57 | func Debug(args ...interface{}) { 58 | log.Debug(args...) 59 | } 60 | 61 | // Tracef takes a formatted template string and template arguments for the trace logging level. 62 | func Tracef(format string, args ...interface{}) { 63 | log.Tracef(format, args...) 64 | } 65 | 66 | // Trace logs the given arguments at the trace logging level. 67 | func Trace(args ...interface{}) { 68 | log.Trace(args...) 69 | } 70 | 71 | // WithFields returns a message logger with multiple key-value fields. 72 | func WithFields(fields ...interface{}) logger.MessageLogger { 73 | return log.WithFields(fields...) 74 | } 75 | 76 | // Nested returns a new logger with hard coded key-value pairs 77 | func Nested(fields ...interface{}) logger.Logger { 78 | return log.Nested(fields...) 79 | } 80 | -------------------------------------------------------------------------------- /internal/utils/format.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // CleanArgs trims the whitespace from the given set of strings. 8 | func CleanArgs(s []string) []string { 9 | var r []string 10 | for _, str := range s { 11 | if str != "" { 12 | r = append(r, strings.Trim(str, " ")) 13 | } 14 | } 15 | return r 16 | } 17 | -------------------------------------------------------------------------------- /internal/utils/view.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "github.com/awesome-gocui/gocui" 6 | "github.com/wagoodman/dive/internal/log" 7 | ) 8 | 9 | // IsNewView determines if a view has already been created based on the set of errors given (a bit hokie) 10 | func IsNewView(errs ...error) bool { 11 | for _, err := range errs { 12 | if err == nil { 13 | return false 14 | } 15 | if !errors.Is(err, gocui.ErrUnknownView) { 16 | log.WithFields("error", err).Error("IsNewView() unexpected error") 17 | return true 18 | } 19 | } 20 | return true 21 | } 22 | --------------------------------------------------------------------------------