├── .github ├── dependabot.yml ├── graph.png ├── graph2.png ├── stats.png ├── table.png └── workflows │ ├── build-release-artifact.yml │ ├── docker-build-push.yml │ ├── go-fmt-vet-tests.yml │ └── push-coverage-badge.yml ├── .gitignore ├── LICENSE ├── README.md ├── build └── Dockerfile ├── cmd ├── filter.go ├── graph.go ├── root.go ├── stats.go ├── table.go └── version.go ├── docs ├── filter.md ├── stats.md └── table.md ├── go.mod ├── go.sum ├── pkg └── tf-profile │ ├── aggregate │ ├── aggregate.go │ └── aggregate_test.go │ ├── core │ ├── errors.go │ ├── text.go │ └── types.go │ ├── filter │ ├── filter.go │ └── filter_test.go │ ├── graph │ ├── graph.go │ └── graph_test.go │ ├── parser │ ├── apply_patterns.go │ ├── apply_patterns_test.go │ ├── parser.go │ ├── parser_test.go │ ├── plan_patterns.go │ ├── plan_patterns_test.go │ └── refresh_patterns.go │ ├── readers │ ├── file_reader.go │ ├── reader.go │ ├── reader_test.go │ └── stdin_reader.go │ ├── sort │ ├── sort.go │ └── sort_test.go │ ├── stats │ ├── resource_utils.go │ ├── stats.go │ └── stats_test.go │ ├── table │ ├── table.go │ └── table_test.go │ └── utils │ └── fmt_utils.go ├── test ├── aggregate.log ├── aggregate │ ├── aggregate.tf │ └── modules │ │ └── test │ │ └── test.tf ├── all_operations.log ├── apply_with_color.log ├── argo.log ├── destroy_with_color.log ├── failures.log ├── failures │ └── provider.tf ├── failures_without_plan.log ├── many_modules.log ├── many_modules │ ├── main.tf │ └── modules │ │ ├── airflow │ │ └── airflow.tf │ │ ├── applications │ │ └── applications.tf │ │ ├── core_infrastructure │ │ └── core.tf │ │ ├── dbt │ │ └── dbt.tf │ │ ├── role │ │ └── role.tf │ │ └── security_rule │ │ └── rule.tf ├── multiple_resources.log ├── multiple_resources │ └── null.tf ├── null_resources.log ├── null_resources │ └── null.tf ├── only_failures.log └── test_file.txt └── tf-profile.go /.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: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | - package-ecosystem: "github-actions" 13 | # Workflow files stored in the default location of `.github/workflows`. (You don't need to specify `/.github/workflows` for `directory`. You can use `directory: "/"`.) 14 | directory: "/" 15 | schedule: 16 | interval: "weekly" -------------------------------------------------------------------------------- /.github/graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datarootsio/tf-profile/a7d91eef3aa9d03199ff21ea4996411d216e1028/.github/graph.png -------------------------------------------------------------------------------- /.github/graph2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datarootsio/tf-profile/a7d91eef3aa9d03199ff21ea4996411d216e1028/.github/graph2.png -------------------------------------------------------------------------------- /.github/stats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datarootsio/tf-profile/a7d91eef3aa9d03199ff21ea4996411d216e1028/.github/stats.png -------------------------------------------------------------------------------- /.github/table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datarootsio/tf-profile/a7d91eef3aa9d03199ff21ea4996411d216e1028/.github/table.png -------------------------------------------------------------------------------- /.github/workflows/build-release-artifact.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Go Binary 2 | on: 3 | push: 4 | branches: 5 | - 'release/*' 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | os: [linux, darwin, windows] 12 | arch: [amd64, arm64] 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: '1.23' 22 | 23 | - name: Build Go binaries 24 | run: | 25 | if [ ${{ matrix.os }} == "windows" ]; then 26 | CGO_ENABLED=0 GOOS=${{ matrix.os }} GOARCH=${{ matrix.arch }} go build -a -tags netgo -ldflags '-w -extldflags "-static"' -o tf-profile.exe . 27 | else 28 | CGO_ENABLED=0 GOOS=${{ matrix.os }} GOARCH=${{ matrix.arch }} go build -a -tags netgo -ldflags '-w -extldflags "-static"' -o tf-profile . 29 | fi 30 | 31 | - name: Extract release version from branch name 32 | id: release_version 33 | run: echo "::set-output name=version::${GITHUB_REF##*/}" 34 | 35 | - name: Create artifact 36 | uses: actions/upload-artifact@v4 37 | with: 38 | name: tf-profile-v${{ steps.release_version.outputs.version }}-${{ matrix.os }}-${{ matrix.arch }} 39 | path: | 40 | ./tf-profile 41 | ./tf-profile.exe 42 | -------------------------------------------------------------------------------- /.github/workflows/docker-build-push.yml: -------------------------------------------------------------------------------- 1 | name: Build and push Docker image 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'release/*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | 15 | - name: Login to Docker Hub 16 | uses: docker/login-action@v1 17 | with: 18 | username: qbruynseraede 19 | password: ${{ secrets.DOCKER_PASSWORD }} 20 | 21 | - name: Extract release version from branch name 22 | id: release_version 23 | run: echo "::set-output name=version::${GITHUB_REF##*/}" 24 | 25 | - name: Build Docker image 26 | run: > 27 | docker build 28 | -t qbruynseraede/tf-profile:${{ steps.release_version.outputs.version }} 29 | -f build/Dockerfile 30 | . 31 | 32 | - name: Push Docker image 33 | run: docker push qbruynseraede/tf-profile:${{ steps.release_version.outputs.version }} 34 | 35 | -------------------------------------------------------------------------------- /.github/workflows/go-fmt-vet-tests.yml: -------------------------------------------------------------------------------- 1 | name: Go fmt, go vet, go test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | lint-verify-and-test: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@v5 17 | with: 18 | go-version: 1.17 19 | 20 | - name: Check formatting with go fmt 21 | run: | 22 | output=$(gofmt -l -w -d ./pkg/tf-profile) 23 | if [ -n "$output" ]; then 24 | echo "Code needs to be reformatted:" 25 | echo "$output" 26 | exit 1 27 | fi 28 | 29 | - name: Verify code with go vet 30 | run: | 31 | output=$(go vet ./...) 32 | if [ -n "$output" ]; then 33 | echo "Code has issues that need to be fixed:" 34 | echo "$output" 35 | exit 1 36 | fi 37 | 38 | - name: Run tests 39 | run: go test -v ./... -------------------------------------------------------------------------------- /.github/workflows/push-coverage-badge.yml: -------------------------------------------------------------------------------- 1 | name: Generate code coverage badge 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | name: Update coverage badge 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | persist-credentials: false # otherwise, the token used is the GITHUB_TOKEN, instead of your personal access token. 17 | fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. 18 | 19 | - name: Setup go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: '1.23' 23 | 24 | - uses: actions/cache@v4 25 | with: 26 | path: ~/go/pkg/mod 27 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 28 | restore-keys: | 29 | ${{ runner.os }}-go- 30 | 31 | - name: Run Test 32 | run: | 33 | go test -v ./... -covermode=count -coverprofile=coverage.out 34 | go tool cover -func=coverage.out -o=coverage.out 35 | 36 | # REMOVED: these actions are compromised (https://github.com/tj-actions/changed-files/issues/2463) 37 | # - name: Go Coverage Badge # Pass the `coverage.out` output to this action 38 | # uses: tj-actions/coverage-badge-go@v2 39 | # with: 40 | # filename: coverage.out 41 | 42 | # - name: Verify Changed files 43 | # uses: tj-actions/verify-changed-files@v20 44 | # id: verify-changed-files 45 | # with: 46 | # files: README.md 47 | 48 | # - name: Commit changes 49 | # if: steps.verify-changed-files.outputs.files_changed == 'true' 50 | # run: | 51 | # git config --local user.email "action@github.com" 52 | # git config --local user.name "GitHub Action" 53 | # git add README.md 54 | # git commit -m "chore: Updated coverage badge." 55 | 56 | # - name: Push changes 57 | # if: steps.verify-changed-files.outputs.files_changed == 'true' 58 | # uses: ad-m/github-push-action@master 59 | # with: 60 | # github_token: ${{ github.token }} 61 | # branch: ${{ github.head_ref }} 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Local .terraform directories 18 | **/.terraform/* 19 | 20 | # .tfstate files 21 | *.tfstate 22 | *.tfstate.* 23 | 24 | # Crash log files 25 | crash.log 26 | crash.*.log 27 | 28 | # Exclude all .tfvars files, which are likely to contain sensitive data, such as 29 | # password, private keys, and other secrets. These should not be part of version 30 | # control as they are data points which are potentially sensitive and subject 31 | # to change depending on the environment. 32 | *.tfvars 33 | *.tfvars.json 34 | 35 | # Ignore override files as they are usually used to override resources locally and so 36 | # are not checked in 37 | override.tf 38 | override.tf.json 39 | *_override.tf 40 | *_override.tf.json 41 | 42 | # Include override files you do wish to add to version control using negated pattern 43 | # !example_override.tf 44 | 45 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan 46 | # example: *tfplan* 47 | 48 | # Ignore CLI configuration files 49 | .terraformrc 50 | terraform.rc 51 | .terraform.lock.hcl 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Quinten Bruynseraede 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 | # tf-profile 2 | ![Coverage](https://img.shields.io/badge/Coverage-86.2%25-brightgreen) 3 | 4 | [![Go Linting, Verification, and Testing](https://github.com/QuintenBruynseraede/tf-profile/actions/workflows/go-fmt-vet-tests.yml/badge.svg?branch=main)](https://github.com/QuintenBruynseraede/tf-profile/actions/workflows/go-fmt-vet-tests.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/QuintenBruynseraede/tf-profile)](https://goreportcard.com/report/github.com/QuintenBruynseraede/tf-profile) [![Go Reference](https://pkg.go.dev/badge/github.com/QuintenBruynseraede/tf-profile.svg)](https://pkg.go.dev/github.com/QuintenBruynseraede/tf-profile) 5 | 6 | CLI tool to profile Terraform runs, written in Go. 7 | 8 | Main features: 9 | - Modern CLI ([cobra](https://github.com/spf13/cobra)-based) with autocomplete 10 | - Read logs straight from your Terraform process (using pipe) or a log file 11 | - Can generate global stats, resource-level stats or visualizations 12 | - Provides many levels of granularity and aggregation and customizable outputs 13 | 14 | Featured on: [awesome-go](https://github.com/avelino/awesome-go) | [awesome-terraform](https://github.com/shuaibiyy/awesome-terraform) 15 | 16 | > [!WARNING] 17 | > tf-profile is no longer being actively developed. I do not intend to add new features or support newer versions of Terraform. 18 | 19 | 20 | ## Installation 21 | 22 | ### Brew install 23 | ```bash 24 | ❱ brew tap datarootsio/tf-profile 25 | ❱ brew install tf-profile 26 | ❱ tf-profile --help 27 | tf-profile is a CLI tool to profile Terraform runs 28 | 29 | Usage: 30 | tf-profile [command] 31 | ``` 32 | 33 | ### Binary download 34 | 35 | - Head over to the releases page ([https://github.com/QuintenBruynseraede/tf-profile/releases](https://github.com/QuintenBruynseraede/tf-profile/releases)) 36 | - Download the correct binary for your operating system 37 | - Copy it to a path that is on your `$PATH`. On a Linux system, `/usr/local/bin` is the most common location. 38 | 39 | ### Using docker 40 | 41 | If you want to try `tf-profile` without installing anything, you can run it using Docker (or similar). 42 | 43 | ```bash 44 | ❱ cat my_log_file.log | docker run -i qbruynseraede/tf-profile:0.5.0 stats 45 | 46 | Key Value 47 | Number of resources created 1510 48 | 49 | Cumulative duration 36m19s 50 | Longest apply time 7m18s 51 | Longest apply resource time_sleep.foo[*] 52 | ... 53 | ``` 54 | 55 | Optionally, define an alias: 56 | 57 | ```bash 58 | ❱ alias tf-profile=docker run -i qbruynseraede/tf-profile:0.5.0 59 | ❱ cat my_log_file.log | tf-profile 60 | ``` 61 | 62 | ### Build from source 63 | 64 | This requires at least version 1.23 of the `go` cli. 65 | 66 | ```bash 67 | ❱ git clone git@github.com:QuintenBruynseraede/tf-profile.git 68 | ❱ cd tf-profile && go build . 69 | ❱ sudo ln -s $(pwd)/tf-profile /usr/local/bin # Optional: only if you want to run tf-profile from other directories 70 | ❱ tf-profile --help 71 | tf-profile is a CLI tool to profile Terraform runs 72 | 73 | Usage: 74 | tf-profile [command] 75 | ``` 76 | 77 | ## Basic usage 78 | 79 | `tf-profile` handles input from stdin and from files. These two commands are therefore equivalent: 80 | 81 | ```bash 82 | ❱ terraform apply -auto-approve | tf-profile table 83 | ❱ terraform apply -auto-approve > log.txt && tf-profile table log.txt 84 | ``` 85 | 86 | Four major commands are supported: 87 | - [🔗](#tf-profile-stats) `tf-profile stats`: provide general statistics about a Terraform run 88 | - [🔗](#tf-profile-table) `tf-profile table`: provide detailed, resource-level statistics about a Terraform run 89 | - [🔗](#tf-profile-filter) `tf-profile filter`: filter logs to include only certain resources 90 | - [🔗](#tf-profile-graph) `tf-profile graph`: generate a visual overview of a Terraform run. 91 | 92 | 93 | ## `tf-profile stats` 94 | 95 | `tf-profile stats` is the most basic command. Given a Terraform log, it will only provide high-level statistics. 96 | 97 | ```bash 98 | ❱ terraform apply -auto-approve > log.txt 99 | ❱ tf-profile stats log.txt 100 | 101 | Key Value 102 | ----------------------------------------------------------------- 103 | Number of resources in configuration 1510 104 | 105 | Cumulative duration 36m19s 106 | Longest apply time 7m18s 107 | Longest apply resource time_sleep.foo[*] 108 | 109 | Resources marked for operation Create 892 110 | Resources marked for operation None 18 111 | Resources marked for operation Replace 412 112 | 113 | Resources in state AllCreated 800 114 | Resources in state Created 695 115 | Resources in state Started 15 116 | 117 | Resources in desired state 1492 out of 1510 (98.8%) 118 | Resources not in desired state 18 out of 1510 (0.01%) 119 | 120 | Number of top-level modules 13 121 | Largest top-level module module.core[2] 122 | Size of largest top-level module 170 123 | Deepest module module.core[2].module.role[47] 124 | Deepest module depth 2 125 | Largest leaf module module.dbt[4] 126 | Size of largest leaf module 40 127 | ``` 128 | 129 | For more information, refer to the [reference](./docs/stats.md) for the `stats` command. 130 | 131 | ## `tf-profile table` 132 | 133 | `tf-profile table` will parse a log and provide per-resource metrics. 134 | 135 | ```bash 136 | ❱ terraform apply -auto-approve > log.txt 137 | ❱ tf-profile table log.txt 138 | 139 | resource n tot_time modify_started modify_ended desired_state operation final_state 140 | aws_ssm_parameter.p6 1 0s 6 7 Created Replace Created 141 | aws_ssm_parameter.p1 1 0s 7 5 Created Replace Created 142 | aws_ssm_parameter.p3 1 0s 5 6 Created Replace Created 143 | aws_ssm_parameter.p4 1 0s / 1 NotCreated Destroy NotCreated 144 | aws_ssm_parameter.p5 1 0s 4 4 Created Modify Created 145 | aws_ssm_parameter.p2 1 0s / / Created None Created 146 | ``` 147 | 148 | For a full description of the options, see the [reference](./docs/table.md) page. 149 | 150 | ## `tf-profile filter` 151 | `tf-profile filter` filters logs to include only certain resources. Wildcards are supported to filter on multiple resources. 152 | 153 | ```sh 154 | ❱ tf-profile filter "module.*.null_resource.*" log.txt 155 | 156 | # module.mod1.null_resource.foo will be created 157 | + resource "null_resource" "foo" { 158 | ... 159 | } 160 | 161 | # module.mod2.null_resource.bar will be created 162 | + resource "null_resource" "bar" { 163 | ... 164 | } 165 | 166 | module.mod1.null_resource.foo: Creating... 167 | module.mod2.null_resource.bar: Creating... 168 | module.mod1.null_resource.foo: Creation complete after 1s [id=foo] 169 | module.mod2.null_resource.bar: Creation complete after 1s [id=bar] 170 | ``` 171 | 172 | For a full description of the options, see the [reference](./docs/filter.md) page. 173 | 174 | 175 | 176 | ## `tf-profile graph` 177 | 178 | `tf-profile graph` is used to visualize your terraform logs. It generates a [Gantt](https://en.wikipedia.org/wiki/Gantt_chart)-like chart that shows in which order resources were created. `tf-profile` does not actually create the final image, but generates a script file that [Gnuplot](https://en.wikipedia.org/wiki/Gnuplot) understands. 179 | 180 | ```bash 181 | ❱ tf-profile graph my_log.log --out graph.png --size 2000,1000 | gnuplot 182 | ``` 183 | 184 | ![graph.png](https://github.com/QuintenBruynseraede/tf-profile/blob/main/.github/graph.png?raw=true) 185 | 186 | _Disclaimer:_ Terraform's logs do not contain any absolute timestamps. We can only derive the order in which resources started and finished their modifications. Therefore, the output of `tf-profile graph` gives only a general indication of _how long_ something actually took. In other words: the X axis is meaningless, apart from the fact that it's monotonically increasing. 187 | 188 | 189 | ## Screenshots 190 | 191 | ![stats.png](https://github.com/QuintenBruynseraede/tf-profile/blob/main/.github/stats.png?raw=true) 192 | 193 | ![table.png](https://github.com/QuintenBruynseraede/tf-profile/blob/main/.github/table.png?raw=true) 194 | 195 | ![graph2.png](https://github.com/QuintenBruynseraede/tf-profile/blob/main/.github/graph2.png?raw=true) 196 | 197 | -------------------------------------------------------------------------------- /build/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the application from source 2 | FROM golang:1.23 AS build 3 | 4 | WORKDIR /app 5 | 6 | COPY go.mod go.sum ./ 7 | RUN go mod download 8 | 9 | COPY *.go ./ 10 | COPY ./pkg/ ./pkg/ 11 | COPY ./cmd ./cmd 12 | 13 | RUN find ./ 14 | 15 | RUN CGO_ENABLED=0 GOOS=linux go build -o /tf-profile 16 | 17 | # Run the tests in the container 18 | FROM build AS run-test-stage 19 | COPY ./test ./test 20 | RUN go test -v ./... 21 | 22 | # Deploy the application binary into a lean image 23 | FROM gcr.io/distroless/base-debian11 AS build-release-stage 24 | 25 | WORKDIR / 26 | 27 | COPY --from=build /tf-profile /tf-profile 28 | 29 | USER nonroot:nonroot 30 | 31 | ENTRYPOINT ["/tf-profile"] -------------------------------------------------------------------------------- /cmd/filter.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | filter "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/filter" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | func init() { 9 | rootCmd.AddCommand(filterCmd) 10 | } 11 | 12 | var filterCmd = &cobra.Command{ 13 | Use: "filter", 14 | Short: "Filter a Terraform log to only selected resources", 15 | Long: `The 'filter' command is used to filter down logs to only 16 | those lines that contain references to a set of selected resources. 17 | Resources can be specified with regex. Only lines containing those 18 | resources will be printed. Terraform plans pertaining this resource 19 | are always fully shown. 20 | 21 | This command expects two arguments when reading from a log file: 22 | 23 | $ tf-profile filter "aws_ssm_parameter.*" /path/to/log.txt 24 | 25 | Only one argument is needed to read from stdin: 26 | 27 | $ terraform apply -auto-approve | tf-profile filter "aws_ssm_parameter.*" 28 | 29 | `, 30 | RunE: func(cmd *cobra.Command, args []string) error { 31 | return filter.Filter(args) 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /cmd/graph.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | graph "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/graph" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var ( 12 | Size []int 13 | OutFile string 14 | ) 15 | 16 | func init() { 17 | rootCmd.AddCommand(graphCmd) 18 | graphCmd.Flags().IntSliceVarP(&Size, "size", "s", []int{1000, 600}, "Width and height of generated image") 19 | graphCmd.Flags().StringVarP(&OutFile, "out", "o", "tf-profile-graph.png", "Output file used by gnuplot") 20 | graphCmd.Flags().BoolVarP(&aggregate, "aggregate", "a", true, "Agregate count[] and for_each[]") 21 | } 22 | 23 | var graphCmd = &cobra.Command{ 24 | Use: "graph", 25 | Short: "Visualize a Terraform run graphically", 26 | Long: `TODO`, 27 | RunE: func(cmd *cobra.Command, args []string) error { 28 | if len(Size) != 2 || Size[0] < 0 || Size[1] < 0 { 29 | return fmt.Errorf("Expected two positive integers for --size flag, got %v", Size) 30 | } 31 | return graph.Graph(args, Size[0], Size[1], OutFile, aggregate) 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var ( 8 | // Used for flags. 9 | cfgFile string 10 | userLicense string 11 | 12 | rootCmd = &cobra.Command{ 13 | Use: "tf-profile", 14 | Short: "tf-profile is a CLI tool to profile Terraform runs", 15 | } 16 | ) 17 | 18 | // Execute executes the root command. 19 | func Execute() error { 20 | return rootCmd.Execute() 21 | } 22 | 23 | func init() { 24 | 25 | } 26 | -------------------------------------------------------------------------------- /cmd/stats.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | stats "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/stats" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var ( 10 | aggregate bool 11 | ) 12 | 13 | func init() { 14 | rootCmd.AddCommand(statsCmd) 15 | statsCmd.Flags().BoolP("tee", "t", false, "Print logs while parsing") 16 | statsCmd.Flags().BoolVarP(&aggregate, "aggregate", "a", true, "Agregate count[] and for_each[]") 17 | } 18 | 19 | var statsCmd = &cobra.Command{ 20 | Use: "stats", 21 | Short: "Parse a Terraform log and show general statistics", 22 | Long: `The 'stats' command can be used to show general statistics 23 | a Terraform run. It prints high-level statistics on the following topics: 24 | basic, time-related, creation status and modules.`, 25 | RunE: func(cmd *cobra.Command, args []string) error { 26 | return stats.Stats(args, tee, aggregate) 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /cmd/table.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | table "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/table" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var ( 9 | max_depth int 10 | tee bool 11 | sort string 12 | ) 13 | 14 | func init() { 15 | rootCmd.AddCommand(tableCmd) 16 | tableCmd.Flags().StringVarP( 17 | &sort, 18 | "sort", 19 | "s", 20 | "tot_time=desc,resource=asc", 21 | "Comma-separated list of KEY=(asc|desc) to control sorting.", 22 | ) 23 | tableCmd.Flags().IntVarP( 24 | &max_depth, 25 | "max_depth", 26 | "d", 27 | -1, 28 | "Max recursive module depth before aggregating.", 29 | ) 30 | tableCmd.Flags().BoolVarP(&aggregate, "aggregate", "a", true, "Agregate count[] and for_each[]") 31 | tableCmd.Flags().Bool("tee", false, "Print logs while parsing") 32 | } 33 | 34 | var tableCmd = &cobra.Command{ 35 | Use: "table", 36 | Short: "Parse a Terraform log and perform resource-level profiling", 37 | Args: cobra.MaximumNArgs(1), 38 | Long: `The 'table' command is used to do in-depth profiling on a resource level. 39 | It will parse a log, extract metrics about all resources and show tabular output.`, 40 | RunE: func(cmd *cobra.Command, args []string) error { 41 | return table.Table(args, max_depth, tee, sort, aggregate) 42 | }, 43 | } 44 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func init() { 10 | rootCmd.AddCommand(versionCmd) 11 | } 12 | 13 | var versionCmd = &cobra.Command{ 14 | Use: "version", 15 | Short: "Print the version and exit.", 16 | Long: `Print the version and exit.`, 17 | Run: func(cmd *cobra.Command, args []string) { 18 | fmt.Println("tf-profile v0.5.0") 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /docs/filter.md: -------------------------------------------------------------------------------- 1 | # Filter 2 | 3 | **Syntax:** `tf-profile filter [resource_filter] [log_file]` 4 | 5 | **Description:** filter a Terraform log to only selected resources 6 | 7 | **Arguments:** 8 | 9 | - resource_filter: Output will only contain resources whose name matches this filter. Wildcards are supported. See below for examples on how to filter resources. 10 | - log_file: _Optional_. Instruct `tf-profile` to read input from a text file instead of stdin. 11 | 12 | ## Description 13 | 14 | This command will filter a log line-by-line, and print only the lines that are related to certain resources (as specified by the resource filter). More specifically, the following lines will remain in the output: 15 | 1. Part of the Terraform plan that describes the changes to this resource. 16 | 2. The full error message if an error occurred while modifying the resource. 17 | 3. Any line that matches the resource filter, but is not part of a plan or error message. 18 | 19 | Note that lines are matched to patterns using standard Go `regexp` functions. Out of the box, this would lead to cumbersome resource filtering (filters like `module\\.foo\\.resource\[.*\]` instead of the more natural `module.foo.resource[*]`). To support this, resource filters are transformed from the latter into the former. This entails: 20 | - `*` is replaced by `.*` 21 | - The following characters are escaped: `.`, `[`, `]` 22 | 23 | ## Examples 24 | 25 | Basic usage: 26 | ```sh 27 | ❱ terraform apply -auto-approve | tf-profile filter "aws_ssm_parameter.test" 28 | # aws_ssm_parameter.test will be created 29 | + resource "aws_ssm_parameter" "test" { 30 | + arn = (known after apply) 31 | + name = "my_ssm_parameter" 32 | + type = "String" 33 | + value = (sensitive value) 34 | + version = (known after apply) 35 | } 36 | 37 | aws_ssm_parameter.test: Creating... 38 | aws_ssm_parameter.test: Creation complete after 1s [id=my-param] 39 | ``` 40 | 41 | Reading from a log file: 42 | ```sh 43 | ❱ tf-profile filter "aws_ssm_parameter.test" log.txt 44 | ... # Output identical to above 45 | ``` 46 | 47 | Using wildcards to filter on multiple resources: 48 | ```sh 49 | ❱ tf-profile filter "aws_ssm_parameter.*" log.txt 50 | 51 | # aws_ssm_parameter.param1 will be created 52 | + resource "aws_ssm_parameter" "param1" { 53 | ... 54 | } 55 | 56 | # aws_ssm_parameter.param2 will be created 57 | + resource "aws_ssm_parameter" "param2" { 58 | ... 59 | } 60 | 61 | aws_ssm_parameter.param1: Creating... 62 | aws_ssm_parameter.param2: Creating... 63 | aws_ssm_parameter.param1: Creation complete after 1s [id=param1] 64 | 65 | Error: creating SSM Parameter (param2): ValidationException: Parameter name must not end with slash. 66 | status code: 400, request id: f7abc744-2fff-4d92-824b-b73g40ab256a 67 | 68 | with aws_ssm_parameter.param2, 69 | on provider.tf line 27, in resource "aws_ssm_parameter" "param2": 70 | 27: resource "aws_ssm_parameter" "param2" { 71 | ``` 72 | 73 | Multiple wildcards can be combined: 74 | ```sh 75 | ❱ tf-profile filter "module.*.null_resource.*" log.txt 76 | 77 | # module.mod1.null_resource.foo will be created 78 | + resource "null_resource" "foo" { 79 | ... 80 | } 81 | 82 | # module.mod2.null_resource.bar will be created 83 | + resource "null_resource" "bar" { 84 | ... 85 | } 86 | 87 | module.mod1.null_resource.foo: Creating... 88 | module.mod2.null_resource.bar: Creating... 89 | module.mod1.null_resource.foo: Creation complete after 1s [id=foo] 90 | module.mod2.null_resource.bar: Creation complete after 1s [id=bar] 91 | ``` -------------------------------------------------------------------------------- /docs/stats.md: -------------------------------------------------------------------------------- 1 | # Stats 2 | 3 | **Syntax:** `tf-profile stats [options] [log_file]` 4 | 5 | **Description:** reads a Terraform log file and print high-level information about the run. 6 | 7 | **Options:** 8 | - -t, --tee: print logs while parsing them. Shorthand for `terraform apply | tee >(tf-profile stats)`. Default: false 9 | - -a, --aggregate: enable or disable aggregation of resources created by the same `for_each` or `count` expression. Default: true 10 | 11 | **Arguments:** 12 | 13 | - log_file: _Optional_. Instruct `tf-profile` to read input from a text file instead of stdin. 14 | 15 | ## Description 16 | 17 | The following statistics will be printed: 18 | 19 | General: 20 | - **Number of resources created**: Number of resources detected in your log. Depending on which phases (refresh, plan, apply) were present in the log, this number can differ and may not always match what is defined in your code. For example, doing `terraform apply my_plan_file` will not include a resource that is not to be modified in this plan. 21 | 22 | Duration: 23 | - **Cumulative duration**: Cumulative duration of modifications. This is the sum of the duration of all modifications in the logs. Because Terraform modifies resources in parallel, this will typically be more than the actual wall time. 24 | - **Longest apply time**: Longest time it took to modify a single resource. The next metric shows which resource that was. 25 | - **Longest apply resource**: The name of the resource that took the most time to modify. 26 | 27 | Operations: 28 | - **Resources marked for operation \**: The amount of resources marked for a certain operation. An Operation can be any of: Create, Destroy, Modify, Replace, None. Resources that are consistent with the state, will be marked for operation None. 29 | 30 | Resource status: 31 | - **Resources in state \**: This statistic shows per state how many resources are in that state after the modifications. In general, resources can be in three states after a Terraform run: Created, NotCreated or Failed. 32 | 33 | Desired state: 34 | - **Resources in desired state**: The amount of resources whose `final_state` is equal to their `desired_state`. In a fully applied configuration, this number should be 100%. 35 | - **Resources not in desired state**: Resources whose desired state was not achieved after this run. This can be due to failed creation, failed deletion or because "upstream" resources were not able to get to their desired state. 36 | 37 | Modules: 38 | - **Number of top-level modules**: Number of modules called in the root module. 39 | - **Largest top-level module**: Name of the largest top-level module. 40 | - **Size of largest top-level module**: Number of resources in this largest top-level module. Note that this number includes all resources in submodules as well. 41 | - **Deepest module**: Name of the deepest nested module. For example. `module.a.module.b` is two levels deep, but `module.a.module.b.module.c` is three levels deep. If multiple modules are equally as deep, the first one detected in the log will be printed. 42 | - **Deepest module depth**: The depth of the module in the previous statistic. 43 | - **Largest leaf module**: A module is considered a "leaf module", if it does not make any recursive module calls. This metric prints the name of the largest leaf module. 44 | - **Size of largest leaf module**: Number of resources in the largest leaf module. As a leaf module has no submodules, these are only the resources created directly inside this leaf module. 45 | 46 | -------------------------------------------------------------------------------- /docs/table.md: -------------------------------------------------------------------------------- 1 | # Stats 2 | 3 | **Syntax:** `tf-profile table [options] [log_file]` 4 | 5 | **Description:** reads a Terraform log file and print high-level information about the run. 6 | 7 | **Options:** 8 | - -t, --tee: print logs while parsing them. Shorthand for `terraform apply | tee >(tf-profile stats)`. Default: false 9 | - -d, --max_depth: aggregate resources nested deeper than `-d` levels into a resource that represents the module at depth `-d`. **Not implented yet** 10 | - -s, --sort: comma-separated key-value pairs that instruct how to sort the output table. Valid values follow the format `column1:(asc|desc),column2:(asc|desc):...`. By default, `tot_time=desc,resource=asc` is used: sort first by descending modification time, second by resource name in alphabetical order. 11 | - -a, --aggregate: enable or disable aggregation of resources created by the same `for_each` or `count` expression. Default: true 12 | 13 | 14 | **Arguments:** 15 | 16 | - log_file: _Optional_. Instruct `tf-profile` to read input from a text file instead of stdin. 17 | 18 | ## Description 19 | 20 | This command prints a table based on the log file or input, sorted according to `-s / --sort` and printed to the terminal. Useful to inspect properties about individual resources. 21 | 22 | ``` 23 | resource n tot_time modify_started modify_ended desired_state operation final_state 24 | --------------------------------------------------------------------------------------------------------- 25 | aws_ssm_parameter.p6 1 0s 6 7 Created Replace Created 26 | aws_ssm_parameter.p1 1 0s 7 5 Created Replace Created 27 | aws_ssm_parameter.p3 1 0s 5 6 Created Replace Created 28 | aws_ssm_parameter.p4 1 0s / 1 NotCreated Destroy NotCreated 29 | aws_ssm_parameter.p5 1 0s 4 4 Created Modify Created 30 | aws_ssm_parameter.p2 1 0s / / Created None Created 31 | ``` 32 | 33 | The column names are lowercase and separated by underscores to allow for easy referencing in the `--sort` option. The meaning of each column is: 34 | 35 | - **resource**: Name of the resource. In case a resource is created by a `for_each` or `count` statement, resources are aggregated and individual resource identifiers are replaced by an asterisk (*). See the also `aggregate` option. 36 | - **n**: Number of resources represented by this resource name. For regular resources, this will be 1. For resourced created with `for_each` or `count`, this number represents the number of resources created in that loop. 37 | - **tot_time**: Total cumulative time of all resources identified by this resource name. This is typically higher than the actual wall time, as Terraform can modify multiple resources at the same time. 38 | - **modify_started**: order in which resource modification _started_. This means that Terraform started by modifying the resource with `modify_started = 0`. It does not guarantee the changes to this resource finished first as well (see `modify_ended`). Resources that were already consistent with the desired state do not have this property. 39 | - **modify_ended**: order in which resource modifications _ended_. This means that the resource with `modify_ended = 0` was the first resource to finish its modifications (either a creation, deletion, modification or replacement). Resources that were already consistent with the desired state do not have this property. 40 | - **desired_state**: state (Created, NotCreated) that Terraform will try to achieve with this run. For resources to be modified, created or replaced, Created is the desired state. For resources to be destroyed, NotCreated is the desired state. 41 | - **operation**: the name of the operation the Terraform will use to reconcile the current and desired situation. Operations can be: Create, Destroy, Replace, Modify, None. Resources in the state that are already consistent with the configuration, the operation will be None. 42 | - **final_state**: Final state of the resource after this run. In addition to Created and NotCreated, Failed is used to indicate the operation failed. 43 | 44 | ## Sorting 45 | 46 | Any of the columns above can be used to sort the output table, by means of the `--sort` (shorthand `-s`) option. This option follows the format `column1:(asc|desc),column2:(asc|desc):...`. For example: 47 | - `tot_time=desc,resource=asc`: sort first by total modification time (showing the highest first). For entries with the same modification time, sort alphabetically. 48 | - `modify_started=asc`: sort in order modifications, showing the resources that Terraform started modifying first. 49 | - `final_state=asc`: sort by the final state. See below for the sort order. 50 | 51 | When sorting on resource status (`desired_state` or `final_state`), statuses are mapped onto integers before sorting. 52 | 53 | - Unknown: 0 54 | - NotCreated: 1 55 | - Created: 2 56 | - Failed: 3 57 | - Tainted: 4 58 | - Multiple (for aggregated resources): 5 59 | 60 | When sorting on resource operations (`operation`), these are mapped onto integers as well: 61 | 62 | - None: 0 63 | - Create: 1 64 | - Modify: 2 65 | - Replace: 3 66 | - Destroy: 4 67 | - Multiple (for aggregated resources): 5 68 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/QuintenBruynseraede/tf-profile 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/fatih/color v1.17.0 7 | github.com/rodaine/table v1.3.0 8 | github.com/spf13/cobra v1.8.1 9 | github.com/stretchr/testify v1.9.0 10 | ) 11 | 12 | require ( 13 | github.com/davecgh/go-spew v1.1.1 // indirect 14 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 15 | github.com/mattn/go-colorable v0.1.13 // indirect 16 | github.com/mattn/go-isatty v0.0.20 // indirect 17 | github.com/pmezard/go-difflib v1.0.0 // indirect 18 | github.com/spf13/pflag v1.0.5 // indirect 19 | golang.org/x/sys v0.26.0 // indirect 20 | gopkg.in/yaml.v3 v3.0.1 // indirect 21 | ) 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= 6 | github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= 7 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 8 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 9 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 10 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 11 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 12 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 13 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 14 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 15 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 16 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 17 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 18 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 19 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 20 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 21 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 22 | github.com/rodaine/table v1.3.0 h1:4/3S3SVkHnVZX91EHFvAMV7K42AnJ0XuymRR2C5HlGE= 23 | github.com/rodaine/table v1.3.0/go.mod h1:47zRsHar4zw0jgxGxL9YtFfs7EGN6B/TaS+/Dmk4WxU= 24 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 25 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 26 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 27 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 28 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 29 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 30 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 31 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 32 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 33 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 34 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 35 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 36 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 37 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 38 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 39 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 40 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 41 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 42 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 43 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 44 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 45 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 46 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 47 | -------------------------------------------------------------------------------- /pkg/tf-profile/aggregate/aggregate.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | 7 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/core" 8 | ) 9 | 10 | // Take a parsed log and aggregate resources created 11 | // by the same `foreach` or `count` loop. 12 | func Aggregate(log ParsedLog) (ParsedLog, error) { 13 | New := ParsedLog{Resources: make(map[string]ResourceMetric)} 14 | 15 | // Collect all resource names in slice and sort 16 | ResourceNames := []string{} 17 | for k := range log.Resources { 18 | ResourceNames = append(ResourceNames, k) 19 | } 20 | sort.Strings(ResourceNames) 21 | 22 | // Loop over resources, collect resources to aggregate 23 | // and aggregate when coming across a new one. 24 | ToAgg := []string{} 25 | 26 | for _, name := range ResourceNames { 27 | // If the list is empty, start over. 28 | if len(ToAgg) == 0 { 29 | ToAgg = append(ToAgg, name) 30 | } else if canAggregate(name, last(ToAgg)) { 31 | // If this name is compatible with the list, add it 32 | ToAgg = append(ToAgg, name) 33 | } else { 34 | // Found incompatible resource, aggregate, add to log and start over 35 | AggName, AggMetric := aggregateResources(log, ToAgg) 36 | 37 | // Add aggregated resource to log we're building 38 | NewLog := New.Resources 39 | NewLog[AggName] = AggMetric 40 | New.Resources = NewLog 41 | 42 | // Start over with what the resource we just saw 43 | ToAgg = []string{name} 44 | } 45 | } 46 | 47 | // Aggregate leftovers in the list to get the final result 48 | if len(ToAgg) > 0 { 49 | AggName, AggMetric := aggregateResources(log, ToAgg) 50 | NewLog := New.Resources 51 | NewLog[AggName] = AggMetric 52 | New.Resources = NewLog 53 | } 54 | 55 | return New, nil 56 | } 57 | 58 | // Helper: return the last item in a list 59 | func last(l []string) string { 60 | return l[len(l)-1] 61 | } 62 | 63 | // Given two resource names, returns true if they were created using 64 | // `count` or `for_each`. For example: `resource[1]` and `resource[2]` 65 | func canAggregate(Resource1 string, Resource2 string) bool { 66 | // If any resource doesn't end with "]", can't aggregate 67 | if Resource1[len(Resource1)-1] != ']' || Resource2[len(Resource2)-1] != ']' { 68 | return false 69 | } 70 | // Anything before the last "[" must be equal 71 | split1 := strings.Split(Resource1, "[") 72 | split2 := strings.Split(Resource2, "[") 73 | if len(split1) == 1 || len(split2) == 1 { 74 | return false 75 | } 76 | 77 | prefix1 := strings.Join(split1[:len(split1)-1], "[") 78 | prefix2 := strings.Join(split2[:len(split2)-1], "[") 79 | if prefix1 != prefix2 { 80 | return false 81 | } 82 | return true 83 | } 84 | 85 | // Given a log and resources names to aggregate, find an aggregated name and 86 | // an aggregated ResourceMetric 87 | func aggregateResources(log ParsedLog, resources []string) (string, ResourceMetric) { 88 | // Singleton, just return it 89 | if len(resources) == 1 { 90 | return resources[0], log.Resources[resources[0]] 91 | } 92 | 93 | Metrics := []ResourceMetric{} 94 | for _, r := range resources { 95 | Metrics = append(Metrics, log.Resources[r]) 96 | } 97 | 98 | return aggregateResourceNames(resources...), aggregateResourceMetrics(Metrics...) 99 | } 100 | 101 | // Returns a new name for aggregated resource. For example: 102 | // (module.x.resource[1], module.x.resource[2]) -> module.x.resource[*] 103 | func aggregateResourceNames(names ...string) string { 104 | // Actually we only need to look at one item for now. 105 | split := strings.Split(names[0], "[") 106 | return strings.Join(split[:len(split)-1], "[") + "[*]" 107 | } 108 | 109 | // Aggregates a number of ResourceMetrics into one. 110 | // After aggregating 'NumCalls' contains the number of input records. 111 | // TotalTime contains the sum of individual apply times. 112 | // ModificationStartedIndex contains the *lowest* ModificationStartedIndex of any record. 113 | // ModificationCompletedIndex contains the *highest* ModificationStartedIndex of any record. 114 | // AfterStatus can be any of "Created", "Failed", "NotCreated", "Multiple" or "Unknown" 115 | func aggregateResourceMetrics(metrics ...ResourceMetric) ResourceMetric { 116 | NumCalls := len(metrics) 117 | TotalTime := float64(0) 118 | ModificationStartedIndex := -1 119 | ModificationCompletedIndex := -1 120 | ModificationStartedEvent := -1 121 | ModificationCompletedEvent := -1 122 | 123 | BeforeStatus := NoneStatus 124 | AfterStatus := NoneStatus 125 | DesiredStatus := NoneStatus 126 | Operation := NoneOp 127 | 128 | for _, metric := range metrics { 129 | TotalTime += metric.TotalTime 130 | 131 | // For ModificationStartedIndex and ModificationStartedEvent, take the first one we see 132 | if ModificationStartedIndex == -1 { 133 | ModificationStartedIndex = metric.ModificationStartedIndex 134 | } 135 | if ModificationStartedEvent == -1 { 136 | ModificationStartedEvent = metric.ModificationStartedEvent 137 | } 138 | 139 | // For ModificationCompletedIndex and ModificationCompletedEvent, take the maximum 140 | ModificationCompletedIndex = maxInt(ModificationCompletedIndex, metric.ModificationCompletedIndex) 141 | ModificationCompletedEvent = maxInt(ModificationCompletedEvent, metric.ModificationCompletedEvent) 142 | 143 | // Calculate aggregated statuses: 144 | // - if all statuses are equal to X, the result will be X 145 | // - if multiple statuses are seen, the result will be "Multiple" 146 | if BeforeStatus == NoneStatus { 147 | BeforeStatus = metric.BeforeStatus 148 | } 149 | if AfterStatus == NoneStatus { 150 | AfterStatus = metric.AfterStatus 151 | } 152 | if DesiredStatus == NoneStatus { 153 | DesiredStatus = metric.DesiredStatus 154 | } 155 | if Operation == NoneOp { 156 | Operation = metric.Operation 157 | } 158 | 159 | if BeforeStatus != metric.BeforeStatus { 160 | BeforeStatus = Multiple 161 | } 162 | if AfterStatus != metric.AfterStatus { 163 | AfterStatus = Multiple 164 | } 165 | if DesiredStatus != metric.DesiredStatus { 166 | DesiredStatus = Multiple 167 | } 168 | if Operation != metric.Operation { 169 | Operation = MultipleOp 170 | } 171 | 172 | } 173 | 174 | return ResourceMetric{ 175 | NumCalls: NumCalls, 176 | TotalTime: TotalTime, 177 | ModificationStartedIndex: ModificationStartedIndex, 178 | ModificationCompletedIndex: ModificationCompletedIndex, 179 | ModificationStartedEvent: ModificationStartedEvent, 180 | ModificationCompletedEvent: ModificationCompletedEvent, 181 | BeforeStatus: BeforeStatus, 182 | AfterStatus: AfterStatus, 183 | DesiredStatus: DesiredStatus, 184 | Operation: Operation, 185 | } 186 | } 187 | 188 | func maxInt(a int, b int) int { 189 | if a >= b { 190 | return a 191 | } 192 | return b 193 | } 194 | -------------------------------------------------------------------------------- /pkg/tf-profile/aggregate/aggregate_test.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/core" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | // Helper to prevent "struct literal uses unkeyed fields" 12 | func MkMetric(a int, b float64, c int, d int, e int, f int, g Status, h Status, i Status, j Operation) ResourceMetric { 13 | return ResourceMetric{NumCalls: a, TotalTime: b, ModificationStartedIndex: c, ModificationCompletedIndex: d, ModificationStartedEvent: e, ModificationCompletedEvent: f, BeforeStatus: g, AfterStatus: h, DesiredStatus: i, Operation: j} 14 | } 15 | func TestAggregateResourceMetricBasic(t *testing.T) { 16 | M1 := MkMetric(1, 2000, 0, 0, 0, 3, NotCreated, Created, Created, Create) 17 | M2 := MkMetric(1, 5000, 1, 1, 1, 4, NotCreated, Created, Created, Create) 18 | M3 := MkMetric(1, 1000, 2, 2, 2, 5, NotCreated, Created, Created, Create) 19 | 20 | Result := aggregateResourceMetrics(M1, M2, M3) 21 | Expected := MkMetric(3, 8000, 0, 2, 0, 5, NotCreated, Created, Created, Create) 22 | assert.Equalf(t, Expected, Result, "Expected different result after aggregating.") 23 | } 24 | 25 | func AggStatus(In ...Status) Status { 26 | ResourceMetrics := []ResourceMetric{} 27 | for _, rm := range In { 28 | ResourceMetrics = append(ResourceMetrics, ResourceMetric{AfterStatus: rm}) 29 | } 30 | return aggregateResourceMetrics(ResourceMetrics...).AfterStatus 31 | } 32 | func TestAggregateResourceMetricStatuses(t *testing.T) { 33 | Result := AggStatus(Failed, Failed, Failed) 34 | assert.Equal(t, Failed, Result) 35 | 36 | Result = AggStatus(Created, Failed, NotCreated) 37 | assert.Equal(t, Multiple, Result) 38 | } 39 | 40 | func TestCanAgg(t *testing.T) { 41 | assert.False(t, canAggregate("resource1", "resource2")) 42 | assert.False(t, canAggregate("module.x.r1", "module.y.r1")) 43 | assert.False(t, canAggregate("module.x[1].r1", "module.x[1].r2")) 44 | assert.False(t, canAggregate("module.x.r[1]", "module.y.r[2]")) 45 | assert.False(t, canAggregate("module.x.r1[\"abc\"]", "module.y.r1[\"def\"]")) 46 | 47 | assert.True(t, canAggregate("r1[1]", "r1[2]")) 48 | assert.True(t, canAggregate("r1[\"abc\"]", "r1[\"def\"]")) 49 | assert.True(t, canAggregate("module.x.r1[\"abc\"]", "module.x.r1[\"def\"]")) 50 | assert.True(t, canAggregate("module.x[\"a\"].r1[\"abc\"]", "module.x[\"a\"].r1[\"def\"]")) 51 | assert.True(t, canAggregate("r[1]", "r[\"a\"]")) // Edge case as they come from different loops... 52 | } 53 | 54 | // No aggregation possible 55 | func TestNoAgg(t *testing.T) { 56 | In := ParsedLog{ 57 | Resources: map[string]ResourceMetric{ 58 | "resource1": {}, 59 | "resource2": {}, 60 | "resource3": {}, 61 | }, 62 | } 63 | Result, err := Aggregate(In) 64 | assert.Nil(t, err) 65 | assert.Equal(t, In, Result) // Assert Result identical to input 66 | } 67 | 68 | func TestBasicAgg(t *testing.T) { 69 | In := ParsedLog{ 70 | Resources: map[string]ResourceMetric{ 71 | "r1[1]": MkMetric(1, 1, 0, 0, 0, 3, NotCreated, Created, Created, Create), 72 | "r1[2]": MkMetric(1, 1, 1, 1, 1, 4, NotCreated, Created, Created, Create), 73 | "r1[3]": MkMetric(1, 1, 2, 2, 2, 5, NotCreated, Created, Created, Create), 74 | }, 75 | } 76 | Out := ParsedLog{ 77 | Resources: map[string]ResourceMetric{ 78 | "r1[*]": MkMetric(3, 3, 0, 2, 0, 5, NotCreated, Created, Created, Create), 79 | }, 80 | } 81 | Result, err := Aggregate(In) 82 | assert.Nil(t, err) 83 | assert.Equal(t, Out, Result) 84 | } 85 | 86 | func TestMixedAgg(t *testing.T) { 87 | In := ParsedLog{ 88 | Resources: map[string]ResourceMetric{ 89 | "r1[1]": MkMetric(1, 1, 0, 0, 0, 7, NotCreated, Created, Created, Create), 90 | "r1[2]": MkMetric(1, 1, 1, 1, 1, 8, NotCreated, Created, Created, Create), 91 | "r1[3]": MkMetric(1, 1, 2, 2, 2, 9, NotCreated, Created, Created, Create), 92 | "r2[\"a\"]": MkMetric(1, 1, 3, 3, 3, 10, NotCreated, Created, Created, Create), 93 | "r2[\"b\"]": MkMetric(1, 1, 4, 4, 4, 11, NotCreated, Created, Created, Create), 94 | "r3": MkMetric(1, 1, 5, 5, 5, 12, NotCreated, Created, Created, Create), 95 | "r4": MkMetric(1, 1, 6, 6, 6, 13, NotCreated, Created, Created, Create), 96 | }, 97 | } 98 | Out := ParsedLog{ 99 | Resources: map[string]ResourceMetric{ 100 | "r1[*]": MkMetric(3, 3, 0, 2, 0, 9, NotCreated, Created, Created, Create), 101 | "r2[*]": MkMetric(2, 2, 3, 4, 3, 11, NotCreated, Created, Created, Create), 102 | "r3": MkMetric(1, 1, 5, 5, 5, 12, NotCreated, Created, Created, Create), 103 | "r4": MkMetric(1, 1, 6, 6, 6, 13, NotCreated, Created, Created, Create), 104 | }, 105 | } 106 | Result, err := Aggregate(In) 107 | assert.Nil(t, err) 108 | assert.Equal(t, Out, Result) 109 | } 110 | 111 | func TestFullAgg(t *testing.T) { 112 | In := ParsedLog{ 113 | Resources: map[string]ResourceMetric{ 114 | // Can be aggregated on name 115 | "module.x.r[1]": MkMetric(1, 1, 0, 0, 0, 0, NotCreated, Created, Created, Create), 116 | "module.x.r[2]": MkMetric(1, 2, 0, 0, 0, 0, NotCreated, Created, Created, Create), 117 | "module.x.r[3]": MkMetric(1, 3, 0, 0, 0, 0, NotCreated, Created, Created, Create), 118 | // With nested modules 119 | "module.y[1].module.y[1].r[1]": MkMetric(1, 1, 0, 0, 0, 0, NotCreated, Created, Created, Create), 120 | "module.y[1].module.y[1].r[2]": MkMetric(1, 2, 0, 0, 0, 0, NotCreated, Created, Created, Create), 121 | "module.y[1].module.y[1].r[3]": MkMetric(1, 3, 0, 0, 0, 0, NotCreated, Created, Created, Create), 122 | // With ModificationStartedIndexMkMetric(onCompletedIndex 123 | "module.z[1].module.z[1].r[1]": MkMetric(1, 3, 2, 1, 0, 0, NotCreated, Created, Created, Create), 124 | "module.z[1].module.z[1].r[2]": MkMetric(1, 2, 3, 5, 0, 0, NotCreated, Created, Created, Create), 125 | "module.z[1].module.z[1].r[3]": MkMetric(1, 1, 5, 9, 0, 0, NotCreated, Created, Created, Create), 126 | // Mixed states 127 | "module.a[1].module.b[1].r[1]": MkMetric(1, 3, 2, 1, 0, 0, NotCreated, Created, Created, Create), 128 | "module.a[1].module.b[1].r[2]": MkMetric(1, 2, 3, 5, 0, 0, NotCreated, Failed, Created, Create), 129 | "module.a[1].module.b[1].r[3]": MkMetric(1, 1, 5, 9, 0, 0, NotCreated, Created, Created, Create), 130 | "module.a[1].module.b[1].r[4]": MkMetric(1, 1, 5, 9, 0, 0, NotCreated, Created, Created, Create), 131 | // Not agg'able 132 | "random_resource": MkMetric(1, 1, 5, 9, 1, 1, NotCreated, Created, Created, Create), 133 | "random_resource2": MkMetric(1, 1, 5, 9, 2, 2, NotCreated, Failed, Created, Create), 134 | "random_resource3": MkMetric(1, 1, 5, 9, 3, 3, NotCreated, Failed, Created, Create), 135 | }, 136 | } 137 | Out := ParsedLog{ 138 | Resources: map[string]ResourceMetric{ 139 | "module.x.r[*]": MkMetric(3, 6, 0, 0, 0, 0, NotCreated, Created, Created, Create), 140 | "module.y[1].module.y[1].r[*]": MkMetric(3, 6, 0, 0, 0, 0, NotCreated, Created, Created, Create), 141 | "module.z[1].module.z[1].r[*]": MkMetric(3, 6, 2, 9, 0, 0, NotCreated, Created, Created, Create), 142 | "module.a[1].module.b[1].r[*]": MkMetric(4, 7, 2, 9, 0, 0, NotCreated, Multiple, Created, Create), 143 | "random_resource": MkMetric(1, 1, 5, 9, 1, 1, NotCreated, Created, Created, Create), 144 | "random_resource2": MkMetric(1, 1, 5, 9, 2, 2, NotCreated, Failed, Created, Create), 145 | "random_resource3": MkMetric(1, 1, 5, 9, 3, 3, NotCreated, Failed, Created, Create), 146 | }, 147 | } 148 | Result, err := Aggregate(In) 149 | assert.Nil(t, err) 150 | assert.Equal(t, Out, Result) 151 | } 152 | -------------------------------------------------------------------------------- /pkg/tf-profile/core/errors.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import "fmt" 4 | 5 | type ( 6 | LineParseError struct{ Msg string } 7 | ResourceNotFoundError struct{ Resource string } 8 | ) 9 | 10 | func (e *LineParseError) Error() string { 11 | return e.Msg 12 | } 13 | 14 | func (e *ResourceNotFoundError) Error() string { 15 | return fmt.Sprintf("Unable to find resource %v in log.", e.Resource) 16 | } 17 | -------------------------------------------------------------------------------- /pkg/tf-profile/core/text.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import "regexp" 4 | 5 | // Terraform inserts a lot of formatting strings into its output when 6 | // -no-color is not specified. This function removes all of those 7 | func RemoveTerminalFormatting(in string) string { 8 | // regex to detect ANSI terminal formatting directives (https://stackoverflow.com/a/14693789) 9 | re := regexp.MustCompile(`(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]`) 10 | return re.ReplaceAllString(in, "") 11 | } 12 | -------------------------------------------------------------------------------- /pkg/tf-profile/core/types.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import "fmt" 4 | 5 | const ( 6 | // Status for individual resources 7 | // NotStarted Status = 0 8 | // Started Status = 1 9 | NoneStatus Status = -1 // Internal only 10 | Unknown Status = 0 11 | NotCreated Status = 1 12 | Created Status = 2 13 | Failed Status = 3 14 | Tainted Status = 4 15 | // For aggregated resources 16 | Multiple Status = 5 17 | 18 | // Operation types 19 | NoneOp Operation = -1 // Internal only 20 | None Operation = 0 // Default, "not seen", can occur when upstream resources fail 21 | Create Operation = 1 22 | Modify Operation = 2 23 | Replace Operation = 3 24 | Destroy Operation = 4 25 | MultipleOp Operation = 5 26 | ) 27 | 28 | type ( 29 | Status int 30 | Operation int 31 | 32 | // Data structure that holds all metrics for one particular resource 33 | ResourceMetric struct { 34 | NumCalls int 35 | TotalTime float64 36 | // Resource was the Nth to start creation. 37 | ModificationStartedIndex int 38 | // Resource was the Nth to finish creation 39 | ModificationCompletedIndex int 40 | // (Global) event index of when creation started. As this is a global event, 41 | // it can be compared chronologically with a ModificationCompletedEvent. 42 | ModificationStartedEvent int 43 | // (Global) event index of when creation finished. As this is a global event, 44 | // it can be compared chronologically with a ModificationStartedEvent. 45 | ModificationCompletedEvent int // (Global) event index of when creation finished 46 | // Inferred status before the TF run 47 | BeforeStatus Status 48 | // Status after the TF run 49 | AfterStatus Status 50 | // Expected status as planned by TF 51 | DesiredStatus Status 52 | // Operation to perform to go from BeforeStatus to DesiredStatus 53 | Operation Operation 54 | } 55 | 56 | // Parsing a log results in a map of resource names and their metrics 57 | ParsedLog struct { 58 | // Indices to keep track of progress during parse 59 | CurrentModificationStartedIndex int 60 | CurrentModificationEndedIndex int 61 | CurrentEvent int 62 | // Stage information 63 | ContainsRefresh bool 64 | ContainsPlan bool 65 | ContainsApply bool 66 | // Resources detected 67 | Resources map[string]ResourceMetric 68 | } 69 | ) 70 | 71 | func (log ParsedLog) SetNumCalls(Resource string, NumCalls int) error { 72 | metric, found := log.Resources[Resource] 73 | if found == false { 74 | return &ResourceNotFoundError{Resource} 75 | } 76 | metric.NumCalls = NumCalls 77 | log.Resources[Resource] = metric 78 | return nil 79 | } 80 | 81 | func (log ParsedLog) SetTotalTime(Resource string, TotalTime float64) error { 82 | metric, found := log.Resources[Resource] 83 | if found == false { 84 | return &ResourceNotFoundError{Resource} 85 | } 86 | metric.TotalTime = TotalTime 87 | log.Resources[Resource] = metric 88 | return nil 89 | } 90 | 91 | func (log ParsedLog) SetModificationStartedIndex(Resource string, Idx int) error { 92 | metric, found := log.Resources[Resource] 93 | if found == false { 94 | return &ResourceNotFoundError{Resource} 95 | } 96 | metric.ModificationStartedIndex = Idx 97 | log.Resources[Resource] = metric 98 | return nil 99 | } 100 | 101 | func (log ParsedLog) SetModificationCompletedIndex(Resource string, Idx int) error { 102 | metric, found := log.Resources[Resource] 103 | if found == false { 104 | return &ResourceNotFoundError{Resource} 105 | } 106 | metric.ModificationCompletedIndex = Idx 107 | log.Resources[Resource] = metric 108 | return nil 109 | } 110 | 111 | func (log ParsedLog) SetModificationStartedEvent(Resource string, Idx int) error { 112 | metric, found := log.Resources[Resource] 113 | if found == false { 114 | return &ResourceNotFoundError{Resource} 115 | } 116 | metric.ModificationStartedEvent = Idx 117 | log.Resources[Resource] = metric 118 | return nil 119 | } 120 | 121 | func (log ParsedLog) SetModificationCompletedEvent(Resource string, Idx int) error { 122 | metric, found := log.Resources[Resource] 123 | if found == false { 124 | return &ResourceNotFoundError{Resource} 125 | } 126 | metric.ModificationCompletedEvent = Idx 127 | log.Resources[Resource] = metric 128 | return nil 129 | } 130 | 131 | func (log ParsedLog) SetAfterStatus(Resource string, Status Status) error { 132 | metric, found := log.Resources[Resource] 133 | if found == false { 134 | return &ResourceNotFoundError{Resource} 135 | } 136 | metric.AfterStatus = Status 137 | log.Resources[Resource] = metric 138 | return nil 139 | } 140 | 141 | func (log ParsedLog) SetBeforeStatus(Resource string, Status Status) error { 142 | metric, found := log.Resources[Resource] 143 | if found == false { 144 | return &ResourceNotFoundError{Resource} 145 | } 146 | metric.BeforeStatus = Status 147 | log.Resources[Resource] = metric 148 | return nil 149 | } 150 | 151 | func (log ParsedLog) SetDesiredStatus(Resource string, Status Status) error { 152 | metric, found := log.Resources[Resource] 153 | if found == false { 154 | return &ResourceNotFoundError{Resource} 155 | } 156 | metric.DesiredStatus = Status 157 | log.Resources[Resource] = metric 158 | return nil 159 | } 160 | 161 | func (log ParsedLog) SetOperation(Resource string, Op Operation) error { 162 | metric, found := log.Resources[Resource] 163 | if found == false { 164 | return &ResourceNotFoundError{Resource} 165 | } 166 | // If Operation was Destroy before, overwrite Create with Replace 167 | if metric.Operation == Destroy && Op == Create { 168 | metric.Operation = Replace 169 | } else { 170 | metric.Operation = Op 171 | } 172 | log.Resources[Resource] = metric 173 | return nil 174 | } 175 | 176 | func (log ParsedLog) RegisterNewResource(Resource string) { 177 | _, found := (log.Resources)[Resource] 178 | if found { 179 | return 180 | } 181 | (log.Resources)[Resource] = ResourceMetric{ 182 | NumCalls: 1, 183 | TotalTime: -1, // Not finished yet, will be overwritten 184 | ModificationStartedIndex: log.CurrentModificationStartedIndex, 185 | ModificationCompletedIndex: -1, // Not finished yet, will be overwritten 186 | ModificationStartedEvent: log.CurrentEvent, 187 | ModificationCompletedEvent: -1, // Not finished yet, will be overwritten 188 | BeforeStatus: Created, 189 | AfterStatus: Created, 190 | DesiredStatus: Created, 191 | Operation: None, 192 | } 193 | } 194 | 195 | func (s Status) String() string { 196 | switch s { 197 | case NotCreated: 198 | return "NotCreated" 199 | case Created: 200 | return "Created" 201 | case Failed: 202 | return "Failed" 203 | case Unknown: 204 | return "Unknown" 205 | case Tainted: 206 | return "Tainted" 207 | default: 208 | return fmt.Sprintf("%d (unknown)", int(s)) 209 | } 210 | } 211 | 212 | func (s Operation) String() string { 213 | switch s { 214 | case Destroy: 215 | return "Destroy" 216 | case Create: 217 | return "Create" 218 | case Modify: 219 | return "Modify" 220 | case Replace: 221 | return "Replace" 222 | case MultipleOp: 223 | return "Multiple" 224 | case None: 225 | return "None" 226 | default: 227 | return fmt.Sprintf("%d (unknown)", int(s)) 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /pkg/tf-profile/filter/filter.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | 9 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/core" 10 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/readers" 11 | ) 12 | 13 | type Stat struct { 14 | name string 15 | value string 16 | } 17 | 18 | func Filter(args []string) error { 19 | var file *bufio.Scanner 20 | var regex string 21 | var err error 22 | 23 | file, regex, err = parseArgs(args) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | logs := FilterLogs(file, regex) 29 | printList(logs) 30 | return nil 31 | } 32 | 33 | // Read a file line by line and return only lines that contain information 34 | // regarding the resources we're filtering to. This includes: plan logs, 35 | // errors logs and (otherwise) any lines that match the resource regex. 36 | func FilterLogs(file *bufio.Scanner, regex string) []string { 37 | output := []string{} 38 | 39 | // If we're currently seeing plan logs, collect the lines into a 40 | // buffer until detecting the end, then add all at once. 41 | var CollectingPlan = false 42 | var PlanBuffer = []string{} 43 | 44 | // If we're seeing errors logs, collect lines into a buffer until 45 | // the end of the message, then print add at once. 46 | var CollectingError = false 47 | var ErrorBuffer = []string{} 48 | 49 | for file.Scan() { 50 | line := RemoveTerminalFormatting(file.Text()) 51 | 52 | if CollectingPlan && isEndOfPlan(line) { 53 | // End of plan, print the buffer and reset 54 | PlanBuffer = append(PlanBuffer, line) 55 | output = append(output, PlanBuffer...) 56 | output = append(output, "") // Empty line to make it look nice. 57 | 58 | PlanBuffer = []string{} 59 | CollectingPlan = false 60 | } else if CollectingPlan { 61 | // Continuation of plan 62 | PlanBuffer = append(PlanBuffer, line) 63 | } else if isStartOfPlan(line, regex) { 64 | // Detected start of plan, start new buffer 65 | CollectingPlan = true 66 | PlanBuffer = append(PlanBuffer, line) 67 | } else if CollectingError && isEndOfError(line) { 68 | // End of error, print buffer and reset 69 | ErrorBuffer = append(ErrorBuffer, line) 70 | output = append(output, ErrorBuffer...) 71 | 72 | ErrorBuffer = []string{} 73 | CollectingError = false 74 | } else if CollectingError && errorDoesntMatchResource(line, regex) { 75 | // Discard buffer, error is not about an interesting resource 76 | ErrorBuffer = []string{} 77 | CollectingError = false 78 | } else if CollectingError { 79 | // Continuation of error 80 | ErrorBuffer = append(ErrorBuffer, line) 81 | } else if isStartOfError(line) { 82 | // Start new error 83 | CollectingError = true 84 | ErrorBuffer = append(ErrorBuffer, line) 85 | } else { 86 | // No plan or error but a resource is mentioned: print the line 87 | match, _ := regexp.MatchString(regex, line) 88 | if match { 89 | output = append(output, line) 90 | } 91 | } 92 | } 93 | return output 94 | } 95 | 96 | // Make using regex to specify Terraform resources easier by allowing things 97 | // that are not valid regex but make sense intuitively. 98 | // For example: `module.*.my_resource[*]` can not directly be evaluated because 99 | // `.` and `[]` are regex constructs, `*` is used as `.*`. To allow this type of 100 | // "natural" querying, we replace `*` with `.*` and escape the following characters: 101 | // ".[]". 102 | func cleanRegex(regex string) string { 103 | out := strings.ReplaceAll(regex, `.`, `\.`) 104 | out = strings.ReplaceAll(out, `*`, `.*`) 105 | out = strings.ReplaceAll(out, `]`, `\]`) 106 | out = strings.ReplaceAll(out, `[`, `\[`) 107 | return out 108 | } 109 | 110 | func parseArgs(args []string) (*bufio.Scanner, string, error) { 111 | var err error 112 | var file *bufio.Scanner 113 | var regex string 114 | 115 | if len(args) == 2 { 116 | file, err = FileReader{File: args[1]}.Read() 117 | regex = args[0] 118 | 119 | if file == nil { 120 | return nil, "", fmt.Errorf("File could not be read (%v)\n", args[1]) 121 | } 122 | } else if len(args) == 1 { 123 | file, err = StdinReader{}.Read() 124 | regex = args[0] 125 | } else if err != nil { 126 | return nil, "", err 127 | } else { 128 | return nil, "", fmt.Errorf( 129 | "Filter command requires one or two arguments, %v were given!\n", len(args)) 130 | } 131 | 132 | return file, cleanRegex(regex), nil 133 | } 134 | 135 | // The start of a plan block can be identified by various sentences, such as: 136 | // "X will be created", "X will replaced", etc... 137 | func isStartOfPlan(line string, resource string) bool { 138 | patterns := []string{ 139 | fmt.Sprintf("%v is tainted, so must be replaced", resource), 140 | fmt.Sprintf("%v will be created", resource), 141 | fmt.Sprintf("%v will be replaced, as requested", resource), 142 | fmt.Sprintf("%v will be destroyed", resource), 143 | fmt.Sprintf("%v will be updated in-place", resource), 144 | fmt.Sprintf("%v must be replaced", resource), 145 | } 146 | 147 | // Check if any of the patterns match 148 | for _, p := range patterns { 149 | match, _ := regexp.MatchString(p, line) 150 | if match { 151 | return true 152 | } 153 | } 154 | return false 155 | } 156 | 157 | func isEndOfPlan(line string) bool { 158 | return line == " }" 159 | } 160 | 161 | func printList(b []string) { 162 | for _, line := range b { 163 | fmt.Println(line) 164 | } 165 | } 166 | 167 | func isStartOfError(line string) bool { 168 | return strings.HasPrefix(line, "Error: ") 169 | } 170 | 171 | func isEndOfError(line string) bool { 172 | pattern := `[0-9]+: (resource|data) ".*" ".*" {` 173 | match, _ := regexp.MatchString(pattern, line) 174 | return match 175 | } 176 | 177 | // Return true when we are detecting an error but it does not relate 178 | // to a resource (specified by regex). 179 | func errorDoesntMatchResource(line string, resource string) bool { 180 | // Interesting lines start with " with " and and with "," 181 | if strings.HasPrefix(line, " with ") && strings.HasSuffix(line, ",") { 182 | pattern := fmt.Sprintf(" with %v,", resource) 183 | match, _ := regexp.MatchString(pattern, line) 184 | return !match 185 | } 186 | return false 187 | } 188 | -------------------------------------------------------------------------------- /pkg/tf-profile/filter/filter_test.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestCli(t *testing.T) { 13 | err := Filter([]string{"aws_ssm_parameter.*", "../../../test/failures.log"}) 14 | assert.Nil(t, err) 15 | 16 | err = Filter([]string{}) 17 | assert.NotNil(t, err) 18 | 19 | err = Filter([]string{"*", "non/existing/file.txt"}) 20 | assert.NotNil(t, err) 21 | 22 | err = Filter([]string{"1", "2", "3"}) 23 | assert.NotNil(t, err) 24 | 25 | err = Filter([]string{"*"}) 26 | assert.Nil(t, err) 27 | } 28 | 29 | func TestFilterSanityCheck(t *testing.T) { 30 | Files, err := os.ReadDir("../../../test") 31 | assert.Nil(t, err) 32 | 33 | // Sanity check: some output when filtering to resource .* 34 | for _, File := range Files { 35 | if strings.Contains(File.Name(), ".log") { 36 | file, _ := os.Open("../../../test/" + File.Name()) 37 | s := bufio.NewScanner(file) 38 | 39 | out := FilterLogs(s, ".*") 40 | assert.True(t, len(out) > 0) 41 | } 42 | } 43 | } 44 | 45 | func TestFullyQualifiedResourceFilter(t *testing.T) { 46 | file, _ := os.Open("../../../test/null_resources.log") 47 | s := bufio.NewScanner(file) 48 | regex := cleanRegex("null_resource.next") 49 | out := FilterLogs(s, regex) 50 | 51 | assert.Contains(t, out, ` # null_resource.next will be created`) 52 | assert.Contains(t, out, ` + resource "null_resource" "next" {`) 53 | assert.Contains(t, out, ` + id = (known after apply)`) 54 | assert.Contains(t, out, ` }`) 55 | assert.Contains(t, out, `null_resource.next: Creating...`) 56 | assert.Contains(t, out, `null_resource.next: Creation complete after 0s [id=1766626192520212902]`) 57 | } 58 | 59 | func TestBasicWildcardFilter(t *testing.T) { 60 | file, _ := os.Open("../../../test/null_resources.log") 61 | s := bufio.NewScanner(file) 62 | regex := cleanRegex("null_resource*") 63 | out := FilterLogs(s, regex) 64 | 65 | assert.Contains(t, out, ` # null_resource.next will be created`) 66 | assert.Contains(t, out, ` # null_resource.previous will be created`) 67 | assert.Contains(t, out, ` + resource "null_resource" "next" {`) 68 | assert.Contains(t, out, ` + resource "null_resource" "previous" {`) 69 | assert.Contains(t, out, `null_resource.previous: Creation complete after 0s [id=5144705655797302376]`) 70 | assert.Contains(t, out, `null_resource.next: Creation complete after 0s [id=1766626192520212902]`) 71 | } 72 | 73 | func TestFilterWithError(t *testing.T) { 74 | file, _ := os.Open("../../../test/failures.log") 75 | s := bufio.NewScanner(file) 76 | regex := cleanRegex("aws_ssm_parameter.bad2*") 77 | out := FilterLogs(s, regex) 78 | 79 | assert.Contains(t, out, ` # aws_ssm_parameter.bad2[0] will be created`) 80 | assert.Contains(t, out, ` # aws_ssm_parameter.bad2[1] will be created`) 81 | assert.Contains(t, out, ` # aws_ssm_parameter.bad2[2] will be created`) 82 | 83 | assert.Contains(t, out, `Error: creating SSM Parameter (/slash/at/end2/): ValidationException: Parameter name must not end with slash.`) 84 | assert.Contains(t, out, `Error: creating SSM Parameter (/slash/at/end1/): ValidationException: Parameter name must not end with slash.`) 85 | assert.Contains(t, out, `Error: creating SSM Parameter (/slash/at/end0/): ValidationException: Parameter name must not end with slash.`) 86 | 87 | assert.Contains(t, out, ` with aws_ssm_parameter.bad2[0],`) 88 | assert.Contains(t, out, ` with aws_ssm_parameter.bad2[1],`) 89 | assert.Contains(t, out, ` with aws_ssm_parameter.bad2[2],`) 90 | 91 | // Doesn't contain anything related to aws_ssm_parameter.bad 92 | assert.NotContains(t, out, ` with aws_ssm_parameter.bad,`) 93 | assert.NotContains(t, out, `Error: creating SSM Parameter (/slash/at/end/): ValidationException: Parameter name must not end with slash.`) 94 | } 95 | 96 | func TestCleanRegex(t *testing.T) { 97 | var m = map[string]string{ 98 | `*`: `.*`, 99 | `x.y`: `x\.y`, 100 | `module.*.x[*]`: `module\..*\.x\[.*\]`, 101 | } 102 | 103 | for in, out := range m { 104 | assert.Equal(t, cleanRegex(in), out) 105 | } 106 | } 107 | 108 | func TestPatterns(t *testing.T) { 109 | assert.True(t, isStartOfPlan("x is tainted, so must be replaced", "x")) 110 | assert.True(t, isStartOfPlan("x will be created", "x")) 111 | assert.True(t, isStartOfPlan("x will be replaced, as requested", "x")) 112 | assert.True(t, isStartOfPlan("x will be destroyed", "x")) 113 | assert.True(t, isStartOfPlan("x will be updated in-place", "x")) 114 | assert.True(t, isStartOfPlan("x must be replaced", "x")) 115 | 116 | assert.True(t, isStartOfError("Error: something the provider returns")) 117 | assert.True(t, isEndOfError(`12: resource "x" "y" {`)) 118 | assert.True(t, isEndOfError(`1234: data "x" "y" {`)) 119 | assert.False(t, isEndOfError(`12: something "x" "x" {`)) 120 | assert.False(t, isEndOfError(`abcd: resource "x" "x" {`)) 121 | assert.False(t, isEndOfError(`12: resource "x" "x"`)) 122 | 123 | assert.True(t, errorDoesntMatchResource(` with y,`, `x`)) 124 | assert.True(t, errorDoesntMatchResource(` with aws_ssm_parameter,`, `azure*`)) 125 | } 126 | -------------------------------------------------------------------------------- /pkg/tf-profile/graph/graph.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "sort" 10 | "strings" 11 | "text/template" 12 | 13 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/aggregate" 14 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/core" 15 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/parser" 16 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/readers" 17 | ) 18 | 19 | func Graph(args []string, w int, h int, OutFile string, aggregate bool) error { 20 | var file *bufio.Scanner 21 | var err error 22 | 23 | if len(args) == 1 { 24 | file, err = FileReader{File: args[0]}.Read() 25 | } else { 26 | file, err = StdinReader{}.Read() 27 | } 28 | if err != nil { 29 | return err 30 | } 31 | tflog, err := Parse(file, false) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | if aggregate { 37 | tflog, err = Aggregate(tflog) 38 | if err != nil { 39 | return err 40 | } 41 | } 42 | 43 | cleanFailedResources(tflog) 44 | _, err = printGNUPlotOutput(tflog, w, h, OutFile) 45 | 46 | if err != nil { 47 | return err 48 | } 49 | return nil 50 | } 51 | 52 | // For failed resources, ModificationCompletedEvent will always be -1, since we never 53 | // detect the end of their modifications. We manually set their ModificationCompletedEvent 54 | // to the maximum value, leading to a long red bar. 55 | func cleanFailedResources(tflog ParsedLog) { 56 | max := 0 57 | 58 | // Find max creation value 59 | for _, metrics := range tflog.Resources { 60 | if metrics.ModificationCompletedEvent > max { 61 | max = metrics.ModificationCompletedEvent 62 | } 63 | } 64 | 65 | // Update all non-successful resources to end at that index 66 | for resource, metrics := range tflog.Resources { 67 | if metrics.AfterStatus == Failed { 68 | metrics.ModificationCompletedEvent = max 69 | tflog.Resources[resource] = metrics 70 | } 71 | } 72 | } 73 | 74 | // Use the template below and a ParsedLog to generate all output for gnuplot. 75 | // This can be piped into gnuplot to generate a .png file 76 | func printGNUPlotOutput(tflog ParsedLog, w int, h int, OutFile string) (string, error) { 77 | if w < 1 || h < 1 { 78 | return "", errors.New("--size must provided as two positive integers (e.g. '1000,1000').") 79 | } 80 | 81 | // Context object for templating 82 | Context := map[string]interface{}{} 83 | Context["W"] = w 84 | Context["H"] = h 85 | Context["File"] = OutFile 86 | 87 | SortedResources := sortResourcesForGraph(tflog) 88 | Resources := []string{} // Lines passed into template 89 | 90 | // Build list of lines and let template do the looping 91 | for _, r := range SortedResources { 92 | metrics := tflog.Resources[r] 93 | 94 | NameForOutput := strings.Replace(r, "_", `\\\_`, -1) 95 | NameForOutput = strings.Replace(NameForOutput, `"`, `'`, -1) 96 | // Escape underscores and add the necessary metrics. 97 | line := fmt.Sprintf("%v %v %v %v", 98 | NameForOutput, 99 | metrics.ModificationStartedEvent, 100 | metrics.ModificationCompletedEvent, 101 | metrics.AfterStatus, 102 | ) 103 | Resources = append(Resources, line) 104 | } 105 | Context["Resources"] = Resources 106 | 107 | template, _ := template.New("plot").Parse(Template) 108 | err := template.Execute(os.Stdout, Context) // To stdout 109 | if err != nil { 110 | return "", err 111 | } 112 | 113 | var output bytes.Buffer 114 | err = template.Execute(&output, Context) // To variable 115 | if err != nil { 116 | return "", err 117 | } 118 | return output.String(), nil 119 | } 120 | 121 | // To create a nice graph, sort the resources chronologically 122 | // according to ModificationStartedEvent 123 | func sortResourcesForGraph(log ParsedLog) []string { 124 | // Collect keys 125 | keys := []string{} 126 | for key := range log.Resources { 127 | keys = append(keys, key) 128 | } 129 | 130 | sort.Slice(keys, func(i, j int) bool { 131 | return log.Resources[keys[i]].ModificationStartedEvent > log.Resources[keys[j]].ModificationStartedEvent 132 | }) 133 | return keys 134 | } 135 | 136 | const Template string = ` 137 | # GNUplot template for generating Gantt chart. $DATA will be provided at runtime 138 | reset 139 | set termoption dash 140 | set terminal pngcairo background "#ffffff" fontscale 1.0 dashed size {{ .W }}, {{ .H }} 141 | 142 | # --- Output colors 143 | green = 0x49A720;# 0xFFE599; 144 | red = 0xD32F2F; # 0xF1C232; 145 | 146 | # resource start end status 147 | $DATA << EOD 148 | {{range .Resources -}} 149 | {{ . }} 150 | {{ end }} 151 | EOD 152 | 153 | # set output 154 | set output "{{ .File }}" 155 | 156 | # grid and tics 157 | set mxtics 158 | set mytics 159 | set grid xtics 160 | set grid ytics 161 | set grid mxtics 162 | 163 | # create list of keys 164 | List = '' 165 | set table $Dummy 166 | plot $DATA u (List=List.'"'.strcol(1).'" ',NaN) w table 167 | unset table 168 | 169 | # define functions for lookup/index and color 170 | Lookup(s) = (Index = NaN, sum [i=1:words(List)] \ 171 | (Index = s eq word(List,i) ? i : Index,0), Index) 172 | Color(s) = (s eq "Failed") ? red : green 173 | 174 | # set range of x-axis and y-axis 175 | set xrange [-1:] 176 | set yrange [0.5:words(List)+0.5] 177 | 178 | plot $DATA u 2:(Idx=Lookup(strcol(1))): 3 : 2 :(Idx-0.2):(Idx+0.2): \ 179 | (Color(strcol(4))): ytic(strcol(1)) w boxxyerror fill solid 0.7 lw 2.0 lc rgb var notitle` 180 | -------------------------------------------------------------------------------- /pkg/tf-profile/graph/graph_test.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "testing" 9 | 10 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/aggregate" 11 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/parser" 12 | 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestFailureParse(t *testing.T) { 17 | Files, err := os.ReadDir("../../../test") 18 | assert.Nil(t, err) 19 | 20 | // Sanity check: all *.log files must be graph-able 21 | for _, File := range Files { 22 | if strings.Contains(File.Name(), ".log") { 23 | err := Graph([]string{"../../../test/" + File.Name()}, 1000, 600, "tf-profile-graph.png", true) 24 | assert.Nil(t, err) 25 | } 26 | } 27 | 28 | err = Graph([]string{"../../../test/does-not-exist"}, 1000, 600, "tf-profile-graph.png", true) 29 | assert.NotNil(t, err) 30 | err = Graph([]string{"../../../test/failures.log"}, -1, -1, "tf-profile-graph.png", true) 31 | assert.NotNil(t, err) 32 | } 33 | 34 | func TestPlotOutput(t *testing.T) { 35 | file, _ := os.Open("../../../test/failures.log") 36 | s := bufio.NewScanner(file) 37 | 38 | log, _ := Parse(s, false) 39 | log, _ = Aggregate(log) 40 | 41 | out, err := printGNUPlotOutput(log, 1000, 600, "tf-profile-graph.png") 42 | 43 | assert.Nil(t, err) 44 | fmt.Println(out) 45 | assert.Contains(t, out, `aws\\\_ssm\\\_parameter.good2[*] 7 11 Created`) 46 | assert.Contains(t, out, `aws\\\_ssm\\\_parameter.bad 5 -1 Failed`) 47 | assert.Contains(t, out, `aws\\\_ssm\\\_parameter.bad2[*] 3 -1 Failed`) 48 | assert.Contains(t, out, `aws\\\_ssm\\\_parameter.good 0 8 Created`) 49 | 50 | } 51 | -------------------------------------------------------------------------------- /pkg/tf-profile/parser/apply_patterns.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/core" 9 | ) 10 | 11 | var ( 12 | // All regexes that recognize interesting logs during the apply phase 13 | resourceName = `[a-zA-Z0-9_.["\]\/:]*` // Simplified regex but it will do 14 | 15 | resourceCreated = fmt.Sprintf("%v: Creation complete after", resourceName) 16 | resourceCreationStarted = fmt.Sprintf("%v: Creating...", resourceName) 17 | resourceOperationFailed = fmt.Sprintf("with %v,", resourceName) 18 | 19 | resourceDestructionStarted = fmt.Sprintf("%v: Destroying...", resourceName) 20 | resourceDestroyed = fmt.Sprintf("%v: Destruction complete after", resourceName) 21 | 22 | resourceModificationStarted = fmt.Sprintf("%v: Modifying...", resourceName) 23 | resourceModified = fmt.Sprintf("%v: Modifications complete after", resourceName) 24 | ) 25 | 26 | // Handle line that indicates creation of a resource was completed. E.g: 27 | // resource: Creation complete after 1s [id=2023-04-09T18:17:33Z] 28 | func parseResourceCreated(Line string, log *ParsedLog) (bool, error) { 29 | match, _ := regexp.MatchString(resourceCreated, Line) 30 | if !match { 31 | return false, nil 32 | } 33 | 34 | tokens := strings.Split(Line, ": Creation complete after ") 35 | if len(tokens) < 2 { 36 | msg := fmt.Sprintf("Unable to parse resource creation line: %v\n", Line) 37 | return false, &LineParseError{Msg: msg} 38 | } 39 | resource := tokens[0] 40 | 41 | // The next token will contain the create time (" Creation complete after ...s [id=...]") 42 | tokens2 := strings.Split(tokens[1], " ") 43 | if len(tokens2) < 2 { 44 | msg := fmt.Sprintf("Unable to parse creation duration: %v\n", tokens[1]) 45 | return false, &LineParseError{Msg: msg} 46 | } 47 | createDuration := parseCreateDurationString(tokens2[0]) 48 | 49 | // We know the resource and the duration, insert everything into the log 50 | log.SetTotalTime(resource, createDuration) 51 | log.SetAfterStatus(resource, Created) 52 | log.SetModificationCompletedEvent(resource, log.CurrentEvent) 53 | log.SetModificationCompletedIndex(resource, log.CurrentModificationEndedIndex) 54 | 55 | log.CurrentModificationEndedIndex += 1 56 | log.CurrentEvent += 1 57 | return true, nil 58 | } 59 | 60 | // Handle line that indicates the creation of a resource was started. E.g: 61 | // aws_ssm_parameter.bad2[2]: Creating... 62 | func parseResourceCreationStarted(Line string, log *ParsedLog) (bool, error) { 63 | match, _ := regexp.MatchString(resourceCreationStarted, Line) 64 | if !match { 65 | return false, nil 66 | } 67 | tokens := strings.Split(Line, ": Creating...") 68 | if len(tokens) < 2 || tokens[1] != "" { 69 | msg := fmt.Sprintf("Unable to parse resource creation line: %v\n", Line) 70 | return false, &LineParseError{Msg: msg} 71 | } 72 | 73 | // Knowing the resource whose creation stared, insert everything in the log 74 | log.RegisterNewResource(tokens[0]) 75 | log.SetOperation(tokens[0], Create) 76 | log.SetModificationStartedIndex(tokens[0], log.CurrentModificationStartedIndex) 77 | log.SetModificationStartedEvent(tokens[0], log.CurrentEvent) 78 | log.CurrentModificationStartedIndex += 1 79 | log.CurrentEvent += 1 80 | return true, nil 81 | } 82 | 83 | // Handle line that indicates resource modifications failed. E.g: 84 | // Error: creating SSM Parameter (/slash/at/end1/): ValidationException: Something something 85 | // status code: 400, request id: 77765932-a8b2-48bf-abe2-71a151da56ea 86 | // with aws_ssm_parameter.bad2[1], 87 | // In practice we just detect the "with ", as we only receive one line of context 88 | func parseResourceCreationFailed(Line string, log *ParsedLog) (bool, error) { 89 | match, _ := regexp.MatchString(resourceOperationFailed, Line) 90 | if !match { 91 | return false, nil 92 | } 93 | 94 | tokens := strings.Split(Line, "with ") 95 | if len(tokens) < 2 { 96 | msg := fmt.Sprintf("Unable to parse failure line: %v\n", Line) 97 | return false, &LineParseError{Msg: msg} 98 | } 99 | Line = strings.TrimSpace(Line) 100 | resource := strings.Split(Line, "with ")[1] // Everything after 'with' 101 | resource = resource[:len(resource)-1] // Remove comma at end 102 | 103 | // Knowing the resource whose modifications failed, insert everything in the log 104 | // TODO: dependin on the operation, Failed is not always correct. E.g. destroy fails => Created 105 | log.SetAfterStatus(resource, Failed) 106 | return true, nil 107 | } 108 | 109 | // Handle line that indicates the destruction of a resource was started. E.g: 110 | // aws_ssm_parameter.bad2[2]: Destroying... 111 | func parseResourceDestructionStarted(Line string, log *ParsedLog) (bool, error) { 112 | match, _ := regexp.MatchString(resourceDestructionStarted, Line) 113 | if !match { 114 | return false, nil 115 | } 116 | tokens := strings.Split(Line, ": Destroying...") 117 | if len(tokens) < 2 { 118 | msg := fmt.Sprintf("Unable to parse resource deletion line: %v\n", Line) 119 | return false, &LineParseError{Msg: msg} 120 | } 121 | 122 | // Knowing the resource whose deletion stared, insert everything in the log 123 | log.RegisterNewResource(tokens[0]) 124 | log.SetOperation(tokens[0], Destroy) 125 | log.SetModificationCompletedEvent(tokens[0], log.CurrentEvent) 126 | log.SetModificationCompletedIndex(tokens[0], log.CurrentModificationEndedIndex) 127 | log.CurrentModificationStartedIndex += 1 128 | log.CurrentEvent += 1 129 | return true, nil 130 | } 131 | 132 | // Handle line that indicates deletion of a resource was completed. E.g: 133 | // resource: Destruction complete after 1s [id=2023-04-09T18:17:33Z] 134 | func parseResourceDestroyed(Line string, log *ParsedLog) (bool, error) { 135 | match, _ := regexp.MatchString(resourceDestroyed, Line) 136 | if !match { 137 | return false, nil 138 | } 139 | 140 | tokens := strings.Split(Line, ": Destruction complete after ") 141 | if len(tokens) < 2 { 142 | msg := fmt.Sprintf("Unable to parse resource destruction line: %v\n", Line) 143 | return false, &LineParseError{Msg: msg} 144 | } 145 | resource := tokens[0] 146 | 147 | // The next token will contain the create time (" Destruction complete after ...s [id=...]") 148 | tokens2 := strings.Split(tokens[1], " ") 149 | createDuration := parseCreateDurationString(tokens2[0]) 150 | 151 | // We know the resource and the duration, insert everything into the log 152 | log.SetTotalTime(resource, createDuration) 153 | log.SetAfterStatus(resource, NotCreated) 154 | log.SetModificationCompletedEvent(resource, log.CurrentEvent) 155 | log.SetModificationCompletedIndex(resource, log.CurrentModificationEndedIndex) 156 | 157 | log.CurrentModificationEndedIndex += 1 158 | log.CurrentEvent += 1 159 | return true, nil 160 | } 161 | 162 | // Handle line that indicates the destruction of a resource was started. E.g: 163 | // aws_ssm_parameter.bad2[2]: Destroying... 164 | func parseResourceModificationStarted(Line string, log *ParsedLog) (bool, error) { 165 | match, _ := regexp.MatchString(resourceModificationStarted, Line) 166 | if !match { 167 | return false, nil 168 | } 169 | tokens := strings.Split(Line, ": Modifying...") 170 | if len(tokens) < 2 { 171 | msg := fmt.Sprintf("Unable to parse resource modification line: %v\n", Line) 172 | return false, &LineParseError{Msg: msg} 173 | } 174 | 175 | // Knowing the resource whose modification stared, insert everything in the log 176 | log.RegisterNewResource(tokens[0]) 177 | log.SetOperation(tokens[0], Modify) 178 | log.SetModificationStartedEvent(tokens[0], log.CurrentEvent) 179 | log.SetModificationStartedIndex(tokens[0], log.CurrentModificationStartedIndex) 180 | log.CurrentModificationStartedIndex += 1 181 | log.CurrentEvent += 1 182 | return true, nil 183 | } 184 | 185 | // Handle line that indicates modification of a resource was completed. E.g: 186 | // resource: Destruction complete after 1s [id=2023-04-09T18:17:33Z] 187 | func parseResourceModified(Line string, log *ParsedLog) (bool, error) { 188 | match, _ := regexp.MatchString(resourceModified, Line) 189 | if !match { 190 | return false, nil 191 | } 192 | 193 | tokens := strings.Split(Line, ": Modifications complete after ") 194 | if len(tokens) < 2 { 195 | msg := fmt.Sprintf("Unable to parse resource modification line: %v\n", Line) 196 | return false, &LineParseError{Msg: msg} 197 | } 198 | resource := tokens[0] 199 | 200 | // The next token will contain the create time (" Modifications complete after ...s [id=...]") 201 | tokens2 := strings.Split(tokens[1], " ") 202 | if len(tokens2) < 2 { 203 | msg := fmt.Sprintf("Unable to parse duration: %v\n", tokens[1]) 204 | return false, &LineParseError{Msg: msg} 205 | } 206 | Duration := parseCreateDurationString(tokens2[0]) 207 | 208 | // We know the resource and the duration, insert everything into the log 209 | log.SetTotalTime(resource, Duration) 210 | log.SetAfterStatus(resource, Created) 211 | log.SetModificationCompletedEvent(resource, log.CurrentEvent) 212 | log.SetModificationCompletedIndex(resource, log.CurrentModificationEndedIndex) 213 | 214 | log.CurrentModificationEndedIndex += 1 215 | log.CurrentEvent += 1 216 | return true, nil 217 | } 218 | -------------------------------------------------------------------------------- /pkg/tf-profile/parser/apply_patterns_test.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/core" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestParseCreate(t *testing.T) { 11 | log := ParsedLog{Resources: map[string]ResourceMetric{}} 12 | 13 | modified, err := parseResourceCreationStarted("foo: Creating...", &log) 14 | assert.True(t, modified) 15 | assert.Nil(t, err) 16 | 17 | modified, err = parseResourceCreated("foo: Creation complete after 1s [id=/no/slash/at/end0]", &log) 18 | assert.True(t, modified) 19 | assert.Nil(t, err) 20 | assert.Equal(t, float64(1000), log.Resources["foo"].TotalTime) 21 | assert.Equal(t, Created, log.Resources["foo"].AfterStatus) 22 | } 23 | 24 | func TestParseCreateFailed(t *testing.T) { 25 | log := ParsedLog{Resources: map[string]ResourceMetric{}} 26 | 27 | modified, err := parseResourceCreationStarted("foo: Creating...", &log) 28 | assert.True(t, modified) 29 | assert.Nil(t, err) 30 | 31 | modified, err = parseResourceCreationFailed("with foo,", &log) 32 | assert.True(t, modified) 33 | assert.Nil(t, err) 34 | assert.Equal(t, Failed, log.Resources["foo"].AfterStatus) 35 | } 36 | 37 | func TestResourceDestruction(t *testing.T) { 38 | log := ParsedLog{Resources: map[string]ResourceMetric{}} 39 | 40 | modified, err := parseResourceDestructionStarted("foo: Destroying...", &log) 41 | assert.True(t, modified) 42 | assert.Nil(t, err) 43 | 44 | modified, err = parseResourceDestroyed("foo: Destruction complete after 10s [id=/no/slash/at/end0]", &log) 45 | assert.True(t, modified) 46 | assert.Nil(t, err) 47 | assert.Equal(t, float64(10000), log.Resources["foo"].TotalTime) 48 | assert.Equal(t, NotCreated, log.Resources["foo"].AfterStatus) 49 | } 50 | 51 | func TestResourceModification(t *testing.T) { 52 | log := ParsedLog{Resources: map[string]ResourceMetric{}} 53 | 54 | modified, err := parseResourceModificationStarted("foo: Modifying...", &log) 55 | assert.True(t, modified) 56 | assert.Nil(t, err) 57 | 58 | modified, err = parseResourceModified("foo: Modifications complete after 10s [id=/no/slash/at/end0]", &log) 59 | assert.True(t, modified) 60 | assert.Nil(t, err) 61 | assert.Equal(t, float64(10000), log.Resources["foo"].TotalTime) 62 | assert.Equal(t, Created, log.Resources["foo"].AfterStatus) 63 | } 64 | -------------------------------------------------------------------------------- /pkg/tf-profile/parser/parser.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "log" 7 | "strconv" 8 | "strings" 9 | 10 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/core" 11 | ) 12 | 13 | type parseFunction = func(Line string, log *ParsedLog) (bool, error) 14 | 15 | var RefreshParsers = []parseFunction{ 16 | refreshParser, 17 | } 18 | var PlanParsers = []parseFunction{ 19 | parseStartPlan, 20 | parsePlanTainted, 21 | parsePlanExplicitReplace, 22 | parsePlanWillBeDestroyed, 23 | parsePlanWillBeModified, 24 | parsePlanForcedReplace, 25 | parsePlanWillBeCreated, 26 | } 27 | var ApplyParsers = []parseFunction{ 28 | parseResourceCreationStarted, 29 | parseResourceCreated, 30 | parseResourceCreationFailed, 31 | parseResourceDestructionStarted, 32 | parseResourceDestroyed, 33 | parseResourceModificationStarted, 34 | parseResourceModified, 35 | } 36 | 37 | // Parse a Terraform log into a ParsedLog object. This function will 38 | // pass line by line over the file, apply parse functions (see above) 39 | // until one of them recognizes the line and extracts information. In 40 | // that case the line is considered "handled" and the next one is scanned 41 | // Possible optimization here: since Terraform has distinct refresh, 42 | // plan, apply phases we could skip parse functions of previous phases. 43 | func Parse(file *bufio.Scanner, tee bool) (ParsedLog, error) { 44 | tflog := ParsedLog{Resources: map[string]ResourceMetric{}} 45 | 46 | for file.Scan() { 47 | line := RemoveTerminalFormatting(file.Text()) 48 | 49 | if tee { 50 | fmt.Println(line) 51 | } 52 | 53 | // Apply refresh parsers until one modifies the log 54 | for _, f := range RefreshParsers { 55 | modified, err := f(line, &tflog) 56 | if err != nil { 57 | return ParsedLog{}, err 58 | } 59 | if modified { 60 | tflog.ContainsRefresh = true 61 | break 62 | } 63 | } 64 | 65 | // Apply plan parsers until one modifies the log 66 | for _, f := range PlanParsers { 67 | modified, err := f(line, &tflog) 68 | if err != nil { 69 | return ParsedLog{}, err 70 | } 71 | if modified { 72 | tflog.ContainsPlan = true 73 | break 74 | } 75 | } 76 | 77 | // Apply apply parsers until one modifies the log. 78 | for _, f := range ApplyParsers { 79 | modified, err := f(line, &tflog) 80 | if err != nil { 81 | return ParsedLog{}, err 82 | } 83 | if modified { 84 | tflog.ContainsApply = true 85 | break 86 | } 87 | } 88 | } 89 | 90 | return tflog, nil 91 | } 92 | 93 | // Convert a create duration string into milliseconds 94 | func parseCreateDurationString(in string) float64 { 95 | // Q: what's the formatting when > 1hr? 96 | // For now handle two cases: "1m10s" and "10s" 97 | if strings.Contains(in, "m") { 98 | split := strings.Split(in, "m") 99 | mins, err1 := strconv.Atoi(split[0]) 100 | seconds, err2 := strconv.Atoi(strings.TrimSuffix(split[1], "s")) 101 | 102 | if err1 != nil || err2 != nil { 103 | log.Fatal("Unable to parse resource create duration.") 104 | } 105 | 106 | return float64(1000.0 * (60*mins + seconds)) 107 | } else { 108 | seconds, err := strconv.Atoi(strings.TrimSuffix(in, "s")) 109 | if err != nil { 110 | log.Fatal("Unable to parse resource create duration.") 111 | } 112 | return float64(1000.0 * seconds) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /pkg/tf-profile/parser/parser_test.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/core" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestFullParse(t *testing.T) { 15 | file, _ := os.Open("../../../test/multiple_resources.log") 16 | s := bufio.NewScanner(file) 17 | 18 | log, err := Parse(s, false) 19 | assert.Nil(t, err) 20 | 21 | metrics, ok := log.Resources["time_sleep.count_9"] 22 | assert.True(t, ok) 23 | 24 | expected := ResourceMetric{ 25 | NumCalls: 1, 26 | TotalTime: 10000, 27 | ModificationStartedIndex: 10, 28 | ModificationCompletedIndex: 12, 29 | ModificationStartedEvent: 11, 30 | ModificationCompletedEvent: 26, 31 | BeforeStatus: Created, 32 | DesiredStatus: Created, 33 | AfterStatus: Created, 34 | Operation: Create, 35 | } 36 | if metrics != expected { 37 | t.Fatalf("Expected %v, got %v\n", expected, metrics) 38 | } 39 | 40 | metrics2 := log.Resources["time_sleep.for_each_a"] 41 | expected2 := ResourceMetric{ 42 | NumCalls: 1, 43 | TotalTime: 1000, 44 | ModificationStartedIndex: 5, 45 | ModificationCompletedIndex: 1, 46 | ModificationStartedEvent: 5, 47 | ModificationCompletedEvent: 12, 48 | BeforeStatus: Created, 49 | DesiredStatus: Created, 50 | AfterStatus: Created, 51 | Operation: Create, 52 | } 53 | if metrics2 != expected2 { 54 | t.Fatalf("Expected %v, got %v\n", expected2, metrics2) 55 | } 56 | } 57 | 58 | func TestFailureParse(t *testing.T) { 59 | file, _ := os.Open("../../../test/failures.log") 60 | s := bufio.NewScanner(file) 61 | 62 | log, err := Parse(s, false) 63 | assert.Nil(t, err) 64 | 65 | metrics, exists := log.Resources["aws_ssm_parameter.good2[0]"] 66 | assert.True(t, exists) 67 | assert.Equal(t, metrics.AfterStatus, Created) 68 | 69 | metrics, exists = log.Resources["aws_ssm_parameter.good"] 70 | assert.True(t, exists) 71 | assert.Equal(t, metrics.AfterStatus, Created) 72 | 73 | metrics, exists = log.Resources["aws_ssm_parameter.bad2[1]"] 74 | assert.True(t, exists) 75 | assert.Equal(t, metrics.AfterStatus, Failed) 76 | 77 | metrics, exists = log.Resources["aws_ssm_parameter.bad"] 78 | assert.True(t, exists) 79 | assert.Equal(t, metrics.AfterStatus, Failed) 80 | } 81 | 82 | func TestParserSanityCheck(t *testing.T) { 83 | Files, err := os.ReadDir("../../../test") 84 | assert.Nil(t, err) 85 | 86 | // Sanity check: all *.log files must be parseable 87 | for _, File := range Files { 88 | if strings.Contains(File.Name(), ".log") { 89 | file, _ := os.Open("../../../test/" + File.Name()) 90 | s := bufio.NewScanner(file) 91 | 92 | _, err := Parse(s, false) 93 | assert.Nil(t, err) 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /pkg/tf-profile/parser/plan_patterns.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/core" 9 | ) 10 | 11 | var ( 12 | startPlan = "Terraform will perform the following actions:" 13 | isTainted = fmt.Sprintf("%v is tainted, so must be replaced", resourceName) 14 | willBeCreated = fmt.Sprintf("%v will be created", resourceName) 15 | explicitReplace = fmt.Sprintf("%v will be replaced, as requested", resourceName) 16 | willBeDestroyed = fmt.Sprintf("%v will be destroyed", resourceName) 17 | willBeModified = fmt.Sprintf("%v will be updated in-place", resourceName) 18 | forcedReplace = fmt.Sprintf("%v must be replaced", resourceName) 19 | ) 20 | 21 | // Handle line that indicates the start of a Terraform plan: 22 | // "Terraform will perform the following actions:" 23 | func parseStartPlan(Line string, log *ParsedLog) (bool, error) { 24 | match, _ := regexp.MatchString(resourceCreated, Line) 25 | if !match { 26 | return false, nil 27 | } 28 | log.ContainsPlan = true 29 | return true, nil 30 | } 31 | 32 | // Handle line that indicates a resource is tainted. E.g: 33 | // " # aws_ssm_parameter.p1 is tainted, so must be replaced" 34 | func parsePlanTainted(Line string, log *ParsedLog) (bool, error) { 35 | match, _ := regexp.MatchString(isTainted, Line) 36 | if !match { 37 | return false, nil 38 | } 39 | 40 | tokens := strings.Split(Line, "# ") 41 | if len(tokens) < 2 { 42 | msg := fmt.Sprintf("Unable to parse tainted resource: %v\n", Line) 43 | return false, &LineParseError{Msg: msg} 44 | } 45 | resource := strings.Split(tokens[1], " is tainted, so must be replaced")[0] 46 | 47 | log.RegisterNewResource(resource) 48 | log.SetDesiredStatus(resource, Created) 49 | return true, nil 50 | } 51 | 52 | // Handle line that indicates a resource has been marked to be replaced. E.g: 53 | // " # aws_ssm_parameter.p1 will be replaced, as requested" 54 | func parsePlanExplicitReplace(Line string, log *ParsedLog) (bool, error) { 55 | match, _ := regexp.MatchString(explicitReplace, Line) 56 | if !match { 57 | return false, nil 58 | } 59 | 60 | tokens := strings.Split(Line, "# ") 61 | if len(tokens) < 2 { 62 | msg := fmt.Sprintf("Unable to parse resource to replace: %v\n", Line) 63 | return false, &LineParseError{Msg: msg} 64 | } 65 | resource := strings.Split(tokens[1], " will be replaced, as requested")[0] 66 | 67 | log.RegisterNewResource(resource) 68 | log.SetDesiredStatus(resource, Created) 69 | return true, nil 70 | } 71 | 72 | // Handle line that indicates a resource will be destroyed. E.g: 73 | // " # aws_ssm_parameter.p1 will be destroyed" 74 | func parsePlanWillBeDestroyed(Line string, log *ParsedLog) (bool, error) { 75 | match, _ := regexp.MatchString(willBeDestroyed, Line) 76 | if !match { 77 | return false, nil 78 | } 79 | 80 | tokens := strings.Split(Line, "# ") 81 | if len(tokens) < 2 { 82 | msg := fmt.Sprintf("Unable to parse resource for destroy: %v\n", Line) 83 | return false, &LineParseError{Msg: msg} 84 | } 85 | resource := strings.Split(tokens[1], " will be destroyed")[0] 86 | 87 | log.RegisterNewResource(resource) 88 | log.SetDesiredStatus(resource, NotCreated) 89 | return true, nil 90 | } 91 | 92 | // Handle line that indicates a resource will be modified. E.g: 93 | // " # aws_ssm_parameter.p5 will be updated in-place" 94 | func parsePlanWillBeModified(Line string, log *ParsedLog) (bool, error) { 95 | match, _ := regexp.MatchString(willBeModified, Line) 96 | if !match { 97 | return false, nil 98 | } 99 | 100 | tokens := strings.Split(Line, "# ") 101 | if len(tokens) < 2 { 102 | msg := fmt.Sprintf("Unable to parse resource for modify: %v\n", Line) 103 | return false, &LineParseError{Msg: msg} 104 | } 105 | resource := strings.Split(tokens[1], " will be updated in-place")[0] 106 | 107 | log.RegisterNewResource(resource) 108 | log.SetDesiredStatus(resource, Created) 109 | return true, nil 110 | } 111 | 112 | // Handle line that indicates a resource must be replaced. E.g: 113 | // "# aws_ssm_parameter.p6 must be replaced" 114 | func parsePlanForcedReplace(Line string, log *ParsedLog) (bool, error) { 115 | match, _ := regexp.MatchString(forcedReplace, Line) 116 | if !match { 117 | return false, nil 118 | } 119 | 120 | tokens := strings.Split(Line, "# ") 121 | if len(tokens) < 2 { 122 | msg := fmt.Sprintf("Unable to parse resource for replacement: %v\n", Line) 123 | return false, &LineParseError{Msg: msg} 124 | } 125 | resource := strings.Split(tokens[1], " must be replaced")[0] 126 | 127 | log.RegisterNewResource(resource) 128 | log.SetDesiredStatus(resource, Created) 129 | return true, nil 130 | } 131 | 132 | // Handle line that indicates a resource will be created. E.g: 133 | // "# aws_ssm_parameter.p6 will be created" 134 | func parsePlanWillBeCreated(Line string, log *ParsedLog) (bool, error) { 135 | match, _ := regexp.MatchString(willBeCreated, Line) 136 | if !match { 137 | return false, nil 138 | } 139 | 140 | tokens := strings.Split(Line, "# ") 141 | if len(tokens) < 2 { 142 | msg := fmt.Sprintf("Unable to parse creation plan: %v\n", Line) 143 | return false, &LineParseError{Msg: msg} 144 | } 145 | resource := strings.Split(tokens[1], " will be created")[0] 146 | 147 | log.RegisterNewResource(resource) 148 | log.SetDesiredStatus(resource, Created) 149 | return true, nil 150 | } 151 | -------------------------------------------------------------------------------- /pkg/tf-profile/parser/plan_patterns_test.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/core" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestParsePlan(t *testing.T) { 11 | log := ParsedLog{Resources: map[string]ResourceMetric{}} 12 | 13 | modified, err := parsePlanTainted(" # foo is tainted, so must be replaced", &log) 14 | assert.True(t, modified) 15 | assert.Nil(t, err) 16 | assert.Equal(t, Created, log.Resources["foo"].DesiredStatus) 17 | 18 | modified, err = parsePlanExplicitReplace(" # foo will be replaced, as requested", &log) 19 | assert.True(t, modified) 20 | assert.Nil(t, err) 21 | assert.Equal(t, Created, log.Resources["foo"].DesiredStatus) 22 | 23 | modified, err = parsePlanWillBeDestroyed(" # foo will be destroyed", &log) 24 | assert.True(t, modified) 25 | assert.Nil(t, err) 26 | assert.Equal(t, NotCreated, log.Resources["foo"].DesiredStatus) 27 | 28 | modified, err = parsePlanWillBeModified(" # foo will be updated in-place", &log) 29 | assert.True(t, modified) 30 | assert.Nil(t, err) 31 | assert.Equal(t, Created, log.Resources["foo"].DesiredStatus) 32 | 33 | modified, err = parsePlanForcedReplace(" # foo must be replaced", &log) 34 | assert.True(t, modified) 35 | assert.Nil(t, err) 36 | assert.Equal(t, Created, log.Resources["foo"].DesiredStatus) 37 | 38 | modified, err = parsePlanWillBeCreated(" # foo will be created", &log) 39 | assert.True(t, modified) 40 | assert.Nil(t, err) 41 | assert.Equal(t, Created, log.Resources["foo"].DesiredStatus) 42 | } 43 | 44 | // Not the best test as we need to construct text that passes the regex check, 45 | // but still throws an error during parsing 46 | func TestParseErrors(t *testing.T) { 47 | log := ParsedLog{Resources: map[string]ResourceMetric{}} 48 | _, err := parsePlanTainted("foo is tainted, so must be replaced", &log) 49 | assert.NotNil(t, err) 50 | _, err = parsePlanExplicitReplace("foo will be replaced, as requested", &log) 51 | assert.NotNil(t, err) 52 | _, err = parsePlanWillBeDestroyed("foo will be destroyed", &log) 53 | assert.NotNil(t, err) 54 | _, err = parsePlanWillBeModified("foo will be updated in-place", &log) 55 | assert.NotNil(t, err) 56 | _, err = parsePlanForcedReplace("foo must be replaced", &log) 57 | assert.NotNil(t, err) 58 | _, err = parsePlanWillBeCreated("foo will be created", &log) 59 | assert.NotNil(t, err) 60 | } 61 | -------------------------------------------------------------------------------- /pkg/tf-profile/parser/refresh_patterns.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/core" 9 | ) 10 | 11 | // Parse a refresh line and records the resource in the log. 12 | func refreshParser(Line string, log *ParsedLog) (bool, error) { 13 | regex := fmt.Sprintf("%v: Refreshing state...", resourceName) 14 | match, _ := regexp.MatchString(regex, Line) 15 | if !match { 16 | return false, nil 17 | } 18 | tokens := strings.Split(Line, ": Refreshing state...") 19 | if len(tokens) < 2 { 20 | msg := fmt.Sprintf("Unable to parse resource creation line: %v\n", Line) 21 | return false, &LineParseError{Msg: msg} 22 | } 23 | 24 | // Knowing the resource whose creation stared, insert everything in the log 25 | resource := tokens[0] 26 | log.RegisterNewResource(resource) 27 | log.SetModificationStartedEvent(resource, -1) 28 | log.SetModificationStartedIndex(resource, -1) 29 | 30 | return true, nil 31 | } 32 | -------------------------------------------------------------------------------- /pkg/tf-profile/readers/file_reader.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | ) 7 | 8 | type FileReader struct { 9 | File string 10 | } 11 | 12 | func (r FileReader) Read() (*bufio.Scanner, error) { 13 | file, err := os.Open(r.File) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | return bufio.NewScanner(file), nil 19 | } 20 | -------------------------------------------------------------------------------- /pkg/tf-profile/readers/reader.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import "bufio" 4 | 5 | // A Reader creates a Scanner to read a log line by line 6 | type Reader interface { 7 | Read() (*bufio.Scanner, error) 8 | } 9 | -------------------------------------------------------------------------------- /pkg/tf-profile/readers/reader_test.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestFileReader(t *testing.T) { 10 | inputfile := "../../../test/test_file.txt" 11 | file, _ := FileReader{File: inputfile}.Read() 12 | 13 | file.Scan() 14 | assert.Equal(t, file.Text(), "Used to test reader module") 15 | file.Scan() 16 | assert.Equal(t, file.Text(), "Another line") 17 | file.Scan() 18 | assert.Equal(t, file.Text(), "Another line2") 19 | assert.Equal(t, false, file.Scan()) 20 | assert.Equal(t, file.Text(), "") 21 | } 22 | 23 | func TestNonExistentFile(t *testing.T) { 24 | file, err := FileReader{File: "does-not-exist"}.Read() 25 | assert.Nil(t, file) 26 | assert.NotNil(t, err) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/tf-profile/readers/stdin_reader.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | ) 7 | 8 | type StdinReader struct{} 9 | 10 | func (r StdinReader) Read() (*bufio.Scanner, error) { 11 | return bufio.NewScanner(os.Stdin), nil 12 | } 13 | -------------------------------------------------------------------------------- /pkg/tf-profile/sort/sort.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "reflect" 5 | "sort" 6 | "strings" 7 | 8 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/core" 9 | ) 10 | 11 | type ( 12 | sortSpecItem struct { 13 | col string 14 | order string 15 | } 16 | 17 | // Fake record we construct to allow sorting on (multiple) custom columns. 18 | // See Sort() for usage. 19 | proxyRecord struct { 20 | resource string 21 | items []float64 22 | } 23 | ) 24 | 25 | // Parse a sort_spec into a map 26 | // e.g "n=asc,tot_time=desc" => {n: asc, tot_time: desc} 27 | func parseSortSpec(in string) []sortSpecItem { 28 | tokens := strings.Split(in, ",") 29 | 30 | result := []sortSpecItem{} 31 | for _, spec := range tokens { 32 | split := strings.Split(spec, "=") 33 | result = append(result, sortSpecItem{split[0], split[1]}) 34 | } 35 | return result 36 | } 37 | 38 | // Sort a parsed log according to the provided sort_spec 39 | func Sort(log ParsedLog, sort_spec string) []string { 40 | // Because we can not construct a custom sort function upfront, 41 | // we "rebuild" the log such that the "sorting" metrics come first, 42 | // and values for columns that are to be sorted descendingly are 43 | // inverted. This way, the sorting function is always the same 44 | proxy_log := []proxyRecord{} 45 | 46 | sort_spec_p := parseSortSpec(sort_spec) 47 | 48 | for k, v := range log.Resources { 49 | proxy_item_values := []float64{0, 0, 0, 0} 50 | 51 | // With values in the order of the sort_spec, create a proxy record 52 | for idx, sort_item := range sort_spec_p { 53 | column := sort_item.col 54 | order := sort_item.order 55 | var value float64 56 | if column == "n" { 57 | value = float64(v.NumCalls) 58 | } else if column == "tot_time" { 59 | value = float64(v.TotalTime) 60 | } else if column == "idx_creation" { 61 | value = float64(v.ModificationStartedIndex) 62 | } else if column == "idx_created" { 63 | value = float64(v.ModificationCompletedIndex) 64 | } else if column == "status" { 65 | value = float64(v.AfterStatus) 66 | } 67 | if order == "desc" { 68 | value = -value 69 | } 70 | proxy_item_values[idx] = value 71 | } 72 | 73 | proxy_log = append(proxy_log, proxyRecord{k, proxy_item_values}) 74 | } 75 | 76 | N := reflect.TypeOf(proxyRecord{}).NumField() 77 | 78 | // Sort the proxy log 79 | sort.Slice(proxy_log, func(i, j int) bool { 80 | // Custom sort function: sort by all values in 'items' 81 | for item := 0; item < N; item++ { 82 | if proxy_log[i].items[item] != proxy_log[j].items[item] { 83 | return proxy_log[i].items[item] < proxy_log[j].items[item] 84 | } 85 | } 86 | return false // Everything is equal 87 | }) 88 | 89 | // Finally, extract the resource names out of the sorted slice 90 | result := []string{} 91 | for _, v := range proxy_log { 92 | result = append(result, v.resource) 93 | } 94 | return result 95 | } 96 | -------------------------------------------------------------------------------- /pkg/tf-profile/sort/sort_test.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | "testing" 7 | 8 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/parser" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestParseSortSpec(t *testing.T) { 14 | p1 := parseSortSpec("key=asc") 15 | assert.Equal(t, len(p1), 1, "Expected one item after parsing.") 16 | assert.Equal(t, p1[0].col, "key") 17 | assert.Equal(t, p1[0].order, "asc") 18 | 19 | p2 := parseSortSpec("a=asc,b=desc,c=asc") 20 | expected := []sortSpecItem{ 21 | {"a", "asc"}, 22 | {"b", "desc"}, 23 | {"c", "asc"}, 24 | } 25 | assert.Equalf(t, p2, expected, "Expected %v after parsing, got %v\n", p2, expected) 26 | } 27 | 28 | func TestSort(t *testing.T) { 29 | file, _ := os.Open("../../../test/multiple_resources.log") 30 | s := bufio.NewScanner(file) 31 | log, err := Parse(s, false) 32 | assert.Nil(t, err) 33 | 34 | sorted := Sort(log, "tot_time=asc,idx_created=asc") 35 | expected := []string{ 36 | "time_sleep.count_0", 37 | "time_sleep.for_each_a", 38 | "time_sleep.count_1", 39 | "time_sleep.for_each_c", 40 | "time_sleep.count_2", 41 | "time_sleep.for_each_d", 42 | "time_sleep.for_each_b", 43 | "time_sleep.count_3", 44 | "time_sleep.count_4", 45 | "time_sleep.count_5", 46 | "time_sleep.count_6", 47 | "time_sleep.count_7", 48 | "time_sleep.count_8", 49 | "time_sleep.count_9", 50 | } 51 | assert.Equal(t, sorted, expected) 52 | 53 | sorted2 := Sort(log, "tot_time=desc,idx_created=desc") 54 | for i := 0; i < len(expected); i++ { 55 | assert.Equal(t, expected[i], sorted2[len(expected)-i-1]) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /pkg/tf-profile/stats/resource_utils.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // Extract the top level module. 8 | // For example, "module.mymod.aws_subnet.test" will return "module.mymod" 9 | func getTopLevelModule(name string) string { 10 | split := strings.Split(name, ".") 11 | if len(split) < 2 { 12 | return "" 13 | } 14 | if split[0] == "module" { 15 | return split[0] + "." + split[1] 16 | } 17 | return "" 18 | } 19 | 20 | // Return the amount of nested modules for a resource. 21 | // E.g. aws_subnet.test => 0 22 | // E.g. module.mod1.aws_subnet.test => 1. 23 | // E.g. module.mod1.module.mod2.aws_subnet.test => 2. 24 | func getModuleDepth(name string) int { 25 | tokens := len(strings.Split(name, ".")) 26 | return (tokens - 2) / 2 27 | } 28 | 29 | // Given a full resource name, return the name of the deepest module it belongs to 30 | // (including parent modules) 31 | func getModule(name string) string { 32 | split := strings.Split(name, ".") 33 | return strings.Join(split[:len(split)-2], ".") 34 | } 35 | 36 | // Given a full resource name, return only the name of the deepest module 37 | // without parent modules 38 | func getLeafModuleName(name string) string { 39 | split := strings.Split(name, ".") 40 | leaf := "" 41 | 42 | for idx, s := range split { 43 | if s == "module" { 44 | leaf = split[idx+1] 45 | } 46 | } 47 | return leaf 48 | } 49 | -------------------------------------------------------------------------------- /pkg/tf-profile/stats/stats.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "sort" 7 | 8 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/aggregate" 9 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/core" 10 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/parser" 11 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/readers" 12 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/utils" 13 | "github.com/fatih/color" 14 | "github.com/rodaine/table" 15 | ) 16 | 17 | type Stat struct { 18 | name string 19 | value string 20 | } 21 | 22 | func Stats(args []string, tee bool, aggregate bool) error { 23 | var file *bufio.Scanner 24 | var err error 25 | 26 | if len(args) == 1 { 27 | file, err = FileReader{File: args[0]}.Read() 28 | } else { 29 | file, err = StdinReader{}.Read() 30 | } 31 | 32 | if err != nil { 33 | return err 34 | } 35 | 36 | tflog, err := Parse(file, tee) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | if aggregate { 42 | tflog, err = Aggregate(tflog) 43 | if err != nil { 44 | return err 45 | } 46 | } 47 | 48 | err = PrintStats(tflog) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | return nil 54 | } 55 | 56 | // Print various high-level stats about a ParsedLog 57 | func PrintStats(log ParsedLog) error { 58 | headerFmt := color.New(color.FgHiBlue, color.Underline).SprintfFunc() 59 | columnFmt := color.New(color.FgBlue).SprintfFunc() 60 | 61 | tbl := table.New("Key", "Value") 62 | tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) 63 | 64 | addRows(&tbl, getBasicStats(log)) 65 | addRows(&tbl, getTimeStats(log)) 66 | addRows(&tbl, getOperationStats(log)) 67 | addRows(&tbl, getAfterStatusStats(log)) 68 | addRows(&tbl, getDesiredStateStats(log)) 69 | addRows(&tbl, getModuleStats(log)) 70 | 71 | fmt.Println() // Create space above the table 72 | tbl.Print() 73 | 74 | return nil 75 | } 76 | 77 | // Helper to add multiple rows at once 78 | func addRows(tbl *table.Table, rows []Stat) { 79 | for _, stat := range rows { 80 | (*tbl).AddRow(stat.name, stat.value) 81 | } 82 | (*tbl).AddRow("", "") // Add some spacing between sections 83 | } 84 | 85 | func getBasicStats(log ParsedLog) []Stat { 86 | NumCalls := 0 87 | for _, resource := range log.Resources { 88 | NumCalls += resource.NumCalls 89 | } 90 | return []Stat{ 91 | {"Number of resources in configuration", fmt.Sprint(NumCalls)}, 92 | } 93 | } 94 | 95 | func getTimeStats(log ParsedLog) []Stat { 96 | TotalTime := 0 97 | HighestTime := -1 98 | HighestResource := "" 99 | 100 | for name, metric := range log.Resources { 101 | TotalTime += int(metric.TotalTime / 1000) 102 | if int(metric.TotalTime) > HighestTime { 103 | HighestTime = int(metric.TotalTime) 104 | HighestResource = name 105 | } 106 | } 107 | return []Stat{ 108 | {"Cumulative duration", FormatDuration(TotalTime)}, 109 | {"Longest apply time", FormatDuration(HighestTime / 1000)}, 110 | {"Longest apply resource", HighestResource}, 111 | } 112 | } 113 | 114 | func getAfterStatusStats(log ParsedLog) []Stat { 115 | StatusCount := make(map[string]int) 116 | for _, metrics := range log.Resources { 117 | StatusCount[metrics.AfterStatus.String()] += metrics.NumCalls 118 | } 119 | 120 | result := []Stat{} 121 | for status, count := range StatusCount { 122 | StatName := fmt.Sprintf("Resources in state %v", status) 123 | result = append(result, Stat{StatName, fmt.Sprint(count)}) 124 | } 125 | 126 | // Sort on name to make it consistent 127 | sort.Slice(result, func(i int, j int) bool { 128 | return result[i].name < result[j].name 129 | }) 130 | return result 131 | } 132 | 133 | func getDesiredStateStats(log ParsedLog) []Stat { 134 | inDesiredState := 0 135 | notInDesiredState := 0 136 | 137 | for _, metric := range log.Resources { 138 | if metric.AfterStatus == metric.DesiredStatus { 139 | inDesiredState += 1 140 | } else { 141 | notInDesiredState += 1 142 | } 143 | } 144 | sum := inDesiredState + notInDesiredState 145 | 146 | percInDesired := 100 * float64(inDesiredState) / float64(sum) 147 | percNotInDesired := 100 * float64(notInDesiredState) / float64(sum) 148 | 149 | return []Stat{ 150 | {"Resources in desired state", fmt.Sprintf("%v out of %v (%.1f%%)", inDesiredState, sum, percInDesired)}, 151 | {"Resources not in desired state", fmt.Sprintf("%v out of %v (%.1f%%)", notInDesiredState, sum, percNotInDesired)}, 152 | } 153 | } 154 | 155 | func getOperationStats(log ParsedLog) []Stat { 156 | 157 | Operations := make(map[string]int) 158 | for _, metrics := range log.Resources { 159 | Operations[metrics.Operation.String()] += metrics.NumCalls 160 | } 161 | 162 | result := []Stat{} 163 | for op, count := range Operations { 164 | StatName := fmt.Sprintf("Resources marked for operation %v", op) 165 | result = append(result, Stat{StatName, fmt.Sprint(count)}) 166 | } 167 | return result 168 | } 169 | 170 | func getModuleStats(log ParsedLog) []Stat { 171 | LargestTopLevelModule := "/" 172 | LargestTopLevelModuleSize := 0 173 | DeepestModuleDepth := 0 174 | DeepestModuleName := "/" 175 | LargestLeafModuleSize := 0 176 | LargestLeafModuleName := "/" 177 | 178 | toplevel := make(map[string]int) 179 | LeafModuleCounts := make(map[string]int) 180 | 181 | for name, metrics := range log.Resources { 182 | toplevelmodule := getTopLevelModule(name) 183 | leafmodule := getLeafModuleName(name) 184 | 185 | // If created in a module and we haven't seen it 186 | _, seen := toplevel[toplevelmodule] 187 | if toplevelmodule != "" && seen == true { 188 | toplevel[toplevelmodule] += metrics.NumCalls 189 | } else if toplevelmodule != "" && seen == false { 190 | toplevel[toplevelmodule] = metrics.NumCalls 191 | } 192 | 193 | // New leaf module? 194 | _, seen = LeafModuleCounts[leafmodule] 195 | if leafmodule != "" && seen == true { 196 | LeafModuleCounts[leafmodule] += metrics.NumCalls 197 | } else if leafmodule != "" && seen == false { 198 | LeafModuleCounts[leafmodule] = metrics.NumCalls 199 | } 200 | 201 | // Is deeper submodule than seen before? 202 | if getModuleDepth(name) > DeepestModuleDepth { 203 | DeepestModuleDepth = getModuleDepth(name) 204 | DeepestModuleName = getModule(name) 205 | } 206 | } 207 | 208 | // Get largest toplevel module 209 | for name, count := range toplevel { 210 | if count > LargestTopLevelModuleSize { 211 | LargestTopLevelModule = name 212 | LargestTopLevelModuleSize = count 213 | } 214 | } 215 | 216 | // Get largest leaf module 217 | for name, count := range LeafModuleCounts { 218 | if count > LargestLeafModuleSize { 219 | LargestLeafModuleSize = count 220 | LargestLeafModuleName = "module." + name 221 | } 222 | } 223 | 224 | return []Stat{ 225 | {"Number of top-level modules", fmt.Sprint(len(toplevel))}, 226 | {"Largest top-level module", LargestTopLevelModule}, 227 | {"Size of largest top-level module", fmt.Sprint(LargestTopLevelModuleSize)}, 228 | {"Deepest module", DeepestModuleName}, 229 | {"Deepest module depth", fmt.Sprint(DeepestModuleDepth)}, 230 | {"Largest leaf module", LargestLeafModuleName}, 231 | {"Size of largest leaf module", fmt.Sprint(LargestLeafModuleSize)}, 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /pkg/tf-profile/stats/stats_test.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/core" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestBasicStats(t *testing.T) { 11 | In := ParsedLog{ 12 | Resources: map[string]ResourceMetric{ 13 | "a": {NumCalls: 1, AfterStatus: Created}, 14 | "b": {NumCalls: 1, AfterStatus: Created}, 15 | "c": {NumCalls: 1, AfterStatus: Created}, 16 | "d": {NumCalls: 1, AfterStatus: Created}, 17 | }, 18 | } 19 | Out := getBasicStats(In) 20 | assert.Equal(t, 1, len(Out)) 21 | assert.Equal(t, "Number of resources in configuration", Out[0].name) 22 | assert.Equal(t, "4", Out[0].value) 23 | } 24 | 25 | func TestTimeStats(t *testing.T) { 26 | In := ParsedLog{ 27 | Resources: map[string]ResourceMetric{ 28 | "a": {NumCalls: 1, TotalTime: 1000, AfterStatus: Created}, 29 | "b": {NumCalls: 1, TotalTime: 2000, AfterStatus: Created}, 30 | "c": {NumCalls: 1, TotalTime: 3000, AfterStatus: Created}, 31 | "d": {NumCalls: 1, TotalTime: 59000, AfterStatus: Created}, 32 | }, 33 | } 34 | Out := getTimeStats(In) 35 | 36 | assert.Equal(t, 3, len(Out)) 37 | assert.Equal(t, "Cumulative duration", Out[0].name) 38 | assert.Equal(t, "Longest apply time", Out[1].name) 39 | assert.Equal(t, "Longest apply resource", Out[2].name) 40 | 41 | assert.Equal(t, "1m5s", Out[0].value) 42 | assert.Equal(t, "59s", Out[1].value) 43 | assert.Equal(t, "d", Out[2].value) 44 | } 45 | 46 | func TestStatusStats(t *testing.T) { 47 | In := ParsedLog{ 48 | Resources: map[string]ResourceMetric{ 49 | "a": {NumCalls: 1, AfterStatus: Created}, 50 | "b": {NumCalls: 1, AfterStatus: Failed}, 51 | "c": {NumCalls: 1, AfterStatus: Failed}, 52 | "d": {NumCalls: 1, AfterStatus: NotCreated}, 53 | }, 54 | } 55 | Out := getAfterStatusStats(In) 56 | 57 | Expected := []Stat{ 58 | {"Resources in state Created", "1"}, 59 | {"Resources in state Failed", "2"}, 60 | {"Resources in state NotCreated", "1"}, 61 | } 62 | assert.Equal(t, Expected, Out) 63 | } 64 | 65 | func TestModuleStats(t *testing.T) { 66 | In := ParsedLog{ 67 | Resources: map[string]ResourceMetric{ 68 | "r.test": {NumCalls: 1, AfterStatus: Created}, 69 | "module.test1.resource.test1": {NumCalls: 1, AfterStatus: Created}, 70 | "module.test1.resource.test2": {NumCalls: 1, AfterStatus: Created}, 71 | "module.test1.resource.test3": {NumCalls: 1, AfterStatus: Created}, 72 | "module.test2.resource.test1": {NumCalls: 1, AfterStatus: Created}, 73 | "module.test2.resource.test2": {NumCalls: 1, AfterStatus: Created}, 74 | "module.a.module.b.module.c.module.d.resource.test": {NumCalls: 1, AfterStatus: Created}, 75 | }, 76 | } 77 | Out := getModuleStats(In) 78 | Expected := []Stat{ 79 | {"Number of top-level modules", "3"}, 80 | {"Largest top-level module", "module.test1"}, 81 | {"Size of largest top-level module", "3"}, 82 | {"Deepest module", "module.a.module.b.module.c.module.d"}, 83 | {"Deepest module depth", "4"}, 84 | {"Largest leaf module", "module.test1"}, 85 | {"Size of largest leaf module", "3"}, 86 | } 87 | assert.Equal(t, Expected, Out) 88 | } 89 | 90 | func TestFullStats(t *testing.T) { 91 | err := Stats([]string{"../../../test/aggregate.log"}, false, true) 92 | assert.Nil(t, err) 93 | err = Stats([]string{"../../../test/multiple_resources.log"}, false, true) 94 | assert.Nil(t, err) 95 | err = Stats([]string{"../../../test/null_resources.log"}, false, true) 96 | assert.Nil(t, err) 97 | 98 | err = Stats([]string{"does-not-exist"}, false, true) 99 | assert.NotNil(t, err) 100 | } 101 | -------------------------------------------------------------------------------- /pkg/tf-profile/table/table.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | 7 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/aggregate" 8 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/parser" 9 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/readers" 10 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/sort" 11 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/utils" 12 | 13 | . "github.com/QuintenBruynseraede/tf-profile/pkg/tf-profile/core" 14 | "github.com/fatih/color" 15 | "github.com/rodaine/table" 16 | ) 17 | 18 | // Execute the `tf-profile table` command 19 | func Table(args []string, max_depth int, tee bool, sort string, aggregate bool) error { 20 | var file *bufio.Scanner 21 | var err error 22 | 23 | if len(args) == 1 { 24 | file, err = FileReader{File: args[0]}.Read() 25 | } else { 26 | file, err = StdinReader{}.Read() 27 | } 28 | 29 | if err != nil { 30 | return err 31 | } 32 | 33 | tflog, err := Parse(file, tee) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | if aggregate { 39 | tflog, err = Aggregate(tflog) 40 | if err != nil { 41 | return err 42 | } 43 | } 44 | 45 | err = PrintTable(tflog, sort) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | return nil 51 | } 52 | 53 | // Print a parsed log in tabular format, optionally sorting by certain columns 54 | // sort_spec is a comma-separated list of "column_name=(asc|desc)", e.g. "n=asc,tot_time=desc" 55 | func PrintTable(log ParsedLog, sort_spec string) error { 56 | headerFmt := color.New(color.FgHiBlue, color.Underline).SprintfFunc() 57 | columnFmt := color.New(color.FgBlue).SprintfFunc() 58 | 59 | tbl := table.New("resource", "n", "tot_time", "modify_started", "modify_ended", "desired_state", "operation", "final_state") 60 | tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) 61 | 62 | // Sort the resources according to the sort_spec and create rows 63 | for _, r := range Sort(log, sort_spec) { 64 | for resource, metric := range log.Resources { 65 | if r == resource { 66 | tbl.AddRow( 67 | resource, 68 | (metric.NumCalls), 69 | FormatDuration(int(metric.TotalTime/1000)), // Display as "10s" or "1m30s" 70 | removeMinusOne(metric.ModificationStartedIndex), 71 | removeMinusOne(metric.ModificationCompletedIndex), 72 | (metric.DesiredStatus), 73 | (metric.Operation), 74 | (metric.AfterStatus), 75 | ) 76 | break 77 | } 78 | } 79 | } 80 | 81 | fmt.Println() // Create space above the table 82 | tbl.Print() 83 | 84 | return nil 85 | } 86 | 87 | // Many metrics use -1 as value for "unknown at the time". When a resource change fails, 88 | // these initial values remain in the log. Before printing, we replace then with '/' 89 | func removeMinusOne(val int) string { 90 | if val == -1 { 91 | return "/" 92 | } else { 93 | return fmt.Sprintf("%v", val) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /pkg/tf-profile/table/table_test.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestBasicRun(t *testing.T) { 10 | err := Table([]string{}, 1, true, "tot_time=asc", true) 11 | assert.Nil(t, err) 12 | } 13 | 14 | func TestFileDoesntExist(t *testing.T) { 15 | err := Table([]string{"does-not-exist"}, 1, true, "tot_time=asc", true) 16 | assert.NotNil(t, err) 17 | } 18 | -------------------------------------------------------------------------------- /pkg/tf-profile/utils/fmt_utils.go: -------------------------------------------------------------------------------- 1 | package tfprofile 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | // Format a duration in seconds into "30s" or "2m30s" 9 | func FormatDuration(seconds int) string { 10 | duration := time.Duration(seconds) * time.Second 11 | minutes := int(duration.Minutes()) 12 | seconds = seconds - (minutes * 60) 13 | if minutes == 0 { 14 | return fmt.Sprintf("%ds", seconds) 15 | } 16 | return fmt.Sprintf("%dm%ds", minutes, seconds) 17 | } 18 | -------------------------------------------------------------------------------- /test/aggregate.log: -------------------------------------------------------------------------------- 1 | 2 | Terraform used the selected providers to generate the following execution 3 | plan. Resource actions are indicated with the following symbols: 4 | + create 5 | 6 | Terraform will perform the following actions: 7 | 8 | # time_sleep.count[0] will be created 9 | + resource "time_sleep" "count" { 10 | + create_duration = "0s" 11 | + id = (known after apply) 12 | } 13 | 14 | # time_sleep.count[1] will be created 15 | + resource "time_sleep" "count" { 16 | + create_duration = "1s" 17 | + id = (known after apply) 18 | } 19 | 20 | # time_sleep.count[2] will be created 21 | + resource "time_sleep" "count" { 22 | + create_duration = "2s" 23 | + id = (known after apply) 24 | } 25 | 26 | # time_sleep.count[3] will be created 27 | + resource "time_sleep" "count" { 28 | + create_duration = "3s" 29 | + id = (known after apply) 30 | } 31 | 32 | # time_sleep.count[4] will be created 33 | + resource "time_sleep" "count" { 34 | + create_duration = "4s" 35 | + id = (known after apply) 36 | } 37 | 38 | # time_sleep.foreach["a"] will be created 39 | + resource "time_sleep" "foreach" { 40 | + create_duration = "1s" 41 | + id = (known after apply) 42 | } 43 | 44 | # time_sleep.foreach["b"] will be created 45 | + resource "time_sleep" "foreach" { 46 | + create_duration = "2s" 47 | + id = (known after apply) 48 | } 49 | 50 | # time_sleep.foreach["c"] will be created 51 | + resource "time_sleep" "foreach" { 52 | + create_duration = "3s" 53 | + id = (known after apply) 54 | } 55 | 56 | # module.test[0].time_sleep.count[0] will be created 57 | + resource "time_sleep" "count" { 58 | + create_duration = "0s" 59 | + id = (known after apply) 60 | } 61 | 62 | # module.test[0].time_sleep.count[1] will be created 63 | + resource "time_sleep" "count" { 64 | + create_duration = "1s" 65 | + id = (known after apply) 66 | } 67 | 68 | # module.test[0].time_sleep.count[2] will be created 69 | + resource "time_sleep" "count" { 70 | + create_duration = "2s" 71 | + id = (known after apply) 72 | } 73 | 74 | # module.test[1].time_sleep.count[0] will be created 75 | + resource "time_sleep" "count" { 76 | + create_duration = "0s" 77 | + id = (known after apply) 78 | } 79 | 80 | # module.test[1].time_sleep.count[1] will be created 81 | + resource "time_sleep" "count" { 82 | + create_duration = "1s" 83 | + id = (known after apply) 84 | } 85 | 86 | # module.test[1].time_sleep.count[2] will be created 87 | + resource "time_sleep" "count" { 88 | + create_duration = "2s" 89 | + id = (known after apply) 90 | } 91 | 92 | Plan: 14 to add, 0 to change, 0 to destroy. 93 | time_sleep.count[0]: Creating... 94 | time_sleep.count[1]: Creating... 95 | time_sleep.foreach["c"]: Creating... 96 | module.test[1].time_sleep.count[0]: Creating... 97 | time_sleep.foreach["a"]: Creating... 98 | module.test[1].time_sleep.count[1]: Creating... 99 | time_sleep.foreach["b"]: Creating... 100 | module.test[0].time_sleep.count[1]: Creating... 101 | module.test[0].time_sleep.count[2]: Creating... 102 | module.test[0].time_sleep.count[0]: Creating... 103 | time_sleep.count[0]: Creation complete after 0s [id=2023-04-07T11:04:04Z] 104 | module.test[0].time_sleep.count[0]: Creation complete after 0s [id=2023-04-07T11:04:04Z] 105 | module.test[1].time_sleep.count[0]: Creation complete after 0s [id=2023-04-07T11:04:04Z] 106 | module.test[1].time_sleep.count[2]: Creating... 107 | time_sleep.count[3]: Creating... 108 | time_sleep.count[2]: Creating... 109 | time_sleep.count[1]: Creation complete after 1s [id=2023-04-07T11:04:05Z] 110 | module.test[1].time_sleep.count[1]: Creation complete after 2s [id=2023-04-07T11:04:05Z] 111 | time_sleep.foreach["a"]: Creation complete after 2s [id=2023-04-07T11:04:05Z] 112 | module.test[0].time_sleep.count[1]: Creation complete after 2s [id=2023-04-07T11:04:05Z] 113 | time_sleep.count[4]: Creating... 114 | module.test[0].time_sleep.count[2]: Creation complete after 2s [id=2023-04-07T11:04:06Z] 115 | time_sleep.foreach["b"]: Creation complete after 2s [id=2023-04-07T11:04:06Z] 116 | module.test[1].time_sleep.count[2]: Creation complete after 3s [id=2023-04-07T11:04:06Z] 117 | time_sleep.count[2]: Creation complete after 3s [id=2023-04-07T11:04:06Z] 118 | time_sleep.foreach["c"]: Creation complete after 3s [id=2023-04-07T11:04:07Z] 119 | time_sleep.count[3]: Creation complete after 3s [id=2023-04-07T11:04:07Z] 120 | time_sleep.count[4]: Creation complete after 4s [id=2023-04-07T11:04:09Z] 121 | 122 | Apply complete! Resources: 14 added, 0 changed, 0 destroyed. 123 | -------------------------------------------------------------------------------- /test/aggregate/aggregate.tf: -------------------------------------------------------------------------------- 1 | // Create some resources with foreach and count 2 | // tf-profile will agreggate these 3 | 4 | module "test" { 5 | source = "./modules/test" 6 | count = 2 7 | input = "foo" 8 | } 9 | 10 | resource "time_sleep" "count" { 11 | count = 5 12 | create_duration = "${count.index}s" 13 | } 14 | 15 | resource "time_sleep" "foreach" { 16 | for_each = {"a": 1, "b": 2, "c": 3} 17 | create_duration = "${each.value}s" 18 | } 19 | -------------------------------------------------------------------------------- /test/aggregate/modules/test/test.tf: -------------------------------------------------------------------------------- 1 | variable "input" { 2 | 3 | } 4 | 5 | resource "time_sleep" "count" { 6 | count = 3 7 | create_duration = "${count.index}s" 8 | } 9 | -------------------------------------------------------------------------------- /test/all_operations.log: -------------------------------------------------------------------------------- 1 | aws_ssm_parameter.p4: Refreshing state... [id=p4] 2 | aws_ssm_parameter.p3: Refreshing state... [id=p3] 3 | aws_ssm_parameter.p6: Refreshing state... [id=p6] 4 | aws_ssm_parameter.p2: Refreshing state... [id=p2] 5 | aws_ssm_parameter.p5: Refreshing state... [id=p5] 6 | aws_ssm_parameter.p1: Refreshing state... [id=p1] 7 | 8 | Terraform used the selected providers to generate the following execution plan. Resource 9 | actions are indicated with the following symbols: 10 | ~ update in-place 11 | - destroy 12 | -/+ destroy and then create replacement 13 | 14 | Terraform will perform the following actions: 15 | 16 | # aws_ssm_parameter.p1 is tainted, so must be replaced 17 | -/+ resource "aws_ssm_parameter" "p1" { 18 | ~ arn = "arn:aws:ssm:eu-west-1:233295694198:parameter/p1" -> (known after apply) 19 | ~ data_type = "text" -> (known after apply) 20 | ~ id = "p1" -> (known after apply) 21 | + insecure_value = (known after apply) 22 | + key_id = (known after apply) 23 | name = "p1" 24 | - tags = {} -> null 25 | ~ tags_all = {} -> (known after apply) 26 | ~ tier = "Standard" -> (known after apply) 27 | ~ version = 1 -> (known after apply) 28 | # (2 unchanged attributes hidden) 29 | } 30 | 31 | # aws_ssm_parameter.p3 will be replaced, as requested 32 | -/+ resource "aws_ssm_parameter" "p3" { 33 | ~ arn = "arn:aws:ssm:eu-west-1:233295694198:parameter/p3" -> (known after apply) 34 | ~ data_type = "text" -> (known after apply) 35 | ~ id = "p3" -> (known after apply) 36 | + insecure_value = (known after apply) 37 | + key_id = (known after apply) 38 | name = "p3" 39 | - tags = {} -> null 40 | ~ tags_all = {} -> (known after apply) 41 | ~ tier = "Standard" -> (known after apply) 42 | ~ version = 1 -> (known after apply) 43 | # (2 unchanged attributes hidden) 44 | } 45 | 46 | # aws_ssm_parameter.p4 will be destroyed 47 | # (because aws_ssm_parameter.p4 is not in configuration) 48 | - resource "aws_ssm_parameter" "p4" { 49 | - arn = "arn:aws:ssm:eu-west-1:233295694198:parameter/p4" -> null 50 | - data_type = "text" -> null 51 | - id = "p4" -> null 52 | - name = "p4" -> null 53 | - tags = {} -> null 54 | - tags_all = {} -> null 55 | - tier = "Standard" -> null 56 | - type = "String" -> null 57 | - value = (sensitive value) 58 | - version = 1 -> null 59 | } 60 | 61 | # aws_ssm_parameter.p5 will be updated in-place 62 | ~ resource "aws_ssm_parameter" "p5" { 63 | id = "p5" 64 | + insecure_value = (known after apply) 65 | name = "p5" 66 | tags = {} 67 | ~ value = (sensitive value) 68 | ~ version = 1 -> (known after apply) 69 | # (5 unchanged attributes hidden) 70 | } 71 | 72 | # aws_ssm_parameter.p6 must be replaced 73 | -/+ resource "aws_ssm_parameter" "p6" { 74 | ~ arn = "arn:aws:ssm:eu-west-1:233295694198:parameter/p6" -> (known after apply) 75 | ~ data_type = "text" -> (known after apply) 76 | ~ id = "p6" -> (known after apply) 77 | + insecure_value = (known after apply) 78 | + key_id = (known after apply) 79 | ~ name = "p6" -> "new-p6" # forces replacement 80 | - tags = {} -> null 81 | ~ tags_all = {} -> (known after apply) 82 | ~ tier = "Standard" -> (known after apply) 83 | ~ version = 1 -> (known after apply) 84 | # (2 unchanged attributes hidden) 85 | } 86 | 87 | Plan: 3 to add, 1 to change, 4 to destroy. 88 | 89 | Do you want to perform these actions? 90 | Terraform will perform the actions described above. 91 | Only 'yes' will be accepted to approve. 92 | 93 | Enter a value: yes 94 | 95 | aws_ssm_parameter.p4: Destroying... [id=p4] 96 | aws_ssm_parameter.p1: Destroying... [id=p1] 97 | aws_ssm_parameter.p6: Destroying... [id=p6] 98 | aws_ssm_parameter.p3: Destroying... [id=p3] 99 | aws_ssm_parameter.p5: Modifying... [id=p5] 100 | aws_ssm_parameter.p3: Destruction complete after 0s 101 | aws_ssm_parameter.p4: Destruction complete after 0s 102 | aws_ssm_parameter.p1: Destruction complete after 0s 103 | aws_ssm_parameter.p6: Destruction complete after 0s 104 | aws_ssm_parameter.p3: Creating... 105 | aws_ssm_parameter.p6: Creating... 106 | aws_ssm_parameter.p1: Creating... 107 | aws_ssm_parameter.p5: Modifications complete after 0s [id=p5] 108 | aws_ssm_parameter.p1: Creation complete after 0s [id=p1] 109 | aws_ssm_parameter.p3: Creation complete after 0s [id=p3] 110 | aws_ssm_parameter.p6: Creation complete after 0s [id=new-p6] 111 | 112 | Apply complete! Resources: 3 added, 1 changed, 4 destroyed. -------------------------------------------------------------------------------- /test/failures.log: -------------------------------------------------------------------------------- 1 | 2 | Terraform used the selected providers to generate the following execution 3 | plan. Resource actions are indicated with the following symbols: 4 | + create 5 | 6 | Terraform will perform the following actions: 7 | 8 | # aws_ssm_parameter.bad will be created 9 | + resource "aws_ssm_parameter" "bad" { 10 | + arn = (known after apply) 11 | + data_type = (known after apply) 12 | + id = (known after apply) 13 | + insecure_value = (known after apply) 14 | + key_id = (known after apply) 15 | + name = "/slash/at/end/" 16 | + tags_all = (known after apply) 17 | + tier = (known after apply) 18 | + type = "String" 19 | + value = (sensitive value) 20 | + version = (known after apply) 21 | } 22 | 23 | # aws_ssm_parameter.bad2[0] will be created 24 | + resource "aws_ssm_parameter" "bad2" { 25 | + arn = (known after apply) 26 | + data_type = (known after apply) 27 | + id = (known after apply) 28 | + insecure_value = (known after apply) 29 | + key_id = (known after apply) 30 | + name = "/slash/at/end0/" 31 | + tags_all = (known after apply) 32 | + tier = (known after apply) 33 | + type = "String" 34 | + value = (sensitive value) 35 | + version = (known after apply) 36 | } 37 | 38 | # aws_ssm_parameter.bad2[1] will be created 39 | + resource "aws_ssm_parameter" "bad2" { 40 | + arn = (known after apply) 41 | + data_type = (known after apply) 42 | + id = (known after apply) 43 | + insecure_value = (known after apply) 44 | + key_id = (known after apply) 45 | + name = "/slash/at/end1/" 46 | + tags_all = (known after apply) 47 | + tier = (known after apply) 48 | + type = "String" 49 | + value = (sensitive value) 50 | + version = (known after apply) 51 | } 52 | 53 | # aws_ssm_parameter.bad2[2] will be created 54 | + resource "aws_ssm_parameter" "bad2" { 55 | + arn = (known after apply) 56 | + data_type = (known after apply) 57 | + id = (known after apply) 58 | + insecure_value = (known after apply) 59 | + key_id = (known after apply) 60 | + name = "/slash/at/end2/" 61 | + tags_all = (known after apply) 62 | + tier = (known after apply) 63 | + type = "String" 64 | + value = (sensitive value) 65 | + version = (known after apply) 66 | } 67 | 68 | # aws_ssm_parameter.good will be created 69 | + resource "aws_ssm_parameter" "good" { 70 | + arn = (known after apply) 71 | + data_type = (known after apply) 72 | + id = (known after apply) 73 | + insecure_value = (known after apply) 74 | + key_id = (known after apply) 75 | + name = "/no/slash/at/end" 76 | + tags_all = (known after apply) 77 | + tier = (known after apply) 78 | + type = "String" 79 | + value = (sensitive value) 80 | + version = (known after apply) 81 | } 82 | 83 | # aws_ssm_parameter.good2[0] will be created 84 | + resource "aws_ssm_parameter" "good2" { 85 | + arn = (known after apply) 86 | + data_type = (known after apply) 87 | + id = (known after apply) 88 | + insecure_value = (known after apply) 89 | + key_id = (known after apply) 90 | + name = "/no/slash/at/end0" 91 | + tags_all = (known after apply) 92 | + tier = (known after apply) 93 | + type = "String" 94 | + value = (sensitive value) 95 | + version = (known after apply) 96 | } 97 | 98 | # aws_ssm_parameter.good2[1] will be created 99 | + resource "aws_ssm_parameter" "good2" { 100 | + arn = (known after apply) 101 | + data_type = (known after apply) 102 | + id = (known after apply) 103 | + insecure_value = (known after apply) 104 | + key_id = (known after apply) 105 | + name = "/no/slash/at/end1" 106 | + tags_all = (known after apply) 107 | + tier = (known after apply) 108 | + type = "String" 109 | + value = (sensitive value) 110 | + version = (known after apply) 111 | } 112 | 113 | # aws_ssm_parameter.good2[2] will be created 114 | + resource "aws_ssm_parameter" "good2" { 115 | + arn = (known after apply) 116 | + data_type = (known after apply) 117 | + id = (known after apply) 118 | + insecure_value = (known after apply) 119 | + key_id = (known after apply) 120 | + name = "/no/slash/at/end2" 121 | + tags_all = (known after apply) 122 | + tier = (known after apply) 123 | + type = "String" 124 | + value = (sensitive value) 125 | + version = (known after apply) 126 | } 127 | 128 | Plan: 8 to add, 0 to change, 0 to destroy. 129 | aws_ssm_parameter.good: Creating... 130 | aws_ssm_parameter.bad2[2]: Creating... 131 | aws_ssm_parameter.bad2[1]: Creating... 132 | aws_ssm_parameter.bad2[0]: Creating... 133 | aws_ssm_parameter.good2[1]: Creating... 134 | aws_ssm_parameter.bad: Creating... 135 | aws_ssm_parameter.good2[2]: Creating... 136 | aws_ssm_parameter.good2[0]: Creating... 137 | aws_ssm_parameter.good: Creation complete after 1s [id=/no/slash/at/end] 138 | aws_ssm_parameter.good2[0]: Creation complete after 1s [id=/no/slash/at/end0] 139 | aws_ssm_parameter.good2[2]: Creation complete after 1s [id=/no/slash/at/end2] 140 | aws_ssm_parameter.good2[1]: Creation complete after 1s [id=/no/slash/at/end1] 141 | 142 | Error: creating SSM Parameter (/slash/at/end/): ValidationException: Parameter name must not end with slash. 143 | status code: 400, request id: 99b72eaf-10ec-49d7-99e4-bc960809383e 144 | 145 | with aws_ssm_parameter.bad, 146 | on provider.tf line 15, in resource "aws_ssm_parameter" "bad": 147 | 15: resource "aws_ssm_parameter" "bad" { 148 | 149 | 150 | Error: creating SSM Parameter (/slash/at/end2/): ValidationException: Parameter name must not end with slash. 151 | status code: 400, request id: 99a3ae5f-9d97-4bc9-bfd8-ac29b72fc00d 152 | 153 | with aws_ssm_parameter.bad2[2], 154 | on provider.tf line 27, in resource "aws_ssm_parameter" "bad2": 155 | 27: resource "aws_ssm_parameter" "bad2" { 156 | 157 | 158 | Error: creating SSM Parameter (/slash/at/end1/): ValidationException: Parameter name must not end with slash. 159 | status code: 400, request id: 77765932-a8b2-48bf-abe2-71a151da56ea 160 | 161 | with aws_ssm_parameter.bad2[1], 162 | on provider.tf line 27, in resource "aws_ssm_parameter" "bad2": 163 | 27: resource "aws_ssm_parameter" "bad2" { 164 | 165 | 166 | Error: creating SSM Parameter (/slash/at/end0/): ValidationException: Parameter name must not end with slash. 167 | status code: 400, request id: f78b2744-2fff-4df9-824b-ba7c40ab256a 168 | 169 | with aws_ssm_parameter.bad2[0], 170 | on provider.tf line 27, in resource "aws_ssm_parameter" "bad2": 171 | 27: resource "aws_ssm_parameter" "bad2" { 172 | 173 | -------------------------------------------------------------------------------- /test/failures/provider.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = "~> 4.0" 6 | } 7 | } 8 | } 9 | 10 | # Configure the AWS Provider 11 | provider "aws" { 12 | region = "eu-west-1" 13 | } 14 | 15 | resource "aws_ssm_parameter" "bad" { 16 | name = "/slash/at/end/" 17 | type = "String" 18 | value = "test" 19 | } 20 | 21 | resource "aws_ssm_parameter" "good" { 22 | name = "/no/slash/at/end" 23 | type = "String" 24 | value = "test" 25 | } 26 | 27 | resource "aws_ssm_parameter" "bad2" { 28 | count = 3 29 | name = "/slash/at/end${count.index}/" 30 | type = "String" 31 | value = "test" 32 | } 33 | 34 | resource "aws_ssm_parameter" "good2" { 35 | count = 3 36 | name = "/no/slash/at/end${count.index}" 37 | type = "String" 38 | value = "test" 39 | } 40 | -------------------------------------------------------------------------------- /test/failures_without_plan.log: -------------------------------------------------------------------------------- 1 | aws_ssm_parameter.good: Creating... 2 | aws_ssm_parameter.bad2[2]: Creating... 3 | aws_ssm_parameter.bad2[1]: Creating... 4 | aws_ssm_parameter.bad2[0]: Creating... 5 | aws_ssm_parameter.good2[1]: Creating... 6 | aws_ssm_parameter.bad: Creating... 7 | aws_ssm_parameter.good2[2]: Creating... 8 | aws_ssm_parameter.good2[0]: Creating... 9 | aws_ssm_parameter.good: Creation complete after 1s [id=/no/slash/at/end] 10 | aws_ssm_parameter.good2[0]: Creation complete after 1s [id=/no/slash/at/end0] 11 | aws_ssm_parameter.good2[2]: Creation complete after 1s [id=/no/slash/at/end2] 12 | aws_ssm_parameter.good2[1]: Creation complete after 1s [id=/no/slash/at/end1] 13 | 14 | Error: creating SSM Parameter (/slash/at/end/): ValidationException: Parameter name must not end with slash. 15 | status code: 400, request id: 99b72eaf-10ec-49d7-99e4-bc960809383e 16 | 17 | with aws_ssm_parameter.bad, 18 | on provider.tf line 15, in resource "aws_ssm_parameter" "bad": 19 | 15: resource "aws_ssm_parameter" "bad" { 20 | 21 | 22 | Error: creating SSM Parameter (/slash/at/end2/): ValidationException: Parameter name must not end with slash. 23 | status code: 400, request id: 99a3ae5f-9d97-4bc9-bfd8-ac29b72fc00d 24 | 25 | with aws_ssm_parameter.bad2[2], 26 | on provider.tf line 27, in resource "aws_ssm_parameter" "bad2": 27 | 27: resource "aws_ssm_parameter" "bad2" { 28 | 29 | 30 | Error: creating SSM Parameter (/slash/at/end1/): ValidationException: Parameter name must not end with slash. 31 | status code: 400, request id: 77765932-a8b2-48bf-abe2-71a151da56ea 32 | 33 | with aws_ssm_parameter.bad2[1], 34 | on provider.tf line 27, in resource "aws_ssm_parameter" "bad2": 35 | 27: resource "aws_ssm_parameter" "bad2" { 36 | 37 | 38 | Error: creating SSM Parameter (/slash/at/end0/): ValidationException: Parameter name must not end with slash. 39 | status code: 400, request id: f78b2744-2fff-4df9-824b-ba7c40ab256a 40 | 41 | with aws_ssm_parameter.bad2[0], 42 | on provider.tf line 27, in resource "aws_ssm_parameter" "bad2": 43 | 27: resource "aws_ssm_parameter" "bad2" { 44 | 45 | -------------------------------------------------------------------------------- /test/many_modules/main.tf: -------------------------------------------------------------------------------- 1 | // Create a bunch of toplevel resources with a timeout to make them fail occasionally. 2 | resource "time_sleep" "foo" { 3 | count = 150 4 | create_duration = "${random_integer.sleep[count.index].result}s" 5 | } 6 | 7 | resource "random_integer" "sleep" { 8 | count = 300 9 | min = 1 10 | max = 5 11 | } 12 | 13 | resource "time_sleep" "bar" { 14 | count = 150 15 | create_duration = "${random_integer.sleep[150+count.index].result}s" 16 | } 17 | 18 | 19 | module "core" { 20 | count = 3 21 | source = "./modules/core_infrastructure" 22 | } 23 | 24 | module "applications" { 25 | count = 10 26 | source = "./modules/applications" 27 | } -------------------------------------------------------------------------------- /test/many_modules/modules/airflow/airflow.tf: -------------------------------------------------------------------------------- 1 | resource "random_integer" "sleep" { 2 | min = 1 3 | max = 5 4 | } 5 | 6 | resource "time_sleep" "bar" { 7 | create_duration = "${random_integer.sleep.result}s" 8 | } -------------------------------------------------------------------------------- /test/many_modules/modules/applications/applications.tf: -------------------------------------------------------------------------------- 1 | module "airflow" { 2 | source = "../airflow" 3 | count = 10 4 | } 5 | 6 | module "dbt" { 7 | source = "../dbt" 8 | count = 5 9 | } -------------------------------------------------------------------------------- /test/many_modules/modules/core_infrastructure/core.tf: -------------------------------------------------------------------------------- 1 | module "role" { 2 | count = 50 3 | source = "../role" 4 | } 5 | 6 | module "security_rule" { 7 | count = 35 8 | source = "../security_rule" 9 | } -------------------------------------------------------------------------------- /test/many_modules/modules/dbt/dbt.tf: -------------------------------------------------------------------------------- 1 | resource "random_integer" "sleep" { 2 | count = 2 3 | min = 1 4 | max = 5 5 | } 6 | 7 | resource "time_sleep" "bar" { 8 | count = 2 9 | create_duration = "${random_integer.sleep[count.index].result}s" 10 | } -------------------------------------------------------------------------------- /test/many_modules/modules/role/role.tf: -------------------------------------------------------------------------------- 1 | resource "random_integer" "sleep" { 2 | min = 1 3 | max = 5 4 | } 5 | 6 | resource "time_sleep" "bar" { 7 | create_duration = "${random_integer.sleep.result}s" 8 | } -------------------------------------------------------------------------------- /test/many_modules/modules/security_rule/rule.tf: -------------------------------------------------------------------------------- 1 | resource "random_integer" "sleep" { 2 | min = 0 3 | max = 5 4 | } 5 | 6 | resource "time_sleep" "bar" { 7 | create_duration = "${random_integer.sleep.result}s" 8 | 9 | provisioner "local-exec" { 10 | command = "if [ ${random_integer.sleep.result} -gt 4 ]; then return 1; fi" 11 | } 12 | } -------------------------------------------------------------------------------- /test/multiple_resources.log: -------------------------------------------------------------------------------- 1 | Plan: 14 to add, 0 to change, 0 to destroy. 2 | time_sleep.count_2: Creating... 3 | time_sleep.count_4: Creating... 4 | time_sleep.count_0: Creating... 5 | time_sleep.for_each_b: Creating... 6 | time_sleep.count_8: Creating... 7 | time_sleep.for_each_a: Creating... 8 | time_sleep.count_1: Creating... 9 | time_sleep.for_each_d: Creating... 10 | time_sleep.count_6: Creating... 11 | time_sleep.count_5: Creating... 12 | time_sleep.count_0: Creation complete after 0s [id=2023-03-14T20:55:49Z] 13 | time_sleep.count_9: Creating... 14 | time_sleep.for_each_a: Creation complete after 1s [id=2023-03-14T20:55:50Z] 15 | time_sleep.count_1: Creation complete after 1s [id=2023-03-14T20:55:50Z] 16 | time_sleep.for_each_c: Creating... 17 | time_sleep.count_3: Creating... 18 | time_sleep.count_2: Creation complete after 2s [id=2023-03-14T20:55:51Z] 19 | time_sleep.for_each_d: Creation complete after 2s [id=2023-03-14T20:55:51Z] 20 | time_sleep.for_each_b: Creation complete after 2s [id=2023-03-14T20:55:51Z] 21 | time_sleep.count_7: Creating... 22 | time_sleep.for_each_c: Creation complete after 1s [id=2023-03-14T20:55:51Z] 23 | time_sleep.count_4: Creation complete after 4s [id=2023-03-14T20:55:53Z] 24 | time_sleep.count_3: Creation complete after 3s [id=2023-03-14T20:55:53Z] 25 | time_sleep.count_5: Creation complete after 5s [id=2023-03-14T20:55:54Z] 26 | time_sleep.count_6: Creation complete after 6s [id=2023-03-14T20:55:55Z] 27 | time_sleep.count_8: Creation complete after 8s [id=2023-03-14T20:55:57Z] 28 | time_sleep.count_9: Creation complete after 10s [id=2023-03-14T20:55:58Z] 29 | time_sleep.count_7: Creation complete after 7s [id=2023-03-14T20:55:58Z] 30 | 31 | Apply complete! Resources: 14 added, 0 changed, 0 destroyed. 32 | -------------------------------------------------------------------------------- /test/multiple_resources/null.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | foreach = { 3 | a = "1s" 4 | b = "2s" 5 | c = "1s" 6 | d = "2s" 7 | } 8 | } 9 | 10 | resource "time_sleep" "count" { 11 | count = 10 12 | create_duration = "${count.index}s" 13 | } 14 | 15 | resource "time_sleep" "for_each" { 16 | for_each = local.foreach 17 | create_duration = each.value 18 | } -------------------------------------------------------------------------------- /test/null_resources.log: -------------------------------------------------------------------------------- 1 | 2 | Terraform used the selected providers to generate the following execution 3 | plan. Resource actions are indicated with the following symbols: 4 | + create 5 | 6 | Terraform will perform the following actions: 7 | 8 | # null_resource.next will be created 9 | + resource "null_resource" "next" { 10 | + id = (known after apply) 11 | } 12 | 13 | # null_resource.previous will be created 14 | + resource "null_resource" "previous" { 15 | + id = (known after apply) 16 | } 17 | 18 | # time_sleep.wait_30_seconds will be created 19 | + resource "time_sleep" "wait_30_seconds" { 20 | + create_duration = "75s" 21 | + id = (known after apply) 22 | } 23 | 24 | Plan: 3 to add, 0 to change, 0 to destroy. 25 | null_resource.previous: Creating... 26 | null_resource.previous: Creation complete after 0s [id=5144705655797302376] 27 | time_sleep.wait_30_seconds: Creating... 28 | time_sleep.wait_30_seconds: Still creating... [10s elapsed] 29 | time_sleep.wait_30_seconds: Still creating... [20s elapsed] 30 | time_sleep.wait_30_seconds: Still creating... [30s elapsed] 31 | time_sleep.wait_30_seconds: Still creating... [40s elapsed] 32 | time_sleep.wait_30_seconds: Still creating... [50s elapsed] 33 | time_sleep.wait_30_seconds: Still creating... [1m0s elapsed] 34 | time_sleep.wait_30_seconds: Still creating... [1m10s elapsed] 35 | time_sleep.wait_30_seconds: Creation complete after 1m15s [id=2023-03-11T20:04:18Z] 36 | null_resource.next: Creating... 37 | null_resource.next: Creation complete after 0s [id=1766626192520212902] 38 | 39 | Apply complete! Resources: 3 added, 0 changed, 0 destroyed. 40 | -------------------------------------------------------------------------------- /test/null_resources/null.tf: -------------------------------------------------------------------------------- 1 | # This resource will destroy (potentially immediately) after null_resource.next 2 | resource "null_resource" "previous" {} 3 | 4 | resource "time_sleep" "wait_30_seconds" { 5 | depends_on = [null_resource.previous] 6 | 7 | create_duration = "75s" 8 | } 9 | 10 | # This resource will create (at least) 30 seconds after null_resource.previous 11 | resource "null_resource" "next" { 12 | depends_on = [time_sleep.wait_30_seconds] 13 | } -------------------------------------------------------------------------------- /test/only_failures.log: -------------------------------------------------------------------------------- 1 | 2 | Terraform used the selected providers to generate the following execution 3 | plan. Resource actions are indicated with the following symbols: 4 | + create 5 | 6 | Terraform will perform the following actions: 7 | 8 | # aws_ssm_parameter.bad will be created 9 | + resource "aws_ssm_parameter" "bad" { 10 | + arn = (known after apply) 11 | + data_type = (known after apply) 12 | + id = (known after apply) 13 | + insecure_value = (known after apply) 14 | + key_id = (known after apply) 15 | + name = "/slash/at/end/" 16 | + tags_all = (known after apply) 17 | + tier = (known after apply) 18 | + type = "String" 19 | + value = (sensitive value) 20 | + version = (known after apply) 21 | } 22 | 23 | # aws_ssm_parameter.bad2[0] will be created 24 | + resource "aws_ssm_parameter" "bad2" { 25 | + arn = (known after apply) 26 | + data_type = (known after apply) 27 | + id = (known after apply) 28 | + insecure_value = (known after apply) 29 | + key_id = (known after apply) 30 | + name = "/slash/at/end0/" 31 | + tags_all = (known after apply) 32 | + tier = (known after apply) 33 | + type = "String" 34 | + value = (sensitive value) 35 | + version = (known after apply) 36 | } 37 | 38 | # aws_ssm_parameter.bad2[1] will be created 39 | + resource "aws_ssm_parameter" "bad2" { 40 | + arn = (known after apply) 41 | + data_type = (known after apply) 42 | + id = (known after apply) 43 | + insecure_value = (known after apply) 44 | + key_id = (known after apply) 45 | + name = "/slash/at/end1/" 46 | + tags_all = (known after apply) 47 | + tier = (known after apply) 48 | + type = "String" 49 | + value = (sensitive value) 50 | + version = (known after apply) 51 | } 52 | 53 | # aws_ssm_parameter.bad2[2] will be created 54 | + resource "aws_ssm_parameter" "bad2" { 55 | + arn = (known after apply) 56 | + data_type = (known after apply) 57 | + id = (known after apply) 58 | + insecure_value = (known after apply) 59 | + key_id = (known after apply) 60 | + name = "/slash/at/end2/" 61 | + tags_all = (known after apply) 62 | + tier = (known after apply) 63 | + type = "String" 64 | + value = (sensitive value) 65 | + version = (known after apply) 66 | } 67 | 68 | 69 | Plan: 4 to add, 0 to change, 0 to destroy. 70 | aws_ssm_parameter.bad2[2]: Creating... 71 | aws_ssm_parameter.bad2[1]: Creating... 72 | aws_ssm_parameter.bad2[0]: Creating... 73 | aws_ssm_parameter.bad: Creating... 74 | 75 | Error: creating SSM Parameter (/slash/at/end/): ValidationException: Parameter name must not end with slash. 76 | status code: 400, request id: 99b72eaf-10ec-49d7-99e4-bc960809383e 77 | 78 | with aws_ssm_parameter.bad, 79 | on provider.tf line 15, in resource "aws_ssm_parameter" "bad": 80 | 15: resource "aws_ssm_parameter" "bad" { 81 | 82 | 83 | Error: creating SSM Parameter (/slash/at/end2/): ValidationException: Parameter name must not end with slash. 84 | status code: 400, request id: 99a3ae5f-9d97-4bc9-bfd8-ac29b72fc00d 85 | 86 | with aws_ssm_parameter.bad2[2], 87 | on provider.tf line 27, in resource "aws_ssm_parameter" "bad2": 88 | 27: resource "aws_ssm_parameter" "bad2" { 89 | 90 | 91 | Error: creating SSM Parameter (/slash/at/end1/): ValidationException: Parameter name must not end with slash. 92 | status code: 400, request id: 77765932-a8b2-48bf-abe2-71a151da56ea 93 | 94 | with aws_ssm_parameter.bad2[1], 95 | on provider.tf line 27, in resource "aws_ssm_parameter" "bad2": 96 | 27: resource "aws_ssm_parameter" "bad2" { 97 | 98 | 99 | Error: creating SSM Parameter (/slash/at/end0/): ValidationException: Parameter name must not end with slash. 100 | status code: 400, request id: f78b2744-2fff-4df9-824b-ba7c40ab256a 101 | 102 | with aws_ssm_parameter.bad2[0], 103 | on provider.tf line 27, in resource "aws_ssm_parameter" "bad2": 104 | 27: resource "aws_ssm_parameter" "bad2" { 105 | 106 | -------------------------------------------------------------------------------- /test/test_file.txt: -------------------------------------------------------------------------------- 1 | Used to test reader module 2 | Another line 3 | Another line2 -------------------------------------------------------------------------------- /tf-profile.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/QuintenBruynseraede/tf-profile/cmd" 5 | ) 6 | 7 | func main() { 8 | cmd.Execute() 9 | } 10 | --------------------------------------------------------------------------------