├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── codeql.yml │ ├── lint_and_test.yml │ └── release.yml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yaml ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── Dockerfile.ubuntu ├── LICENSE ├── Makefile ├── README.md ├── examples ├── dockerfiles │ ├── Dockerfile │ └── Dockerfile.large └── images │ ├── Dockerfile-large.svg │ ├── Dockerfile-layers.svg │ └── Dockerfile-legend.svg ├── go.mod ├── go.sum ├── internal ├── cmd │ ├── enum.go │ ├── integration_test.go │ ├── root.go │ ├── root_test.go │ ├── testdata │ │ └── Dockerfile.golden.dot │ └── version.go └── dockerfile2dot │ ├── build.go │ ├── build_test.go │ ├── convert.go │ ├── convert_test.go │ ├── load.go │ ├── load_test.go │ └── structs.go ├── main.go ├── main_test.go └── renovate.json /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: docker 4 | directory: / 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: github-actions 8 | directory: / 9 | schedule: 10 | interval: daily 11 | - package-ecosystem: gomod 12 | directory: / 13 | schedule: 14 | interval: daily 15 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | push: 7 | branches: [main] 8 | 9 | jobs: 10 | lint-and-test: 11 | uses: ./.github/workflows/lint_and_test.yml 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: ["main"] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: ["main"] 20 | schedule: 21 | - cron: "0 0 * * 1" 22 | 23 | permissions: # added using https://github.com/step-security/secure-workflows 24 | contents: read 25 | 26 | jobs: 27 | analyze: 28 | name: Analyze 29 | runs-on: ubuntu-latest 30 | permissions: 31 | actions: read 32 | contents: read 33 | security-events: write 34 | 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | language: ["go"] 39 | # CodeQL supports [ $supported-codeql-languages ] 40 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 41 | 42 | steps: 43 | - name: Harden Runner 44 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 45 | with: 46 | egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs 47 | 48 | - name: Checkout repository 49 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 50 | 51 | # Initializes the CodeQL tools for scanning. 52 | - name: Initialize CodeQL 53 | uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 54 | with: 55 | languages: ${{ matrix.language }} 56 | # If you wish to specify custom queries, you can do so here or in a config file. 57 | # By default, queries listed here will override any specified in a config file. 58 | # Prefix the list here with "+" to use these queries and those in the config file. 59 | 60 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 61 | # queries: security-extended,security-and-quality 62 | 63 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 64 | # If this step fails, then you should remove it and run the build manually (see below) 65 | - name: Autobuild 66 | uses: github/codeql-action/autobuild@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 67 | 68 | # ℹ️ Command-line programs to run using the OS shell. 69 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 70 | 71 | # If the Autobuild fails above, remove it and uncomment the following three lines. 72 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 73 | 74 | # - run: | 75 | # echo "Run, Build Application using script" 76 | # ./location_of_script_within_repo/buildscript.sh 77 | 78 | - name: Perform CodeQL Analysis 79 | uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 80 | with: 81 | category: "/language:${{matrix.language}}" 82 | -------------------------------------------------------------------------------- /.github/workflows/lint_and_test.yml: -------------------------------------------------------------------------------- 1 | name: Lint & Test 2 | 3 | on: 4 | workflow_call: 5 | 6 | permissions: # added using https://github.com/step-security/secure-workflows 7 | contents: read 8 | 9 | jobs: 10 | lint: 11 | permissions: 12 | contents: read # for actions/checkout to fetch code 13 | pull-requests: read # for golangci/golangci-lint-action to fetch pull requests 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Harden Runner 17 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 18 | with: 19 | egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs 20 | 21 | - name: Check out the code 22 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 23 | 24 | - name: Set up Go 25 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 26 | with: 27 | go-version-file: 'go.mod' 28 | 29 | - name: Run the linters 30 | uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 31 | 32 | test: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - name: Harden Runner 36 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 37 | with: 38 | egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs 39 | 40 | - name: Check out the code 41 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 42 | 43 | - name: Set up Go 44 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 45 | with: 46 | go-version-file: 'go.mod' 47 | 48 | - name: Install graphviz 49 | run: sudo apt install --no-install-recommends -y graphviz 50 | 51 | - name: Run the tests and generate the coverage profile 52 | run: go test ./... --coverprofile=cover.out 53 | 54 | - name: Install gocovergate 55 | run: go install github.com/patrickhoefler/gocovergate@latest 56 | 57 | - name: Check the code coverage 58 | run: gocovergate 59 | 60 | build-native: 61 | strategy: 62 | matrix: 63 | os: [macos-latest, ubuntu-latest, windows-latest] 64 | runs-on: ${{ matrix.os }} 65 | needs: [lint, test] 66 | steps: 67 | - name: Harden Runner 68 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 69 | with: 70 | egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs 71 | 72 | - name: Check out the code 73 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 74 | 75 | - name: Set up Go 76 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 77 | with: 78 | go-version-file: 'go.mod' 79 | 80 | - name: Build 81 | run: make build 82 | 83 | - name: '[macOS] Install graphviz' 84 | if: runner.os == 'macOS' 85 | run: brew install graphviz 86 | 87 | - name: '[Ubuntu] Install graphviz' 88 | if: runner.os == 'Linux' 89 | run: sudo apt install --no-install-recommends -y graphviz 90 | 91 | - name: '[Windows] Install graphviz' 92 | if: runner.os == 'Windows' 93 | run: choco install graphviz --no-progress 94 | 95 | # Smoke tests 96 | - name: Get the version 97 | run: ./dockerfilegraph --version 98 | 99 | - name: Run the binary with flags 100 | run: ./dockerfilegraph --filename examples/dockerfiles/Dockerfile --legend --output png --dpi 200 101 | 102 | build-images: 103 | runs-on: ubuntu-latest 104 | needs: [lint, test] 105 | steps: 106 | - name: Harden Runner 107 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 108 | with: 109 | egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs 110 | 111 | - name: Check out the code 112 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 113 | 114 | - name: Set up Go 115 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 116 | with: 117 | go-version-file: 'go.mod' 118 | 119 | - name: Build binaries and Docker image with GoReleaser 120 | uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 121 | with: 122 | version: '~> v2' 123 | args: release --snapshot 124 | 125 | # Smoke tests 126 | - name: Get the version 127 | run: | 128 | docker run \ 129 | ghcr.io/patrickhoefler/dockerfilegraph:latest \ 130 | --version 131 | 132 | - name: Get the version on Ubuntu 133 | run: | 134 | docker run \ 135 | ghcr.io/patrickhoefler/dockerfilegraph:ubuntu \ 136 | --version 137 | 138 | - name: Run the Docker image with flags 139 | run: | 140 | cd examples/dockerfiles 141 | docker run \ 142 | --user "$(id -u):$(id -g)" \ 143 | --workdir /workspace \ 144 | --volume "$(pwd)":/workspace \ 145 | ghcr.io/patrickhoefler/dockerfilegraph:latest \ 146 | --legend \ 147 | --output png \ 148 | --dpi 200 149 | 150 | - name: Run the Ubuntu-based Docker image with flags 151 | run: | 152 | cd examples/dockerfiles 153 | docker run \ 154 | --user "$(id -u):$(id -g)" \ 155 | --workdir /workspace \ 156 | --volume "$(pwd)":/workspace \ 157 | ghcr.io/patrickhoefler/dockerfilegraph:ubuntu \ 158 | --legend \ 159 | --output png \ 160 | --dpi 200 161 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | lint-and-test: 10 | uses: ./.github/workflows/lint_and_test.yml 11 | 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | needs: [lint-and-test] 15 | steps: 16 | - name: Harden Runner 17 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 18 | with: 19 | egress-policy: audit 20 | 21 | - name: Checkout 22 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 23 | with: 24 | fetch-depth: 0 25 | 26 | - name: Set up Go 27 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 28 | with: 29 | go-version-file: 'go.mod' 30 | 31 | - name: Login to GitHub Container Registry 32 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 33 | with: 34 | registry: ghcr.io 35 | username: ${{ github.actor }} 36 | password: ${{ secrets.GITHUB_TOKEN }} 37 | 38 | - name: Run GoReleaser 39 | uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 40 | with: 41 | version: '~> v2' 42 | args: release --clean 43 | env: 44 | # needed for updating https://github.com/patrickhoefler/homebrew-tap 45 | GITHUB_TOKEN: ${{ secrets.GH_PAT }} 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Visual Studio Code 2 | .vscode 3 | 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, build with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # goreleaser 18 | dist 19 | 20 | # go build 21 | dockerfilegraph 22 | 23 | # dockerfilegraph 24 | Dockerfile.* 25 | !/Dockerfile.ubuntu 26 | !Dockerfile.golden.dot 27 | !examples/dockerfiles/Dockerfile.large 28 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | linters: 4 | enable: 5 | - gocyclo 6 | - ineffassign 7 | - lll 8 | - misspell 9 | - staticcheck 10 | - revive 11 | settings: 12 | gocyclo: 13 | min-complexity: 15 14 | exclusions: 15 | presets: 16 | - std-error-handling 17 | 18 | formatters: 19 | enable: 20 | - gofmt 21 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | builds: 4 | - env: 5 | - CGO_ENABLED=0 6 | goarch: 7 | - amd64 8 | - arm64 9 | ldflags: 10 | - -s 11 | - -w 12 | - -X github.com/patrickhoefler/dockerfilegraph/internal/cmd.gitVersion={{.Version}} 13 | - -X github.com/patrickhoefler/dockerfilegraph/internal/cmd.gitCommit={{.Commit}} 14 | - -X github.com/patrickhoefler/dockerfilegraph/internal/cmd.buildDate={{.Date}} 15 | 16 | changelog: 17 | disable: true 18 | 19 | dockers: 20 | - dockerfile: Dockerfile 21 | image_templates: 22 | - 'ghcr.io/patrickhoefler/dockerfilegraph:latest' 23 | - 'ghcr.io/patrickhoefler/dockerfilegraph:{{ .Major }}' 24 | - 'ghcr.io/patrickhoefler/dockerfilegraph:{{ .Major }}.{{ .Minor }}' 25 | - 'ghcr.io/patrickhoefler/dockerfilegraph:{{ .Major }}.{{ .Minor }}.{{ .Patch }}' 26 | - 'ghcr.io/patrickhoefler/dockerfilegraph:alpine' 27 | - 'ghcr.io/patrickhoefler/dockerfilegraph:latest-alpine' 28 | - 'ghcr.io/patrickhoefler/dockerfilegraph:{{ .Major }}-alpine' 29 | - 'ghcr.io/patrickhoefler/dockerfilegraph:{{ .Major }}.{{ .Minor }}-alpine' 30 | - 'ghcr.io/patrickhoefler/dockerfilegraph:{{ .Major }}.{{ .Minor }}.{{ .Patch }}-alpine' 31 | 32 | - dockerfile: Dockerfile.ubuntu 33 | image_templates: 34 | - 'ghcr.io/patrickhoefler/dockerfilegraph:ubuntu' 35 | - 'ghcr.io/patrickhoefler/dockerfilegraph:latest-ubuntu' 36 | - 'ghcr.io/patrickhoefler/dockerfilegraph:{{ .Major }}-ubuntu' 37 | - 'ghcr.io/patrickhoefler/dockerfilegraph:{{ .Major }}.{{ .Minor }}-ubuntu' 38 | - 'ghcr.io/patrickhoefler/dockerfilegraph:{{ .Major }}.{{ .Minor }}.{{ .Patch }}-ubuntu' 39 | 40 | brews: 41 | - repository: 42 | owner: patrickhoefler 43 | name: homebrew-tap 44 | 45 | homepage: https://github.com/patrickhoefler/dockerfilegraph 46 | description: 'Visualize your multi-stage Dockerfile' 47 | 48 | dependencies: 49 | - graphviz 50 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | patrick.hoefler@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ### Release image 2 | FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715 3 | 4 | LABEL org.opencontainers.image.source="https://github.com/patrickhoefler/dockerfilegraph" 5 | 6 | # renovate: datasource=repology depName=alpine_3_22/font-dejavu versioning=loose 7 | ENV FONT_DEJAVU_VERSION="2.37-r6" 8 | 9 | # renovate: datasource=repology depName=alpine_3_22/graphviz versioning=loose 10 | ENV GRAPHVIZ_VERSION="12.2.1-r0" 11 | 12 | RUN apk add --update --no-cache \ 13 | font-dejavu="${FONT_DEJAVU_VERSION}" \ 14 | graphviz="${GRAPHVIZ_VERSION}" \ 15 | \ 16 | # Add a non-root user 17 | && adduser -D app 18 | 19 | # Run as non-root user 20 | USER app 21 | 22 | # This only works after running `make build-linux` 23 | # or when using goreleaser 24 | COPY dockerfilegraph / 25 | 26 | ENTRYPOINT ["/dockerfilegraph"] 27 | -------------------------------------------------------------------------------- /Dockerfile.ubuntu: -------------------------------------------------------------------------------- 1 | ### Release image 2 | FROM ubuntu:oracular-20250428@sha256:707879280c0bbfe6cbeb3ae1a85b564ea2356b5310a122c225b92cb3d1ed131b 3 | 4 | LABEL org.opencontainers.image.source="https://github.com/patrickhoefler/dockerfilegraph" 5 | 6 | # renovate: datasource=repology depName=ubuntu_24_10/fonts-dejavu versioning=loose 7 | ENV FONTS_DEJAVU_VERSION="2.37-8" 8 | 9 | # renovate: datasource=repology depName=ubuntu_24_10/graphviz versioning=loose 10 | ENV GRAPHVIZ_VERSION="2.42.4-2build2" 11 | 12 | RUN \ 13 | apt-get update \ 14 | && apt-get install -y --no-install-recommends \ 15 | fonts-dejavu="${FONTS_DEJAVU_VERSION}" \ 16 | graphviz="${GRAPHVIZ_VERSION}" \ 17 | && rm -rf /var/lib/apt/lists/* \ 18 | \ 19 | # Add a non-root user 20 | && useradd app 21 | 22 | # Run as non-root user 23 | USER app 24 | 25 | # This only works after running `make build-linux` 26 | # or when using goreleaser 27 | COPY dockerfilegraph / 28 | 29 | ENTRYPOINT ["/dockerfilegraph"] 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Patrick Hoefler 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 | GITVERSION := $(shell git describe --tags --always) 2 | GITCOMMIT := $(shell git log -1 --pretty=format:"%H") 3 | BUILDDATE := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') 4 | 5 | LDFLAGS += -s -w 6 | LDFLAGS += -X github.com/patrickhoefler/dockerfilegraph/internal/cmd.gitVersion=$(GITVERSION) 7 | LDFLAGS += -X github.com/patrickhoefler/dockerfilegraph/internal/cmd.gitCommit=$(GITCOMMIT) 8 | LDFLAGS += -X github.com/patrickhoefler/dockerfilegraph/internal/cmd.buildDate=$(BUILDDATE) 9 | FLAGS = -ldflags "$(LDFLAGS)" 10 | 11 | build: clean 12 | go build $(FLAGS) 13 | 14 | build-docker-image-alpine: build-linux 15 | docker build -t dockerfilegraph:alpine -f Dockerfile . 16 | 17 | build-docker-image-ubuntu: build-linux 18 | docker build -t dockerfilegraph:ubuntu -f Dockerfile.ubuntu . 19 | 20 | build-linux: clean 21 | CGO_ENABLED=0 GOOS=linux go build $(FLAGS) 22 | 23 | clean: 24 | go clean 25 | 26 | example-images: 27 | # Change to the root directory of the project. 28 | cd $(git rev-parse --show-toplevel) 29 | 30 | go run . -f examples/dockerfiles/Dockerfile --legend -o svg \ 31 | && mv Dockerfile.svg examples/images/Dockerfile-legend.svg 32 | 33 | go run . -f examples/dockerfiles/Dockerfile --layers -o svg \ 34 | && mv Dockerfile.svg examples/images/Dockerfile-layers.svg 35 | 36 | go run . -f examples/dockerfiles/Dockerfile.large -c -n 0.3 -o svg -u 4 \ 37 | && mv Dockerfile.svg examples/images/Dockerfile-large.svg 38 | 39 | lint: 40 | # https://github.com/golangci/golangci-lint needs to be installed. 41 | golangci-lint run 42 | 43 | test: 44 | go test ./... --coverprofile=cover.out 45 | go install github.com/patrickhoefler/gocovergate@latest 46 | gocovergate 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dockerfilegraph 2 | 3 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/patrickhoefler/dockerfilegraph/ci.yml?branch=main)](https://github.com/patrickhoefler/dockerfilegraph/actions/workflows/ci.yml?query=branch%3Amain) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/patrickhoefler/dockerfilegraph)](https://goreportcard.com/report/github.com/patrickhoefler/dockerfilegraph) 5 | [![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/patrickhoefler/dockerfilegraph)](https://github.com/patrickhoefler/dockerfilegraph/releases/latest) 6 | [![GitHub](https://img.shields.io/github/license/patrickhoefler/dockerfilegraph)](https://github.com/patrickhoefler/dockerfilegraph/blob/main/LICENSE) 7 | 8 | A command-line tool that visualizes multi-stage Dockerfiles as dependency graphs. 9 | 10 | Uses [Graphviz](https://graphviz.org/) to generate visual representations of Docker build processes, helping you understand build dependencies, document architecture, and debug complex multi-stage builds. 11 | 12 | ## What You Get 13 | 14 | The generated graph includes: 15 | 16 | **Nodes:** 17 | 18 | - All build stages 19 | - Default build target (highlighted in grey) 20 | - External images (with dashed borders) 21 | 22 | **Edges:** 23 | 24 | - `FROM ...` dependencies → solid line with full arrow 25 | - `COPY --from=...` dependencies → dashed line with empty arrow 26 | - `RUN --mount=(.*)from=...` dependencies → dotted line with diamond arrow 27 | 28 | Supports multiple output formats (PDF, SVG, PNG), a legend, and layout customization options. 29 | 30 | ## Example Output 31 | 32 | ### With Legend (`--legend`) 33 | 34 | ![Example output including a legend](./examples/images/Dockerfile-legend.svg) 35 | 36 | ### With Layer Visualization (`--layers`) 37 | 38 | ![Example output including layers](./examples/images/Dockerfile-layers.svg) 39 | 40 | ### Complex Multi-stage Build (`--concentrate --nodesep 0.3 --unflatten 4`) 41 | 42 | ![Example output with `--concentrate` and `--unflatten 4`](./examples/images/Dockerfile-large.svg) 43 | 44 | ## Getting Started 45 | 46 | ### Prerequisites 47 | 48 | - A multi-stage [Dockerfile](https://docs.docker.com/engine/reference/builder/) 49 | 50 | ### Installation and Usage 51 | 52 | Run `dockerfilegraph` in your project directory to generate a `Dockerfile.pdf` with your build graph. 53 | 54 | #### Docker 55 | 56 | - **Alpine-based** (Graphviz 12.2) - **Default**: 57 | 58 | ```shell 59 | docker run --rm --user "$(id -u):$(id -g)" \ 60 | -v "$(pwd)":/workspace -w /workspace \ 61 | ghcr.io/patrickhoefler/dockerfilegraph 62 | ``` 63 | 64 | - **Ubuntu-based** (Graphviz 2.42): 65 | 66 | ```shell 67 | docker run --rm --user "$(id -u):$(id -g)" \ 68 | -v "$(pwd)":/workspace -w /workspace \ 69 | ghcr.io/patrickhoefler/dockerfilegraph:ubuntu 70 | ``` 71 | 72 | #### Homebrew 73 | 74 | ```shell 75 | brew install patrickhoefler/tap/dockerfilegraph 76 | dockerfilegraph 77 | ``` 78 | 79 | #### [toolctl](https://github.com/toolctl/toolctl) 80 | 81 | *Requirements: [Graphviz](https://graphviz.org/) installed locally* 82 | 83 | ```shell 84 | toolctl install dockerfilegraph 85 | dockerfilegraph 86 | ``` 87 | 88 | #### Build from Source 89 | 90 | - **Native Build** 91 | 92 | *Requirements: `make`, [Go](https://go.dev/) and [Graphviz](https://graphviz.org/)* 93 | 94 | ```shell 95 | make build 96 | ./dockerfilegraph 97 | ``` 98 | 99 | - **Container Build (Alpine)** 100 | 101 | *Requirements: `make`, [Go](https://go.dev/) and Docker* 102 | 103 | ```shell 104 | make build-docker-image-alpine 105 | docker run \ 106 | --rm \ 107 | --user "$(id -u):$(id -g)" \ 108 | --workdir /workspace \ 109 | --volume "$(pwd)":/workspace \ 110 | dockerfilegraph:alpine 111 | ``` 112 | 113 | - **Container Build (Ubuntu)** 114 | 115 | *Requirements: `make`, [Go](https://go.dev/) and Docker* 116 | 117 | ```shell 118 | make build-docker-image-ubuntu 119 | docker run \ 120 | --rm \ 121 | --user "$(id -u):$(id -g)" \ 122 | --workdir /workspace \ 123 | --volume "$(pwd)":/workspace \ 124 | dockerfilegraph:ubuntu 125 | ``` 126 | 127 | ## Configuration Options 128 | 129 | **Common Flags:** 130 | 131 | - `--output svg|png|pdf` - Choose your output format 132 | - `--legend` - Add a legend explaining the notation 133 | - `--layers` - Show all Docker layers 134 | 135 | **All Available Options:** 136 | 137 | ```text 138 | ❯ dockerfilegraph --help 139 | dockerfilegraph visualizes your multi-stage Dockerfile. 140 | It creates a visual graph representation of the build process. 141 | 142 | Usage: 143 | dockerfilegraph [flags] 144 | 145 | Flags: 146 | -c, --concentrate concentrate the edges (default false) 147 | -d, --dpi uint dots per inch of the PNG export (default 96) 148 | -e, --edgestyle style of the graph edges, one of: default, solid (default default) 149 | -f, --filename string name of the Dockerfile (default "Dockerfile") 150 | -h, --help help for dockerfilegraph 151 | --layers display all layers (default false) 152 | --legend add a legend (default false) 153 | -m, --max-label-length uint maximum length of the node labels, must be at least 4 (default 20) 154 | -n, --nodesep float minimum space between two adjacent nodes in the same rank (default 1) 155 | -o, --output output file format, one of: canon, dot, pdf, png, raw, svg (default pdf) 156 | -r, --ranksep float minimum separation between ranks (default 0.5) 157 | -u, --unflatten uint stagger length of leaf edges between [1,u] (default 0) 158 | --version display the version of dockerfilegraph 159 | ``` 160 | 161 | ## Contributing 162 | 163 | Found a bug or have a feature request? [Open an issue](https://github.com/patrickhoefler/dockerfilegraph/issues) or submit a pull request. 164 | 165 | ## License 166 | 167 | [MIT](https://github.com/patrickhoefler/dockerfilegraph/blob/main/LICENSE) 168 | -------------------------------------------------------------------------------- /examples/dockerfiles/Dockerfile: -------------------------------------------------------------------------------- 1 | ### TLS root certs 2 | FROM ubuntu:latest@sha256:b6b83d3c331794420340093eb706a6f152d9c1fa51b262d9bf34594887c2c7ac AS ubuntu 3 | 4 | RUN \ 5 | apt-get update \ 6 | && apt-get install -y --no-install-recommends \ 7 | ca-certificates \ 8 | && rm -rf /var/lib/apt/lists/* 9 | 10 | # --- 11 | 12 | FROM golang:1.18.4@sha256:6e10f44d212b24a3611280c8035edaf9d519f20e3f4d8f6f5e26796b738433da AS build-tool-dependencies 13 | RUN --mount=type=cache,from=buildcache,source=/go/pkg/mod/cache/,target=/go/pkg/mod/cache/ go build 14 | 15 | # --- 16 | 17 | FROM scratch AS release 18 | 19 | COPY --from=ubuntu /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 20 | COPY --from=build-tool-dependencies . . 21 | 22 | ENTRYPOINT ["/example"] 23 | -------------------------------------------------------------------------------- /examples/dockerfiles/Dockerfile.large: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/amd64 ubuntu:22.04 AS ubuntu_amd64 2 | 3 | FROM --platform=linux/arm64 ubuntu:22.04 AS ubuntu_arm64 4 | 5 | FROM ubuntu:22.04 AS ubuntu_with_amd64_emulation 6 | COPY --from=ubuntu_amd64 /etc/apt/sources.list /etc/apt/sources.list.amd64 7 | COPY --from=ubuntu_arm64 /etc/apt/sources.list /etc/apt/sources.list.arm64 8 | COPY --from=ubuntu_amd64 /usr/bin/cat ./cat 9 | COPY --from=ubuntu_arm64 /usr/bin/cat ./cat 10 | 11 | FROM alpine:3.12.0 AS a_dependency 12 | 13 | FROM ubuntu_with_amd64_emulation AS base 14 | COPY --from=a_dependency /bin/cat /tmp/cat 15 | COPY --from=docker.io/alpine:3.14 /bin/cat /tmp/cat 16 | COPY --from=docker.io/alpine:3.14.1 /bin/cat /tmp/cat 17 | COPY --from=docker.io/alpine:3.14.2 /bin/cat /tmp/cat 18 | COPY --from=docker.io/alpine:3.14.3 /bin/cat /tmp/cat 19 | COPY --from=docker.io/alpine:3.14.4 /bin/cat /tmp/cat 20 | COPY --from=docker.io/alpine:3.14.5 /bin/cat /tmp/cat 21 | COPY --from=docker.io/alpine:3.14.6 /bin/cat /tmp/cat 22 | COPY --from=docker.io/alpine:3.14.7 /bin/cat /tmp/cat 23 | COPY --from=docker.io/alpine:3.14.8 /bin/cat /tmp/cat 24 | COPY --from=docker.io/alpine:3.15 /bin/cat /tmp/cat 25 | COPY --from=docker.io/alpine:3.15.1 /bin/cat /tmp/cat 26 | COPY --from=docker.io/alpine:3.15.2 /bin/cat /tmp/cat 27 | COPY --from=docker.io/alpine:3.15.3 /bin/cat /tmp/cat 28 | COPY --from=docker.io/alpine:3.15.4 /bin/cat /tmp/cat 29 | COPY --from=docker.io/alpine:3.15.5 /bin/cat /tmp/cat 30 | COPY --from=docker.io/alpine:3.15.6 /bin/cat /tmp/cat 31 | COPY --from=docker.io/alpine:3.16.1 /bin/cat /tmp/cat 32 | COPY --from=docker.io/alpine:3.16.2 /bin/cat /tmp/cat 33 | COPY --from=docker.io/alpine:3.16.3 /bin/cat /tmp/cat 34 | COPY --from=docker.io/alpine:3.16 /bin/cat /tmp/cat 35 | 36 | FROM --platform=$BUILDPLATFORM ubuntu:22.04 AS build_buildplatform 37 | RUN --mount=type=cache,target=/root/.m2/repository echo mvn dependencies ... 38 | RUN --mount=type=cache,target=/root/.m2/repository echo mvn package ... 39 | 40 | FROM base as test 41 | COPY --from=build_buildplatform /bin/cat /tmp/cat 42 | COPY --from=build_buildplatform /bin/cat /tmp/cat 43 | COPY --from=build_buildplatform /bin/cat /tmp/cat 44 | RUN --mount=type=cache,target=/root/.m2/repository echo mvn verify ... 45 | 46 | FROM base 47 | COPY --from=build_buildplatform /bin/cat /tmp/cat 48 | COPY --from=build_buildplatform /bin/cat /tmp/cat 49 | -------------------------------------------------------------------------------- /examples/images/Dockerfile-large.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | G 11 | 12 | 13 | 14 | external_image_0 15 | 16 | ubuntu:22.04 17 | 18 | 19 | 20 | stage_0 21 | 22 | ubuntu_amd64 23 | 24 | 25 | 26 | external_image_0->stage_0 27 | 28 | 29 | 30 | 31 | 32 | stage_1 33 | 34 | ubuntu_arm64 35 | 36 | 37 | 38 | external_image_0->stage_1 39 | 40 | 41 | 42 | 43 | 44 | stage_2 45 | 46 | ubuntu_with_amd64... 47 | 48 | 49 | 50 | external_image_0->stage_2 51 | 52 | 53 | 54 | 55 | 56 | 57 | stage_5 58 | 59 | build_buildplatform 60 | 61 | 62 | 63 | external_image_0->stage_5 64 | 65 | 66 | 67 | 68 | 69 | stage_0->stage_2 70 | 71 | 72 | 73 | 74 | 75 | stage_1->stage_2 76 | 77 | 78 | 79 | 80 | 81 | stage_4 82 | 83 | base 84 | 85 | 86 | 87 | stage_2->stage_4 88 | 89 | 90 | 91 | 92 | 93 | stage_6 94 | 95 | test 96 | 97 | 98 | 99 | stage_5->stage_6 100 | 101 | 102 | 103 | 104 | 105 | stage_7 106 | 107 | 7 108 | 109 | 110 | 111 | stage_5->stage_7 112 | 113 | 114 | 115 | 116 | 117 | stage_4->stage_6 118 | 119 | 120 | 121 | 122 | 123 | stage_4->stage_7 124 | 125 | 126 | 127 | 128 | 129 | external_image_1 130 | 131 | alpine:3.12.0 132 | 133 | 134 | 135 | stage_3 136 | 137 | a_dependency 138 | 139 | 140 | 141 | external_image_1->stage_3 142 | 143 | 144 | 145 | 146 | 147 | stage_3->stage_4 148 | 149 | 150 | 151 | 152 | 153 | external_image_2 154 | 155 | docker.i...pine:3.14 156 | 157 | 158 | 159 | external_image_2->stage_4 160 | 161 | 162 | 163 | 164 | 165 | external_image_3 166 | 167 | docker.i...ne:3.14.1 168 | 169 | 170 | 171 | external_image_3->stage_4 172 | 173 | 174 | 175 | 176 | external_image_4 177 | 178 | docker.i...ne:3.14.2 179 | 180 | 181 | 182 | external_image_4->stage_4 183 | 184 | 185 | 186 | 187 | external_image_5 188 | 189 | docker.i...ne:3.14.3 190 | 191 | 192 | 193 | external_image_5->stage_4 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | external_image_6 202 | 203 | docker.i...ne:3.14.4 204 | 205 | 206 | 207 | external_image_6->stage_4 208 | 209 | 210 | 211 | 212 | 213 | external_image_7 214 | 215 | docker.i...ne:3.14.5 216 | 217 | 218 | 219 | external_image_7->stage_4 220 | 221 | 222 | 223 | 224 | external_image_8 225 | 226 | docker.i...ne:3.14.6 227 | 228 | 229 | 230 | external_image_8->stage_4 231 | 232 | 233 | 234 | 235 | external_image_9 236 | 237 | docker.i...ne:3.14.7 238 | 239 | 240 | 241 | external_image_9->stage_4 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | external_image_10 250 | 251 | docker.i...ne:3.14.8 252 | 253 | 254 | 255 | external_image_10->stage_4 256 | 257 | 258 | 259 | 260 | 261 | external_image_11 262 | 263 | docker.i...pine:3.15 264 | 265 | 266 | 267 | external_image_11->stage_4 268 | 269 | 270 | 271 | 272 | external_image_12 273 | 274 | docker.i...ne:3.15.1 275 | 276 | 277 | 278 | external_image_12->stage_4 279 | 280 | 281 | 282 | 283 | external_image_13 284 | 285 | docker.i...ne:3.15.2 286 | 287 | 288 | 289 | external_image_13->stage_4 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | external_image_14 298 | 299 | docker.i...ne:3.15.3 300 | 301 | 302 | 303 | external_image_14->stage_4 304 | 305 | 306 | 307 | 308 | 309 | external_image_15 310 | 311 | docker.i...ne:3.15.4 312 | 313 | 314 | 315 | external_image_15->stage_4 316 | 317 | 318 | 319 | 320 | external_image_16 321 | 322 | docker.i...ne:3.15.5 323 | 324 | 325 | 326 | external_image_16->stage_4 327 | 328 | 329 | 330 | 331 | external_image_17 332 | 333 | docker.i...ne:3.15.6 334 | 335 | 336 | 337 | external_image_17->stage_4 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | external_image_18 346 | 347 | docker.i...ne:3.16.1 348 | 349 | 350 | 351 | external_image_18->stage_4 352 | 353 | 354 | 355 | 356 | 357 | external_image_19 358 | 359 | docker.i...ne:3.16.2 360 | 361 | 362 | 363 | external_image_19->stage_4 364 | 365 | 366 | 367 | 368 | external_image_20 369 | 370 | docker.i...ne:3.16.3 371 | 372 | 373 | 374 | external_image_20->stage_4 375 | 376 | 377 | 378 | 379 | external_image_21 380 | 381 | docker.i...pine:3.16 382 | 383 | 384 | 385 | external_image_21->stage_4 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | -------------------------------------------------------------------------------- /examples/images/Dockerfile-layers.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | G 11 | 12 | 13 | cluster_stage_0 14 | 15 | ubuntu 16 | 17 | 18 | cluster_stage_1 19 | 20 | build-tool-dependencies 21 | 22 | 23 | cluster_stage_2 24 | 25 | release 26 | 27 | 28 | 29 | stage_0_layer_0 30 | 31 | FROM ubuntu:lates... 32 | 33 | 34 | 35 | stage_0_layer_1 36 | 37 | RUN apt-get updat... 38 | 39 | 40 | 41 | stage_0_layer_0->stage_0_layer_1 42 | 43 | 44 | 45 | 46 | 47 | stage_2_layer_1 48 | 49 | COPY --from=ubunt... 50 | 51 | 52 | 53 | stage_0_layer_1->stage_2_layer_1 54 | 55 | 56 | 57 | 58 | 59 | external_image_0 60 | 61 | ubuntu:l...887c2c7ac 62 | 63 | 64 | 65 | external_image_0->stage_0_layer_0 66 | 67 | 68 | 69 | 70 | 71 | stage_1_layer_0 72 | 73 | FROM golang:1.18.... 74 | 75 | 76 | 77 | stage_1_layer_1 78 | 79 | RUN --mount=type=... 80 | 81 | 82 | 83 | stage_1_layer_0->stage_1_layer_1 84 | 85 | 86 | 87 | 88 | 89 | stage_2_layer_2 90 | 91 | COPY --from=build... 92 | 93 | 94 | 95 | stage_1_layer_1->stage_2_layer_2 96 | 97 | 98 | 99 | 100 | 101 | external_image_1 102 | 103 | golang:1...b738433da 104 | 105 | 106 | 107 | external_image_1->stage_1_layer_0 108 | 109 | 110 | 111 | 112 | 113 | external_image_2 114 | 115 | buildcache 116 | 117 | 118 | 119 | external_image_2->stage_1_layer_1 120 | 121 | 122 | 123 | 124 | 125 | stage_2_layer_0 126 | 127 | FROM scratch AS r... 128 | 129 | 130 | 131 | stage_2_layer_0->stage_2_layer_1 132 | 133 | 134 | 135 | 136 | 137 | stage_2_layer_1->stage_2_layer_2 138 | 139 | 140 | 141 | 142 | 143 | stage_2_layer_3 144 | 145 | ENTRYPOINT ['/exa... 146 | 147 | 148 | 149 | stage_2_layer_2->stage_2_layer_3 150 | 151 | 152 | 153 | 154 | 155 | external_image_3 156 | 157 | scratch 158 | 159 | 160 | 161 | external_image_3->stage_2_layer_0 162 | 163 | 164 | 165 | 166 | 167 | -------------------------------------------------------------------------------- /examples/images/Dockerfile-legend.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | G 11 | 12 | 13 | cluster_legend 14 | 15 | 16 | 17 | 18 | key 19 | FROM ...  20 | COPY --from=...  21 | RUN --mount=(.*)from=...  22 | 23 | 24 | 25 | key2 26 |   27 |   28 |   29 | 30 | 31 | 32 | key:e->key2:w 33 | 34 | 35 | 36 | 37 | 38 | key:e->key2:w 39 | 40 | 41 | 42 | 43 | 44 | key:e->key2:w 45 | 46 | 47 | 48 | 49 | 50 | external_image_0 51 | 52 | ubuntu:l...887c2c7ac 53 | 54 | 55 | 56 | stage_0 57 | 58 | ubuntu 59 | 60 | 61 | 62 | external_image_0->stage_0 63 | 64 | 65 | 66 | 67 | 68 | stage_2 69 | 70 | release 71 | 72 | 73 | 74 | stage_0->stage_2 75 | 76 | 77 | 78 | 79 | 80 | external_image_1 81 | 82 | golang:1...b738433da 83 | 84 | 85 | 86 | stage_1 87 | 88 | build-tool-depend... 89 | 90 | 91 | 92 | external_image_1->stage_1 93 | 94 | 95 | 96 | 97 | 98 | stage_1->stage_2 99 | 100 | 101 | 102 | 103 | 104 | external_image_2 105 | 106 | buildcache 107 | 108 | 109 | 110 | external_image_2->stage_1 111 | 112 | 113 | 114 | 115 | 116 | external_image_3 117 | 118 | scratch 119 | 120 | 121 | 122 | external_image_3->stage_2 123 | 124 | 125 | 126 | 127 | 128 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/patrickhoefler/dockerfilegraph 2 | 3 | go 1.23.4 4 | 5 | require ( 6 | github.com/aquilax/truncate v1.0.1 7 | github.com/awalterschulze/gographviz v2.0.3+incompatible 8 | github.com/google/go-cmp v0.7.0 9 | github.com/moby/buildkit v0.22.0 10 | github.com/spf13/afero v1.14.0 11 | github.com/spf13/cobra v1.9.1 12 | ) 13 | 14 | require ( 15 | github.com/containerd/typeurl/v2 v2.2.3 // indirect 16 | github.com/gogo/protobuf v1.3.2 // indirect 17 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 18 | github.com/pkg/errors v0.9.1 // indirect 19 | github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect 20 | github.com/spf13/pflag v1.0.6 // indirect 21 | golang.org/x/text v0.24.0 // indirect 22 | google.golang.org/protobuf v1.36.5 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aquilax/truncate v1.0.1 h1:+hqGSRxnQ0F5wdPCGbi1XW4ipQ6vzpli23V9Rd+I/mc= 2 | github.com/aquilax/truncate v1.0.1/go.mod h1:BeMESIDMlvlS3bmg4BVvBbbZUNwWtS8uzYPAKXwwhLw= 3 | github.com/awalterschulze/gographviz v2.0.3+incompatible h1:9sVEXJBJLwGX7EQVhLm2elIKCm7P2YHFC8v6096G09E= 4 | github.com/awalterschulze/gographviz v2.0.3+incompatible/go.mod h1:GEV5wmg4YquNw7v1kkyoX9etIk8yVmXj+AkDHuuETHs= 5 | github.com/containerd/typeurl/v2 v2.2.3 h1:yNA/94zxWdvYACdYO8zofhrTVuQY73fFU1y++dYSw40= 6 | github.com/containerd/typeurl/v2 v2.2.3/go.mod h1:95ljDnPfD3bAbDJRugOiShd/DlAAsxGtUBhJxIn7SCk= 7 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 11 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 12 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 13 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 14 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 15 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 16 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 17 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 18 | github.com/moby/buildkit v0.22.0 h1:aWN06w1YGSVN1XfeZbj2ZbgY+zi5xDAjEFI8Cy9fTjA= 19 | github.com/moby/buildkit v0.22.0/go.mod h1:j4pP5hxiTWcz7xuTK2cyxQislHl/N2WWHzOy43DlLJw= 20 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 21 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 22 | github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= 23 | github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 24 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 25 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 26 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 27 | github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= 28 | github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= 29 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 30 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 31 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 32 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 33 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 34 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 35 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 36 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 37 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 38 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 39 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 40 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 41 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 42 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 43 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 44 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 45 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 46 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 47 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 48 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 49 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 50 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 51 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 52 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 53 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 54 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 55 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 56 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 57 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 58 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 59 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 60 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 61 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 62 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 63 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 64 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 65 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 66 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 67 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 68 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 69 | -------------------------------------------------------------------------------- /internal/cmd/enum.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "sort" 6 | ) 7 | 8 | // enum is a Cobra-compatible wrapper for defining a string flag 9 | // that can be one of a specified set of values. 10 | type enum struct { 11 | allowedValues []string 12 | value string 13 | } 14 | 15 | // newEnum returns a new enum flag. 16 | func newEnum(defaultValue string, allowedValues ...string) enum { 17 | allowedValues = append(allowedValues, defaultValue) 18 | sort.Strings(allowedValues) 19 | 20 | return enum{ 21 | allowedValues: allowedValues, 22 | value: defaultValue, 23 | } 24 | } 25 | 26 | // String returns a string representation of the enum flag. 27 | func (e *enum) String() string { return e.value } 28 | 29 | // Set assigns the provided string to the enum receiver. 30 | // It returns an error if the string is not an allowed value. 31 | func (e *enum) Set(s string) error { 32 | for _, val := range e.allowedValues { 33 | if val == s { 34 | e.value = s 35 | return nil 36 | } 37 | } 38 | 39 | return errors.New("invalid value: " + s) 40 | } 41 | 42 | // Type returns a string representation of the enum type. 43 | func (e *enum) Type() string { return "" } 44 | 45 | // AllowedValues returns a slice of the flag's valid values. 46 | func (e *enum) AllowedValues() []string { 47 | return e.allowedValues 48 | } 49 | -------------------------------------------------------------------------------- /internal/cmd/integration_test.go: -------------------------------------------------------------------------------- 1 | package cmd_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "runtime" 9 | "testing" 10 | 11 | "github.com/google/go-cmp/cmp" 12 | ) 13 | 14 | func TestIntegrationCLIGeneratesOutputFile(t *testing.T) { 15 | // Find the project root directory 16 | _, thisFile, _, _ := runtime.Caller(0) 17 | projectRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "../..")) 18 | 19 | // Build the Linux binary before building the Docker image 20 | makeCmd := exec.Command("make", "build-linux") 21 | makeCmd.Dir = projectRoot 22 | makeOut, err := makeCmd.CombinedOutput() 23 | if err != nil { 24 | t.Fatalf("make build-linux failed: %v\nOutput:\n%s", err, string(makeOut)) 25 | } 26 | binPath := filepath.Join(projectRoot, "dockerfilegraph") 27 | defer func() { 28 | if err := os.Remove(binPath); err != nil && !os.IsNotExist(err) { 29 | t.Errorf("failed to remove built binary %s: %v", binPath, err) 30 | } 31 | }() 32 | 33 | // Build the Docker image from the project root 34 | buildCmd := exec.Command("docker", "build", "-t", "dockerfilegraph-test", "-f", "Dockerfile", ".") 35 | buildCmd.Dir = projectRoot 36 | buildOut, err := buildCmd.CombinedOutput() 37 | if err != nil { 38 | t.Fatalf("docker build failed: %v\nOutput:\n%s", err, string(buildOut)) 39 | } 40 | 41 | // Prepare temp dir for output 42 | tempDir := t.TempDir() 43 | // Copy example Dockerfile to temp dir 44 | dockerfileSrc := filepath.Join(projectRoot, "examples", "dockerfiles", "Dockerfile") 45 | dockerfileDst := filepath.Join(tempDir, "Dockerfile") 46 | content, err := os.ReadFile(dockerfileSrc) 47 | if err != nil { 48 | t.Fatalf("failed to read example Dockerfile from %s: %v", dockerfileSrc, err) 49 | } 50 | if err := os.WriteFile(dockerfileDst, content, 0644); err != nil { 51 | t.Fatalf("failed to write Dockerfile to temp dir %s: %v", dockerfileDst, err) 52 | } 53 | 54 | // Run the CLI in Docker to generate Dockerfile.dot 55 | dockerCmd := exec.Command( 56 | "docker", "run", "--rm", 57 | "-u", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), 58 | "-v", tempDir+":/data", 59 | "-w", "/data", 60 | "dockerfilegraph-test", 61 | "--filename", "Dockerfile", "--output", "dot", 62 | ) 63 | dockerOut, err := dockerCmd.CombinedOutput() 64 | if err != nil { 65 | t.Fatalf("docker run CLI failed: %v\nOutput:\n%s", err, string(dockerOut)) 66 | } 67 | 68 | // Read the DOT file generated by the CLI 69 | dotFile := filepath.Join(tempDir, "Dockerfile.dot") 70 | outputBytes, err := os.ReadFile(dotFile) 71 | if err != nil { 72 | t.Fatalf("failed to read generated dot file %s: %v", dotFile, err) 73 | } 74 | 75 | checkGoldenFile(t, outputBytes) 76 | } 77 | 78 | func checkGoldenFile(t *testing.T, dotBytes []byte) { 79 | _, thisFile, _, _ := runtime.Caller(0) 80 | goldenDir := filepath.Join(filepath.Dir(thisFile), "testdata") 81 | goldenFile := filepath.Join(goldenDir, "Dockerfile.golden.dot") 82 | 83 | if _, err := os.Stat(goldenFile); os.IsNotExist(err) { 84 | if err := os.MkdirAll(goldenDir, 0755); err != nil { 85 | t.Fatalf("failed to create testdata dir: %v", err) 86 | } 87 | if err := os.WriteFile(goldenFile, dotBytes, 0644); err != nil { 88 | t.Fatalf("failed to write golden file: %v", err) 89 | } 90 | t.Logf("golden file did not exist, created: %s", goldenFile) 91 | } else { 92 | goldenBytes, err := os.ReadFile(goldenFile) 93 | if err != nil { 94 | t.Fatalf("failed to read golden file: %v", err) 95 | } 96 | diff := cmp.Diff(string(goldenBytes), string(dotBytes)) 97 | if diff != "" { 98 | t.Errorf( 99 | "output DOT does not match golden file.\n"+ 100 | "To update, delete %s and re-run the test.\n"+ 101 | "Diff (-want +got):\n%s", 102 | goldenFile, diff, 103 | ) 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /internal/cmd/root.go: -------------------------------------------------------------------------------- 1 | // Package cmd contains the Cobra CLI. 2 | package cmd 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/exec" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/patrickhoefler/dockerfilegraph/internal/dockerfile2dot" 13 | "github.com/spf13/afero" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | var ( 18 | concentrateFlag bool 19 | dpiFlag uint 20 | edgestyleFlag enum 21 | filenameFlag string 22 | layersFlag bool 23 | legendFlag bool 24 | maxLabelLengthFlag uint 25 | nodesepFlag float64 26 | outputFlag enum 27 | ranksepFlag float64 28 | unflattenFlag uint 29 | versionFlag bool 30 | ) 31 | 32 | // dfgWriter is a writer that prints to stdout. When testing, we 33 | // replace this with a writer that prints to a buffer. 34 | type dfgWriter struct{} 35 | 36 | func (d dfgWriter) Write(p []byte) (n int, err error) { 37 | fmt.Print(string(p)) 38 | return len(p), nil 39 | } 40 | 41 | // NewRootCmd creates a new root command. 42 | func NewRootCmd( 43 | dfgWriter io.Writer, inputFS afero.Fs, dotCmd string, 44 | ) *cobra.Command { 45 | rootCmd := &cobra.Command{ 46 | Use: "dockerfilegraph", 47 | Short: "Visualize your multi-stage Dockerfile", 48 | Long: `dockerfilegraph visualizes your multi-stage Dockerfile. 49 | It creates a visual graph representation of the build process.`, 50 | Args: cobra.NoArgs, 51 | PreRunE: func(_ *cobra.Command, _ []string) (err error) { 52 | return checkFlags() 53 | }, 54 | RunE: func(_ *cobra.Command, _ []string) (err error) { 55 | if versionFlag { 56 | return printVersion(dfgWriter) 57 | } 58 | 59 | // Make sure that graphviz is installed. 60 | _, err = exec.LookPath(dotCmd) 61 | if err != nil { 62 | return 63 | } 64 | 65 | // Load and parse the Dockerfile. 66 | dockerfile, err := dockerfile2dot.LoadAndParseDockerfile( 67 | inputFS, 68 | filenameFlag, 69 | int(maxLabelLengthFlag), 70 | ) 71 | if err != nil { 72 | return 73 | } 74 | 75 | dotFile, err := os.CreateTemp("", "dockerfile.*.dot") 76 | if err != nil { 77 | return 78 | } 79 | defer os.Remove(dotFile.Name()) 80 | 81 | dotFileContent := dockerfile2dot.BuildDotFile( 82 | dockerfile, 83 | concentrateFlag, 84 | edgestyleFlag.String(), 85 | layersFlag, 86 | legendFlag, 87 | int(maxLabelLengthFlag), 88 | fmt.Sprintf("%.2f", nodesepFlag), 89 | fmt.Sprintf("%.2f", ranksepFlag), 90 | ) 91 | 92 | _, err = dotFile.Write([]byte(dotFileContent)) 93 | if err != nil { 94 | return 95 | } 96 | 97 | err = dotFile.Close() 98 | if err != nil { 99 | return 100 | } 101 | 102 | if unflattenFlag > 0 { 103 | err = unflatten(dotFile, dfgWriter) 104 | if err != nil { 105 | return 106 | } 107 | } 108 | 109 | filename := "Dockerfile." + outputFlag.String() 110 | 111 | if outputFlag.String() == "raw" { 112 | err = os.Rename(dotFile.Name(), filename) 113 | if err != nil { 114 | return 115 | } 116 | fmt.Fprintf(dfgWriter, "Successfully created %s\n", filename) 117 | return 118 | } 119 | 120 | dotArgs := []string{ 121 | "-T" + outputFlag.String(), 122 | "-o" + filename, 123 | } 124 | if outputFlag.String() == "png" { 125 | dotArgs = append(dotArgs, "-Gdpi="+fmt.Sprint(dpiFlag)) 126 | } 127 | dotArgs = append(dotArgs, dotFile.Name()) 128 | 129 | out, err := exec.Command(dotCmd, dotArgs...).CombinedOutput() 130 | if err != nil { 131 | fmt.Fprintf(dfgWriter, 132 | `Oh no, something went wrong while generating the graph! 133 | 134 | This is the Graphviz file that was generated: 135 | 136 | %s 137 | The following error was reported by Graphviz: 138 | 139 | %s`, 140 | dotFileContent, string(out), 141 | ) 142 | return 143 | } 144 | 145 | fmt.Fprintf(dfgWriter, "Successfully created %s\n", filename) 146 | 147 | return 148 | }, 149 | } 150 | 151 | // Flags 152 | rootCmd.Flags().BoolVarP( 153 | &concentrateFlag, 154 | "concentrate", 155 | "c", 156 | false, 157 | "concentrate the edges (default false)", 158 | ) 159 | 160 | rootCmd.Flags().UintVarP( 161 | &dpiFlag, 162 | "dpi", 163 | "d", 164 | 96, // the default dpi setting of Graphviz 165 | "dots per inch of the PNG export", 166 | ) 167 | 168 | edgestyleFlag = newEnum("default", "solid") 169 | rootCmd.Flags().VarP( 170 | &edgestyleFlag, 171 | "edgestyle", 172 | "e", 173 | "style of the graph edges, one of: "+strings.Join(edgestyleFlag.AllowedValues(), ", "), 174 | ) 175 | 176 | rootCmd.Flags().StringVarP( 177 | &filenameFlag, 178 | "filename", 179 | "f", 180 | "Dockerfile", 181 | "name of the Dockerfile", 182 | ) 183 | 184 | rootCmd.Flags().BoolVar( 185 | &layersFlag, 186 | "layers", 187 | false, 188 | "display all layers (default false)", 189 | ) 190 | 191 | rootCmd.Flags().BoolVar( 192 | &legendFlag, 193 | "legend", 194 | false, 195 | "add a legend (default false)", 196 | ) 197 | 198 | rootCmd.Flags().UintVarP( 199 | &maxLabelLengthFlag, 200 | "max-label-length", 201 | "m", 202 | 20, 203 | "maximum length of the node labels, must be at least 4", 204 | ) 205 | 206 | rootCmd.Flags().Float64VarP( 207 | &nodesepFlag, 208 | "nodesep", 209 | "n", 210 | 1, 211 | "minimum space between two adjacent nodes in the same rank", 212 | ) 213 | 214 | outputFlag = newEnum("pdf", "canon", "dot", "png", "raw", "svg") 215 | rootCmd.Flags().VarP( 216 | &outputFlag, 217 | "output", 218 | "o", 219 | "output file format, one of: "+strings.Join(outputFlag.AllowedValues(), ", "), 220 | ) 221 | 222 | rootCmd.Flags().Float64VarP( 223 | &ranksepFlag, 224 | "ranksep", 225 | "r", 226 | 0.5, 227 | "minimum separation between ranks", 228 | ) 229 | 230 | rootCmd.Flags().UintVarP( 231 | &unflattenFlag, 232 | "unflatten", 233 | "u", 234 | 0, // turned off 235 | "stagger length of leaf edges between [1,u] (default 0)", 236 | ) 237 | 238 | rootCmd.Flags().BoolVar( 239 | &versionFlag, 240 | "version", 241 | false, 242 | "display the version of dockerfilegraph", 243 | ) 244 | 245 | return rootCmd 246 | } 247 | 248 | func unflatten(dotFile *os.File, dfgWriter io.Writer) (err error) { 249 | var unflattenFile *os.File 250 | unflattenFile, err = os.CreateTemp("", "dockerfile.*.dot") 251 | if err != nil { 252 | return 253 | } 254 | defer os.Remove(unflattenFile.Name()) 255 | 256 | unflattenCmd := exec.Command( 257 | "unflatten", 258 | "-l", strconv.FormatUint(uint64(unflattenFlag), 10), 259 | "-o", unflattenFile.Name(), dotFile.Name(), 260 | ) 261 | unflattenCmd.Stdout = dfgWriter 262 | unflattenCmd.Stderr = dfgWriter 263 | err = unflattenCmd.Run() 264 | if err != nil { 265 | return 266 | } 267 | 268 | err = unflattenFile.Close() 269 | if err != nil { 270 | return 271 | } 272 | 273 | err = os.Rename(unflattenFile.Name(), dotFile.Name()) 274 | if err != nil { 275 | return 276 | } 277 | 278 | return 279 | } 280 | 281 | // Execute executes the root command. 282 | func Execute() { 283 | err := NewRootCmd( 284 | dfgWriter{}, afero.NewOsFs(), "dot", 285 | ).Execute() 286 | if err != nil { 287 | // Cobra prints the error message 288 | os.Exit(1) 289 | } 290 | } 291 | 292 | func checkFlags() (err error) { 293 | if maxLabelLengthFlag < 4 { 294 | err = fmt.Errorf("--max-label-length must be at least 4") 295 | return 296 | } 297 | return 298 | } 299 | -------------------------------------------------------------------------------- /internal/cmd/root_test.go: -------------------------------------------------------------------------------- 1 | package cmd_test 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "regexp" 7 | "testing" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/patrickhoefler/dockerfilegraph/internal/cmd" 11 | "github.com/spf13/afero" 12 | ) 13 | 14 | type test struct { 15 | name string 16 | cliArgs []string 17 | dockerfileContent string 18 | dotCmd string 19 | wantErr bool 20 | wantOut string 21 | wantOutRegex string 22 | wantOutFile string 23 | wantOutFileContent string 24 | } 25 | 26 | var usage = `Usage: 27 | dockerfilegraph [flags] 28 | 29 | Flags: 30 | -c, --concentrate concentrate the edges (default false) 31 | -d, --dpi uint dots per inch of the PNG export (default 96) 32 | -e, --edgestyle style of the graph edges, one of: default, solid (default default) 33 | -f, --filename string name of the Dockerfile (default "Dockerfile") 34 | -h, --help help for dockerfilegraph 35 | --layers display all layers (default false) 36 | --legend add a legend (default false) 37 | -m, --max-label-length uint maximum length of the node labels, must be at least 4 (default 20) 38 | -n, --nodesep float minimum space between two adjacent nodes in the same rank (default 1) 39 | -o, --output output file format, one of: canon, dot, pdf, png, raw, svg (default pdf) 40 | -r, --ranksep float minimum separation between ranks (default 0.5) 41 | -u, --unflatten uint stagger length of leaf edges between [1,u] (default 0) 42 | --version display the version of dockerfilegraph 43 | ` 44 | 45 | var dockerfileContent = ` 46 | ### TLS root certs and non-root user 47 | FROM ubuntu:latest AS ubuntu 48 | 49 | RUN \ 50 | apt-get update \ 51 | && apt-get install -y --no-install-recommends \ 52 | ca-certificates \ 53 | && rm -rf /var/lib/apt/lists/* 54 | 55 | # --- 56 | 57 | FROM golang:1.19 AS build-tool-dependencies 58 | RUN --mount=type=cache,from=buildcache,source=/go/pkg/mod/cache/,target=/go/pkg/mod/cache/ go build 59 | 60 | # --- 61 | 62 | FROM scratch AS release 63 | 64 | COPY --from=ubuntu /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 65 | COPY --from=build-tool-dependencies . . 66 | 67 | ENTRYPOINT ["/example"] 68 | ` 69 | 70 | func TestRootCmd(t *testing.T) { 71 | tests := []test{ 72 | { 73 | name: "help flag", 74 | cliArgs: []string{"--help"}, 75 | wantOut: `dockerfilegraph visualizes your multi-stage Dockerfile. 76 | It creates a visual graph representation of the build process. 77 | 78 | ` + usage, 79 | }, 80 | { 81 | name: "version flag", 82 | cliArgs: []string{"--version"}, 83 | wantOut: `{` + 84 | `"GitVersion":"v0.0.0-dev",` + 85 | `"GitCommit":"da39a3ee5e6b4b0d3255bfef95601890afd80709",` + 86 | `"BuildDate":"0000-00-00T00:00:00Z"} 87 | `, 88 | }, 89 | { 90 | name: "no args", 91 | wantOut: "Successfully created Dockerfile.pdf\n", 92 | }, 93 | { 94 | name: "empty Dockerfile", 95 | dockerfileContent: " ", // space is needed so that the default Dockerfile is not used 96 | wantErr: true, 97 | wantOut: "Error: file with no instructions\n" + usage + "\n", 98 | }, 99 | { 100 | name: "graphviz not installed", 101 | dotCmd: "dot-not-found-in-path", 102 | wantErr: true, 103 | wantOut: "Error: exec: \"dot-not-found-in-path\": executable file not found in $PATH\n" + usage + "\n", 104 | }, 105 | { 106 | name: "--max-label-length too small", 107 | cliArgs: []string{"--max-label-length", "3"}, 108 | wantErr: true, 109 | wantOut: "Error: --max-label-length must be at least 4\n" + usage + "\n", 110 | }, 111 | { 112 | name: "output flag dot", 113 | cliArgs: []string{"--output", "dot"}, 114 | wantOut: "Successfully created Dockerfile.dot\n", 115 | wantOutFile: "Dockerfile.dot", 116 | }, 117 | { 118 | name: "output flag pdf", 119 | cliArgs: []string{"-o", "pdf"}, 120 | wantOut: "Successfully created Dockerfile.pdf\n", 121 | wantOutFile: "Dockerfile.pdf", 122 | }, 123 | { 124 | name: "output flag png", 125 | cliArgs: []string{"--output", "png"}, 126 | wantOut: "Successfully created Dockerfile.png\n", 127 | wantOutFile: "Dockerfile.png", 128 | }, 129 | { 130 | name: "output flag png with dpi", 131 | cliArgs: []string{"--output", "png", "--dpi", "200"}, 132 | wantOut: "Successfully created Dockerfile.png\n", 133 | wantOutFile: "Dockerfile.png", 134 | }, 135 | { 136 | name: "output flag svg", 137 | cliArgs: []string{"--output", "svg"}, 138 | wantOut: "Successfully created Dockerfile.svg\n", 139 | wantOutFile: "Dockerfile.svg", 140 | }, 141 | { 142 | name: "filename flag", 143 | cliArgs: []string{"--filename", "subdir/../Dockerfile"}, 144 | wantOut: "Successfully created Dockerfile.pdf\n", 145 | wantOutFile: "Dockerfile.pdf", 146 | }, 147 | { 148 | name: "filename flag with missing Dockerfile", 149 | cliArgs: []string{"--filename", "Dockerfile.missing"}, 150 | wantErr: true, 151 | wantOutRegex: "^Error: could not find a Dockerfile at .+Dockerfile.missing\n", 152 | }, 153 | { 154 | name: "layers flag", 155 | cliArgs: []string{"--layers", "-o", "raw"}, 156 | wantOut: "Successfully created Dockerfile.raw\n", 157 | wantOutFile: "Dockerfile.raw", 158 | //nolint:lll 159 | wantOutFileContent: `digraph G { 160 | compound=true; 161 | nodesep=1.00; 162 | rankdir=LR; 163 | ranksep=0.50; 164 | stage_0_layer_0->stage_0_layer_1; 165 | external_image_0->stage_0_layer_0; 166 | stage_1_layer_0->stage_1_layer_1; 167 | external_image_1->stage_1_layer_0; 168 | external_image_2->stage_1_layer_1[ arrowhead=ediamond, style=dotted ]; 169 | stage_2_layer_0->stage_2_layer_1; 170 | stage_2_layer_1->stage_2_layer_2; 171 | stage_2_layer_2->stage_2_layer_3; 172 | external_image_3->stage_2_layer_0; 173 | stage_0_layer_1->stage_2_layer_1[ arrowhead=empty, ltail=cluster_stage_0, style=dashed ]; 174 | stage_1_layer_1->stage_2_layer_2[ arrowhead=empty, ltail=cluster_stage_1, style=dashed ]; 175 | subgraph cluster_stage_0 { 176 | label=ubuntu; 177 | margin=16; 178 | stage_0_layer_0 [ fillcolor=white, label="FROM ubuntu:lates...", penwidth=0.5, shape=box, style="filled,rounded", width=2 ]; 179 | stage_0_layer_1 [ fillcolor=white, label="RUN apt-get updat...", penwidth=0.5, shape=box, style="filled,rounded", width=2 ]; 180 | 181 | } 182 | ; 183 | subgraph cluster_stage_1 { 184 | label="build-tool-dependencies"; 185 | margin=16; 186 | stage_1_layer_0 [ fillcolor=white, label="FROM golang:1.19 ...", penwidth=0.5, shape=box, style="filled,rounded", width=2 ]; 187 | stage_1_layer_1 [ fillcolor=white, label="RUN --mount=type=...", penwidth=0.5, shape=box, style="filled,rounded", width=2 ]; 188 | 189 | } 190 | ; 191 | subgraph cluster_stage_2 { 192 | fillcolor=grey90; 193 | label=release; 194 | margin=16; 195 | style=filled; 196 | stage_2_layer_0 [ fillcolor=white, label="FROM scratch AS r...", penwidth=0.5, shape=box, style="filled,rounded", width=2 ]; 197 | stage_2_layer_1 [ fillcolor=white, label="COPY --from=ubunt...", penwidth=0.5, shape=box, style="filled,rounded", width=2 ]; 198 | stage_2_layer_2 [ fillcolor=white, label="COPY --from=build...", penwidth=0.5, shape=box, style="filled,rounded", width=2 ]; 199 | stage_2_layer_3 [ fillcolor=white, label="ENTRYPOINT ['/exa...", penwidth=0.5, shape=box, style="filled,rounded", width=2 ]; 200 | 201 | } 202 | ; 203 | external_image_0 [ color=grey20, fontcolor=grey20, label="ubuntu:latest", shape=box, style="dashed,rounded", width=2 ]; 204 | external_image_1 [ color=grey20, fontcolor=grey20, label="golang:1.19", shape=box, style="dashed,rounded", width=2 ]; 205 | external_image_2 [ color=grey20, fontcolor=grey20, label="buildcache", shape=box, style="dashed,rounded", width=2 ]; 206 | external_image_3 [ color=grey20, fontcolor=grey20, label="scratch", shape=box, style="dashed,rounded", width=2 ]; 207 | 208 | } 209 | `, 210 | }, 211 | { 212 | name: "layers flag with solid edges", 213 | cliArgs: []string{"--layers", "-e", "solid", "-o", "canon"}, 214 | wantOut: "Successfully created Dockerfile.canon\n", 215 | wantOutFile: "Dockerfile.canon", 216 | wantOutFileContent: `digraph G { 217 | graph [compound=true, 218 | nodesep=1.00, 219 | rankdir=LR, 220 | ranksep=0.50 221 | ]; 222 | node [label="\N"]; 223 | subgraph cluster_stage_0 { 224 | graph [label=ubuntu, 225 | margin=16 226 | ]; 227 | stage_0_layer_0 [fillcolor=white, 228 | label="FROM ubuntu:lates...", 229 | penwidth=0.5, 230 | shape=box, 231 | style="filled,rounded", 232 | width=2]; 233 | stage_0_layer_1 [fillcolor=white, 234 | label="RUN apt-get updat...", 235 | penwidth=0.5, 236 | shape=box, 237 | style="filled,rounded", 238 | width=2]; 239 | stage_0_layer_0 -> stage_0_layer_1; 240 | } 241 | subgraph cluster_stage_1 { 242 | graph [label="build-tool-dependencies", 243 | margin=16 244 | ]; 245 | stage_1_layer_0 [fillcolor=white, 246 | label="FROM golang:1.19 ...", 247 | penwidth=0.5, 248 | shape=box, 249 | style="filled,rounded", 250 | width=2]; 251 | stage_1_layer_1 [fillcolor=white, 252 | label="RUN --mount=type=...", 253 | penwidth=0.5, 254 | shape=box, 255 | style="filled,rounded", 256 | width=2]; 257 | stage_1_layer_0 -> stage_1_layer_1; 258 | } 259 | subgraph cluster_stage_2 { 260 | graph [fillcolor=grey90, 261 | label=release, 262 | margin=16, 263 | style=filled 264 | ]; 265 | stage_2_layer_0 [fillcolor=white, 266 | label="FROM scratch AS r...", 267 | penwidth=0.5, 268 | shape=box, 269 | style="filled,rounded", 270 | width=2]; 271 | stage_2_layer_1 [fillcolor=white, 272 | label="COPY --from=ubunt...", 273 | penwidth=0.5, 274 | shape=box, 275 | style="filled,rounded", 276 | width=2]; 277 | stage_2_layer_0 -> stage_2_layer_1; 278 | stage_2_layer_2 [fillcolor=white, 279 | label="COPY --from=build...", 280 | penwidth=0.5, 281 | shape=box, 282 | style="filled,rounded", 283 | width=2]; 284 | stage_2_layer_1 -> stage_2_layer_2; 285 | stage_2_layer_3 [fillcolor=white, 286 | label="ENTRYPOINT ['/exa...", 287 | penwidth=0.5, 288 | shape=box, 289 | style="filled,rounded", 290 | width=2]; 291 | stage_2_layer_2 -> stage_2_layer_3; 292 | } 293 | stage_0_layer_1 -> stage_2_layer_1 [arrowhead=empty, 294 | ltail=cluster_stage_0]; 295 | external_image_0 [color=grey20, 296 | fontcolor=grey20, 297 | label="ubuntu:latest", 298 | shape=box, 299 | style="dashed,rounded", 300 | width=2]; 301 | external_image_0 -> stage_0_layer_0; 302 | stage_1_layer_1 -> stage_2_layer_2 [arrowhead=empty, 303 | ltail=cluster_stage_1]; 304 | external_image_1 [color=grey20, 305 | fontcolor=grey20, 306 | label="golang:1.19", 307 | shape=box, 308 | style="dashed,rounded", 309 | width=2]; 310 | external_image_1 -> stage_1_layer_0; 311 | external_image_2 [color=grey20, 312 | fontcolor=grey20, 313 | label=buildcache, 314 | shape=box, 315 | style="dashed,rounded", 316 | width=2]; 317 | external_image_2 -> stage_1_layer_1 [arrowhead=ediamond]; 318 | external_image_3 [color=grey20, 319 | fontcolor=grey20, 320 | label=scratch, 321 | shape=box, 322 | style="dashed,rounded", 323 | width=2]; 324 | external_image_3 -> stage_2_layer_0; 325 | } 326 | `, 327 | }, 328 | { 329 | name: "legend flag with concentrated edges and unflattened", 330 | cliArgs: []string{"--legend", "-c", "-u", "2", "-o", "canon"}, 331 | wantOut: "Successfully created Dockerfile.canon\n", 332 | wantOutFile: "Dockerfile.canon", 333 | wantOutFileContent: `digraph G { 334 | graph [compound=true, 335 | concentrate=true, 336 | nodesep=1.00, 337 | rankdir=LR, 338 | ranksep=0.50 339 | ]; 340 | node [label="\N"]; 341 | subgraph cluster_legend { 342 | key [fontname=monospace, 343 | fontsize=10, 344 | label=< 345 | 346 | 347 | 348 |
FROM ... 
COPY --from=... 
RUN --mount=(.*)from=... 
>, 349 | shape=plaintext]; 350 | key2 [fontname=monospace, 351 | fontsize=10, 352 | label=< 353 | 354 | 355 | 356 |
 
 
 
>, 357 | shape=plaintext]; 358 | key:i0:e -> key2:i0:w; 359 | key:i1:e -> key2:i1:w [arrowhead=empty, 360 | style=dashed]; 361 | key:i2:e -> key2:i2:w [arrowhead=ediamond, 362 | style=dotted]; 363 | } 364 | external_image_0 [color=grey20, 365 | fontcolor=grey20, 366 | label="ubuntu:latest", 367 | shape=box, 368 | style="dashed,rounded", 369 | width=2]; 370 | stage_0 [label=ubuntu, 371 | shape=box, 372 | style=rounded, 373 | width=2]; 374 | external_image_0 -> stage_0 [minlen=1]; 375 | stage_2 [fillcolor=grey90, 376 | label=release, 377 | shape=box, 378 | style="filled,rounded", 379 | width=2]; 380 | stage_0 -> stage_2 [arrowhead=empty, 381 | style=dashed]; 382 | external_image_1 [color=grey20, 383 | fontcolor=grey20, 384 | label="golang:1.19", 385 | shape=box, 386 | style="dashed,rounded", 387 | width=2]; 388 | stage_1 [label="build-tool-depend...", 389 | shape=box, 390 | style=rounded, 391 | width=2]; 392 | external_image_1 -> stage_1 [minlen=1]; 393 | stage_1 -> stage_2 [arrowhead=empty, 394 | style=dashed]; 395 | external_image_2 [color=grey20, 396 | fontcolor=grey20, 397 | label=buildcache, 398 | shape=box, 399 | style="dashed,rounded", 400 | width=2]; 401 | external_image_2 -> stage_1 [arrowhead=ediamond, 402 | minlen=2, 403 | style=dotted]; 404 | external_image_3 [color=grey20, 405 | fontcolor=grey20, 406 | label=scratch, 407 | shape=box, 408 | style="dashed,rounded", 409 | width=2]; 410 | external_image_3 -> stage_2 [minlen=1]; 411 | } 412 | `, 413 | }, 414 | { 415 | name: "legend flag with solid edges", 416 | cliArgs: []string{"--legend", "-e", "solid", "-o", "canon"}, 417 | wantOut: "Successfully created Dockerfile.canon\n", 418 | wantOutFile: "Dockerfile.canon", 419 | wantOutFileContent: `digraph G { 420 | graph [compound=true, 421 | nodesep=1.00, 422 | rankdir=LR, 423 | ranksep=0.50 424 | ]; 425 | node [label="\N"]; 426 | subgraph cluster_legend { 427 | key [fontname=monospace, 428 | fontsize=10, 429 | label=< 430 | 431 | 432 | 433 |
FROM ... 
COPY --from=... 
RUN --mount=(.*)from=... 
>, 434 | shape=plaintext]; 435 | key2 [fontname=monospace, 436 | fontsize=10, 437 | label=< 438 | 439 | 440 | 441 |
 
 
 
>, 442 | shape=plaintext]; 443 | key:i0:e -> key2:i0:w; 444 | key:i1:e -> key2:i1:w [arrowhead=empty]; 445 | key:i2:e -> key2:i2:w [arrowhead=ediamond]; 446 | } 447 | external_image_0 [color=grey20, 448 | fontcolor=grey20, 449 | label="ubuntu:latest", 450 | shape=box, 451 | style="dashed,rounded", 452 | width=2]; 453 | stage_0 [label=ubuntu, 454 | shape=box, 455 | style=rounded, 456 | width=2]; 457 | external_image_0 -> stage_0; 458 | stage_2 [fillcolor=grey90, 459 | label=release, 460 | shape=box, 461 | style="filled,rounded", 462 | width=2]; 463 | stage_0 -> stage_2 [arrowhead=empty]; 464 | external_image_1 [color=grey20, 465 | fontcolor=grey20, 466 | label="golang:1.19", 467 | shape=box, 468 | style="dashed,rounded", 469 | width=2]; 470 | stage_1 [label="build-tool-depend...", 471 | shape=box, 472 | style=rounded, 473 | width=2]; 474 | external_image_1 -> stage_1; 475 | stage_1 -> stage_2 [arrowhead=empty]; 476 | external_image_2 [color=grey20, 477 | fontcolor=grey20, 478 | label=buildcache, 479 | shape=box, 480 | style="dashed,rounded", 481 | width=2]; 482 | external_image_2 -> stage_1 [arrowhead=ediamond]; 483 | external_image_3 [color=grey20, 484 | fontcolor=grey20, 485 | label=scratch, 486 | shape=box, 487 | style="dashed,rounded", 488 | width=2]; 489 | external_image_3 -> stage_2; 490 | } 491 | `, 492 | }, 493 | } 494 | 495 | for _, tt := range tests { 496 | // Create a fake filesystem for the input Dockerfile 497 | inputFS := afero.NewMemMapFs() 498 | if tt.dockerfileContent == "" { 499 | tt.dockerfileContent = dockerfileContent 500 | } 501 | _ = afero.WriteFile(inputFS, "Dockerfile", []byte(tt.dockerfileContent), 0644) 502 | 503 | t.Run(tt.name, func(t *testing.T) { 504 | buf := new(bytes.Buffer) 505 | 506 | if tt.dotCmd == "" { 507 | tt.dotCmd = "dot" 508 | } 509 | command := cmd.NewRootCmd(buf, inputFS, tt.dotCmd) 510 | command.SetArgs(tt.cliArgs) 511 | 512 | // Redirect Cobra output 513 | command.SetOut(buf) 514 | command.SetErr(buf) 515 | 516 | err := command.Execute() 517 | if (err != nil) != tt.wantErr { 518 | t.Errorf("%s: Execute() error = %v, wantErr %v", tt.name, err, tt.wantErr) 519 | } 520 | 521 | checkWantOut(t, tt, buf) 522 | 523 | if tt.wantOutFile != "" { 524 | _, err := os.Stat(tt.wantOutFile) 525 | if err != nil { 526 | t.Errorf("%s: %v", tt.name, err) 527 | } 528 | } 529 | 530 | if tt.wantOutFileContent != "" { 531 | outFileContent, err := os.ReadFile(tt.wantOutFile) 532 | if err != nil { 533 | t.Errorf("%s: %v", tt.name, err) 534 | } 535 | if diff := cmp.Diff(tt.wantOutFileContent, string(outFileContent)); diff != "" { 536 | t.Errorf("Output mismatch (-want +got):\n%s", diff) 537 | } 538 | } 539 | }) 540 | 541 | // Cleanup 542 | if tt.wantOutFile != "" { 543 | os.Remove(tt.wantOutFile) 544 | } 545 | } 546 | } 547 | 548 | func TestExecute(t *testing.T) { 549 | tests := []test{ 550 | { 551 | name: "should work", 552 | wantOutFile: "Dockerfile.pdf", 553 | }, 554 | } 555 | for _, tt := range tests { 556 | t.Run(tt.name, func(t *testing.T) { 557 | _ = os.WriteFile("Dockerfile", []byte(dockerfileContent), 0644) 558 | 559 | cmd.Execute() 560 | 561 | if tt.wantOutFile != "" { 562 | _, err := os.Stat(tt.wantOutFile) 563 | if err != nil { 564 | t.Errorf("%s: %v", tt.name, err) 565 | } 566 | } 567 | 568 | // Cleanup 569 | os.Remove("Dockerfile") 570 | os.Remove(tt.wantOutFile) 571 | }) 572 | } 573 | } 574 | 575 | func checkWantOut(t *testing.T, tt test, buf *bytes.Buffer) { 576 | if tt.wantOut == "" && tt.wantOutRegex == "" { 577 | t.Fatalf("Either wantOut or wantOutRegex must be set") 578 | } 579 | if tt.wantOut != "" && tt.wantOutRegex != "" { 580 | t.Fatalf("wantOut and wantOutRegex cannot be set at the same time") 581 | } 582 | 583 | if tt.wantOut != "" { 584 | if diff := cmp.Diff(tt.wantOut, buf.String()); diff != "" { 585 | t.Errorf("Output mismatch (-want +got):\n%s", diff) 586 | } 587 | } else if tt.wantOutRegex != "" { 588 | matched, err := regexp.Match(tt.wantOutRegex, buf.Bytes()) 589 | if err != nil { 590 | t.Errorf("Error compiling regex: %v", err) 591 | } 592 | if !matched { 593 | t.Errorf( 594 | "Output mismatch (-want +got):\n%s", 595 | cmp.Diff(tt.wantOutRegex, buf.String()), 596 | ) 597 | } 598 | } 599 | } 600 | -------------------------------------------------------------------------------- /internal/cmd/testdata/Dockerfile.golden.dot: -------------------------------------------------------------------------------- 1 | digraph G { 2 | graph [bb="0,0,541.25,252", 3 | compound=true, 4 | nodesep=1.00, 5 | rankdir=LR, 6 | ranksep=0.50 7 | ]; 8 | node [label="\N"]; 9 | external_image_0 [color=grey20, 10 | fontcolor=grey20, 11 | height=0.5, 12 | label="ubuntu:l...887c2c7ac", 13 | pos="85.625,234", 14 | shape=box, 15 | style="dashed,rounded", 16 | width=2.2951]; 17 | stage_0 [height=0.5, 18 | label=ubuntu, 19 | pos="284.25,234", 20 | shape=box, 21 | style=rounded, 22 | width=2]; 23 | external_image_0 -> stage_0 [pos="e,212.06,234 168.59,234 179.19,234 190.05,234 200.66,234"]; 24 | stage_2 [fillcolor=grey90, 25 | height=0.5, 26 | label=release, 27 | pos="469.25,126", 28 | shape=box, 29 | style="filled,rounded", 30 | width=2]; 31 | stage_0 -> stage_2 [arrowhead=empty, 32 | pos="e,437.05,144.41 316.29,215.68 346.92,197.6 393.91,169.87 427.52,150.04", 33 | style=dashed]; 34 | external_image_1 [color=grey20, 35 | fontcolor=grey20, 36 | height=0.5, 37 | label="golang:1...b738433da", 38 | pos="85.625,126", 39 | shape=box, 40 | style="dashed,rounded", 41 | width=2.3785]; 42 | stage_1 [height=0.5, 43 | label="build-tool-depend...", 44 | pos="284.25,126", 45 | shape=box, 46 | style=rounded, 47 | width=2.1389]; 48 | external_image_1 -> stage_1 [pos="e,206.99,126 171.72,126 179.6,126 187.6,126 195.5,126"]; 49 | stage_1 -> stage_2 [arrowhead=empty, 50 | pos="e,396.78,126 361.53,126 369.37,126 377.37,126 385.28,126", 51 | style=dashed]; 52 | external_image_2 [color=grey20, 53 | fontcolor=grey20, 54 | height=0.5, 55 | label=buildcache, 56 | pos="85.625,18", 57 | shape=box, 58 | style="dashed,rounded", 59 | width=2]; 60 | external_image_2 -> stage_1 [arrowhead=ediamond, 61 | pos="e,249.73,107.59 119.98,36.321 152.61,54.241 202.51,81.651 238.55,101.45", 62 | style=dotted]; 63 | external_image_3 [color=grey20, 64 | fontcolor=grey20, 65 | height=0.5, 66 | label=scratch, 67 | pos="284.25,18", 68 | shape=box, 69 | style="dashed,rounded", 70 | width=2]; 71 | external_image_3 -> stage_2 [pos="e,437.05,107.59 316.29,36.321 346.92,54.396 393.91,82.126 427.52,101.96"]; 72 | } 73 | -------------------------------------------------------------------------------- /internal/cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | ) 8 | 9 | var ( 10 | shortFlag bool 11 | gitVersion = "v0.0.0-dev" 12 | gitCommit = "da39a3ee5e6b4b0d3255bfef95601890afd80709" 13 | buildDate = "0000-00-00T00:00:00Z" 14 | ) 15 | 16 | // VersionInfo holds the version information for a build of dockerfilegraph. 17 | type VersionInfo struct { 18 | GitVersion string 19 | GitCommit string 20 | BuildDate string 21 | } 22 | 23 | func printVersion(dfgWriter io.Writer) (err error) { 24 | if shortFlag { 25 | fmt.Fprintf(dfgWriter, "%s\n", gitVersion) 26 | } else { 27 | var versionInfo []byte 28 | versionInfo, err = json.Marshal(VersionInfo{ 29 | GitVersion: gitVersion, 30 | GitCommit: gitCommit, 31 | BuildDate: buildDate, 32 | }) 33 | if err != nil { 34 | return 35 | } 36 | 37 | fmt.Fprintf(dfgWriter, "%s\n", string(versionInfo)) 38 | } 39 | 40 | return 41 | } 42 | -------------------------------------------------------------------------------- /internal/dockerfile2dot/build.go: -------------------------------------------------------------------------------- 1 | package dockerfile2dot 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/aquilax/truncate" 8 | "github.com/awalterschulze/gographviz" 9 | ) 10 | 11 | // BuildDotFile builds a GraphViz .dot file from a simplified Dockerfile 12 | func BuildDotFile( 13 | simplifiedDockerfile SimplifiedDockerfile, 14 | concentrate bool, 15 | edgestyle string, 16 | layers bool, 17 | legend bool, 18 | maxLabelLength int, 19 | nodesep string, 20 | ranksep string, 21 | ) string { 22 | // Create a new graph 23 | graph := gographviz.NewEscape() 24 | _ = graph.SetName("G") 25 | _ = graph.SetDir(true) 26 | _ = graph.AddAttr("G", "compound", "true") // allow edges between clusters 27 | _ = graph.AddAttr("G", "nodesep", nodesep) 28 | _ = graph.AddAttr("G", "rankdir", "LR") 29 | _ = graph.AddAttr("G", "ranksep", ranksep) 30 | if concentrate { 31 | _ = graph.AddAttr("G", "concentrate", "true") 32 | } 33 | 34 | // Add the legend if requested 35 | if legend { 36 | addLegend(graph, edgestyle) 37 | } 38 | 39 | // Add the external images 40 | for externalImageIndex, externalImage := range simplifiedDockerfile.ExternalImages { 41 | label := externalImage.Name 42 | if len(label) > maxLabelLength { 43 | truncatePosition := truncate.PositionMiddle 44 | if maxLabelLength < 5 { 45 | truncatePosition = truncate.PositionEnd 46 | } 47 | label = truncate.Truncate(label, maxLabelLength, "...", truncatePosition) 48 | } 49 | 50 | _ = graph.AddNode( 51 | "G", 52 | fmt.Sprintf("external_image_%d", externalImageIndex), 53 | map[string]string{ 54 | "label": "\"" + label + "\"", 55 | "shape": "box", 56 | "width": "2", 57 | "style": "\"dashed,rounded\"", 58 | "color": "grey20", 59 | "fontcolor": "grey20", 60 | }, 61 | ) 62 | } 63 | 64 | for stageIndex, stage := range simplifiedDockerfile.Stages { 65 | attrs := map[string]string{ 66 | "label": "\"" + getStageLabel(stageIndex, stage, maxLabelLength) + "\"", 67 | "shape": "box", 68 | "style": "rounded", 69 | "width": "2", 70 | } 71 | 72 | // Add layers if requested 73 | if layers { 74 | cluster := fmt.Sprintf("cluster_stage_%d", stageIndex) 75 | 76 | clusterAttrs := map[string]string{ 77 | "label": getStageLabel(stageIndex, stage, 0), 78 | "margin": "16", 79 | } 80 | 81 | if stageIndex == len(simplifiedDockerfile.Stages)-1 { 82 | clusterAttrs["style"] = "filled" 83 | clusterAttrs["fillcolor"] = "grey90" 84 | } 85 | 86 | _ = graph.AddSubGraph("G", cluster, clusterAttrs) 87 | 88 | for layerIndex, layer := range stage.Layers { 89 | attrs["label"] = "\"" + layer.Label + "\"" 90 | attrs["penwidth"] = "0.5" 91 | attrs["style"] = "\"filled,rounded\"" 92 | attrs["fillcolor"] = "white" 93 | _ = graph.AddNode( 94 | cluster, 95 | fmt.Sprintf("stage_%d_layer_%d", stageIndex, layerIndex), 96 | attrs, 97 | ) 98 | 99 | // Add edges between layers to guarantee the correct order 100 | if layerIndex > 0 { 101 | _ = graph.AddEdge( 102 | fmt.Sprintf("stage_%d_layer_%d", stageIndex, layerIndex-1), 103 | fmt.Sprintf("stage_%d_layer_%d", stageIndex, layerIndex), 104 | true, 105 | nil, 106 | ) 107 | } 108 | } 109 | } else { 110 | // Add the build stages. 111 | // Color the last one, because it is the default build target. 112 | if stageIndex == len(simplifiedDockerfile.Stages)-1 { 113 | attrs["style"] = "\"filled,rounded\"" 114 | attrs["fillcolor"] = "grey90" 115 | } 116 | 117 | _ = graph.AddNode("G", fmt.Sprintf("stage_%d", stageIndex), attrs) 118 | } 119 | 120 | // Add the egdes for this build stage 121 | addEdgesForStage( 122 | stageIndex, stage, graph, simplifiedDockerfile, layers, edgestyle, 123 | ) 124 | } 125 | 126 | // Add the ARGS that appear before the first stage, if layers are requested 127 | if layers { 128 | if len(simplifiedDockerfile.BeforeFirstStage) > 0 { 129 | _ = graph.AddSubGraph( 130 | "G", 131 | "cluster_before_first_stage", 132 | map[string]string{"label": "Before First Stage"}, 133 | ) 134 | for argIndex, arg := range simplifiedDockerfile.BeforeFirstStage { 135 | _ = graph.AddNode( 136 | "cluster_before_first_stage", 137 | fmt.Sprintf("before_first_stage_%d", argIndex), 138 | map[string]string{ 139 | "label": arg.Label, 140 | "shape": "box", 141 | "style": "rounded", 142 | "width": "2", 143 | }, 144 | ) 145 | } 146 | } 147 | } 148 | 149 | return graph.String() 150 | } 151 | 152 | func addEdgesForStage( 153 | stageIndex int, stage Stage, graph *gographviz.Escape, 154 | simplifiedDockerfile SimplifiedDockerfile, layers bool, edgestyle string, 155 | ) { 156 | for layerIndex, layer := range stage.Layers { 157 | for _, waitFor := range layer.WaitFors { 158 | edgeAttrs := map[string]string{} 159 | if waitFor.Type == waitForType(waitForCopy) { 160 | edgeAttrs["arrowhead"] = "empty" 161 | if edgestyle == "default" { 162 | edgeAttrs["style"] = "dashed" 163 | } 164 | } else if waitFor.Type == waitForType(waitForMount) { 165 | edgeAttrs["arrowhead"] = "ediamond" 166 | if edgestyle == "default" { 167 | edgeAttrs["style"] = "dotted" 168 | } 169 | } 170 | 171 | sourceNodeID, additionalEdgeAttrs := getWaitForNodeID( 172 | simplifiedDockerfile, waitFor.Name, layers, 173 | ) 174 | for k, v := range additionalEdgeAttrs { 175 | edgeAttrs[k] = v 176 | } 177 | 178 | targetNodeID := fmt.Sprintf("stage_%d", stageIndex) 179 | if layers { 180 | targetNodeID = targetNodeID + fmt.Sprintf("_layer_%d", layerIndex) 181 | } 182 | 183 | _ = graph.AddEdge(sourceNodeID, targetNodeID, true, edgeAttrs) 184 | } 185 | } 186 | } 187 | 188 | func addLegend(graph *gographviz.Escape, edgestyle string) { 189 | _ = graph.AddSubGraph("G", "cluster_legend", nil) 190 | 191 | _ = graph.AddNode("cluster_legend", "key", 192 | map[string]string{ 193 | "shape": "plaintext", 194 | "fontname": "monospace", 195 | "fontsize": "10", 196 | "label": `< 197 | 198 | 199 | 200 |
FROM ... 
COPY --from=... 
RUN --mount=(.*)from=... 
>`, 201 | }, 202 | ) 203 | _ = graph.AddNode("cluster_legend", "key2", 204 | map[string]string{ 205 | "shape": "plaintext", 206 | "fontname": "monospace", 207 | "fontsize": "10", 208 | "label": `< 209 | 210 | 211 | 212 |
 
 
 
