├── .github ├── dependabot.yml └── workflows │ ├── ci.yaml │ ├── codeql.yml │ └── release.yaml ├── .gitignore ├── .goreleaser.yaml ├── Dockerfile ├── LICENSE ├── README.md ├── example ├── cloudbuild.png └── cloudbuild.yaml ├── go.mod ├── go.sum ├── internal └── cloudbuild2dot │ ├── build.go │ ├── build_test.go │ ├── convert.go │ ├── load.go │ ├── load_test.go │ ├── structs.go │ └── test │ ├── cloudbuild.json │ ├── valid-input.json │ ├── valid-input.yaml │ └── valid-output.dot ├── main.go └── renovate.json /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "docker" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | - package-ecosystem: "gomod" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | push: 7 | branches: [main] 8 | 9 | jobs: 10 | container: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Harden Runner 14 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 15 | with: 16 | egress-policy: audit 17 | 18 | - name: Checkout 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 23 | with: 24 | go-version-file: 'go.mod' 25 | 26 | - name: Test with code coverage 27 | run: go test ./... -race -covermode=atomic -coverprofile=coverage.txt 28 | 29 | - name: Build binaries and Docker image with GoReleaser 30 | uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 31 | with: 32 | version: '~> v2' 33 | args: release --snapshot 34 | 35 | - name: Make example directory writable for all users 36 | run: chmod o+w example 37 | 38 | - name: Run the built Docker image 39 | run: cd example && docker run --workdir /workspace --mount type=bind,source="$(pwd)",target=/workspace ghcr.io/patrickhoefler/cloudbuildgraph:0.0.0 40 | 41 | native: 42 | strategy: 43 | matrix: 44 | os: [macos-latest, ubuntu-latest, windows-latest] 45 | runs-on: ${{ matrix.os }} 46 | steps: 47 | - name: Harden Runner 48 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 49 | with: 50 | egress-policy: audit 51 | 52 | - name: Checkout 53 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 54 | 55 | - name: Set up Go 56 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 57 | with: 58 | go-version-file: 'go.mod' 59 | 60 | # Some tests are currently broken on Windows, disable for now 61 | - name: Test 62 | if: runner.os != 'Windows' 63 | run: go test ./... 64 | 65 | - name: Build 66 | run: go build 67 | 68 | - name: '[macOS] Install graphviz' 69 | if: runner.os == 'macOS' 70 | run: brew install graphviz 71 | 72 | - name: '[Ubuntu] Install graphviz' 73 | if: runner.os == 'Linux' 74 | run: sudo apt install --no-install-recommends -y graphviz 75 | 76 | - name: '[Windows] Install graphviz' 77 | if: runner.os == 'Windows' 78 | run: choco install graphviz --no-progress 79 | 80 | - name: Run the binary 81 | run: cd example && ../cloudbuildgraph 82 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | # ┌───────────── minute (0 - 59) 10 | # │ ┌───────────── hour (0 - 23) 11 | # │ │ ┌───────────── day of the month (1 - 31) 12 | # │ │ │ ┌───────────── month (1 - 12 or JAN-DEC) 13 | # │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT) 14 | # │ │ │ │ │ 15 | # │ │ │ │ │ 16 | # │ │ │ │ │ 17 | # * * * * * 18 | - cron: '30 1 * * 0' 19 | 20 | jobs: 21 | CodeQL-Build: 22 | # CodeQL runs on ubuntu-latest, windows-latest, and macos-latest 23 | runs-on: ubuntu-latest 24 | 25 | permissions: 26 | # required for all workflows 27 | security-events: write 28 | 29 | # only required for workflows in private repositories 30 | # actions: read 31 | # contents: read 32 | 33 | steps: 34 | - name: Checkout repository 35 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 36 | 37 | # Initializes the CodeQL tools for scanning. 38 | - name: Initialize CodeQL 39 | uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 40 | # Override language selection by uncommenting this and choosing your languages 41 | # with: 42 | # languages: go, javascript, csharp, python, cpp, java 43 | 44 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 45 | # If this step fails, then you should remove it and run the build manually (see below). 46 | - name: Autobuild 47 | uses: github/codeql-action/autobuild@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 48 | 49 | # ℹ️ Command-line programs to run using the OS shell. 50 | # 📚 https://git.io/JvXDl 51 | 52 | # ✏️ If the Autobuild fails above, remove it and uncomment the following 53 | # three lines and modify them (or add more) to build your code if your 54 | # project uses a compiled language 55 | 56 | #- run: | 57 | # make bootstrap 58 | # make release 59 | 60 | - name: Perform CodeQL Analysis 61 | uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 62 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Harden Runner 13 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 14 | with: 15 | egress-policy: audit 16 | 17 | - name: Checkout 18 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 24 | with: 25 | go-version-file: 'go.mod' 26 | 27 | - name: Login to GitHub Container Registry 28 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 29 | with: 30 | registry: ghcr.io 31 | username: ${{ github.actor }} 32 | password: ${{ secrets.GITHUB_TOKEN }} 33 | 34 | - name: Run GoReleaser 35 | uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 36 | with: 37 | version: '~> v2' 38 | args: release --clean 39 | env: 40 | # needed for updating https://github.com/patrickhoefler/homebrew-tap 41 | GITHUB_TOKEN: ${{ secrets.GH_PAT }} 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # goreleaser 15 | dist 16 | 17 | # go build 18 | cloudbuildgraph 19 | 20 | # cloudbuildgraph 21 | example/cloudbuild.pdf 22 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | builds: 4 | - env: 5 | - CGO_ENABLED=0 6 | goarch: 7 | - amd64 8 | 9 | changelog: 10 | sort: asc 11 | filters: 12 | exclude: 13 | - '^docs:' 14 | - '^test:' 15 | 16 | dockers: 17 | - image_templates: 18 | - 'ghcr.io/patrickhoefler/cloudbuildgraph:latest' 19 | - 'ghcr.io/patrickhoefler/cloudbuildgraph:{{ .Major }}' 20 | - 'ghcr.io/patrickhoefler/cloudbuildgraph:{{ .Major }}.{{ .Minor }}' 21 | - 'ghcr.io/patrickhoefler/cloudbuildgraph:{{ .Major }}.{{ .Minor }}.{{ .Patch }}' 22 | 23 | brews: 24 | - repository: 25 | owner: patrickhoefler 26 | name: homebrew-tap 27 | 28 | homepage: https://github.com/patrickhoefler/cloudbuildgraph 29 | description: 'Visualize your Google Cloud Build pipeline' 30 | 31 | dependencies: 32 | - graphviz 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ### Release image 2 | FROM ubuntu:noble-20250529@sha256:b59d21599a2b151e23eea5f6602f4af4d7d31c4e236d22bf0b62b86d2e386b8f 3 | 4 | LABEL org.opencontainers.image.source="https://github.com/patrickhoefler/cloudbuildgraph" 5 | 6 | RUN \ 7 | # Install Graphviz 8 | # Note that the lack of a "lock" mechanism for apt dependencies 9 | # currently prevents a fully reproducible build 10 | apt-get update \ 11 | && apt-get install -y --no-install-recommends \ 12 | graphviz \ 13 | && rm -rf /var/lib/apt/lists/* \ 14 | \ 15 | # Add a non-root user 16 | && useradd app 17 | 18 | # Run as non-root user 19 | USER app 20 | 21 | # This currently only works with goreleaser 22 | # or if you manually copy the binary into the main project directory 23 | COPY cloudbuildgraph / 24 | 25 | ENTRYPOINT ["/cloudbuildgraph"] 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cloudbuildgraph 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/patrickhoefler/cloudbuildgraph)](https://goreportcard.com/report/github.com/patrickhoefler/cloudbuildgraph) 4 | 5 | `cloudbuildgraph` visualizes your Google Cloud Build pipelines. 6 | 7 | ## Example Output 8 | 9 | ![Example graph](example/cloudbuild.png) 10 | 11 | ## Getting Started 12 | 13 | ### Prerequisites 14 | 15 | - A `cloudbuild.yaml`, `cloudbuild.yml` or `cloudbuild.json` [Google Cloud Build config](https://cloud.google.com/cloud-build/docs/build-config) file in your current working directory 16 | 17 | ### Installation and Usage 18 | 19 | Running `cloudbuildgraph` will create a `cloudbuild.pdf` file in your current working directory that contains a visual graph representation of your Cloud Build pipeline. 20 | 21 | #### docker / [nerdctl](https://github.com/containerd/nerdctl) 22 | 23 | ```shell 24 | docker run \ 25 | --rm \ 26 | --workdir /workspace \ 27 | --volume "$(pwd)":/workspace \ 28 | ghcr.io/patrickhoefler/cloudbuildgraph 29 | ``` 30 | 31 | #### [Homebrew](https://brew.sh/) 32 | 33 | ```text 34 | brew install patrickhoefler/tap/cloudbuildgraph 35 | cloudbuildgraph 36 | ``` 37 | 38 | #### Build from Source 39 | 40 | ```text 41 | go build 42 | ./cloudbuildgraph 43 | ``` 44 | 45 | ## License 46 | 47 | [MIT](https://github.com/patrickhoefler/cloudbuildgraph/blob/main/LICENSE) 48 | -------------------------------------------------------------------------------- /example/cloudbuild.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickhoefler/cloudbuildgraph/e7b254a9f2fc550f0a2fd633e9215f3e5a9d8688/example/cloudbuild.png -------------------------------------------------------------------------------- /example/cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: gcr.io/cloud-builders/docker@sha256:232285ef9e080bfae5d6e5782269768374fd3f4782c4bcadcaa1f359b86d0737 3 | args: 4 | - pull 5 | 6 | - name: gcr.io/cloud-builders/docker@sha256:232285ef9e080bfae5d6e5782269768374fd3f4782c4bcadcaa1f359b86d0737 7 | args: 8 | - build 9 | 10 | - id: test 11 | name: gcr.io/my-project/test 12 | 13 | - id: fetch 14 | name: gcr.io/cloud-builders/gsutil@sha256:af65ee2d68cbe5eb56af0c1fabd88faee745e7f90369ebb96eed0fcd509d397d 15 | waitFor: 16 | - '-' 17 | 18 | - name: gcr.io/my-project/package 19 | waitFor: 20 | - fetch 21 | - test 22 | 23 | - name: gcr.io/ma-project/deploy 24 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/patrickhoefler/cloudbuildgraph 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.4 6 | 7 | require ( 8 | github.com/awalterschulze/gographviz v2.0.3+incompatible 9 | github.com/google/go-cmp v0.7.0 10 | github.com/spf13/afero v1.14.0 11 | gopkg.in/yaml.v3 v3.0.1 12 | ) 13 | 14 | require golang.org/x/text v0.23.0 // indirect 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/awalterschulze/gographviz v2.0.3+incompatible h1:9sVEXJBJLwGX7EQVhLm2elIKCm7P2YHFC8v6096G09E= 2 | github.com/awalterschulze/gographviz v2.0.3+incompatible/go.mod h1:GEV5wmg4YquNw7v1kkyoX9etIk8yVmXj+AkDHuuETHs= 3 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 4 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 5 | github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= 6 | github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= 7 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 8 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 10 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 11 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 12 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 13 | -------------------------------------------------------------------------------- /internal/cloudbuild2dot/build.go: -------------------------------------------------------------------------------- 1 | package cloudbuild2dot 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/awalterschulze/gographviz" 7 | ) 8 | 9 | // BuildDotFile builds a GraphViz .dot file from a Google Cloud Build configuration 10 | func BuildDotFile(cloudBuildConfig CloudBuildConfig) string { 11 | graph := gographviz.NewEscape() 12 | graph.SetName("G") 13 | graph.SetDir(true) 14 | graph.AddAttr("G", "splines", "ortho") 15 | graph.AddAttr("G", "rankdir", "LR") 16 | graph.AddAttr("G", "nodesep", "1") 17 | 18 | startingSteps := []string{ 19 | getStepLabel(cloudBuildConfig.Steps[0]), 20 | } 21 | 22 | for index, step := range cloudBuildConfig.Steps { 23 | graph.AddNode( 24 | "G", 25 | getStepLabel(step), 26 | map[string]string{ 27 | "shape": "Mrecord", 28 | "width": "2", 29 | }, 30 | ) 31 | 32 | for _, waitForStepID := range step.WaitFor { 33 | if waitForStepID == "-" { 34 | startingSteps = append(startingSteps, getStepLabel(step)) 35 | } else { 36 | graph.AddEdge( 37 | waitForStepID, 38 | getStepLabel(step), 39 | true, 40 | nil, 41 | ) 42 | } 43 | } 44 | 45 | if step.WaitFor == nil && index > 0 { 46 | graph.AddEdge( 47 | getStepLabel(cloudBuildConfig.Steps[index-1]), 48 | getStepLabel(step), 49 | true, 50 | nil, 51 | ) 52 | } 53 | } 54 | 55 | graph.AddSubGraph("G", "rank0", map[string]string{"rank": "same"}) 56 | 57 | for _, id := range startingSteps { 58 | graph.AddNode("rank0", id, nil) 59 | } 60 | 61 | return graph.String() 62 | } 63 | 64 | func getStepLabel(step Step) string { 65 | if step.ID != "" { 66 | return step.ID 67 | } 68 | 69 | splitName := strings.Split(step.Name, "/") 70 | 71 | if len(step.Args) > 0 { 72 | return splitName[len(splitName)-1] + " " + step.Args[0] 73 | } 74 | 75 | return splitName[len(splitName)-1] 76 | } 77 | -------------------------------------------------------------------------------- /internal/cloudbuild2dot/build_test.go: -------------------------------------------------------------------------------- 1 | package cloudbuild2dot 2 | 3 | import ( 4 | "io/ioutil" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | ) 9 | 10 | func TestBuildDotFile(t *testing.T) { 11 | type args struct { 12 | cloudBuildConfig CloudBuildConfig 13 | } 14 | 15 | tests := []struct { 16 | name string 17 | args args 18 | want string 19 | }{ 20 | { 21 | "valid YAML config", 22 | args{yamlToCloudBuild(loadTestFile("valid-input.yaml"))}, 23 | string(loadTestFile("valid-output.dot")), 24 | }, 25 | { 26 | "valid JSON config", 27 | args{jsonToCloudBuild(loadTestFile("valid-input.json"))}, 28 | string(loadTestFile("valid-output.dot")), 29 | }, 30 | } 31 | 32 | for _, tt := range tests { 33 | t.Run(tt.name, func(t *testing.T) { 34 | got := BuildDotFile(tt.args.cloudBuildConfig) 35 | 36 | if diff := cmp.Diff(tt.want, got); diff != "" { 37 | t.Errorf("BuildDotFile() mismatch (-want +got):\n%s", diff) 38 | } 39 | }) 40 | } 41 | } 42 | 43 | func loadTestFile(filename string) []byte { 44 | testFileContent, _ := ioutil.ReadFile("test/" + filename) 45 | 46 | return testFileContent 47 | } 48 | -------------------------------------------------------------------------------- /internal/cloudbuild2dot/convert.go: -------------------------------------------------------------------------------- 1 | package cloudbuild2dot 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | func jsonToCloudBuild(content []byte) CloudBuildConfig { 11 | cloudBuildConfig := CloudBuildConfig{} 12 | 13 | err := json.Unmarshal(content, &cloudBuildConfig) 14 | if err != nil { 15 | log.Fatal(err) 16 | } 17 | 18 | return cloudBuildConfig 19 | } 20 | 21 | func yamlToCloudBuild(content []byte) CloudBuildConfig { 22 | cloudBuildConfig := CloudBuildConfig{} 23 | 24 | err := yaml.Unmarshal(content, &cloudBuildConfig) 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | 29 | return cloudBuildConfig 30 | } 31 | -------------------------------------------------------------------------------- /internal/cloudbuild2dot/load.go: -------------------------------------------------------------------------------- 1 | package cloudbuild2dot 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "os" 7 | 8 | "github.com/spf13/afero" 9 | ) 10 | 11 | // LoadCloudBuildConfig looks for the Cloud Build configuration file (currently 12 | // only YAML is supported) and returns a CloudBuildConfig. 13 | func LoadCloudBuildConfig() (cloudBuildConfig CloudBuildConfig, err error) { 14 | return loadConfig(afero.NewOsFs()) 15 | } 16 | 17 | func loadConfig(AppFs afero.Fs) (CloudBuildConfig, error) { 18 | for _, filename := range []string{"cloudbuild.yaml", "cloudbuild.yml"} { 19 | yamlContent, err := afero.ReadFile(AppFs, filename) 20 | if err != nil { 21 | if os.IsNotExist(err) { 22 | continue 23 | } else { 24 | log.Fatal(err) 25 | } 26 | } 27 | 28 | return yamlToCloudBuild(yamlContent), nil 29 | } 30 | 31 | for _, filename := range []string{"cloudbuild.json"} { 32 | jsonContent, err := afero.ReadFile(AppFs, filename) 33 | if err != nil { 34 | if os.IsNotExist(err) { 35 | continue 36 | } else { 37 | log.Fatal(err) 38 | } 39 | } 40 | 41 | return jsonToCloudBuild(jsonContent), nil 42 | } 43 | 44 | return CloudBuildConfig{}, errors.New("Could not find any Cloud Build config file in the current working directory") 45 | } 46 | -------------------------------------------------------------------------------- /internal/cloudbuild2dot/load_test.go: -------------------------------------------------------------------------------- 1 | package cloudbuild2dot 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/spf13/afero" 8 | ) 9 | 10 | func Test_loadConfig(t *testing.T) { 11 | type args struct { 12 | AppFs afero.Fs 13 | } 14 | 15 | yamlFileContent := []byte("{steps: [{id: yaml}]}") 16 | ymlFileContent := []byte("{steps: [{id: yml}]}") 17 | jsonFileContent := []byte("{\"steps\": [{\"id\": \"json\"}]}") 18 | 19 | // cloudbuild.yaml 20 | yamlFS := afero.NewMemMapFs() 21 | afero.WriteFile(yamlFS, "cloudbuild.yaml", yamlFileContent, 0644) 22 | afero.WriteFile(yamlFS, "cloudbuild.yml", ymlFileContent, 0644) 23 | afero.WriteFile(yamlFS, "cloudbuild.json", jsonFileContent, 0644) 24 | 25 | // cloudbuild.yml 26 | ymlFS := afero.NewMemMapFs() 27 | afero.WriteFile(ymlFS, "cloudbuild.yml", ymlFileContent, 0644) 28 | afero.WriteFile(ymlFS, "cloudbuild.json", jsonFileContent, 0644) 29 | 30 | // cloudbuild.json 31 | jsonFS := afero.NewMemMapFs() 32 | afero.WriteFile(jsonFS, "cloudbuild.json", jsonFileContent, 0644) 33 | 34 | tests := []struct { 35 | name string 36 | args args 37 | want CloudBuildConfig 38 | wantErr bool 39 | }{ 40 | { 41 | "No YAML files", 42 | args{afero.NewMemMapFs()}, 43 | CloudBuildConfig{}, 44 | true, 45 | }, 46 | { 47 | "cloudbuild.yaml", 48 | args{yamlFS}, 49 | yamlToCloudBuild(yamlFileContent), 50 | false, 51 | }, 52 | { 53 | "cloudbuild.yml", 54 | args{ymlFS}, 55 | yamlToCloudBuild(ymlFileContent), 56 | false, 57 | }, 58 | { 59 | "cloudbuild.json", 60 | args{jsonFS}, 61 | jsonToCloudBuild(jsonFileContent), 62 | false, 63 | }, 64 | } 65 | 66 | for _, tt := range tests { 67 | t.Run(tt.name, func(t *testing.T) { 68 | got, err := loadConfig(tt.args.AppFs) 69 | 70 | if (err != nil) != tt.wantErr { 71 | t.Errorf("loadConfig() error = %v, wantErr %v", err, tt.wantErr) 72 | return 73 | } 74 | 75 | if diff := cmp.Diff(tt.want, got); diff != "" { 76 | t.Errorf("loadConfig() mismatch (-want +got):\n%s", diff) 77 | } 78 | }) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /internal/cloudbuild2dot/structs.go: -------------------------------------------------------------------------------- 1 | package cloudbuild2dot 2 | 3 | // CloudBuildConfig contains the parts of the Cloud Build configuration schema 4 | // that are relevant for generating the steps graph 5 | type CloudBuildConfig struct { 6 | Steps []Step 7 | } 8 | 9 | // Step contains the parts of a single build step within a Cloud Build configuration 10 | // that are relevant for generating the steps graph 11 | type Step struct { 12 | ID string 13 | Name string 14 | Args []string 15 | WaitFor []string `yaml:"waitFor"` 16 | } 17 | -------------------------------------------------------------------------------- /internal/cloudbuild2dot/test/cloudbuild.json: -------------------------------------------------------------------------------- 1 | { 2 | "steps": [ 3 | { 4 | "name": "gcr.io/cloud-builders/docker", 5 | "args": [ 6 | "pull" 7 | ] 8 | }, 9 | { 10 | "id": "step-b", 11 | "name": "gcr.io/cloud-builders/docker", 12 | "args": [ 13 | "build" 14 | ] 15 | }, 16 | { 17 | "name": "gcr.io/my-project/my-image", 18 | "waitFor": [ 19 | "-" 20 | ] 21 | }, 22 | { 23 | "id": "step-c", 24 | "name": "gcr.io/cloud-builders/docker", 25 | "waitFor": [ 26 | "my-image", 27 | "step-b" 28 | ] 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /internal/cloudbuild2dot/test/valid-input.json: -------------------------------------------------------------------------------- 1 | { 2 | "steps": [ 3 | { 4 | "name": "gcr.io/cloud-builders/docker", 5 | "args": [ 6 | "pull" 7 | ] 8 | }, 9 | { 10 | "id": "step-b", 11 | "name": "gcr.io/cloud-builders/docker", 12 | "args": [ 13 | "build" 14 | ] 15 | }, 16 | { 17 | "name": "gcr.io/my-project/my-image", 18 | "waitFor": [ 19 | "-" 20 | ] 21 | }, 22 | { 23 | "id": "step-c", 24 | "name": "gcr.io/cloud-builders/docker", 25 | "waitFor": [ 26 | "my-image", 27 | "step-b" 28 | ] 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /internal/cloudbuild2dot/test/valid-input.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | steps: 3 | - name: gcr.io/cloud-builders/docker 4 | args: 5 | - pull 6 | 7 | - id: step-b 8 | name: gcr.io/cloud-builders/docker 9 | args: 10 | - build 11 | 12 | - name: gcr.io/my-project/my-image 13 | waitFor: 14 | - '-' 15 | 16 | - id: step-c 17 | name: gcr.io/cloud-builders/docker 18 | waitFor: 19 | - my-image 20 | - step-b 21 | -------------------------------------------------------------------------------- /internal/cloudbuild2dot/test/valid-output.dot: -------------------------------------------------------------------------------- 1 | digraph G { 2 | nodesep=1; 3 | rankdir=LR; 4 | splines=ortho; 5 | "docker pull"->"step-b"; 6 | "my-image"->"step-c"; 7 | "step-b"->"step-c"; 8 | subgraph rank0 { 9 | rank=same; 10 | "docker pull" [ shape=Mrecord, width=2 ]; 11 | "my-image" [ shape=Mrecord, width=2 ]; 12 | 13 | } 14 | ; 15 | "step-b" [ shape=Mrecord, width=2 ]; 16 | "step-c" [ shape=Mrecord, width=2 ]; 17 | 18 | } 19 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | "os/exec" 9 | 10 | "github.com/patrickhoefler/cloudbuildgraph/internal/cloudbuild2dot" 11 | ) 12 | 13 | func main() { 14 | log.SetFlags(0) 15 | 16 | cloudBuildConfig, err := cloudbuild2dot.LoadCloudBuildConfig() 17 | if err != nil { 18 | log.Fatal(err) 19 | } 20 | 21 | dotFile, err := ioutil.TempFile("", "cloudbuild.*.dot") 22 | if err != nil { 23 | log.Fatal(err) 24 | } 25 | defer os.Remove(dotFile.Name()) 26 | 27 | _, err = dotFile.Write([]byte(cloudbuild2dot.BuildDotFile(cloudBuildConfig))) 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | 32 | err = dotFile.Close() 33 | if err != nil { 34 | log.Fatal(err) 35 | } 36 | 37 | cmd := exec.Command("dot", "-Tpdf", "-ocloudbuild.pdf", dotFile.Name()) 38 | err = cmd.Run() 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | 43 | fmt.Println("Successfully created cloudbuild.pdf") 44 | } 45 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:best-practices" 4 | ], 5 | "automerge": true, 6 | "dependencyDashboard": false, 7 | "ignorePaths": [ 8 | "**/example/**" 9 | ], 10 | "major": { 11 | "automerge": false 12 | }, 13 | "packageRules": [ 14 | { 15 | "matchDatasources": [ 16 | "docker" 17 | ], 18 | "matchPackageNames": [ 19 | "ubuntu" 20 | ], 21 | "versioning": "regex:^(?[a-z]+)-?(?\\d+)?$" 22 | } 23 | ], 24 | "postUpdateOptions": [ 25 | "gomodTidy" 26 | ] 27 | } 28 | --------------------------------------------------------------------------------