diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 31948c5f..c68ff0b5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,8 +27,8 @@ jobs: run: make vet - name: Setup provider mirror for tests run: ./hacks/setup_mirror.sh - - name: Install terragrunt for tests - run: make install-terragrunt + - name: Install test dependencies + run: make install-terragrunt install-infracost - name: Tests run: make test release-please: diff --git a/Makefile b/Makefile index 8471a62f..60dbbc04 100644 --- a/Makefile +++ b/Makefile @@ -53,3 +53,10 @@ install-terragrunt: mkdir -p ~/.local/bin curl -L https://github.com/gruntwork-io/terragrunt/releases/download/v0.60.0/terragrunt_linux_amd64 -o ~/.local/bin/terragrunt chmod +x ~/.local/bin/terragrunt + +.PHONY: install-infracost +install-infracost: + mkdir -p ~/.local/bin + curl -L https://github.com/infracost/infracost/releases/download/v0.10.38/infracost-linux-amd64.tar.gz | tar -zxf - + mv infracost-linux-amd64 ~/.local/bin/infracost + chmod +x ~/.local/bin/infracost diff --git a/README.md b/README.md index 81f4aaed..9671a580 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A terminal user interface for terraform power users. * Supports terraform, tofu and terragrunt * Supports terragrunt dependencies * Supports workspaces +* Calculate costs using infracost * Automatically loads workspace variable files * Backend agnostic (s3, cloud, etc) @@ -128,6 +129,7 @@ Press `w` to go to the workspaces page. |`a`|Run `terraform apply`|✓| |`d`|Run `terraform apply -destroy`|✓| |`C`|Run `terraform workspace select`|✗| +|`$`|Run `infracost breakdown`|✓| ### State @@ -291,6 +293,18 @@ A task can be canceled at any stage. If it is `running` then the current terrafo When a workspace is loaded into Pug for the first time, a task is created to invoke `terraform state pull`, which retrieves workspace's state, and then the state is loaded into Pug. The task is also triggered after any task that alters the state, such as an apply or moving a resource in the state. +## Infracost integration + +NOTE: Requires `infracost` to be installed on your machine, along with configured API key. + +Pug integrates with infracost to provide cost estimation. Select workspaces on the workspace page and press `$` to run calculate their costs: + +![Infracost output screenshot](./demo/infracost_output.png) + +Once the task has finished, the costs are visible on the workspaces page: + +![Worksapces with costs screenshot](./demo/workspaces_with_cost.png) + ## Tofu support To use tofu, set `--program=tofu`. Ensure it is installed first. diff --git a/demo/demo.gif b/demo/demo.gif index a80bc7be..87c6836a 100644 Binary files a/demo/demo.gif and b/demo/demo.gif differ diff --git a/demo/do_cost_money/modules/a/dev.tfvars b/demo/do_cost_money/modules/a/dev.tfvars new file mode 100644 index 00000000..0663a780 --- /dev/null +++ b/demo/do_cost_money/modules/a/dev.tfvars @@ -0,0 +1 @@ +instance_type = "n1-standard-96" diff --git a/demo/do_cost_money/modules/a/main.tf b/demo/do_cost_money/modules/a/main.tf new file mode 100644 index 00000000..20084e80 --- /dev/null +++ b/demo/do_cost_money/modules/a/main.tf @@ -0,0 +1,59 @@ +terraform { + backend "local" {} +} + +# Configure the AWS Provider +provider "google" { + region = "us-east-1" +} + +resource "google_compute_instance" "default" { + name = "my-instance" + machine_type = var.instance_type + zone = "us-central1-a" + + tags = ["foo", "bar"] + + boot_disk { + initialize_params { + image = "debian-cloud/debian-11" + labels = { + my_label = "value" + } + } + } + + // Local SSD disk + scratch_disk { + interface = "NVME" + } + + network_interface { + network = "default" + + access_config { + // Ephemeral public IP + } + } + + metadata = { + foo = "bar" + } + + metadata_startup_script = "echo hi > /test.txt" + + service_account { + # Google recommends custom service accounts that have cloud-platform scope and permissions granted via IAM Roles. + email = google_service_account.default.email + scopes = ["cloud-platform"] + } +} + +resource "google_service_account" "default" { + account_id = "my-custom-sa" + display_name = "Custom SA for VM Instance" +} + +variable "instance_type" { + default = "n2-standard-2" +} diff --git a/demo/do_cost_money/modules/b/main.tf b/demo/do_cost_money/modules/b/main.tf new file mode 100644 index 00000000..20084e80 --- /dev/null +++ b/demo/do_cost_money/modules/b/main.tf @@ -0,0 +1,59 @@ +terraform { + backend "local" {} +} + +# Configure the AWS Provider +provider "google" { + region = "us-east-1" +} + +resource "google_compute_instance" "default" { + name = "my-instance" + machine_type = var.instance_type + zone = "us-central1-a" + + tags = ["foo", "bar"] + + boot_disk { + initialize_params { + image = "debian-cloud/debian-11" + labels = { + my_label = "value" + } + } + } + + // Local SSD disk + scratch_disk { + interface = "NVME" + } + + network_interface { + network = "default" + + access_config { + // Ephemeral public IP + } + } + + metadata = { + foo = "bar" + } + + metadata_startup_script = "echo hi > /test.txt" + + service_account { + # Google recommends custom service accounts that have cloud-platform scope and permissions granted via IAM Roles. + email = google_service_account.default.email + scopes = ["cloud-platform"] + } +} + +resource "google_service_account" "default" { + account_id = "my-custom-sa" + display_name = "Custom SA for VM Instance" +} + +variable "instance_type" { + default = "n2-standard-2" +} diff --git a/demo/do_cost_money/modules/c/main.tf b/demo/do_cost_money/modules/c/main.tf new file mode 100644 index 00000000..20084e80 --- /dev/null +++ b/demo/do_cost_money/modules/c/main.tf @@ -0,0 +1,59 @@ +terraform { + backend "local" {} +} + +# Configure the AWS Provider +provider "google" { + region = "us-east-1" +} + +resource "google_compute_instance" "default" { + name = "my-instance" + machine_type = var.instance_type + zone = "us-central1-a" + + tags = ["foo", "bar"] + + boot_disk { + initialize_params { + image = "debian-cloud/debian-11" + labels = { + my_label = "value" + } + } + } + + // Local SSD disk + scratch_disk { + interface = "NVME" + } + + network_interface { + network = "default" + + access_config { + // Ephemeral public IP + } + } + + metadata = { + foo = "bar" + } + + metadata_startup_script = "echo hi > /test.txt" + + service_account { + # Google recommends custom service accounts that have cloud-platform scope and permissions granted via IAM Roles. + email = google_service_account.default.email + scopes = ["cloud-platform"] + } +} + +resource "google_service_account" "default" { + account_id = "my-custom-sa" + display_name = "Custom SA for VM Instance" +} + +variable "instance_type" { + default = "n2-standard-2" +} diff --git a/demo/modules/a/main.tf b/demo/dont_cost_money/modules/a/main.tf similarity index 100% rename from demo/modules/a/main.tf rename to demo/dont_cost_money/modules/a/main.tf diff --git a/demo/modules/b/main.tf b/demo/dont_cost_money/modules/b/main.tf similarity index 100% rename from demo/modules/b/main.tf rename to demo/dont_cost_money/modules/b/main.tf diff --git a/demo/modules/c/main.tf b/demo/dont_cost_money/modules/c/main.tf similarity index 100% rename from demo/modules/c/main.tf rename to demo/dont_cost_money/modules/c/main.tf diff --git a/demo/modules/d/main.tf b/demo/dont_cost_money/modules/d/main.tf similarity index 100% rename from demo/modules/d/main.tf rename to demo/dont_cost_money/modules/d/main.tf diff --git a/demo/modules/e/main.tf b/demo/dont_cost_money/modules/e/main.tf similarity index 100% rename from demo/modules/e/main.tf rename to demo/dont_cost_money/modules/e/main.tf diff --git a/demo/modules/f/main.tf b/demo/dont_cost_money/modules/f/main.tf similarity index 100% rename from demo/modules/f/main.tf rename to demo/dont_cost_money/modules/f/main.tf diff --git a/demo/modules/g/main.tf b/demo/dont_cost_money/modules/g/main.tf similarity index 100% rename from demo/modules/g/main.tf rename to demo/dont_cost_money/modules/g/main.tf diff --git a/demo/modules/h/main.tf b/demo/dont_cost_money/modules/h/main.tf similarity index 100% rename from demo/modules/h/main.tf rename to demo/dont_cost_money/modules/h/main.tf diff --git a/demo/modules/i/main.tf b/demo/dont_cost_money/modules/i/main.tf similarity index 100% rename from demo/modules/i/main.tf rename to demo/dont_cost_money/modules/i/main.tf diff --git a/demo/modules/j/main.tf b/demo/dont_cost_money/modules/j/main.tf similarity index 100% rename from demo/modules/j/main.tf rename to demo/dont_cost_money/modules/j/main.tf diff --git a/demo/modules/k/main.tf b/demo/dont_cost_money/modules/k/main.tf similarity index 100% rename from demo/modules/k/main.tf rename to demo/dont_cost_money/modules/k/main.tf diff --git a/demo/modules/l/main.tf b/demo/dont_cost_money/modules/l/main.tf similarity index 100% rename from demo/modules/l/main.tf rename to demo/dont_cost_money/modules/l/main.tf diff --git a/demo/modules/m/main.tf b/demo/dont_cost_money/modules/m/main.tf similarity index 100% rename from demo/modules/m/main.tf rename to demo/dont_cost_money/modules/m/main.tf diff --git a/demo/modules/n/main.tf b/demo/dont_cost_money/modules/n/main.tf similarity index 100% rename from demo/modules/n/main.tf rename to demo/dont_cost_money/modules/n/main.tf diff --git a/demo/modules/o/main.tf b/demo/dont_cost_money/modules/o/main.tf similarity index 100% rename from demo/modules/o/main.tf rename to demo/dont_cost_money/modules/o/main.tf diff --git a/demo/modules/p/main.tf b/demo/dont_cost_money/modules/p/main.tf similarity index 100% rename from demo/modules/p/main.tf rename to demo/dont_cost_money/modules/p/main.tf diff --git a/demo/modules/q/main.tf b/demo/dont_cost_money/modules/q/main.tf similarity index 100% rename from demo/modules/q/main.tf rename to demo/dont_cost_money/modules/q/main.tf diff --git a/demo/modules/r/main.tf b/demo/dont_cost_money/modules/r/main.tf similarity index 100% rename from demo/modules/r/main.tf rename to demo/dont_cost_money/modules/r/main.tf diff --git a/demo/modules/s/main.tf b/demo/dont_cost_money/modules/s/main.tf similarity index 100% rename from demo/modules/s/main.tf rename to demo/dont_cost_money/modules/s/main.tf diff --git a/demo/modules/t/main.tf b/demo/dont_cost_money/modules/t/main.tf similarity index 100% rename from demo/modules/t/main.tf rename to demo/dont_cost_money/modules/t/main.tf diff --git a/demo/modules/u/main.tf b/demo/dont_cost_money/modules/u/main.tf similarity index 100% rename from demo/modules/u/main.tf rename to demo/dont_cost_money/modules/u/main.tf diff --git a/demo/modules/v/main.tf b/demo/dont_cost_money/modules/v/main.tf similarity index 100% rename from demo/modules/v/main.tf rename to demo/dont_cost_money/modules/v/main.tf diff --git a/demo/modules/w/main.tf b/demo/dont_cost_money/modules/w/main.tf similarity index 100% rename from demo/modules/w/main.tf rename to demo/dont_cost_money/modules/w/main.tf diff --git a/demo/modules/x/main.tf b/demo/dont_cost_money/modules/x/main.tf similarity index 100% rename from demo/modules/x/main.tf rename to demo/dont_cost_money/modules/x/main.tf diff --git a/demo/modules/y/main.tf b/demo/dont_cost_money/modules/y/main.tf similarity index 100% rename from demo/modules/y/main.tf rename to demo/dont_cost_money/modules/y/main.tf diff --git a/demo/modules/z/main.tf b/demo/dont_cost_money/modules/z/main.tf similarity index 100% rename from demo/modules/z/main.tf rename to demo/dont_cost_money/modules/z/main.tf diff --git a/demo/filter.png b/demo/filter.png index b629c2e1..46916f41 100644 Binary files a/demo/filter.png and b/demo/filter.png differ diff --git a/demo/infracost_output.png b/demo/infracost_output.png new file mode 100644 index 00000000..6e113d85 Binary files /dev/null and b/demo/infracost_output.png differ diff --git a/demo/logs.png b/demo/logs.png index b0af8fab..efe1618a 100644 Binary files a/demo/logs.png and b/demo/logs.png differ diff --git a/demo/modules.png b/demo/modules.png index 7e39b3a8..7a1f6763 100644 Binary files a/demo/modules.png and b/demo/modules.png differ diff --git a/demo/modules/a/.terraform.lock.hcl b/demo/modules/a/.terraform.lock.hcl deleted file mode 100644 index 690881c1..00000000 --- a/demo/modules/a/.terraform.lock.hcl +++ /dev/null @@ -1,17 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - constraints = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/b/.terraform.lock.hcl b/demo/modules/b/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/b/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/c/.terraform.lock.hcl b/demo/modules/c/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/c/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/d/.terraform.lock.hcl b/demo/modules/d/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/d/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/e/.terraform.lock.hcl b/demo/modules/e/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/e/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/f/.terraform.lock.hcl b/demo/modules/f/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/f/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/g/.terraform.lock.hcl b/demo/modules/g/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/g/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/h/.terraform.lock.hcl b/demo/modules/h/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/h/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/i/.terraform.lock.hcl b/demo/modules/i/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/i/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/j/.terraform.lock.hcl b/demo/modules/j/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/j/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/k/.terraform.lock.hcl b/demo/modules/k/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/k/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/l/.terraform.lock.hcl b/demo/modules/l/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/l/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/m/.terraform.lock.hcl b/demo/modules/m/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/m/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/n/.terraform.lock.hcl b/demo/modules/n/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/n/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/o/.terraform.lock.hcl b/demo/modules/o/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/o/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/p/.terraform.lock.hcl b/demo/modules/p/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/p/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/q/.terraform.lock.hcl b/demo/modules/q/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/q/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/r/.terraform.lock.hcl b/demo/modules/r/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/r/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/s/.terraform.lock.hcl b/demo/modules/s/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/s/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/t/.terraform.lock.hcl b/demo/modules/t/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/t/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/u/.terraform.lock.hcl b/demo/modules/u/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/u/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/v/.terraform.lock.hcl b/demo/modules/v/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/v/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/w/.terraform.lock.hcl b/demo/modules/w/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/w/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/x/.terraform.lock.hcl b/demo/modules/x/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/x/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/y/.terraform.lock.hcl b/demo/modules/y/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/y/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/modules/z/.terraform.lock.hcl b/demo/modules/z/.terraform.lock.hcl deleted file mode 100644 index 38727646..00000000 --- a/demo/modules/z/.terraform.lock.hcl +++ /dev/null @@ -1,16 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/random" { - version = "3.6.0" - hashes = [ - "h1:R5Ucn26riKIEijcsiOMBR3uOAjuOMfI1x7XvH4P6B1w=", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.11.1" - hashes = [ - "h1:IkDriv5C9G+kQQ+mP+8QGIahwKgbQcw1/mzh9U6q+ZI=", - ] -} diff --git a/demo/money.gif b/demo/money.gif new file mode 100644 index 00000000..7a6e4bbf Binary files /dev/null and b/demo/money.gif differ diff --git a/demo/money.tape b/demo/money.tape new file mode 100644 index 00000000..0cf5ece4 --- /dev/null +++ b/demo/money.tape @@ -0,0 +1,38 @@ +Output demo/money.gif + +Set Shell "bash" +Set FontSize 14 +Set Width 1200 +Set Height 800 +Set Framerate 24 +Set Padding 5 + +Hide +Type `TF_CLI_CONFIG_FILE=$PWD/mirror/mirror.tfrc go run main.go -w demo/workspaces_that_cost_money` Enter +Sleep 1s +Show + +# show unintialized modules +Sleep 1s + +# init all modules +Ctrl+a Sleep 0.5s Type "i" +# we're taken to the init task group page, wait for a few seconds for tasks to finish (it takes longer because it is copying hefty aws provider about +Sleep 3s + +# go to workspaces +Type "w" Sleep 2s +# select all workspaces +Ctrl+a Sleep 0.5s +# calculate cost +Type "$" +# user is taken to infracost task page, watch output for a few seconds +Sleep 5s +# take screen shot of infracost output (sleep to ensure page doesn't switch too soon) +Screenshot demo/infracost_output.png Sleep 0.5s + +# go back to workspaces +Type "w" Sleep 0.5s +# take screen shot of workspaces (sleep to ensure page doesn't switch too soon) +Screenshot demo/workspaces_with_cost.png Sleep 0.5s +Sleep 2s diff --git a/demo/state.png b/demo/state.png index 69e8ae5c..c6db92b1 100644 Binary files a/demo/state.png and b/demo/state.png differ diff --git a/demo/task_group.png b/demo/task_group.png index 898144ed..1ec46b04 100644 Binary files a/demo/task_group.png and b/demo/task_group.png differ diff --git a/demo/task_groups.png b/demo/task_groups.png index be9ae6f2..b4fbcbaa 100644 Binary files a/demo/task_groups.png and b/demo/task_groups.png differ diff --git a/demo/tasks.png b/demo/tasks.png index 511e2fc3..8863b594 100644 Binary files a/demo/tasks.png and b/demo/tasks.png differ diff --git a/demo/vhs.tape b/demo/vhs.tape index 7a52fa32..6ea3797f 100644 --- a/demo/vhs.tape +++ b/demo/vhs.tape @@ -8,8 +8,7 @@ Set Framerate 24 Set Padding 5 Hide -Type "demo/reset.sh" Enter -Type `TF_CLI_CONFIG_FILE=$PWD/mirror/mirror.tfrc go run main.go -w demo` Enter +Type `TF_CLI_CONFIG_FILE=$PWD/mirror/mirror.tfrc go run main.go -w demo/dont_cost_money` Enter Sleep 1s Show @@ -160,3 +159,35 @@ Type "l" # take screenshot of logs Screenshot demo/logs.png Sleep 2s + +# quit app and restart, this time with workspaces that cost money, to demonstrate infracost integration +Hide +Ctrl+c Type "y" +Type `TF_CLI_CONFIG_FILE=$PWD/mirror/mirror.tfrc go run main.go -w demo/do_cost_money` Enter +Sleep 1s +Show + +# show unintialized modules +Sleep 1s + +# init all modules +Ctrl+a Sleep 0.5s Type "i" +# we're taken to the init task group page, wait for a few seconds for tasks to finish (it takes longer because it is copying hefty aws provider about +Sleep 5s + +# go to workspaces +Type "w" Sleep 2s +# select all workspaces +Ctrl+a Sleep 0.5s +# calculate cost +Type "$" +# user is taken to infracost task page, watch output for a few seconds +Sleep 5s +# take screen shot of infracost output (sleep to ensure page doesn't switch too soon) +Screenshot demo/infracost_output.png Sleep 0.5s + +# go back to workspaces +Type "w" Sleep 0.5s +# take screen shot of workspaces (sleep to ensure page doesn't switch too soon) +Screenshot demo/workspaces_with_cost.png Sleep 0.5s +Sleep 2s diff --git a/demo/workspaces.png b/demo/workspaces.png index 48f8c2be..a591ebb9 100644 Binary files a/demo/workspaces.png and b/demo/workspaces.png differ diff --git a/demo/workspaces_with_cost.png b/demo/workspaces_with_cost.png new file mode 100644 index 00000000..5d717eb8 Binary files /dev/null and b/demo/workspaces_with_cost.png differ diff --git a/hacks/reset_demo.sh b/hacks/reset_demo.sh index f9915a0f..774d1734 100755 --- a/hacks/reset_demo.sh +++ b/hacks/reset_demo.sh @@ -1,4 +1,5 @@ -find demo/modules -name .terraform -exec rm -rf {} \; > /dev/null 2>&1 || true -find demo/modules -name terraform.tfstate -exec rm {} \; > /dev/null 2>&1 || true -find demo/modules -name terraform.tfstate.* -exec rm {} \; > /dev/null 2>&1 || true -find demo/modules -name environment -exec rm {} \; > /dev/null 2>&1 || true +find demo/ -name .terraform -exec rm -rf {} \; > /dev/null 2>&1 || true +find demo/ -name terraform.tfstate -exec rm {} \; > /dev/null 2>&1 || true +find demo/ -name terraform.tfstate.* -exec rm {} \; > /dev/null 2>&1 || true +find demo/ -name .terraform.lock.hcl -exec rm {} \; > /dev/null 2>&1 || true +find demo/ -name environment -exec rm {} \; > /dev/null 2>&1 || true diff --git a/internal/app/app.go b/internal/app/app.go index d11a567c..c53760ff 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -63,6 +63,8 @@ func New(cfg Config) (*App, error) { Tasks: tasks, Modules: modules, Logger: logger, + DataDir: cfg.DataDir, + Workdir: cfg.Workdir, }) states := state.NewService(state.ServiceOptions{ Modules: modules, diff --git a/internal/integration/cost_test.go b/internal/integration/cost_test.go new file mode 100644 index 00000000..58b1aaf5 --- /dev/null +++ b/internal/integration/cost_test.go @@ -0,0 +1,109 @@ +package app + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "os/exec" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/leg100/pug/internal/app" + "github.com/stretchr/testify/require" +) + +func TestCost(t *testing.T) { + t.Parallel() + skipIfInfracostNotFound(t) + + tm := setupInfracostWorkspaces(t) + + // Calculate cost for all four workspaces + tm.Send(tea.KeyMsg{Type: tea.KeyCtrlA}) + tm.Type("$") + + // Wait for infracost task to produce overall total + waitFor(t, tm, func(s string) bool { + return matchPattern(t, `Task.*cost.*exited`, s) && + matchPattern(t, `OVERALL TOTAL.*\$2\,621\.90`, s) + }) + + // Go back to workspace listing + tm.Type("w") + + // Each workspace should now have a cost. + waitFor(t, tm, func(s string) bool { + return matchPattern(t, `modules/a.*default.*\$87.12`, s) && + matchPattern(t, `modules/a.*dev.*\$2360.55`, s) && + matchPattern(t, `modules/b.*default.*\$87.12`, s) && + matchPattern(t, `modules/c.*default.*\$87.12`, s) + }) +} + +func setupInfracostWorkspaces(t *testing.T) *testModel { + responses := map[string][]byte{ + "n2-standard-2": []byte(`[{"data":{"products":[{"prices":[{"priceHash":"3460e5656b29ac302574c1c49d98a379-66d0d770bee368b4f2a8f2f597eeb417","USD":"0.097118"}]}]}},{"data":{"products":[{"prices":[{"priceHash":"4e58b7b536714dfce35b3050caa6034b-af6a951f170fc579633ad2c8f86a9dca","USD":"0.04"}]}]}},{"data":{"products":[{"prices":[{"priceHash":"dae3672d3f7605d4e5c6d48aa342d66c-57bc5d148491a8381abaccb21ca6b4e9","USD":"0.08"}]}]}}]`), + "n1-standard-96": []byte(`[{"data":{"products":[{"prices":[{"priceHash":"84f8a08589f2331eac14c963f98e7f73-66d0d770bee368b4f2a8f2f597eeb417","USD":"4.559976"}]}]}},{"data":{"products":[{"prices":[{"priceHash":"4e58b7b536714dfce35b3050caa6034b-af6a951f170fc579633ad2c8f86a9dca","USD":"0.04"}]}]}},{"data":{"products":[{"prices":[{"priceHash":"dae3672d3f7605d4e5c6d48aa342d66c-57bc5d148491a8381abaccb21ca6b4e9","USD":"0.08"}]}]}}]`), + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + for instanceType, res := range responses { + if strings.Contains(string(body), instanceType) { + w.Write(res) + return + } + } + })) + t.Cleanup(srv.Close) + + tm := setup(t, "./testdata/workspaces_that_cost_money", withInfracostEnvs(srv.URL)) + + // Expect three modules to be listed + waitFor(t, tm, func(s string) bool { + return strings.Contains(s, "modules/a") && + strings.Contains(s, "modules/b") && + strings.Contains(s, "modules/c") + }) + + // Select all modules and init + tm.Send(tea.KeyMsg{Type: tea.KeyCtrlA}) + tm.Type("i") + waitFor(t, tm, func(s string) bool { + return matchPattern(t, "TaskGroup.*init", s) && + matchPattern(t, `modules/a.*exited`, s) && + matchPattern(t, `modules/b.*exited`, s) && + matchPattern(t, `modules/c.*exited`, s) + }) + + // Go to workspace listing + tm.Type("w") + + // Wait for all four workspaces to be listed. + waitFor(t, tm, func(s string) bool { + return matchPattern(t, `modules/a.*default`, s) && + matchPattern(t, `modules/a.*dev`, s) && + matchPattern(t, `modules/b.*default`, s) && + matchPattern(t, `modules/c.*default`, s) + }) + + return tm +} + +func withInfracostEnvs(pricingEndpoint string) configOption { + return func(cfg *app.Config) { + cfg.Envs = []string{ + fmt.Sprintf("PRICING_API_ENDPOINT=%s", pricingEndpoint), + "INFRACOST_API_KEY=ico-abc", + } + } +} + +func skipIfInfracostNotFound(t *testing.T) { + if _, err := exec.LookPath("infracost"); err != nil { + t.Skip("skipping test: infracost not found") + } +} diff --git a/internal/integration/testdata/ec2_instance/main.tf b/internal/integration/testdata/ec2_instance/main.tf new file mode 100644 index 00000000..a93fe490 --- /dev/null +++ b/internal/integration/testdata/ec2_instance/main.tf @@ -0,0 +1,13 @@ +# Configure the AWS Provider +provider "aws" { + region = "us-east-1" +} + +resource "aws_instance" "web" { + ami = data.aws_ami.ubuntu.id + instance_type = "t3.micro" + + tags = { + Name = "HelloWorld" + } +} diff --git a/internal/integration/testdata/workspaces_that_cost_money/modules/a/dev.tfvars b/internal/integration/testdata/workspaces_that_cost_money/modules/a/dev.tfvars new file mode 100644 index 00000000..0663a780 --- /dev/null +++ b/internal/integration/testdata/workspaces_that_cost_money/modules/a/dev.tfvars @@ -0,0 +1 @@ +instance_type = "n1-standard-96" diff --git a/internal/integration/testdata/workspaces_that_cost_money/modules/a/main.tf b/internal/integration/testdata/workspaces_that_cost_money/modules/a/main.tf new file mode 100644 index 00000000..20084e80 --- /dev/null +++ b/internal/integration/testdata/workspaces_that_cost_money/modules/a/main.tf @@ -0,0 +1,59 @@ +terraform { + backend "local" {} +} + +# Configure the AWS Provider +provider "google" { + region = "us-east-1" +} + +resource "google_compute_instance" "default" { + name = "my-instance" + machine_type = var.instance_type + zone = "us-central1-a" + + tags = ["foo", "bar"] + + boot_disk { + initialize_params { + image = "debian-cloud/debian-11" + labels = { + my_label = "value" + } + } + } + + // Local SSD disk + scratch_disk { + interface = "NVME" + } + + network_interface { + network = "default" + + access_config { + // Ephemeral public IP + } + } + + metadata = { + foo = "bar" + } + + metadata_startup_script = "echo hi > /test.txt" + + service_account { + # Google recommends custom service accounts that have cloud-platform scope and permissions granted via IAM Roles. + email = google_service_account.default.email + scopes = ["cloud-platform"] + } +} + +resource "google_service_account" "default" { + account_id = "my-custom-sa" + display_name = "Custom SA for VM Instance" +} + +variable "instance_type" { + default = "n2-standard-2" +} diff --git a/internal/integration/testdata/workspaces_that_cost_money/modules/a/terraform.tfstate.d/dev/.gitkeep b/internal/integration/testdata/workspaces_that_cost_money/modules/a/terraform.tfstate.d/dev/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/internal/integration/testdata/workspaces_that_cost_money/modules/b/main.tf b/internal/integration/testdata/workspaces_that_cost_money/modules/b/main.tf new file mode 100644 index 00000000..20084e80 --- /dev/null +++ b/internal/integration/testdata/workspaces_that_cost_money/modules/b/main.tf @@ -0,0 +1,59 @@ +terraform { + backend "local" {} +} + +# Configure the AWS Provider +provider "google" { + region = "us-east-1" +} + +resource "google_compute_instance" "default" { + name = "my-instance" + machine_type = var.instance_type + zone = "us-central1-a" + + tags = ["foo", "bar"] + + boot_disk { + initialize_params { + image = "debian-cloud/debian-11" + labels = { + my_label = "value" + } + } + } + + // Local SSD disk + scratch_disk { + interface = "NVME" + } + + network_interface { + network = "default" + + access_config { + // Ephemeral public IP + } + } + + metadata = { + foo = "bar" + } + + metadata_startup_script = "echo hi > /test.txt" + + service_account { + # Google recommends custom service accounts that have cloud-platform scope and permissions granted via IAM Roles. + email = google_service_account.default.email + scopes = ["cloud-platform"] + } +} + +resource "google_service_account" "default" { + account_id = "my-custom-sa" + display_name = "Custom SA for VM Instance" +} + +variable "instance_type" { + default = "n2-standard-2" +} diff --git a/internal/integration/testdata/workspaces_that_cost_money/modules/c/main.tf b/internal/integration/testdata/workspaces_that_cost_money/modules/c/main.tf new file mode 100644 index 00000000..20084e80 --- /dev/null +++ b/internal/integration/testdata/workspaces_that_cost_money/modules/c/main.tf @@ -0,0 +1,59 @@ +terraform { + backend "local" {} +} + +# Configure the AWS Provider +provider "google" { + region = "us-east-1" +} + +resource "google_compute_instance" "default" { + name = "my-instance" + machine_type = var.instance_type + zone = "us-central1-a" + + tags = ["foo", "bar"] + + boot_disk { + initialize_params { + image = "debian-cloud/debian-11" + labels = { + my_label = "value" + } + } + } + + // Local SSD disk + scratch_disk { + interface = "NVME" + } + + network_interface { + network = "default" + + access_config { + // Ephemeral public IP + } + } + + metadata = { + foo = "bar" + } + + metadata_startup_script = "echo hi > /test.txt" + + service_account { + # Google recommends custom service accounts that have cloud-platform scope and permissions granted via IAM Roles. + email = google_service_account.default.email + scopes = ["cloud-platform"] + } +} + +resource "google_service_account" "default" { + account_id = "my-custom-sa" + display_name = "Custom SA for VM Instance" +} + +variable "instance_type" { + default = "n2-standard-2" +} diff --git a/internal/task/spec.go b/internal/task/spec.go index 9d56a5da..6389f09f 100644 --- a/internal/task/spec.go +++ b/internal/task/spec.go @@ -6,10 +6,15 @@ import "github.com/leg100/pug/internal/resource" type Spec struct { // Resource that the task belongs to. Parent resource.Resource + // Program to execute. Defaults to the `program` pug config option. + Program string // Program command and any sub commands, e.g. plan, state rm, etc. Command []string // Args to pass to program. Args []string + // AdditionalExecution specifies the execution of another program. The + // program is only executed if the first program exits successfully. + AdditionalExecution *AdditionalExecution // Path relative to the pug working directory in which to run the command. Path string // Environment variables. @@ -70,3 +75,12 @@ type Spec struct { // SpecFunc is a function that creates a spec. type SpecFunc func(resource.ID) (Spec, error) + +type AdditionalExecution struct { + // Program to execute. Defaults to the `program` pug config option. + Program string + // Program command and any sub commands, e.g. plan, state rm, etc. + Command []string + // Args to pass to program. + Args []string +} diff --git a/internal/task/task.go b/internal/task/task.go index ae4b3e93..d9002384 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -21,15 +21,16 @@ import ( type Task struct { resource.Common - Command []string - Args []string - Path string - Blocking bool - State Status - JSON bool - Immediate bool - AdditionalEnv []string - DependsOn []resource.ID + Command []string + Args []string + AdditionalExecution *AdditionalExecution + Path string + Blocking bool + State Status + JSON bool + Immediate bool + AdditionalEnv []string + DependsOn []resource.ID // Summary summarises the outcome of a task to the end-user. Summary Summary description string @@ -99,41 +100,36 @@ type Summary interface { // TODO: check presence of mandatory options func (f *factory) newTask(spec Spec) *Task { - // In terragrunt mode add default terragrunt flags - args := append(f.userArgs, spec.Args...) - if f.terragrunt { - args = append(args, "--terragrunt-non-interactive") - } - - return &Task{ - Common: resource.New(resource.Task, spec.Parent), - State: Pending, - Created: time.Now(), - Updated: time.Now(), - finished: make(chan struct{}), - stdout: newBuffer(), - combined: newBuffer(), - program: f.program, - terragrunt: f.terragrunt, - Command: spec.Command, - Path: filepath.Join(f.workdir.String(), spec.Path), - Args: args, - AdditionalEnv: append(f.userEnvs, spec.Env...), - JSON: spec.JSON, - Blocking: spec.Blocking, - DependsOn: spec.DependsOn, - Immediate: spec.Immediate, - exclusive: spec.Exclusive, - description: spec.Description, - Spec: spec, - AfterCreate: spec.AfterCreate, - AfterRunning: spec.AfterRunning, - AfterQueued: spec.AfterQueued, - BeforeExited: spec.BeforeExited, - AfterExited: spec.AfterExited, - AfterError: spec.AfterError, - AfterCanceled: spec.AfterCanceled, - AfterFinish: spec.AfterFinish, + task := &Task{ + Common: resource.New(resource.Task, spec.Parent), + State: Pending, + Created: time.Now(), + Updated: time.Now(), + finished: make(chan struct{}), + stdout: newBuffer(), + combined: newBuffer(), + program: f.program, + terragrunt: f.terragrunt, + Command: spec.Command, + Path: filepath.Join(f.workdir.String(), spec.Path), + Args: append(f.userArgs, spec.Args...), + AdditionalExecution: spec.AdditionalExecution, + AdditionalEnv: append(f.userEnvs, spec.Env...), + JSON: spec.JSON, + Blocking: spec.Blocking, + DependsOn: spec.DependsOn, + Immediate: spec.Immediate, + exclusive: spec.Exclusive, + description: spec.Description, + Spec: spec, + AfterCreate: spec.AfterCreate, + AfterRunning: spec.AfterRunning, + AfterQueued: spec.AfterQueued, + BeforeExited: spec.BeforeExited, + AfterExited: spec.AfterExited, + AfterError: spec.AfterError, + AfterCanceled: spec.AfterCanceled, + AfterFinish: spec.AfterFinish, // Publish an event whenever task state is updated afterUpdate: func(t *Task) { // TODO: remove nil-check that is only here to ensure tests don't @@ -152,6 +148,18 @@ func (f *factory) newTask(spec Spec) *Task { }, }, } + // Override program if specified + if spec.Program != "" { + task.program = spec.Program + } + // In terragrunt mode add default terragrunt flags + // + // TODO: introduce a better way to determine whether terrarunt is in use. + // Perhaps use constants for terraform, tofu, and terragrunt. + if task.program == "terragrunt" && f.terragrunt { + task.Args = append(task.Args, "--terragrunt-non-interactive") + } + return task } func (t *Task) String() string { @@ -245,17 +253,7 @@ func (t *Task) cancel() error { } func (t *Task) start(ctx context.Context) (func(), error) { - // Use the provided context to kill the program if the context becomes done, - // but also to prevent the program from starting if the context becomes done. - cmd := exec.CommandContext(ctx, t.program, append(t.Command, t.Args...)...) - cmd.Cancel = func() error { - // Kill program gracefully - return cmd.Process.Signal(os.Interrupt) - } - cmd.Dir = t.Path - cmd.Stdout = io.MultiWriter(t.stdout, t.combined) - cmd.Stderr = t.combined - cmd.Env = append(t.AdditionalEnv, os.Environ()...) + cmd := t.execute(ctx, t.program, append(t.Command, t.Args...)) t.mu.Lock() defer t.mu.Unlock() @@ -275,9 +273,17 @@ func (t *Task) start(ctx context.Context) (func(), error) { wait := func() { state := Exited - if werr := cmd.Wait(); werr != nil { + if err := cmd.Wait(); err != nil { state = Errored - t.Err = fmt.Errorf("task failed: %w", werr) + t.Err = fmt.Errorf("task failed: %w", err) + } else if t.AdditionalExecution != nil { + // Execute additional program. + args := append(t.AdditionalExecution.Command, t.AdditionalExecution.Args...) + cmd = t.execute(ctx, t.AdditionalExecution.Program, args) + if err := cmd.Run(); err != nil { + state = Errored + t.Err = fmt.Errorf("task failed: %w", err) + } } t.mu.Lock() @@ -287,6 +293,21 @@ func (t *Task) start(ctx context.Context) (func(), error) { return wait, nil } +func (t *Task) execute(ctx context.Context, program string, args []string) *exec.Cmd { + // Use the provided context to kill the program if the context becomes done, + // but also to prevent the program from starting if the context becomes done. + cmd := exec.CommandContext(ctx, program, args...) + cmd.Cancel = func() error { + // Kill program gracefully + return cmd.Process.Signal(os.Interrupt) + } + cmd.Dir = t.Path + cmd.Stdout = io.MultiWriter(t.stdout, t.combined) + cmd.Stderr = t.combined + cmd.Env = append(t.AdditionalEnv, os.Environ()...) + return cmd +} + // record time at which current status finished func (t *Task) recordStatusEndTime(now time.Time) { currentStateTimestamps := t.timestamps[t.State] @@ -317,6 +338,7 @@ func (t *Task) updateState(state Status) { summary, err := t.BeforeExited(t) if err != nil { state = Errored + t.Err = err } t.Summary = summary } diff --git a/internal/tui/helpers.go b/internal/tui/helpers.go index d904653e..ef9c3118 100644 --- a/internal/tui/helpers.go +++ b/internal/tui/helpers.go @@ -49,7 +49,6 @@ func (h *Helpers) WorkspaceName(res resource.Resource) string { func (h *Helpers) ModuleCurrentWorkspace(mod *module.Module) *workspace.Workspace { if mod.CurrentWorkspaceID == nil { - h.Logger.Error("module does not have a current workspace", "module", mod) return nil } ws, err := h.Workspaces.Get(*mod.CurrentWorkspaceID) @@ -110,6 +109,22 @@ func (h *Helpers) WorkspaceCurrentCheckmark(ws *workspace.Workspace) string { return "" } +// ModuleCost renders the cost of the module's current workspace, if it has one. +func (h *Helpers) ModuleCost(mod *module.Module) string { + if ws := h.ModuleCurrentWorkspace(mod); ws != nil { + return h.WorkspaceCost(ws) + } + return "" +} + +// WorkspaceCost renders the cost of the given workspace. +func (h *Helpers) WorkspaceCost(ws *workspace.Workspace) string { + if ws.Cost == 0 { + return "-" + } + return fmt.Sprintf("$%.2f", ws.Cost) +} + func (h *Helpers) WorkspaceResourceCount(ws *workspace.Workspace) string { state, err := h.States.Get(ws.ID) if errors.Is(err, resource.ErrNotFound) { @@ -174,6 +189,8 @@ func (h *Helpers) TaskSummary(t *task.Task, table bool) string { return h.ResourceReport(summary, table) case workspace.ReloadSummary: return h.WorkspaceReloadReport(summary, table) + case workspace.CostSummary: + return h.CostSummary(summary, table) case state.ReloadSummary: return h.StateReloadReport(summary, table) } @@ -235,6 +252,20 @@ func (h *Helpers) StateReloadReport(report state.ReloadSummary, table bool) stri return s } +// CostSummary renders a summary of the costs for a workspace. Set table to true +// if the report is rendered within a table row. +func (h *Helpers) CostSummary(report workspace.CostSummary, table bool) string { + var background lipgloss.TerminalColor = lipgloss.NoColor{} + if !table { + background = RunReportBackgroundColor + } + s := Regular.Background(background).Foreground(Green).Render(report.String()) + if !table { + s = Padded.Background(background).Render(s) + } + return s +} + // GroupReport renders a colored summary of a task group's task statuses. Set table to true if // the report is rendered within a table row. func (h *Helpers) GroupReport(group *task.Group, table bool) string { @@ -270,11 +301,11 @@ func (h *Helpers) CreateTasks(fn task.SpecFunc, ids ...resource.ID) tea.Cmd { case 1: spec, err := fn(ids[0]) if err != nil { - return ReportError(fmt.Errorf("creating task: %w", err)) + return ErrorMsg(fmt.Errorf("creating task: %w", err)) } task, err := h.Tasks.Create(spec) if err != nil { - return ReportError(fmt.Errorf("creating task: %w", err)) + return ErrorMsg(fmt.Errorf("creating task: %w", err)) } return NewNavigationMsg(TaskKind, WithParent(task.ID)) default: @@ -300,7 +331,7 @@ func (h *Helpers) CreateTasksWithSpecs(specs ...task.Spec) tea.Cmd { case 1: task, err := h.Tasks.Create(specs[0]) if err != nil { - return ReportError(fmt.Errorf("creating task: %w", err)) + return ErrorMsg(fmt.Errorf("creating task: %w", err)) } return NewNavigationMsg(TaskKind, WithParent(task.ID)) default: diff --git a/internal/tui/keys/common.go b/internal/tui/keys/common.go index 5b37df3f..e13b669b 100644 --- a/internal/tui/keys/common.go +++ b/internal/tui/keys/common.go @@ -18,6 +18,7 @@ type common struct { Init key.Binding Validate key.Binding Format key.Binding + Cost key.Binding } // Keys shared by several models. @@ -82,4 +83,8 @@ var Common = common{ key.WithKeys("f"), key.WithHelp("f", "format"), ), + Cost: key.NewBinding( + key.WithKeys("$"), + key.WithHelp("$", "cost"), + ), } diff --git a/internal/tui/table/columns.go b/internal/tui/table/columns.go index 4557b3a3..7a5ee3ef 100644 --- a/internal/tui/table/columns.go +++ b/internal/tui/table/columns.go @@ -22,4 +22,10 @@ var ( Title: "RESOURCES", Width: len("RESOURCES"), } + CostColumn = Column{ + Key: "cost", + Title: "COST", + FlexFactor: 1, + RightAlign: true, + } ) diff --git a/internal/tui/table/table.go b/internal/tui/table/table.go index 842711ee..6d9f9b31 100644 --- a/internal/tui/table/table.go +++ b/internal/tui/table/table.go @@ -71,6 +71,9 @@ type Column struct { Width int FlexFactor int TruncationFunc func(s string, w int, tail string) string + // RightAlign aligns content to the right. If false, content is aligned to + // the left. + RightAlign bool } type ColumnKey string @@ -622,6 +625,9 @@ func (m Model[V]) headersView() string { var s = make([]string, 0, len(m.cols)) for _, col := range m.cols { style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true) + if col.RightAlign { + style = style.AlignHorizontal(lipgloss.Right) + } renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…")) s = append(s, tui.Regular.Padding(0, 1).Render(renderedCell)) } @@ -661,11 +667,14 @@ func (m *Model[V]) renderRow(rowIdx int) string { // Truncate content if it is wider than column truncated := col.TruncationFunc(content, col.Width, "…") // Ensure content is all on one line. - inlined := lipgloss.NewStyle(). + style := lipgloss.NewStyle(). Width(col.Width). MaxWidth(col.Width). - Inline(true). - Render(truncated) + Inline(true) + if col.RightAlign { + style = style.AlignHorizontal(lipgloss.Right) + } + inlined := style.Render(truncated) // Apply block-styling to content boxed := lipgloss.NewStyle(). Padding(0, 1). diff --git a/internal/tui/workspace/list.go b/internal/tui/workspace/list.go index 45471e04..a3dc8752 100644 --- a/internal/tui/workspace/list.go +++ b/internal/tui/workspace/list.go @@ -16,10 +16,9 @@ import ( ) var currentColumn = table.Column{ - Key: "current", - Title: "CURRENT", - Width: len("CURRENT"), - FlexFactor: 1, + Key: "current", + Title: "CURRENT", + Width: len("CURRENT"), } type ListMaker struct { @@ -34,6 +33,7 @@ func (m *ListMaker) Make(_ resource.ID, width, height int) (tea.Model, error) { table.ModuleColumn, table.WorkspaceColumn, currentColumn, + table.CostColumn, table.ResourceCountColumn, } @@ -42,6 +42,7 @@ func (m *ListMaker) Make(_ resource.ID, width, height int) (tea.Model, error) { table.ModuleColumn.Key: ws.ModulePath(), table.WorkspaceColumn.Key: ws.Name, table.ResourceCountColumn.Key: m.Helpers.WorkspaceResourceCount(ws), + table.CostColumn.Key: m.Helpers.WorkspaceCost(ws), currentColumn.Key: m.Helpers.WorkspaceCurrentCheckmark(ws), } } @@ -154,6 +155,13 @@ func (m list) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if row, ok := m.table.CurrentRow(); ok { return m, tui.NavigateTo(tui.ResourceListKind, tui.WithParent(row.ID)) } + case key.Matches(msg, keys.Common.Cost): + workspaceIDs := m.table.SelectedOrCurrentIDs() + spec, err := m.Workspaces.Cost(workspaceIDs...) + if err != nil { + return m, tui.ReportError(fmt.Errorf("creating task: %w", err)) + } + return m, m.helpers.CreateTasksWithSpecs(spec) } } @@ -182,6 +190,7 @@ func (m list) HelpBindings() []key.Binding { keys.Common.Apply, keys.Common.Destroy, keys.Common.Delete, + keys.Common.Cost, localKeys.SetCurrent, } } diff --git a/internal/workspace/cost.go b/internal/workspace/cost.go new file mode 100644 index 00000000..08365797 --- /dev/null +++ b/internal/workspace/cost.go @@ -0,0 +1,192 @@ +package workspace + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + + "github.com/google/uuid" + "github.com/leg100/pug/internal" + "github.com/leg100/pug/internal/resource" + "github.com/leg100/pug/internal/task" + "gopkg.in/yaml.v3" +) + +type costTaskSpecCreator struct { + *Service +} + +// Cost creates a task that retrieves a breakdown of the costs of the +// infrastructure deployed by the workspace. +func (s *costTaskSpecCreator) Cost(workspaceIDs ...resource.ID) (task.Spec, error) { + if len(workspaceIDs) == 0 { + return task.Spec{}, errors.New("no workspaces specified") + } + workspaces := make([]*Workspace, len(workspaceIDs)) + for i, id := range workspaceIDs { + ws, err := s.Get(id) + if err != nil { + return task.Spec{}, err + } + workspaces[i] = ws + } + var ( + configPath string + breakdownPath string + ) + { + // generate unique names for temporary files + id := uuid.New() + configPath = filepath.Join(s.datadir, fmt.Sprintf("cost-%s.yaml", id.String())) + breakdownPath = filepath.Join(s.datadir, fmt.Sprintf("breakdown-%s.json", id.String())) + } + { + // generate config for infracost + configBody, err := generateCostConfig(s.workdir, workspaces...) + if err != nil { + return task.Spec{}, err + } + if err := os.WriteFile(configPath, configBody, 0o644); err != nil { + return task.Spec{}, err + } + } + return task.Spec{ + Parent: resource.GlobalResource, + Program: "infracost", + Command: []string{"breakdown"}, + Args: []string{"--config-file", configPath, "--format", "json", "--out-file", breakdownPath}, + // Update task to chain commands + AdditionalExecution: &task.AdditionalExecution{ + Program: "infracost", + Command: []string{"output"}, + Args: []string{"--format", "table", "--path", breakdownPath}, + }, + Blocking: true, + Description: "cost", + BeforeExited: func(*task.Task) (task.Summary, error) { + // Parse JSON output and update workspaces. + breakdown, err := os.ReadFile(breakdownPath) + if err != nil { + return nil, err + } + result, err := parseBreakdown(breakdown) + if err != nil { + return nil, err + } + for _, result := range result.projects { + ws, err := s.GetByName(result.path, result.workspace) + if err != nil { + return nil, err + } + _, err = s.table.Update(ws.ID, func(existing *Workspace) error { + existing.Cost = result.cost + return nil + }) + if err != nil { + return nil, err + } + } + return CostSummary(result.total), nil + }, + AfterFinish: func(*task.Task) { + os.Remove(configPath) + os.Remove(breakdownPath) + }, + }, nil +} + +type CostSummary float64 + +func (c CostSummary) String() string { + return fmt.Sprintf("$%.2f", c) +} + +type infracostConfig struct { + Version string + Projects []infracostProjectConfig +} + +type infracostProjectConfig struct { + Path string + Name string `yaml:",omitempty"` + TerraformWorkspace string `yaml:"terraform_workspace,omitempty"` + TerraformVarFiles []string `yaml:"terraform_var_files,omitempty"` +} + +func generateCostConfig(workdir internal.Workdir, workspaces ...*Workspace) ([]byte, error) { + cfg := infracostConfig{Version: "0.1"} + cfg.Projects = make([]infracostProjectConfig, len(workspaces)) + + for i, ws := range workspaces { + cfg.Projects[i] = infracostProjectConfig{ + Path: ws.ModulePath(), + TerraformWorkspace: ws.Name, + } + if fname, ok := ws.VarsFile(workdir); ok { + cfg.Projects[i].TerraformVarFiles = []string{fname} + } + } + + return yaml.Marshal(cfg) +} + +type infracostBreakdown struct { + Version string + Projects []infracostBreakdownProject + TotalMonthlyCost string `json:"totalMonthlyCost"` +} + +type infracostBreakdownProject struct { + Metadata infracostBreakdownProjectMetadata + Breakdown infracostBreakdownProjectBreakdown +} + +type infracostBreakdownProjectMetadata struct { + TerraformModulePath string `json:"terraformModulePath"` + TerraformWorkspace string `json:"terraformWorkspace"` +} + +type infracostBreakdownProjectBreakdown struct { + TotalMonthlyCost string `json:"totalMonthlyCost"` +} + +type breakdownResult struct { + projects []breakdownResultProject + total float64 +} + +type breakdownResultProject struct { + path string + workspace string + cost float64 +} + +func parseBreakdown(jsonPayload []byte) (breakdownResult, error) { + var breakdown infracostBreakdown + if err := json.Unmarshal(jsonPayload, &breakdown); err != nil { + return breakdownResult{}, err + } + // Parse overall total cost + total, err := strconv.ParseFloat(breakdown.TotalMonthlyCost, 64) + if err != nil { + return breakdownResult{}, err + } + // Parse per-project costs + result := breakdownResult{total: total} + result.projects = make([]breakdownResultProject, len(breakdown.Projects)) + for i, proj := range breakdown.Projects { + result.projects[i] = breakdownResultProject{ + path: proj.Metadata.TerraformModulePath, + workspace: proj.Metadata.TerraformWorkspace, + } + cost, err := strconv.ParseFloat(proj.Breakdown.TotalMonthlyCost, 64) + if err != nil { + return breakdownResult{}, err + } + result.projects[i].cost = cost + } + return result, nil +} diff --git a/internal/workspace/cost_test.go b/internal/workspace/cost_test.go new file mode 100644 index 00000000..d64cef6f --- /dev/null +++ b/internal/workspace/cost_test.go @@ -0,0 +1,58 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" + + "github.com/leg100/pug/internal" + "github.com/leg100/pug/internal/module" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCost_generateInfracostConfig(t *testing.T) { + workdir := internal.NewTestWorkdir(t) + mod := module.New(module.Options{Path: "a/b/c"}) + ws1, err := New(mod, "default") + require.NoError(t, err) + ws2, err := New(mod, "dev") + require.NoError(t, err) + + // Create a workspace tfvars file for ws2 + path := workdir.Join(mod.Path, "dev.tfvars") + os.MkdirAll(filepath.Dir(path), 0o755) + _, err = os.Create(path) + require.NoError(t, err) + + want := `version: "0.1" +projects: + - path: a/b/c + terraform_workspace: default + - path: a/b/c + terraform_workspace: dev + terraform_var_files: + - dev.tfvars +` + + got, err := generateCostConfig(workdir, ws1, ws2) + require.NoError(t, err) + + assert.YAMLEq(t, want, string(got)) +} + +func TestCost_parseBreakdown(t *testing.T) { + breakdown, err := os.ReadFile("./testdata/costs.json") + require.NoError(t, err) + + got, err := parseBreakdown(breakdown) + require.NoError(t, err) + + assert.Equal(t, 264.248, got.total) + assert.Len(t, got.projects, 4) + assert.Contains(t, got.projects, breakdownResultProject{ + path: "modules/a", + workspace: "dev", + cost: 239.072, + }) +} diff --git a/internal/workspace/reloader.go b/internal/workspace/reloader.go index de465005..ebcbf1ac 100644 --- a/internal/workspace/reloader.go +++ b/internal/workspace/reloader.go @@ -103,7 +103,7 @@ func (r *reloader) resetWorkspaces(mod *module.Module, discovered []string, curr } } // Reset current workspace - currentWorkspace, err := r.GetByName(mod.ID, current) + currentWorkspace, err := r.GetByName(mod.Path, current) if err != nil { return nil, nil, fmt.Errorf("cannot find current workspace: %s: %w", current, err) } diff --git a/internal/workspace/service.go b/internal/workspace/service.go index 63e43e45..b550628c 100644 --- a/internal/workspace/service.go +++ b/internal/workspace/service.go @@ -3,6 +3,7 @@ package workspace import ( "fmt" + "github.com/leg100/pug/internal" "github.com/leg100/pug/internal/logging" "github.com/leg100/pug/internal/module" "github.com/leg100/pug/internal/pubsub" @@ -16,15 +17,20 @@ type Service struct { modules modules tasks *task.Service + datadir string + workdir internal.Workdir *pubsub.Broker[*Workspace] *reloader + *costTaskSpecCreator } type ServiceOptions struct { Tasks *task.Service Modules *module.Service Logger logging.Interface + DataDir string + Workdir internal.Workdir } type workspaceTable interface { @@ -55,8 +61,11 @@ func NewService(opts ServiceOptions) *Service { modules: opts.Modules, tasks: opts.Tasks, logger: opts.Logger, + datadir: opts.DataDir, + workdir: opts.Workdir, } s.reloader = &reloader{s} + s.costTaskSpecCreator = &costTaskSpecCreator{s} return s } @@ -127,9 +136,9 @@ func (s *Service) Get(workspaceID resource.ID) (*Workspace, error) { return s.table.Get(workspaceID) } -func (s *Service) GetByName(moduleID resource.ID, name string) (*Workspace, error) { +func (s *Service) GetByName(modulePath, name string) (*Workspace, error) { for _, ws := range s.table.List() { - if ws.ModuleID() == moduleID && ws.Name == name { + if ws.ModulePath() == modulePath && ws.Name == name { return ws, nil } } diff --git a/internal/workspace/testdata/costs.json b/internal/workspace/testdata/costs.json new file mode 100644 index 00000000..86c76377 --- /dev/null +++ b/internal/workspace/testdata/costs.json @@ -0,0 +1,720 @@ +{ + "version": "0.2", + "metadata": { + "infracostCommand": "breakdown", + "vcsBranch": "", + "vcsCommitSha": "", + "vcsCommitAuthorName": "", + "vcsCommitAuthorEmail": "", + "vcsCommitTimestamp": "0001-01-01T00:00:00Z", + "vcsCommitMessage": "", + "configFilePath": "config.yaml" + }, + "currency": "USD", + "projects": [ + { + "name": "/home/louis/co/ec2/modules/a", + "displayName": "modules-a", + "metadata": { + "path": "/home/louis/co/ec2/modules/a", + "type": "terraform_dir", + "terraformModulePath": "modules/a", + "providers": [ + { + "name": "aws", + "filename": "modules/a/main.tf", + "startLine": 2, + "endLine": 4 + } + ] + }, + "pastBreakdown": { + "resources": [ + { + "name": "aws_instance.web", + "resourceType": "aws_instance", + "tags": { + "Name": "HelloWorld" + }, + "metadata": { + "calls": [ + { + "blockName": "aws_instance.web", + "endLine": 13, + "filename": "modules/a/main.tf", + "startLine": 6 + } + ], + "checksum": "6a6bccf9f0c1060f170f6dbcfeab9022de51a8cac07e79c64c79f9a37eb90908", + "endLine": 13, + "filename": "modules/a/main.tf", + "startLine": 6 + }, + "hourlyCost": "0.0114958904109589", + "monthlyCost": "8.392", + "costComponents": [ + { + "name": "Instance usage (Linux/UNIX, on-demand, t3.micro)", + "unit": "hours", + "hourlyQuantity": "1", + "monthlyQuantity": "730", + "price": "0.0104", + "hourlyCost": "0.0104", + "monthlyCost": "7.592", + "priceNotFound": false + }, + { + "name": "CPU credits", + "unit": "vCPU-hours", + "hourlyQuantity": "0", + "monthlyQuantity": "0", + "price": "0.05", + "hourlyCost": "0", + "monthlyCost": "0", + "priceNotFound": false + } + ], + "subresources": [ + { + "name": "root_block_device", + "metadata": {}, + "hourlyCost": "0.0010958904109589", + "monthlyCost": "0.8", + "costComponents": [ + { + "name": "Storage (general purpose SSD, gp2)", + "unit": "GB", + "hourlyQuantity": "0.010958904109589", + "monthlyQuantity": "8", + "price": "0.1", + "hourlyCost": "0.0010958904109589", + "monthlyCost": "0.8", + "priceNotFound": false + } + ] + } + ] + } + ], + "totalHourlyCost": "0.0114958904109589", + "totalMonthlyCost": "8.392", + "totalMonthlyUsageCost": "0" + }, + "breakdown": { + "resources": [ + { + "name": "aws_instance.web", + "resourceType": "aws_instance", + "tags": { + "Name": "HelloWorld" + }, + "metadata": { + "calls": [ + { + "blockName": "aws_instance.web", + "endLine": 13, + "filename": "modules/a/main.tf", + "startLine": 6 + } + ], + "checksum": "6a6bccf9f0c1060f170f6dbcfeab9022de51a8cac07e79c64c79f9a37eb90908", + "endLine": 13, + "filename": "modules/a/main.tf", + "startLine": 6 + }, + "hourlyCost": "0.0114958904109589", + "monthlyCost": "8.392", + "costComponents": [ + { + "name": "Instance usage (Linux/UNIX, on-demand, t3.micro)", + "unit": "hours", + "hourlyQuantity": "1", + "monthlyQuantity": "730", + "price": "0.0104", + "hourlyCost": "0.0104", + "monthlyCost": "7.592", + "priceNotFound": false + }, + { + "name": "CPU credits", + "unit": "vCPU-hours", + "hourlyQuantity": "0", + "monthlyQuantity": "0", + "price": "0.05", + "hourlyCost": "0", + "monthlyCost": "0", + "priceNotFound": false + } + ], + "subresources": [ + { + "name": "root_block_device", + "metadata": {}, + "hourlyCost": "0.0010958904109589", + "monthlyCost": "0.8", + "costComponents": [ + { + "name": "Storage (general purpose SSD, gp2)", + "unit": "GB", + "hourlyQuantity": "0.010958904109589", + "monthlyQuantity": "8", + "price": "0.1", + "hourlyCost": "0.0010958904109589", + "monthlyCost": "0.8", + "priceNotFound": false + } + ] + } + ] + } + ], + "totalHourlyCost": "0.0114958904109589", + "totalMonthlyCost": "8.392", + "totalMonthlyUsageCost": "0" + }, + "diff": { + "resources": [], + "totalHourlyCost": "0", + "totalMonthlyCost": "0", + "totalMonthlyUsageCost": "0" + }, + "summary": { + "totalDetectedResources": 1, + "totalSupportedResources": 1, + "totalUnsupportedResources": 0, + "totalUsageBasedResources": 1, + "totalNoPriceResources": 0, + "unsupportedResourceCounts": {}, + "noPriceResourceCounts": {} + } + }, + { + "name": "/home/louis/co/ec2/modules/a", + "displayName": "modules-a-dev", + "metadata": { + "path": "/home/louis/co/ec2/modules/a", + "type": "terraform_dir", + "terraformModulePath": "modules/a", + "terraformWorkspace": "dev", + "providers": [ + { + "name": "aws", + "filename": "modules/a/main.tf", + "startLine": 2, + "endLine": 4 + } + ] + }, + "pastBreakdown": { + "resources": [ + { + "name": "aws_instance.web", + "resourceType": "aws_instance", + "tags": { + "Name": "HelloWorld" + }, + "metadata": { + "calls": [ + { + "blockName": "aws_instance.web", + "endLine": 13, + "filename": "modules/a/main.tf", + "startLine": 6 + } + ], + "checksum": "ad8e494d3a038136e0e0d2dc204a8e0bb7e4cc0c7b6fe1d91c0927a8ee7b7db0", + "endLine": 13, + "filename": "modules/a/main.tf", + "startLine": 6 + }, + "hourlyCost": "0.3274958904109589", + "monthlyCost": "239.072", + "costComponents": [ + { + "name": "Instance usage (Linux/UNIX, on-demand, m7g.2xlarge)", + "unit": "hours", + "hourlyQuantity": "1", + "monthlyQuantity": "730", + "price": "0.3264", + "hourlyCost": "0.3264", + "monthlyCost": "238.272", + "priceNotFound": false + } + ], + "subresources": [ + { + "name": "root_block_device", + "metadata": {}, + "hourlyCost": "0.0010958904109589", + "monthlyCost": "0.8", + "costComponents": [ + { + "name": "Storage (general purpose SSD, gp2)", + "unit": "GB", + "hourlyQuantity": "0.010958904109589", + "monthlyQuantity": "8", + "price": "0.1", + "hourlyCost": "0.0010958904109589", + "monthlyCost": "0.8", + "priceNotFound": false + } + ] + } + ] + } + ], + "totalHourlyCost": "0.3274958904109589", + "totalMonthlyCost": "239.072", + "totalMonthlyUsageCost": "0" + }, + "breakdown": { + "resources": [ + { + "name": "aws_instance.web", + "resourceType": "aws_instance", + "tags": { + "Name": "HelloWorld" + }, + "metadata": { + "calls": [ + { + "blockName": "aws_instance.web", + "endLine": 13, + "filename": "modules/a/main.tf", + "startLine": 6 + } + ], + "checksum": "ad8e494d3a038136e0e0d2dc204a8e0bb7e4cc0c7b6fe1d91c0927a8ee7b7db0", + "endLine": 13, + "filename": "modules/a/main.tf", + "startLine": 6 + }, + "hourlyCost": "0.3274958904109589", + "monthlyCost": "239.072", + "costComponents": [ + { + "name": "Instance usage (Linux/UNIX, on-demand, m7g.2xlarge)", + "unit": "hours", + "hourlyQuantity": "1", + "monthlyQuantity": "730", + "price": "0.3264", + "hourlyCost": "0.3264", + "monthlyCost": "238.272", + "priceNotFound": false + } + ], + "subresources": [ + { + "name": "root_block_device", + "metadata": {}, + "hourlyCost": "0.0010958904109589", + "monthlyCost": "0.8", + "costComponents": [ + { + "name": "Storage (general purpose SSD, gp2)", + "unit": "GB", + "hourlyQuantity": "0.010958904109589", + "monthlyQuantity": "8", + "price": "0.1", + "hourlyCost": "0.0010958904109589", + "monthlyCost": "0.8", + "priceNotFound": false + } + ] + } + ] + } + ], + "totalHourlyCost": "0.3274958904109589", + "totalMonthlyCost": "239.072", + "totalMonthlyUsageCost": "0" + }, + "diff": { + "resources": [], + "totalHourlyCost": "0", + "totalMonthlyCost": "0", + "totalMonthlyUsageCost": "0" + }, + "summary": { + "totalDetectedResources": 1, + "totalSupportedResources": 1, + "totalUnsupportedResources": 0, + "totalUsageBasedResources": 1, + "totalNoPriceResources": 0, + "unsupportedResourceCounts": {}, + "noPriceResourceCounts": {} + } + }, + { + "name": "/home/louis/co/ec2/modules/b", + "displayName": "modules-b", + "metadata": { + "path": "/home/louis/co/ec2/modules/b", + "type": "terraform_dir", + "terraformModulePath": "modules/b", + "providers": [ + { + "name": "aws", + "filename": "modules/b/main.tf", + "startLine": 2, + "endLine": 4 + } + ] + }, + "pastBreakdown": { + "resources": [ + { + "name": "aws_instance.web", + "resourceType": "aws_instance", + "tags": { + "Name": "HelloWorld" + }, + "metadata": { + "calls": [ + { + "blockName": "aws_instance.web", + "endLine": 13, + "filename": "modules/b/main.tf", + "startLine": 6 + } + ], + "checksum": "6a6bccf9f0c1060f170f6dbcfeab9022de51a8cac07e79c64c79f9a37eb90908", + "endLine": 13, + "filename": "modules/b/main.tf", + "startLine": 6 + }, + "hourlyCost": "0.0114958904109589", + "monthlyCost": "8.392", + "costComponents": [ + { + "name": "Instance usage (Linux/UNIX, on-demand, t3.micro)", + "unit": "hours", + "hourlyQuantity": "1", + "monthlyQuantity": "730", + "price": "0.0104", + "hourlyCost": "0.0104", + "monthlyCost": "7.592", + "priceNotFound": false + }, + { + "name": "CPU credits", + "unit": "vCPU-hours", + "hourlyQuantity": "0", + "monthlyQuantity": "0", + "price": "0.05", + "hourlyCost": "0", + "monthlyCost": "0", + "priceNotFound": false + } + ], + "subresources": [ + { + "name": "root_block_device", + "metadata": {}, + "hourlyCost": "0.0010958904109589", + "monthlyCost": "0.8", + "costComponents": [ + { + "name": "Storage (general purpose SSD, gp2)", + "unit": "GB", + "hourlyQuantity": "0.010958904109589", + "monthlyQuantity": "8", + "price": "0.1", + "hourlyCost": "0.0010958904109589", + "monthlyCost": "0.8", + "priceNotFound": false + } + ] + } + ] + } + ], + "totalHourlyCost": "0.0114958904109589", + "totalMonthlyCost": "8.392", + "totalMonthlyUsageCost": "0" + }, + "breakdown": { + "resources": [ + { + "name": "aws_instance.web", + "resourceType": "aws_instance", + "tags": { + "Name": "HelloWorld" + }, + "metadata": { + "calls": [ + { + "blockName": "aws_instance.web", + "endLine": 13, + "filename": "modules/b/main.tf", + "startLine": 6 + } + ], + "checksum": "6a6bccf9f0c1060f170f6dbcfeab9022de51a8cac07e79c64c79f9a37eb90908", + "endLine": 13, + "filename": "modules/b/main.tf", + "startLine": 6 + }, + "hourlyCost": "0.0114958904109589", + "monthlyCost": "8.392", + "costComponents": [ + { + "name": "Instance usage (Linux/UNIX, on-demand, t3.micro)", + "unit": "hours", + "hourlyQuantity": "1", + "monthlyQuantity": "730", + "price": "0.0104", + "hourlyCost": "0.0104", + "monthlyCost": "7.592", + "priceNotFound": false + }, + { + "name": "CPU credits", + "unit": "vCPU-hours", + "hourlyQuantity": "0", + "monthlyQuantity": "0", + "price": "0.05", + "hourlyCost": "0", + "monthlyCost": "0", + "priceNotFound": false + } + ], + "subresources": [ + { + "name": "root_block_device", + "metadata": {}, + "hourlyCost": "0.0010958904109589", + "monthlyCost": "0.8", + "costComponents": [ + { + "name": "Storage (general purpose SSD, gp2)", + "unit": "GB", + "hourlyQuantity": "0.010958904109589", + "monthlyQuantity": "8", + "price": "0.1", + "hourlyCost": "0.0010958904109589", + "monthlyCost": "0.8", + "priceNotFound": false + } + ] + } + ] + } + ], + "totalHourlyCost": "0.0114958904109589", + "totalMonthlyCost": "8.392", + "totalMonthlyUsageCost": "0" + }, + "diff": { + "resources": [], + "totalHourlyCost": "0", + "totalMonthlyCost": "0", + "totalMonthlyUsageCost": "0" + }, + "summary": { + "totalDetectedResources": 1, + "totalSupportedResources": 1, + "totalUnsupportedResources": 0, + "totalUsageBasedResources": 1, + "totalNoPriceResources": 0, + "unsupportedResourceCounts": {}, + "noPriceResourceCounts": {} + } + }, + { + "name": "/home/louis/co/ec2/modules/c", + "displayName": "modules-c", + "metadata": { + "path": "/home/louis/co/ec2/modules/c", + "type": "terraform_dir", + "terraformModulePath": "modules/c", + "providers": [ + { + "name": "aws", + "filename": "modules/c/main.tf", + "startLine": 2, + "endLine": 4 + } + ] + }, + "pastBreakdown": { + "resources": [ + { + "name": "aws_instance.web", + "resourceType": "aws_instance", + "tags": { + "Name": "HelloWorld" + }, + "metadata": { + "calls": [ + { + "blockName": "aws_instance.web", + "endLine": 13, + "filename": "modules/c/main.tf", + "startLine": 6 + } + ], + "checksum": "6a6bccf9f0c1060f170f6dbcfeab9022de51a8cac07e79c64c79f9a37eb90908", + "endLine": 13, + "filename": "modules/c/main.tf", + "startLine": 6 + }, + "hourlyCost": "0.0114958904109589", + "monthlyCost": "8.392", + "costComponents": [ + { + "name": "Instance usage (Linux/UNIX, on-demand, t3.micro)", + "unit": "hours", + "hourlyQuantity": "1", + "monthlyQuantity": "730", + "price": "0.0104", + "hourlyCost": "0.0104", + "monthlyCost": "7.592", + "priceNotFound": false + }, + { + "name": "CPU credits", + "unit": "vCPU-hours", + "hourlyQuantity": "0", + "monthlyQuantity": "0", + "price": "0.05", + "hourlyCost": "0", + "monthlyCost": "0", + "priceNotFound": false + } + ], + "subresources": [ + { + "name": "root_block_device", + "metadata": {}, + "hourlyCost": "0.0010958904109589", + "monthlyCost": "0.8", + "costComponents": [ + { + "name": "Storage (general purpose SSD, gp2)", + "unit": "GB", + "hourlyQuantity": "0.010958904109589", + "monthlyQuantity": "8", + "price": "0.1", + "hourlyCost": "0.0010958904109589", + "monthlyCost": "0.8", + "priceNotFound": false + } + ] + } + ] + } + ], + "totalHourlyCost": "0.0114958904109589", + "totalMonthlyCost": "8.392", + "totalMonthlyUsageCost": "0" + }, + "breakdown": { + "resources": [ + { + "name": "aws_instance.web", + "resourceType": "aws_instance", + "tags": { + "Name": "HelloWorld" + }, + "metadata": { + "calls": [ + { + "blockName": "aws_instance.web", + "endLine": 13, + "filename": "modules/c/main.tf", + "startLine": 6 + } + ], + "checksum": "6a6bccf9f0c1060f170f6dbcfeab9022de51a8cac07e79c64c79f9a37eb90908", + "endLine": 13, + "filename": "modules/c/main.tf", + "startLine": 6 + }, + "hourlyCost": "0.0114958904109589", + "monthlyCost": "8.392", + "costComponents": [ + { + "name": "Instance usage (Linux/UNIX, on-demand, t3.micro)", + "unit": "hours", + "hourlyQuantity": "1", + "monthlyQuantity": "730", + "price": "0.0104", + "hourlyCost": "0.0104", + "monthlyCost": "7.592", + "priceNotFound": false + }, + { + "name": "CPU credits", + "unit": "vCPU-hours", + "hourlyQuantity": "0", + "monthlyQuantity": "0", + "price": "0.05", + "hourlyCost": "0", + "monthlyCost": "0", + "priceNotFound": false + } + ], + "subresources": [ + { + "name": "root_block_device", + "metadata": {}, + "hourlyCost": "0.0010958904109589", + "monthlyCost": "0.8", + "costComponents": [ + { + "name": "Storage (general purpose SSD, gp2)", + "unit": "GB", + "hourlyQuantity": "0.010958904109589", + "monthlyQuantity": "8", + "price": "0.1", + "hourlyCost": "0.0010958904109589", + "monthlyCost": "0.8", + "priceNotFound": false + } + ] + } + ] + } + ], + "totalHourlyCost": "0.0114958904109589", + "totalMonthlyCost": "8.392", + "totalMonthlyUsageCost": "0" + }, + "diff": { + "resources": [], + "totalHourlyCost": "0", + "totalMonthlyCost": "0", + "totalMonthlyUsageCost": "0" + }, + "summary": { + "totalDetectedResources": 1, + "totalSupportedResources": 1, + "totalUnsupportedResources": 0, + "totalUsageBasedResources": 1, + "totalNoPriceResources": 0, + "unsupportedResourceCounts": {}, + "noPriceResourceCounts": {} + } + } + ], + "totalHourlyCost": "0.3619835616438356", + "totalMonthlyCost": "264.248", + "totalMonthlyUsageCost": "0", + "pastTotalHourlyCost": "0.3619835616438356", + "pastTotalMonthlyCost": "264.248", + "pastTotalMonthlyUsageCost": "0", + "diffTotalHourlyCost": "0", + "diffTotalMonthlyCost": "0", + "diffTotalMonthlyUsageCost": "0", + "timeGenerated": "2024-08-18T12:17:02.247735309Z", + "summary": { + "totalDetectedResources": 4, + "totalSupportedResources": 4, + "totalUnsupportedResources": 0, + "totalUsageBasedResources": 4, + "totalNoPriceResources": 0, + "unsupportedResourceCounts": {}, + "noPriceResourceCounts": {} + } +} diff --git a/internal/workspace/testdata/infracost b/internal/workspace/testdata/infracost new file mode 100755 index 00000000..f66e2b27 --- /dev/null +++ b/internal/workspace/testdata/infracost @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +cat ./infracost.table diff --git a/internal/workspace/testdata/modules/expensive/main.tf b/internal/workspace/testdata/modules/expensive/main.tf new file mode 100644 index 00000000..877d2e0c --- /dev/null +++ b/internal/workspace/testdata/modules/expensive/main.tf @@ -0,0 +1,12 @@ +terraform { + backend "local" {} +} + +resource "aws_instance" "web" { + ami = "${data.aws_ami.ubuntu.id}" + instance_type = "t2.micro" + + tags = { + Name = "HelloWorld" + } +} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 7ba2972e..d6872e6e 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -16,6 +16,7 @@ type Workspace struct { resource.Common Name string + Cost float64 } func New(mod *module.Module, name string) (*Workspace, error) { diff --git a/mirror/providers.tf b/mirror/providers.tf index 8e179dd5..e4e92c4e 100644 --- a/mirror/providers.tf +++ b/mirror/providers.tf @@ -9,5 +9,8 @@ terraform { http = { version = "= 3.4.3" } + google = { + version = "= 5.42.0" + } } }