├── .dockerignore ├── .github └── workflows │ ├── build.yml │ ├── publishDockerImage.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── Dockerfile ├── LICENSE ├── README.md ├── docs ├── rover-cropped-screenshot.png └── rover-full-screenshot.png ├── example ├── multiple-files-same-resource-type-test │ ├── file-one.tf │ └── file-two.tf ├── nested-test │ ├── main.tf │ └── nested-module │ │ └── main.tf ├── random-test │ ├── .gitignore │ ├── .terraform.lock.hcl │ ├── main.tf │ ├── random-name │ │ └── main.tf │ └── test.tfvars └── simple-test │ └── main.tf ├── go.mod ├── go.sum ├── graph.go ├── main.go ├── map.go ├── rso.go ├── screenshot.go ├── server.go ├── ui ├── .gitignore ├── README.md ├── babel.config.js ├── dist │ ├── chota.min.css │ ├── css │ │ └── app.620d0115.css │ ├── favicon.ico │ ├── img │ │ ├── alert-triangle.d88bf755.svg │ │ ├── arrow-down-circle.27fdf30c.svg │ │ ├── arrow-up-circle.c7e27cfe.svg │ │ ├── aws.082444af.png │ │ ├── azure.0386fb3d.png │ │ ├── gcp.2bdb5143.png │ │ ├── helm.0d1950ff.png │ │ ├── kubernetes.36fdbc6b.png │ │ ├── minus.f2deefda.svg │ │ ├── plus.b121a385.svg │ │ └── refresh-cw.286819b2.svg │ ├── index.html │ ├── js │ │ ├── app.3f69df0b.js │ │ ├── app.3f69df0b.js.map │ │ ├── chunk-vendors.f533c4a1.js │ │ └── chunk-vendors.f533c4a1.js.map │ └── style.css ├── package-lock.json ├── package.json ├── public │ ├── chota.min.css │ ├── favicon.ico │ ├── index.html │ └── style.css └── src │ ├── App.vue │ ├── assets │ ├── icons │ │ ├── arrow-down-circle.svg │ │ └── arrow-up-circle.svg │ ├── logo.png │ ├── provider-icons │ │ ├── aws.png │ │ ├── azure.png │ │ ├── gcp.png │ │ ├── helm.png │ │ └── kubernetes.png │ └── resource-icons │ │ ├── alert-triangle.svg │ │ ├── minus.svg │ │ ├── plus.svg │ │ └── refresh-cw.svg │ ├── components │ ├── Explorer.vue │ ├── File.vue │ ├── Graph │ │ └── Graph.vue │ ├── MainNav.vue │ ├── ResourceCard.vue │ └── ResourceDetail.vue │ └── main.js └── zip.go /.dockerignore: -------------------------------------------------------------------------------- 1 | # ignore .git 2 | .git 3 | .github 4 | docs 5 | example 6 | rover 7 | **/node_modules 8 | **/dist -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: 20 20 | - name: Setup Go 21 | uses: actions/setup-go@v4 22 | with: 23 | go-version-file: go.mod 24 | - name: UI build 25 | run: | 26 | cd ui 27 | npm install 28 | npm run build 29 | - name: Go Build 30 | run: go build 31 | -------------------------------------------------------------------------------- /.github/workflows/publishDockerImage.yml: -------------------------------------------------------------------------------- 1 | name: PublishDockerImage 2 | 3 | # Run whenever the publish job runs 4 | on: 5 | push: 6 | tags: 7 | - "v*" 8 | 9 | jobs: 10 | push_to_registry: 11 | name: Push Docker image to Docker Hub 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Check out the repo 15 | uses: actions/checkout@v3 16 | 17 | - name: Log in to Docker Hub 18 | uses: docker/login-action@v1 19 | with: 20 | username: ${{ secrets.DOCKER_USERNAME }} 21 | password: ${{ secrets.DOCKER_PASSWORD }} 22 | 23 | - name: Extract metadata (tags, labels) for Docker 24 | id: meta 25 | uses: docker/metadata-action@v3 26 | with: 27 | images: im2nguyen/rover 28 | 29 | - name: Build and push Docker image 30 | uses: docker/build-push-action@v2 31 | with: 32 | context: . 33 | push: true 34 | tags: ${{ steps.meta.outputs.tags }} 35 | labels: ${{ steps.meta.outputs.labels }} 36 | build-args: 37 | TF_VERSION=1.1.2 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This GitHub action can publish assets for release when a tag is created. 2 | # Currently its setup to run on any tag that matches the pattern "v*" (ie. v0.1.0). 3 | # 4 | # This uses an action (hashicorp/ghaction-import-gpg) that assumes you set your 5 | # private key in the `GPG_PRIVATE_KEY` secret and passphrase in the `PASSPHRASE` 6 | # secret. If you would rather own your own GPG handling, please fork this action 7 | # or use an alternative one for key handling. 8 | # 9 | # You will need to pass the `--batch` flag to `gpg` in your signing step 10 | # in `goreleaser` to indicate this is being used in a non-interactive mode. 11 | # 12 | name: release 13 | on: 14 | push: 15 | tags: 16 | - "v*" 17 | jobs: 18 | goreleaser: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v3 23 | - name: Unshallow 24 | run: git fetch --prune --unshallow 25 | - name: Set up Go 26 | uses: actions/setup-go@v4 27 | with: 28 | go-version-file: go.mod 29 | - name: Import GPG key 30 | id: import_gpg 31 | uses: crazy-max/ghaction-import-gpg@v5 32 | with: 33 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} 34 | passphrase: ${{ secrets.PASSPHRASE }} 35 | - name: Run GoReleaser 36 | uses: goreleaser/goreleaser-action@v2 37 | with: 38 | version: latest 39 | args: release --rm-dist 40 | env: 41 | GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/rover 2 | .DS_Store 3 | rover.zip 4 | plan.out 5 | 6 | # Ignore generated terraform files 7 | .terraform** 8 | 9 | .idea/ 10 | 11 | build/ -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # Visit https://goreleaser.com for documentation on how to customize this 2 | # behavior. 3 | before: 4 | hooks: 5 | # this is just an example and not a requirement for provider building/publishing 6 | - go mod tidy 7 | builds: 8 | - env: 9 | # goreleaser does not work with CGO, it could also complicate 10 | # usage by users in CI/CD systems like Terraform Cloud where 11 | # they are unable to install libraries. 12 | - CGO_ENABLED=0 13 | mod_timestamp: "{{ .CommitTimestamp }}" 14 | flags: 15 | - -trimpath 16 | ldflags: 17 | - "-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}" 18 | goos: 19 | - freebsd 20 | - windows 21 | - linux 22 | - darwin 23 | goarch: 24 | - amd64 25 | - "386" 26 | - arm 27 | - arm64 28 | ignore: 29 | - goos: darwin 30 | goarch: "386" 31 | binary: "{{ .ProjectName }}_v{{ .Version }}" 32 | archives: 33 | - format: zip 34 | name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" 35 | checksum: 36 | name_template: "{{ .ProjectName }}_{{ .Version }}_SHA256SUMS" 37 | algorithm: sha256 38 | signs: 39 | - artifacts: checksum 40 | args: 41 | # if you are using this is a GitHub action or some other automated pipeline, you 42 | # need to pass the batch flag to indicate its not interactive. 43 | - "--batch" 44 | - "--local-user" 45 | - "{{ .Env.GPG_FINGERPRINT }}" # set this environment variable for your signing key 46 | - "--output" 47 | - "${signature}" 48 | - "--detach-sign" 49 | - "${artifact}" 50 | release: 51 | # If you want to manually examine the release before its live, uncomment this line: 52 | # draft: true 53 | changelog: 54 | skip: true 55 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Prep base stage 2 | ARG TF_VERSION=light 3 | 4 | # Build ui 5 | FROM node:20-alpine as ui 6 | WORKDIR /src 7 | # Copy specific package files 8 | COPY ./ui/package-lock.json ./ 9 | COPY ./ui/package.json ./ 10 | COPY ./ui/babel.config.js ./ 11 | # Set Progress, Config and install 12 | RUN npm set progress=false && npm config set depth 0 && npm install 13 | # Copy source 14 | # Copy Specific Directories 15 | COPY ./ui/public ./public 16 | COPY ./ui/src ./src 17 | # build (to dist folder) 18 | RUN NODE_OPTIONS='--openssl-legacy-provider' npm run build 19 | 20 | # Build rover 21 | FROM golang:1.21 AS rover 22 | WORKDIR /src 23 | # Copy full source 24 | COPY . . 25 | # Copy ui/dist from ui stage as it needs to embedded 26 | COPY --from=ui ./src/dist ./ui/dist 27 | # Build rover 28 | RUN go get -d -v golang.org/x/net/html 29 | RUN CGO_ENABLED=0 GOOS=linux go build -o rover . 30 | 31 | # Release stage 32 | FROM hashicorp/terraform:$TF_VERSION AS release 33 | # Copy terraform binary to the rover's default terraform path 34 | RUN cp /bin/terraform /usr/local/bin/terraform 35 | # Copy rover binary 36 | COPY --from=rover /src/rover /bin/rover 37 | RUN chmod +x /bin/rover 38 | 39 | # Install Google Chrome 40 | RUN apk add chromium 41 | 42 | WORKDIR /src 43 | 44 | ENTRYPOINT [ "/bin/rover" ] 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 rover 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Rover - Terraform Visualizer 2 | 3 | Rover is a [Terraform](http://terraform.io/) visualizer. 4 | 5 | In order to do this, Rover: 6 | 7 | 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. 8 | 1. parses the `plan` and configuration files to generate three items: the resource overview (`rso`), the resource map (`map`), and the resource graph (`graph`). 9 | 1. consumes the `rso`, `map`, and `graph` to generate an interactive configuration and state visualization hosts on `0.0.0.0:9000`. 10 | 11 | Feedback (via issues) and pull requests are appreciated! 12 | 13 | ![Rover Screenshot](docs/rover-cropped-screenshot.png) 14 | 15 | ## Quickstart 16 | 17 | The fastest way to get up and running with Rover is through Docker. 18 | 19 | Run the following command in any Terraform workspace to generate a visualization. This command copies all the files in your current directory to the Rover container and exposes port `:9000`. 20 | 21 | ``` 22 | $ docker run --rm -it -p 9000:9000 -v $(pwd):/src im2nguyen/rover 23 | 2021/07/02 06:46:23 Starting Rover... 24 | 2021/07/02 06:46:23 Initializing Terraform... 25 | 2021/07/02 06:46:24 Generating plan... 26 | 2021/07/02 06:46:25 Parsing configuration... 27 | 2021/07/02 06:46:25 Generating resource overview... 28 | 2021/07/02 06:46:25 Generating resource map... 29 | 2021/07/02 06:46:25 Generating resource graph... 30 | 2021/07/02 06:46:25 Done generating assets. 31 | 2021/07/02 06:46:25 Rover is running on 0.0.0.0:9000 32 | ``` 33 | 34 | Once Rover runs on `0.0.0.0:9000`, navigate to it to find the visualization! 35 | 36 | ### Run on Terraform plan file 37 | 38 | Use `-planJSONPath` to start Rover on Terraform plan file. The `plan.json` file should be in Linux version - Unix (LF), UTF-8. 39 | 40 | First, generate the plan file in JSON format. 41 | 42 | ``` 43 | $ terraform plan -out plan.out 44 | $ terraform show -json plan.out > plan.json 45 | ``` 46 | 47 | Then, run Rover on it. 48 | 49 | ``` 50 | $ docker run --rm -it -p 9000:9000 -v $(pwd)/plan.json:/src/plan.json im2nguyen/rover:latest -planJSONPath=plan.json 51 | ``` 52 | 53 | ### Standalone mode 54 | 55 | Standalone mode generates a `rover.zip` file containing all the static assets. 56 | 57 | ``` 58 | $ docker run --rm -it -p 9000:9000 -v "$(pwd):/src" im2nguyen/rover -standalone true 59 | ``` 60 | 61 | After all the assets are generated, unzip `rover.zip` and open `rover/index.html` in your favourite web browser. 62 | 63 | ### Set environment variables 64 | 65 | 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. 66 | 67 | ``` 68 | $ printenv | grep "AWS" > .env 69 | ``` 70 | 71 | Then, add it as environment variables to your Docker container with `--env-file`. 72 | 73 | ``` 74 | $ docker run --rm -it -p 9000:9000 -v "$(pwd):/src" --env-file ./.env im2nguyen/rover 75 | ``` 76 | 77 | ### Define tfbackend, tfvars and Terraform variables 78 | 79 | 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. 80 | 81 | ``` 82 | $ docker run --rm -it -p 9000:9000 -v "$(pwd):/src" im2nguyen/rover -tfBackendConfig test.tfbackend -tfVarsFile test.tfvars -tfVar max_length=4 83 | ``` 84 | 85 | ### Image generation 86 | 87 | Use `-genImage` to generate and save the visualization as a SVG image. 88 | 89 | ``` 90 | $ docker run --rm -it -v "$(pwd):/src" im2nguyen/rover -genImage true 91 | ``` 92 | 93 | ## Installation 94 | 95 | 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`. 96 | 97 | - [rover zip — MacOS - intel](https://github.com/im2nguyen/rover/releases/download/v0.3.2/rover_0.3.2_darwin_amd64.zip) 98 | - [rover zip — MacOS - Apple Silicon](https://github.com/im2nguyen/rover/releases/download/v0.3.2/rover_0.3.2_darwin_arm64.zip) 99 | - [rover zip — Windows](https://github.com/im2nguyen/rover/releases/download/v0.3.2/rover_0.3.2_windows_amd64.zip) 100 | 101 | ### Build from source 102 | 103 | You can build Rover manually by cloning this repository, then building the frontend and compiling the binary. It requires Go v1.21+ and `npm`. 104 | 105 | #### Build frontend 106 | 107 | First, navigate to the `ui`. 108 | 109 | ``` 110 | $ cd ui 111 | ``` 112 | 113 | Then, install the dependencies. 114 | 115 | ``` 116 | $ npm install 117 | ``` 118 | 119 | Finally, build the frontend. 120 | 121 | ``` 122 | $ npm run build 123 | ``` 124 | 125 | #### Compile binary 126 | 127 | Navigate to the root directory. 128 | 129 | ``` 130 | $ cd .. 131 | ``` 132 | 133 | Compile and install the binary. Alternatively, you can use `go build` and move the binary into your `PATH`. 134 | 135 | ``` 136 | $ go install 137 | ``` 138 | 139 | ### Build Docker image 140 | 141 | First, compile the binary for `linux/amd64`. 142 | 143 | ``` 144 | $ env GOOS=linux GOARCH=amd64 go build . 145 | ``` 146 | 147 | Then, build the Docker image. 148 | 149 | ``` 150 | $ docker build . -t im2nguyen/rover --no-cache 151 | ``` 152 | 153 | 154 | ## Basic usage 155 | 156 | This repository contains two examples of Terraform configurations in `example`. 157 | 158 | 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. 159 | 160 | ``` 161 | $ cd example/random-test 162 | ``` 163 | 164 | Run Rover. Rover will start running in the current directory and assume the Terraform binary lives in `/usr/local/bin/terraform` by default. 165 | 166 | ``` 167 | $ rover 168 | 2021/06/23 22:51:27 Starting Rover... 169 | 2021/06/23 22:51:27 Initializing Terraform... 170 | 2021/06/23 22:51:28 Generating plan... 171 | 2021/06/23 22:51:28 Parsing configuration... 172 | 2021/06/23 22:51:28 Generating resource overview... 173 | 2021/06/23 22:51:28 Generating resource map... 174 | 2021/06/23 22:51:28 Generating resource graph... 175 | 2021/06/23 22:51:28 Done generating assets. 176 | 2021/06/23 22:51:28 Rover is running on 0.0.0.0:9000 177 | ``` 178 | 179 | You can specify the working directory (where your configuration is living) and the Terraform binary location using flags. 180 | 181 | ``` 182 | $ rover -workingDir "example/eks-cluster" -tfPath "/Users/dos/terraform" 183 | ``` 184 | 185 | Once Rover runs on `0.0.0.0:9000`, navigate to it to find the visualization! 186 | -------------------------------------------------------------------------------- /docs/rover-cropped-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im2nguyen/rover/4be74731103aca85eee1dd93e47351e17979090b/docs/rover-cropped-screenshot.png -------------------------------------------------------------------------------- /docs/rover-full-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im2nguyen/rover/4be74731103aca85eee1dd93e47351e17979090b/docs/rover-full-screenshot.png -------------------------------------------------------------------------------- /example/multiple-files-same-resource-type-test/file-one.tf: -------------------------------------------------------------------------------- 1 | resource "random_integer" "one" { 2 | min = 1 3 | max = 3 4 | } -------------------------------------------------------------------------------- /example/multiple-files-same-resource-type-test/file-two.tf: -------------------------------------------------------------------------------- 1 | resource "random_integer" "two" { 2 | min = 1 3 | max = 4 4 | } -------------------------------------------------------------------------------- /example/nested-test/main.tf: -------------------------------------------------------------------------------- 1 | module "sub_module" { 2 | source = "./nested-module" 3 | 4 | } -------------------------------------------------------------------------------- /example/nested-test/nested-module/main.tf: -------------------------------------------------------------------------------- 1 | module "remote_module" { 2 | source = "git::https://github.com/im2nguyen/rover.git//example/random-test/random-name" 3 | 4 | max_length = "3" 5 | 6 | } -------------------------------------------------------------------------------- /example/random-test/.gitignore: -------------------------------------------------------------------------------- 1 | # Local .terraform directories 2 | **/.terraform/* 3 | 4 | # .tfstate files 5 | *.tfstate 6 | *.tfstate.* 7 | 8 | # Crash log files 9 | crash.log 10 | 11 | # Ignore any .tfvars files that are generated automatically for each Terraform run. Most 12 | # .tfvars files are managed as part of configuration and so should be included in 13 | # version control. 14 | # 15 | # example.tfvars 16 | 17 | # Ignore override files as they are usually used to override resources locally and so 18 | # are not checked in 19 | override.tf 20 | override.tf.json 21 | *_override.tf 22 | *_override.tf.json 23 | 24 | # Include override files you do wish to add to version control using negated pattern 25 | # 26 | # !example_override.tf 27 | 28 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan 29 | # example: *tfplan* 30 | -------------------------------------------------------------------------------- /example/random-test/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/http" { 5 | version = "2.1.0" 6 | hashes = [ 7 | "h1:HmUcHqc59VeHReHD2SEhnLVQPUKHKTipJ8Jxq67GiDU=", 8 | "h1:OaCZWFiSj1Rts5300dC2fzCM56SSWqq0aQP3YH6QECE=", 9 | "h1:SE5ufHNXfkUaY6ZLLW742Pasb/Jx/Y/lUwoYS3XbElA=", 10 | "zh:03d82dc0887d755b8406697b1d27506bc9f86f93b3e9b4d26e0679d96b802826", 11 | "zh:0704d02926393ddc0cfad0b87c3d51eafeeae5f9e27cc71e193c141079244a22", 12 | "zh:095ea350ea94973e043dad2394f10bca4a4bf41be775ba59d19961d39141d150", 13 | "zh:0b71ac44e87d6964ace82979fc3cbb09eb876ed8f954449481bcaa969ba29cb7", 14 | "zh:0e255a170db598bd1142c396cefc59712ad6d4e1b0e08a840356a371e7b73bc4", 15 | "zh:67c8091cfad226218c472c04881edf236db8f2dc149dc5ada878a1cd3c1de171", 16 | "zh:75df05e25d14b5101d4bc6624ac4a01bb17af0263c9e8a740e739f8938b86ee3", 17 | "zh:b4e36b2c4f33fdc44bf55fa1c9bb6864b5b77822f444bd56f0be7e9476674d0e", 18 | "zh:b9b36b01d2ec4771838743517bc5f24ea27976634987c6d5529ac4223e44365d", 19 | "zh:ca264a916e42e221fddb98d640148b12e42116046454b39ede99a77fc52f59f4", 20 | "zh:fe373b2fb2cc94777a91ecd7ac5372e699748c455f44f6ea27e494de9e5e6f92", 21 | ] 22 | } 23 | 24 | provider "registry.terraform.io/hashicorp/random" { 25 | version = "3.1.0" 26 | constraints = "3.1.0" 27 | hashes = [ 28 | "h1:9cCiLO/Cqr6IUvMDSApCkQItooiYNatZpEXmcu0nnng=", 29 | "h1:BZMEPucF+pbu9gsPk0G0BHx7YP04+tKdq2MrRDF1EDM=", 30 | "h1:EPIax4Ftp2SNdB9pUfoSjxoueDoLc/Ck3EUoeX0Dvsg=", 31 | "zh:2bbb3339f0643b5daa07480ef4397bd23a79963cc364cdfbb4e86354cb7725bc", 32 | "zh:3cd456047805bf639fbf2c761b1848880ea703a054f76db51852008b11008626", 33 | "zh:4f251b0eda5bb5e3dc26ea4400dba200018213654b69b4a5f96abee815b4f5ff", 34 | "zh:7011332745ea061e517fe1319bd6c75054a314155cb2c1199a5b01fe1889a7e2", 35 | "zh:738ed82858317ccc246691c8b85995bc125ac3b4143043219bd0437adc56c992", 36 | "zh:7dbe52fac7bb21227acd7529b487511c91f4107db9cc4414f50d04ffc3cab427", 37 | "zh:a3a9251fb15f93e4cfc1789800fc2d7414bbc18944ad4c5c98f466e6477c42bc", 38 | "zh:a543ec1a3a8c20635cf374110bd2f87c07374cf2c50617eee2c669b3ceeeaa9f", 39 | "zh:d9ab41d556a48bd7059f0810cf020500635bfc696c9fc3adab5ea8915c1d886b", 40 | "zh:d9e13427a7d011dbd654e591b0337e6074eef8c3b9bb11b2e39eaaf257044fd7", 41 | "zh:f7605bd1437752114baf601bdf6931debe6dc6bfe3006eb7e9bb9080931dca8a", 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /example/random-test/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | random = { 4 | source = "hashicorp/random" 5 | version = "3.1.0" 6 | } 7 | } 8 | } 9 | 10 | provider "random" {} 11 | 12 | variable "max_length" { 13 | default = 5 14 | sensitive = false 15 | } 16 | 17 | resource "random_integer" "pet_length" { 18 | min = 1 19 | max = var.max_length 20 | } 21 | 22 | resource "random_pet" "dog" { 23 | length = random_integer.pet_length.result 24 | } 25 | 26 | locals { 27 | random_dog = random_pet.dog.id 28 | } 29 | 30 | resource "random_pet" "bird" { 31 | length = random_integer.pet_length.result 32 | prefix = local.random_dog 33 | } 34 | 35 | resource "random_pet" "dogs" { 36 | count = 3 37 | length = random_integer.pet_length.result 38 | } 39 | 40 | resource "random_pet" "cow" { 41 | length = random_integer.pet_length.result 42 | } 43 | 44 | module "random_cat" { 45 | source = "./random-name" 46 | 47 | max_length = "3" 48 | } 49 | 50 | output "random_cat_name" { 51 | description = "random_cat_name" 52 | value = module.random_cat.random_name 53 | sensitive = true 54 | } 55 | 56 | output "random_cow_name" { 57 | description = "random_cow_name" 58 | value = random_pet.cow.id 59 | } 60 | 61 | resource "random_pet" "birds" { 62 | for_each = { 63 | "billy" = 1 64 | "bob" = 2 65 | "jill" = 3 66 | } 67 | 68 | prefix = each.key 69 | length = each.value 70 | } 71 | 72 | data "http" "terraform_metadata" { 73 | url = "https://checkpoint-api.hashicorp.com/v1/check/terraform" 74 | 75 | # Optional request headers 76 | request_headers = { 77 | Accept = "application/json" 78 | } 79 | } 80 | 81 | output "terraform_metadata" { 82 | description = "Terraform metadata" 83 | value = data.http.terraform_metadata.body 84 | } -------------------------------------------------------------------------------- /example/random-test/random-name/main.tf: -------------------------------------------------------------------------------- 1 | 2 | variable "max_length" { 3 | default = 5 4 | } 5 | 6 | resource "random_integer" "pet_length" { 7 | min = 1 8 | max = var.max_length 9 | } 10 | 11 | resource "random_pet" "pet" { 12 | length = random_integer.pet_length.result 13 | } 14 | 15 | output "random_name" { 16 | value = random_pet.pet.id 17 | } -------------------------------------------------------------------------------- /example/random-test/test.tfvars: -------------------------------------------------------------------------------- 1 | max_length = 3 -------------------------------------------------------------------------------- /example/simple-test/main.tf: -------------------------------------------------------------------------------- 1 | output "hello_world" { 2 | value = "Hello, World!" 3 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module rover 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/chromedp/cdproto v0.0.0-20211205231339-d2673e93eee4 7 | github.com/chromedp/chromedp v0.7.6 8 | github.com/hashicorp/terraform-config-inspect v0.0.0-20210511202847-ad33d83d7650 9 | github.com/hashicorp/terraform-exec v0.15.0 10 | github.com/hashicorp/terraform-json v0.13.0 11 | golang.org/x/net v0.0.0-20210924151903-3ad01bbaa167 // indirect 12 | ) 13 | 14 | require github.com/hashicorp/go-tfe v0.20.0 15 | 16 | require ( 17 | github.com/agext/levenshtein v1.2.2 // indirect 18 | github.com/apparentlymart/go-textseg v1.0.0 // indirect 19 | github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect 20 | github.com/chromedp/sysutil v1.0.0 // indirect 21 | github.com/gobwas/httphead v0.1.0 // indirect 22 | github.com/gobwas/pool v0.2.1 // indirect 23 | github.com/gobwas/ws v1.1.0 // indirect 24 | github.com/golang/mock v1.6.0 // indirect 25 | github.com/google/go-cmp v0.5.6 // indirect 26 | github.com/google/go-querystring v1.1.0 // indirect 27 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 28 | github.com/hashicorp/go-retryablehttp v0.7.0 // indirect 29 | github.com/hashicorp/go-slug v0.7.0 // indirect 30 | github.com/hashicorp/go-version v1.3.0 // indirect 31 | github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f // indirect 32 | github.com/hashicorp/hcl/v2 v2.0.0 // indirect 33 | github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d // indirect 34 | github.com/josharian/intern v1.0.0 // indirect 35 | github.com/mailru/easyjson v0.7.7 // indirect 36 | github.com/mitchellh/go-wordwrap v1.0.0 // indirect 37 | github.com/zclconf/go-cty v1.9.1 // indirect 38 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect 39 | golang.org/x/text v0.3.6 // indirect 40 | golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect 41 | ) 42 | -------------------------------------------------------------------------------- /graph.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "regexp" 7 | "strings" 8 | 9 | tfjson "github.com/hashicorp/terraform-json" 10 | ) 11 | 12 | const ( 13 | VARIABLE_COLOR string = "#1d7ada" 14 | OUTPUT_COLOR string = "#ffc107" 15 | DATA_COLOR string = "#dc477d" 16 | MODULE_COLOR string = "#8450ba" 17 | MODULE_BG_COLOR string = "white" 18 | FNAME_BG_COLOR string = "white" 19 | RESOURCE_COLOR string = "lightgray" 20 | LOCAL_COLOR string = "black" 21 | ) 22 | 23 | // ModuleGraph TODO 24 | type Graph struct { 25 | Nodes []Node `json:"nodes"` 26 | Edges []Edge `json:"edges"` 27 | } 28 | 29 | // Node TODO 30 | type Node struct { 31 | Data NodeData `json:"data"` 32 | Classes string `json:"classes,omitempty"` 33 | } 34 | 35 | // NodeData TODO 36 | type NodeData struct { 37 | ID string `json:"id"` 38 | Label string `json:"label,omitempty"` 39 | Type ResourceType `json:"type,omitempty"` 40 | Parent string `json:"parent,omitempty"` 41 | ParentColor string `json:"parentColor,omitempty"` 42 | Change string `json:"change,omitempty"` 43 | } 44 | 45 | // Edge TODO 46 | type Edge struct { 47 | Data EdgeData `json:"data"` 48 | Classes string `json:"classes,omitempty"` 49 | } 50 | 51 | // EdgeData TODO 52 | type EdgeData struct { 53 | ID string `json:"id"` 54 | Source string `json:"source"` 55 | Target string `json:"target"` 56 | Gradient string `json:"gradient,omitempty"` 57 | } 58 | 59 | // GenerateGraph - 60 | func (r *rover) GenerateGraph() error { 61 | log.Println("Generating resource graph...") 62 | 63 | nodes := r.GenerateNodes() 64 | edges := r.GenerateEdges() 65 | 66 | // Edge case for terraform.workspace 67 | for _, e := range edges { 68 | if strings.Contains(e.Data.ID, "terraform.workspace") { 69 | nodes = append(nodes, Node{ 70 | Data: NodeData{ 71 | ID: "terraform.workspace", 72 | Label: "terraform.workspace", 73 | Type: "locals", 74 | // Parent is equal to basePath 75 | Parent: strings.ReplaceAll(r.Map.Path, "./", ""), 76 | }, 77 | Classes: "locals", 78 | }) 79 | break 80 | } 81 | } 82 | 83 | r.Graph = Graph{ 84 | Nodes: nodes, 85 | Edges: edges, 86 | } 87 | 88 | return nil 89 | } 90 | 91 | func (r *rover) addNodes(base string, parent string, nodeMap map[string]Node, resources map[string]*Resource) []string { 92 | 93 | nmo := []string{} 94 | 95 | for id, re := range resources { 96 | 97 | if re.Type == ResourceTypeResource || re.Type == ResourceTypeData { 98 | 99 | pid := parent 100 | 101 | if nodeMap[parent].Data.Type == ResourceTypeFile { 102 | pid = strings.TrimSuffix(pid, nodeMap[parent].Data.Label) 103 | pid = strings.TrimSuffix(pid, ".") 104 | //qfmt.Printf("%v\n", pid) 105 | } 106 | 107 | mid := fmt.Sprintf("%v.%v", pid, re.ResourceType) 108 | mid = strings.TrimPrefix(mid, fmt.Sprintf("%v.", base)) 109 | mid = strings.TrimPrefix(mid, ".") 110 | mid = strings.TrimSuffix(mid, ".") 111 | 112 | l := strings.Split(mid, ".") 113 | label := l[len(l)-1] 114 | 115 | midParent := parent 116 | 117 | if midParent == mid { 118 | midParent = nodeMap[midParent].Data.Parent 119 | } 120 | 121 | if nodeMap[midParent].Data.Type == ResourceTypeFile { 122 | mid = fmt.Sprintf("%s {%s}", mid, nodeMap[parent].Data.Label) 123 | } 124 | 125 | //fmt.Printf(midParent + " - " + mid + "\n") 126 | 127 | // Append resource type 128 | nmo = append(nmo, mid) 129 | nodeMap[mid] = Node{ 130 | Data: NodeData{ 131 | ID: mid, 132 | Label: label, 133 | Type: re.Type, 134 | Parent: midParent, 135 | ParentColor: getResourceColor(nodeMap[parent].Data.Type), 136 | }, 137 | Classes: fmt.Sprintf("%s-type", re.Type), 138 | } 139 | 140 | mrChange := string(re.ChangeAction) 141 | 142 | // Append resource name 143 | nmo = append(nmo, id) 144 | nodeMap[id] = Node{ 145 | Data: NodeData{ 146 | ID: id, 147 | Label: re.Name, 148 | Type: re.Type, 149 | Parent: mid, 150 | ParentColor: getResourceColor(nodeMap[parent].Data.Type), 151 | Change: mrChange, 152 | }, 153 | Classes: fmt.Sprintf("%s-name %s", re.Type, mrChange), 154 | } 155 | //fmt.Printf(id + " - " + mid + "\n") 156 | 157 | nmo = append(nmo, r.addNodes(base, id, nodeMap, re.Children)...) 158 | 159 | } else if re.Type == ResourceTypeFile { 160 | fid := id 161 | if parent != base { 162 | fid = fmt.Sprintf("%s.%s", parent, fid) 163 | } 164 | //fmt.Printf("%v\n", fid) 165 | nmo = append(nmo, fid) 166 | nodeMap[fid] = Node{ 167 | Data: NodeData{ 168 | ID: fid, 169 | Label: id, 170 | Type: re.Type, 171 | Parent: parent, 172 | ParentColor: getResourceColor(nodeMap[parent].Data.Type), 173 | }, 174 | 175 | Classes: getResourceClass(re.Type), 176 | } 177 | nmo = append(nmo, r.addNodes(base, fid, nodeMap, re.Children)...) 178 | } else { 179 | 180 | pid := parent 181 | 182 | if nodeMap[parent].Data.Type == ResourceTypeFile { 183 | pid = strings.TrimSuffix(pid, nodeMap[parent].Data.Label) 184 | pid = strings.TrimSuffix(pid, ".") 185 | } 186 | 187 | ls := strings.Split(id, ".") 188 | label := ls[len(ls)-1] 189 | 190 | //fmt.Printf("%v - %v\n", id, re.Type) 191 | 192 | nmo = append(nmo, id) 193 | nodeMap[id] = Node{ 194 | Data: NodeData{ 195 | ID: id, 196 | Label: label, 197 | Type: re.Type, 198 | Parent: parent, 199 | ParentColor: getResourceColor(nodeMap[pid].Data.Type), 200 | }, 201 | 202 | Classes: getResourceClass(re.Type), 203 | } 204 | 205 | nmo = append(nmo, r.addNodes(base, id, nodeMap, re.Children)...) 206 | 207 | } 208 | 209 | } 210 | 211 | return nmo 212 | 213 | } 214 | 215 | // GenerateNodes - 216 | func (r *rover) GenerateNodes() []Node { 217 | 218 | nodeMap := make(map[string]Node) 219 | nmo := []string{} 220 | 221 | basePath := strings.ReplaceAll(r.Map.Path, "./", "") 222 | 223 | nmo = append(nmo, basePath) 224 | nodeMap[basePath] = Node{ 225 | Data: NodeData{ 226 | ID: basePath, 227 | Label: basePath, 228 | Type: "basename", 229 | }, 230 | Classes: "basename", 231 | } 232 | 233 | nmo = append(nmo, r.addNodes(basePath, basePath, nodeMap, r.Map.Root)...) 234 | 235 | nodes := make([]Node, 0, len(nodeMap)) 236 | exists := make(map[string]bool) 237 | 238 | for _, i := range nmo { 239 | if _, ok := exists[i]; !ok { 240 | nodes = append(nodes, nodeMap[i]) 241 | exists[i] = true 242 | } 243 | } 244 | 245 | return nodes 246 | } 247 | 248 | func (r *rover) addEdges(base string, parent string, edgeMap map[string]Edge, resources map[string]*Resource) []string { 249 | emo := []string{} 250 | for id, re := range resources { 251 | matchBrackets := regexp.MustCompile(`\[[^\[\]]*\]`) 252 | 253 | configId := matchBrackets.ReplaceAllString(id, "") 254 | 255 | var expressions map[string]*tfjson.Expression 256 | 257 | if r.RSO.Configs[configId] != nil { 258 | // If Resource 259 | if r.RSO.Configs[configId].ResourceConfig != nil { 260 | expressions = r.RSO.Configs[configId].ResourceConfig.Expressions 261 | // If Module 262 | } else if r.RSO.Configs[configId].ModuleConfig != nil { 263 | expressions = r.RSO.Configs[configId].ModuleConfig.Expressions 264 | // If Output 265 | } else if r.RSO.Configs[configId].OutputConfig != nil { 266 | expressions = make(map[string]*tfjson.Expression) 267 | expressions["output"] = r.RSO.Configs[configId].OutputConfig.Expression 268 | } 269 | } 270 | // fmt.Printf("%+v - %+v\n", oName, oValue) 271 | for _, reValues := range expressions { 272 | for _, dependsOnR := range reValues.References { 273 | if !strings.HasPrefix(dependsOnR, "each.") { 274 | 275 | /*if strings.HasPrefix(dependsOnR, "module.") { 276 | id := strings.Split(dependsOnR, ".") 277 | dependsOnR = fmt.Sprintf("%s.%s", id[0], id[1]) 278 | }*/ 279 | 280 | sourceColor := getResourceColor(re.Type) 281 | targetId := dependsOnR 282 | if parent != "" { 283 | targetId = fmt.Sprintf("%s.%s", parent, dependsOnR) 284 | } 285 | 286 | targetColor := RESOURCE_COLOR 287 | 288 | if strings.Contains(dependsOnR, "output.") { 289 | targetColor = OUTPUT_COLOR 290 | } else if strings.Contains(dependsOnR, "var.") { 291 | targetColor = VARIABLE_COLOR 292 | } else if strings.HasPrefix(dependsOnR, "module.") { 293 | targetColor = MODULE_COLOR 294 | } else if strings.Contains(dependsOnR, "data.") { 295 | targetColor = DATA_COLOR 296 | } else if strings.Contains(dependsOnR, "local.") { 297 | targetColor = LOCAL_COLOR 298 | } 299 | 300 | // For Terraform 1.0, resource references point to specific resource attributes 301 | // Skip if the target is a resource and reference points to an attribute 302 | if targetColor == RESOURCE_COLOR && len(strings.Split(dependsOnR, ".")) != 2 { 303 | continue 304 | } else if targetColor == DATA_COLOR && len(strings.Split(dependsOnR, ".")) != 3 { 305 | continue 306 | } 307 | 308 | edgeId := fmt.Sprintf("%s->%s", id, targetId) 309 | emo = append(emo, edgeId) 310 | edgeMap[edgeId] = Edge{ 311 | Data: EdgeData{ 312 | ID: edgeId, 313 | Source: id, 314 | Target: targetId, 315 | Gradient: fmt.Sprintf("%s %s", sourceColor, targetColor), 316 | }, 317 | Classes: "edge", 318 | } 319 | } 320 | } 321 | } 322 | 323 | // Ignore files in edge generation 324 | if re.Type == ResourceTypeFile { 325 | emo = append(emo, r.addEdges(base, parent, edgeMap, re.Children)...) 326 | } else { 327 | emo = append(emo, r.addEdges(base, id, edgeMap, re.Children)...) 328 | } 329 | } 330 | 331 | return emo 332 | } 333 | 334 | // GenerateEdges - 335 | func (r *rover) GenerateEdges() []Edge { 336 | edgeMap := make(map[string]Edge) 337 | emo := []string{} 338 | 339 | //config := r.Plan.Config.RootModule 340 | 341 | emo = append(emo, r.addEdges("", "", edgeMap, r.Map.Root)...) 342 | 343 | edges := make([]Edge, 0, len(edgeMap)) 344 | exists := make(map[string]bool) 345 | 346 | for _, i := range emo { 347 | if _, ok := exists[i]; !ok { 348 | edges = append(edges, edgeMap[i]) 349 | exists[i] = true 350 | } 351 | } 352 | 353 | return edges 354 | } 355 | 356 | func getResourceColor(t ResourceType) string { 357 | switch t { 358 | case ResourceTypeModule: 359 | return MODULE_COLOR 360 | case ResourceTypeData: 361 | return DATA_COLOR 362 | case ResourceTypeOutput: 363 | return OUTPUT_COLOR 364 | case ResourceTypeVariable: 365 | return VARIABLE_COLOR 366 | case ResourceTypeLocal: 367 | return LOCAL_COLOR 368 | } 369 | return RESOURCE_COLOR 370 | } 371 | 372 | func getPrimitiveType(resourceType string) string { 373 | switch resourceType { 374 | case 375 | "module", 376 | "data", 377 | "output", 378 | "var", 379 | "local": 380 | return resourceType 381 | } 382 | return "resource" 383 | } 384 | 385 | func getResourceClass(resourceType ResourceType) string { 386 | switch resourceType { 387 | 388 | case ResourceTypeData: 389 | return "data-type" 390 | case ResourceTypeOutput: 391 | return "output" 392 | case ResourceTypeVariable: 393 | return "variable" 394 | case ResourceTypeFile: 395 | return "fname" 396 | case ResourceTypeLocal: 397 | return "locals" 398 | case ResourceTypeModule: 399 | return "module" 400 | } 401 | return "resource-type" 402 | } 403 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "encoding/json" 7 | "errors" 8 | "flag" 9 | "fmt" 10 | "io/fs" 11 | "io/ioutil" 12 | "log" 13 | "net/http" 14 | "os" 15 | "path/filepath" 16 | "strings" 17 | "time" 18 | 19 | tfe "github.com/hashicorp/go-tfe" 20 | "github.com/hashicorp/terraform-config-inspect/tfconfig" 21 | "github.com/hashicorp/terraform-exec/tfexec" 22 | tfjson "github.com/hashicorp/terraform-json" 23 | ) 24 | 25 | const VERSION = "0.3.3" 26 | 27 | var TRUE = true 28 | 29 | //go:embed ui/dist 30 | var frontend embed.FS 31 | 32 | type arrayFlags []string 33 | 34 | func (i arrayFlags) String() string { 35 | var ts []string 36 | for _, el := range i { 37 | ts = append(ts, el) 38 | } 39 | return strings.Join(ts, ",") 40 | } 41 | 42 | func (i *arrayFlags) Set(value string) error { 43 | *i = append(*i, value) 44 | return nil 45 | } 46 | 47 | type rover struct { 48 | Name string 49 | WorkingDir string 50 | TfPath string 51 | TfVarsFiles []string 52 | TfVars []string 53 | TfBackendConfigs []string 54 | PlanPath string 55 | PlanJSONPath string 56 | WorkspaceName string 57 | TFCOrgName string 58 | TFCWorkspaceName string 59 | ShowSensitive bool 60 | GenImage bool 61 | TFCNewRun bool 62 | Plan *tfjson.Plan 63 | RSO *ResourcesOverview 64 | Map *Map 65 | Graph Graph 66 | } 67 | 68 | func main() { 69 | var tfPath, workingDir, name, zipFileName, ipPort, planPath, planJSONPath, workspaceName, tfcOrgName, tfcWorkspaceName string 70 | var standalone, genImage, showSensitive, getVersion, tfcNewRun bool 71 | var tfVarsFiles, tfVars, tfBackendConfigs arrayFlags 72 | flag.StringVar(&tfPath, "tfPath", "/usr/local/bin/terraform", "Path to Terraform binary") 73 | flag.StringVar(&workingDir, "workingDir", ".", "Path to Terraform configuration") 74 | flag.StringVar(&name, "name", "rover", "Configuration name") 75 | flag.StringVar(&zipFileName, "zipFileName", "rover", "Standalone zip file name") 76 | flag.StringVar(&ipPort, "ipPort", "0.0.0.0:9000", "IP and port for Rover server") 77 | flag.StringVar(&planPath, "planPath", "", "Plan file path") 78 | flag.StringVar(&planJSONPath, "planJSONPath", "", "Plan JSON file path") 79 | flag.StringVar(&workspaceName, "workspaceName", "", "Workspace name") 80 | flag.StringVar(&tfcOrgName, "tfcOrg", "", "Terraform Cloud Organization name") 81 | flag.StringVar(&tfcWorkspaceName, "tfcWorkspace", "", "Terraform Cloud Workspace name") 82 | flag.BoolVar(&standalone, "standalone", false, "Generate standalone HTML files") 83 | flag.BoolVar(&showSensitive, "showSensitive", false, "Display sensitive values") 84 | flag.BoolVar(&tfcNewRun, "tfcNewRun", false, "Create new Terraform Cloud run") 85 | flag.BoolVar(&getVersion, "version", false, "Get current version") 86 | flag.BoolVar(&genImage, "genImage", false, "Generate graph image") 87 | flag.Var(&tfVarsFiles, "tfVarsFile", "Path to *.tfvars files") 88 | flag.Var(&tfVars, "tfVar", "Terraform variable (key=value)") 89 | flag.Var(&tfBackendConfigs, "tfBackendConfig", "Path to *.tfbackend files") 90 | flag.Parse() 91 | 92 | if getVersion { 93 | fmt.Printf("Rover v%s\n", VERSION) 94 | return 95 | } 96 | 97 | log.Println("Starting Rover...") 98 | 99 | parsedTfVarsFiles := strings.Split(tfVarsFiles.String(), ",") 100 | parsedTfVars := strings.Split(tfVars.String(), ",") 101 | parsedTfBackendConfigs := strings.Split(tfBackendConfigs.String(), ",") 102 | 103 | path, err := os.Getwd() 104 | if err != nil { 105 | log.Fatal(errors.New("Unable to get current working directory")) 106 | } 107 | 108 | if planPath != "" { 109 | if !strings.HasPrefix(planPath, "/") { 110 | planPath = filepath.Join(path, planPath) 111 | } 112 | } 113 | 114 | if planJSONPath != "" { 115 | if !strings.HasPrefix(planJSONPath, "/") { 116 | planJSONPath = filepath.Join(path, planJSONPath) 117 | } 118 | } 119 | 120 | r := rover{ 121 | Name: name, 122 | WorkingDir: workingDir, 123 | TfPath: tfPath, 124 | PlanPath: planPath, 125 | PlanJSONPath: planJSONPath, 126 | ShowSensitive: showSensitive, 127 | GenImage: genImage, 128 | TfVarsFiles: parsedTfVarsFiles, 129 | TfVars: parsedTfVars, 130 | TfBackendConfigs: parsedTfBackendConfigs, 131 | WorkspaceName: workspaceName, 132 | TFCOrgName: tfcOrgName, 133 | TFCWorkspaceName: tfcWorkspaceName, 134 | TFCNewRun: tfcNewRun, 135 | } 136 | 137 | // Generate assets 138 | err = r.generateAssets() 139 | if err != nil { 140 | log.Fatal(err.Error()) 141 | } 142 | 143 | log.Println("Done generating assets.") 144 | 145 | // Save to file (debug) 146 | // saveJSONToFile(name, "plan", "output", r.Plan) 147 | // saveJSONToFile(name, "rso", "output", r.Plan) 148 | // saveJSONToFile(name, "map", "output", r.Map) 149 | // saveJSONToFile(name, "graph", "output", r.Graph) 150 | 151 | // Embed frontend 152 | fe, err := fs.Sub(frontend, "ui/dist") 153 | if err != nil { 154 | log.Fatalln(err) 155 | } 156 | frontendFS := http.FileServer(http.FS(fe)) 157 | 158 | if standalone { 159 | err = r.generateZip(fe, fmt.Sprintf("%s.zip", zipFileName)) 160 | if err != nil { 161 | log.Fatalln(err) 162 | } 163 | 164 | log.Printf("Generated zip file: %s.zip\n", zipFileName) 165 | return 166 | } 167 | 168 | err = r.startServer(ipPort, frontendFS) 169 | if err != nil { 170 | // http.Serve() returns error on shutdown 171 | if genImage { 172 | log.Println("Server shut down.") 173 | } else { 174 | log.Fatalf("Could not start server: %s\n", err.Error()) 175 | } 176 | } 177 | 178 | } 179 | 180 | func (r *rover) generateAssets() error { 181 | // Get Plan 182 | err := r.getPlan() 183 | if err != nil { 184 | return errors.New(fmt.Sprintf("Unable to parse Plan: %s", err)) 185 | } 186 | 187 | // Generate RSO, Map, Graph 188 | err = r.GenerateResourceOverview() 189 | if err != nil { 190 | return err 191 | } 192 | 193 | err = r.GenerateMap() 194 | if err != nil { 195 | return err 196 | } 197 | 198 | err = r.GenerateGraph() 199 | if err != nil { 200 | return err 201 | } 202 | 203 | return nil 204 | } 205 | 206 | func (r *rover) getPlan() error { 207 | tmpDir, err := ioutil.TempDir("", "rover") 208 | if err != nil { 209 | return err 210 | } 211 | defer os.RemoveAll(tmpDir) 212 | 213 | tf, err := tfexec.NewTerraform(r.WorkingDir, r.TfPath) 214 | if err != nil { 215 | return err 216 | } 217 | 218 | // If user provided path to plan file 219 | if r.PlanPath != "" { 220 | log.Println("Using provided plan...") 221 | r.Plan, err = tf.ShowPlanFile(context.Background(), r.PlanPath) 222 | if err != nil { 223 | return errors.New(fmt.Sprintf("Unable to read Plan (%s): %s", r.PlanPath, err)) 224 | } 225 | return nil 226 | } 227 | 228 | // If user provided path to plan JSON file 229 | if r.PlanJSONPath != "" { 230 | log.Println("Using provided JSON plan...") 231 | 232 | planJsonFile, err := os.Open(r.PlanJSONPath) 233 | if err != nil { 234 | return errors.New(fmt.Sprintf("Unable to read Plan (%s): %s", r.PlanJSONPath, err)) 235 | } 236 | defer planJsonFile.Close() 237 | 238 | planJson, err := ioutil.ReadAll(planJsonFile) 239 | if err != nil { 240 | return errors.New(fmt.Sprintf("Unable to read Plan (%s): %s", r.PlanJSONPath, err)) 241 | } 242 | 243 | if err := json.Unmarshal(planJson, &r.Plan); err != nil { 244 | return errors.New(fmt.Sprintf("Unable to read Plan (%s): %s", r.PlanJSONPath, err)) 245 | } 246 | 247 | return nil 248 | } 249 | 250 | // If user specified TFC workspace 251 | if r.TFCWorkspaceName != "" { 252 | tfcToken := os.Getenv("TFC_TOKEN") 253 | 254 | if tfcToken == "" { 255 | return errors.New("TFC_TOKEN environment variable not set") 256 | } 257 | 258 | if r.TFCOrgName == "" { 259 | return errors.New("Must specify Terraform Cloud organization to retrieve plan from Terraform Cloud") 260 | } 261 | 262 | config := &tfe.Config{ 263 | Token: tfcToken, 264 | } 265 | 266 | client, err := tfe.NewClient(config) 267 | if err != nil { 268 | return errors.New(fmt.Sprintf("Unable to connect to Terraform Cloud. %s", err)) 269 | } 270 | 271 | // Get TFC Workspace 272 | ws, err := client.Workspaces.Read(context.Background(), r.TFCOrgName, r.TFCWorkspaceName) 273 | if err != nil { 274 | return errors.New(fmt.Sprintf("Unable to list workspace %s in %s organization. %s", r.TFCWorkspaceName, r.TFCOrgName, err)) 275 | } 276 | 277 | // Retrieve all runs from specified TFC workspace 278 | runs, err := client.Runs.List(context.Background(), ws.ID, tfe.RunListOptions{}) 279 | if err != nil { 280 | return errors.New(fmt.Sprintf("Unable to retrieve plan from %s in %s organization. %s", r.TFCWorkspaceName, r.TFCOrgName, err)) 281 | } 282 | 283 | run := runs.Items[0] 284 | 285 | // Get most recent plan item 286 | planID := runs.Items[0].Plan.ID 287 | 288 | // Run hasn't been applied or discarded, therefore is still "actionable" by user 289 | runIsActionable := run.StatusTimestamps.AppliedAt.IsZero() && run.StatusTimestamps.DiscardedAt.IsZero() 290 | 291 | if runIsActionable && r.TFCNewRun { 292 | return errors.New(fmt.Sprintf("Did not create new run. %s in %s in %s is still active", run.ID, r.TFCWorkspaceName, r.TFCOrgName)) 293 | } 294 | 295 | // If latest run is not actionable, rover will create new run 296 | if r.TFCNewRun { 297 | // Create new run in specified TFC workspace 298 | newRun, err := client.Runs.Create(context.Background(), tfe.RunCreateOptions{ 299 | Refresh: &TRUE, 300 | Workspace: ws, 301 | }) 302 | if err != nil { 303 | return errors.New(fmt.Sprintf("Unable to generate new run from %s in %s organization. %s", r.TFCWorkspaceName, r.TFCOrgName, err)) 304 | } 305 | 306 | run = newRun 307 | 308 | log.Printf("Starting new Terraform Cloud run in %s workspace...", r.TFCWorkspaceName) 309 | 310 | // Wait maximum of 5 mins 311 | for i := 0; i < 30; i++ { 312 | run, err := client.Runs.Read(context.Background(), newRun.ID) 313 | if err != nil { 314 | return errors.New(fmt.Sprintf("Unable to retrieve run from %s in %s organization. %s", r.TFCWorkspaceName, r.TFCOrgName, err)) 315 | } 316 | 317 | if run.Plan != nil { 318 | planID = run.Plan.ID 319 | // Add 20 second timeout so plan JSON becomes available 320 | time.Sleep(20 * time.Second) 321 | log.Printf("Run %s to completed!", newRun.ID) 322 | break 323 | } 324 | 325 | time.Sleep(10 * time.Second) 326 | log.Printf("Waiting for run %s to complete (%ds)...", newRun.ID, 10*(i+1)) 327 | } 328 | 329 | if planID == "" { 330 | return errors.New(fmt.Sprintf("Timeout waiting for plan to complete in %s in %s organization. %s", r.TFCWorkspaceName, r.TFCOrgName, err)) 331 | } 332 | } 333 | 334 | // Get most recent plan file 335 | planBytes, err := client.Plans.JSONOutput(context.Background(), planID) 336 | if err != nil { 337 | return errors.New(fmt.Sprintf("Unable to retrieve plan from %s in %s organization. %s", r.TFCWorkspaceName, r.TFCOrgName, err)) 338 | } 339 | // If empty plan file 340 | if string(planBytes) == "" { 341 | return errors.New(fmt.Sprintf("Empty plan. Check run %s in %s in %s is not pending", run.ID, r.TFCWorkspaceName, r.TFCOrgName)) 342 | } 343 | 344 | if err := json.Unmarshal(planBytes, &r.Plan); err != nil { 345 | return errors.New(fmt.Sprintf("Unable to parse plan (ID: %s) from %s in %s organization.: %s", planID, r.TFCWorkspaceName, r.TFCOrgName, err)) 346 | } 347 | 348 | return nil 349 | } 350 | 351 | log.Println("Initializing Terraform...") 352 | 353 | // Create TF Init options 354 | var tfInitOptions []tfexec.InitOption 355 | tfInitOptions = append(tfInitOptions, tfexec.Upgrade(true)) 356 | 357 | // Add *.tfbackend files 358 | for _, tfBackendConfig := range r.TfBackendConfigs { 359 | if tfBackendConfig != "" { 360 | tfInitOptions = append(tfInitOptions, tfexec.BackendConfig(tfBackendConfig)) 361 | } 362 | } 363 | 364 | // tfInitOptions = append(tfInitOptions, tfexec.LockTimeout("60s")) 365 | 366 | err = tf.Init(context.Background(), tfInitOptions...) 367 | if err != nil { 368 | return errors.New(fmt.Sprintf("Unable to initialize Terraform Plan: %s", err)) 369 | } 370 | 371 | if r.WorkspaceName != "" { 372 | log.Printf("Running in %s workspace...", r.WorkspaceName) 373 | err = tf.WorkspaceSelect(context.Background(), r.WorkspaceName) 374 | if err != nil { 375 | return errors.New(fmt.Sprintf("Unable to select workspace (%s): %s", r.WorkspaceName, err)) 376 | } 377 | } 378 | 379 | log.Println("Generating plan...") 380 | planPath := fmt.Sprintf("%s/%s-%v", tmpDir, "roverplan", time.Now().Unix()) 381 | 382 | // Create TF Plan options 383 | var tfPlanOptions []tfexec.PlanOption 384 | tfPlanOptions = append(tfPlanOptions, tfexec.Out(planPath)) 385 | 386 | // Add *.tfvars files 387 | for _, tfVarsFile := range r.TfVarsFiles { 388 | if tfVarsFile != "" { 389 | tfPlanOptions = append(tfPlanOptions, tfexec.VarFile(tfVarsFile)) 390 | } 391 | } 392 | 393 | // Add Terraform variables 394 | for _, tfVar := range r.TfVars { 395 | if tfVar != "" { 396 | tfPlanOptions = append(tfPlanOptions, tfexec.Var(tfVar)) 397 | } 398 | } 399 | 400 | _, err = tf.Plan(context.Background(), tfPlanOptions...) 401 | if err != nil { 402 | return errors.New(fmt.Sprintf("Unable to run Plan: %s", err)) 403 | } 404 | 405 | r.Plan, err = tf.ShowPlanFile(context.Background(), planPath) 406 | if err != nil { 407 | return errors.New(fmt.Sprintf("Unable to read Plan: %s", err)) 408 | } 409 | 410 | return nil 411 | } 412 | 413 | func showJSON(g interface{}) { 414 | j, err := json.Marshal(g) 415 | if err != nil { 416 | log.Printf("Error producing JSON: %s\n", err) 417 | os.Exit(2) 418 | } 419 | log.Printf("%+v", string(j)) 420 | } 421 | 422 | func showModuleJSON(module *tfconfig.Module) { 423 | j, err := json.MarshalIndent(module, "", " ") 424 | if err != nil { 425 | fmt.Fprintf(os.Stderr, "error producing JSON: %s\n", err) 426 | os.Exit(2) 427 | } 428 | os.Stdout.Write(j) 429 | os.Stdout.Write([]byte{'\n'}) 430 | } 431 | 432 | func saveJSONToFile(prefix string, fileType string, path string, j interface{}) string { 433 | b, err := json.Marshal(j) 434 | if err != nil { 435 | fmt.Fprintf(os.Stderr, "error producing JSON: %s\n", err) 436 | os.Exit(2) 437 | } 438 | 439 | newpath := filepath.Join(".", fmt.Sprintf("%s/%s", path, prefix)) 440 | err = os.MkdirAll(newpath, os.ModePerm) 441 | if err != nil { 442 | log.Fatal(err) 443 | } 444 | 445 | f, err := os.Create(fmt.Sprintf("%s/%s-%s.json", newpath, prefix, fileType)) 446 | if err != nil { 447 | log.Fatal(err) 448 | } 449 | 450 | defer f.Close() 451 | 452 | _, err = f.WriteString(string(b)) 453 | if err != nil { 454 | log.Fatal(err) 455 | } 456 | 457 | return fmt.Sprintf("%s/%s-%s.json", newpath, prefix, fileType) 458 | } 459 | 460 | func enableCors(w *http.ResponseWriter) { 461 | (*w).Header().Set("Access-Control-Allow-Origin", "*") 462 | } 463 | -------------------------------------------------------------------------------- /map.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "path/filepath" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/hashicorp/terraform-config-inspect/tfconfig" 11 | tfjson "github.com/hashicorp/terraform-json" 12 | ) 13 | 14 | type Action string 15 | type ResourceType string 16 | 17 | const ( 18 | ResourceTypeFile ResourceType = "file" 19 | ResourceTypeLocal ResourceType = "locals" 20 | ResourceTypeVariable ResourceType = "variable" 21 | ResourceTypeOutput ResourceType = "output" 22 | ResourceTypeResource ResourceType = "resource" 23 | ResourceTypeData ResourceType = "data" 24 | ResourceTypeModule ResourceType = "module" 25 | DefaultFileName string = "unknown file" 26 | ) 27 | 28 | const ( 29 | // ActionNoop denotes a no-op operation. 30 | ActionNoop Action = "no-op" 31 | 32 | // ActionCreate denotes a create operation. 33 | ActionCreate Action = "create" 34 | 35 | // ActionRead denotes a read operation. 36 | ActionRead Action = "read" 37 | 38 | // ActionUpdate denotes an update operation. 39 | ActionUpdate Action = "update" 40 | 41 | // ActionDelete denotes a delete operation. 42 | ActionDelete Action = "delete" 43 | 44 | // ActionReplace denotes a replace operation. 45 | ActionReplace Action = "replace" 46 | ) 47 | 48 | // Map represents the root module 49 | type Map struct { 50 | Path string `json:"path"` 51 | RequiredCore []string `json:"required_core,omitempty"` 52 | RequiredProviders map[string]*tfconfig.ProviderRequirement `json:"required_providers,omitempty"` 53 | // ProviderConfigs map[string]*tfconfig.ProviderConfig `json:"provider_configs,omitempty"` 54 | Root map[string]*Resource `json:"root,omitempty"` 55 | } 56 | 57 | // Resource is a modified tfconfig.Resource 58 | type Resource struct { 59 | Type ResourceType `json:"type"` 60 | Name string `json:"name"` 61 | Line *int `json:"line,omitempty"` 62 | 63 | Children map[string]*Resource `json:"children,omitempty"` 64 | 65 | // Resource 66 | ChangeAction Action `json:"change_action,omitempty"` 67 | // Variable and Output 68 | Required *bool `json:"required,omitempty"` 69 | Sensitive bool `json:"sensitive,omitempty"` 70 | // Provider and Data 71 | Provider string `json:"provider,omitempty"` 72 | ResourceType string `json:"resource_type,omitempty"` 73 | // ModuleCall 74 | Source string `json:"source,omitempty"` 75 | Version string `json:"version,omitempty"` 76 | } 77 | 78 | // ModuleCall is a modified tfconfig.ModuleCall 79 | type ModuleCall struct { 80 | Name string `json:"name"` 81 | Source string `json:"source"` 82 | Version string `json:"version,omitempty"` 83 | Line int `json:"line,omitempty"` 84 | } 85 | 86 | func (r *rover) GenerateModuleMap(parent *Resource, parentModule string) { 87 | 88 | childIndex := regexp.MustCompile(`\[[^[\]]*\]$`) 89 | matchBrackets := regexp.MustCompile(`\[[^\[\]]*\]`) 90 | 91 | states := r.RSO.States 92 | configs := r.RSO.Configs 93 | 94 | prefix := parentModule 95 | if parentModule != "" { 96 | prefix = fmt.Sprintf("%s.", prefix) 97 | } 98 | 99 | parentConfig := matchBrackets.ReplaceAllString(parentModule, "") 100 | parentConfigured := configs[parentConfig] != nil && configs[parentConfig].Module != nil 101 | 102 | // Add variables and outputs with line numbers and file names if configured 103 | if parentConfigured && !states[parentModule].IsParent { 104 | for oName, o := range configs[parentConfig].Module.Outputs { 105 | fname := filepath.Base(o.Pos.Filename) 106 | oid := fmt.Sprintf("%soutput.%s", prefix, oName) 107 | out := &Resource{ 108 | Type: ResourceTypeOutput, 109 | Name: oName, 110 | Sensitive: o.Sensitive, 111 | Line: &o.Pos.Line, 112 | } 113 | r.AddFileIfNotExists(parent, parentModule, fname) 114 | 115 | parent.Children[fname].Children[oid] = out 116 | } 117 | 118 | for vName, v := range configs[parentConfig].Module.Variables { 119 | fname := filepath.Base(v.Pos.Filename) 120 | vid := fmt.Sprintf("%svar.%s", prefix, vName) 121 | va := &Resource{ 122 | Type: ResourceTypeVariable, 123 | Name: vName, 124 | Required: &v.Required, 125 | Line: &v.Pos.Line, 126 | } 127 | 128 | r.AddFileIfNotExists(parent, parentModule, fname) 129 | 130 | parent.Children[fname].Children[vid] = va 131 | 132 | } 133 | // Add variables and Outputs if no configuration files 134 | } else if configs[parentConfig] != nil && configs[parentConfig].ModuleConfig.Module != nil && !states[parentModule].IsParent { 135 | for oName, o := range configs[parentConfig].ModuleConfig.Module.Outputs { 136 | oid := fmt.Sprintf("%soutput.%s", prefix, oName) 137 | out := &Resource{ 138 | Type: ResourceTypeOutput, 139 | Name: oName, 140 | Sensitive: o.Sensitive, 141 | } 142 | 143 | parent.Children[oid] = out 144 | } 145 | 146 | for vName := range configs[parentConfig].ModuleConfig.Module.Variables { 147 | vid := fmt.Sprintf("%svar.%s", prefix, vName) 148 | va := &Resource{ 149 | Type: ResourceTypeVariable, 150 | Name: vName, 151 | } 152 | 153 | parent.Children[vid] = va 154 | 155 | } 156 | } 157 | 158 | for id, rs := range states[parentModule].Children { 159 | 160 | configId := matchBrackets.ReplaceAllString(id, "") 161 | configured := configs[parentConfig] != nil && configs[parentConfig].Module != nil && configs[configId] != nil // If there is configuration for filenames, lines, etc. 162 | 163 | re := &Resource{ 164 | Type: rs.Type, 165 | Children: map[string]*Resource{}, 166 | } 167 | 168 | if states[id].Change.Actions != nil { 169 | 170 | re.ChangeAction = Action(string(states[id].Change.Actions[0])) 171 | if len(states[id].Change.Actions) > 1 { 172 | re.ChangeAction = ActionReplace 173 | } 174 | } 175 | 176 | if rs.Type == ResourceTypeResource || rs.Type == ResourceTypeData { 177 | re.ResourceType = configs[configId].ResourceConfig.Type 178 | re.Name = configs[configId].ResourceConfig.Name 179 | 180 | for crName, cr := range states[id].Children { 181 | 182 | if re.Children == nil { 183 | re.Children = make(map[string]*Resource) 184 | } 185 | 186 | tcr := &Resource{ 187 | Type: rs.Type, 188 | } 189 | 190 | if rs.Type == ResourceTypeData { 191 | tcr.Name = strings.TrimPrefix(crName, fmt.Sprintf("%sdata.%s.", prefix, re.ResourceType)) 192 | } else { 193 | tcr.Name = strings.TrimPrefix(crName, fmt.Sprintf("%s%s.", prefix, re.ResourceType)) 194 | } 195 | 196 | if cr.Change.Actions != nil { 197 | tcr.ChangeAction = Action(string(cr.Change.Actions[0])) 198 | 199 | if len(cr.Change.Actions) > 1 { 200 | tcr.ChangeAction = ActionReplace 201 | } 202 | } 203 | 204 | re.Children[crName] = tcr 205 | } 206 | 207 | if configured { 208 | 209 | var fname string 210 | ind := fmt.Sprintf("%s.%s", re.ResourceType, re.Name) 211 | 212 | if rs.Type == ResourceTypeData { 213 | ind = fmt.Sprintf("data.%s", ind) 214 | } 215 | 216 | if rs.Type == ResourceTypeData && configs[parentConfig].Module.DataResources[ind] != nil { 217 | 218 | fname = filepath.Base(configs[parentConfig].Module.DataResources[ind].Pos.Filename) 219 | re.Line = &configs[parentConfig].Module.DataResources[ind].Pos.Line 220 | 221 | r.AddFileIfNotExists(parent, parentModule, fname) 222 | 223 | parent.Children[fname].Children[id] = re 224 | 225 | } else if rs.Type == ResourceTypeResource && configs[parentConfig].Module.ManagedResources[ind] != nil { 226 | 227 | fname = filepath.Base(configs[parentConfig].Module.ManagedResources[ind].Pos.Filename) 228 | re.Line = &configs[parentConfig].Module.ManagedResources[ind].Pos.Line 229 | 230 | r.AddFileIfNotExists(parent, parentModule, fname) 231 | 232 | parent.Children[fname].Children[id] = re 233 | 234 | } else { 235 | 236 | r.AddFileIfNotExists(parent, parentModule, DefaultFileName) 237 | 238 | parent.Children[DefaultFileName].Children[id] = re 239 | } 240 | 241 | } else { 242 | 243 | parent.Children[id] = re 244 | } 245 | 246 | } else if rs.Type == ResourceTypeModule { 247 | re.Name = strings.Split(id, ".")[len(strings.Split(id, "."))-1] 248 | 249 | if configured && !childIndex.MatchString(id) && configs[parentConfig].Module.ModuleCalls[matchBrackets.ReplaceAllString(re.Name, "")] != nil { 250 | fname := filepath.Base(configs[parentConfig].Module.ModuleCalls[matchBrackets.ReplaceAllString(re.Name, "")].Pos.Filename) 251 | re.Line = &configs[parentConfig].Module.ModuleCalls[matchBrackets.ReplaceAllString(re.Name, "")].Pos.Line 252 | 253 | r.AddFileIfNotExists(parent, parentModule, fname) 254 | 255 | parent.Children[fname].Children[id] = re 256 | 257 | } else { 258 | parent.Children[id] = re 259 | } 260 | 261 | r.GenerateModuleMap(re, id) 262 | 263 | } 264 | 265 | // Add locals 266 | if configs[configId] != nil && !(re.Type == ResourceTypeModule && childIndex.MatchString(id)) { 267 | expressions := map[string]*tfjson.Expression{} 268 | 269 | if re.Type == ResourceTypeResource { 270 | expressions = configs[configId].ResourceConfig.Expressions 271 | } else if re.Type == ResourceTypeModule { 272 | expressions = configs[configId].ModuleConfig.Expressions 273 | } else if re.Type == ResourceTypeOutput { 274 | expressions["exp"] = configs[configId].OutputConfig.Expression 275 | } 276 | 277 | // Add locals 278 | for _, reValues := range expressions { 279 | for _, dependsOnR := range reValues.References { 280 | ref := &Resource{} 281 | if strings.HasPrefix(dependsOnR, "local.") { 282 | // Append local variable 283 | ref.Type = ResourceTypeLocal 284 | ref.Name = strings.TrimPrefix(dependsOnR, "local.") 285 | rid := fmt.Sprintf("%s%s", prefix, dependsOnR) 286 | 287 | if parentConfigured { 288 | r.AddFileIfNotExists(parent, parentModule, DefaultFileName) 289 | parent.Children[DefaultFileName].Children[rid] = ref 290 | 291 | } else { 292 | parent.Children[rid] = ref 293 | 294 | } 295 | } 296 | } 297 | } 298 | 299 | } 300 | } 301 | } 302 | 303 | func (r *rover) AddFileIfNotExists(module *Resource, parentModule string, fname string) { 304 | 305 | if _, ok := module.Children[fname]; !ok { 306 | 307 | module.Children[fname] = &Resource{ 308 | Type: ResourceTypeFile, 309 | Name: fname, 310 | Source: fmt.Sprintf("%s/%s", module.Source, fname), 311 | Children: map[string]*Resource{}, 312 | } 313 | } 314 | } 315 | 316 | // Generates Map - Overview of files and their resources 317 | // Groups different resource types together 318 | // Defaults to config 319 | func (r *rover) GenerateMap() error { 320 | log.Println("Generating resource map...") 321 | 322 | // Root module 323 | rootModule := &Resource{ 324 | Type: ResourceTypeModule, 325 | Name: "", 326 | Source: "unknown", 327 | Children: map[string]*Resource{}, 328 | } 329 | 330 | mapObj := &Map{ 331 | Path: "Rover Visualization", 332 | Root: rootModule.Children, 333 | } 334 | 335 | // If root module has local filesystem configuration stuff (line number/ file name info) 336 | rootConfig := r.RSO.Configs[""].Module 337 | 338 | if rootConfig != nil { 339 | rootModule.Source = rootConfig.Path 340 | mapObj.Path = rootConfig.Path 341 | mapObj.RequiredProviders = rootConfig.RequiredProviders 342 | mapObj.RequiredCore = rootConfig.RequiredCore 343 | r.GenerateModuleMap(rootModule, "") 344 | } else { 345 | r.AddFileIfNotExists(rootModule, "", DefaultFileName) 346 | r.GenerateModuleMap(rootModule.Children[DefaultFileName], "") 347 | } 348 | 349 | r.Map = mapObj 350 | 351 | return nil 352 | } 353 | -------------------------------------------------------------------------------- /rso.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/hashicorp/terraform-config-inspect/tfconfig" 7 | tfjson "github.com/hashicorp/terraform-json" 8 | "io/ioutil" 9 | "log" 10 | "os" 11 | "path/filepath" 12 | "regexp" 13 | "strings" 14 | ) 15 | 16 | // ResourcesOverview represents the root module 17 | type ResourcesOverview struct { 18 | Locations map[string]string `json:"locations,omitempty"` 19 | States map[string]*StateOverview `json:"states,omitempty"` 20 | Configs map[string]*ConfigOverview `json:"configs,omitempty"` 21 | } 22 | 23 | // ResourceOverview is a modified tfjson.Plan 24 | type StateOverview struct { 25 | // ChangeAction tfjson.Actions `json:change_action` 26 | Change tfjson.Change `json:"change,omitempty"` 27 | Module *tfjson.StateModule `json:"module,omitempty"` 28 | DependsOn []string `json:"depends_on,omitempty"` 29 | Children map[string]*StateOverview `json:"children,omitempty"` 30 | Type ResourceType `json:"type,omitempty"` 31 | IsParent bool `json:"isparent,omitempty"` 32 | } 33 | 34 | type ConfigOverview struct { 35 | ResourceConfig *tfjson.ConfigResource `json:"resource_config,omitempty"` 36 | ModuleConfig *tfjson.ModuleCall `json:"module_config,omitempty"` 37 | VariableConfig *tfjson.ConfigVariable `json:"variable_config,omitempty"` 38 | OutputConfig *tfjson.ConfigOutput `json:"output_config,omitempty"` 39 | Module *tfconfig.Module `json:"module,omitempty"` 40 | } 41 | 42 | // For parsing modules.json 43 | type ModuleLocations struct { 44 | Locations []ModuleLocation `json:"Modules,omitempty"` 45 | } 46 | 47 | type ModuleLocation struct { 48 | Key string `json:"Key,omitempty""` 49 | Source string `json:"Source,omitempty"` 50 | Dir string `json:"Dir,omitempty"` 51 | } 52 | 53 | // PopulateModuleLocations Parses the modules.json file in the .terraform folder, if it exists 54 | // The module locations are then added to rso.Locations and referenced when loading 55 | // modules from the filesystem with tfconfig.LoadModule 56 | func (r *rover) PopulateModuleLocations(moduleJSONFile string, locations map[string]string) { 57 | 58 | moduleLocations := ModuleLocations{} 59 | 60 | jsonFile, err := os.Open(moduleJSONFile) 61 | if err != nil { 62 | log.Println("No submodule configurations found...") 63 | } 64 | defer jsonFile.Close() 65 | 66 | // read our opened jsonFile as a byte array. 67 | byteValue, _ := ioutil.ReadAll(jsonFile) 68 | 69 | // we unmarshal our byteArray which contains our 70 | // jsonFile's content into 'users' which we defined above 71 | json.Unmarshal(byteValue, &moduleLocations) 72 | 73 | for _, loc := range moduleLocations.Locations { 74 | locations[loc.Key] = fmt.Sprintf("%s/%s", r.WorkingDir, loc.Dir) 75 | //fmt.Printf("%v\n", loc.Dir) 76 | } 77 | } 78 | 79 | func (r *rover) PopulateConfigs(parent string, parentKey string, rso *ResourcesOverview, config *tfjson.ConfigModule) { 80 | 81 | ml := rso.Locations 82 | rc := rso.Configs 83 | 84 | prefix := parent 85 | if prefix != "" { 86 | prefix = fmt.Sprintf("%s.", prefix) 87 | } 88 | 89 | // Loop through variable configs 90 | for variableName, variable := range config.Variables { 91 | variableName = fmt.Sprintf("%svar.%s", prefix, variableName) 92 | if _, ok := rc[variableName]; !ok { 93 | rc[variableName] = &ConfigOverview{} 94 | } 95 | rc[variableName].VariableConfig = variable 96 | } 97 | 98 | // Loop through output configs 99 | for outputName, output := range config.Outputs { 100 | outputName = fmt.Sprintf("%soutput.%s", prefix, outputName) 101 | if _, ok := rc[outputName]; !ok { 102 | rc[outputName] = &ConfigOverview{} 103 | } 104 | rc[outputName].OutputConfig = output 105 | } 106 | 107 | // Loop through each resource type and populate graph 108 | for _, resource := range config.Resources { 109 | 110 | address := fmt.Sprintf("%v%v", prefix, resource.Address) 111 | 112 | if _, ok := rc[address]; !ok { 113 | rc[address] = &ConfigOverview{} 114 | } 115 | 116 | rc[address].ResourceConfig = resource 117 | //rc[address].DependsOn = resource.DependsOn 118 | 119 | if _, ok := rc[parent]; !ok { 120 | rc[parent] = &ConfigOverview{} 121 | } 122 | } 123 | 124 | // Add modules 125 | for moduleName, m := range config.ModuleCalls { 126 | 127 | mn := fmt.Sprintf("module.%s", moduleName) 128 | if prefix != "" { 129 | mn = fmt.Sprintf("%s%s", prefix, mn) 130 | } 131 | 132 | if _, ok := rc[mn]; !ok { 133 | rc[mn] = &ConfigOverview{} 134 | } 135 | 136 | childKey := strings.TrimPrefix(moduleName, "module.") 137 | if parentKey != "" { 138 | childKey = fmt.Sprintf("%s.%s", parentKey, childKey) 139 | } 140 | 141 | childPath := ml[childKey] 142 | child, _ := tfconfig.LoadModule(childPath) 143 | // If module can be loaded from filesystem 144 | if !child.Diagnostics.HasErrors() { 145 | rc[mn].Module = child 146 | } else { 147 | log.Printf("Continuing without loading module from filesystem: %s\n", childKey) 148 | } 149 | 150 | rc[mn].ModuleConfig = m 151 | 152 | r.PopulateConfigs(mn, childKey, rso, m.Module) 153 | } 154 | } 155 | 156 | func (r *rover) PopulateModuleState(rso *ResourcesOverview, module *tfjson.StateModule, prior bool) { 157 | childIndex := regexp.MustCompile(`\[[^[\]]*\]$`) 158 | 159 | rs := rso.States 160 | 161 | // Loop through each resource type and populate states 162 | for _, rst := range module.Resources { 163 | id := rst.Address 164 | parent := module.Address 165 | //fmt.Printf("ID: %v\n", id) 166 | if rst.AttributeValues != nil { 167 | 168 | // Add resource to parent 169 | // Create resource if doesn't exist 170 | if _, ok := rs[id]; !ok { 171 | rs[id] = &StateOverview{} 172 | if rst.Mode == "data" { 173 | rs[id].Type = ResourceTypeData 174 | } else { 175 | rs[id].Type = ResourceTypeResource 176 | } 177 | } 178 | 179 | if _, ok := rs[parent]; !ok { 180 | rs[parent] = &StateOverview{} 181 | rs[parent].Type = ResourceTypeModule 182 | rs[parent].IsParent = false 183 | rs[parent].Children = make(map[string]*StateOverview) 184 | } 185 | 186 | // Check if resource has parent 187 | // part of, resource w/ count or for_each 188 | if childIndex.MatchString(id) { 189 | parent = childIndex.ReplaceAllString(id, "") 190 | // If resource has parent, create parent if doesn't exist 191 | if _, ok := rs[parent]; !ok { 192 | rs[parent] = &StateOverview{} 193 | rs[parent].Children = make(map[string]*StateOverview) 194 | if rst.Mode == "data" { 195 | rs[parent].Type = ResourceTypeData 196 | } else { 197 | rs[parent].Type = ResourceTypeResource 198 | } 199 | 200 | } 201 | 202 | rs[module.Address].Children[parent] = rs[parent] 203 | 204 | } 205 | 206 | //fmt.Printf("%v - %v\n", id, parent) 207 | rs[parent].Children[id] = rs[id] 208 | 209 | if prior { 210 | rs[id].Change.Before = rst.AttributeValues 211 | } else { 212 | rs[id].Change.After = rst.AttributeValues 213 | } 214 | } 215 | } 216 | 217 | for _, childModule := range module.ChildModules { 218 | 219 | parent := module.Address 220 | 221 | id := childModule.Address 222 | 223 | if _, ok := rs[parent]; !ok { 224 | rs[parent] = &StateOverview{} 225 | rs[parent].Children = make(map[string]*StateOverview) 226 | rs[parent].Type = ResourceTypeModule 227 | rs[parent].IsParent = false 228 | } 229 | 230 | if childIndex.MatchString(id) { 231 | parent = childIndex.ReplaceAllString(id, "") 232 | 233 | // If module has parent, create parent if doesn't exist 234 | if _, ok := rs[parent]; !ok { 235 | rs[parent] = &StateOverview{} 236 | rs[parent].Children = make(map[string]*StateOverview) 237 | rs[parent].Type = ResourceTypeModule 238 | rs[parent].IsParent = true 239 | } 240 | 241 | rs[module.Address].Children[parent] = rs[parent] 242 | } 243 | 244 | if rs[parent].Module == nil { 245 | rs[parent].Module = module 246 | } 247 | 248 | if _, ok := rs[id]; !ok { 249 | rs[id] = &StateOverview{} 250 | rs[id].Children = make(map[string]*StateOverview) 251 | rs[id].Type = ResourceTypeModule 252 | } 253 | 254 | rs[id].Module = childModule 255 | 256 | rs[parent].Children[id] = rs[id] 257 | 258 | r.PopulateModuleState(rso, childModule, prior) 259 | } 260 | 261 | } 262 | 263 | // GenerateResourceOverview - Overview of files and their resources 264 | // Groups different resource types together 265 | func (r *rover) GenerateResourceOverview() error { 266 | log.Println("Generating resource overview...") 267 | 268 | matchBrackets := regexp.MustCompile(`\[[^\[\]]*\]`) 269 | rso := &ResourcesOverview{} 270 | 271 | rso.Locations = make(map[string]string) 272 | rso.Configs = make(map[string]*ConfigOverview) 273 | rso.States = make(map[string]*StateOverview) 274 | 275 | rc := rso.Configs 276 | rs := rso.States 277 | 278 | // This is the location of modules.json, which contains where modules are stored on the local filesystem 279 | moduleJSONPath := filepath.Join(r.WorkingDir, ".terraform/modules/modules.json") 280 | r.PopulateModuleLocations(moduleJSONPath, rso.Locations) 281 | 282 | // Create root module configuration 283 | rc[""] = &ConfigOverview{} 284 | rootModule, _ := tfconfig.LoadModule(r.WorkingDir) 285 | // If module can be loaded from filesystem 286 | if !rootModule.Diagnostics.HasErrors() { 287 | rc[""].Module = rootModule 288 | } else { 289 | log.Printf("Could not load configuration from: %v\n", r.WorkingDir) 290 | log.Printf("Continuing without configuration file data...") 291 | } 292 | 293 | rc[""].ModuleConfig = &tfjson.ModuleCall{} 294 | rc[""].ModuleConfig.Module = r.Plan.Config.RootModule 295 | 296 | r.PopulateConfigs("", "", rso, r.Plan.Config.RootModule) 297 | 298 | // Populate prior state 299 | if r.Plan.PriorState != nil { 300 | if r.Plan.PriorState.Values != nil { 301 | if r.Plan.PriorState.Values.RootModule != nil { 302 | r.PopulateModuleState(rso, r.Plan.PriorState.Values.RootModule, true) 303 | } 304 | } 305 | } 306 | 307 | // Populate planned state 308 | if r.Plan.PlannedValues != nil { 309 | if r.Plan.PlannedValues.RootModule != nil { 310 | r.PopulateModuleState(rso, r.Plan.PlannedValues.RootModule, false) 311 | } 312 | } 313 | 314 | // Create root module in state if doesn't exist 315 | if _, ok := rs[""]; !ok { 316 | rs[""] = &StateOverview{} 317 | rs[""].Children = make(map[string]*StateOverview) 318 | rs[""].IsParent = false 319 | rs[""].Type = ResourceTypeModule 320 | } 321 | 322 | // reIsChild := regexp.MustCompile(`^\w+\.\w+[\.\[]`) 323 | // reGetParent := regexp.MustCompile(`^\w+\.\w+`) 324 | //reIsChild := regexp.MustCompile(`^\w+\.[\w-]+[\.\[]`) 325 | 326 | // Loop through output changes 327 | for outputName, output := range r.Plan.OutputChanges { 328 | if _, ok := rs[outputName]; !ok { 329 | rs[outputName] = &StateOverview{} 330 | } 331 | 332 | // If before/after sensitive, set value to "Sensitive Value" 333 | if !r.ShowSensitive { 334 | if output.BeforeSensitive != nil { 335 | if output.BeforeSensitive.(bool) { 336 | output.Before = "Sensitive Value" 337 | } 338 | } 339 | if output.AfterSensitive != nil { 340 | if output.AfterSensitive.(bool) { 341 | output.After = "Sensitive Value" 342 | } 343 | } 344 | } 345 | 346 | rs[outputName].Change = *output 347 | rs[outputName].Type = ResourceTypeOutput 348 | } 349 | 350 | // Loop through resource changes 351 | for _, resource := range r.Plan.ResourceChanges { 352 | id := resource.Address 353 | configId := matchBrackets.ReplaceAllString(id, "") 354 | parent := resource.ModuleAddress 355 | 356 | if resource.Change != nil { 357 | 358 | // If has parent, create parent if doesn't exist 359 | if _, ok := rs[parent]; !ok { 360 | rs[parent] = &StateOverview{} 361 | rs[parent].Children = make(map[string]*StateOverview) 362 | } 363 | 364 | // Add resource to parent 365 | // Create resource if doesn't exist 366 | if _, ok := rs[id]; !ok { 367 | rs[id] = &StateOverview{} 368 | if resource.Mode == "data" { 369 | rs[id].Type = ResourceTypeData 370 | } else { 371 | rs[id].Type = ResourceTypeResource 372 | } 373 | rs[parent].Children[id] = rs[id] 374 | } 375 | rs[id].Change = *resource.Change 376 | 377 | // Create resource config if doesn't exist 378 | if _, ok := rc[configId]; !ok { 379 | rc[configId] = &ConfigOverview{} 380 | rc[configId].ResourceConfig = &tfjson.ConfigResource{} 381 | 382 | // Add type and name since it's missing 383 | // TODO: Find long term fix 384 | rc[configId].ResourceConfig.Name = resource.Name 385 | rc[configId].ResourceConfig.Type = resource.Type 386 | } 387 | 388 | } 389 | } 390 | 391 | r.RSO = rso 392 | 393 | return nil 394 | } 395 | -------------------------------------------------------------------------------- /screenshot.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "os" 10 | "strings" 11 | "time" 12 | 13 | "github.com/chromedp/cdproto/browser" 14 | "github.com/chromedp/chromedp" 15 | ) 16 | 17 | // Heavily inspired by: https://github.com/chromedp/examples/blob/master/download_file/main.go 18 | func screenshot(s *http.Server) { 19 | // ctx, cancel := chromedp.NewContext(context.Background(), chromedp.WithDebugf(log.Printf)) 20 | ctx, cancel := chromedp.NewContext(context.Background()) 21 | defer cancel() 22 | 23 | // create a timeout as a safety net to prevent any infinite wait loops 24 | ctx, cancel = context.WithTimeout(ctx, 60*time.Second) 25 | defer cancel() 26 | 27 | url := fmt.Sprintf("http://%s", s.Addr) 28 | 29 | // this will be used to capture the file name later 30 | var downloadGUID string 31 | 32 | downloadComplete := make(chan bool) 33 | chromedp.ListenTarget(ctx, func(v interface{}) { 34 | if ev, ok := v.(*browser.EventDownloadProgress); ok { 35 | if ev.State == browser.DownloadProgressStateCompleted { 36 | downloadGUID = ev.GUID 37 | close(downloadComplete) 38 | } 39 | } 40 | }) 41 | 42 | if err := chromedp.Run(ctx, chromedp.Tasks{ 43 | browser.SetDownloadBehavior(browser.SetDownloadBehaviorBehaviorAllowAndName). 44 | WithDownloadPath(os.TempDir()). 45 | WithEventsEnabled(true), 46 | 47 | chromedp.Navigate(url), 48 | // wait for graph to be visible 49 | chromedp.WaitVisible(`#cytoscape-div`), 50 | // find and click "Save Graph" button 51 | chromedp.Click(`#saveGraph`, chromedp.NodeVisible), 52 | }); err != nil && !strings.Contains(err.Error(), "net::ERR_ABORTED") { 53 | // Note: Ignoring the net::ERR_ABORTED page error is essential here since downloads 54 | // will cause this error to be emitted, although the download will still succeed. 55 | log.Fatal(err) 56 | } 57 | <-downloadComplete 58 | 59 | e := moveFile(fmt.Sprintf("%v/%v", os.TempDir(), downloadGUID), "./rover.svg") 60 | if e != nil { 61 | log.Fatal(e) 62 | } 63 | 64 | log.Println("Image generation complete.") 65 | 66 | // Shutdown http server 67 | s.Shutdown(context.Background()) 68 | } 69 | 70 | // This function resolves the "invalid cross-device link" error for moving files 71 | // between volumes for Docker. 72 | // https://gist.github.com/var23rav/23ae5d0d4d830aff886c3c970b8f6c6b 73 | func moveFile(sourcePath, destPath string) error { 74 | inputFile, err := os.Open(sourcePath) 75 | if err != nil { 76 | return fmt.Errorf("Couldn't open source file: %s", err) 77 | } 78 | outputFile, err := os.Create(destPath) 79 | if err != nil { 80 | inputFile.Close() 81 | return fmt.Errorf("Couldn't open dest file: %s", err) 82 | } 83 | defer outputFile.Close() 84 | _, err = io.Copy(outputFile, inputFile) 85 | inputFile.Close() 86 | if err != nil { 87 | return fmt.Errorf("Writing to output file failed: %s", err) 88 | } 89 | // The copy was successful, so now delete the original file 90 | err = os.Remove(sourcePath) 91 | if err != nil { 92 | return fmt.Errorf("Failed removing original file: %s", err) 93 | } 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net" 10 | "net/http" 11 | "strings" 12 | // tfjson "github.com/hashicorp/terraform-json" 13 | ) 14 | 15 | func (ro *rover) startServer(ipPort string, frontendFS http.Handler) error { 16 | 17 | m := http.NewServeMux() 18 | s := http.Server{Addr: ipPort, Handler: m} 19 | 20 | m.Handle("/", frontendFS) 21 | m.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { 22 | // simple healthcheck 23 | w.WriteHeader(http.StatusOK) 24 | w.Header().Set("Content-Type", "application/json") 25 | io.WriteString(w, `{"alive": true}`) 26 | }) 27 | m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { 28 | fileType := strings.Replace(r.URL.Path, "/api/", "", 1) 29 | 30 | var j []byte 31 | var err error 32 | 33 | enableCors(&w) 34 | 35 | switch fileType { 36 | case "plan": 37 | j, err = json.Marshal(ro.Plan) 38 | if err != nil { 39 | io.WriteString(w, fmt.Sprintf("Error producing plan JSON: %s\n", err)) 40 | } 41 | case "rso": 42 | j, err = json.Marshal(ro.RSO) 43 | if err != nil { 44 | io.WriteString(w, fmt.Sprintf("Error producing rso JSON: %s\n", err)) 45 | } 46 | case "map": 47 | j, err = json.Marshal(ro.Map) 48 | if err != nil { 49 | io.WriteString(w, fmt.Sprintf("Error producing map JSON: %s\n", err)) 50 | } 51 | case "graph": 52 | j, err = json.Marshal(ro.Graph) 53 | if err != nil { 54 | io.WriteString(w, fmt.Sprintf("Error producing graph JSON: %s\n", err)) 55 | } 56 | default: 57 | io.WriteString(w, "Please enter a valid file type: plan, rso, map, graph\n") 58 | } 59 | 60 | w.Header().Set("Content-Type", "application/json") 61 | io.Copy(w, bytes.NewReader(j)) 62 | }) 63 | 64 | log.Printf("Rover is running on %s", ipPort) 65 | 66 | l, err := net.Listen("tcp", ipPort) 67 | if err != nil { 68 | log.Fatal(err) 69 | } 70 | 71 | // The browser can connect now because the listening socket is open. 72 | if ro.GenImage { 73 | go screenshot(&s) 74 | } 75 | 76 | // Start the blocking server loop. 77 | return s.Serve(l) 78 | 79 | } 80 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | # /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | # Mercator UI 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | npm run lint 21 | ``` 22 | 23 | ### Customize configuration 24 | See [Configuration Reference](https://cli.vuejs.org/config/). 25 | -------------------------------------------------------------------------------- /ui/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/cli-plugin-babel/preset'], 3 | plugins: ['@babel/plugin-proposal-optional-chaining'] 4 | } -------------------------------------------------------------------------------- /ui/dist/chota.min.css: -------------------------------------------------------------------------------- 1 | /*! chota.css v0.8.0 | MIT License | github.com/jenil/chota */:root{--bg-color:#fff;--bg-secondary-color:#f3f3f6;--color-primary:#14854f;--color-lightGrey:#d2d6dd;--color-grey:#747681;--color-darkGrey:#3f4144;--color-error:#d43939;--color-success:#28bd14;--grid-maxWidth:120rem;--grid-gutter:2rem;--font-size:1.6rem;--font-color:#333;--font-family-sans:-apple-system,BlinkMacSystemFont,Avenir,"Avenir Next","Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue",sans-serif;--font-family-mono:monaco,"Consolas","Lucida Console",monospace}html{-webkit-box-sizing:border-box;box-sizing:border-box;font-size:62.5%;line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}*,:after,:before{-webkit-box-sizing:inherit;box-sizing:inherit}*{scrollbar-width:thin;scrollbar-color:var(--color-lightGrey) var(--bg-primary)}::-webkit-scrollbar{width:8px}::-webkit-scrollbar-track{background:var(--bg-primary)}::-webkit-scrollbar-thumb{background:var(--color-lightGrey)}body{background-color:var(--bg-color);line-height:1.6;font-size:var(--font-size);color:var(--font-color);font-family:Segoe UI,Helvetica Neue,sans-serif;font-family:var(--font-family-sans);margin:0;padding:0}h1,h2,h3,h4,h5,h6{font-weight:500;margin:.35em 0 .7em}h1{font-size:2em}h2{font-size:1.75em}h3{font-size:1.5em}h4{font-size:1.25em}h5{font-size:1em}h6{font-size:.85em}a{color:var(--color-primary);text-decoration:none}a:hover:not(.button){opacity:.75}button{font-family:inherit}p{margin-top:0}blockquote{background-color:var(--bg-secondary-color);padding:1.5rem 2rem;border-left:3px solid var(--color-lightGrey)}dl dt{font-weight:700}hr{background-color:var(--color-lightGrey);height:1px;margin:1rem 0}hr,table{border:none}table{width:100%;border-collapse:collapse;border-spacing:0;text-align:left}table.striped tr:nth-of-type(2n){background-color:var(--bg-secondary-color)}td,th{vertical-align:middle;padding:1.2rem .4rem}thead{border-bottom:2px solid var(--color-lightGrey)}tfoot{border-top:2px solid var(--color-lightGrey)}code,kbd,pre,samp,tt{font-family:var(--font-family-mono)}code,kbd{font-size:90%;white-space:pre-wrap;border-radius:4px;padding:.2em .4em;color:var(--color-error)}code,kbd,pre{background-color:var(--bg-secondary-color)}pre{font-size:1em;padding:1rem;overflow-x:auto}pre code{background:none;padding:0}abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}img{max-width:100%}fieldset{border:1px solid var(--color-lightGrey)}iframe{border:0}.container{max-width:var(--grid-maxWidth);margin:0 auto;width:96%;padding:0 calc(var(--grid-gutter)/2)}.row{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-direction:normal;-ms-flex-flow:row wrap;flex-flow:row wrap;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start;margin-left:calc(var(--grid-gutter)/-2);margin-right:calc(var(--grid-gutter)/-2)}.row,.row.reverse{-webkit-box-orient:horizontal}.row.reverse{-webkit-box-direction:reverse;-ms-flex-direction:row-reverse;flex-direction:row-reverse}.col{-webkit-box-flex:1;-ms-flex:1;flex:1}.col,[class*=" col-"],[class^=col-]{margin:0 calc(var(--grid-gutter)/2) calc(var(--grid-gutter)/2)}.col-1{-ms-flex:0 0 calc(8.33333% - var(--grid-gutter));flex:0 0 calc(8.33333% - var(--grid-gutter));max-width:calc(8.33333% - var(--grid-gutter))}.col-1,.col-2{-webkit-box-flex:0}.col-2{-ms-flex:0 0 calc(16.66667% - var(--grid-gutter));flex:0 0 calc(16.66667% - var(--grid-gutter));max-width:calc(16.66667% - var(--grid-gutter))}.col-3{-ms-flex:0 0 calc(25% - var(--grid-gutter));flex:0 0 calc(25% - var(--grid-gutter));max-width:calc(25% - var(--grid-gutter))}.col-3,.col-4{-webkit-box-flex:0}.col-4{-ms-flex:0 0 calc(33.33333% - var(--grid-gutter));flex:0 0 calc(33.33333% - var(--grid-gutter));max-width:calc(33.33333% - var(--grid-gutter))}.col-5{-ms-flex:0 0 calc(41.66667% - var(--grid-gutter));flex:0 0 calc(41.66667% - var(--grid-gutter));max-width:calc(41.66667% - var(--grid-gutter))}.col-5,.col-6{-webkit-box-flex:0}.col-6{-ms-flex:0 0 calc(50% - var(--grid-gutter));flex:0 0 calc(50% - var(--grid-gutter));max-width:calc(50% - var(--grid-gutter))}.col-7{-ms-flex:0 0 calc(58.33333% - var(--grid-gutter));flex:0 0 calc(58.33333% - var(--grid-gutter));max-width:calc(58.33333% - var(--grid-gutter))}.col-7,.col-8{-webkit-box-flex:0}.col-8{-ms-flex:0 0 calc(66.66667% - var(--grid-gutter));flex:0 0 calc(66.66667% - var(--grid-gutter));max-width:calc(66.66667% - var(--grid-gutter))}.col-9{-ms-flex:0 0 calc(75% - var(--grid-gutter));flex:0 0 calc(75% - var(--grid-gutter));max-width:calc(75% - var(--grid-gutter))}.col-9,.col-10{-webkit-box-flex:0}.col-10{-ms-flex:0 0 calc(83.33333% - var(--grid-gutter));flex:0 0 calc(83.33333% - var(--grid-gutter));max-width:calc(83.33333% - var(--grid-gutter))}.col-11{-ms-flex:0 0 calc(91.66667% - var(--grid-gutter));flex:0 0 calc(91.66667% - var(--grid-gutter));max-width:calc(91.66667% - var(--grid-gutter))}.col-11,.col-12{-webkit-box-flex:0}.col-12{-ms-flex:0 0 calc(100% - var(--grid-gutter));flex:0 0 calc(100% - var(--grid-gutter));max-width:calc(100% - var(--grid-gutter))}@media screen and (max-width:599px){.container{width:100%}.col,[class*=col-],[class^=col-]{-webkit-box-flex:0;-ms-flex:0 1 100%;flex:0 1 100%;max-width:100%}}@media screen and (min-width:900px){.col-1-md{-webkit-box-flex:0;-ms-flex:0 0 calc(8.33333% - var(--grid-gutter));flex:0 0 calc(8.33333% - var(--grid-gutter));max-width:calc(8.33333% - var(--grid-gutter))}.col-2-md{-webkit-box-flex:0;-ms-flex:0 0 calc(16.66667% - var(--grid-gutter));flex:0 0 calc(16.66667% - var(--grid-gutter));max-width:calc(16.66667% - var(--grid-gutter))}.col-3-md{-webkit-box-flex:0;-ms-flex:0 0 calc(25% - var(--grid-gutter));flex:0 0 calc(25% - var(--grid-gutter));max-width:calc(25% - var(--grid-gutter))}.col-4-md{-webkit-box-flex:0;-ms-flex:0 0 calc(33.33333% - var(--grid-gutter));flex:0 0 calc(33.33333% - var(--grid-gutter));max-width:calc(33.33333% - var(--grid-gutter))}.col-5-md{-webkit-box-flex:0;-ms-flex:0 0 calc(41.66667% - var(--grid-gutter));flex:0 0 calc(41.66667% - var(--grid-gutter));max-width:calc(41.66667% - var(--grid-gutter))}.col-6-md{-webkit-box-flex:0;-ms-flex:0 0 calc(50% - var(--grid-gutter));flex:0 0 calc(50% - var(--grid-gutter));max-width:calc(50% - var(--grid-gutter))}.col-7-md{-webkit-box-flex:0;-ms-flex:0 0 calc(58.33333% - var(--grid-gutter));flex:0 0 calc(58.33333% - var(--grid-gutter));max-width:calc(58.33333% - var(--grid-gutter))}.col-8-md{-webkit-box-flex:0;-ms-flex:0 0 calc(66.66667% - var(--grid-gutter));flex:0 0 calc(66.66667% - var(--grid-gutter));max-width:calc(66.66667% - var(--grid-gutter))}.col-9-md{-webkit-box-flex:0;-ms-flex:0 0 calc(75% - var(--grid-gutter));flex:0 0 calc(75% - var(--grid-gutter));max-width:calc(75% - var(--grid-gutter))}.col-10-md{-webkit-box-flex:0;-ms-flex:0 0 calc(83.33333% - var(--grid-gutter));flex:0 0 calc(83.33333% - var(--grid-gutter));max-width:calc(83.33333% - var(--grid-gutter))}.col-11-md{-webkit-box-flex:0;-ms-flex:0 0 calc(91.66667% - var(--grid-gutter));flex:0 0 calc(91.66667% - var(--grid-gutter));max-width:calc(91.66667% - var(--grid-gutter))}.col-12-md{-webkit-box-flex:0;-ms-flex:0 0 calc(100% - var(--grid-gutter));flex:0 0 calc(100% - var(--grid-gutter));max-width:calc(100% - var(--grid-gutter))}}@media screen and (min-width:1200px){.col-1-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(8.33333% - var(--grid-gutter));flex:0 0 calc(8.33333% - var(--grid-gutter));max-width:calc(8.33333% - var(--grid-gutter))}.col-2-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(16.66667% - var(--grid-gutter));flex:0 0 calc(16.66667% - var(--grid-gutter));max-width:calc(16.66667% - var(--grid-gutter))}.col-3-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(25% - var(--grid-gutter));flex:0 0 calc(25% - var(--grid-gutter));max-width:calc(25% - var(--grid-gutter))}.col-4-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(33.33333% - var(--grid-gutter));flex:0 0 calc(33.33333% - var(--grid-gutter));max-width:calc(33.33333% - var(--grid-gutter))}.col-5-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(41.66667% - var(--grid-gutter));flex:0 0 calc(41.66667% - var(--grid-gutter));max-width:calc(41.66667% - var(--grid-gutter))}.col-6-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(50% - var(--grid-gutter));flex:0 0 calc(50% - var(--grid-gutter));max-width:calc(50% - var(--grid-gutter))}.col-7-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(58.33333% - var(--grid-gutter));flex:0 0 calc(58.33333% - var(--grid-gutter));max-width:calc(58.33333% - var(--grid-gutter))}.col-8-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(66.66667% - var(--grid-gutter));flex:0 0 calc(66.66667% - var(--grid-gutter));max-width:calc(66.66667% - var(--grid-gutter))}.col-9-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(75% - var(--grid-gutter));flex:0 0 calc(75% - var(--grid-gutter));max-width:calc(75% - var(--grid-gutter))}.col-10-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(83.33333% - var(--grid-gutter));flex:0 0 calc(83.33333% - var(--grid-gutter));max-width:calc(83.33333% - var(--grid-gutter))}.col-11-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(91.66667% - var(--grid-gutter));flex:0 0 calc(91.66667% - var(--grid-gutter));max-width:calc(91.66667% - var(--grid-gutter))}.col-12-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(100% - var(--grid-gutter));flex:0 0 calc(100% - var(--grid-gutter));max-width:calc(100% - var(--grid-gutter))}}fieldset{padding:.5rem 2rem}legend{text-transform:uppercase;font-size:.8em;letter-spacing:.1rem}input:not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=color]):not([type=button]):not([type=reset]),select,textarea,textarea[type=text]{font-family:inherit;padding:.8rem 1rem;border-radius:4px;border:1px solid var(--color-lightGrey);font-size:1em;-webkit-transition:all .2s ease;transition:all .2s ease;display:block;width:100%}input:not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=color]):not([type=button]):not([type=reset]):not(:disabled):hover,select:hover,textarea:hover,textarea[type=text]:hover{border-color:var(--color-grey)}input:not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=color]):not([type=button]):not([type=reset]):focus,select:focus,textarea:focus,textarea[type=text]:focus{outline:none;border-color:var(--color-primary);-webkit-box-shadow:0 0 1px var(--color-primary);box-shadow:0 0 1px var(--color-primary)}input.error:not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=color]):not([type=button]):not([type=reset]),textarea.error{border-color:var(--color-error)}input.success:not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=color]):not([type=button]):not([type=reset]),textarea.success{border-color:var(--color-success)}select{-webkit-appearance:none;background:#f3f3f6 no-repeat 100%;background-size:1ex;background-origin:content-box;background-image:url("data:image/svg+xml;utf8,")}[type=checkbox],[type=radio]{width:1.6rem;height:1.6rem}.button,[type=button],[type=reset],[type=submit],button{padding:1rem 2.5rem;color:var(--color-darkGrey);background:var(--color-lightGrey);border-radius:4px;border:1px solid transparent;font-size:var(--font-size);line-height:1;text-align:center;-webkit-transition:opacity .2s ease;transition:opacity .2s ease;text-decoration:none;-webkit-transform:scale(1);transform:scale(1);display:inline-block;cursor:pointer}.grouped{display:-webkit-box;display:-ms-flexbox;display:flex}.grouped>:not(:last-child){margin-right:16px}.grouped.gapless>*{margin:0 0 0 -1px!important;border-radius:0!important}.grouped.gapless>:first-child{margin:0!important;border-radius:4px 0 0 4px!important}.grouped.gapless>:last-child{border-radius:0 4px 4px 0!important}.button+.button{margin-left:1rem}.button:hover,[type=button]:hover,[type=reset]:hover,[type=submit]:hover,button:hover{opacity:.8}.button:active,[type=button]:active,[type=reset]:active,[type=submit]:active,button:active{-webkit-transform:scale(.98);transform:scale(.98)}button:disabled,button:disabled:hover,input:disabled,input:disabled:hover{opacity:.4;cursor:not-allowed}.button.dark,.button.error,.button.primary,.button.secondary,.button.success,[type=submit]{color:#fff;z-index:1;background-color:#000;background-color:var(--color-primary)}.button.secondary{background-color:var(--color-grey)}.button.dark{background-color:var(--color-darkGrey)}.button.error{background-color:var(--color-error)}.button.success{background-color:var(--color-success)}.button.outline{background-color:transparent;border-color:var(--color-lightGrey)}.button.outline.primary{border-color:var(--color-primary);color:var(--color-primary)}.button.outline.secondary{border-color:var(--color-grey);color:var(--color-grey)}.button.outline.dark{border-color:var(--color-darkGrey);color:var(--color-darkGrey)}.button.clear{background-color:transparent;border-color:transparent;color:var(--color-primary)}.button.icon{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.button.icon>img{margin-left:2px}.button.icon-only{padding:1rem}::-webkit-input-placeholder{color:#bdbfc4}::-moz-placeholder{color:#bdbfc4}:-ms-input-placeholder{color:#bdbfc4}::-ms-input-placeholder{color:#bdbfc4}::placeholder{color:#bdbfc4}.nav{display:-webkit-box;display:-ms-flexbox;display:flex;min-height:5rem;-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch}.nav img{max-height:3rem}.nav-center,.nav-left,.nav-right,.nav>.container{display:-webkit-box;display:-ms-flexbox;display:flex}.nav-center,.nav-left,.nav-right{-webkit-box-flex:1;-ms-flex:1;flex:1}.nav-left{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.nav-right{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.nav-center{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}@media screen and (max-width:480px){.nav,.nav>.container{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.nav-center,.nav-left,.nav-right{-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}}.nav .brand,.nav a{text-decoration:none;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;padding:1rem 2rem;color:var(--color-darkGrey)}.nav .active:not(.button),.nav [aria-current=page]:not(.button){color:#000;color:var(--color-primary)}.nav .brand{font-size:1.75em;padding-top:0;padding-bottom:0}.nav .brand img{padding-right:1rem}.nav .button{margin:auto 1rem}.card{padding:1rem 2rem;border-radius:4px;background:var(--bg-color);-webkit-box-shadow:0 1px 3px var(--color-grey);box-shadow:0 1px 3px var(--color-grey)}.card p:last-child{margin:0}.card header>*{margin-top:0;margin-bottom:1rem}.tabs{display:-webkit-box;display:-ms-flexbox;display:flex}.tabs a{text-decoration:none}.tabs>.dropdown>summary,.tabs>a{padding:1rem 2rem;-webkit-box-flex:0;-ms-flex:0 1 auto;flex:0 1 auto;color:var(--color-darkGrey);border-bottom:2px solid var(--color-lightGrey);text-align:center}.tabs>a.active,.tabs>a:hover,.tabs>a[aria-current=page]{opacity:1;border-bottom:2px solid var(--color-darkGrey)}.tabs>a.active,.tabs>a[aria-current=page]{border-color:var(--color-primary)}.tabs.is-full a{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto}.tag{display:inline-block;border:1px solid var(--color-lightGrey);text-transform:uppercase;color:var(--color-grey);padding:.5rem;line-height:1;letter-spacing:.5px}.tag.is-small{padding:.4rem;font-size:.75em}.tag.is-large{padding:.7rem;font-size:1.125em}.tag+.tag{margin-left:1rem}details.dropdown{position:relative;display:inline-block}details.dropdown>:last-child{position:absolute;left:0;white-space:nowrap}.bg-primary{background-color:var(--color-primary)!important}.bg-light{background-color:var(--color-lightGrey)!important}.bg-dark{background-color:var(--color-darkGrey)!important}.bg-grey{background-color:var(--color-grey)!important}.bg-error{background-color:var(--color-error)!important}.bg-success{background-color:var(--color-success)!important}.bd-primary{border:1px solid var(--color-primary)!important}.bd-light{border:1px solid var(--color-lightGrey)!important}.bd-dark{border:1px solid var(--color-darkGrey)!important}.bd-grey{border:1px solid var(--color-grey)!important}.bd-error{border:1px solid var(--color-error)!important}.bd-success{border:1px solid var(--color-success)!important}.text-primary{color:var(--color-primary)!important}.text-light{color:var(--color-lightGrey)!important}.text-dark{color:var(--color-darkGrey)!important}.text-grey{color:var(--color-grey)!important}.text-error{color:var(--color-error)!important}.text-success{color:var(--color-success)!important}.text-white{color:#fff!important}.pull-right{float:right!important}.pull-left{float:left!important}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.text-justify{text-align:justify}.text-uppercase{text-transform:uppercase}.text-lowercase{text-transform:lowercase}.text-capitalize{text-transform:capitalize}.is-full-screen{width:100%;min-height:100vh}.is-full-width{width:100%!important}.is-vertical-align{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.is-center,.is-horizontal-align{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.is-center{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.is-right{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.is-left,.is-right{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.is-left{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.is-fixed{position:fixed;width:100%}.is-paddingless{padding:0!important}.is-marginless{margin:0!important}.is-pointer{cursor:pointer!important}.is-rounded{border-radius:100%}.clearfix{content:"";display:table;clear:both}.is-hidden{display:none!important}@media screen and (max-width:599px){.hide-xs{display:none!important}}@media screen and (min-width:600px) and (max-width:899px){.hide-sm{display:none!important}}@media screen and (min-width:900px) and (max-width:1199px){.hide-md{display:none!important}}@media screen and (min-width:1200px){.hide-lg{display:none!important}}@media print{.hide-pr{display:none!important}} -------------------------------------------------------------------------------- /ui/dist/css/app.620d0115.css: -------------------------------------------------------------------------------- 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} -------------------------------------------------------------------------------- /ui/dist/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im2nguyen/rover/4be74731103aca85eee1dd93e47351e17979090b/ui/dist/favicon.ico -------------------------------------------------------------------------------- /ui/dist/img/alert-triangle.d88bf755.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/dist/img/arrow-down-circle.27fdf30c.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/dist/img/arrow-up-circle.c7e27cfe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/dist/img/aws.082444af.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im2nguyen/rover/4be74731103aca85eee1dd93e47351e17979090b/ui/dist/img/aws.082444af.png -------------------------------------------------------------------------------- /ui/dist/img/azure.0386fb3d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im2nguyen/rover/4be74731103aca85eee1dd93e47351e17979090b/ui/dist/img/azure.0386fb3d.png -------------------------------------------------------------------------------- /ui/dist/img/gcp.2bdb5143.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im2nguyen/rover/4be74731103aca85eee1dd93e47351e17979090b/ui/dist/img/gcp.2bdb5143.png -------------------------------------------------------------------------------- /ui/dist/img/helm.0d1950ff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im2nguyen/rover/4be74731103aca85eee1dd93e47351e17979090b/ui/dist/img/helm.0d1950ff.png -------------------------------------------------------------------------------- /ui/dist/img/kubernetes.36fdbc6b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im2nguyen/rover/4be74731103aca85eee1dd93e47351e17979090b/ui/dist/img/kubernetes.36fdbc6b.png -------------------------------------------------------------------------------- /ui/dist/img/minus.f2deefda.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/dist/img/plus.b121a385.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/dist/img/refresh-cw.286819b2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/dist/index.html: -------------------------------------------------------------------------------- 1 | ui
-------------------------------------------------------------------------------- /ui/dist/js/app.3f69df0b.js: -------------------------------------------------------------------------------- 1 | (function(e){function t(t){for(var n,s,c=t[0],i=t[1],l=t[2],u=0,f=[];u1?"replace":n.actions[0]),r.before=n.before?n.before:null,r.after=n.after?n.after:{},"string"===typeof r.before&&(r.before={value:r.before}),"string"===typeof r.after&&(r.after={value:r.after}),n["after_unknown"]&&(r.after["value"]={unknown:!0}),r}return r={}}if(t.states[e]&&t.states[e].change){var o=t.states[e].change;o.actions&&(r.action=o.actions.length>1?"replace":o.actions[0]),r.before=o.before?o.before:{},r.after=o.after?o.after:{},o["after_unknown"]&&Object.keys(o["after_unknown"]).forEach((function(e){r.after[e]={unknown:!0}}))}return r}},computed:{resource:function(){var e="";e=this.resourceID.startsWith("Resources/")?this.resourceID.split("/").join("."):this.resourceID.split("/").slice(-2).join(".");var t=e.split("."),r=t.length-1,n=t.join(".");return this.resourceID.startsWith("Resources/")&&(n=t.slice(1).join(".")),{fileName:"".concat(t[0],".").concat(t[1]),id:n,resource_type:t[r-1],resource_name:t[r]}},primitiveType:function(){switch(this.resource.resource_type){case"output":case"var":case"local":return this.resource.resource_type;default:return this.resource.id.startsWith("data.")?"data":"resource"}},isChild:function(){return null!=this.resource.id.match(/\[[^[\]]*\]$/g)},hasNoState:function(){return this.resource.id.includes("var.")},resourceConfig:function(){return this.getResourceConfig(this.resource.id,this.overview,this.isChild)},resourceChange:function(){return this.getResourceChange(this.resource.id,this.overview)}},watch:{resourceID:function(e){e.includes("var.")&&(this.curTab="config")}},mounted:function(){var e=this;"undefined"!==typeof rso?this.overview=rso:v.a.get("/api/rso").then((function(t){e.overview=t.data}))}},_=y,C=(r("081a"),Object(d["a"])(_,p,h,!1,null,"4e3cd299",null)),w=C.exports,x=function(){var e=this,t=e.$createElement,r=e._self._c||t;return r("transition",{attrs:{name:"graph"}},[r("fieldset",[r("legend",[e._v("Graph")]),r("cytoscape",{ref:"cy",attrs:{config:e.config,preConfig:e.preConfig}})],1)])},k=[],R=(r("d81d"),r("8a79"),r("4de4"),r("21a6")),T=r("8df5"),I=r.n(T),j=r("2701"),O=r.n(j),$=r("cc5f"),N=r.n($),P={autounselectify:!0,style:[{selector:"node",style:{label:"data(label)",width:"500px","font-family":"Avenir, Helvetica, Arial, sans-serif","font-size":"2em"}},{selector:"edge",css:{"curve-style":"taxi","line-fill":"linear-gradient","line-gradient-stop-colors":"data(gradient)","line-dash-offset":24,width:10}},{selector:".basename",style:{padding:"200px","text-margin-y":75,"font-weight":"bold",shape:"roundrectangle","min-height":"400px","border-width":2,"border-color":"white","background-color":"#f4ecff"}},{selector:".fname",style:{padding:"100px","text-margin-y":75,"font-weight":"bold",shape:"roundrectangle","border-width":1,"border-color":"lightgrey","background-color":"white"}},{selector:".provider",style:{"text-valign":"center","text-halign":"center",padding:"1em",shape:"roundrectangle","border-width":0,color:"white","background-color":"black"}},{selector:".module",style:{padding:"100px","font-weight":"bold","text-margin-y":60,shape:"roundrectangle",color:"#8450ba","border-width":10,"border-color":"#8450ba","background-color":"white"}},{selector:".data-type",style:{padding:"10%",width:"label","font-weight":"bold","text-background-color":"white","text-background-opacity":1,"text-background-padding":"2em","text-margin-y":15,shape:"roundrectangle","border-width":"5px","border-color":"black","background-color":"white"}},{selector:".data-name",css:{"background-color":"#ffecec",color:"black","font-weight":"bold","text-valign":"center","text-halign":"center",padding:"1.5em",shape:"roundrectangle","border-opacity":1,"border-width":5,"border-color":"#dc477d",label:"data(label)"}},{selector:".output",css:{"background-color":"#fff7e0",color:"black","font-weight":"bold","text-valign":"center","text-halign":"center",padding:"1.5em",shape:"roundrectangle","border-opacity":1,"border-width":5,"border-color":"#ffc107",label:"data(label)"}},{selector:".variable",css:{"background-color":"#e1f0ff",color:"black","font-weight":"bold","text-valign":"center","text-halign":"center",padding:"1.5em",shape:"roundrectangle","border-opacity":1,"border-width":5,"border-color":"#1d7ada",label:"data(label)"}},{selector:".locals",css:{"background-color":"black",color:"white","font-weight":"bold","text-valign":"center","text-halign":"center",padding:"1.5em",shape:"roundrectangle","border-opacity":1,"border-width":5,"border-color":"black",label:"data(label)"}},{selector:".resource-type",style:{padding:"10%",width:"label","font-weight":"bold","text-background-color":"white","text-background-opacity":1,"text-background-padding":"2em","text-margin-y":15,shape:"roundrectangle","border-width":"5px","border-color":"black","background-color":"white"}},{selector:".resource-parent",style:{padding:"10%",width:"label","font-weight":"bold","text-background-color":"white","text-background-opacity":1,"text-background-padding":"2em","text-margin-y":15,shape:"roundrectangle","border-width":"5px","border-color":"black","background-color":"white"}},{selector:".resource-name",css:{"text-valign":"center","text-halign":"center",padding:"1.5em",shape:"roundrectangle","border-opacity":0,color:"white","background-color":"#8450ba","text-wrap":"ellipsis","text-max-width":500}},{selector:".create",css:{"background-color":"#28a745",color:"white","font-weight":"bold"}},{selector:".delete",css:{"background-color":"#e40707",color:"white","font-weight":"bold"}},{selector:".update",css:{"background-color":"#1d7ada",color:"white","font-weight":"bold"}},{selector:".replace",css:{"background-color":"#ffc107",color:"black","font-weight":"bold"}},{selector:".no-op",css:{color:"black","border-opacity":1,"font-weight":"bold","border-width":"5px","border-color":"lightgray","background-color":"white"}},{selector:".invisible",css:{opacity:"0"}},{selector:".semitransp",css:{opacity:"0.4"}},{selector:"edge.semitransp",css:{opacity:"0"}},{selector:".visible",css:{opacity:"1"}},{selector:".dashed",css:{"line-style":"dashed","line-dash-pattern":[20,20]}}]},G={name:"Graph",data:function(){return{selectedNode:"",config:P,graph:{}}},methods:{preConfig:function(e){e.use(I.a),e.use(O.a),"function"!==typeof e("core","nodeHtmlLabel")&&e.use(N.a)},renderGraph:function(){var e=this,t=this.$refs.cy.instance,r=t.elements(),n=this.graph.nodes.map((function(e){return e.data.id}));t.remove(r),this.graph.nodes.forEach((function(e){t.add(e)})),this.graph.edges.forEach((function(e){if(!e.data.id.includes("-variable")&&!e.data.id.includes("-output")){var r=e.data.target;while(!n.includes(r)){if(r=r.split("."),r.length<2)return void console.warn("edge target",e.data.target,"not found in nodes");r.pop(),r=r.join(".")}e.data.target=r,t.add(e)}})),this.runLayouts(),t.on("click","node",(function(t){for(var r=t.target,n={id:r.data().id,in:[],out:[]},o=r.connectedEdges(),a=0;a 1%", 48 | "last 2 versions", 49 | "not dead" 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /ui/public/chota.min.css: -------------------------------------------------------------------------------- 1 | /*! chota.css v0.8.0 | MIT License | github.com/jenil/chota */:root{--bg-color:#fff;--bg-secondary-color:#f3f3f6;--color-primary:#14854f;--color-lightGrey:#d2d6dd;--color-grey:#747681;--color-darkGrey:#3f4144;--color-error:#d43939;--color-success:#28bd14;--grid-maxWidth:120rem;--grid-gutter:2rem;--font-size:1.6rem;--font-color:#333;--font-family-sans:-apple-system,BlinkMacSystemFont,Avenir,"Avenir Next","Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue",sans-serif;--font-family-mono:monaco,"Consolas","Lucida Console",monospace}html{-webkit-box-sizing:border-box;box-sizing:border-box;font-size:62.5%;line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}*,:after,:before{-webkit-box-sizing:inherit;box-sizing:inherit}*{scrollbar-width:thin;scrollbar-color:var(--color-lightGrey) var(--bg-primary)}::-webkit-scrollbar{width:8px}::-webkit-scrollbar-track{background:var(--bg-primary)}::-webkit-scrollbar-thumb{background:var(--color-lightGrey)}body{background-color:var(--bg-color);line-height:1.6;font-size:var(--font-size);color:var(--font-color);font-family:Segoe UI,Helvetica Neue,sans-serif;font-family:var(--font-family-sans);margin:0;padding:0}h1,h2,h3,h4,h5,h6{font-weight:500;margin:.35em 0 .7em}h1{font-size:2em}h2{font-size:1.75em}h3{font-size:1.5em}h4{font-size:1.25em}h5{font-size:1em}h6{font-size:.85em}a{color:var(--color-primary);text-decoration:none}a:hover:not(.button){opacity:.75}button{font-family:inherit}p{margin-top:0}blockquote{background-color:var(--bg-secondary-color);padding:1.5rem 2rem;border-left:3px solid var(--color-lightGrey)}dl dt{font-weight:700}hr{background-color:var(--color-lightGrey);height:1px;margin:1rem 0}hr,table{border:none}table{width:100%;border-collapse:collapse;border-spacing:0;text-align:left}table.striped tr:nth-of-type(2n){background-color:var(--bg-secondary-color)}td,th{vertical-align:middle;padding:1.2rem .4rem}thead{border-bottom:2px solid var(--color-lightGrey)}tfoot{border-top:2px solid var(--color-lightGrey)}code,kbd,pre,samp,tt{font-family:var(--font-family-mono)}code,kbd{font-size:90%;white-space:pre-wrap;border-radius:4px;padding:.2em .4em;color:var(--color-error)}code,kbd,pre{background-color:var(--bg-secondary-color)}pre{font-size:1em;padding:1rem;overflow-x:auto}pre code{background:none;padding:0}abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}img{max-width:100%}fieldset{border:1px solid var(--color-lightGrey)}iframe{border:0}.container{max-width:var(--grid-maxWidth);margin:0 auto;width:96%;padding:0 calc(var(--grid-gutter)/2)}.row{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-direction:normal;-ms-flex-flow:row wrap;flex-flow:row wrap;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start;margin-left:calc(var(--grid-gutter)/-2);margin-right:calc(var(--grid-gutter)/-2)}.row,.row.reverse{-webkit-box-orient:horizontal}.row.reverse{-webkit-box-direction:reverse;-ms-flex-direction:row-reverse;flex-direction:row-reverse}.col{-webkit-box-flex:1;-ms-flex:1;flex:1}.col,[class*=" col-"],[class^=col-]{margin:0 calc(var(--grid-gutter)/2) calc(var(--grid-gutter)/2)}.col-1{-ms-flex:0 0 calc(8.33333% - var(--grid-gutter));flex:0 0 calc(8.33333% - var(--grid-gutter));max-width:calc(8.33333% - var(--grid-gutter))}.col-1,.col-2{-webkit-box-flex:0}.col-2{-ms-flex:0 0 calc(16.66667% - var(--grid-gutter));flex:0 0 calc(16.66667% - var(--grid-gutter));max-width:calc(16.66667% - var(--grid-gutter))}.col-3{-ms-flex:0 0 calc(25% - var(--grid-gutter));flex:0 0 calc(25% - var(--grid-gutter));max-width:calc(25% - var(--grid-gutter))}.col-3,.col-4{-webkit-box-flex:0}.col-4{-ms-flex:0 0 calc(33.33333% - var(--grid-gutter));flex:0 0 calc(33.33333% - var(--grid-gutter));max-width:calc(33.33333% - var(--grid-gutter))}.col-5{-ms-flex:0 0 calc(41.66667% - var(--grid-gutter));flex:0 0 calc(41.66667% - var(--grid-gutter));max-width:calc(41.66667% - var(--grid-gutter))}.col-5,.col-6{-webkit-box-flex:0}.col-6{-ms-flex:0 0 calc(50% - var(--grid-gutter));flex:0 0 calc(50% - var(--grid-gutter));max-width:calc(50% - var(--grid-gutter))}.col-7{-ms-flex:0 0 calc(58.33333% - var(--grid-gutter));flex:0 0 calc(58.33333% - var(--grid-gutter));max-width:calc(58.33333% - var(--grid-gutter))}.col-7,.col-8{-webkit-box-flex:0}.col-8{-ms-flex:0 0 calc(66.66667% - var(--grid-gutter));flex:0 0 calc(66.66667% - var(--grid-gutter));max-width:calc(66.66667% - var(--grid-gutter))}.col-9{-ms-flex:0 0 calc(75% - var(--grid-gutter));flex:0 0 calc(75% - var(--grid-gutter));max-width:calc(75% - var(--grid-gutter))}.col-9,.col-10{-webkit-box-flex:0}.col-10{-ms-flex:0 0 calc(83.33333% - var(--grid-gutter));flex:0 0 calc(83.33333% - var(--grid-gutter));max-width:calc(83.33333% - var(--grid-gutter))}.col-11{-ms-flex:0 0 calc(91.66667% - var(--grid-gutter));flex:0 0 calc(91.66667% - var(--grid-gutter));max-width:calc(91.66667% - var(--grid-gutter))}.col-11,.col-12{-webkit-box-flex:0}.col-12{-ms-flex:0 0 calc(100% - var(--grid-gutter));flex:0 0 calc(100% - var(--grid-gutter));max-width:calc(100% - var(--grid-gutter))}@media screen and (max-width:599px){.container{width:100%}.col,[class*=col-],[class^=col-]{-webkit-box-flex:0;-ms-flex:0 1 100%;flex:0 1 100%;max-width:100%}}@media screen and (min-width:900px){.col-1-md{-webkit-box-flex:0;-ms-flex:0 0 calc(8.33333% - var(--grid-gutter));flex:0 0 calc(8.33333% - var(--grid-gutter));max-width:calc(8.33333% - var(--grid-gutter))}.col-2-md{-webkit-box-flex:0;-ms-flex:0 0 calc(16.66667% - var(--grid-gutter));flex:0 0 calc(16.66667% - var(--grid-gutter));max-width:calc(16.66667% - var(--grid-gutter))}.col-3-md{-webkit-box-flex:0;-ms-flex:0 0 calc(25% - var(--grid-gutter));flex:0 0 calc(25% - var(--grid-gutter));max-width:calc(25% - var(--grid-gutter))}.col-4-md{-webkit-box-flex:0;-ms-flex:0 0 calc(33.33333% - var(--grid-gutter));flex:0 0 calc(33.33333% - var(--grid-gutter));max-width:calc(33.33333% - var(--grid-gutter))}.col-5-md{-webkit-box-flex:0;-ms-flex:0 0 calc(41.66667% - var(--grid-gutter));flex:0 0 calc(41.66667% - var(--grid-gutter));max-width:calc(41.66667% - var(--grid-gutter))}.col-6-md{-webkit-box-flex:0;-ms-flex:0 0 calc(50% - var(--grid-gutter));flex:0 0 calc(50% - var(--grid-gutter));max-width:calc(50% - var(--grid-gutter))}.col-7-md{-webkit-box-flex:0;-ms-flex:0 0 calc(58.33333% - var(--grid-gutter));flex:0 0 calc(58.33333% - var(--grid-gutter));max-width:calc(58.33333% - var(--grid-gutter))}.col-8-md{-webkit-box-flex:0;-ms-flex:0 0 calc(66.66667% - var(--grid-gutter));flex:0 0 calc(66.66667% - var(--grid-gutter));max-width:calc(66.66667% - var(--grid-gutter))}.col-9-md{-webkit-box-flex:0;-ms-flex:0 0 calc(75% - var(--grid-gutter));flex:0 0 calc(75% - var(--grid-gutter));max-width:calc(75% - var(--grid-gutter))}.col-10-md{-webkit-box-flex:0;-ms-flex:0 0 calc(83.33333% - var(--grid-gutter));flex:0 0 calc(83.33333% - var(--grid-gutter));max-width:calc(83.33333% - var(--grid-gutter))}.col-11-md{-webkit-box-flex:0;-ms-flex:0 0 calc(91.66667% - var(--grid-gutter));flex:0 0 calc(91.66667% - var(--grid-gutter));max-width:calc(91.66667% - var(--grid-gutter))}.col-12-md{-webkit-box-flex:0;-ms-flex:0 0 calc(100% - var(--grid-gutter));flex:0 0 calc(100% - var(--grid-gutter));max-width:calc(100% - var(--grid-gutter))}}@media screen and (min-width:1200px){.col-1-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(8.33333% - var(--grid-gutter));flex:0 0 calc(8.33333% - var(--grid-gutter));max-width:calc(8.33333% - var(--grid-gutter))}.col-2-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(16.66667% - var(--grid-gutter));flex:0 0 calc(16.66667% - var(--grid-gutter));max-width:calc(16.66667% - var(--grid-gutter))}.col-3-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(25% - var(--grid-gutter));flex:0 0 calc(25% - var(--grid-gutter));max-width:calc(25% - var(--grid-gutter))}.col-4-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(33.33333% - var(--grid-gutter));flex:0 0 calc(33.33333% - var(--grid-gutter));max-width:calc(33.33333% - var(--grid-gutter))}.col-5-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(41.66667% - var(--grid-gutter));flex:0 0 calc(41.66667% - var(--grid-gutter));max-width:calc(41.66667% - var(--grid-gutter))}.col-6-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(50% - var(--grid-gutter));flex:0 0 calc(50% - var(--grid-gutter));max-width:calc(50% - var(--grid-gutter))}.col-7-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(58.33333% - var(--grid-gutter));flex:0 0 calc(58.33333% - var(--grid-gutter));max-width:calc(58.33333% - var(--grid-gutter))}.col-8-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(66.66667% - var(--grid-gutter));flex:0 0 calc(66.66667% - var(--grid-gutter));max-width:calc(66.66667% - var(--grid-gutter))}.col-9-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(75% - var(--grid-gutter));flex:0 0 calc(75% - var(--grid-gutter));max-width:calc(75% - var(--grid-gutter))}.col-10-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(83.33333% - var(--grid-gutter));flex:0 0 calc(83.33333% - var(--grid-gutter));max-width:calc(83.33333% - var(--grid-gutter))}.col-11-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(91.66667% - var(--grid-gutter));flex:0 0 calc(91.66667% - var(--grid-gutter));max-width:calc(91.66667% - var(--grid-gutter))}.col-12-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(100% - var(--grid-gutter));flex:0 0 calc(100% - var(--grid-gutter));max-width:calc(100% - var(--grid-gutter))}}fieldset{padding:.5rem 2rem}legend{text-transform:uppercase;font-size:.8em;letter-spacing:.1rem}input:not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=color]):not([type=button]):not([type=reset]),select,textarea,textarea[type=text]{font-family:inherit;padding:.8rem 1rem;border-radius:4px;border:1px solid var(--color-lightGrey);font-size:1em;-webkit-transition:all .2s ease;transition:all .2s ease;display:block;width:100%}input:not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=color]):not([type=button]):not([type=reset]):not(:disabled):hover,select:hover,textarea:hover,textarea[type=text]:hover{border-color:var(--color-grey)}input:not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=color]):not([type=button]):not([type=reset]):focus,select:focus,textarea:focus,textarea[type=text]:focus{outline:none;border-color:var(--color-primary);-webkit-box-shadow:0 0 1px var(--color-primary);box-shadow:0 0 1px var(--color-primary)}input.error:not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=color]):not([type=button]):not([type=reset]),textarea.error{border-color:var(--color-error)}input.success:not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=color]):not([type=button]):not([type=reset]),textarea.success{border-color:var(--color-success)}select{-webkit-appearance:none;background:#f3f3f6 no-repeat 100%;background-size:1ex;background-origin:content-box;background-image:url("data:image/svg+xml;utf8,")}[type=checkbox],[type=radio]{width:1.6rem;height:1.6rem}.button,[type=button],[type=reset],[type=submit],button{padding:1rem 2.5rem;color:var(--color-darkGrey);background:var(--color-lightGrey);border-radius:4px;border:1px solid transparent;font-size:var(--font-size);line-height:1;text-align:center;-webkit-transition:opacity .2s ease;transition:opacity .2s ease;text-decoration:none;-webkit-transform:scale(1);transform:scale(1);display:inline-block;cursor:pointer}.grouped{display:-webkit-box;display:-ms-flexbox;display:flex}.grouped>:not(:last-child){margin-right:16px}.grouped.gapless>*{margin:0 0 0 -1px!important;border-radius:0!important}.grouped.gapless>:first-child{margin:0!important;border-radius:4px 0 0 4px!important}.grouped.gapless>:last-child{border-radius:0 4px 4px 0!important}.button+.button{margin-left:1rem}.button:hover,[type=button]:hover,[type=reset]:hover,[type=submit]:hover,button:hover{opacity:.8}.button:active,[type=button]:active,[type=reset]:active,[type=submit]:active,button:active{-webkit-transform:scale(.98);transform:scale(.98)}button:disabled,button:disabled:hover,input:disabled,input:disabled:hover{opacity:.4;cursor:not-allowed}.button.dark,.button.error,.button.primary,.button.secondary,.button.success,[type=submit]{color:#fff;z-index:1;background-color:#000;background-color:var(--color-primary)}.button.secondary{background-color:var(--color-grey)}.button.dark{background-color:var(--color-darkGrey)}.button.error{background-color:var(--color-error)}.button.success{background-color:var(--color-success)}.button.outline{background-color:transparent;border-color:var(--color-lightGrey)}.button.outline.primary{border-color:var(--color-primary);color:var(--color-primary)}.button.outline.secondary{border-color:var(--color-grey);color:var(--color-grey)}.button.outline.dark{border-color:var(--color-darkGrey);color:var(--color-darkGrey)}.button.clear{background-color:transparent;border-color:transparent;color:var(--color-primary)}.button.icon{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.button.icon>img{margin-left:2px}.button.icon-only{padding:1rem}::-webkit-input-placeholder{color:#bdbfc4}::-moz-placeholder{color:#bdbfc4}:-ms-input-placeholder{color:#bdbfc4}::-ms-input-placeholder{color:#bdbfc4}::placeholder{color:#bdbfc4}.nav{display:-webkit-box;display:-ms-flexbox;display:flex;min-height:5rem;-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch}.nav img{max-height:3rem}.nav-center,.nav-left,.nav-right,.nav>.container{display:-webkit-box;display:-ms-flexbox;display:flex}.nav-center,.nav-left,.nav-right{-webkit-box-flex:1;-ms-flex:1;flex:1}.nav-left{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.nav-right{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.nav-center{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}@media screen and (max-width:480px){.nav,.nav>.container{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.nav-center,.nav-left,.nav-right{-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}}.nav .brand,.nav a{text-decoration:none;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;padding:1rem 2rem;color:var(--color-darkGrey)}.nav .active:not(.button),.nav [aria-current=page]:not(.button){color:#000;color:var(--color-primary)}.nav .brand{font-size:1.75em;padding-top:0;padding-bottom:0}.nav .brand img{padding-right:1rem}.nav .button{margin:auto 1rem}.card{padding:1rem 2rem;border-radius:4px;background:var(--bg-color);-webkit-box-shadow:0 1px 3px var(--color-grey);box-shadow:0 1px 3px var(--color-grey)}.card p:last-child{margin:0}.card header>*{margin-top:0;margin-bottom:1rem}.tabs{display:-webkit-box;display:-ms-flexbox;display:flex}.tabs a{text-decoration:none}.tabs>.dropdown>summary,.tabs>a{padding:1rem 2rem;-webkit-box-flex:0;-ms-flex:0 1 auto;flex:0 1 auto;color:var(--color-darkGrey);border-bottom:2px solid var(--color-lightGrey);text-align:center}.tabs>a.active,.tabs>a:hover,.tabs>a[aria-current=page]{opacity:1;border-bottom:2px solid var(--color-darkGrey)}.tabs>a.active,.tabs>a[aria-current=page]{border-color:var(--color-primary)}.tabs.is-full a{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto}.tag{display:inline-block;border:1px solid var(--color-lightGrey);text-transform:uppercase;color:var(--color-grey);padding:.5rem;line-height:1;letter-spacing:.5px}.tag.is-small{padding:.4rem;font-size:.75em}.tag.is-large{padding:.7rem;font-size:1.125em}.tag+.tag{margin-left:1rem}details.dropdown{position:relative;display:inline-block}details.dropdown>:last-child{position:absolute;left:0;white-space:nowrap}.bg-primary{background-color:var(--color-primary)!important}.bg-light{background-color:var(--color-lightGrey)!important}.bg-dark{background-color:var(--color-darkGrey)!important}.bg-grey{background-color:var(--color-grey)!important}.bg-error{background-color:var(--color-error)!important}.bg-success{background-color:var(--color-success)!important}.bd-primary{border:1px solid var(--color-primary)!important}.bd-light{border:1px solid var(--color-lightGrey)!important}.bd-dark{border:1px solid var(--color-darkGrey)!important}.bd-grey{border:1px solid var(--color-grey)!important}.bd-error{border:1px solid var(--color-error)!important}.bd-success{border:1px solid var(--color-success)!important}.text-primary{color:var(--color-primary)!important}.text-light{color:var(--color-lightGrey)!important}.text-dark{color:var(--color-darkGrey)!important}.text-grey{color:var(--color-grey)!important}.text-error{color:var(--color-error)!important}.text-success{color:var(--color-success)!important}.text-white{color:#fff!important}.pull-right{float:right!important}.pull-left{float:left!important}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.text-justify{text-align:justify}.text-uppercase{text-transform:uppercase}.text-lowercase{text-transform:lowercase}.text-capitalize{text-transform:capitalize}.is-full-screen{width:100%;min-height:100vh}.is-full-width{width:100%!important}.is-vertical-align{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.is-center,.is-horizontal-align{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.is-center{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.is-right{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.is-left,.is-right{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.is-left{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.is-fixed{position:fixed;width:100%}.is-paddingless{padding:0!important}.is-marginless{margin:0!important}.is-pointer{cursor:pointer!important}.is-rounded{border-radius:100%}.clearfix{content:"";display:table;clear:both}.is-hidden{display:none!important}@media screen and (max-width:599px){.hide-xs{display:none!important}}@media screen and (min-width:600px) and (max-width:899px){.hide-sm{display:none!important}}@media screen and (min-width:900px) and (max-width:1199px){.hide-md{display:none!important}}@media screen and (min-width:1200px){.hide-lg{display:none!important}}@media print{.hide-pr{display:none!important}} -------------------------------------------------------------------------------- /ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im2nguyen/rover/4be74731103aca85eee1dd93e47351e17979090b/ui/public/favicon.ico -------------------------------------------------------------------------------- /ui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | <%= htmlWebpackPlugin.options.title %> 11 | 12 | 13 | 14 | 15 | 16 | 17 | 21 |
22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /ui/public/style.css: -------------------------------------------------------------------------------- 1 | body.dark { 2 | --bg-color: #181921; 3 | --bg-secondary-color: #1c1c3f; 4 | --font-color: #f5f5f5; 5 | --color-grey: #ccc; 6 | --color-darkGrey: #777; 7 | } 8 | 9 | :root { 10 | --color-primary: #8450ba; /* Terraform brand color */ 11 | } 12 | 13 | .card { 14 | border: 1px solid var(--color-grey); 15 | box-shadow: none; 16 | } -------------------------------------------------------------------------------- /ui/src/App.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 86 | 87 | 110 | -------------------------------------------------------------------------------- /ui/src/assets/icons/arrow-down-circle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/assets/icons/arrow-up-circle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im2nguyen/rover/4be74731103aca85eee1dd93e47351e17979090b/ui/src/assets/logo.png -------------------------------------------------------------------------------- /ui/src/assets/provider-icons/aws.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im2nguyen/rover/4be74731103aca85eee1dd93e47351e17979090b/ui/src/assets/provider-icons/aws.png -------------------------------------------------------------------------------- /ui/src/assets/provider-icons/azure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im2nguyen/rover/4be74731103aca85eee1dd93e47351e17979090b/ui/src/assets/provider-icons/azure.png -------------------------------------------------------------------------------- /ui/src/assets/provider-icons/gcp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im2nguyen/rover/4be74731103aca85eee1dd93e47351e17979090b/ui/src/assets/provider-icons/gcp.png -------------------------------------------------------------------------------- /ui/src/assets/provider-icons/helm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im2nguyen/rover/4be74731103aca85eee1dd93e47351e17979090b/ui/src/assets/provider-icons/helm.png -------------------------------------------------------------------------------- /ui/src/assets/provider-icons/kubernetes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im2nguyen/rover/4be74731103aca85eee1dd93e47351e17979090b/ui/src/assets/provider-icons/kubernetes.png -------------------------------------------------------------------------------- /ui/src/assets/resource-icons/alert-triangle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/assets/resource-icons/minus.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/assets/resource-icons/plus.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/assets/resource-icons/refresh-cw.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/components/Explorer.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 51 | 52 | 58 | -------------------------------------------------------------------------------- /ui/src/components/File.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 76 | 77 | 110 | -------------------------------------------------------------------------------- /ui/src/components/Graph/Graph.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 521 | 522 | 616 | 617 | 618 | 641 | -------------------------------------------------------------------------------- /ui/src/components/MainNav.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 70 | 71 | -------------------------------------------------------------------------------- /ui/src/components/ResourceCard.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 152 | 153 | 326 | -------------------------------------------------------------------------------- /ui/src/components/ResourceDetail.vue: -------------------------------------------------------------------------------- 1 | 119 | 120 | 412 | 413 | -------------------------------------------------------------------------------- /ui/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import App from './App.vue'; 3 | import VueCytoscape from 'vue-cytoscape'; 4 | import VueMeta from 'vue-meta'; 5 | 6 | Vue.use(VueCytoscape); 7 | Vue.use(VueMeta); 8 | 9 | Vue.config.productionTip = false 10 | 11 | new Vue({ 12 | render: h => h(App), 13 | }).$mount('#app') 14 | -------------------------------------------------------------------------------- /zip.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "io/fs" 10 | "log" 11 | "os" 12 | "strings" 13 | ) 14 | 15 | func (r *rover) generateZip(fe fs.FS, filename string) error { 16 | newZipFile, err := os.Create(filename) 17 | if err != nil { 18 | return err 19 | } 20 | defer newZipFile.Close() 21 | 22 | zipWriter := zip.NewWriter(newZipFile) 23 | defer zipWriter.Close() 24 | 25 | // Add frontend to zip file 26 | feItems, err := fs.ReadDir(fe, ".") 27 | if err != nil { 28 | log.Fatalln(err) 29 | } 30 | 31 | for _, feItem := range feItems { 32 | if !feItem.IsDir() { 33 | if err = AddEmbeddedToZip(fe, zipWriter, feItem.Name()); err != nil { 34 | return err 35 | } 36 | continue 37 | } 38 | 39 | // Iterate through subdirectories (ui/dist/*) 40 | feSubItems, err := fs.ReadDir(fe, feItem.Name()) 41 | if err != nil { 42 | return err 43 | } 44 | for _, feSubItem := range feSubItems { 45 | if err = AddEmbeddedToZip(fe, zipWriter, fmt.Sprintf("%s/%s", feItem.Name(), feSubItem.Name())); err != nil { 46 | return err 47 | } 48 | } 49 | } 50 | 51 | // Add plan, rso, map, graph to zip file 52 | if err = AddFileToZip(zipWriter, "plan", r.Plan); err != nil { 53 | return err 54 | } 55 | if err = AddFileToZip(zipWriter, "rso", r.RSO); err != nil { 56 | return err 57 | } 58 | if err = AddFileToZip(zipWriter, "map", r.Map); err != nil { 59 | return err 60 | } 61 | if err = AddFileToZip(zipWriter, "graph", r.Graph); err != nil { 62 | return err 63 | } 64 | 65 | return nil 66 | } 67 | 68 | func AddEmbeddedToZip(fe fs.FS, zipWriter *zip.Writer, filename string) error { 69 | writer, err := zipWriter.Create(filename) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | var fileToZip fs.File 75 | 76 | // Rename standalone to index.html references from absolute to relative 77 | if filename == "index.html" { 78 | curContent, err := fs.ReadFile(fe, filename) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | contents := strings.Split(string(curContent), "") 84 | // Add js files, workaround since CORS error if you try to do getJSON 85 | content := fmt.Sprintf("%s%s%s", contents[0], ` 86 | 87 | `, contents[1]) 88 | content = strings.ReplaceAll(content, "=\"/", "=\"./") 89 | 90 | tempFileName, tempFile, err := createTempFile("temp-index.html", []byte(content)) 91 | defer os.Remove(tempFile.Name()) // clean up 92 | defer tempFile.Close() 93 | 94 | fileToZip, err = os.Open(tempFileName) 95 | if err != nil { 96 | return err 97 | } 98 | defer fileToZip.Close() 99 | } else if strings.HasSuffix(filename, ".js") { 100 | curContent, err := fs.ReadFile(fe, filename) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | rawContent := bytes.ReplaceAll(curContent, []byte("r.p+\""), []byte("\"./")) 106 | 107 | tempFileName, tempFile, err := createTempFile("temp-index.html", rawContent) 108 | defer os.Remove(tempFile.Name()) // clean up 109 | defer tempFile.Close() 110 | 111 | fileToZip, err = os.Open(tempFileName) 112 | if err != nil { 113 | return err 114 | } 115 | defer fileToZip.Close() 116 | 117 | } else { 118 | fileToZip, err = fe.Open(filename) 119 | if err != nil { 120 | return err 121 | } 122 | defer fileToZip.Close() 123 | } 124 | 125 | _, err = io.Copy(writer, fileToZip) 126 | return err 127 | } 128 | 129 | func AddFileToZip(zipWriter *zip.Writer, fileType string, j interface{}) error { 130 | filename := fmt.Sprintf("%s.js", fileType) 131 | 132 | writer, err := zipWriter.Create(filename) 133 | if err != nil { 134 | return err 135 | } 136 | 137 | b, err := json.Marshal(j) 138 | if err != nil { 139 | return fmt.Errorf("error producing JSON: %s\n", err) 140 | } 141 | 142 | // add syntax to make json file a js object 143 | content := fmt.Sprintf("const %s = %s", fileType, string(b)) 144 | 145 | tempFileName, tempFile, err := createTempFile(filename, []byte(content)) 146 | defer os.Remove(tempFile.Name()) // clean up 147 | defer tempFile.Close() 148 | 149 | fileToZip, err := os.Open(tempFileName) 150 | if err != nil { 151 | return err 152 | } 153 | defer fileToZip.Close() 154 | 155 | _, err = io.Copy(writer, fileToZip) 156 | return err 157 | } 158 | 159 | func createTempFile(filename string, b []byte) (string, *os.File, error) { 160 | tempFile, err := os.CreateTemp("", filename) 161 | if err != nil { 162 | log.Fatal(err) 163 | } 164 | 165 | _, err = tempFile.Write(b) 166 | if err != nil { 167 | return "", tempFile, err 168 | } 169 | 170 | return tempFile.Name(), tempFile, nil 171 | } 172 | --------------------------------------------------------------------------------