diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 78bede6..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..560911e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +# ignore .git +.git +.github +docs +example +rover +**/node_modules +**/dist \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..474a6bd --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,30 @@ +name: build + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version-file: go.mod + - name: UI build + run: | + cd ui + npm install + npm run build + - name: Go Build + run: go build diff --git a/.github/workflows/publishDockerImage.yml b/.github/workflows/publishDockerImage.yml new file mode 100644 index 0000000..7aee3eb --- /dev/null +++ b/.github/workflows/publishDockerImage.yml @@ -0,0 +1,37 @@ +name: PublishDockerImage + +# Run whenever the publish job runs +on: + push: + tags: + - "v*" + +jobs: + push_to_registry: + name: Push Docker image to Docker Hub + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v3 + with: + images: im2nguyen/rover + + - name: Build and push Docker image + uses: docker/build-push-action@v2 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: + TF_VERSION=1.1.2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 824feec..81f48e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,19 +19,19 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Unshallow run: git fetch --prune --unshallow - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v4 with: - go-version: 1.16 + go-version-file: go.mod - name: Import GPG key id: import_gpg - uses: hashicorp/ghaction-import-gpg@v2.1.0 - env: - GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} - PASSPHRASE: ${{ secrets.PASSPHRASE }} + uses: crazy-max/ghaction-import-gpg@v5 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.PASSPHRASE }} - name: Run GoReleaser uses: goreleaser/goreleaser-action@v2 with: diff --git a/.gitignore b/.gitignore index 75263fb..2fbb82f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,11 @@ -rover \ No newline at end of file +build/rover +.DS_Store +rover.zip +plan.out + +# Ignore generated terraform files +.terraform** + +.idea/ + +build/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index ad14935..8c0b36c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,44 @@ +# Prep base stage ARG TF_VERSION=light -FROM hashicorp/terraform:$TF_VERSION +# Build ui +FROM node:20-alpine as ui +WORKDIR /src +# Copy specific package files +COPY ./ui/package-lock.json ./ +COPY ./ui/package.json ./ +COPY ./ui/babel.config.js ./ +# Set Progress, Config and install +RUN npm set progress=false && npm config set depth 0 && npm install +# Copy source +# Copy Specific Directories +COPY ./ui/public ./public +COPY ./ui/src ./src +# build (to dist folder) +RUN NODE_OPTIONS='--openssl-legacy-provider' npm run build -RUN cp /bin/terraform /usr/local/bin/terraform +# Build rover +FROM golang:1.21 AS rover +WORKDIR /src +# Copy full source +COPY . . +# Copy ui/dist from ui stage as it needs to embedded +COPY --from=ui ./src/dist ./ui/dist +# Build rover +RUN go get -d -v golang.org/x/net/html +RUN CGO_ENABLED=0 GOOS=linux go build -o rover . -COPY ./rover /bin/rover +# Release stage +FROM hashicorp/terraform:$TF_VERSION AS release +# Copy terraform binary to the rover's default terraform path +RUN cp /bin/terraform /usr/local/bin/terraform +# Copy rover binary +COPY --from=rover /src/rover /bin/rover RUN chmod +x /bin/rover +# Install Google Chrome +RUN apk add chromium + WORKDIR /src ENTRYPOINT [ "/bin/rover" ] - -# CMD ["rover"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..468195b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 rover + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 54aaa0d..dd4c6db 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ ## Rover - Terraform Visualizer -Rover is a [Terraform](http://terraform.io/) visualizer. +Rover is a [Terraform](http://terraform.io/) visualizer. In order to do this, Rover: -1. generates a [`plan`](https://www.terraform.io/docs/cli/commands/plan.html#out-filename) file and parses the configuration in the root directory. +1. generates a [`plan`](https://www.terraform.io/docs/cli/commands/plan.html#out-filename) file and parses the configuration in the root directory or uses a provided plan. 1. parses the `plan` and configuration files to generate three items: the resource overview (`rso`), the resource map (`map`), and the resource graph (`graph`). -1. consumes the `rso`, `map`, and `graph` to generate an interactive configuration and state visualization hosts on `localhost:9000`. +1. consumes the `rso`, `map`, and `graph` to generate an interactive configuration and state visualization hosts on `0.0.0.0:9000`. -Feedback (via issues) and pull requests are appreciated! +Feedback (via issues) and pull requests are appreciated!  @@ -28,12 +28,41 @@ $ docker run --rm -it -p 9000:9000 -v $(pwd):/src im2nguyen/rover 2021/07/02 06:46:25 Generating resource map... 2021/07/02 06:46:25 Generating resource graph... 2021/07/02 06:46:25 Done generating assets. -2021/07/02 06:46:25 Rover is running on localhost:9000 +2021/07/02 06:46:25 Rover is running on 0.0.0.0:9000 ``` -Once Rover runs on `localhost:9000`, navigate to it to find the visualization! +Once Rover runs on `0.0.0.0:9000`, navigate to it to find the visualization! -Use `--env` or `--env-file` to set environment variables in the Docker container. For example, you can save your AWS credentials to an `.env` file. +### Run on Terraform plan file + +Use `-planJSONPath` to start Rover on Terraform plan file. The `plan.json` file should be in Linux version - Unix (LF), UTF-8. + +First, generate the plan file in JSON format. + +``` +$ terraform plan -out plan.out +$ terraform show -json plan.out > plan.json +``` + +Then, run Rover on it. + +``` +$ docker run --rm -it -p 9000:9000 -v $(pwd)/plan.json:/src/plan.json im2nguyen/rover:latest -planJSONPath=plan.json +``` + +### Standalone mode + +Standalone mode generates a `rover.zip` file containing all the static assets. + +``` +$ docker run --rm -it -p 9000:9000 -v "$(pwd):/src" im2nguyen/rover -standalone true +``` + +After all the assets are generated, unzip `rover.zip` and open `rover/index.html` in your favourite web browser. + +### Set environment variables + +Use `--env` or `--env-file` to set environment variables in the Docker container. For example, you can save your AWS credentials to a `.env` file. ``` $ printenv | grep "AWS" > .env @@ -42,19 +71,36 @@ $ printenv | grep "AWS" > .env Then, add it as environment variables to your Docker container with `--env-file`. ``` -$ docker run --rm -it -p 9000:9000 -v $(pwd):/src --env-file ./.env im2nguyen/rover +$ docker run --rm -it -p 9000:9000 -v "$(pwd):/src" --env-file ./.env im2nguyen/rover +``` + +### Define tfbackend, tfvars and Terraform variables + +Use `-tfBackendConfig` to define backend config files and `-tfVarsFile` or `-tfVar` to define variables. For example, you can run the following in the `example/random-test` directory to overload variables. + +``` +$ docker run --rm -it -p 9000:9000 -v "$(pwd):/src" im2nguyen/rover -tfBackendConfig test.tfbackend -tfVarsFile test.tfvars -tfVar max_length=4 +``` + +### Image generation + +Use `-genImage` to generate and save the visualization as a SVG image. + +``` +$ docker run --rm -it -v "$(pwd):/src" im2nguyen/rover -genImage true ``` ## Installation You can download Rover binary specific to your system by visiting the [Releases page](https://github.com/im2nguyen/rover/releases). Download the binary, unzip, then move `rover` into your `PATH`. -- [rover zip — MacOS](https://github.com/im2nguyen/rover/releases/download/v0.1.3/rover_0.1.3_darwin_amd64.zip) -- [rover zip — Windows](https://github.com/im2nguyen/rover/releases/download/v0.1.3/rover_0.1.3_windows_amd64.zip) +- [rover zip — MacOS - intel](https://github.com/im2nguyen/rover/releases/download/v0.3.2/rover_0.3.2_darwin_amd64.zip) +- [rover zip — MacOS - Apple Silicon](https://github.com/im2nguyen/rover/releases/download/v0.3.2/rover_0.3.2_darwin_arm64.zip) +- [rover zip — Windows](https://github.com/im2nguyen/rover/releases/download/v0.3.2/rover_0.3.2_windows_amd64.zip) ### Build from source -You can build Rover manually by cloning this repository, then building the frontend and compiling the binary. It requires Go v1.16+ and `npm`. +You can build Rover manually by cloning this repository, then building the frontend and compiling the binary. It requires Go v1.21+ and `npm`. #### Build frontend @@ -101,13 +147,13 @@ $ env GOOS=linux GOARCH=amd64 go build . Then, build the Docker image. ``` -$ docker build . -t im2nguyen/rover +$ docker build . -t im2nguyen/rover --no-cache ``` ## Basic usage -This repository contains two example Terraform configurations in `example`. +This repository contains two examples of Terraform configurations in `example`. Navigate into `random-test` example configuration. This directory contains configuration that showcases a wide variety of features common in Terraform (modules, count, output, locals, etc) with the [`random`](https://registry.terraform.io/providers/hashicorp/random/latest) provider. @@ -127,7 +173,7 @@ $ rover 2021/06/23 22:51:28 Generating resource map... 2021/06/23 22:51:28 Generating resource graph... 2021/06/23 22:51:28 Done generating assets. -2021/06/23 22:51:28 Rover is running on localhost:9000 +2021/06/23 22:51:28 Rover is running on 0.0.0.0:9000 ``` You can specify the working directory (where your configuration is living) and the Terraform binary location using flags. @@ -136,4 +182,4 @@ You can specify the working directory (where your configuration is living) and t $ rover -workingDir "example/eks-cluster" -tfPath "/Users/dos/terraform" ``` -Once Rover runs on `localhost:9000`, navigate to it to find the visualization! \ No newline at end of file +Once Rover runs on `0.0.0.0:9000`, navigate to it to find the visualization! diff --git a/example/.DS_Store b/example/.DS_Store deleted file mode 100644 index 0401edf..0000000 Binary files a/example/.DS_Store and /dev/null differ diff --git a/example/eks-cluster b/example/eks-cluster deleted file mode 160000 index 434cd8a..0000000 --- a/example/eks-cluster +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 434cd8a3b387598b0534bc7efb3235fe1f6628e8 diff --git a/example/multiple-files-same-resource-type-test/file-one.tf b/example/multiple-files-same-resource-type-test/file-one.tf new file mode 100644 index 0000000..4886113 --- /dev/null +++ b/example/multiple-files-same-resource-type-test/file-one.tf @@ -0,0 +1,4 @@ +resource "random_integer" "one" { + min = 1 + max = 3 +} \ No newline at end of file diff --git a/example/multiple-files-same-resource-type-test/file-two.tf b/example/multiple-files-same-resource-type-test/file-two.tf new file mode 100644 index 0000000..2ed8e73 --- /dev/null +++ b/example/multiple-files-same-resource-type-test/file-two.tf @@ -0,0 +1,4 @@ +resource "random_integer" "two" { + min = 1 + max = 4 +} \ No newline at end of file diff --git a/example/nested-test/main.tf b/example/nested-test/main.tf new file mode 100644 index 0000000..03ef04c --- /dev/null +++ b/example/nested-test/main.tf @@ -0,0 +1,4 @@ +module "sub_module" { + source = "./nested-module" + +} \ No newline at end of file diff --git a/example/nested-test/nested-module/main.tf b/example/nested-test/nested-module/main.tf new file mode 100644 index 0000000..6df9bf5 --- /dev/null +++ b/example/nested-test/nested-module/main.tf @@ -0,0 +1,6 @@ +module "remote_module" { + source = "git::https://github.com/im2nguyen/rover.git//example/random-test/random-name" + + max_length = "3" + +} \ No newline at end of file diff --git a/example/random-test/.terraform.lock.hcl b/example/random-test/.terraform.lock.hcl index f1ae799..b23f899 100644 --- a/example/random-test/.terraform.lock.hcl +++ b/example/random-test/.terraform.lock.hcl @@ -1,12 +1,33 @@ # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. +provider "registry.terraform.io/hashicorp/http" { + version = "2.1.0" + hashes = [ + "h1:HmUcHqc59VeHReHD2SEhnLVQPUKHKTipJ8Jxq67GiDU=", + "h1:OaCZWFiSj1Rts5300dC2fzCM56SSWqq0aQP3YH6QECE=", + "h1:SE5ufHNXfkUaY6ZLLW742Pasb/Jx/Y/lUwoYS3XbElA=", + "zh:03d82dc0887d755b8406697b1d27506bc9f86f93b3e9b4d26e0679d96b802826", + "zh:0704d02926393ddc0cfad0b87c3d51eafeeae5f9e27cc71e193c141079244a22", + "zh:095ea350ea94973e043dad2394f10bca4a4bf41be775ba59d19961d39141d150", + "zh:0b71ac44e87d6964ace82979fc3cbb09eb876ed8f954449481bcaa969ba29cb7", + "zh:0e255a170db598bd1142c396cefc59712ad6d4e1b0e08a840356a371e7b73bc4", + "zh:67c8091cfad226218c472c04881edf236db8f2dc149dc5ada878a1cd3c1de171", + "zh:75df05e25d14b5101d4bc6624ac4a01bb17af0263c9e8a740e739f8938b86ee3", + "zh:b4e36b2c4f33fdc44bf55fa1c9bb6864b5b77822f444bd56f0be7e9476674d0e", + "zh:b9b36b01d2ec4771838743517bc5f24ea27976634987c6d5529ac4223e44365d", + "zh:ca264a916e42e221fddb98d640148b12e42116046454b39ede99a77fc52f59f4", + "zh:fe373b2fb2cc94777a91ecd7ac5372e699748c455f44f6ea27e494de9e5e6f92", + ] +} + provider "registry.terraform.io/hashicorp/random" { version = "3.1.0" constraints = "3.1.0" hashes = [ + "h1:9cCiLO/Cqr6IUvMDSApCkQItooiYNatZpEXmcu0nnng=", "h1:BZMEPucF+pbu9gsPk0G0BHx7YP04+tKdq2MrRDF1EDM=", - "h1:rKYu5ZUbXwrLG1w81k7H3nce/Ys6yAxXhWcbtk36HjY=", + "h1:EPIax4Ftp2SNdB9pUfoSjxoueDoLc/Ck3EUoeX0Dvsg=", "zh:2bbb3339f0643b5daa07480ef4397bd23a79963cc364cdfbb4e86354cb7725bc", "zh:3cd456047805bf639fbf2c761b1848880ea703a054f76db51852008b11008626", "zh:4f251b0eda5bb5e3dc26ea4400dba200018213654b69b4a5f96abee815b4f5ff", diff --git a/example/random-test/main.tf b/example/random-test/main.tf index 8eab3ed..afc0141 100644 --- a/example/random-test/main.tf +++ b/example/random-test/main.tf @@ -11,6 +11,7 @@ provider "random" {} variable "max_length" { default = 5 + sensitive = false } resource "random_integer" "pet_length" { @@ -36,6 +37,10 @@ resource "random_pet" "dogs" { length = random_integer.pet_length.result } +resource "random_pet" "cow" { + length = random_integer.pet_length.result +} + module "random_cat" { source = "./random-name" @@ -45,6 +50,12 @@ module "random_cat" { output "random_cat_name" { description = "random_cat_name" value = module.random_cat.random_name + sensitive = true +} + +output "random_cow_name" { + description = "random_cow_name" + value = random_pet.cow.id } resource "random_pet" "birds" { @@ -56,4 +67,18 @@ resource "random_pet" "birds" { prefix = each.key length = each.value +} + +data "http" "terraform_metadata" { + url = "https://checkpoint-api.hashicorp.com/v1/check/terraform" + + # Optional request headers + request_headers = { + Accept = "application/json" + } +} + +output "terraform_metadata" { + description = "Terraform metadata" + value = data.http.terraform_metadata.body } \ No newline at end of file diff --git a/example/random-test/test.tfvars b/example/random-test/test.tfvars new file mode 100644 index 0000000..23d13f3 --- /dev/null +++ b/example/random-test/test.tfvars @@ -0,0 +1 @@ +max_length = 3 \ No newline at end of file diff --git a/example/simple-test/main.tf b/example/simple-test/main.tf new file mode 100644 index 0000000..fbd7bc7 --- /dev/null +++ b/example/simple-test/main.tf @@ -0,0 +1,3 @@ +output "hello_world" { + value = "Hello, World!" +} \ No newline at end of file diff --git a/go.mod b/go.mod index a06c44b..60ff13c 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,41 @@ module rover -go 1.16 +go 1.21 require ( + github.com/chromedp/cdproto v0.0.0-20211205231339-d2673e93eee4 + github.com/chromedp/chromedp v0.7.6 github.com/hashicorp/terraform-config-inspect v0.0.0-20210511202847-ad33d83d7650 - github.com/hashicorp/terraform-exec v0.13.3 - github.com/hashicorp/terraform-json v0.12.0 + github.com/hashicorp/terraform-exec v0.15.0 + github.com/hashicorp/terraform-json v0.13.0 + golang.org/x/net v0.0.0-20210924151903-3ad01bbaa167 // indirect +) + +require github.com/hashicorp/go-tfe v0.20.0 + +require ( + github.com/agext/levenshtein v1.2.2 // indirect + github.com/apparentlymart/go-textseg v1.0.0 // indirect + github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect + github.com/chromedp/sysutil v1.0.0 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.1.0 // indirect + github.com/golang/mock v1.6.0 // indirect + github.com/google/go-cmp v0.5.6 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.0 // indirect + github.com/hashicorp/go-slug v0.7.0 // indirect + github.com/hashicorp/go-version v1.3.0 // indirect + github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f // indirect + github.com/hashicorp/hcl/v2 v2.0.0 // indirect + github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mitchellh/go-wordwrap v1.0.0 // indirect + github.com/zclconf/go-cty v1.9.1 // indirect + golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect + golang.org/x/text v0.3.6 // indirect + golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect ) diff --git a/go.sum b/go.sum index 3cbae76..e920738 100644 --- a/go.sum +++ b/go.sum @@ -15,10 +15,13 @@ github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuN github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= github.com/Microsoft/go-winio v0.4.16 h1:FtSW/jqD+l4ba5iPBj9CODVtgfYAD8w2wS923g/cFDk= github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= +github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 h1:YoJbenK9C67SkzkDfmQuVln04ygHj3vjZfd9FL+GmQQ= +github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo= +github.com/acomagu/bufpipe v1.0.3 h1:fxAGrHZTgQ9w5QqVItgzwj235/uYZYgbXitB+dLupOk= +github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/agext/levenshtein v1.2.2 h1:0S/Yg6LYmFJ5stwQeRp6EeOcCbj7xiqQSdNelsXvaqE= github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= -github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= github.com/andybalholm/crlf v0.0.0-20171020200849-670099aa064f/go.mod h1:k8feO4+kXDxro6ErPXBRTJ/ro2mf0SsFG8s7doP9kJE= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM= @@ -34,6 +37,13 @@ github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1U github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/cheggaaa/pb v1.0.27/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s= +github.com/chromedp/cdproto v0.0.0-20211126220118-81fa0469ad77/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U= +github.com/chromedp/cdproto v0.0.0-20211205231339-d2673e93eee4 h1:St4rQbn3gGWL59ygb4NBxchIeAIW0CTz5Kw4m5JTemU= +github.com/chromedp/cdproto v0.0.0-20211205231339-d2673e93eee4/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U= +github.com/chromedp/chromedp v0.7.6 h1:2juGaktzjwULlsn+DnvIZXFUckEp5xs+GOBroaea+jA= +github.com/chromedp/chromedp v0.7.6/go.mod h1:ayT4YU/MGAALNfOg9gNrpGSAdnU51PMx+FCeuT1iXzo= +github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= +github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -46,18 +56,26 @@ github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4= github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= -github.com/go-git/go-billy/v5 v5.0.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= -github.com/go-git/go-billy/v5 v5.1.0 h1:4pl5BV4o7ZG/lterP4S6WzJ6xr49Ba5ET9ygheTYahk= -github.com/go-git/go-billy/v5 v5.1.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= -github.com/go-git/go-git-fixtures/v4 v4.0.2-0.20200613231340-f56387b50c12/go.mod h1:m+ICp2rF3jDhFgEZ/8yziagdT1C+ZpZcrJjappBCDSw= -github.com/go-git/go-git/v5 v5.3.0 h1:8WKMtJR2j8RntEXR/uvTKagfEt4GYlwQ7mntE4+0GWc= -github.com/go-git/go-git/v5 v5.3.0/go.mod h1:xdX4bWJ48aOrdhnl2XqHYstHbbp6+LFS4r4X+lNVprw= +github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= +github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Aiu34= +github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= +github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0= +github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4= +github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA= +github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -69,8 +87,11 @@ github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= @@ -82,15 +103,25 @@ github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU= github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-getter v1.5.3 h1:NF5+zOlQegim+w/EUhSLh6QhXHmZMEeHLQzllkQ3ROU= github.com/hashicorp/go-getter v1.5.3/go.mod h1:BrrV/1clo8cCYu6mxvboYg+KutTiFnXjMEgDD8+i7ZI= +github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-retryablehttp v0.7.0 h1:eu1EI/mbirUgP5C8hVsTNaGZreBDlYiwC1FZWkvQPQ4= +github.com/hashicorp/go-retryablehttp v0.7.0/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= -github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM= +github.com/hashicorp/go-slug v0.7.0 h1:8HIi6oreWPtnhpYd8lIGQBgp4rXzDWQTOhfILZm+nok= +github.com/hashicorp/go-slug v0.7.0/go.mod h1:Ib+IWBYfEfJGI1ZyXMGNbu2BU+aa3Dzu41RKLH301v4= +github.com/hashicorp/go-tfe v0.20.0 h1:XUAhKoCX8ZUQfwBebC8hz7nkSSnqgNkaablIfxnZ0PQ= +github.com/hashicorp/go-tfe v0.20.0/go.mod h1:gyXLXbpBVxA2F/6opah8XBsOkZJxHYQmghl0OWi8keI= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.3.0 h1:McDWVJIU/y+u1BRV06dPaLfLCaT7fUTJLp5r04x7iNw= github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= @@ -101,14 +132,15 @@ github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f h1:UdxlrJz4JOnY8W+Db github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w= github.com/hashicorp/hcl/v2 v2.0.0 h1:efQznTz+ydmQXq3BOnRa3AXzvCeTq1P4dKj/z5GLlY8= github.com/hashicorp/hcl/v2 v2.0.0/go.mod h1:oVVDG71tEinNGYCxinCYadcmKU9bglqW9pV3txagJ90= +github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d h1:9ARUJJ1VVynB176G1HCwleORqCaXm/Vx0uUi0dL26I0= +github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d/go.mod h1:Yog5+CPEM3c99L1CL2CFCYoSzgWm5vTU58idbRUaLik= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/terraform-config-inspect v0.0.0-20210511202847-ad33d83d7650 h1:0TEFM00EMM31qUcOh950Ox7piRLkSORB38i+rYgRr9w= github.com/hashicorp/terraform-config-inspect v0.0.0-20210511202847-ad33d83d7650/go.mod h1:Z0Nnk4+3Cy89smEbrq+sl1bxc9198gIP4I7wcQF6Kqs= -github.com/hashicorp/terraform-exec v0.13.3 h1:R6L2mNpDGSEqtLrSONN8Xth0xYwNrnEVzDz6LF/oJPk= -github.com/hashicorp/terraform-exec v0.13.3/go.mod h1:SSg6lbUsVB3DmFyCPjBPklqf6EYGX0TlQ6QTxOlikDU= -github.com/hashicorp/terraform-json v0.10.0/go.mod h1:3defM4kkMfttwiE7VakJDwCd4R+umhSQnvJwORXbprE= -github.com/hashicorp/terraform-json v0.12.0 h1:8czPgEEWWPROStjkWPUnTQDXmpmZPlkQAwYYLETaTvw= -github.com/hashicorp/terraform-json v0.12.0/go.mod h1:pmbq9o4EuL43db5+0ogX10Yofv1nozM+wskr/bGFJpI= +github.com/hashicorp/terraform-exec v0.15.0 h1:cqjh4d8HYNQrDoEmlSGelHmg2DYDh5yayckvJ5bV18E= +github.com/hashicorp/terraform-exec v0.15.0/go.mod h1:H4IG8ZxanU+NW0ZpDRNsvh9f0ul7C0nHP+rUR/CHs7I= +github.com/hashicorp/terraform-json v0.13.0 h1:Li9L+lKD1FO5RVFRM1mMMIBDoUHslOniyEi5CM+FWGY= +github.com/hashicorp/terraform-json v0.13.0/go.mod h1:y5OdLBCT+rxbwnpxZs9kGL7R9ExU76+cpdY8zHwoazk= github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= @@ -118,6 +150,8 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8 h1:12VvqtR6Aowv3l/EQUlocDHW2Cp4G9WJVH7uyH8QFJE= github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 h1:DowS9hvgyYSX4TO5NpyC606/Z4SxnNYbT+WX27or6Ck= github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= @@ -134,6 +168,9 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= @@ -152,8 +189,11 @@ github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUb github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/orisano/pixelmatch v0.0.0-20210112091706-4fa4c7ba91d5 h1:1SoBaSPudixRecmlHXb/GxmaD3fLMtHIDN13QujwQuc= +github.com/orisano/pixelmatch v0.0.0-20210112091706-4fa4c7ba91d5/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/sebdah/goldie v1.0.0/go.mod h1:jXP4hmWywNEwZzhMuv2ccnqTSFpuq8iyQhtQdkkZBH4= @@ -170,6 +210,7 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/ulikunitz/xz v0.5.8 h1:ERv8V6GKqVi23rgu5cj9pVfVzJbOqAY2Ntl88O6c2nQ= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= @@ -178,11 +219,11 @@ github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+ github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI= github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/zclconf/go-cty v1.1.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s= github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8= -github.com/zclconf/go-cty v1.2.1/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8= -github.com/zclconf/go-cty v1.8.2 h1:u+xZfBKgpycDnTNjPhGiTEYZS5qS/Sb5MqSfm7vzcjg= -github.com/zclconf/go-cty v1.8.2/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= +github.com/zclconf/go-cty v1.9.1 h1:viqrgQwFl5UpSxc046qblj78wZXVDFnSOufaOTER+cc= +github.com/zclconf/go-cty v1.9.1/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0 h1:C9hSCOW830chIVkdja34wa6Ky+IzWllkUinR+BtRZd4= @@ -191,9 +232,11 @@ golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w= golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= @@ -203,6 +246,7 @@ golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTk golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -216,8 +260,10 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210326060303-6b1517762897 h1:KrsHThm5nFk34YtATK1LsThyGhGbGe1olrte/HInHvs= golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210924151903-3ad01bbaa167 h1:eDd+TJqbgfXruGQ5sJRU7tEtp/58OAx4+Ayjxg4SM+4= +golang.org/x/net v0.0.0-20210924151903-3ad01bbaa167/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= @@ -227,6 +273,7 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -240,18 +287,27 @@ golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210324051608-47abb6519492 h1:Paq34FxTluEPvVyayQqMPgHm+vTOrIifmcYxFBx9TLg= golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs= +golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -262,8 +318,13 @@ golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBn golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= @@ -298,6 +359,7 @@ gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRN gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/graph.go b/graph.go index 533b15f..2490d1f 100644 --- a/graph.go +++ b/graph.go @@ -2,6 +2,8 @@ package main import ( "fmt" + "log" + "regexp" "strings" tfjson "github.com/hashicorp/terraform-json" @@ -14,7 +16,7 @@ const ( MODULE_COLOR string = "#8450ba" MODULE_BG_COLOR string = "white" FNAME_BG_COLOR string = "white" - RESOURCE_COLOR string = "#8450ba" + RESOURCE_COLOR string = "lightgray" LOCAL_COLOR string = "black" ) @@ -32,12 +34,12 @@ type Node struct { // NodeData TODO type NodeData struct { - ID string `json:"id"` - Label string `json:"label,omitempty"` - Type string `json:"type,omitempty"` - Parent string `json:"parent,omitempty"` - ParentColor string `json:"parentColor,omitempty"` - Change string `json:"change,omitempty"` + ID string `json:"id"` + Label string `json:"label,omitempty"` + Type ResourceType `json:"type,omitempty"` + Parent string `json:"parent,omitempty"` + ParentColor string `json:"parentColor,omitempty"` + Change string `json:"change,omitempty"` } // Edge TODO @@ -55,235 +57,181 @@ type EdgeData struct { } // GenerateGraph - -func GenerateGraph(plan *tfjson.Plan, mapDM *Map) Graph { - graph := Graph{} +func (r *rover) GenerateGraph() error { + log.Println("Generating resource graph...") - graph.Nodes = GenerateNodes(plan, mapDM) - graph.Edges = GenerateEdges(plan) + nodes := r.GenerateNodes() + edges := r.GenerateEdges() - return graph -} - -// GenerateNodes - -func GenerateNodes(plan *tfjson.Plan, mapDM *Map) []Node { - nodeMap := make(map[string]Node) - nmo := []string{} - - moduleMap := make(map[string]string) - - basePath := strings.ReplaceAll(mapDM.Path, "./", "") - - nmo = append(nmo, basePath) - nodeMap[basePath] = Node{ - Data: NodeData{ - ID: basePath, - Label: basePath, - Type: "basename", - }, - Classes: "basename", - } - - // mrChange := make(map[string]string) - - for file := range mapDM.Files { - // remove suffix and file path - fname := strings.ReplaceAll(file, fmt.Sprintf("%s/", basePath), "") - - nmo = append(nmo, fname) - nodeMap[fname] = Node{ - Data: NodeData{ - ID: fname, - Label: fname, - Type: "fname", - Parent: basePath, - }, - Classes: "fname", + // Edge case for terraform.workspace + for _, e := range edges { + if strings.Contains(e.Data.ID, "terraform.workspace") { + nodes = append(nodes, Node{ + Data: NodeData{ + ID: "terraform.workspace", + Label: "terraform.workspace", + Type: "locals", + // Parent is equal to basePath + Parent: strings.ReplaceAll(r.Map.Path, "./", ""), + }, + Classes: "locals", + }) + break } + } - for id, rdata := range mapDM.Files[file] { - rid := strings.Split(id, ".") + r.Graph = Graph{ + Nodes: nodes, + Edges: edges, + } - rtype := getPrimitiveType(rid[0]) + return nil +} - switch rtype { - case "module": - moduleMap[id] = fname +func (r *rover) addNodes(base string, parent string, nodeMap map[string]Node, resources map[string]*Resource) []string { - for id, crdata := range rdata.Children { - // cID := strings.TrimRight(id, fmt.Sprintf(".%s", crdata.Name)) + nmo := []string{} - if string(crdata.ChangeAction) != "" { - nodeMap[id] = Node{ - Data: NodeData{ - ID: id, - Change: string(crdata.ChangeAction), - }, - } - } - } + for id, re := range resources { - // You don't want to parse module because it will - // parse the module configuration and display everything - // even unused resources/data sources which may be confusing + if re.Type == ResourceTypeResource || re.Type == ResourceTypeData { - // nm, tempNmo := parseModule(rid[0], fname, id, rdata) + pid := parent - // for _, i := range tempNmo { - // nmo = append(nmo, i) - // nodeMap[i] = nm[i] - // } - case "var": - nm := parseVariable(id, fname, id, rdata) + if nodeMap[parent].Data.Type == ResourceTypeFile { + pid = strings.TrimSuffix(pid, nodeMap[parent].Data.Label) + pid = strings.TrimSuffix(pid, ".") + //qfmt.Printf("%v\n", pid) + } - for k, v := range nm { - nmo = append(nmo, k) - nodeMap[k] = v - } - case "output": - nm := parseOutput(fname, id) + mid := fmt.Sprintf("%v.%v", pid, re.ResourceType) + mid = strings.TrimPrefix(mid, fmt.Sprintf("%v.", base)) + mid = strings.TrimPrefix(mid, ".") + mid = strings.TrimSuffix(mid, ".") - for k, v := range nm { - nmo = append(nmo, k) - nodeMap[k] = v - } - case "data": - nm, tempNmo := parseData(rid[1], rid[1], fname, id, rdata) + l := strings.Split(mid, ".") + label := l[len(l)-1] - for _, i := range tempNmo { - nmo = append(nmo, i) - nodeMap[i] = nm[i] - } - default: - nm, tempNmo := parseResource(rid[0], rid[0], fname, id, rdata) + midParent := parent - for _, i := range tempNmo { - nmo = append(nmo, i) - nodeMap[i] = nm[i] - } + if midParent == mid { + midParent = nodeMap[midParent].Data.Parent } - } - - } - // Go through all module calls, add module resources - planned := plan.PlannedValues.RootModule - for _, module := range planned.ChildModules { - fname := moduleMap[module.Address] - - // Append resource name - nmo = append(nmo, module.Address) - nodeMap[module.Address] = Node{ - Data: NodeData{ - ID: module.Address, - Label: strings.TrimPrefix(module.Address, "module."), - Type: "module", - Parent: fname, - }, - Classes: "module", - } - - for _, mr := range module.Resources { - resourceNameSuffix := "name" + if nodeMap[midParent].Data.Type == ResourceTypeFile { + mid = fmt.Sprintf("%s {%s}", mid, nodeMap[parent].Data.Label) + } - mid := fmt.Sprintf("%s.%s", module.Address, mr.Type) + //fmt.Printf(midParent + " - " + mid + "\n") // Append resource type nmo = append(nmo, mid) nodeMap[mid] = Node{ Data: NodeData{ ID: mid, - Label: mr.Type, - Type: "resource", - Parent: module.Address, - ParentColor: getResourceColor(module.Address), + Label: label, + Type: re.Type, + Parent: midParent, + ParentColor: getResourceColor(nodeMap[parent].Data.Type), }, - Classes: "resource-type", + Classes: fmt.Sprintf("%s-type", re.Type), } - mrChange := string(ActionNoop) - if _, ok := nodeMap[mr.Address]; ok { - mrChange = string(nodeMap[mr.Address].Data.Change) - - // Delete old entry, for some reason - // delete(nodeMap, mr.Address) - // } else { - } + mrChange := string(re.ChangeAction) // Append resource name - nmo = append(nmo, mr.Address) - nodeMap[mr.Address] = Node{ + nmo = append(nmo, id) + nodeMap[id] = Node{ Data: NodeData{ - ID: mr.Address, - Label: mr.Name, - Type: getPrimitiveType(mr.Type), + ID: id, + Label: re.Name, + Type: re.Type, Parent: mid, - ParentColor: getResourceColor(module.Address), + ParentColor: getResourceColor(nodeMap[parent].Data.Type), Change: mrChange, }, - Classes: fmt.Sprintf("resource-%s %s", resourceNameSuffix, mrChange), + Classes: fmt.Sprintf("%s-name %s", re.Type, mrChange), } - } - } + //fmt.Printf(id + " - " + mid + "\n") - // Get module outputs - config := plan.Config.RootModule - // for mName, mValue := range config.ModuleCalls { - // if mValue.Module != nil { - // mid := fmt.Sprintf("module.%s", mName) - // for oName, _ := range mValue.Module.Outputs { - // oid := fmt.Sprintf("module.%s.%s", mName, oName) - - // nm := parseOutput(oid, mid, oid) - - // for k, v := range nm { - // nmo = append(nmo, k) - // nodeMap[k] = v - // } - // } - // } - // } - - // Check for locals - for _, v := range config.Outputs { - if v.Expression != nil { - for _, dependsOnR := range v.Expression.References { - if strings.HasPrefix(dependsOnR, "local.") { - // Append local variable - nmo = append(nmo, dependsOnR) - nodeMap[dependsOnR] = Node{ - Data: NodeData{ - ID: dependsOnR, - Label: strings.TrimPrefix(dependsOnR, "local."), - Type: "locals", - Parent: basePath, - }, - Classes: "locals", - } - } + nmo = append(nmo, r.addNodes(base, id, nodeMap, re.Children)...) + + } else if re.Type == ResourceTypeFile { + fid := id + if parent != base { + fid = fmt.Sprintf("%s.%s", parent, fid) } - } - } - for _, r := range config.Resources { - // fmt.Printf("%+v - %+v\n", oName, oValue) - for _, reValues := range r.Expressions { - for _, dependsOnR := range reValues.References { - if strings.HasPrefix(dependsOnR, "local.") { - // Append local variable - nmo = append(nmo, dependsOnR) - nodeMap[dependsOnR] = Node{ - Data: NodeData{ - ID: dependsOnR, - Label: strings.TrimPrefix(dependsOnR, "local."), - Type: "locals", - Parent: basePath, - }, - Classes: "locals", - } - } + //fmt.Printf("%v\n", fid) + nmo = append(nmo, fid) + nodeMap[fid] = Node{ + Data: NodeData{ + ID: fid, + Label: id, + Type: re.Type, + Parent: parent, + ParentColor: getResourceColor(nodeMap[parent].Data.Type), + }, + + Classes: getResourceClass(re.Type), + } + nmo = append(nmo, r.addNodes(base, fid, nodeMap, re.Children)...) + } else { + + pid := parent + + if nodeMap[parent].Data.Type == ResourceTypeFile { + pid = strings.TrimSuffix(pid, nodeMap[parent].Data.Label) + pid = strings.TrimSuffix(pid, ".") + } + + ls := strings.Split(id, ".") + label := ls[len(ls)-1] + + //fmt.Printf("%v - %v\n", id, re.Type) + + nmo = append(nmo, id) + nodeMap[id] = Node{ + Data: NodeData{ + ID: id, + Label: label, + Type: re.Type, + Parent: parent, + ParentColor: getResourceColor(nodeMap[pid].Data.Type), + }, + + Classes: getResourceClass(re.Type), } + + nmo = append(nmo, r.addNodes(base, id, nodeMap, re.Children)...) + } + } + return nmo + +} + +// GenerateNodes - +func (r *rover) GenerateNodes() []Node { + + nodeMap := make(map[string]Node) + nmo := []string{} + + basePath := strings.ReplaceAll(r.Map.Path, "./", "") + + nmo = append(nmo, basePath) + nodeMap[basePath] = Node{ + Data: NodeData{ + ID: basePath, + Label: basePath, + Type: "basename", + }, + Classes: "basename", + } + + nmo = append(nmo, r.addNodes(basePath, basePath, nodeMap, r.Map.Root)...) + nodes := make([]Node, 0, len(nodeMap)) exists := make(map[string]bool) @@ -297,159 +245,100 @@ func GenerateNodes(plan *tfjson.Plan, mapDM *Map) []Node { return nodes } -// GenerateEdges - -func GenerateEdges(plan *tfjson.Plan) []Edge { - edgeMap := make(map[string]Edge) +func (r *rover) addEdges(base string, parent string, edgeMap map[string]Edge, resources map[string]*Resource) []string { emo := []string{} - - config := plan.Config.RootModule - - // Loop through outputs - for oName, oValue := range config.Outputs { - // fmt.Printf("%+v - %+v\n", oName, oValue) - if oValue.Expression != nil { - oid := fmt.Sprintf("output.%s", oName) - for _, dependsOnR := range oValue.Expression.References { - // ignore each. - if !strings.HasPrefix(dependsOnR, "each.") { - if strings.HasPrefix(dependsOnR, "module.") { - id := strings.Split(dependsOnR, ".") - dependsOnR = fmt.Sprintf("%s.%s", id[0], id[1]) - } - id := fmt.Sprintf("%s->%s", oid, dependsOnR) - - targetType := RESOURCE_COLOR - - if strings.HasPrefix(dependsOnR, "output.") { - targetType = OUTPUT_COLOR - } else if strings.HasPrefix(dependsOnR, "var.") { - targetType = VARIABLE_COLOR - } else if strings.HasPrefix(dependsOnR, "module.") { - targetType = MODULE_COLOR - } else if strings.HasPrefix(dependsOnR, "data.") { - targetType = DATA_COLOR - } else if strings.HasPrefix(dependsOnR, "local.") { - targetType = LOCAL_COLOR - } - - emo = append(emo, id) - edgeMap[id] = Edge{ - Data: EdgeData{ - ID: id, - Source: oid, - Target: dependsOnR, - Gradient: fmt.Sprintf("%s %s", OUTPUT_COLOR, targetType), - }, - Classes: "edge", - } - } + for id, re := range resources { + matchBrackets := regexp.MustCompile(`\[[^\[\]]*\]`) + + configId := matchBrackets.ReplaceAllString(id, "") + + var expressions map[string]*tfjson.Expression + + if r.RSO.Configs[configId] != nil { + // If Resource + if r.RSO.Configs[configId].ResourceConfig != nil { + expressions = r.RSO.Configs[configId].ResourceConfig.Expressions + // If Module + } else if r.RSO.Configs[configId].ModuleConfig != nil { + expressions = r.RSO.Configs[configId].ModuleConfig.Expressions + // If Output + } else if r.RSO.Configs[configId].OutputConfig != nil { + expressions = make(map[string]*tfjson.Expression) + expressions["output"] = r.RSO.Configs[configId].OutputConfig.Expression } } - } - - // Loop through resources - for _, resource := range config.Resources { // fmt.Printf("%+v - %+v\n", oName, oValue) - for _, reValues := range resource.Expressions { + for _, reValues := range expressions { for _, dependsOnR := range reValues.References { if !strings.HasPrefix(dependsOnR, "each.") { - if strings.HasPrefix(dependsOnR, "module.") { + + /*if strings.HasPrefix(dependsOnR, "module.") { id := strings.Split(dependsOnR, ".") dependsOnR = fmt.Sprintf("%s.%s", id[0], id[1]) + }*/ + + sourceColor := getResourceColor(re.Type) + targetId := dependsOnR + if parent != "" { + targetId = fmt.Sprintf("%s.%s", parent, dependsOnR) } - sourceType := RESOURCE_COLOR - targetType := RESOURCE_COLOR - - if strings.HasPrefix(resource.Address, "output.") { - sourceType = OUTPUT_COLOR - } else if strings.HasPrefix(resource.Address, "var.") { - sourceType = VARIABLE_COLOR - } else if strings.HasPrefix(resource.Address, "module.") { - sourceType = MODULE_COLOR - } else if strings.HasPrefix(resource.Address, "data.") { - sourceType = DATA_COLOR - } - if strings.HasPrefix(dependsOnR, "output.") { - targetType = OUTPUT_COLOR - } else if strings.HasPrefix(dependsOnR, "var.") { - targetType = VARIABLE_COLOR + targetColor := RESOURCE_COLOR + + if strings.Contains(dependsOnR, "output.") { + targetColor = OUTPUT_COLOR + } else if strings.Contains(dependsOnR, "var.") { + targetColor = VARIABLE_COLOR } else if strings.HasPrefix(dependsOnR, "module.") { - targetType = MODULE_COLOR - } else if strings.HasPrefix(dependsOnR, "data.") { - targetType = DATA_COLOR + targetColor = MODULE_COLOR + } else if strings.Contains(dependsOnR, "data.") { + targetColor = DATA_COLOR + } else if strings.Contains(dependsOnR, "local.") { + targetColor = LOCAL_COLOR } // For Terraform 1.0, resource references point to specific resource attributes // Skip if the target is a resource and reference points to an attribute - if targetType == RESOURCE_COLOR && len(strings.Split(dependsOnR, ".")) != 2 { + if targetColor == RESOURCE_COLOR && len(strings.Split(dependsOnR, ".")) != 2 { continue - } else if targetType == DATA_COLOR && len(strings.Split(dependsOnR, ".")) != 3 { + } else if targetColor == DATA_COLOR && len(strings.Split(dependsOnR, ".")) != 3 { continue } - id := fmt.Sprintf("%s->%s", resource.Address, dependsOnR) - emo = append(emo, id) - edgeMap[id] = Edge{ + edgeId := fmt.Sprintf("%s->%s", id, targetId) + emo = append(emo, edgeId) + edgeMap[edgeId] = Edge{ Data: EdgeData{ - ID: id, - Source: resource.Address, - Target: dependsOnR, - Gradient: fmt.Sprintf("%s %s", sourceType, targetType), + ID: edgeId, + Source: id, + Target: targetId, + Gradient: fmt.Sprintf("%s %s", sourceColor, targetColor), }, Classes: "edge", } } } } + + // Ignore files in edge generation + if re.Type == ResourceTypeFile { + emo = append(emo, r.addEdges(base, parent, edgeMap, re.Children)...) + } else { + emo = append(emo, r.addEdges(base, id, edgeMap, re.Children)...) + } } - // Loop through modules - for mid, module := range config.ModuleCalls { - // fmt.Printf("%+v - %+v\n", oName, oValue) - for _, mExpressions := range module.Expressions { - for _, dependsOnR := range mExpressions.References { - if !strings.HasPrefix(dependsOnR, "each.") { - if strings.HasPrefix(dependsOnR, "module.") { - id := strings.Split(dependsOnR, ".") - dependsOnR = fmt.Sprintf("%s.%s", id[0], id[1]) - } - sourceType := MODULE_COLOR - targetType := RESOURCE_COLOR + return emo +} - if strings.HasPrefix(dependsOnR, "output.") { - targetType = OUTPUT_COLOR - } else if strings.HasPrefix(dependsOnR, "var.") { - targetType = VARIABLE_COLOR - } else if strings.HasPrefix(dependsOnR, "module.") { - targetType = MODULE_COLOR - } else if strings.HasPrefix(dependsOnR, "data.") { - targetType = DATA_COLOR - } +// GenerateEdges - +func (r *rover) GenerateEdges() []Edge { + edgeMap := make(map[string]Edge) + emo := []string{} - // For Terraform 1.0, resource references point to specific resource attributes - // Skip if the target is a resource and reference points to an attribute - if targetType == RESOURCE_COLOR && len(strings.Split(dependsOnR, ".")) != 2 { - continue - } else if targetType == DATA_COLOR && len(strings.Split(dependsOnR, ".")) != 3 { - continue - } + //config := r.Plan.Config.RootModule - id := fmt.Sprintf("%s->%s", fmt.Sprintf("module.%s", mid), dependsOnR) - emo = append(emo, id) - edgeMap[id] = Edge{ - Data: EdgeData{ - ID: id, - Source: fmt.Sprintf("module.%s", mid), - Target: dependsOnR, - Gradient: fmt.Sprintf("%s %s", sourceType, targetType), - }, - Classes: "edge", - } - } - } - } - } + emo = append(emo, r.addEdges("", "", edgeMap, r.Map.Root)...) edges := make([]Edge, 0, len(edgeMap)) exists := make(map[string]bool) @@ -464,20 +353,20 @@ func GenerateEdges(plan *tfjson.Plan) []Edge { return edges } -func getResourceColor(resourceID string) string { - rID := strings.Split(resourceID, ".") - switch rID[0] { - case "module": - return MODULE_BG_COLOR - case "data": +func getResourceColor(t ResourceType) string { + switch t { + case ResourceTypeModule: + return MODULE_COLOR + case ResourceTypeData: return DATA_COLOR - case "output": + case ResourceTypeOutput: return OUTPUT_COLOR - case "var": + case ResourceTypeVariable: return VARIABLE_COLOR + case ResourceTypeLocal: + return LOCAL_COLOR } - // return RESOURCE_COLOR - return FNAME_BG_COLOR + return RESOURCE_COLOR } func getPrimitiveType(resourceType string) string { @@ -493,190 +382,21 @@ func getPrimitiveType(resourceType string) string { return "resource" } -func getResourceClass(resourceType string) string { +func getResourceClass(resourceType ResourceType) string { switch resourceType { - case - "data", - "output", - "var", - "local": - return resourceType - } - return "resource" -} - -func parseResource(rid string, rtype string, parentID string, id string, rdata *Resource) (map[string]Node, []string) { - nodeMap := make(map[string]Node) - nmo := []string{} - - resourceNameSuffix := "name" - if rdata.Children != nil { - resourceNameSuffix = "parent" - } - - // Append resource type - nmo = append(nmo, rid) - nodeMap[rid] = Node{ - Data: NodeData{ - ID: rid, - Label: rtype, - Type: "data", - Parent: parentID, - ParentColor: getResourceColor(parentID), - }, - Classes: "resource-type", - } - - // Append resource name - nmo = append(nmo, id) - nodeMap[id] = Node{ - Data: NodeData{ - ID: id, - Label: rdata.Name, - Type: string(rdata.Type), - Parent: rid, - ParentColor: getResourceColor(parentID), - Change: string(rdata.ChangeAction), - }, - Classes: fmt.Sprintf("resource-%s %s", resourceNameSuffix, string(rdata.ChangeAction)), - } - - for cid, crdata := range rdata.Children { - nmo = append(nmo, cid) - nodeMap[cid] = Node{ - Data: NodeData{ - ID: cid, - Label: crdata.Name, - Type: string(crdata.Type), - Parent: id, - ParentColor: getResourceColor(parentID), - Change: string(crdata.ChangeAction), - }, - Classes: fmt.Sprintf("resource-name %s", string(crdata.ChangeAction)), - } - } - - return nodeMap, nmo -} - -func parseData(rid string, rtype string, parentID string, id string, rdata *Resource) (map[string]Node, []string) { - nodeMap := make(map[string]Node) - nmo := []string{} - resourceNameSuffix := "name" - if rdata.Children != nil { - resourceNameSuffix = "parent" + case ResourceTypeData: + return "data-type" + case ResourceTypeOutput: + return "output" + case ResourceTypeVariable: + return "variable" + case ResourceTypeFile: + return "fname" + case ResourceTypeLocal: + return "locals" + case ResourceTypeModule: + return "module" } - - // Append resource type - nmo = append(nmo, rid) - nodeMap[rid] = Node{ - Data: NodeData{ - ID: rid, - Label: rtype, - Type: "data", - Parent: parentID, - ParentColor: getResourceColor(parentID), - }, - Classes: "data-type", - } - - // Append resource name - nmo = append(nmo, id) - nodeMap[id] = Node{ - Data: NodeData{ - ID: id, - Label: rdata.Name, - Type: "data", - Parent: rid, - ParentColor: getResourceColor(parentID), - Change: string(rdata.ChangeAction), - }, - Classes: fmt.Sprintf("data-%s %s", resourceNameSuffix, string(rdata.ChangeAction)), - } - - for cid, crdata := range rdata.Children { - nmo = append(nmo, cid) - nodeMap[cid] = Node{ - Data: NodeData{ - ID: cid, - Label: crdata.Name, - Type: "data", - Parent: id, - ParentColor: getResourceColor(parentID), - }, - Classes: fmt.Sprintf("data-name %s", string(rdata.ChangeAction)), - } - } - - return nodeMap, nmo -} - -func parseVariable(rid string, parentID string, id string, rdata *Resource) map[string]Node { - nodeMap := make(map[string]Node) - - // Append resource type - nodeMap[rid] = Node{ - Data: NodeData{ - ID: id, - Label: strings.TrimPrefix(id, "var."), - Type: "variable", - Parent: parentID, - }, - Classes: "variable", - } - - return nodeMap -} - -func parseOutput(parentID string, id string) map[string]Node { - nodeMap := make(map[string]Node) - - label := strings.TrimPrefix(id, fmt.Sprintf("%s.", parentID)) - label = strings.TrimPrefix(label, "output.") - - // Append resource type - nodeMap[id] = Node{ - Data: NodeData{ - ID: id, - Label: label, - Type: "output", - Parent: parentID, - }, - Classes: "output", - } - - return nodeMap -} - -func parseModule(rtype string, basePath string, mID string, rdata *Resource) (map[string]Node, []string) { - nodeMap := make(map[string]Node) - nmo := []string{} - - // Append resource name - nmo = append(nmo, mID) - nodeMap[mID] = Node{ - Data: NodeData{ - ID: mID, - Label: rdata.Name, - Type: "module", - Parent: basePath, - }, - Classes: "module", - } - - for id, crdata := range rdata.Children { - cID := strings.TrimRight(id, fmt.Sprintf(".%s", crdata.Name)) - - nm, tempNmo := parseResource(cID, crdata.ResourceType, mID, id, crdata) - - for _, i := range tempNmo { - nmo = append(nmo, i) - nodeMap[i] = nm[i] - } - } - - // fmt.Printf("%+v\n", nodeMap) - - return nodeMap, nmo + return "resource-type" } diff --git a/main.go b/main.go index a3e6e9e..ccf5957 100644 --- a/main.go +++ b/main.go @@ -1,14 +1,12 @@ package main import ( - "bytes" "context" "embed" "encoding/json" "errors" "flag" "fmt" - "io" "io/fs" "io/ioutil" "log" @@ -18,147 +16,398 @@ import ( "strings" "time" + tfe "github.com/hashicorp/go-tfe" "github.com/hashicorp/terraform-config-inspect/tfconfig" "github.com/hashicorp/terraform-exec/tfexec" tfjson "github.com/hashicorp/terraform-json" ) +const VERSION = "0.3.3" + +var TRUE = true + //go:embed ui/dist var frontend embed.FS -func main() { - log.Println("Starting Rover...") +type arrayFlags []string + +func (i arrayFlags) String() string { + var ts []string + for _, el := range i { + ts = append(ts, el) + } + return strings.Join(ts, ",") +} + +func (i *arrayFlags) Set(value string) error { + *i = append(*i, value) + return nil +} + +type rover struct { + Name string + WorkingDir string + TfPath string + TfVarsFiles []string + TfVars []string + TfBackendConfigs []string + PlanPath string + PlanJSONPath string + WorkspaceName string + TFCOrgName string + TFCWorkspaceName string + ShowSensitive bool + GenImage bool + TFCNewRun bool + Plan *tfjson.Plan + RSO *ResourcesOverview + Map *Map + Graph Graph +} - var tfPath, workingDir, name string +func main() { + var tfPath, workingDir, name, zipFileName, ipPort, planPath, planJSONPath, workspaceName, tfcOrgName, tfcWorkspaceName string + var standalone, genImage, showSensitive, getVersion, tfcNewRun bool + var tfVarsFiles, tfVars, tfBackendConfigs arrayFlags flag.StringVar(&tfPath, "tfPath", "/usr/local/bin/terraform", "Path to Terraform binary") flag.StringVar(&workingDir, "workingDir", ".", "Path to Terraform configuration") flag.StringVar(&name, "name", "rover", "Configuration name") + flag.StringVar(&zipFileName, "zipFileName", "rover", "Standalone zip file name") + flag.StringVar(&ipPort, "ipPort", "0.0.0.0:9000", "IP and port for Rover server") + flag.StringVar(&planPath, "planPath", "", "Plan file path") + flag.StringVar(&planJSONPath, "planJSONPath", "", "Plan JSON file path") + flag.StringVar(&workspaceName, "workspaceName", "", "Workspace name") + flag.StringVar(&tfcOrgName, "tfcOrg", "", "Terraform Cloud Organization name") + flag.StringVar(&tfcWorkspaceName, "tfcWorkspace", "", "Terraform Cloud Workspace name") + flag.BoolVar(&standalone, "standalone", false, "Generate standalone HTML files") + flag.BoolVar(&showSensitive, "showSensitive", false, "Display sensitive values") + flag.BoolVar(&tfcNewRun, "tfcNewRun", false, "Create new Terraform Cloud run") + flag.BoolVar(&getVersion, "version", false, "Get current version") + flag.BoolVar(&genImage, "genImage", false, "Generate graph image") + flag.Var(&tfVarsFiles, "tfVarsFile", "Path to *.tfvars files") + flag.Var(&tfVars, "tfVar", "Terraform variable (key=value)") + flag.Var(&tfBackendConfigs, "tfBackendConfig", "Path to *.tfbackend files") flag.Parse() + if getVersion { + fmt.Printf("Rover v%s\n", VERSION) + return + } + + log.Println("Starting Rover...") + + parsedTfVarsFiles := strings.Split(tfVarsFiles.String(), ",") + parsedTfVars := strings.Split(tfVars.String(), ",") + parsedTfBackendConfigs := strings.Split(tfBackendConfigs.String(), ",") + + path, err := os.Getwd() + if err != nil { + log.Fatal(errors.New("Unable to get current working directory")) + } + + if planPath != "" { + if !strings.HasPrefix(planPath, "/") { + planPath = filepath.Join(path, planPath) + } + } + + if planJSONPath != "" { + if !strings.HasPrefix(planJSONPath, "/") { + planJSONPath = filepath.Join(path, planJSONPath) + } + } + + r := rover{ + Name: name, + WorkingDir: workingDir, + TfPath: tfPath, + PlanPath: planPath, + PlanJSONPath: planJSONPath, + ShowSensitive: showSensitive, + GenImage: genImage, + TfVarsFiles: parsedTfVarsFiles, + TfVars: parsedTfVars, + TfBackendConfigs: parsedTfBackendConfigs, + WorkspaceName: workspaceName, + TFCOrgName: tfcOrgName, + TFCWorkspaceName: tfcWorkspaceName, + TFCNewRun: tfcNewRun, + } + // Generate assets - plan, rso, mapDM, graph := generateAssets(name, workingDir, tfPath) + err = r.generateAssets() + if err != nil { + log.Fatal(err.Error()) + } + + log.Println("Done generating assets.") // Save to file (debug) - // saveJSONToFile(name, "plan", "output", plan) - // saveJSONToFile(name, "rso", "output", rso) - // saveJSONToFile(name, "map", "output", mapDM) - // saveJSONToFile(name, "graph", "output", graph) + // saveJSONToFile(name, "plan", "output", r.Plan) + // saveJSONToFile(name, "rso", "output", r.Plan) + // saveJSONToFile(name, "map", "output", r.Map) + // saveJSONToFile(name, "graph", "output", r.Graph) // Embed frontend - stripped, err := fs.Sub(frontend, "ui/dist") + fe, err := fs.Sub(frontend, "ui/dist") if err != nil { log.Fatalln(err) } - frontendFS := http.FileServer(http.FS(stripped)) + frontendFS := http.FileServer(http.FS(fe)) - http.Handle("/", frontendFS) - http.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { - fileType := strings.Replace(r.URL.Path, "/api/", "", 1) - - var j []byte - var err error - - enableCors(&w) - - switch fileType { - case "plan": - j, err = json.Marshal(plan) - if err != nil { - io.WriteString(w, fmt.Sprintf("Error producing JSON: %s\n", err)) - } - case "rso": - j, err = json.Marshal(rso) - if err != nil { - io.WriteString(w, fmt.Sprintf("Error producing JSON: %s\n", err)) - } - case "map": - j, err = json.Marshal(mapDM) - if err != nil { - io.WriteString(w, fmt.Sprintf("Error producing JSON: %s\n", err)) - } - case "graph": - j, err = json.Marshal(graph) - if err != nil { - io.WriteString(w, fmt.Sprintf("Error producing JSON: %s\n", err)) - } - default: - io.WriteString(w, "Please enter a valid file type: plan, rso, map, graph\n") + if standalone { + err = r.generateZip(fe, fmt.Sprintf("%s.zip", zipFileName)) + if err != nil { + log.Fatalln(err) } - w.Header().Set("Content-Type", "application/json") - io.Copy(w, bytes.NewReader(j)) - }) - - log.Println("Done generating assets.") - log.Println("Rover is running on localhost:9000") + log.Printf("Generated zip file: %s.zip\n", zipFileName) + return + } - err = http.ListenAndServe(":9000", nil) + err = r.startServer(ipPort, frontendFS) if err != nil { - log.Fatalf("Could not start server: %s\n", err.Error()) + // http.Serve() returns error on shutdown + if genImage { + log.Println("Server shut down.") + } else { + log.Fatalf("Could not start server: %s\n", err.Error()) + } } } -func generateAssets(name string, workingDir string, tfPath string) (*tfjson.Plan, *ResourcesOverview, *Map, Graph) { - // Generate Plan - plan, err := generatePlan(name, workingDir, tfPath) +func (r *rover) generateAssets() error { + // Get Plan + err := r.getPlan() if err != nil { - log.Printf(fmt.Sprintf("Unable to parse Plan: %s", err)) - os.Exit(2) + return errors.New(fmt.Sprintf("Unable to parse Plan: %s", err)) } - // Parse Configuration - log.Println("Parsing configuration...") - // Get current directory file - config, _ := tfconfig.LoadModule(workingDir) - if config.Diagnostics.HasErrors() { - os.Exit(1) + // Generate RSO, Map, Graph + err = r.GenerateResourceOverview() + if err != nil { + return err } - // Generate RSO - log.Println("Generating resource overview...") - rso := GenerateResourceOverview(plan) - - // Generate Map - log.Println("Generating resource map...") - mapDM := GenerateMap(config, rso) + err = r.GenerateMap() + if err != nil { + return err + } - // Generate Graph - log.Println("Generating resource graph...") - graph := GenerateGraph(plan, mapDM) + err = r.GenerateGraph() + if err != nil { + return err + } - return plan, rso, mapDM, graph + return nil } -func generatePlan(name string, workingDir string, tfPath string) (*tfjson.Plan, error) { +func (r *rover) getPlan() error { tmpDir, err := ioutil.TempDir("", "rover") if err != nil { - return nil, err + return err } defer os.RemoveAll(tmpDir) - tf, err := tfexec.NewTerraform(workingDir, tfPath) + tf, err := tfexec.NewTerraform(r.WorkingDir, r.TfPath) if err != nil { - return nil, err + return err + } + + // If user provided path to plan file + if r.PlanPath != "" { + log.Println("Using provided plan...") + r.Plan, err = tf.ShowPlanFile(context.Background(), r.PlanPath) + if err != nil { + return errors.New(fmt.Sprintf("Unable to read Plan (%s): %s", r.PlanPath, err)) + } + return nil + } + + // If user provided path to plan JSON file + if r.PlanJSONPath != "" { + log.Println("Using provided JSON plan...") + + planJsonFile, err := os.Open(r.PlanJSONPath) + if err != nil { + return errors.New(fmt.Sprintf("Unable to read Plan (%s): %s", r.PlanJSONPath, err)) + } + defer planJsonFile.Close() + + planJson, err := ioutil.ReadAll(planJsonFile) + if err != nil { + return errors.New(fmt.Sprintf("Unable to read Plan (%s): %s", r.PlanJSONPath, err)) + } + + if err := json.Unmarshal(planJson, &r.Plan); err != nil { + return errors.New(fmt.Sprintf("Unable to read Plan (%s): %s", r.PlanJSONPath, err)) + } + + return nil + } + + // If user specified TFC workspace + if r.TFCWorkspaceName != "" { + tfcToken := os.Getenv("TFC_TOKEN") + + if tfcToken == "" { + return errors.New("TFC_TOKEN environment variable not set") + } + + if r.TFCOrgName == "" { + return errors.New("Must specify Terraform Cloud organization to retrieve plan from Terraform Cloud") + } + + config := &tfe.Config{ + Token: tfcToken, + } + + client, err := tfe.NewClient(config) + if err != nil { + return errors.New(fmt.Sprintf("Unable to connect to Terraform Cloud. %s", err)) + } + + // Get TFC Workspace + ws, err := client.Workspaces.Read(context.Background(), r.TFCOrgName, r.TFCWorkspaceName) + if err != nil { + return errors.New(fmt.Sprintf("Unable to list workspace %s in %s organization. %s", r.TFCWorkspaceName, r.TFCOrgName, err)) + } + + // Retrieve all runs from specified TFC workspace + runs, err := client.Runs.List(context.Background(), ws.ID, tfe.RunListOptions{}) + if err != nil { + return errors.New(fmt.Sprintf("Unable to retrieve plan from %s in %s organization. %s", r.TFCWorkspaceName, r.TFCOrgName, err)) + } + + run := runs.Items[0] + + // Get most recent plan item + planID := runs.Items[0].Plan.ID + + // Run hasn't been applied or discarded, therefore is still "actionable" by user + runIsActionable := run.StatusTimestamps.AppliedAt.IsZero() && run.StatusTimestamps.DiscardedAt.IsZero() + + if runIsActionable && r.TFCNewRun { + return errors.New(fmt.Sprintf("Did not create new run. %s in %s in %s is still active", run.ID, r.TFCWorkspaceName, r.TFCOrgName)) + } + + // If latest run is not actionable, rover will create new run + if r.TFCNewRun { + // Create new run in specified TFC workspace + newRun, err := client.Runs.Create(context.Background(), tfe.RunCreateOptions{ + Refresh: &TRUE, + Workspace: ws, + }) + if err != nil { + return errors.New(fmt.Sprintf("Unable to generate new run from %s in %s organization. %s", r.TFCWorkspaceName, r.TFCOrgName, err)) + } + + run = newRun + + log.Printf("Starting new Terraform Cloud run in %s workspace...", r.TFCWorkspaceName) + + // Wait maximum of 5 mins + for i := 0; i < 30; i++ { + run, err := client.Runs.Read(context.Background(), newRun.ID) + if err != nil { + return errors.New(fmt.Sprintf("Unable to retrieve run from %s in %s organization. %s", r.TFCWorkspaceName, r.TFCOrgName, err)) + } + + if run.Plan != nil { + planID = run.Plan.ID + // Add 20 second timeout so plan JSON becomes available + time.Sleep(20 * time.Second) + log.Printf("Run %s to completed!", newRun.ID) + break + } + + time.Sleep(10 * time.Second) + log.Printf("Waiting for run %s to complete (%ds)...", newRun.ID, 10*(i+1)) + } + + if planID == "" { + return errors.New(fmt.Sprintf("Timeout waiting for plan to complete in %s in %s organization. %s", r.TFCWorkspaceName, r.TFCOrgName, err)) + } + } + + // Get most recent plan file + planBytes, err := client.Plans.JSONOutput(context.Background(), planID) + if err != nil { + return errors.New(fmt.Sprintf("Unable to retrieve plan from %s in %s organization. %s", r.TFCWorkspaceName, r.TFCOrgName, err)) + } + // If empty plan file + if string(planBytes) == "" { + return errors.New(fmt.Sprintf("Empty plan. Check run %s in %s in %s is not pending", run.ID, r.TFCWorkspaceName, r.TFCOrgName)) + } + + if err := json.Unmarshal(planBytes, &r.Plan); err != nil { + return errors.New(fmt.Sprintf("Unable to parse plan (ID: %s) from %s in %s organization.: %s", planID, r.TFCWorkspaceName, r.TFCOrgName, err)) + } + + return nil } log.Println("Initializing Terraform...") - // err = tf.Init(context.Background(), tfexec.Upgrade(true), tfexec.LockTimeout("60s")) - err = tf.Init(context.Background(), tfexec.Upgrade(true)) + + // Create TF Init options + var tfInitOptions []tfexec.InitOption + tfInitOptions = append(tfInitOptions, tfexec.Upgrade(true)) + + // Add *.tfbackend files + for _, tfBackendConfig := range r.TfBackendConfigs { + if tfBackendConfig != "" { + tfInitOptions = append(tfInitOptions, tfexec.BackendConfig(tfBackendConfig)) + } + } + + // tfInitOptions = append(tfInitOptions, tfexec.LockTimeout("60s")) + + err = tf.Init(context.Background(), tfInitOptions...) if err != nil { - return nil, err + return errors.New(fmt.Sprintf("Unable to initialize Terraform Plan: %s", err)) + } + + if r.WorkspaceName != "" { + log.Printf("Running in %s workspace...", r.WorkspaceName) + err = tf.WorkspaceSelect(context.Background(), r.WorkspaceName) + if err != nil { + return errors.New(fmt.Sprintf("Unable to select workspace (%s): %s", r.WorkspaceName, err)) + } } log.Println("Generating plan...") planPath := fmt.Sprintf("%s/%s-%v", tmpDir, "roverplan", time.Now().Unix()) - _, err = tf.Plan(context.Background(), tfexec.Out(planPath)) + + // Create TF Plan options + var tfPlanOptions []tfexec.PlanOption + tfPlanOptions = append(tfPlanOptions, tfexec.Out(planPath)) + + // Add *.tfvars files + for _, tfVarsFile := range r.TfVarsFiles { + if tfVarsFile != "" { + tfPlanOptions = append(tfPlanOptions, tfexec.VarFile(tfVarsFile)) + } + } + + // Add Terraform variables + for _, tfVar := range r.TfVars { + if tfVar != "" { + tfPlanOptions = append(tfPlanOptions, tfexec.Var(tfVar)) + } + } + + _, err = tf.Plan(context.Background(), tfPlanOptions...) if err != nil { - return nil, errors.New(fmt.Sprintf("Unable to run Plan: %s", err)) + return errors.New(fmt.Sprintf("Unable to run Plan: %s", err)) } - plan, err := tf.ShowPlanFile(context.Background(), planPath) + r.Plan, err = tf.ShowPlanFile(context.Background(), planPath) + if err != nil { + return errors.New(fmt.Sprintf("Unable to read Plan: %s", err)) + } - return plan, err + return nil } func showJSON(g interface{}) { @@ -194,21 +443,17 @@ func saveJSONToFile(prefix string, fileType string, path string, j interface{}) } f, err := os.Create(fmt.Sprintf("%s/%s-%s.json", newpath, prefix, fileType)) - if err != nil { log.Fatal(err) } defer f.Close() - _, err2 := f.WriteString(string(b)) - - if err2 != nil { - log.Fatal(err2) + _, err = f.WriteString(string(b)) + if err != nil { + log.Fatal(err) } - // log.Printf("Saved to %s", fmt.Sprintf("%s/%s-%s.json", newpath, prefix, fileType)) - return fmt.Sprintf("%s/%s-%s.json", newpath, prefix, fileType) } diff --git a/map.go b/map.go index e5805af..3bb3ba0 100644 --- a/map.go +++ b/map.go @@ -2,21 +2,27 @@ package main import ( "fmt" + "log" + "path/filepath" + "regexp" "strings" "github.com/hashicorp/terraform-config-inspect/tfconfig" tfjson "github.com/hashicorp/terraform-json" ) -type ResourceType string type Action string +type ResourceType string const ( + ResourceTypeFile ResourceType = "file" + ResourceTypeLocal ResourceType = "locals" ResourceTypeVariable ResourceType = "variable" ResourceTypeOutput ResourceType = "output" ResourceTypeResource ResourceType = "resource" ResourceTypeData ResourceType = "data" ResourceTypeModule ResourceType = "module" + DefaultFileName string = "unknown file" ) const ( @@ -45,34 +51,22 @@ type Map struct { RequiredCore []string `json:"required_core,omitempty"` RequiredProviders map[string]*tfconfig.ProviderRequirement `json:"required_providers,omitempty"` // ProviderConfigs map[string]*tfconfig.ProviderConfig `json:"provider_configs,omitempty"` - Modules map[string]*tfconfig.ModuleCall `json:"modules,omitempty"` - - Files map[string]map[string]*Resource `json:"files,omitempty"` + Root map[string]*Resource `json:"root,omitempty"` } -// FileContent represents the content within each file -// type FileContent struct { -// Path string `json:"path"` -// Variables map[string]*Variable `json:"variables,omitempty"` -// Outputs map[string]*Output `json:"outputs,omitempty"` -// ManagedResources map[string]*Resource `json:"managed_resources,omitempty"` -// DataResources map[string]*Resource `json:"data_resources,omitempty"` -// ModuleCalls map[string]*ModuleCall `json:"module_calls,omitempty"` -// } - // Resource is a modified tfconfig.Resource type Resource struct { Type ResourceType `json:"type"` Name string `json:"name"` - Line int `json:"line,omitempty"` + Line *int `json:"line,omitempty"` Children map[string]*Resource `json:"children,omitempty"` // Resource ChangeAction Action `json:"change_action,omitempty"` // Variable and Output - Required bool `json:"required,omitempty"` - Sensitive bool `json:"sensitive,omitempty"` + Required *bool `json:"required,omitempty"` + Sensitive bool `json:"sensitive,omitempty"` // Provider and Data Provider string `json:"provider,omitempty"` ResourceType string `json:"resource_type,omitempty"` @@ -89,99 +83,114 @@ type ModuleCall struct { Line int `json:"line,omitempty"` } -// Generates Map - Overview of files and their resources -// Groups different resource types together -func GenerateMap(config *tfconfig.Module, rso *ResourcesOverview) *Map { - mapObj := &Map{ - Path: config.Path, - RequiredProviders: config.RequiredProviders, - RequiredCore: config.RequiredCore, - // ProviderConfigs: module.ProviderConfigs, - Modules: make(map[string]*tfconfig.ModuleCall), - } - - files := make(map[string]map[string]*Resource) +func (r *rover) GenerateModuleMap(parent *Resource, parentModule string) { - // Loop through each resource type and populate graph - for _, variable := range config.Variables { - // Populate with file if doesn't exist - if _, ok := files[variable.Pos.Filename]; !ok { - files[variable.Pos.Filename] = make(map[string]*Resource) - } + childIndex := regexp.MustCompile(`\[[^[\]]*\]$`) + matchBrackets := regexp.MustCompile(`\[[^\[\]]*\]`) - id := fmt.Sprintf("var.%s", variable.Name) + states := r.RSO.States + configs := r.RSO.Configs - files[variable.Pos.Filename][id] = &Resource{ - Type: ResourceTypeVariable, - Name: variable.Name, - Required: variable.Required, - Line: variable.Pos.Line, - } + prefix := parentModule + if parentModule != "" { + prefix = fmt.Sprintf("%s.", prefix) } - for _, output := range config.Outputs { - // Populate with file if doesn't exist - if _, ok := files[output.Pos.Filename]; !ok { - files[output.Pos.Filename] = make(map[string]*Resource) + parentConfig := matchBrackets.ReplaceAllString(parentModule, "") + parentConfigured := configs[parentConfig] != nil && configs[parentConfig].Module != nil + + // Add variables and outputs with line numbers and file names if configured + if parentConfigured && !states[parentModule].IsParent { + for oName, o := range configs[parentConfig].Module.Outputs { + fname := filepath.Base(o.Pos.Filename) + oid := fmt.Sprintf("%soutput.%s", prefix, oName) + out := &Resource{ + Type: ResourceTypeOutput, + Name: oName, + Sensitive: o.Sensitive, + Line: &o.Pos.Line, + } + r.AddFileIfNotExists(parent, parentModule, fname) + + parent.Children[fname].Children[oid] = out } - id := fmt.Sprintf("output.%s", output.Name) + for vName, v := range configs[parentConfig].Module.Variables { + fname := filepath.Base(v.Pos.Filename) + vid := fmt.Sprintf("%svar.%s", prefix, vName) + va := &Resource{ + Type: ResourceTypeVariable, + Name: vName, + Required: &v.Required, + Line: &v.Pos.Line, + } - oo := &Resource{ - Type: ResourceTypeOutput, - Name: output.Name, - Sensitive: output.Sensitive, - Line: output.Pos.Line, - } + r.AddFileIfNotExists(parent, parentModule, fname) - if _, ok := rso.Outputs[output.Name]; ok { - if rso.Outputs[output.Name].Change != nil { - if rso.Outputs[output.Name].Change.Actions != nil { - oo.ChangeAction = Action(string(rso.Outputs[output.Name].Change.Actions[0])) + parent.Children[fname].Children[vid] = va - if len(rso.Outputs[output.Name].Change.Actions) > 1 { - oo.ChangeAction = ActionReplace - } - } + } + // Add variables and Outputs if no configuration files + } else if configs[parentConfig] != nil && configs[parentConfig].ModuleConfig.Module != nil && !states[parentModule].IsParent { + for oName, o := range configs[parentConfig].ModuleConfig.Module.Outputs { + oid := fmt.Sprintf("%soutput.%s", prefix, oName) + out := &Resource{ + Type: ResourceTypeOutput, + Name: oName, + Sensitive: o.Sensitive, } + + parent.Children[oid] = out } - files[output.Pos.Filename][id] = oo - } + for vName := range configs[parentConfig].ModuleConfig.Module.Variables { + vid := fmt.Sprintf("%svar.%s", prefix, vName) + va := &Resource{ + Type: ResourceTypeVariable, + Name: vName, + } + + parent.Children[vid] = va - for _, resource := range config.ManagedResources { - // Populate with file if doesn't exist - if _, ok := files[resource.Pos.Filename]; !ok { - files[resource.Pos.Filename] = make(map[string]*Resource) } + } - id := fmt.Sprintf("%s.%s", resource.Type, resource.Name) + for id, rs := range states[parentModule].Children { - r := &Resource{ - Type: ResourceTypeResource, - Name: resource.Name, - ResourceType: resource.Type, - Provider: resource.Provider.Name, - Line: resource.Pos.Line, + configId := matchBrackets.ReplaceAllString(id, "") + configured := configs[parentConfig] != nil && configs[parentConfig].Module != nil && configs[configId] != nil // If there is configuration for filenames, lines, etc. + + re := &Resource{ + Type: rs.Type, + Children: map[string]*Resource{}, } - if _, ok := rso.Resources[id]; ok { - if rso.Resources[id].Change.Actions != nil { - r.ChangeAction = Action(string(rso.Resources[id].Change.Actions[0])) + if states[id].Change.Actions != nil { - if len(rso.Resources[id].Change.Actions) > 1 { - r.ChangeAction = ActionReplace - } + re.ChangeAction = Action(string(states[id].Change.Actions[0])) + if len(states[id].Change.Actions) > 1 { + re.ChangeAction = ActionReplace } + } + + if rs.Type == ResourceTypeResource || rs.Type == ResourceTypeData { + re.ResourceType = configs[configId].ResourceConfig.Type + re.Name = configs[configId].ResourceConfig.Name + + for crName, cr := range states[id].Children { - for crName, cr := range rso.Resources[id].Children { - if r.Children == nil { - r.Children = make(map[string]*Resource) + if re.Children == nil { + re.Children = make(map[string]*Resource) } tcr := &Resource{ - Type: ResourceTypeResource, - Name: crName, + Type: rs.Type, + } + + if rs.Type == ResourceTypeData { + tcr.Name = strings.TrimPrefix(crName, fmt.Sprintf("%sdata.%s.", prefix, re.ResourceType)) + } else { + tcr.Name = strings.TrimPrefix(crName, fmt.Sprintf("%s%s.", prefix, re.ResourceType)) } if cr.Change.Actions != nil { @@ -192,118 +201,152 @@ func GenerateMap(config *tfconfig.Module, rso *ResourcesOverview) *Map { } } - r.Children[crName] = tcr + re.Children[crName] = tcr } - } - files[resource.Pos.Filename][id] = r + if configured { - } + var fname string + ind := fmt.Sprintf("%s.%s", re.ResourceType, re.Name) - for _, data := range config.DataResources { - // Populate with file if doesn't exist - if _, ok := files[data.Pos.Filename]; !ok { - files[data.Pos.Filename] = make(map[string]*Resource) - } + if rs.Type == ResourceTypeData { + ind = fmt.Sprintf("data.%s", ind) + } - id := fmt.Sprintf("data.%s.%s", data.Type, data.Name) + if rs.Type == ResourceTypeData && configs[parentConfig].Module.DataResources[ind] != nil { - files[data.Pos.Filename][id] = &Resource{ - Type: ResourceTypeData, - Name: data.Name, - ResourceType: data.Type, - Provider: data.Provider.Name, - Line: data.Pos.Line, - } + fname = filepath.Base(configs[parentConfig].Module.DataResources[ind].Pos.Filename) + re.Line = &configs[parentConfig].Module.DataResources[ind].Pos.Line - if rso.Resources[id].Change.Actions != nil { - files[data.Pos.Filename][id].ChangeAction = Action(string(rso.Resources[id].Change.Actions[0])) + r.AddFileIfNotExists(parent, parentModule, fname) - if len(rso.Resources[id].Change.Actions) > 1 { - files[data.Pos.Filename][id].ChangeAction = ActionReplace - } - } - } + parent.Children[fname].Children[id] = re - for _, mc := range config.ModuleCalls { - // Populate with file if doesn't exist - if _, ok := files[mc.Pos.Filename]; !ok { - files[mc.Pos.Filename] = make(map[string]*Resource) - } + } else if rs.Type == ResourceTypeResource && configs[parentConfig].Module.ManagedResources[ind] != nil { - // Add to module attribute - if _, ok := mapObj.Modules[mc.Name]; !ok { - mapObj.Modules[mc.Name] = mc - } + fname = filepath.Base(configs[parentConfig].Module.ManagedResources[ind].Pos.Filename) + re.Line = &configs[parentConfig].Module.ManagedResources[ind].Pos.Line - id := fmt.Sprintf("module.%s", mc.Name) + r.AddFileIfNotExists(parent, parentModule, fname) - m := &Resource{ - Type: ResourceTypeModule, - Name: mc.Name, - Source: mc.Source, - Version: mc.Version, - Line: mc.Pos.Line, - } - - m.Children = make(map[string]*Resource) + parent.Children[fname].Children[id] = re - if _, ok := rso.Resources[id]; ok { - tempChildren := make(map[string]*Resource) + } else { - // Filter through and add configuration - for _, cr := range rso.Resources[id].ModuleConfig.Module.Resources { - crName := fmt.Sprintf("%s.%s", id, cr.Address) + r.AddFileIfNotExists(parent, parentModule, DefaultFileName) - tcr := &Resource{ - Type: ResourceTypeResource, - Name: cr.Name, - ResourceType: cr.Type, + parent.Children[DefaultFileName].Children[id] = re } - if cr.Mode == tfjson.DataResourceMode { - tcr.Type = ResourceTypeData - } + } else { - tempChildren[crName] = tcr + parent.Children[id] = re } - // Filter through and add change action - for crName, cr := range rso.Resources[id].Children { - tcr := tempChildren[crName] - if tcr == nil { - tcr = &Resource{} - } + } else if rs.Type == ResourceTypeModule { + re.Name = strings.Split(id, ".")[len(strings.Split(id, "."))-1] - if tcr.Name == "" { - tcr.Type = ResourceTypeResource - tcr.Name = cr.Config.Name - tcr.ResourceType = cr.Config.Type - } + if configured && !childIndex.MatchString(id) && configs[parentConfig].Module.ModuleCalls[matchBrackets.ReplaceAllString(re.Name, "")] != nil { + fname := filepath.Base(configs[parentConfig].Module.ModuleCalls[matchBrackets.ReplaceAllString(re.Name, "")].Pos.Filename) + re.Line = &configs[parentConfig].Module.ModuleCalls[matchBrackets.ReplaceAllString(re.Name, "")].Pos.Line - if cr.Change.Actions != nil { - tcr.ChangeAction = Action(string(cr.Change.Actions[0])) + r.AddFileIfNotExists(parent, parentModule, fname) - if len(cr.Change.Actions) > 1 { - tcr.ChangeAction = ActionReplace - } - } + parent.Children[fname].Children[id] = re + + } else { + parent.Children[id] = re + } + + r.GenerateModuleMap(re, id) + + } + + // Add locals + if configs[configId] != nil && !(re.Type == ResourceTypeModule && childIndex.MatchString(id)) { + expressions := map[string]*tfjson.Expression{} + + if re.Type == ResourceTypeResource { + expressions = configs[configId].ResourceConfig.Expressions + } else if re.Type == ResourceTypeModule { + expressions = configs[configId].ModuleConfig.Expressions + } else if re.Type == ResourceTypeOutput { + expressions["exp"] = configs[configId].OutputConfig.Expression + } - // Add resource to module children - m.Children[crName] = tcr + // Add locals + for _, reValues := range expressions { + for _, dependsOnR := range reValues.References { + ref := &Resource{} + if strings.HasPrefix(dependsOnR, "local.") { + // Append local variable + ref.Type = ResourceTypeLocal + ref.Name = strings.TrimPrefix(dependsOnR, "local.") + rid := fmt.Sprintf("%s%s", prefix, dependsOnR) - // Add parent resource to module children - parentId := strings.Split(crName, "[")[0] - if _, ok := tempChildren[parentId]; ok { - m.Children[parentId] = tempChildren[parentId] + if parentConfigured { + r.AddFileIfNotExists(parent, parentModule, DefaultFileName) + parent.Children[DefaultFileName].Children[rid] = ref + + } else { + parent.Children[rid] = ref + + } + } } } + + } + } +} + +func (r *rover) AddFileIfNotExists(module *Resource, parentModule string, fname string) { + + if _, ok := module.Children[fname]; !ok { + + module.Children[fname] = &Resource{ + Type: ResourceTypeFile, + Name: fname, + Source: fmt.Sprintf("%s/%s", module.Source, fname), + Children: map[string]*Resource{}, } + } +} + +// Generates Map - Overview of files and their resources +// Groups different resource types together +// Defaults to config +func (r *rover) GenerateMap() error { + log.Println("Generating resource map...") + + // Root module + rootModule := &Resource{ + Type: ResourceTypeModule, + Name: "", + Source: "unknown", + Children: map[string]*Resource{}, + } + + mapObj := &Map{ + Path: "Rover Visualization", + Root: rootModule.Children, + } - files[mc.Pos.Filename][id] = m + // If root module has local filesystem configuration stuff (line number/ file name info) + rootConfig := r.RSO.Configs[""].Module + + if rootConfig != nil { + rootModule.Source = rootConfig.Path + mapObj.Path = rootConfig.Path + mapObj.RequiredProviders = rootConfig.RequiredProviders + mapObj.RequiredCore = rootConfig.RequiredCore + r.GenerateModuleMap(rootModule, "") + } else { + r.AddFileIfNotExists(rootModule, "", DefaultFileName) + r.GenerateModuleMap(rootModule.Children[DefaultFileName], "") } - mapObj.Files = files + r.Map = mapObj - return mapObj + return nil } diff --git a/rso.go b/rso.go index 74e6789..be9deae 100644 --- a/rso.go +++ b/rso.go @@ -1,221 +1,394 @@ package main import ( + "encoding/json" "fmt" + "github.com/hashicorp/terraform-config-inspect/tfconfig" + tfjson "github.com/hashicorp/terraform-json" + "io/ioutil" + "log" + "os" + "path/filepath" "regexp" "strings" - - tfjson "github.com/hashicorp/terraform-json" ) // ResourcesOverview represents the root module type ResourcesOverview struct { - Variables map[string]*tfjson.PlanVariable `json:"variables,omitempty"` - Outputs map[string]*OutputOverview `json:"output,omitempty"` - Resources map[string]*ResourceOverview `json:"resources,omitempty"` + Locations map[string]string `json:"locations,omitempty"` + States map[string]*StateOverview `json:"states,omitempty"` + Configs map[string]*ConfigOverview `json:"configs,omitempty"` } // ResourceOverview is a modified tfjson.Plan -type ResourceOverview struct { +type StateOverview struct { // ChangeAction tfjson.Actions `json:change_action` - PriorState map[string]interface{} `json:"prior_state,omitempty"` - PlannedState map[string]interface{} `json:"planned_state,omitempty"` - Change tfjson.Change `json:"change,omitempty"` - Config tfjson.ConfigResource `json:"config,omitempty"` - ModuleConfig *tfjson.ModuleCall `json:"module_config,omitempty"` - DependsOn []string `json:"depends_on,omitempty"` - Children map[string]*ResourceOverview `json:"children,omitempty"` + Change tfjson.Change `json:"change,omitempty"` + Module *tfjson.StateModule `json:"module,omitempty"` + DependsOn []string `json:"depends_on,omitempty"` + Children map[string]*StateOverview `json:"children,omitempty"` + Type ResourceType `json:"type,omitempty"` + IsParent bool `json:"isparent,omitempty"` } -// OutputOverview is a modified tfjson.Change with Outputs -type OutputOverview struct { - // ChangeAction tfjson.Actions `json:change_action` - Change *tfjson.Change `json:"change"` - Config *tfjson.ConfigOutput `json:"config,omitempty"` +type ConfigOverview struct { + ResourceConfig *tfjson.ConfigResource `json:"resource_config,omitempty"` + ModuleConfig *tfjson.ModuleCall `json:"module_config,omitempty"` + VariableConfig *tfjson.ConfigVariable `json:"variable_config,omitempty"` + OutputConfig *tfjson.ConfigOutput `json:"output_config,omitempty"` + Module *tfconfig.Module `json:"module,omitempty"` } -// GenerateResourceOverview - Overview of files and their resources -// Groups different resource types together -func GenerateResourceOverview(plan *tfjson.Plan) *ResourcesOverview { - rso := &ResourcesOverview{} +// For parsing modules.json +type ModuleLocations struct { + Locations []ModuleLocation `json:"Modules,omitempty"` +} - rso.Variables = plan.Variables +type ModuleLocation struct { + Key string `json:"Key,omitempty""` + Source string `json:"Source,omitempty"` + Dir string `json:"Dir,omitempty"` +} - // Loop through outputs - oo := make(map[string]*OutputOverview) - // Loop through output configs - for outputName, output := range plan.Config.RootModule.Outputs { - if _, ok := oo[outputName]; !ok { - oo[outputName] = &OutputOverview{} - } - oo[outputName].Config = output +// PopulateModuleLocations Parses the modules.json file in the .terraform folder, if it exists +// The module locations are then added to rso.Locations and referenced when loading +// modules from the filesystem with tfconfig.LoadModule +func (r *rover) PopulateModuleLocations(moduleJSONFile string, locations map[string]string) { + + moduleLocations := ModuleLocations{} + + jsonFile, err := os.Open(moduleJSONFile) + if err != nil { + log.Println("No submodule configurations found...") } - // Loop through output changes - for outputName, output := range plan.OutputChanges { - if _, ok := oo[outputName]; !ok { - oo[outputName] = &OutputOverview{} - } - oo[outputName].Change = output + defer jsonFile.Close() + + // read our opened jsonFile as a byte array. + byteValue, _ := ioutil.ReadAll(jsonFile) + + // we unmarshal our byteArray which contains our + // jsonFile's content into 'users' which we defined above + json.Unmarshal(byteValue, &moduleLocations) + + for _, loc := range moduleLocations.Locations { + locations[loc.Key] = fmt.Sprintf("%s/%s", r.WorkingDir, loc.Dir) + //fmt.Printf("%v\n", loc.Dir) } +} - rso.Outputs = oo +func (r *rover) PopulateConfigs(parent string, parentKey string, rso *ResourcesOverview, config *tfjson.ConfigModule) { - rs := make(map[string]*ResourceOverview) + ml := rso.Locations + rc := rso.Configs - // reIsChild := regexp.MustCompile(`^\w+\.\w+[\.\[]`) - // reGetParent := regexp.MustCompile(`^\w+\.\w+`) - reIsChild := regexp.MustCompile(`^\w+\.[\w-]+[\.\[]`) - reGetParent := regexp.MustCompile(`^\w+\.[\w-]+`) + prefix := parent + if prefix != "" { + prefix = fmt.Sprintf("%s.", prefix) + } + + // Loop through variable configs + for variableName, variable := range config.Variables { + variableName = fmt.Sprintf("%svar.%s", prefix, variableName) + if _, ok := rc[variableName]; !ok { + rc[variableName] = &ConfigOverview{} + } + rc[variableName].VariableConfig = variable + } + + // Loop through output configs + for outputName, output := range config.Outputs { + outputName = fmt.Sprintf("%soutput.%s", prefix, outputName) + if _, ok := rc[outputName]; !ok { + rc[outputName] = &ConfigOverview{} + } + rc[outputName].OutputConfig = output + } // Loop through each resource type and populate graph - for _, rc := range plan.Config.RootModule.Resources { - if _, ok := rs[rc.Address]; !ok { - rs[rc.Address] = &ResourceOverview{} + for _, resource := range config.Resources { + + address := fmt.Sprintf("%v%v", prefix, resource.Address) + + if _, ok := rc[address]; !ok { + rc[address] = &ConfigOverview{} } - rs[rc.Address].Config = *rc - rs[rc.Address].DependsOn = rc.DependsOn + rc[address].ResourceConfig = resource + //rc[address].DependsOn = resource.DependsOn + + if _, ok := rc[parent]; !ok { + rc[parent] = &ConfigOverview{} + } } // Add modules - for moduleName, m := range plan.Config.RootModule.ModuleCalls { + for moduleName, m := range config.ModuleCalls { + mn := fmt.Sprintf("module.%s", moduleName) + if prefix != "" { + mn = fmt.Sprintf("%s%s", prefix, mn) + } + + if _, ok := rc[mn]; !ok { + rc[mn] = &ConfigOverview{} + } + + childKey := strings.TrimPrefix(moduleName, "module.") + if parentKey != "" { + childKey = fmt.Sprintf("%s.%s", parentKey, childKey) + } - if _, ok := rs[mn]; !ok { - rs[mn] = &ResourceOverview{} + childPath := ml[childKey] + child, _ := tfconfig.LoadModule(childPath) + // If module can be loaded from filesystem + if !child.Diagnostics.HasErrors() { + rc[mn].Module = child + } else { + log.Printf("Continuing without loading module from filesystem: %s\n", childKey) } - rs[mn].ModuleConfig = m + rc[mn].ModuleConfig = m + + r.PopulateConfigs(mn, childKey, rso, m.Module) } +} - // Loop through resource changes - for _, rc := range plan.ResourceChanges { - id := rc.Address - var parent string +func (r *rover) PopulateModuleState(rso *ResourcesOverview, module *tfjson.StateModule, prior bool) { + childIndex := regexp.MustCompile(`\[[^[\]]*\]$`) - // Check if resource has parent - // part of module, resource w/ count or for_each - if reIsChild.MatchString(id) { - parent = reGetParent.FindString(id) + rs := rso.States - // If resource has parent, create parent if doesn't exist - if _, ok := rs[parent]; !ok { - rs[parent] = &ResourceOverview{} + // Loop through each resource type and populate states + for _, rst := range module.Resources { + id := rst.Address + parent := module.Address + //fmt.Printf("ID: %v\n", id) + if rst.AttributeValues != nil { + + // Add resource to parent + // Create resource if doesn't exist + if _, ok := rs[id]; !ok { + rs[id] = &StateOverview{} + if rst.Mode == "data" { + rs[id].Type = ResourceTypeData + } else { + rs[id].Type = ResourceTypeResource + } } - if rs[parent].Children == nil { - rs[parent].Children = make(map[string]*ResourceOverview) + if _, ok := rs[parent]; !ok { + rs[parent] = &StateOverview{} + rs[parent].Type = ResourceTypeModule + rs[parent].IsParent = false + rs[parent].Children = make(map[string]*StateOverview) } - } - if rc.Change != nil { - // Add resource to parent - if parent != "" { - // Create resource if doesn't exist - if _, ok := rs[parent].Children[id]; !ok { - rs[parent].Children[id] = &ResourceOverview{} + // Check if resource has parent + // part of, resource w/ count or for_each + if childIndex.MatchString(id) { + parent = childIndex.ReplaceAllString(id, "") + // If resource has parent, create parent if doesn't exist + if _, ok := rs[parent]; !ok { + rs[parent] = &StateOverview{} + rs[parent].Children = make(map[string]*StateOverview) + if rst.Mode == "data" { + rs[parent].Type = ResourceTypeData + } else { + rs[parent].Type = ResourceTypeResource + } + } - rs[parent].Children[id].Change = *rc.Change - // Add type and name since it's missing - // TODO: Find long term fix - rs[parent].Children[id].Config.Name = strings.ReplaceAll(rc.Address, fmt.Sprintf("%s.%s.", parent, rc.Type), "") - rs[parent].Children[id].Config.Type = rc.Type + rs[module.Address].Children[parent] = rs[parent] + + } + + //fmt.Printf("%v - %v\n", id, parent) + rs[parent].Children[id] = rs[id] + + if prior { + rs[id].Change.Before = rst.AttributeValues } else { - rs[rc.Address].Change = *rc.Change + rs[id].Change.After = rst.AttributeValues } } } - // Populate prior state - if plan.PriorState != nil { - if plan.PriorState.Values != nil { - if plan.PriorState.Values.RootModule != nil { - for _, rst := range plan.PriorState.Values.RootModule.Resources { - id := rst.Address - var parent string - - // Check if resource has parent - // part of module, resource w/ count or for_each - if reIsChild.MatchString(id) { - parent = reGetParent.FindString(id) - - // If resource has parent, create parent if doesn't exist - if _, ok := rs[parent]; !ok { - rs[parent] = &ResourceOverview{} - } - - if rs[parent].Children == nil { - rs[parent].Children = make(map[string]*ResourceOverview) - } - } + for _, childModule := range module.ChildModules { - if rst.AttributeValues != nil { - // Add resource to parent - if parent != "" { - // Create resource if doesn't exist - if _, ok := rs[parent].Children[id]; !ok { - rs[parent].Children[id] = &ResourceOverview{} - } - rs[parent].Children[id].PriorState = rst.AttributeValues - - // Add type and name since it's missing - // TODO: Find long term fix - rs[parent].Children[id].Config.Name = strings.ReplaceAll(rst.Address, fmt.Sprintf("%s.%s.", parent, rst.Type), "") - rs[parent].Children[id].Config.Type = rst.Type - } else { - rs[rst.Address].PriorState = rst.AttributeValues - } - } - } + parent := module.Address + + id := childModule.Address + + if _, ok := rs[parent]; !ok { + rs[parent] = &StateOverview{} + rs[parent].Children = make(map[string]*StateOverview) + rs[parent].Type = ResourceTypeModule + rs[parent].IsParent = false + } + + if childIndex.MatchString(id) { + parent = childIndex.ReplaceAllString(id, "") + + // If module has parent, create parent if doesn't exist + if _, ok := rs[parent]; !ok { + rs[parent] = &StateOverview{} + rs[parent].Children = make(map[string]*StateOverview) + rs[parent].Type = ResourceTypeModule + rs[parent].IsParent = true + } + + rs[module.Address].Children[parent] = rs[parent] + } + + if rs[parent].Module == nil { + rs[parent].Module = module + } + + if _, ok := rs[id]; !ok { + rs[id] = &StateOverview{} + rs[id].Children = make(map[string]*StateOverview) + rs[id].Type = ResourceTypeModule + } + + rs[id].Module = childModule + + rs[parent].Children[id] = rs[id] + + r.PopulateModuleState(rso, childModule, prior) + } + +} + +// GenerateResourceOverview - Overview of files and their resources +// Groups different resource types together +func (r *rover) GenerateResourceOverview() error { + log.Println("Generating resource overview...") + + matchBrackets := regexp.MustCompile(`\[[^\[\]]*\]`) + rso := &ResourcesOverview{} + + rso.Locations = make(map[string]string) + rso.Configs = make(map[string]*ConfigOverview) + rso.States = make(map[string]*StateOverview) + + rc := rso.Configs + rs := rso.States + + // This is the location of modules.json, which contains where modules are stored on the local filesystem + moduleJSONPath := filepath.Join(r.WorkingDir, ".terraform/modules/modules.json") + r.PopulateModuleLocations(moduleJSONPath, rso.Locations) + + // Create root module configuration + rc[""] = &ConfigOverview{} + rootModule, _ := tfconfig.LoadModule(r.WorkingDir) + // If module can be loaded from filesystem + if !rootModule.Diagnostics.HasErrors() { + rc[""].Module = rootModule + } else { + log.Printf("Could not load configuration from: %v\n", r.WorkingDir) + log.Printf("Continuing without configuration file data...") + } + + rc[""].ModuleConfig = &tfjson.ModuleCall{} + rc[""].ModuleConfig.Module = r.Plan.Config.RootModule + + r.PopulateConfigs("", "", rso, r.Plan.Config.RootModule) + + // Populate prior state + if r.Plan.PriorState != nil { + if r.Plan.PriorState.Values != nil { + if r.Plan.PriorState.Values.RootModule != nil { + r.PopulateModuleState(rso, r.Plan.PriorState.Values.RootModule, true) } } } // Populate planned state - if plan.PlannedValues != nil { - if plan.PlannedValues.RootModule != nil { - for _, rps := range plan.PlannedValues.RootModule.Resources { - id := rps.Address - var parent string - - // Check if resource has parent - // part of module, resource w/ count or for_each - if reIsChild.MatchString(id) { - parent = reGetParent.FindString(id) - - // If resource has parent, create parent if doesn't exist - if _, ok := rs[parent]; !ok { - rs[parent] = &ResourceOverview{} - } + if r.Plan.PlannedValues != nil { + if r.Plan.PlannedValues.RootModule != nil { + r.PopulateModuleState(rso, r.Plan.PlannedValues.RootModule, false) + } + } - if rs[parent].Children == nil { - rs[parent].Children = make(map[string]*ResourceOverview) - } + // Create root module in state if doesn't exist + if _, ok := rs[""]; !ok { + rs[""] = &StateOverview{} + rs[""].Children = make(map[string]*StateOverview) + rs[""].IsParent = false + rs[""].Type = ResourceTypeModule + } + + // reIsChild := regexp.MustCompile(`^\w+\.\w+[\.\[]`) + // reGetParent := regexp.MustCompile(`^\w+\.\w+`) + //reIsChild := regexp.MustCompile(`^\w+\.[\w-]+[\.\[]`) + + // Loop through output changes + for outputName, output := range r.Plan.OutputChanges { + if _, ok := rs[outputName]; !ok { + rs[outputName] = &StateOverview{} + } + + // If before/after sensitive, set value to "Sensitive Value" + if !r.ShowSensitive { + if output.BeforeSensitive != nil { + if output.BeforeSensitive.(bool) { + output.Before = "Sensitive Value" + } + } + if output.AfterSensitive != nil { + if output.AfterSensitive.(bool) { + output.After = "Sensitive Value" } + } + } - if rps.AttributeValues != nil { - // Add resource to parent - if parent != "" { - // Create resource if doesn't exist - if _, ok := rs[parent].Children[id]; !ok { - rs[parent].Children[id] = &ResourceOverview{} - } - rs[parent].Children[id].PlannedState = rps.AttributeValues - - // Add type and name since it's missing - // TODO: Find long term fix - rs[parent].Children[id].Config.Name = strings.ReplaceAll(rps.Address, fmt.Sprintf("%s.%s.", parent, rps.Type), "") - rs[parent].Children[id].Config.Type = rps.Type - } else { - rs[rps.Address].PlannedState = rps.AttributeValues - } + rs[outputName].Change = *output + rs[outputName].Type = ResourceTypeOutput + } + + // Loop through resource changes + for _, resource := range r.Plan.ResourceChanges { + id := resource.Address + configId := matchBrackets.ReplaceAllString(id, "") + parent := resource.ModuleAddress + + if resource.Change != nil { + + // If has parent, create parent if doesn't exist + if _, ok := rs[parent]; !ok { + rs[parent] = &StateOverview{} + rs[parent].Children = make(map[string]*StateOverview) + } + + // Add resource to parent + // Create resource if doesn't exist + if _, ok := rs[id]; !ok { + rs[id] = &StateOverview{} + if resource.Mode == "data" { + rs[id].Type = ResourceTypeData + } else { + rs[id].Type = ResourceTypeResource } + rs[parent].Children[id] = rs[id] } + rs[id].Change = *resource.Change + + // Create resource config if doesn't exist + if _, ok := rc[configId]; !ok { + rc[configId] = &ConfigOverview{} + rc[configId].ResourceConfig = &tfjson.ConfigResource{} + + // Add type and name since it's missing + // TODO: Find long term fix + rc[configId].ResourceConfig.Name = resource.Name + rc[configId].ResourceConfig.Type = resource.Type + } + } } - rso.Resources = rs + r.RSO = rso - return rso + return nil } diff --git a/screenshot.go b/screenshot.go new file mode 100644 index 0000000..998ef7c --- /dev/null +++ b/screenshot.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" + + "github.com/chromedp/cdproto/browser" + "github.com/chromedp/chromedp" +) + +// Heavily inspired by: https://github.com/chromedp/examples/blob/master/download_file/main.go +func screenshot(s *http.Server) { + // ctx, cancel := chromedp.NewContext(context.Background(), chromedp.WithDebugf(log.Printf)) + ctx, cancel := chromedp.NewContext(context.Background()) + defer cancel() + + // create a timeout as a safety net to prevent any infinite wait loops + ctx, cancel = context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + url := fmt.Sprintf("http://%s", s.Addr) + + // this will be used to capture the file name later + var downloadGUID string + + downloadComplete := make(chan bool) + chromedp.ListenTarget(ctx, func(v interface{}) { + if ev, ok := v.(*browser.EventDownloadProgress); ok { + if ev.State == browser.DownloadProgressStateCompleted { + downloadGUID = ev.GUID + close(downloadComplete) + } + } + }) + + if err := chromedp.Run(ctx, chromedp.Tasks{ + browser.SetDownloadBehavior(browser.SetDownloadBehaviorBehaviorAllowAndName). + WithDownloadPath(os.TempDir()). + WithEventsEnabled(true), + + chromedp.Navigate(url), + // wait for graph to be visible + chromedp.WaitVisible(`#cytoscape-div`), + // find and click "Save Graph" button + chromedp.Click(`#saveGraph`, chromedp.NodeVisible), + }); err != nil && !strings.Contains(err.Error(), "net::ERR_ABORTED") { + // Note: Ignoring the net::ERR_ABORTED page error is essential here since downloads + // will cause this error to be emitted, although the download will still succeed. + log.Fatal(err) + } + <-downloadComplete + + e := moveFile(fmt.Sprintf("%v/%v", os.TempDir(), downloadGUID), "./rover.svg") + if e != nil { + log.Fatal(e) + } + + log.Println("Image generation complete.") + + // Shutdown http server + s.Shutdown(context.Background()) +} + +// This function resolves the "invalid cross-device link" error for moving files +// between volumes for Docker. +// https://gist.github.com/var23rav/23ae5d0d4d830aff886c3c970b8f6c6b +func moveFile(sourcePath, destPath string) error { + inputFile, err := os.Open(sourcePath) + if err != nil { + return fmt.Errorf("Couldn't open source file: %s", err) + } + outputFile, err := os.Create(destPath) + if err != nil { + inputFile.Close() + return fmt.Errorf("Couldn't open dest file: %s", err) + } + defer outputFile.Close() + _, err = io.Copy(outputFile, inputFile) + inputFile.Close() + if err != nil { + return fmt.Errorf("Writing to output file failed: %s", err) + } + // The copy was successful, so now delete the original file + err = os.Remove(sourcePath) + if err != nil { + return fmt.Errorf("Failed removing original file: %s", err) + } + return nil +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..0c73023 --- /dev/null +++ b/server.go @@ -0,0 +1,79 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "strings" + // tfjson "github.com/hashicorp/terraform-json" +) + +func (ro *rover) startServer(ipPort string, frontendFS http.Handler) error { + + m := http.NewServeMux() + s := http.Server{Addr: ipPort, Handler: m} + + m.Handle("/", frontendFS) + m.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + // simple healthcheck + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"alive": true}`) + }) + m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { + fileType := strings.Replace(r.URL.Path, "/api/", "", 1) + + var j []byte + var err error + + enableCors(&w) + + switch fileType { + case "plan": + j, err = json.Marshal(ro.Plan) + if err != nil { + io.WriteString(w, fmt.Sprintf("Error producing plan JSON: %s\n", err)) + } + case "rso": + j, err = json.Marshal(ro.RSO) + if err != nil { + io.WriteString(w, fmt.Sprintf("Error producing rso JSON: %s\n", err)) + } + case "map": + j, err = json.Marshal(ro.Map) + if err != nil { + io.WriteString(w, fmt.Sprintf("Error producing map JSON: %s\n", err)) + } + case "graph": + j, err = json.Marshal(ro.Graph) + if err != nil { + io.WriteString(w, fmt.Sprintf("Error producing graph JSON: %s\n", err)) + } + default: + io.WriteString(w, "Please enter a valid file type: plan, rso, map, graph\n") + } + + w.Header().Set("Content-Type", "application/json") + io.Copy(w, bytes.NewReader(j)) + }) + + log.Printf("Rover is running on %s", ipPort) + + l, err := net.Listen("tcp", ipPort) + if err != nil { + log.Fatal(err) + } + + // The browser can connect now because the listening socket is open. + if ro.GenImage { + go screenshot(&s) + } + + // Start the blocking server loop. + return s.Serve(l) + +} diff --git a/ui/babel.config.js b/ui/babel.config.js index e955840..fded4b1 100644 --- a/ui/babel.config.js +++ b/ui/babel.config.js @@ -1,5 +1,4 @@ module.exports = { - presets: [ - '@vue/cli-plugin-babel/preset' - ] -} + presets: ['@vue/cli-plugin-babel/preset'], + plugins: ['@babel/plugin-proposal-optional-chaining'] +} \ No newline at end of file diff --git a/ui/dist/css/app.52fc4fd2.css b/ui/dist/css/app.52fc4fd2.css deleted file mode 100644 index 2173567..0000000 --- a/ui/dist/css/app.52fc4fd2.css +++ /dev/null @@ -1 +0,0 @@ -.title[data-v-b2b0816c]{padding:0}#resource-details[data-v-0d84eb83]{position:sticky;top:1em;min-width:0}.tab-container[data-v-0d84eb83]{max-height:70vh;overflow:scroll}fieldset[data-v-0d84eb83]{margin-bottom:2em}.tabs a[data-v-0d84eb83]:hover{cursor:pointer}.resource-detail[data-v-0d84eb83],.tab-container[data-v-0d84eb83]{padding:1em 0}.tabs .disabled[data-v-0d84eb83]:hover{cursor:not-allowed;border-bottom:4px solid var(--color-lightGrey)}p[data-v-0d84eb83]{word-break:break-all;white-space:normal}a[data-v-0d84eb83]{font-weight:700;border-width:4px!important}.key[data-v-0d84eb83]{font-weight:700;font-size:.9em;text-transform:uppercase;margin:0}dd[data-v-0d84eb83]{display:inline-block}dt.value[data-v-0d84eb83]{margin:.5em 0 1em 0;padding:.5em;font-size:1em;background-color:#f4ecff;color:#000;display:flex;align-items:center;justify-content:space-between}.resource-id[data-v-0d84eb83]{word-wrap:break-word;overflow:hidden;width:100%}.resource-action[data-v-0d84eb83]{float:right}.is-child-resource[data-v-0d84eb83]{display:block}.is-child-resource[data-v-0d84eb83],.unknown-value[data-v-0d84eb83]{text-align:center;font-weight:700;font-style:italic}.copy-button[data-v-0d84eb83]{font-size:.9em;padding:1rem;align-items:flex-end;background-color:#8450ba;color:#fff;font-weight:700}.copy-button[data-v-0d84eb83]:hover{cursor:pointer}#cytoscape-div{height:1000px!important;background-color:#f8f8f8!important}.node{width:14em;font-size:2em;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-align:center;padding:.5em .5em;border-radius:.25em;background-color:#fff;color:#000;font-weight:700;cursor:pointer;border:5px solid #d3d3d3}.node:hover{transform:scale(1.02)}.resource-type{width:20em;font-size:2em;height:100%}.create{background-color:#28a745}.create,.destroy{color:#fff;font-weight:700;border:0}.destroy{background-color:#e40707}.update{background-color:#1d7ada;color:#fff}.replace,.update{font-weight:700;border:0}.replace{background-color:#ffc107;color:#000}.output{background-color:#fff7e0;border:5px solid #ffc107}.output,.variable{color:#000;font-weight:700}.variable{background-color:#e1f0ff;border:5px solid #1d7ada}.data{background-color:#ffecec;border:5px solid #dc477d;color:#000}.data,.locals{font-weight:700}.locals{background-color:#000;color:#fff;border:0}fieldset[data-v-3718cd32]{margin-bottom:2em}.graph-enter-active[data-v-3718cd32],.graph-enter-active legend[data-v-3718cd32],.graph-leave-active[data-v-3718cd32],.graph-leave-active legend[data-v-3718cd32]{transition:all .2s ease;overflow:hidden}.graph-enter[data-v-3718cd32],.graph-enter legend[data-v-3718cd32],.graph-leave-to[data-v-3718cd32],.graph-leave-to legend[data-v-3718cd32]{height:0;padding:0;margin:0;opacity:0}.card[data-v-11b846ff]{margin:.5em 0;border-radius:0;border-width:2px;font-weight:400}.tag[data-v-11b846ff]{border:1px solid var(--color-grey)}.card.child[data-v-11b846ff]{margin:0 -1.3em}.card.child[data-v-11b846ff]:hover{border-width:2px;border-left:0 solid;border-right:0 solid;filter:brightness(.95)}.col[data-v-11b846ff]{margin-bottom:0}.resource-main[data-v-11b846ff]:hover{cursor:pointer;filter:brightness(.95)}.child.resource-main[data-v-11b846ff]{border-left:1px solid;border-right:1px solid}.dark .resource-main[data-v-11b846ff]:hover{cursor:pointer;background-color:#0d032b}.dark .child.resource-main[data-v-11b846ff]{background-color:#1c1c3f}.dark .child.resource-main[data-v-11b846ff]:hover{background-color:#131342!important}.resource-col[data-v-11b846ff]{margin-left:.1em}.resource-action[data-v-11b846ff]{float:left;margin:0;margin-right:.5em}.file-expand-icon[data-v-11b846ff],.resource-action-icon[data-v-11b846ff]{width:1em;padding-top:.1em}.dark .multi-tag[data-v-11b846ff]{filter:invert(100%)}.resource-name[data-v-11b846ff]{width:80%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;float:left}.provider-icon-tag[data-v-11b846ff]{float:left;margin:0 1em 0 0!important;font-weight:700}.provider-icon[data-v-11b846ff]{float:left;width:1.75em;margin:-.2em .5em 0 -.3em!important}.provider-resource-name[data-v-11b846ff]{width:85%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;float:left}.line-number[data-v-11b846ff]{display:inline-block;min-width:2em}.resources-enter-active[data-v-11b846ff],.resources-leave-active[data-v-11b846ff]{transition:all .2s ease;overflow:hidden}.resources-enter[data-v-11b846ff],.resources-leave-to[data-v-11b846ff]{height:0;padding:0;margin:0;opacity:0}.module[data-v-11b846ff]{border:2px solid #8450ba}.resource-card.create[data-v-11b846ff]{border-color:#28a745}.resource-card.output[data-v-11b846ff]{border-color:#ffc107}.resource-card.destroy[data-v-11b846ff]{border-color:#e40707}.resource-card.update[data-v-11b846ff]{border-color:#1d7ada}.resource-card.replace[data-v-11b846ff]{border-color:#ffc107}.resource-type-card[data-v-11b846ff]{margin-top:.5em!important}.file[data-v-5ef7e534]{margin-bottom:1em}.file-name[data-v-5ef7e534]{margin-bottom:0;margin-top:.25em}.file-name[data-v-5ef7e534]:hover{cursor:pointer}.resources-enter-active[data-v-5ef7e534],.resources-leave-active[data-v-5ef7e534]{transition:all .2s ease;overflow:hidden}.resources-enter[data-v-5ef7e534],.resources-leave-to[data-v-5ef7e534]{height:0;padding:0;margin:0;opacity:0}.file-expand-icon[data-v-5ef7e534]{width:1em;padding-top:.1em;margin-left:1.4em}fieldset[data-v-459dfd99]{margin-bottom:2em}#app[data-v-08c2df7e]{font-family:Avenir,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;margin:0 auto;margin-top:60px;width:90%}.node[data-v-08c2df7e]{display:inline-block;margin:0 1%;width:48%;font-size:.9em}.module[data-v-08c2df7e]{border:5px solid #8450ba;color:#8450ba} \ No newline at end of file diff --git a/ui/dist/css/app.620d0115.css b/ui/dist/css/app.620d0115.css new file mode 100644 index 0000000..37f62d4 --- /dev/null +++ b/ui/dist/css/app.620d0115.css @@ -0,0 +1 @@ +.title[data-v-bfab64d6]{padding:0}#resource-details[data-v-4e3cd299]{position:sticky;top:1em;min-width:0}.tab-container[data-v-4e3cd299]{max-height:70vh;overflow:scroll}fieldset[data-v-4e3cd299]{margin-bottom:2em}.tabs a[data-v-4e3cd299]:hover{cursor:pointer}.resource-detail[data-v-4e3cd299],.tab-container[data-v-4e3cd299]{padding:1em 0}.tabs .disabled[data-v-4e3cd299]:hover{cursor:not-allowed;border-bottom:4px solid var(--color-lightGrey)}p[data-v-4e3cd299]{word-break:break-all;white-space:normal}a[data-v-4e3cd299]{font-weight:700;border-width:4px!important}.key[data-v-4e3cd299]{font-weight:700;font-size:.9em;text-transform:uppercase;margin:0}dd[data-v-4e3cd299]{display:inline-block}dt.value[data-v-4e3cd299]{margin:.5em 0 1em 0;padding:.5em;font-size:1em;background-color:#f4ecff;color:#000;display:flex;align-items:center;justify-content:space-between}.resource-id[data-v-4e3cd299]{word-wrap:break-word;overflow:hidden;width:100%}.resource-action[data-v-4e3cd299]{float:right}.is-child-resource[data-v-4e3cd299]{display:block}.is-child-resource[data-v-4e3cd299],.unknown-value[data-v-4e3cd299]{text-align:center;font-weight:700;font-style:italic}.copy-button[data-v-4e3cd299]{font-size:.9em;padding:1rem;align-items:flex-end;background-color:#8450ba;color:#fff;font-weight:700}.copy-button[data-v-4e3cd299]:hover{cursor:pointer}#cytoscape-div{height:1000px!important;background-color:#f8f8f8!important}.node{width:14em;font-size:2em;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-align:center;padding:.5em .5em;border-radius:.25em;background-color:#fff;color:#000;font-weight:700;cursor:pointer;border:5px solid #d3d3d3}.node:hover{transform:scale(1.02)}.resource-type{width:20em;font-size:2em;height:100%}.create{background-color:#28a745}.create,.delete{color:#fff;font-weight:700;border:0}.delete{background-color:#e40707}.update{background-color:#1d7ada;color:#fff}.replace,.update{font-weight:700;border:0}.replace{background-color:#ffc107;color:#000}.output{background-color:#fff7e0;border:5px solid #ffc107}.output,.variable{color:#000;font-weight:700}.variable{background-color:#e1f0ff;border:5px solid #1d7ada}.data{background-color:#ffecec;border:5px solid #dc477d;color:#000}.data,.locals{font-weight:700}.locals{background-color:#000;color:#fff;border:0}fieldset[data-v-11c2dcd0]{margin-bottom:2em}.graph-enter-active[data-v-11c2dcd0],.graph-enter-active legend[data-v-11c2dcd0],.graph-leave-active[data-v-11c2dcd0],.graph-leave-active legend[data-v-11c2dcd0]{transition:all .2s ease;overflow:hidden}.graph-enter[data-v-11c2dcd0],.graph-enter legend[data-v-11c2dcd0],.graph-leave-to[data-v-11c2dcd0],.graph-leave-to legend[data-v-11c2dcd0]{height:0;padding:0;margin:0;opacity:0}.card[data-v-2a9b2d96]{margin:.5em 0;border-radius:0;border-width:2px;font-weight:400}.tag[data-v-2a9b2d96]{border:1px solid var(--color-grey)}.card.child[data-v-2a9b2d96]{margin:0 -1.3em}.card.child[data-v-2a9b2d96]:hover{border-width:2px;border-left:0 solid;border-right:0 solid;filter:brightness(.95)}.col[data-v-2a9b2d96]{margin-bottom:0}.resource-main[data-v-2a9b2d96]:hover{cursor:pointer;filter:brightness(.95)}.child.resource-main[data-v-2a9b2d96]{border-left:1px solid;border-right:1px solid}.dark .resource-main[data-v-2a9b2d96]:hover{cursor:pointer;background-color:#0d032b}.dark .child.resource-main[data-v-2a9b2d96]{background-color:#1c1c3f}.dark .child.resource-main[data-v-2a9b2d96]:hover{background-color:#131342!important}.resource-col[data-v-2a9b2d96]{margin-left:.1em}.resource-action[data-v-2a9b2d96]{float:left;margin:0;margin-right:.5em}.file-expand-icon[data-v-2a9b2d96],.resource-action-icon[data-v-2a9b2d96]{width:1em;padding-top:.1em}.dark .multi-tag[data-v-2a9b2d96]{filter:invert(100%)}.resource-name[data-v-2a9b2d96]{width:80%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;float:left}.provider-icon-tag[data-v-2a9b2d96]{float:left;margin:0 1em 0 0!important;font-weight:700}.provider-icon[data-v-2a9b2d96]{float:left;width:1.75em;margin:-.2em .5em 0 -.3em!important}.provider-resource-name[data-v-2a9b2d96]{width:85%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;float:left}.line-number[data-v-2a9b2d96]{display:inline-block;min-width:2em}.resources-enter-active[data-v-2a9b2d96],.resources-leave-active[data-v-2a9b2d96]{transition:all .2s ease;overflow:hidden}.resources-enter[data-v-2a9b2d96],.resources-leave-to[data-v-2a9b2d96]{height:0;padding:0;margin:0;opacity:0}.module[data-v-2a9b2d96]{border:2px solid #8450ba}.resource-card.create[data-v-2a9b2d96]{border-color:#28a745}.resource-card.output[data-v-2a9b2d96]{border-color:#ffc107}.resource-card.delete[data-v-2a9b2d96]{border-color:#e40707}.resource-card.update[data-v-2a9b2d96]{border-color:#1d7ada}.resource-card.replace[data-v-2a9b2d96]{border-color:#ffc107}.resource-type-card[data-v-2a9b2d96]{margin-top:.5em!important}.file[data-v-3d7b7730]{margin-bottom:1em}.file-name[data-v-3d7b7730]{margin-bottom:0;margin-top:.25em}.file-name[data-v-3d7b7730]:hover{cursor:pointer}.resources-enter-active[data-v-3d7b7730],.resources-leave-active[data-v-3d7b7730]{transition:all .2s ease;overflow:hidden}.resources-enter[data-v-3d7b7730],.resources-leave-to[data-v-3d7b7730]{height:0;padding:0;margin:0;opacity:0}.file-expand-icon[data-v-3d7b7730]{width:1em;padding-top:.1em;margin-left:1.4em}fieldset[data-v-1cda27d5]{margin-bottom:2em}#app[data-v-5cf12920]{font-family:Avenir,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;margin:0 auto;margin-top:60px;width:90%}.node[data-v-5cf12920]{display:inline-block;margin:0 1%;width:48%;font-size:.9em}.module[data-v-5cf12920]{border:5px solid #8450ba;color:#8450ba} \ No newline at end of file diff --git a/ui/dist/index.html b/ui/dist/index.html index 311ba73..c7ff6d8 100644 --- a/ui/dist/index.html +++ b/ui/dist/index.html @@ -1 +1 @@ -
\n \n
\n {{ content.name }}\n
\n\n {{ resourceProvider ? `${resourceProvider}.` : \"\"\n }}{{ content.resource_type ? content.resource_type : \"\" }}\n
\n\n \n
\n {{ content.name }}\n
\n\n {{ resourceProvider ? `${resourceProvider}.` : \"\"\n }}{{ content.resource_type ? content.resource_type : \"\" }}\n
\n