├── .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 | [](https://github.com/patrickhoefler/dockerfilegraph/actions/workflows/ci.yml?query=branch%3Amain)
4 | [](https://goreportcard.com/report/github.com/patrickhoefler/dockerfilegraph)
5 | [](https://github.com/patrickhoefler/dockerfilegraph/releases/latest)
6 | [](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 | 
35 |
36 | ### With Layer Visualization (`--layers`)
37 |
38 | 
39 |
40 | ### Complex Multi-stage Build (`--concentrate --nodesep 0.3 --unflatten 4`)
41 |
42 | 
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 |
393 |
--------------------------------------------------------------------------------
/examples/images/Dockerfile-layers.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
167 |
--------------------------------------------------------------------------------
/examples/images/Dockerfile-legend.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
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 | FROM ... |
346 | COPY --from=... |
347 | RUN --mount=(.*)from=... |
348 |
>,
349 | shape=plaintext];
350 | key2 [fontname=monospace,
351 | fontsize=10,
352 | label=<>,
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 | FROM ... |
431 | COPY --from=... |
432 | RUN --mount=(.*)from=... |
433 |
>,
434 | shape=plaintext];
435 | key2 [fontname=monospace,
436 | fontsize=10,
437 | label=<>,
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 | FROM ... |
198 | COPY --from=... |
199 | RUN --mount=(.*)from=... |
200 |
>`,
201 | },
202 | )
203 | _ = graph.AddNode("cluster_legend", "key2",
204 | map[string]string{
205 | "shape": "plaintext",
206 | "fontname": "monospace",
207 | "fontsize": "10",
208 | "label": `<>`,
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 |
--------------------------------------------------------------------------------