>`, 213 | }, 214 | ) 215 | 216 | _ = graph.AddPortEdge("key", "i0:e", "key2", "i0:w", true, nil) 217 | 218 | copyEdgeAttrs := map[string]string{"arrowhead": "empty"} 219 | if edgestyle == "default" { 220 | copyEdgeAttrs["style"] = "dashed" 221 | } 222 | _ = graph.AddPortEdge( 223 | "key", "i1:e", "key2", "i1:w", true, 224 | copyEdgeAttrs, 225 | ) 226 | 227 | mountEdgeAttrs := map[string]string{"arrowhead": "ediamond"} 228 | if edgestyle == "default" { 229 | mountEdgeAttrs["style"] = "dotted" 230 | } 231 | _ = graph.AddPortEdge( 232 | "key", "i2:e", "key2", "i2:w", true, 233 | mountEdgeAttrs, 234 | ) 235 | } 236 | 237 | func getStageLabel(stageIndex int, stage Stage, maxLabelLength int) string { 238 | if maxLabelLength > 0 && len(stage.Name) > maxLabelLength { 239 | return truncate.Truncate( 240 | stage.Name, maxLabelLength, "...", truncate.PositionEnd, 241 | ) 242 | } 243 | 244 | if stage.Name == "" { 245 | return fmt.Sprintf("%d", stageIndex) 246 | } 247 | 248 | return stage.Name 249 | } 250 | 251 | // getWaitForNodeID returns the ID of the node identified by the stage ID or 252 | // name or the external image name. 253 | func getWaitForNodeID( 254 | simplifiedDockerfile SimplifiedDockerfile, nameOrID string, layers bool, 255 | ) (nodeID string, attrs map[string]string) { 256 | attrs = map[string]string{} 257 | 258 | // If it can be converted to an integer, it's a stage ID 259 | if stageIndex, convertErr := strconv.Atoi(nameOrID); convertErr == nil { 260 | if layers { 261 | // Return the last layer of the stage 262 | nodeID = fmt.Sprintf( 263 | "stage_%d_layer_%d", 264 | stageIndex, len(simplifiedDockerfile.Stages[stageIndex].Layers)-1, 265 | ) 266 | attrs["ltail"] = fmt.Sprintf("cluster_stage_%d", stageIndex) 267 | } else { 268 | nodeID = fmt.Sprintf("stage_%d", stageIndex) 269 | } 270 | return 271 | } 272 | 273 | // Check if it's a stage name 274 | for stageIndex, stage := range simplifiedDockerfile.Stages { 275 | if nameOrID == stage.Name { 276 | if layers { 277 | // Return the last layer of the stage 278 | nodeID = fmt.Sprintf( 279 | "stage_%d_layer_%d", 280 | stageIndex, len(simplifiedDockerfile.Stages[stageIndex].Layers)-1, 281 | ) 282 | attrs["ltail"] = fmt.Sprintf("cluster_stage_%d", stageIndex) 283 | } else { 284 | nodeID = fmt.Sprintf("stage_%d", stageIndex) 285 | } 286 | return 287 | } 288 | } 289 | 290 | // Check if it's an external image name 291 | for externalImageIndex, externalImage := range simplifiedDockerfile.ExternalImages { 292 | if nameOrID == externalImage.Name { 293 | nodeID = fmt.Sprintf("external_image_%d", externalImageIndex) 294 | return 295 | } 296 | } 297 | 298 | panic("Could not find node ID for " + nameOrID) 299 | } 300 | -------------------------------------------------------------------------------- /internal/dockerfile2dot/build_test.go: -------------------------------------------------------------------------------- 1 | package dockerfile2dot 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestBuildDotFile(t *testing.T) { 9 | type args struct { 10 | simplifiedDockerfile SimplifiedDockerfile 11 | concentrate bool 12 | edgestyle string 13 | layers bool 14 | legend bool 15 | maxLabelLength int 16 | nodesep string 17 | ranksep string 18 | } 19 | tests := []struct { 20 | name string 21 | args args 22 | wantContains string 23 | }{ 24 | { 25 | name: "legend", 26 | args: args{ 27 | simplifiedDockerfile: SimplifiedDockerfile{ 28 | BeforeFirstStage: []Layer{ 29 | { 30 | Label: "ARG...", 31 | }, 32 | }, 33 | ExternalImages: []ExternalImage{ 34 | {Name: "build"}, 35 | {Name: "release"}, 36 | }, 37 | Stages: []Stage{ 38 | { 39 | Layers: []Layer{ 40 | { 41 | Label: "FROM...", 42 | WaitFors: []WaitFor{{ 43 | Name: "build", 44 | Type: waitForType(waitForFrom), 45 | }}, 46 | }, 47 | }, 48 | }, 49 | }, 50 | }, 51 | edgestyle: "default", 52 | legend: true, 53 | maxLabelLength: 20, 54 | nodesep: "0.5", 55 | ranksep: "0.5", 56 | }, 57 | wantContains: "release", 58 | }, 59 | { 60 | name: "layers", 61 | args: args{ 62 | simplifiedDockerfile: SimplifiedDockerfile{ 63 | BeforeFirstStage: []Layer{ 64 | { 65 | Label: "ARG...", 66 | }, 67 | }, 68 | ExternalImages: []ExternalImage{ 69 | {Name: "build"}, 70 | {Name: "release"}, 71 | }, 72 | Stages: []Stage{ 73 | { 74 | Layers: []Layer{ 75 | { 76 | Label: "FROM...", 77 | WaitFors: []WaitFor{{ 78 | Name: "build", 79 | Type: waitForType(waitForFrom), 80 | }}, 81 | }, 82 | }, 83 | }, 84 | }, 85 | }, 86 | edgestyle: "default", 87 | layers: true, 88 | maxLabelLength: 20, 89 | nodesep: "0.5", 90 | ranksep: "0.5", 91 | }, 92 | wantContains: "release", 93 | }, 94 | } 95 | for _, tt := range tests { 96 | t.Run(tt.name, func(t *testing.T) { 97 | if got := BuildDotFile( 98 | tt.args.simplifiedDockerfile, 99 | tt.args.concentrate, 100 | tt.args.edgestyle, 101 | tt.args.layers, 102 | tt.args.legend, 103 | tt.args.maxLabelLength, 104 | tt.args.nodesep, 105 | tt.args.ranksep, 106 | ); !strings.Contains(got, tt.wantContains) { 107 | t.Errorf( 108 | "BuildDotFile() = %v, did not contain %v", got, tt.wantContains, 109 | ) 110 | } 111 | }) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /internal/dockerfile2dot/convert.go: -------------------------------------------------------------------------------- 1 | package dockerfile2dot 2 | 3 | import ( 4 | "bytes" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/aquilax/truncate" 9 | "github.com/moby/buildkit/frontend/dockerfile/parser" 10 | ) 11 | 12 | // ArgReplacement holds a key-value pair for ARG variable substitution in Dockerfiles. 13 | type ArgReplacement struct { 14 | Key string 15 | Value string 16 | } 17 | 18 | const ( 19 | instructionFrom = "FROM" 20 | instructionCopy = "COPY" 21 | instructionRun = "RUN" 22 | instructionArg = "ARG" 23 | ) 24 | 25 | var ( 26 | dollarVarRegex = regexp.MustCompile(`\$([A-Za-z_][A-Za-z0-9_]*)`) 27 | bracedVarRegex = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`) 28 | fromFlagRegex = regexp.MustCompile("--from=(.+)") 29 | mountFlagRegex = regexp.MustCompile("--mount=.*from=(.+?)(?:,| |$)") 30 | ) 31 | 32 | // newLayer creates a new layer object with a modified label. 33 | func newLayer( 34 | node *parser.Node, argReplacements []ArgReplacement, maxLabelLength int, 35 | ) (layer Layer) { 36 | // Replace argument variables in the original label. 37 | label := replaceArgVars(node.Original, argReplacements) 38 | 39 | // Replace double quotes with single quotes. 40 | label = strings.ReplaceAll(label, "\"", "'") 41 | 42 | // Collapse multiple spaces into a single space. 43 | label = strings.Join(strings.Fields(label), " ") 44 | 45 | // Truncate the label if it exceeds the maximum length. 46 | if len(label) > maxLabelLength { 47 | label = truncate.Truncate(label, maxLabelLength, "...", truncate.PositionEnd) 48 | } 49 | 50 | // Set the label of the layer object. 51 | layer.Label = label 52 | 53 | return 54 | } 55 | 56 | func dockerfileToSimplifiedDockerfile( 57 | content []byte, 58 | maxLabelLength int, 59 | ) (simplifiedDockerfile SimplifiedDockerfile, err error) { 60 | result, err := parser.Parse(bytes.NewReader(content)) 61 | if err != nil { 62 | return 63 | } 64 | 65 | // Set that holds all stage IDs 66 | stages := make(map[string]struct{}) 67 | 68 | stageIndex := -1 69 | layerIndex := -1 70 | 71 | argReplacements := make([]ArgReplacement, 0) 72 | 73 | for _, node := range result.AST.Children { 74 | switch strings.ToUpper(node.Value) { 75 | case instructionFrom: 76 | // Create a new stage 77 | stageIndex++ 78 | stage := Stage{} 79 | 80 | // If there is an "AS" alias, set it as the name 81 | if node.Next.Next != nil { 82 | stage.Name = node.Next.Next.Next.Value 83 | stages[stage.Name] = struct{}{} 84 | } 85 | 86 | simplifiedDockerfile.Stages = append(simplifiedDockerfile.Stages, stage) 87 | 88 | // Add a new layer 89 | layerIndex = 0 90 | layer := newLayer(node, argReplacements, maxLabelLength) 91 | 92 | // Set the waitFor ID 93 | layer.WaitFors = []WaitFor{{ 94 | Name: replaceArgVars(node.Next.Value, argReplacements), 95 | Type: waitForType(waitForFrom), 96 | }} 97 | 98 | simplifiedDockerfile.Stages[stageIndex].Layers = append( 99 | simplifiedDockerfile.Stages[stageIndex].Layers, 100 | layer, 101 | ) 102 | 103 | case instructionCopy: 104 | // Add a new layer 105 | layerIndex++ 106 | layer := newLayer(node, argReplacements, maxLabelLength) 107 | 108 | // If there is a "--from" option, set the waitFor ID 109 | for _, flag := range node.Flags { 110 | result := fromFlagRegex.FindSubmatch([]byte(flag)) 111 | if len(result) > 1 { 112 | layer.WaitFors = []WaitFor{{ 113 | Name: string(result[1]), 114 | Type: waitForType(waitForCopy), 115 | }} 116 | } 117 | } 118 | 119 | simplifiedDockerfile.Stages[stageIndex].Layers = append( 120 | simplifiedDockerfile.Stages[stageIndex].Layers, 121 | layer, 122 | ) 123 | 124 | case instructionRun: 125 | // Add a new layer 126 | layerIndex++ 127 | layer := newLayer(node, argReplacements, maxLabelLength) 128 | 129 | // If there is a "--mount=(.*)from=..." option, set the waitFor ID 130 | for _, flag := range node.Flags { 131 | matches := mountFlagRegex.FindAllSubmatch([]byte(flag), -1) 132 | for _, match := range matches { 133 | if len(match) > 1 { 134 | layer.WaitFors = append(layer.WaitFors, WaitFor{ 135 | Name: string(match[1]), 136 | Type: waitForType(waitForMount), 137 | }) 138 | } 139 | } 140 | } 141 | 142 | simplifiedDockerfile.Stages[stageIndex].Layers = append( 143 | simplifiedDockerfile.Stages[stageIndex].Layers, 144 | layer, 145 | ) 146 | 147 | default: 148 | // Add a new layer 149 | layerIndex++ 150 | layer := newLayer(node, argReplacements, maxLabelLength) 151 | 152 | if stageIndex == -1 { 153 | simplifiedDockerfile.BeforeFirstStage = append( 154 | simplifiedDockerfile.BeforeFirstStage, 155 | layer, 156 | ) 157 | 158 | // NOTE: Currently, only global ARGs (defined before the first FROM instruction) 159 | // are processed for variable substitution. Stage-specific ARGs are not yet fully supported. 160 | if strings.ToUpper(node.Value) == instructionArg { 161 | key, value, valueProvided := strings.Cut(node.Next.Value, "=") 162 | if valueProvided { 163 | argReplacements = appendAndResolveArgReplacement(argReplacements, ArgReplacement{Key: key, Value: value}) 164 | } 165 | } 166 | 167 | break 168 | } 169 | 170 | simplifiedDockerfile.Stages[stageIndex].Layers = append( 171 | simplifiedDockerfile.Stages[stageIndex].Layers, 172 | layer, 173 | ) 174 | } 175 | } 176 | 177 | addExternalImages(&simplifiedDockerfile, stages) 178 | 179 | return 180 | } 181 | 182 | func addExternalImages( 183 | simplifiedDockerfile *SimplifiedDockerfile, stages map[string]struct{}, 184 | ) { 185 | for _, stage := range simplifiedDockerfile.Stages { 186 | for _, layer := range stage.Layers { 187 | for _, waitFor := range layer.WaitFors { 188 | 189 | // Check if the layer waits for a stage 190 | if _, ok := stages[waitFor.Name]; ok { 191 | continue 192 | } 193 | 194 | // Check if we already added the external image 195 | externalImageAlreadyAdded := false 196 | for _, externalImage := range simplifiedDockerfile.ExternalImages { 197 | if externalImage.Name == waitFor.Name { 198 | externalImageAlreadyAdded = true 199 | break 200 | } 201 | } 202 | if externalImageAlreadyAdded { 203 | continue 204 | } 205 | 206 | // Add the external image 207 | simplifiedDockerfile.ExternalImages = append( 208 | simplifiedDockerfile.ExternalImages, 209 | ExternalImage{Name: waitFor.Name}, 210 | ) 211 | } 212 | } 213 | } 214 | } 215 | 216 | // appendAndResolveArgReplacement appends a new ARG and resolves its value using already-resolved previous ARGs. 217 | func appendAndResolveArgReplacement( 218 | argReplacements []ArgReplacement, 219 | newArgReplacement ArgReplacement, 220 | ) []ArgReplacement { 221 | // Resolve the new ARG using previous, already-resolved ARGs 222 | resolvedValue := newArgReplacement.Value 223 | for _, prevArg := range argReplacements { 224 | resolvedValue = strings.ReplaceAll(resolvedValue, "$"+prevArg.Key, prevArg.Value) 225 | resolvedValue = strings.ReplaceAll(resolvedValue, "${"+prevArg.Key+"}", prevArg.Value) 226 | } 227 | // Remove any remaining ARG patterns 228 | resolvedValue = stripRemainingArgPatterns(resolvedValue) 229 | return append(argReplacements, ArgReplacement{Key: newArgReplacement.Key, Value: resolvedValue}) 230 | } 231 | 232 | // stripRemainingArgPatterns replaces any remaining $VAR or ${VAR} patterns in s with an empty string. 233 | // It's intended to be called after defined ARGs have already been substituted into s. 234 | func stripRemainingArgPatterns(s string) string { 235 | s = dollarVarRegex.ReplaceAllString(s, "") 236 | s = bracedVarRegex.ReplaceAllString(s, "") 237 | return s 238 | } 239 | 240 | // replaceArgVars replaces ARG variables in a string using fully resolved replacements. 241 | func replaceArgVars(baseImage string, resolvedReplacements []ArgReplacement) string { 242 | result := baseImage 243 | for _, r := range resolvedReplacements { 244 | result = strings.ReplaceAll(result, "$"+r.Key, r.Value) 245 | result = strings.ReplaceAll(result, "${"+r.Key+"}", r.Value) 246 | } 247 | return result 248 | } 249 | -------------------------------------------------------------------------------- /internal/dockerfile2dot/convert_test.go: -------------------------------------------------------------------------------- 1 | package dockerfile2dot 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | ) 9 | 10 | func Test_dockerfileToSimplifiedDockerfile(t *testing.T) { 11 | type args struct { 12 | content []byte 13 | maxLabelLength int 14 | } 15 | tests := []struct { 16 | name string 17 | args args 18 | want SimplifiedDockerfile 19 | }{ 20 | { 21 | name: "Most minimal Dockerfile", 22 | args: args{ 23 | content: []byte("FROM scratch"), 24 | maxLabelLength: 20, 25 | }, 26 | want: SimplifiedDockerfile{ 27 | ExternalImages: []ExternalImage{ 28 | {Name: "scratch"}, 29 | }, 30 | Stages: []Stage{ 31 | { 32 | Layers: []Layer{ 33 | { 34 | Label: "FROM scratch", 35 | WaitFors: []WaitFor{{Name: "scratch", Type: waitForType(waitForFrom)}}, 36 | }, 37 | }, 38 | }, 39 | }, 40 | }, 41 | }, 42 | { 43 | name: "All waitFor types", 44 | args: args{ 45 | content: []byte(` 46 | # syntax=docker/dockerfile:1 47 | FROM ubuntu as base 48 | FROM scratch 49 | COPY --from=base . . 50 | RUN --mount=type=cache,from=buildcache,source=/go/pkg/mod/cache/,target=/go/pkg/mod/cache/ go build 51 | `), 52 | maxLabelLength: 20, 53 | }, 54 | want: SimplifiedDockerfile{ 55 | ExternalImages: []ExternalImage{ 56 | {Name: "ubuntu"}, 57 | {Name: "scratch"}, 58 | {Name: "buildcache"}, 59 | }, 60 | Stages: []Stage{ 61 | { 62 | Name: "base", 63 | Layers: []Layer{ 64 | { 65 | Label: "FROM ubuntu as base", 66 | WaitFors: []WaitFor{{Name: "ubuntu", Type: waitForType(waitForFrom)}}, 67 | }, 68 | }, 69 | }, 70 | { 71 | Layers: []Layer{ 72 | { 73 | Label: "FROM scratch", 74 | WaitFors: []WaitFor{{Name: "scratch", Type: waitForType(waitForFrom)}}, 75 | }, 76 | { 77 | Label: "COPY --from=base . .", 78 | WaitFors: []WaitFor{{Name: "base", Type: waitForType(waitForCopy)}}, 79 | }, 80 | { 81 | Label: "RUN --mount=type=...", 82 | WaitFors: []WaitFor{{Name: "buildcache", Type: waitForType(waitForMount)}}, 83 | }, 84 | }, 85 | }, 86 | }, 87 | }, 88 | }, 89 | { 90 | name: "Wait for multiple mounts", 91 | args: args{ 92 | content: []byte(` 93 | # syntax=docker/dockerfile:1 94 | FROM ubuntu as base 95 | RUN \ 96 | --mount=type=cache,from=buildcache,source=/go/pkg/mod/cache/,target=/go/pkg/mod/cache/ \ 97 | --mount=from=artifacts,source=/artifacts/embeddata,target=/artifacts/embeddata go build 98 | `), 99 | maxLabelLength: 20, 100 | }, 101 | want: SimplifiedDockerfile{ 102 | ExternalImages: []ExternalImage{ 103 | {Name: "ubuntu"}, 104 | {Name: "buildcache"}, 105 | {Name: "artifacts"}, 106 | }, 107 | Stages: []Stage{ 108 | { 109 | Name: "base", 110 | Layers: []Layer{ 111 | { 112 | Label: "FROM ubuntu as base", 113 | WaitFors: []WaitFor{{Name: "ubuntu", Type: waitForType(waitForFrom)}}, 114 | }, 115 | { 116 | Label: "RUN --mount=type=...", 117 | WaitFors: []WaitFor{ 118 | {Name: "buildcache", Type: waitForType(waitForMount)}, 119 | {Name: "artifacts", Type: waitForType(waitForMount)}, 120 | }, 121 | }, 122 | }, 123 | }, 124 | }, 125 | }, 126 | }, 127 | { 128 | name: "bind mount", 129 | args: args{ 130 | content: []byte(` 131 | # syntax=docker/dockerfile:1 132 | FROM scratch 133 | RUN --mount=from=build,source=/build/,target=/build/ go build 134 | `), 135 | maxLabelLength: 20, 136 | }, 137 | want: SimplifiedDockerfile{ 138 | ExternalImages: []ExternalImage{ 139 | {Name: "scratch"}, 140 | {Name: "build"}, 141 | }, 142 | Stages: []Stage{ 143 | { 144 | Layers: []Layer{ 145 | { 146 | Label: "FROM scratch", 147 | WaitFors: []WaitFor{{Name: "scratch", Type: waitForType(waitForFrom)}}, 148 | }, 149 | { 150 | Label: "RUN --mount=from=...", 151 | WaitFors: []WaitFor{{Name: "build", Type: waitForType(waitForMount)}}, 152 | }, 153 | }, 154 | }, 155 | }, 156 | }, 157 | }, 158 | { 159 | name: "ARGs before FROM", 160 | args: args{ 161 | content: []byte(` 162 | # syntax=docker/dockerfile:1 163 | ARG UBUNTU_VERSION=22.04 164 | ARG PHP_VERSION=8.0 165 | ARG ALPINE_VERSION=3.15 166 | 167 | FROM ubuntu:$UBUNTU_VERSION as base 168 | USER app 169 | 170 | FROM php:${PHP_VERSION}-fpm-alpine${ALPINE_VERSION} as php 171 | 172 | FROM scratch 173 | COPY --from=base . . 174 | RUN --mount=type=cache,source=/go/pkg/mod/cache/,target=/go/pkg/mod/cache/,from=buildcache go build 175 | `), 176 | maxLabelLength: 20, 177 | }, 178 | want: SimplifiedDockerfile{ 179 | ExternalImages: []ExternalImage{ 180 | {Name: "ubuntu:22.04"}, 181 | {Name: "php:8.0-fpm-alpine3.15"}, 182 | {Name: "scratch"}, 183 | {Name: "buildcache"}, 184 | }, 185 | Stages: []Stage{ 186 | { 187 | Name: "base", 188 | Layers: []Layer{ 189 | { 190 | Label: "FROM ubuntu:22.04...", 191 | WaitFors: []WaitFor{{Name: "ubuntu:22.04", Type: waitForType(waitForFrom)}}, 192 | }, 193 | { 194 | Label: "USER app", 195 | }, 196 | }, 197 | }, 198 | { 199 | Name: "php", 200 | Layers: []Layer{ 201 | { 202 | Label: "FROM php:8.0-fpm-...", 203 | WaitFors: []WaitFor{{ 204 | Name: "php:8.0-fpm-alpine3.15", 205 | Type: waitForType(waitForFrom), 206 | }}, 207 | }, 208 | }, 209 | }, 210 | { 211 | Layers: []Layer{ 212 | { 213 | Label: "FROM scratch", 214 | WaitFors: []WaitFor{{Name: "scratch", Type: waitForType(waitForFrom)}}, 215 | }, 216 | { 217 | Label: "COPY --from=base . .", 218 | WaitFors: []WaitFor{{Name: "base", Type: waitForType(waitForCopy)}}, 219 | }, 220 | { 221 | Label: "RUN --mount=type=...", 222 | WaitFors: []WaitFor{{Name: "buildcache", Type: waitForType(waitForMount)}}, 223 | }, 224 | }, 225 | }, 226 | }, 227 | BeforeFirstStage: []Layer{ 228 | {Label: "ARG UBUNTU_VERSIO..."}, 229 | {Label: "ARG PHP_VERSION=8.0"}, 230 | {Label: "ARG ALPINE_VERSIO..."}, 231 | }, 232 | }, 233 | }, 234 | { 235 | name: "External image used in multiple stages", 236 | args: args{ 237 | content: []byte(` 238 | # syntax=docker/dockerfile:1.4 239 | 240 | FROM scratch AS download-node-setup 241 | ADD https://deb.nodesource.com/setup_16.x ./ 242 | 243 | FROM scratch AS download-get-pip 244 | ADD https://bootstrap.pypa.io/get-pip.py ./ 245 | 246 | FROM alpine AS final 247 | COPY --from=download-node-setup setup_16.x ./ 248 | COPY --from=download-get-pip get-pip.py ./ 249 | `), 250 | maxLabelLength: 20, 251 | }, 252 | want: SimplifiedDockerfile{ 253 | ExternalImages: []ExternalImage{ 254 | {Name: "scratch"}, 255 | {Name: "alpine"}, 256 | }, 257 | Stages: []Stage{ 258 | { 259 | Name: "download-node-setup", 260 | Layers: []Layer{ 261 | { 262 | Label: "FROM scratch AS d...", 263 | WaitFors: []WaitFor{{ 264 | Name: "scratch", 265 | Type: waitForType(waitForFrom), 266 | }}, 267 | }, 268 | {Label: "ADD https://deb.n..."}, 269 | }, 270 | }, 271 | { 272 | Name: "download-get-pip", 273 | Layers: []Layer{ 274 | { 275 | Label: "FROM scratch AS d...", 276 | WaitFors: []WaitFor{{ 277 | Name: "scratch", 278 | Type: waitForType(waitForFrom), 279 | }}, 280 | }, 281 | {Label: "ADD https://boots..."}, 282 | }, 283 | }, 284 | { 285 | Name: "final", 286 | Layers: []Layer{ 287 | { 288 | Label: "FROM alpine AS final", 289 | WaitFors: []WaitFor{{ 290 | Name: "alpine", 291 | Type: waitForType(waitForFrom), 292 | }}, 293 | }, 294 | { 295 | Label: "COPY --from=downl...", 296 | WaitFors: []WaitFor{{ 297 | Name: "download-node-setup", 298 | Type: waitForType(waitForCopy), 299 | }}, 300 | }, 301 | { 302 | Label: "COPY --from=downl...", 303 | WaitFors: []WaitFor{{ 304 | Name: "download-get-pip", 305 | Type: waitForType(waitForCopy), 306 | }}, 307 | }, 308 | }, 309 | }, 310 | }, 311 | }, 312 | }, 313 | { 314 | name: "Nested ARG variable substitution", 315 | args: args{ 316 | content: []byte(` 317 | ARG WORLD=world 318 | ARG IMAGE1=hello-${WORLD}-1 319 | ARG IMAGE2=hello-${WORLD}-2 320 | 321 | FROM ${IMAGE1}:latest AS stage1 322 | RUN echo "Stage 1" 323 | 324 | FROM ${IMAGE2}:latest AS stage2 325 | RUN echo "Stage 2" 326 | `), 327 | maxLabelLength: 20, 328 | }, 329 | want: SimplifiedDockerfile{ 330 | ExternalImages: []ExternalImage{ 331 | {Name: "hello-world-1:latest"}, 332 | {Name: "hello-world-2:latest"}, 333 | }, 334 | Stages: []Stage{ 335 | { 336 | Name: "stage1", 337 | Layers: []Layer{ 338 | { 339 | Label: "FROM hello-world-...", 340 | WaitFors: []WaitFor{{ 341 | Name: "hello-world-1:latest", 342 | Type: waitForType(waitForFrom), 343 | }}, 344 | }, 345 | {Label: "RUN echo 'Stage 1'"}, 346 | }, 347 | }, 348 | { 349 | Name: "stage2", 350 | Layers: []Layer{ 351 | { 352 | Label: "FROM hello-world-...", 353 | WaitFors: []WaitFor{{ 354 | Name: "hello-world-2:latest", 355 | Type: waitForType(waitForFrom), 356 | }}, 357 | }, 358 | {Label: "RUN echo 'Stage 2'"}, 359 | }, 360 | }, 361 | }, 362 | BeforeFirstStage: []Layer{ 363 | {Label: "ARG WORLD=world"}, 364 | {Label: "ARG IMAGE1=hello-..."}, 365 | {Label: "ARG IMAGE2=hello-..."}, 366 | }, 367 | }, 368 | }, 369 | { 370 | // This test verifies that an ARG referenced before its definition resolves to an empty string. 371 | // This aligns with the Docker specification's behavior for ARG variable substitution, 372 | // where only previously defined ARGs are considered for replacement. 373 | name: "ARG referencing later ARG (should not resolve)", 374 | args: args{ 375 | content: []byte(` 376 | ARG IMAGE1=$IMAGE2 377 | ARG IMAGE2=scratch 378 | FROM $IMAGE1 379 | FROM $IMAGE2 380 | `), 381 | maxLabelLength: 20, 382 | }, 383 | want: SimplifiedDockerfile{ 384 | ExternalImages: []ExternalImage{ 385 | {Name: ""}, 386 | {Name: "scratch"}, 387 | }, 388 | Stages: []Stage{ 389 | { 390 | Layers: []Layer{{ 391 | Label: "FROM", 392 | WaitFors: []WaitFor{{Name: "", Type: waitForType(waitForFrom)}}, 393 | }}, 394 | }, 395 | { 396 | Layers: []Layer{{ 397 | Label: "FROM scratch", 398 | WaitFors: []WaitFor{{Name: "scratch", Type: waitForType(waitForFrom)}}, 399 | }}, 400 | }, 401 | }, 402 | BeforeFirstStage: []Layer{ 403 | {Label: "ARG IMAGE1=$IMAGE2"}, 404 | {Label: "ARG IMAGE2=scratch"}, 405 | }, 406 | }, 407 | }, 408 | } 409 | for _, tt := range tests { 410 | t.Run(tt.name, func(t *testing.T) { 411 | got, err := dockerfileToSimplifiedDockerfile( 412 | tt.args.content, 413 | tt.args.maxLabelLength, 414 | ) 415 | if tt.name == "Wait for multiple mounts" { 416 | fmt.Printf("%q", got.Stages[0].Layers[1]) 417 | } 418 | if err != nil { 419 | t.Errorf("dockerfileToSimplifiedDockerfile() error = %v", err) 420 | return 421 | } 422 | if diff := cmp.Diff(tt.want, got); diff != "" { 423 | t.Errorf("Output mismatch (-want +got):\n%s", diff) 424 | } 425 | }) 426 | } 427 | } 428 | -------------------------------------------------------------------------------- /internal/dockerfile2dot/load.go: -------------------------------------------------------------------------------- 1 | // Package dockerfile2dot provides the functionality for loading a Dockerfile 2 | // and converting it into a GraphViz DOT file. 3 | package dockerfile2dot 4 | 5 | import ( 6 | "errors" 7 | "io/fs" 8 | "path/filepath" 9 | 10 | "github.com/spf13/afero" 11 | ) 12 | 13 | // LoadAndParseDockerfile looks for the Dockerfile and returns a 14 | // SimplifiedDockerfile. 15 | func LoadAndParseDockerfile( 16 | inputFS afero.Fs, 17 | filename string, 18 | maxLabelLength int, 19 | ) (SimplifiedDockerfile, error) { 20 | content, err := afero.ReadFile(inputFS, filename) 21 | if err != nil { 22 | if errors.Is(err, fs.ErrNotExist) { 23 | absFilePath, err := filepath.Abs(filename) 24 | if err != nil { 25 | panic(err) 26 | } 27 | return SimplifiedDockerfile{}, errors.New("could not find a Dockerfile at " + absFilePath) 28 | } 29 | panic(err) 30 | } 31 | return dockerfileToSimplifiedDockerfile(content, maxLabelLength) 32 | } 33 | -------------------------------------------------------------------------------- /internal/dockerfile2dot/load_test.go: -------------------------------------------------------------------------------- 1 | package dockerfile2dot 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/spf13/afero" 8 | ) 9 | 10 | func TestLoadAndParseDockerfile(t *testing.T) { 11 | type args struct { 12 | inputFS afero.Fs 13 | filename string 14 | maxLabelLength int 15 | } 16 | 17 | dockerfileFS := afero.NewMemMapFs() 18 | _ = afero.WriteFile(dockerfileFS, "Dockerfile", []byte(`FROM scratch`), 0o644) 19 | 20 | tests := []struct { 21 | name string 22 | args args 23 | want SimplifiedDockerfile 24 | wantErr bool 25 | }{ 26 | { 27 | name: "Dockerfile not found", 28 | args: args{ 29 | inputFS: dockerfileFS, 30 | filename: "missing/Dockerfile", 31 | }, 32 | wantErr: true, 33 | }, 34 | { 35 | name: "should work in the current working directory", 36 | args: args{ 37 | inputFS: dockerfileFS, 38 | filename: "Dockerfile", 39 | maxLabelLength: 20, 40 | }, 41 | want: SimplifiedDockerfile{ 42 | ExternalImages: []ExternalImage{{Name: "scratch"}}, 43 | Stages: []Stage{ 44 | { 45 | Layers: []Layer{ 46 | { 47 | Label: "FROM scratch", 48 | WaitFors: []WaitFor{{Name: "scratch", Type: waitForType(waitForFrom)}}, 49 | }, 50 | }, 51 | }, 52 | }, 53 | }, 54 | }, 55 | { 56 | name: "should work in any directory", 57 | args: args{ 58 | inputFS: dockerfileFS, 59 | filename: "subdir/../Dockerfile", 60 | maxLabelLength: 20, 61 | }, 62 | want: SimplifiedDockerfile{ 63 | ExternalImages: []ExternalImage{{Name: "scratch"}}, 64 | Stages: []Stage{ 65 | { 66 | Layers: []Layer{ 67 | { 68 | Label: "FROM scratch", 69 | WaitFors: []WaitFor{{Name: "scratch", Type: waitForType(waitForFrom)}}, 70 | }, 71 | }, 72 | }, 73 | }, 74 | }, 75 | }, 76 | } 77 | 78 | for _, tt := range tests { 79 | t.Run(tt.name, func(t *testing.T) { 80 | got, err := LoadAndParseDockerfile( 81 | tt.args.inputFS, 82 | tt.args.filename, 83 | tt.args.maxLabelLength, 84 | ) 85 | if (err != nil) != tt.wantErr { 86 | t.Errorf("LoadAndParseDockerfile() error = %v, wantErr %v", err, tt.wantErr) 87 | return 88 | } 89 | if diff := cmp.Diff(tt.want, got); diff != "" { 90 | t.Errorf("LoadAndParseDockerfile() mismatch (-want +got):\n%s", diff) 91 | } 92 | }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /internal/dockerfile2dot/structs.go: -------------------------------------------------------------------------------- 1 | package dockerfile2dot 2 | 3 | // SimplifiedDockerfile contains the parts of the Dockerfile 4 | // that are relevant for generating the multi-stage build graph. 5 | type SimplifiedDockerfile struct { 6 | // Args set before the first stage, see 7 | // https://docs.docker.com/engine/reference/builder/#understand-how-arg-and-from-interact 8 | BeforeFirstStage []Layer 9 | // Build stages 10 | Stages []Stage 11 | // External images 12 | ExternalImages []ExternalImage 13 | } 14 | 15 | // Stage represents a single build stage within the multi-stage Dockerfile or 16 | // an external image. 17 | type Stage struct { 18 | Name string // the part after the AS in the FROM line 19 | Layers []Layer // the layers of the stage 20 | } 21 | 22 | // Layer stores the changes compared to the image it’s based on within a 23 | // multi-stage Dockerfile. 24 | type Layer struct { 25 | Label string // the command and truncated args 26 | WaitFors []WaitFor // stages or external images for which this layer needs to wait 27 | } 28 | 29 | // ExternalImage holds the name of an external image. 30 | type ExternalImage struct { 31 | Name string 32 | } 33 | 34 | type waitForType int 35 | 36 | const ( 37 | waitForCopy waitForType = iota 38 | waitForFrom 39 | waitForMount 40 | ) 41 | 42 | // WaitFor holds the name of the stage or external image for which the builder 43 | // has to wait, and the type, i.e. the reason why it has to wait for it 44 | type WaitFor struct { 45 | Name string // the name of the stage or external image for which the builder has to wait 46 | Type waitForType // the reason why it has to wait 47 | } 48 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Package main only calls `cmd.Execute()`, which is the entry point for the CLI. 2 | package main 3 | 4 | import ( 5 | "github.com/patrickhoefler/dockerfilegraph/internal/cmd" 6 | ) 7 | 8 | func main() { 9 | cmd.Execute() 10 | } 11 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func Test_main(t *testing.T) { 6 | tests := []struct { 7 | name string 8 | }{ 9 | { 10 | name: "should work", 11 | }, 12 | } 13 | for _, tt := range tests { 14 | t.Run(tt.name, func(_ *testing.T) { 15 | main() 16 | }) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "customManagers": [ 4 | { 5 | "customType": "regex", 6 | "managerFilePatterns": [ 7 | "/^Dockerfile/" 8 | ], 9 | "matchStrings": [ 10 | "#\\s*renovate:\\s*datasource=(?.*?) depName=(?.*?)( versioning=(?.*?))?\\sENV .*?_VERSION=\"(?.*)\"\\s" 11 | ], 12 | "versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}" 13 | } 14 | ], 15 | "extends": [ 16 | "config:best-practices", 17 | ":disableDependencyDashboard", 18 | ":pinAllExceptPeerDependencies", 19 | ":maintainLockFilesWeekly" 20 | ], 21 | "packageRules": [ 22 | { 23 | "automerge": true, 24 | "matchCurrentVersion": "!/^0/", 25 | "matchUpdateTypes": [ 26 | "minor", 27 | "patch" 28 | ] 29 | }, 30 | { 31 | "matchDatasources": [ 32 | "docker" 33 | ], 34 | "matchPackageNames": [ 35 | "ubuntu" 36 | ], 37 | "versioning": "regex:^(?[a-z]+)-?(?\\d+)?$" 38 | } 39 | ], 40 | "postUpdateOptions": [ 41 | "gomodTidy" 42 | ], 43 | "prConcurrentLimit": 2 44 | } 45 | --------------------------------------------------------------------------------