diff --git a/.github/examples/terraform-plan-no-llm.yml b/.github/examples/terraform-plan-no-llm.yml index 9561c43..733974d 100644 --- a/.github/examples/terraform-plan-no-llm.yml +++ b/.github/examples/terraform-plan-no-llm.yml @@ -36,7 +36,7 @@ jobs: - run: terraform show -json tf.plan > tf-plan.json - - uses: Cro22/CloudOracle@v2.0.0 + - uses: Cro22/CloudOracle@v2 with: plan-file: tf-plan.json no-llm: 'true' diff --git a/.github/examples/terraform-plan.yml b/.github/examples/terraform-plan.yml index 974cbb4..c2fe26c 100644 --- a/.github/examples/terraform-plan.yml +++ b/.github/examples/terraform-plan.yml @@ -50,7 +50,7 @@ jobs: # each changed resource, asks the LLM for a 1-3 sentence narrative, # and posts/upserts a comment on the PR using the workflow token. - name: CloudOracle cost analysis - uses: Cro22/CloudOracle@v2.0.0 + uses: Cro22/CloudOracle@v2 with: plan-file: tf-plan.json region: us-east-2 diff --git a/.github/workflows/cost-self-test.yml b/.github/workflows/cost-self-test.yml index 61ae99a..15edfc9 100644 --- a/.github/workflows/cost-self-test.yml +++ b/.github/workflows/cost-self-test.yml @@ -1,11 +1,16 @@ name: Action self-test (Cost Comment) -# In-repo smoke test for the CloudOracle Action. Runs the Action -# against the toy Terraform plan in e2e-test/, using `uses: ./` so -# Docker builds the image from the current checkout instead of -# pulling a published tag. This catches regressions in the Action -# manifest, Dockerfile.action, entrypoint.sh, or the pr-check -# command before they reach a tagged release. +# In-repo smoke test for the CloudOracle Action. Runs the Action against the +# committed GCP plan fixture in e2e-test/plan.json, using `uses: ./` so Docker +# builds the image from the current checkout instead of pulling a published +# tag. This catches regressions in the Action manifest, Dockerfile.action, +# entrypoint.sh, or the pr-check command before they reach a tagged release. +# +# The fixture is GCP-only on purpose: GCP resources are priced from the +# embedded static table, so the self-test needs no cloud credentials and no +# Terraform — deterministic and secret-free. It runs with --no-llm so it +# doesn't depend on an LLM key either. The Action auto-posts the cost comment +# to the PR via the default github.token. on: pull_request: @@ -33,35 +38,12 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: us-east-2 - - - uses: hashicorp/setup-terraform@v3 - with: - terraform_version: latest - - - name: Terraform init - working-directory: e2e-test - run: terraform init - - - name: Terraform plan - working-directory: e2e-test - run: terraform plan -out=tf.plan - - - name: Convert plan to JSON - working-directory: e2e-test - run: terraform show -json tf.plan > tf-plan.json - - # `uses: ./` builds Dockerfile.action from the checked-out tree, - # so any changes to the Action code on this branch are exercised - # end-to-end before publishing a tag. + # `uses: ./` builds Dockerfile.action from the checked-out tree, so any + # changes to the Action code on this branch are exercised end-to-end + # against the committed GCP plan before publishing a tag. - name: CloudOracle (in-repo build) uses: ./ with: - plan-file: e2e-test/tf-plan.json - region: us-east-2 - env: - GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + plan-file: e2e-test/plan.json + region: us-central1 + no-llm: 'true' diff --git a/README.md b/README.md index 8f843d9..33a6c17 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CloudOracle -![Tests](https://img.shields.io/badge/tests-469%20unit%20%2B%2021%20integration-brightgreen)![Go Version](https://img.shields.io/badge/go-1.25-blue) ![License](https://img.shields.io/badge/license-Apache%20License%202.0-green) +![Tests](https://img.shields.io/badge/tests-581%20unit%20%2B%2022%20integration-brightgreen)![Go Version](https://img.shields.io/badge/go-1.25-blue) ![License](https://img.shields.io/badge/license-Apache%20License%202.0-green) **One FinOps toolkit, three surfaces over the same cost data** — audit what you spend, predict what a PR will cost, and ask about both in plain language. @@ -23,7 +23,7 @@ flowchart LR A Go FinOps toolkit spanning three modes — two from the same `oracle` binary, plus a polyglot Python agent extension: - **v1 — Audit existing cloud spend.** Ingest live EC2/RDS/EBS/Lambda inventory from AWS, GCP, or Azure into Postgres, run deterministic rules over it, and produce an executive PDF + dashboard with an LLM-narrated summary. See **[docs/v1-guide.md](docs/v1-guide.md)**. -- **v2 — Predict cost impact of a Terraform PR before merge.** Read `terraform show -json plan.tfplan`, look every changing resource up against the AWS Pricing API, and post (or upsert) a Markdown comment on the PR with the net monthly delta, top movers, and a 1–3 sentence LLM narrative. Ships as a GitHub Action and as the `oracle pr-check` subcommand. See **[docs/v2-guide.md](docs/v2-guide.md)**. +- **v2 — Predict cost impact of a Terraform PR before merge.** Read `terraform show -json plan.tfplan`, price every changing resource (AWS via the live Pricing API; GCP via an embedded static price table), and post (or upsert) a Markdown comment on the PR with the net monthly delta, top movers, and a 1–3 sentence LLM narrative. Ships as a GitHub Action and as the `oracle pr-check` subcommand. See **[docs/v2-guide.md](docs/v2-guide.md)**. - **v3 — Insights Agent.** Polyglot Go + Python extension adding agentic FinOps analysis on top of v1/v2 cost data — a hand-rolled LangGraph supervisor over specialist agents, RAG over a FinOps corpus (pgvector), production guardrails, real billing via AWS Cost Explorer, and a CLI + HTTP surface. See **[v3 — Insights Agent](#v3--insights-agent)** below, **[docs/v3-guide.md](docs/v3-guide.md)**, and **[insights-agent/README.md](insights-agent/README.md)**. ## v3 — Insights Agent @@ -104,7 +104,7 @@ jobs: - uses: hashicorp/setup-terraform@v3 - run: terraform init && terraform plan -out=tf.plan - run: terraform show -json tf.plan > tf-plan.json - - uses: Cro22/CloudOracle@v2.0.0 + - uses: Cro22/CloudOracle@v2 with: plan-file: tf-plan.json env: @@ -131,8 +131,8 @@ The synthetic provider needs no credentials. To run against AWS / GCP / Azure, s | Language | Go 1.25 | | Database | PostgreSQL 16 (Alpine) | | DB Driver | pgx v5 (connection pool) | -| AWS SDK | aws-sdk-go-v2 (EC2, RDS, Lambda, STS) | -| GCP SDK | Google Cloud Go (Compute, SQL, Functions) | +| AWS SDK | aws-sdk-go-v2 (EC2, RDS, Lambda, STS, Pricing, Cost Explorer) | +| GCP SDK | Google Cloud Go (Compute, SQL, Functions, BigQuery) | | Azure SDK | Azure SDK for Go (Compute, SQL, App Service) | | Concurrency | `golang.org/x/sync/errgroup` | | Logging | `log/slog` (structured, text/JSON) | @@ -161,13 +161,14 @@ The synthetic provider needs no credentials. To run against AWS / GCP / Azure, s - [X] **Milestone 8.3** — pgvector + RAG over a curated FinOps corpus: packaged markdown knowledge base, Gemini embeddings (mirroring the LLM-provider ABC), `langchain-postgres` PGVector store (compose image → `pgvector/pgvector:pg16`), `insights-agent-ingest` CLI, and a `finops_knowledge_search` tool the agent uses for conceptual/policy questions with source citations. Optional via `DATABASE_URL`; retrieval path unit-tested offline with an in-memory store - [X] **Milestone 8.4** — Hand-rolled supervisor multi-agent graph replacing `create_react_agent`: a `StateGraph` where a tool-call-routing supervisor delegates to three specialist workers (cost analyst, savings advisor, concept expert — each its own hand-rolled ReAct loop) and a synthesizer composes the answer, with a hop cap. Driveable end-to-end by the scripted fake model; `create_react_agent` kept as the simple graph - [X] **Milestone 8.5** — Production guardrails: per-run cost/usage caps (`RunLimits`); layered semantic answer validation (deterministic figure-grounding against tool observations, then an optional LLM judge); deterministic no-LLM fallback on run failure or failed validation; and a FastAPI HTTP surface (`POST /ask`, `GET /health`, optional `X-API-Key`) sharing one `GeminiAgentRunner` with the CLI -- [X] **Milestone 8.7** — Real billing integration behind a `billing.Source` abstraction: the v1 cost endpoints now consume normalized cost records, with the snapshot approximation as the default source and an **AWS Cost Explorer** source (real unblended cost, `data_source: billing_aws_cost_explorer`) selectable via `CLOUDORACLE_BILLING_PROVIDER=aws_cost_explorer`. GCP (BigQuery export) and Azure (Cost Management) sources can plug into the same interface next +- [X] **Milestone 8.7** — Real billing integration behind a `billing.Source` abstraction: the v1 cost endpoints now consume normalized cost records, with the snapshot approximation as the default source and an **AWS Cost Explorer** source (real unblended cost, `data_source: billing_aws_cost_explorer`) selectable via `CLOUDORACLE_BILLING_PROVIDER=aws_cost_explorer`, plus a **GCP BigQuery billing-export** source (real net cost, `data_source: billing_gcp_bigquery`) selectable via `CLOUDORACLE_BILLING_PROVIDER=gcp_bigquery`. Azure (Cost Management) can plug into the same interface next ### v2 — Terraform PR cost analysis - [X] Terraform plan parser — `internal/iac` reads `terraform show -json` into a typed `Plan` model with action classification (create / update / replace / delete / no-op); unknown-until-apply attributes surface as JSON `null` and are treated as missing (a missing *required* attribute routes the resource to `Skipped`) - [X] AWS Pricing API client + cache — `internal/pricing.Client` wraps AWS SDK v2 `pricing:GetProducts`; `internal/pricing.Cache` adds a 7-day disk cache keyed by service+filters -- [X] Per-resource estimators — EC2, EBS, RDS, Aurora cluster instance, Lambda, NAT gateway with breakdown line items and assumption notes +- [X] Per-resource estimators (AWS) — EC2, EBS, RDS, Aurora cluster instance, Lambda, NAT gateway with breakdown line items and assumption notes +- [X] **GCP pricing** — `google_compute_instance` (+ boot disk, Spot upper-bound), `google_compute_disk`, and `google_sql_database_instance` (Cloud SQL Postgres/MySQL: custom/shared/legacy tiers, storage, REGIONAL HA), priced from an embedded static table (`internal/pricing/gcp_prices.json`, us-central1 base + region multipliers) rather than a live API — deterministic, credential-free, capped at `medium` confidence with a drift caveat. `google_*` types dispatch through `internal/iac/gcp` + the estimators in `internal/pricing/gcp_*.go` - [X] CostDiff aggregator — `internal/diff.Analyze` collapses per-resource estimates into a plan-wide picture with Created / Deleted / Updated / Replaced / Skipped slices, top movers, and aggregate confidence - [X] Markdown renderer — `internal/diff.RenderMarkdown` produces the canonical PR comment (header / top movers table / full breakdown / caveats / marker footer), templated and golden-tested - [X] LLM-narrated PR comment — `RenderMarkdownWithLLM` swaps the templated narrative for a 1–3 sentence LLM output with caveat grouping, sanity checks (length cap, preamble strip, paragraph-break warn), and silent fallback to the templated text on any failure diff --git a/cmd/oracle/main.go b/cmd/oracle/main.go index 1b9ff7d..c3fbb4c 100644 --- a/cmd/oracle/main.go +++ b/cmd/oracle/main.go @@ -699,17 +699,26 @@ func runServe(ctx context.Context, pool *db.Pool, cfg config.Config, args []stri defer stop() var serverOpts []api.ServerOption - if cfg.API.BillingProvider == config.BillingAWSCostExplorer { + // Don't fail startup over a billing-source problem: fall back to the + // snapshot approximation so the API still serves, and make the degradation + // loud. + switch cfg.API.BillingProvider { + case config.BillingAWSCostExplorer: src, err := billing.NewAWSCostExplorerSource(runCtx, cfg.Cloud.AWSRegion, cfg.Cloud.AWSProfile) if err != nil { - // Don't fail startup over a billing-source problem: fall back to the - // snapshot approximation so the API still serves, and make the - // degradation loud. slog.Warn("falling back to snapshot cost source: AWS Cost Explorer init failed", "error", err) } else { slog.Info("v1 cost endpoints using AWS Cost Explorer (real billed cost)") serverOpts = append(serverOpts, api.WithBillingSource(src)) } + case config.BillingGCPBigQuery: + src, err := billing.NewGCPBigQuerySource(runCtx, cfg.Cloud.GCPProject, cfg.Cloud.GCPBillingDataset, cfg.Cloud.GCPBillingTable) + if err != nil { + slog.Warn("falling back to snapshot cost source: GCP BigQuery init failed", "error", err) + } else { + slog.Info("v1 cost endpoints using GCP BigQuery billing export (real billed cost)") + serverOpts = append(serverOpts, api.WithBillingSource(src)) + } } server := api.NewServer(pool, cfg.API, serverOpts...) diff --git a/docs/cloud-providers.md b/docs/cloud-providers.md index a0e4fe4..95bcbc3 100644 --- a/docs/cloud-providers.md +++ b/docs/cloud-providers.md @@ -105,6 +105,35 @@ go run ./cmd/oracle serve --port 8080 Since this path hasn't been exercised end-to-end, expect to debug the SDK call mapping on first run. +### Real billing via the BigQuery export + +The steps above cover *inventory* (what you have). For *real billed cost* on the +v1 cost endpoints, point `oracle serve` at the GCP billing export in BigQuery — +the GCP counterpart to AWS Cost Explorer. + +1. In the console, enable **Cloud Billing → Billing export → BigQuery export** + (Standard usage cost). GCP starts writing a + `gcp_billing_export_v1_` table into the dataset you choose. + Export data is not backfilled, so cost only appears from the day you enable it. +2. Grant the service account `roles/bigquery.dataViewer` on the dataset and + `roles/bigquery.jobUser` on the project (needed to run the query). ADC is the + same as for inventory — `GOOGLE_APPLICATION_CREDENTIALS` or `gcloud auth + application-default login`. +3. Point CloudOracle at it: + +```bash +export CLOUDORACLE_BILLING_PROVIDER=gcp_bigquery +export GOOGLE_CLOUD_PROJECT=your-project-id # project that owns the dataset +export CLOUDORACLE_GCP_BILLING_DATASET=billing # dataset holding the export +export CLOUDORACLE_GCP_BILLING_TABLE=gcp_billing_export_v1_0123AB_CDEF45_6789GH +go run ./cmd/oracle serve --port 8080 +``` + +The cost endpoints then report `data_source: billing_gcp_bigquery` (net cost = +list cost plus credits, grouped by service). If the client fails to initialize, +the server logs a warning and falls back to the `snapshots` approximation rather +than refusing to start. + ## Azure (untested against a live account) > Implemented but not verified against a real Azure subscription. diff --git a/docs/configuration.md b/docs/configuration.md index fc5c030..4fa4dac 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,7 +12,9 @@ Reference for every environment variable CloudOracle reads. All vars are loaded | `SYNTHETIC_COUNT` | `100` | Default number of synthetic resources to generate | | `SYNTHETIC_ACCOUNT` | `synthetic-account` | Default account ID for synthetic data | | `CLOUD_SERVICE_TIMEOUT` | `30s` | Per-service timeout for each cloud API call (Go duration string) | -| `CLOUDORACLE_BILLING_PROVIDER` | `snapshots` | Cost source for the v1 endpoints: `snapshots` (the projected-cost approximation) or `aws_cost_explorer` (real AWS unblended cost via the Cost Explorer API; uses `AWS_REGION`/`AWS_PROFILE`). On init failure it logs and falls back to `snapshots`. | +| `CLOUDORACLE_BILLING_PROVIDER` | `snapshots` | Cost source for the v1 endpoints: `snapshots` (the projected-cost approximation), `aws_cost_explorer` (real AWS unblended cost via the Cost Explorer API; uses `AWS_REGION`/`AWS_PROFILE`), or `gcp_bigquery` (real GCP net cost from the billing export in BigQuery; uses `GOOGLE_CLOUD_PROJECT` + the two `CLOUDORACLE_GCP_BILLING_*` vars below). On init failure it logs and falls back to `snapshots`. | +| `CLOUDORACLE_GCP_BILLING_DATASET` | _(unset)_ | BigQuery dataset holding the GCP billing export (required when `CLOUDORACLE_BILLING_PROVIDER=gcp_bigquery`) | +| `CLOUDORACLE_GCP_BILLING_TABLE` | _(unset)_ | Billing-export table name, e.g. `gcp_billing_export_v1_0123AB_CDEF45_6789GH` (required when `CLOUDORACLE_BILLING_PROVIDER=gcp_bigquery`) | | `DB_HOST` | `localhost` | PostgreSQL host | | `DB_PORT` | `5432` | PostgreSQL port | | `DB_USER` | `oracle` | Database user | diff --git a/docs/v2-guide.md b/docs/v2-guide.md index 2149394..04b1dce 100644 --- a/docs/v2-guide.md +++ b/docs/v2-guide.md @@ -49,7 +49,7 @@ jobs: - uses: hashicorp/setup-terraform@v3 - run: terraform init && terraform plan -out=tf.plan - run: terraform show -json tf.plan > tf-plan.json - - uses: Cro22/CloudOracle@v2.0.0 + - uses: Cro22/CloudOracle@v2 with: plan-file: tf-plan.json env: @@ -63,7 +63,7 @@ Two reference workflows live under [`.github/examples/`](../.github/examples) | Input | Required | Default | Notes | |-------|----------|---------|-------| | `plan-file` | yes | — | Path to `terraform show -json` output. | -| `region` | no | `us-east-2` | AWS region the Pricing API queries against. | +| `region` | no | `us-east-2` | Region for pricing lookups — an AWS region (`us-east-2`) for AWS plans or a GCP region (`us-central1`) for GCP plans. | | `output-file` | no | `` | Also write the rendered Markdown to a file (useful for artefact upload). | | `marker` | no | `cloudoracle-pr-v1` | HTML-comment substring used for upsert. Bump if you change the comment template. | | `no-llm` | no | `false` | Force the deterministic templated narrative even with LLM keys configured. | @@ -97,7 +97,7 @@ Full flag listing: | Flag | Default | Notes | |------|---------|-------| | `--plan-file` | — | Required. Path to JSON plan. | -| `--region` | `us-east-2` | AWS region for pricing. | +| `--region` | `us-east-2` | Region for pricing — an AWS region for AWS plans, or a GCP region (e.g. `us-central1`) for GCP plans. | | `--output` | _(stdout)_ | File to also write the Markdown to; `-` or empty means stdout. | | `--no-llm` | `false` | Force templated narrative. | | `--post` | `false` | Post / upsert the comment via the GitHub API. Requires `--repo` and `--pr`. | @@ -124,7 +124,17 @@ The v2 prompt (in `internal/diff/narrative.go`) is purpose-built for PR review t ## Supported resources -EC2 instances (Linux on-demand compute + root EBS), EBS volumes (gp2/gp3/io1/io2/st1/sc1), RDS instances (single-AZ + Aurora cluster instances), Lambda functions (cold-start estimate), NAT gateways (hourly only). Unsupported types appear in the rendered comment under "Skipped" with a one-line reason — they don't fail the run. Adding a new resource type is one new file under `internal/pricing/` plus a switch case in `estimator.go`. +**AWS** (priced live against the AWS Pricing API): EC2 instances (Linux on-demand compute + root EBS), EBS volumes (gp2/gp3/io1/io2/st1/sc1), RDS instances (single-AZ + Aurora cluster instances), Lambda functions (cold-start estimate), NAT gateways (hourly only). + +**GCP** (priced from an embedded static table — see below): `google_compute_instance` (machine type + boot disk; Spot/preemptible priced at on-demand as a labeled upper bound), `google_compute_disk` (persistent disk), `google_sql_database_instance` (Cloud SQL PostgreSQL/MySQL — custom/shared/legacy tiers, storage, REGIONAL HA doubling; SQL Server is skipped because its licensing isn't modeled). + +Unsupported types appear in the rendered comment under "Skipped" with a one-line reason — they don't fail the run. Adding a new AWS resource type is one new file under `internal/pricing/` plus a switch case in `change.go`; GCP types add an extractor under `internal/iac/gcp/` and an estimator that reads `internal/pricing/gcp_prices.json`. + +### How GCP is priced + +AWS resources price against the live AWS Pricing API, which is cleanly queryable by product attributes. GCP has no comparable API — the Cloud Billing Catalog exposes SKUs whose machine-type mapping lives in free-text descriptions, brittle to match and requiring a live API call in CI. Since a pr-check estimate is explicitly approximate (with per-resource confidence levels), **GCP is priced from a curated static table** (`internal/pricing/gcp_prices.json`, embedded at build time): us-central1 base rates + per-region multipliers for Compute Engine, plus persistent-disk and Cloud SQL rates. This is deterministic and needs no credentials, but it **drifts from the current list price** over time — GCP estimates are capped at `medium` confidence and carry a "static price table" caveat. Refresh the table from [Compute Engine pricing](https://cloud.google.com/compute/all-pricing) and [Cloud SQL pricing](https://cloud.google.com/sql/pricing) when it goes stale. + +For GCP plans, pass the GCP region to `--region` (e.g. `--region=us-central1`); a per-resource zone in the plan (`us-central1-a` → `us-central1`) overrides it. A single pr-check run assumes one provider/region — mixed AWS+GCP plans price each type against its own path but share the one `--region` value. --- diff --git a/e2e-test/README.md b/e2e-test/README.md new file mode 100644 index 0000000..f8916b0 --- /dev/null +++ b/e2e-test/README.md @@ -0,0 +1,15 @@ +# e2e-test + +`plan.json` is a committed `terraform show -json` fixture used by the +**Action self-test** workflow (`.github/workflows/cost-self-test.yml`). + +It is deliberately a **GCP-only** plan: GCP resources are priced from the +embedded static table (`internal/pricing/gcp_prices.json`), so the self-test +needs **no cloud credentials and no Terraform** — it just builds the Action +from the checkout (`uses: ./`) and runs `oracle pr-check` against this plan, +exercising the compute-instance, persistent-disk, and Cloud SQL estimators +plus the "skipped unsupported type" path (the storage bucket) end-to-end. + +Regenerate it by running `terraform show -json` on a plan with the same +resources if the pricing surface changes; there is no live Terraform state +here on purpose. diff --git a/e2e-test/plan.json b/e2e-test/plan.json new file mode 100644 index 0000000..fe82cc9 --- /dev/null +++ b/e2e-test/plan.json @@ -0,0 +1,83 @@ +{ + "format_version": "1.2", + "terraform_version": "1.7.4", + "resource_changes": [ + { + "address": "google_compute_instance.web", + "mode": "managed", + "type": "google_compute_instance", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/google", + "change": { + "actions": ["create"], + "before": null, + "after": { + "machine_type": "n2-standard-4", + "zone": "us-central1-a", + "boot_disk": [ + {"initialize_params": [{"size": 100, "type": "pd-ssd"}]} + ] + } + } + }, + { + "address": "google_compute_instance.batch", + "mode": "managed", + "type": "google_compute_instance", + "name": "batch", + "provider_name": "registry.terraform.io/hashicorp/google", + "change": { + "actions": ["create"], + "before": null, + "after": { + "machine_type": "e2-standard-8", + "zone": "europe-west2-b", + "scheduling": [{"provisioning_model": "SPOT"}], + "boot_disk": [{"initialize_params": [{"size": 50}]}] + } + } + }, + { + "address": "google_compute_disk.data", + "mode": "managed", + "type": "google_compute_disk", + "name": "data", + "provider_name": "registry.terraform.io/hashicorp/google", + "change": { + "actions": ["create"], + "before": null, + "after": {"type": "pd-balanced", "size": 200} + } + }, + { + "address": "google_sql_database_instance.main", + "mode": "managed", + "type": "google_sql_database_instance", + "name": "main", + "provider_name": "registry.terraform.io/hashicorp/google", + "change": { + "actions": ["create"], + "before": null, + "after": { + "database_version": "POSTGRES_15", + "region": "us-central1", + "settings": [ + {"tier": "db-custom-2-8192", "disk_size": 50, "disk_type": "PD_SSD", "availability_type": "REGIONAL"} + ] + } + } + }, + { + "address": "google_storage_bucket.assets", + "mode": "managed", + "type": "google_storage_bucket", + "name": "assets", + "provider_name": "registry.terraform.io/hashicorp/google", + "change": { + "actions": ["create"], + "before": null, + "after": {"name": "assets", "location": "US"} + } + } + ] +} diff --git a/go.mod b/go.mod index 3b6ae0d..572ac33 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module CloudOracle go 1.25.0 require ( + cloud.google.com/go/bigquery v1.81.0 cloud.google.com/go/compute v1.60.0 cloud.google.com/go/functions v1.22.0 codeberg.org/go-pdf/fpdf v0.11.1 @@ -10,7 +11,9 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v4 v4.1.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.4.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2 v2.0.0-beta.7 + github.com/aws/aws-sdk-go-v2 v1.41.9 github.com/aws/aws-sdk-go-v2/config v1.32.17 + github.com/aws/aws-sdk-go-v2/service/costexplorer v1.63.10 github.com/aws/aws-sdk-go-v2/service/ec2 v1.297.0 github.com/aws/aws-sdk-go-v2/service/lambda v1.89.1 github.com/aws/aws-sdk-go-v2/service/pricing v1.41.2 @@ -19,8 +22,8 @@ require ( github.com/jackc/pgx/v5 v5.9.1 github.com/testcontainers/testcontainers-go v0.42.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 - golang.org/x/sync v0.20.0 - google.golang.org/api v0.276.0 + golang.org/x/sync v0.21.0 + google.golang.org/api v0.287.1 google.golang.org/protobuf v1.36.11 ) @@ -29,22 +32,21 @@ require ( cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect - cloud.google.com/go/iam v1.7.0 // indirect - cloud.google.com/go/longrunning v0.9.0 // indirect + cloud.google.com/go/iam v1.11.0 // indirect + cloud.google.com/go/longrunning v1.2.0 // indirect dario.cat/mergo v1.0.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect + github.com/apache/arrow/go/v15 v15.0.2 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect - github.com/aws/aws-sdk-go-v2/service/costexplorer v1.63.10 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect @@ -58,7 +60,7 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect @@ -67,15 +69,18 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect + github.com/goccy/go-json v0.10.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/google/flatbuffers v23.5.26+incompatible // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect - github.com/googleapis/gax-go/v2 v2.21.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect + github.com/googleapis/gax-go/v2 v2.23.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/cpuid/v2 v2.2.5 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect @@ -90,8 +95,9 @@ require ( github.com/moby/term v0.5.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pierrec/lz4/v4 v4.1.18 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect @@ -99,21 +105,27 @@ require ( github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/net v0.52.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.45.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/grpc v1.80.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect + google.golang.org/grpc v1.82.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 46d7e9d..056a402 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,29 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/bigquery v1.81.0 h1:w0ygxA/AD6FDuewuIHPk0IrQXVJtZWTp5eazQ3KBtCw= +cloud.google.com/go/bigquery v1.81.0/go.mod h1:cc0XscySNQNuHBxuZSg5yyxFsg/ZHAfViAG49gJbWew= cloud.google.com/go/compute v1.60.0 h1:CqGt23ysz990ZZe1vq/9aDPKKnmwM6kcC7Y1Q05H2kI= cloud.google.com/go/compute v1.60.0/go.mod h1:Xm6PbsLgBpAg4va77ljbBdpMjzuU+uPp5Ze2dnZq7lw= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/datacatalog v1.32.0 h1:fyYn8ODkGil5y3zTIqgIhOfzTu1ACaU2o+C750CO6Ac= +cloud.google.com/go/datacatalog v1.32.0/go.mod h1:DE272tynQUwheJeQAyVfV+nO8yrdkuDyOgH2LtOrkWM= cloud.google.com/go/functions v1.22.0 h1:rJ2bSt2KUEi0OBMsUKICI/lJYCsTOw3aMgzKxBmuyNo= cloud.google.com/go/functions v1.22.0/go.mod h1:t40GeqBAQNuqKlHCxmV/pxhyYJnImLcvRa3GBv4tAy0= -cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U= -cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY= -cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY= -cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E= +cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= +cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM= +cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0= +cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/storage v1.62.3 h1:SZq1t23NCI+e96dH77Dg3PEfsNNEjqO8zE5AnD8gVD0= +cloud.google.com/go/storage v1.62.3/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= codeberg.org/go-pdf/fpdf v0.11.1 h1:U8+coOTDVLxHIXZgGvkfQEi/q0hYHYvEHFuGNX2GzGs= codeberg.org/go-pdf/fpdf v0.11.1/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= @@ -44,10 +54,16 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= -github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/apache/arrow/go/v15 v15.0.2 h1:60IliRbiyTWCWjERBCkO1W4Qun9svcYoZrSLcyOsMLE= +github.com/apache/arrow/go/v15 v15.0.2/go.mod h1:DGXsR3ajT524njufqf95822i+KTh+yea1jass9YXgjA= github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A= @@ -58,12 +74,8 @@ github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60Qb github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= @@ -90,16 +102,14 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= -github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= -github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -113,8 +123,8 @@ github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHf github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= @@ -124,12 +134,14 @@ github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= -github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -137,21 +149,27 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg= +github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= -github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= -github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= +github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg= +github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= +github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= +github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -164,6 +182,8 @@ github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRt github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= +github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -200,12 +220,15 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= +github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -214,6 +237,8 @@ github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfx github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= @@ -231,55 +256,72 @@ github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9R github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE= +golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.276.0 h1:nVArUtfLEihtW+b0DdcqRGK1xoEm2+ltAihyztq7MKY= -google.golang.org/api v0.276.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw= +google.golang.org/api v0.287.1 h1:LiyJx32VU3cwQfLchn/513qKhc25hq0pEANYJoWNnnI= +google.golang.org/api v0.287.1/go.mod h1:lM2kYRzYUCBY91P9h6VF1PYmvhxii3O5hji37qRvIcY= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 h1:eM/YSd5bBFagF51o1E745Ta7RwzpW0h+z+QDNZOgmQ8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/insights-agent/src/insights_agent/knowledge/data-sources-and-caveats.md b/insights-agent/src/insights_agent/knowledge/data-sources-and-caveats.md index 9bc009b..1df5931 100644 --- a/insights-agent/src/insights_agent/knowledge/data-sources-and-caveats.md +++ b/insights-agent/src/insights_agent/knowledge/data-sources-and-caveats.md @@ -29,8 +29,20 @@ Used by cost-summary, cost-by-service, and cost-trends. compute cloud - compute"), not CloudOracle's short names (ec2), and the numbers can still lag the final invoice slightly as AWS finalizes charges. - **How to phrase it.** State the figures as real billed cost; the snapshot - caveat does **not** apply. Only AWS has a real billing source today; GCP and - Azure still report `snapshots_approximation`. + caveat does **not** apply. Azure still reports `snapshots_approximation`. + +## `billing_gcp_bigquery` — real GCP billed cost + +- **What it is.** Real **net** cost (list cost plus credits, Google's "total + cost") from the GCP billing export in BigQuery, grouped by service, for the + requested period. Returned when the deployment sets + `CLOUDORACLE_BILLING_PROVIDER=gcp_bigquery`. +- **What it is NOT.** Not an approximation — these are actual billed figures. + Service names follow GCP's billing taxonomy (e.g. "compute engine"), not + CloudOracle's short names, and the export only covers dates since it was + enabled (GCP does not backfill). +- **How to phrase it.** State the figures as real billed cost; the snapshot + caveat does **not** apply. ## `heuristic_rules` — the recommendations endpoint diff --git a/internal/billing/bigquery.go b/internal/billing/bigquery.go new file mode 100644 index 0000000..d9d4a69 --- /dev/null +++ b/internal/billing/bigquery.go @@ -0,0 +1,135 @@ +package billing + +import ( + "context" + "fmt" + "time" + + "cloud.google.com/go/bigquery" + "google.golang.org/api/iterator" +) + +const ( + // GCPBigQueryDataSource marks a Report as real billed cost from the GCP + // billing export, the BigQuery sibling of AWSCostExplorerDataSource. The + // agent / dashboard can drop the "approximation" caveat when they see this. + GCPBigQueryDataSource = "billing_gcp_bigquery" + gcpBigQueryNote = "Costs are net costs (list cost plus credits) from the " + + "GCP billing export in BigQuery for the requested period (grouped by service)." +) + +// billingRow is one already-parsed (service, cost) pair from the billing-export +// query. The interface returns these instead of a *bigquery.RowIterator so tests +// can inject canned rows without the BigQuery SDK — the same flattening trick the +// GCP inventory listers use in internal/cloud/gcp_clients.go. +type billingRow struct { + Service string + Cost float64 +} + +type bigQueryAPI interface { + query(ctx context.Context, sql string) ([]billingRow, error) +} + +// BigQuerySource implements Source against the standard GCP billing export +// (a dataset table named like gcp_billing_export_v1_XXXXXX). `table` is the +// fully-qualified, backtick-quoted `project.dataset.table` reference. +type BigQuerySource struct { + api bigQueryAPI + table string +} + +func NewBigQuerySource(api bigQueryAPI, table string) *BigQuerySource { + return &BigQuerySource{api: api, table: table} +} + +// NewGCPBigQuerySource builds a source backed by a real BigQuery client. +// Credentials come from Application Default Credentials (GOOGLE_APPLICATION_ +// CREDENTIALS or the metadata server), the same as the GCP inventory clients. +func NewGCPBigQuerySource( + ctx context.Context, projectID, dataset, table string, +) (*BigQuerySource, error) { + client, err := bigquery.NewClient(ctx, projectID) + if err != nil { + return nil, fmt.Errorf("creating BigQuery client: %w", err) + } + qualified := fmt.Sprintf("`%s.%s.%s`", projectID, dataset, table) + return NewBigQuerySource(&realBigQuery{client: client}, qualified), nil +} + +// Costs runs the grouped-by-service query for [start, end] and sums the net +// cost per service into one CostRecord each. usage_start_time is the export's +// partition column, so filtering on it keeps the scan (and cost) bounded. +func (s *BigQuerySource) Costs( + ctx context.Context, start, end time.Time, +) (Report, error) { + rows, err := s.api.query(ctx, s.sql(start, end)) + if err != nil { + return Report{}, &SourceError{Code: "billing_query_failed", Err: err} + } + + perService := map[string]float64{} + for _, r := range rows { + perService[normalizeService(r.Service)] += r.Cost + } + records := make([]CostRecord, 0, len(perService)) + for service, amount := range perService { + records = append(records, CostRecord{ + Provider: "gcp", + Service: service, + AmountUSD: amount, + }) + } + return Report{ + Records: records, + DataSource: GCPBigQueryDataSource, + Note: gcpBigQueryNote, + }, nil +} + +// sql builds the billing-export query. Net cost is list `cost` plus the credits +// array (Google's documented "total cost"). The bounds are formatted from +// caller-supplied time.Time values (never user input), so string interpolation +// is safe from injection here. end is the handler's inclusive 23:59:59.999 close. +func (s *BigQuerySource) sql(start, end time.Time) string { + return fmt.Sprintf( + "SELECT service.description AS service, "+ + "SUM(cost + IFNULL((SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS cost "+ + "FROM %s "+ + "WHERE usage_start_time >= TIMESTAMP('%s') "+ + "AND usage_start_time <= TIMESTAMP('%s') "+ + "GROUP BY service", + s.table, + start.UTC().Format(time.DateTime), + end.UTC().Format(time.DateTime), + ) +} + +// realBigQuery wraps *bigquery.Client, running the query and flattening its +// RowIterator into []billingRow. Null service/cost cells (rounding rows, credits +// with no service) survive as zero-valued fields. +type realBigQuery struct { + client *bigquery.Client +} + +func (r *realBigQuery) query(ctx context.Context, sql string) ([]billingRow, error) { + it, err := r.client.Query(sql).Read(ctx) + if err != nil { + return nil, err + } + var out []billingRow + for { + var row struct { + Service bigquery.NullString + Cost bigquery.NullFloat64 + } + switch err := it.Next(&row); err { + case iterator.Done: + return out, nil + case nil: + out = append(out, billingRow{Service: row.Service.StringVal, Cost: row.Cost.Float64}) + default: + return nil, err + } + } +} diff --git a/internal/billing/bigquery_test.go b/internal/billing/bigquery_test.go new file mode 100644 index 0000000..b20636e --- /dev/null +++ b/internal/billing/bigquery_test.go @@ -0,0 +1,88 @@ +package billing + +import ( + "context" + "errors" + "strings" + "testing" +) + +type fakeBQ struct { + rows []billingRow + err error + sqls []string +} + +func (f *fakeBQ) query(_ context.Context, sql string) ([]billingRow, error) { + f.sqls = append(f.sqls, sql) + if f.err != nil { + return nil, f.err + } + return f.rows, nil +} + +func TestBigQuery_SumsAndTagsProvider(t *testing.T) { + fake := &fakeBQ{rows: []billingRow{ + {Service: "Compute Engine", Cost: 100.50}, + {Service: "Cloud SQL", Cost: 40}, + {Service: "Compute Engine", Cost: 99.50}, // same service, second row + }} + src := NewBigQuerySource(fake, "`p.d.t`") + + report, err := src.Costs(context.Background(), apr1(), apr30End()) + if err != nil { + t.Fatalf("Costs: %v", err) + } + if report.DataSource != GCPBigQueryDataSource { + t.Errorf("DataSource = %q, want %q", report.DataSource, GCPBigQueryDataSource) + } + got := recordsByService(report) + if got["compute engine"] != 200 { // 100.50 + 99.50, normalized lower-case + t.Errorf("compute engine total = %v, want 200", got["compute engine"]) + } + if got["cloud sql"] != 40 { + t.Errorf("cloud sql total = %v, want 40", got["cloud sql"]) + } + for _, r := range report.Records { + if r.Provider != "gcp" { + t.Errorf("record provider = %q, want gcp", r.Provider) + } + } +} + +func TestBigQuery_SQLBoundsAndTable(t *testing.T) { + fake := &fakeBQ{} + src := NewBigQuerySource(fake, "`proj.ds.gcp_billing_export_v1_ABC`") + + if _, err := src.Costs(context.Background(), apr1(), apr30End()); err != nil { + t.Fatalf("Costs: %v", err) + } + sql := fake.sqls[0] + for _, want := range []string{ + "`proj.ds.gcp_billing_export_v1_ABC`", + "TIMESTAMP('2026-04-01 00:00:00')", + "TIMESTAMP('2026-04-30 23:59:59')", + "GROUP BY service", + } { + if !strings.Contains(sql, want) { + t.Errorf("SQL missing %q\ngot: %s", want, sql) + } + } +} + +func TestBigQuery_ErrorWrapsAsSourceError(t *testing.T) { + fake := &fakeBQ{err: errors.New("permission denied")} + src := NewBigQuerySource(fake, "`p.d.t`") + + _, err := src.Costs(context.Background(), apr1(), apr30End()) + var srcErr *SourceError + if !errors.As(err, &srcErr) { + t.Fatalf("error = %v, want *SourceError", err) + } + if srcErr.Code != "billing_query_failed" { + t.Errorf("code = %q, want billing_query_failed", srcErr.Code) + } + if !errors.Is(err, srcErr.Err) { + t.Error("SourceError should unwrap to the underlying error") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 9b37c5e..7b0c529 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,13 +28,17 @@ type DBConfig struct { } type CloudConfig struct { - Provider string - AWSRegion string - AWSProfile string - GCPProject string - AzureSubID string - SyntheticCount int - SyntheticAcct string + Provider string + AWSRegion string + AWSProfile string + GCPProject string + // GCP billing export (BigQuery) coordinates, only used when + // CLOUDORACLE_BILLING_PROVIDER=gcp_bigquery. + GCPBillingDataset string + GCPBillingTable string + AzureSubID string + SyntheticCount int + SyntheticAcct string } type LLMConfig struct { @@ -58,7 +62,7 @@ type APIConfig struct { Port string ShutdownTimeout time.Duration // BillingProvider selects the cost data source for the v1 endpoints: - // "snapshots" (default) or "aws_cost_explorer". + // "snapshots" (default), "aws_cost_explorer", or "gcp_bigquery". BillingProvider string } @@ -69,16 +73,18 @@ const ( providerAzure = "azure" // Billing providers select where the v1 cost endpoints read from: - // "snapshots" (the default approximation) or "aws_cost_explorer" (real - // AWS billed cost via the Cost Explorer API). - BillingSnapshots = "snapshots" + // "snapshots" (the default approximation), "aws_cost_explorer" (real AWS + // billed cost via the Cost Explorer API), or "gcp_bigquery" (real GCP + // billed cost from the billing export in BigQuery). + BillingSnapshots = "snapshots" BillingAWSCostExplorer = "aws_cost_explorer" + BillingGCPBigQuery = "gcp_bigquery" ) var ( validCloudProviders = []string{providerSynthetic, providerAWS, providerGCP, providerAzure} validLLMProviders = []string{"gemini", "claude", "openai"} - validBillingProviders = []string{BillingSnapshots, BillingAWSCostExplorer} + validBillingProviders = []string{BillingSnapshots, BillingAWSCostExplorer, BillingGCPBigQuery} validLogLevels = []string{"debug", "info", "warn", "error"} validLogFormats = []string{"text", "json"} ) @@ -118,13 +124,15 @@ func Load() (Config, error) { Database: getEnv("DB_NAME", "cloudoracle"), }, Cloud: CloudConfig{ - Provider: v.requireEnum("CLOUDORACLE_PROVIDER", providerSynthetic, validCloudProviders), - AWSRegion: getEnv("AWS_REGION", "us-east-2"), - AWSProfile: getEnv("AWS_PROFILE", "cloudoracle"), - GCPProject: os.Getenv("GOOGLE_CLOUD_PROJECT"), - AzureSubID: os.Getenv("AZURE_SUBSCRIPTION_ID"), - SyntheticCount: v.requirePositiveInt("SYNTHETIC_COUNT", 100), - SyntheticAcct: getEnv("SYNTHETIC_ACCOUNT", "synthetic-account"), + Provider: v.requireEnum("CLOUDORACLE_PROVIDER", providerSynthetic, validCloudProviders), + AWSRegion: getEnv("AWS_REGION", "us-east-2"), + AWSProfile: getEnv("AWS_PROFILE", "cloudoracle"), + GCPProject: os.Getenv("GOOGLE_CLOUD_PROJECT"), + GCPBillingDataset: os.Getenv("CLOUDORACLE_GCP_BILLING_DATASET"), + GCPBillingTable: os.Getenv("CLOUDORACLE_GCP_BILLING_TABLE"), + AzureSubID: os.Getenv("AZURE_SUBSCRIPTION_ID"), + SyntheticCount: v.requirePositiveInt("SYNTHETIC_COUNT", 100), + SyntheticAcct: getEnv("SYNTHETIC_ACCOUNT", "synthetic-account"), }, LLM: LLMConfig{ Provider: v.optionalEnum("LLM_PROVIDER", validLLMProviders), @@ -305,6 +313,18 @@ func (v *validator) crossFieldChecks(cfg *Config) { v.errorf("OPENAI_API_KEY is required when LLM_PROVIDER=openai") } } + + if cfg.API.BillingProvider == BillingGCPBigQuery { + if cfg.Cloud.GCPProject == "" { + v.errorf("GOOGLE_CLOUD_PROJECT is required when CLOUDORACLE_BILLING_PROVIDER=gcp_bigquery") + } + if cfg.Cloud.GCPBillingDataset == "" { + v.errorf("CLOUDORACLE_GCP_BILLING_DATASET is required when CLOUDORACLE_BILLING_PROVIDER=gcp_bigquery") + } + if cfg.Cloud.GCPBillingTable == "" { + v.errorf("CLOUDORACLE_GCP_BILLING_TABLE is required when CLOUDORACLE_BILLING_PROVIDER=gcp_bigquery") + } + } } func getEnv(key, def string) string { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2dcc3e9..8c4a1ab 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -15,9 +15,11 @@ func allConfigEnvVars() []string { "DB_HOST", "DB_PORT", "DB_USER", "DB_PASSWORD", "DB_NAME", "CLOUDORACLE_PROVIDER", "AWS_REGION", "AWS_PROFILE", "GOOGLE_CLOUD_PROJECT", "AZURE_SUBSCRIPTION_ID", + "CLOUDORACLE_GCP_BILLING_DATASET", "CLOUDORACLE_GCP_BILLING_TABLE", "SYNTHETIC_COUNT", "SYNTHETIC_ACCOUNT", "LLM_PROVIDER", "GEMINI_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "LLM_TIMEOUT", "CLOUDORACLE_API_KEY", "CLOUDORACLE_API_PORT", "CLOUDORACLE_API_SHUTDOWN_TIMEOUT", + "CLOUDORACLE_BILLING_PROVIDER", "CLOUD_SERVICE_TIMEOUT", "LOG_LEVEL", "LOG_FORMAT", } } @@ -199,6 +201,46 @@ func TestLoad_GCPProviderWithProject_OK(t *testing.T) { } } +// TestLoad_GCPBigQueryBillingRequiresCoordinates covers the cross-field rule: +// CLOUDORACLE_BILLING_PROVIDER=gcp_bigquery needs project + dataset + table. +func TestLoad_GCPBigQueryBillingRequiresCoordinates(t *testing.T) { + clearAll(t) + t.Setenv("CLOUDORACLE_BILLING_PROVIDER", "gcp_bigquery") + + _, err := Load() + if err == nil { + t.Fatal("expected error when billing=gcp_bigquery without coordinates") + } + for _, want := range []string{ + "GOOGLE_CLOUD_PROJECT", + "CLOUDORACLE_GCP_BILLING_DATASET", + "CLOUDORACLE_GCP_BILLING_TABLE", + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("error should mention %s: %v", want, err) + } + } +} + +func TestLoad_GCPBigQueryBillingWithCoordinates_OK(t *testing.T) { + clearAll(t) + t.Setenv("CLOUDORACLE_BILLING_PROVIDER", "gcp_bigquery") + t.Setenv("GOOGLE_CLOUD_PROJECT", "my-project") + t.Setenv("CLOUDORACLE_GCP_BILLING_DATASET", "billing") + t.Setenv("CLOUDORACLE_GCP_BILLING_TABLE", "gcp_billing_export_v1_ABC123") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.API.BillingProvider != BillingGCPBigQuery { + t.Errorf("BillingProvider = %q, want %q", cfg.API.BillingProvider, BillingGCPBigQuery) + } + if cfg.Cloud.GCPBillingDataset != "billing" || cfg.Cloud.GCPBillingTable != "gcp_billing_export_v1_ABC123" { + t.Errorf("billing coordinates not loaded: %+v", cfg.Cloud) + } +} + func TestLoad_AzureProviderRequiresSubscription(t *testing.T) { clearAll(t) t.Setenv("CLOUDORACLE_PROVIDER", "azure") diff --git a/internal/iac/gcp/compute_disk.go b/internal/iac/gcp/compute_disk.go new file mode 100644 index 0000000..dc44d1c --- /dev/null +++ b/internal/iac/gcp/compute_disk.go @@ -0,0 +1,35 @@ +package gcp + +// ComputeDiskAttributes captures the cost-impacting fields of a +// google_compute_disk (a standalone zonal persistent disk). +type ComputeDiskAttributes struct { + // Type is the PD type ("pd-standard", "pd-balanced", "pd-ssd", "pd-extreme"). + // Empty when unspecified; the estimator defaults to pd-standard, the + // google_compute_disk default. + Type string + + // SizeGB is the disk size in GB. Zero when the plan doesn't carry it (the + // size is computed from an image/snapshot); the estimator then skips it. + SizeGB int +} + +// ExtractComputeDisk reads cost-impacting attributes from a google_compute_disk +// attribute map. Only `type` and `size` affect price. Both are optional at the +// extractor level; a missing size routes to a Skipped estimate downstream. +func ExtractComputeDisk(attrs map[string]interface{}) (*ComputeDiskAttributes, error) { + const typ = "google_compute_disk" + if len(attrs) == 0 { + return nil, errEmptyAttrs(typ) + } + + diskType, _, err := getString(attrs, "type") + if err != nil { + return nil, wrapAttr(typ, err) + } + size, _, err := getInt(attrs, "size") + if err != nil { + return nil, wrapAttr(typ, err) + } + + return &ComputeDiskAttributes{Type: diskType, SizeGB: size}, nil +} diff --git a/internal/iac/gcp/compute_disk_test.go b/internal/iac/gcp/compute_disk_test.go new file mode 100644 index 0000000..1935f17 --- /dev/null +++ b/internal/iac/gcp/compute_disk_test.go @@ -0,0 +1,35 @@ +package gcp + +import "testing" + +func TestExtract_DispatchesComputeDisk(t *testing.T) { + r, err := Extract("google_compute_disk", map[string]interface{}{ + "type": "pd-ssd", + "size": float64(500), + }) + if err != nil { + t.Fatalf("Extract: %v", err) + } + if r.Type != "google_compute_disk" || r.ComputeDisk == nil { + t.Fatalf("dispatch failed: %+v", r) + } + if r.ComputeDisk.Type != "pd-ssd" || r.ComputeDisk.SizeGB != 500 { + t.Errorf("got %+v, want pd-ssd/500", r.ComputeDisk) + } +} + +func TestExtractComputeDisk_MissingSizeIsZero(t *testing.T) { + cd, err := ExtractComputeDisk(map[string]interface{}{"type": "pd-balanced"}) + if err != nil { + t.Fatalf("ExtractComputeDisk: %v", err) + } + if cd.SizeGB != 0 { + t.Errorf("SizeGB = %d, want 0 (absent)", cd.SizeGB) + } +} + +func TestExtractComputeDisk_EmptyAttrsErrors(t *testing.T) { + if _, err := ExtractComputeDisk(map[string]interface{}{}); err == nil { + t.Fatal("want error for empty attrs") + } +} diff --git a/internal/iac/gcp/compute_instance.go b/internal/iac/gcp/compute_instance.go new file mode 100644 index 0000000..2c6831c --- /dev/null +++ b/internal/iac/gcp/compute_instance.go @@ -0,0 +1,108 @@ +package gcp + +// ComputeInstanceAttributes captures the cost-impacting fields of a +// google_compute_instance. Non-pricing fields (tags, network config, metadata) +// are deliberately excluded. +type ComputeInstanceAttributes struct { + // MachineType is the short machine-type name, e.g. "e2-standard-4". Required. + // Terraform may render it as a full self-link URL; the extractor reduces it + // to the trailing name. + MachineType string + + // Region is derived from the instance zone ("us-central1-a" → "us-central1"). + // Empty when the plan omits the zone, in which case pricing falls back to the + // plan-wide --region. + Region string + + // Preemptible reports whether the instance is a Spot/preemptible VM, from + // either scheduling.preemptible=true or scheduling.provisioning_model="SPOT". + // Spot pricing is 60–91% below on-demand and varies, so the estimator prices + // it at on-demand as a labeled upper bound. + Preemptible bool + + // BootDiskSizeGB is the boot disk size in GB from + // boot_disk.initialize_params.size. Zero when the plan doesn't specify it + // (GCP then uses the source image's default, commonly 10 GB). + BootDiskSizeGB int + + // BootDiskType is the boot disk type ("pd-standard", "pd-balanced", + // "pd-ssd"). Empty when unspecified; the estimator defaults to pd-balanced, + // the google_compute_instance default. + BootDiskType string +} + +// ExtractComputeInstance reads cost-impacting attributes from a +// google_compute_instance attribute map. +// +// Required: machine_type. Optional: zone (→ Region), scheduling block +// (→ Preemptible), boot_disk.initialize_params (→ size/type). Unknown +// attributes are ignored so Terraform version drift doesn't break extraction. +func ExtractComputeInstance(attrs map[string]interface{}) (*ComputeInstanceAttributes, error) { + const typ = "google_compute_instance" + if len(attrs) == 0 { + return nil, errEmptyAttrs(typ) + } + + machineType, present, err := getString(attrs, "machine_type") + if err != nil { + return nil, wrapAttr(typ, err) + } + if !present { + return nil, errMissingRequired(typ, "machine_type") + } + + zone, _, err := getString(attrs, "zone") + if err != nil { + return nil, wrapAttr(typ, err) + } + + out := &ComputeInstanceAttributes{ + MachineType: lastPathSegment(machineType), + Region: regionFromZone(lastPathSegment(zone)), + } + + // scheduling is a nested block; preemptible VMs set either the legacy + // `preemptible` bool or the newer `provisioning_model = "SPOT"`. + sched, present, err := getNestedFirst(attrs, "scheduling") + if err != nil { + return nil, wrapAttr(typ, err) + } + if present { + preempt, _, err := getBool(sched, "preemptible") + if err != nil { + return nil, wrapAttr(typ+".scheduling", err) + } + model, _, err := getString(sched, "provisioning_model") + if err != nil { + return nil, wrapAttr(typ+".scheduling", err) + } + out.Preemptible = preempt || model == "SPOT" + } + + // boot_disk → initialize_params holds the size and type. + bootDisk, present, err := getNestedFirst(attrs, "boot_disk") + if err != nil { + return nil, wrapAttr(typ, err) + } + if present { + params, present, err := getNestedFirst(bootDisk, "initialize_params") + if err != nil { + return nil, wrapAttr(typ+".boot_disk", err) + } + if present { + size, _, err := getInt(params, "size") + if err != nil { + return nil, wrapAttr(typ+".boot_disk.initialize_params", err) + } + out.BootDiskSizeGB = size + + diskType, _, err := getString(params, "type") + if err != nil { + return nil, wrapAttr(typ+".boot_disk.initialize_params", err) + } + out.BootDiskType = diskType + } + } + + return out, nil +} diff --git a/internal/iac/gcp/compute_instance_test.go b/internal/iac/gcp/compute_instance_test.go new file mode 100644 index 0000000..57855c9 --- /dev/null +++ b/internal/iac/gcp/compute_instance_test.go @@ -0,0 +1,94 @@ +package gcp + +import "testing" + +func TestExtract_DispatchesComputeInstance(t *testing.T) { + r, err := Extract("google_compute_instance", map[string]interface{}{ + "machine_type": "e2-standard-4", + "zone": "us-central1-a", + }) + if err != nil { + t.Fatalf("Extract: %v", err) + } + if r.Type != "google_compute_instance" || r.ComputeInstance == nil { + t.Fatalf("dispatch failed: %+v", r) + } + if r.ComputeInstance.MachineType != "e2-standard-4" { + t.Errorf("MachineType = %q", r.ComputeInstance.MachineType) + } + if r.ComputeInstance.Region != "us-central1" { + t.Errorf("Region = %q, want us-central1", r.ComputeInstance.Region) + } +} + +func TestExtract_UnsupportedTypeReturnsNil(t *testing.T) { + r, err := Extract("google_storage_bucket", map[string]interface{}{"name": "x"}) + if err != nil { + t.Fatalf("Extract: %v", err) + } + if r != nil { + t.Errorf("want nil for unsupported type, got %+v", r) + } +} + +func TestExtractComputeInstance_FullSelfLinksAndBootDisk(t *testing.T) { + attrs := map[string]interface{}{ + "machine_type": "projects/p/zones/europe-west2-b/machineTypes/n2-standard-8", + "zone": "projects/p/zones/europe-west2-b", + "boot_disk": []interface{}{map[string]interface{}{ + "initialize_params": []interface{}{map[string]interface{}{ + "size": float64(200), + "type": "pd-ssd", + }}, + }}, + } + ci, err := ExtractComputeInstance(attrs) + if err != nil { + t.Fatalf("ExtractComputeInstance: %v", err) + } + if ci.MachineType != "n2-standard-8" { + t.Errorf("MachineType = %q, want n2-standard-8 (last path segment)", ci.MachineType) + } + if ci.Region != "europe-west2" { + t.Errorf("Region = %q, want europe-west2", ci.Region) + } + if ci.BootDiskSizeGB != 200 || ci.BootDiskType != "pd-ssd" { + t.Errorf("boot disk = %d/%q, want 200/pd-ssd", ci.BootDiskSizeGB, ci.BootDiskType) + } +} + +func TestExtractComputeInstance_PreemptibleFromEitherField(t *testing.T) { + for _, tc := range []struct { + name string + sched map[string]interface{} + }{ + {"legacy preemptible bool", map[string]interface{}{"preemptible": true}}, + {"provisioning_model SPOT", map[string]interface{}{"provisioning_model": "SPOT"}}, + } { + t.Run(tc.name, func(t *testing.T) { + ci, err := ExtractComputeInstance(map[string]interface{}{ + "machine_type": "e2-medium", + "scheduling": []interface{}{tc.sched}, + }) + if err != nil { + t.Fatalf("ExtractComputeInstance: %v", err) + } + if !ci.Preemptible { + t.Error("Preemptible = false, want true") + } + }) + } +} + +func TestExtractComputeInstance_MissingMachineTypeErrors(t *testing.T) { + _, err := ExtractComputeInstance(map[string]interface{}{"zone": "us-central1-a"}) + if err == nil { + t.Fatal("want error for missing machine_type") + } +} + +func TestExtractComputeInstance_EmptyAttrsErrors(t *testing.T) { + if _, err := ExtractComputeInstance(map[string]interface{}{}); err == nil { + t.Fatal("want error for empty attrs") + } +} diff --git a/internal/iac/gcp/gcp.go b/internal/iac/gcp/gcp.go new file mode 100644 index 0000000..210299c --- /dev/null +++ b/internal/iac/gcp/gcp.go @@ -0,0 +1,62 @@ +// Package gcp extracts strongly-typed cost-impacting attributes from +// Terraform plan resource changes for Google Cloud resources. It is the GCP +// counterpart to internal/iac/aws and follows the same contract: each +// ExtractXxx reads a map[string]interface{} (the shape of Change.Before / +// Change.After) and returns a typed attribute struct the pricing package +// consumes. +// +// Currently supported: google_compute_instance. Persistent disks and Cloud SQL +// are added in subsequent milestones. +package gcp + +// ResourceAttributes is a discriminated union over the GCP resource types this +// package supports. Exactly one inner pointer is non-nil; Type identifies which. +// Mirrors internal/iac/aws.ResourceAttributes so the pricing dispatcher can +// switch on the inner pointer the same way for both providers. +type ResourceAttributes struct { + Type string + ComputeInstance *ComputeInstanceAttributes + ComputeDisk *ComputeDiskAttributes + SQLInstance *SQLInstanceAttributes +} + +// Extract dispatches to the type-specific extractor for resourceType. +// +// Unsupported types return (nil, nil): the caller treats "no data" as "no cost +// impact", exactly as the aws extractor does — a real plan is full of GCP types +// we don't price (IAM, VPCs, DNS records). Extraction failures on supported +// types return (nil, error). +func Extract(resourceType string, attrs map[string]interface{}) (*ResourceAttributes, error) { + switch resourceType { + case "google_compute_instance": + ci, err := ExtractComputeInstance(attrs) + if err != nil { + return nil, err + } + return &ResourceAttributes{Type: resourceType, ComputeInstance: ci}, nil + case "google_compute_disk": + cd, err := ExtractComputeDisk(attrs) + if err != nil { + return nil, err + } + return &ResourceAttributes{Type: resourceType, ComputeDisk: cd}, nil + case "google_sql_database_instance": + si, err := ExtractSQLInstance(attrs) + if err != nil { + return nil, err + } + return &ResourceAttributes{Type: resourceType, SQLInstance: si}, nil + default: + return nil, nil + } +} + +// SupportedTypes returns the GCP resource types this package can extract, for +// docs and the pr-check "unsupported" diagnostics. +func SupportedTypes() []string { + return []string{ + "google_compute_instance", + "google_compute_disk", + "google_sql_database_instance", + } +} diff --git a/internal/iac/gcp/helpers.go b/internal/iac/gcp/helpers.go new file mode 100644 index 0000000..24c30f4 --- /dev/null +++ b/internal/iac/gcp/helpers.go @@ -0,0 +1,119 @@ +package gcp + +import ( + "fmt" + "math" + "strings" +) + +// These helpers mirror the ones in internal/iac/aws/helpers.go. They are +// duplicated rather than shared for the same reason internal/diff duplicates +// weakestConfidence: they are a few lines each, generic, and importing across +// the aws↔gcp extractor packages would couple two otherwise-independent +// dialects to one package's unexported internals. If a third provider appears +// this is the moment to hoist them into a shared internal/iac/attrs package. + +// getString returns (value, present, error) for a string attribute. JSON null +// and absent both read as "not present" so the caller can apply its own default. +func getString(attrs map[string]interface{}, key string) (string, bool, error) { + raw, ok := attrs[key] + if !ok || raw == nil { + return "", false, nil + } + s, ok := raw.(string) + if !ok { + return "", false, fmt.Errorf("attribute %q: want string, got %T", key, raw) + } + return s, true, nil +} + +// getInt returns (value, present, error) for an integer attribute. JSON numbers +// decode to float64, so whole-valued float64 is accepted; a fractional value is +// an error (the caller asked for an int, not a rounding). +func getInt(attrs map[string]interface{}, key string) (int, bool, error) { + raw, ok := attrs[key] + if !ok || raw == nil { + return 0, false, nil + } + switch v := raw.(type) { + case int: + return v, true, nil + case float64: + if math.Trunc(v) != v { + return 0, false, fmt.Errorf("attribute %q: want integer, got fractional %g", key, v) + } + return int(v), true, nil + default: + return 0, false, fmt.Errorf("attribute %q: want integer, got %T", key, raw) + } +} + +// getBool returns (value, present, error) for a strict JSON bool attribute. +func getBool(attrs map[string]interface{}, key string) (bool, bool, error) { + raw, ok := attrs[key] + if !ok || raw == nil { + return false, false, nil + } + b, ok := raw.(bool) + if !ok { + return false, false, fmt.Errorf("attribute %q: want bool, got %T", key, raw) + } + return b, true, nil +} + +// getNestedFirst returns the first element of a list-of-maps attribute — the +// shape Terraform plans use for nested blocks (boot_disk, scheduling, ...) even +// when only one is allowed. (nil, false, nil) for absent/null/empty-list. +func getNestedFirst(attrs map[string]interface{}, key string) (map[string]interface{}, bool, error) { + raw, ok := attrs[key] + if !ok || raw == nil { + return nil, false, nil + } + list, ok := raw.([]interface{}) + if !ok { + return nil, false, fmt.Errorf("attribute %q: want list, got %T", key, raw) + } + if len(list) == 0 { + return nil, false, nil + } + first, ok := list[0].(map[string]interface{}) + if !ok { + return nil, false, fmt.Errorf("attribute %q[0]: want object, got %T", key, list[0]) + } + return first, true, nil +} + +// lastPathSegment returns the final "/"-separated segment of s, or s unchanged +// when it has no slash. GCP self-links arrive either as short names +// ("e2-medium", "us-central1-a") or full URLs +// ("projects/p/zones/us-central1-a/machineTypes/e2-medium"); we only ever want +// the trailing name. +func lastPathSegment(s string) string { + if i := strings.LastIndex(s, "/"); i >= 0 { + return s[i+1:] + } + return s +} + +// regionFromZone strips the trailing "-" from a GCP zone to get its +// region ("us-central1-a" → "us-central1"). Returns "" when s doesn't look like +// a zone (no hyphen), leaving the caller to fall back to the plan-wide region. +func regionFromZone(zone string) string { + i := strings.LastIndex(zone, "-") + if i <= 0 { + return "" + } + return zone[:i] +} + +func errEmptyAttrs(typ string) error { + return fmt.Errorf("%s: empty attributes", typ) +} + +func errMissingRequired(typ, key string) error { + return fmt.Errorf("%s: missing required attribute %q", typ, key) +} + +func wrapAttr(typ string, err error) error { + return fmt.Errorf("%s: %w", typ, err) +} diff --git a/internal/iac/gcp/sql_database_instance.go b/internal/iac/gcp/sql_database_instance.go new file mode 100644 index 0000000..3f770fa --- /dev/null +++ b/internal/iac/gcp/sql_database_instance.go @@ -0,0 +1,79 @@ +package gcp + +// SQLInstanceAttributes captures the cost-impacting fields of a +// google_sql_database_instance. +type SQLInstanceAttributes struct { + // DatabaseVersion, e.g. "POSTGRES_15", "MYSQL_8_0", "SQLSERVER_2019_STANDARD". + // Used only to detect SQL Server, whose licensing the estimator doesn't model. + DatabaseVersion string + + // Tier is the machine tier from settings.tier, e.g. "db-custom-2-8192", + // "db-f1-micro", "db-n1-standard-2". Required for pricing. + Tier string + + // DiskSizeGB is settings.disk_size. Zero when the plan omits it (Cloud SQL + // defaults to 10 GB); the estimator applies that default. + DiskSizeGB int + + // DiskType is settings.disk_type ("PD_SSD" default, "PD_HDD"). + DiskType string + + // Regional is true when settings.availability_type == "REGIONAL" (HA), which + // roughly doubles compute and storage cost. + Regional bool +} + +// ExtractSQLInstance reads cost-impacting attributes from a +// google_sql_database_instance attribute map. `database_version` is top-level; +// the tier, disk, and availability live in the single `settings` block. +// +// Required: settings.tier. A missing tier routes to a Skipped estimate. +func ExtractSQLInstance(attrs map[string]interface{}) (*SQLInstanceAttributes, error) { + const typ = "google_sql_database_instance" + if len(attrs) == 0 { + return nil, errEmptyAttrs(typ) + } + + dbVersion, _, err := getString(attrs, "database_version") + if err != nil { + return nil, wrapAttr(typ, err) + } + + out := &SQLInstanceAttributes{DatabaseVersion: dbVersion} + + settings, present, err := getNestedFirst(attrs, "settings") + if err != nil { + return nil, wrapAttr(typ, err) + } + if !present { + // No settings block → no tier → nothing to price. Return the shell; the + // estimator skips on the empty tier. + return out, nil + } + + tier, _, err := getString(settings, "tier") + if err != nil { + return nil, wrapAttr(typ+".settings", err) + } + out.Tier = tier + + diskSize, _, err := getInt(settings, "disk_size") + if err != nil { + return nil, wrapAttr(typ+".settings", err) + } + out.DiskSizeGB = diskSize + + diskType, _, err := getString(settings, "disk_type") + if err != nil { + return nil, wrapAttr(typ+".settings", err) + } + out.DiskType = diskType + + availability, _, err := getString(settings, "availability_type") + if err != nil { + return nil, wrapAttr(typ+".settings", err) + } + out.Regional = availability == "REGIONAL" + + return out, nil +} diff --git a/internal/iac/gcp/sql_database_instance_test.go b/internal/iac/gcp/sql_database_instance_test.go new file mode 100644 index 0000000..7b6381e --- /dev/null +++ b/internal/iac/gcp/sql_database_instance_test.go @@ -0,0 +1,48 @@ +package gcp + +import "testing" + +func TestExtract_DispatchesSQLInstance(t *testing.T) { + r, err := Extract("google_sql_database_instance", map[string]interface{}{ + "database_version": "POSTGRES_15", + "region": "us-central1", + "settings": []interface{}{map[string]interface{}{ + "tier": "db-custom-2-8192", + "disk_size": float64(50), + "disk_type": "PD_SSD", + "availability_type": "REGIONAL", + }}, + }) + if err != nil { + t.Fatalf("Extract: %v", err) + } + si := r.SQLInstance + if si == nil { + t.Fatal("SQLInstance nil — dispatch failed") + } + if si.Tier != "db-custom-2-8192" || si.DiskSizeGB != 50 || si.DiskType != "PD_SSD" { + t.Errorf("got %+v", si) + } + if !si.Regional { + t.Error("Regional = false, want true for availability_type=REGIONAL") + } + if si.DatabaseVersion != "POSTGRES_15" { + t.Errorf("DatabaseVersion = %q", si.DatabaseVersion) + } +} + +func TestExtractSQLInstance_NoSettingsLeavesTierEmpty(t *testing.T) { + si, err := ExtractSQLInstance(map[string]interface{}{"database_version": "MYSQL_8_0"}) + if err != nil { + t.Fatalf("ExtractSQLInstance: %v", err) + } + if si.Tier != "" { + t.Errorf("Tier = %q, want empty when settings absent", si.Tier) + } +} + +func TestExtractSQLInstance_EmptyAttrsErrors(t *testing.T) { + if _, err := ExtractSQLInstance(map[string]interface{}{}); err == nil { + t.Fatal("want error for empty attrs") + } +} diff --git a/internal/pricing/change.go b/internal/pricing/change.go index eb6d93c..128e51d 100644 --- a/internal/pricing/change.go +++ b/internal/pricing/change.go @@ -2,10 +2,13 @@ package pricing import ( "context" + "errors" "fmt" + "strings" "CloudOracle/internal/iac" "CloudOracle/internal/iac/aws" + "CloudOracle/internal/iac/gcp" ) // EstimateChange returns the cost impact of a single resource change in @@ -145,6 +148,11 @@ func estimateState(ctx context.Context, src productGetter, resourceType string, if len(attrs) == 0 { return Estimate{}, "no attributes for state", nil } + // GCP resources price from the embedded static table and never touch the + // AWS Pricing API src, so they dispatch through their own path. + if strings.HasPrefix(resourceType, "google_") { + return estimateGCPState(resourceType, attrs, region) + } ra, err := aws.Extract(resourceType, attrs) if err != nil { return Estimate{}, "", fmt.Errorf("extracting %s: %w", resourceType, err) @@ -178,6 +186,41 @@ func estimateState(ctx context.Context, src productGetter, resourceType string, return Estimate{}, "unsupported resource type: " + resourceType, nil } +// estimateGCPState is the GCP arm of estimateState: extract typed attributes, +// dispatch to the static-table estimator. An unpriced machine type becomes a +// Skipped result (non-empty skipReason) rather than an error, mirroring how the +// AWS path treats unsupported types. +func estimateGCPState(resourceType string, attrs map[string]interface{}, region string) (Estimate, string, error) { + ra, err := gcp.Extract(resourceType, attrs) + if err != nil { + return Estimate{}, "", fmt.Errorf("extracting %s: %w", resourceType, err) + } + if ra == nil { + return Estimate{}, "unsupported resource type: " + resourceType, nil + } + switch { + case ra.ComputeInstance != nil: + est, err := EstimateGCPComputeInstance(ra.ComputeInstance, region) + if errors.Is(err, errUnpricedGCPMachineType) { + return Estimate{}, err.Error(), nil + } + return est, "", err + case ra.ComputeDisk != nil: + est, err := EstimateGCPComputeDisk(ra.ComputeDisk) + if errors.Is(err, errUnpricedGCPDisk) { + return Estimate{}, err.Error(), nil + } + return est, "", err + case ra.SQLInstance != nil: + est, err := EstimateGCPSQLInstance(ra.SQLInstance) + if errors.Is(err, errUnpricedGCPSQLTier) || errors.Is(err, errSQLServerNotModeled) { + return Estimate{}, err.Error(), nil + } + return est, "", err + } + return Estimate{}, "unsupported resource type: " + resourceType, nil +} + // weakestConfidence returns whichever confidence is "weaker" — high is // the strongest, low is the weakest. Used by EstimateChange to merge // the before/after confidences of an update or replace into a single diff --git a/internal/pricing/gcp_change_test.go b/internal/pricing/gcp_change_test.go new file mode 100644 index 0000000..ed32219 --- /dev/null +++ b/internal/pricing/gcp_change_test.go @@ -0,0 +1,84 @@ +package pricing + +import ( + "context" + "testing" + + "CloudOracle/internal/iac" +) + +// The GCP path prices from the static table and never calls the productGetter, +// so these dispatch tests pass a nil src. + +func TestEstimateChange_CreateGCPComputeInstance(t *testing.T) { + rc := iac.ResourceChange{ + Address: "google_compute_instance.web", + Mode: "managed", + Type: "google_compute_instance", + Change: iac.Change{ + Actions: []string{"create"}, + After: map[string]interface{}{ + "machine_type": "e2-standard-4", + "zone": "us-central1-a", + "boot_disk": []interface{}{map[string]interface{}{ + "initialize_params": []interface{}{map[string]interface{}{ + "size": float64(50), + "type": "pd-balanced", + }}, + }}, + }, + }, + } + // region flag is us-east-2 (AWS-style default); the instance zone overrides it. + ce, err := EstimateChange(context.Background(), nil, rc, "us-east-2") + if err != nil { + t.Fatalf("EstimateChange: %v", err) + } + if ce.Skipped { + t.Fatalf("Skipped = true, reason=%q", ce.SkipReason) + } + if ce.MonthlyDelta <= 0 { + t.Errorf("MonthlyDelta = %.2f, want > 0", ce.MonthlyDelta) + } + if ce.AfterMonthly != ce.MonthlyDelta { + t.Errorf("create: AfterMonthly (%.2f) should equal MonthlyDelta (%.2f)", ce.AfterMonthly, ce.MonthlyDelta) + } +} + +func TestEstimateChange_GCPUnknownMachineTypeSkipped(t *testing.T) { + rc := iac.ResourceChange{ + Address: "google_compute_instance.exotic", + Mode: "managed", + Type: "google_compute_instance", + Change: iac.Change{ + Actions: []string{"create"}, + After: map[string]interface{}{"machine_type": "z9-mega-9999", "zone": "us-central1-a"}, + }, + } + ce, err := EstimateChange(context.Background(), nil, rc, "us-central1") + if err != nil { + t.Fatalf("EstimateChange returned error, want Skipped: %v", err) + } + if !ce.Skipped { + t.Fatal("Skipped = false, want true for unpriced machine type") + } +} + +func TestEstimateChange_GCPUnsupportedTypeSkipped(t *testing.T) { + rc := iac.ResourceChange{ + Address: "google_storage_bucket.assets", + Mode: "managed", + Type: "google_storage_bucket", + Change: iac.Change{ + Actions: []string{"create"}, + After: map[string]interface{}{"name": "assets", "location": "US"}, + }, + } + ce, err := EstimateChange(context.Background(), nil, rc, "us-central1") + if err != nil { + t.Fatalf("EstimateChange: %v", err) + } + if !ce.Skipped { + t.Fatal("Skipped = false, want true for unsupported GCP type") + } +} diff --git a/internal/pricing/gcp_compute.go b/internal/pricing/gcp_compute.go new file mode 100644 index 0000000..5b4cde1 --- /dev/null +++ b/internal/pricing/gcp_compute.go @@ -0,0 +1,90 @@ +package pricing + +import ( + "errors" + "fmt" + + "CloudOracle/internal/iac/gcp" +) + +// defaultBootDiskType is google_compute_instance's default boot disk type when +// initialize_params.type is omitted. +const defaultBootDiskType = "pd-balanced" + +// errUnpricedGCPMachineType marks a machine type absent from the static price +// table. The dispatcher turns it into a Skipped change (the "unsupported" +// substring makes it count under unsupported types in the plan-wide notes) +// rather than a hard estimation error. +var errUnpricedGCPMachineType = errors.New("unsupported machine type not in static price table") + +// EstimateGCPComputeInstance calculates the monthly cost of a +// google_compute_instance from the embedded static price table. Unlike the AWS +// estimators it makes no API call, so it takes no productGetter. +// +// planRegion is the plan-wide --region used when the instance's own zone was +// absent from the plan (attrs.Region empty). +// +// The estimate is capped at ConfidenceMedium (static table, may drift) and +// dropped to ConfidenceLow when the region is not in the multiplier table or +// the instance is preemptible/Spot (priced at on-demand as an upper bound). +// +// An unknown machine type returns errUnpricedGCPMachineType so EstimateChange +// routes it to a Skipped result rather than a hard error. +func EstimateGCPComputeInstance(attrs *gcp.ComputeInstanceAttributes, planRegion string) (Estimate, error) { + if attrs == nil { + return Estimate{}, fmt.Errorf("EstimateGCPComputeInstance: nil attrs") + } + if attrs.MachineType == "" { + return Estimate{}, fmt.Errorf("EstimateGCPComputeInstance: empty MachineType") + } + + region := attrs.Region + if region == "" { + region = planRegion + } + + hourly, machineKnown, regionKnown := gcpComputeHourly(attrs.MachineType, region) + if !machineKnown { + return Estimate{}, fmt.Errorf("%w: %q", errUnpricedGCPMachineType, attrs.MachineType) + } + + compute := hourly * HoursPerMonth + confidence := ConfidenceMedium + notes := []string{"Priced from a static GCP price table (may drift from current list price)"} + + if !regionKnown { + confidence = ConfidenceLow + notes = append(notes, fmt.Sprintf("Region %q not in price table; used us-central1 base rate", region)) + } + if attrs.Preemptible { + confidence = ConfidenceLow + notes = append(notes, "Spot/preemptible VM priced at on-demand rate (real cost is 60–91% lower)") + } + + breakdown := []LineItem{{Component: "Compute", MonthlyUSD: compute}} + total := compute + + if attrs.BootDiskSizeGB > 0 { + diskType := attrs.BootDiskType + if diskType == "" { + diskType = defaultBootDiskType + } + gbMo, ok := gcpPDGBMonth(diskType) + if !ok { + return Estimate{}, fmt.Errorf("EstimateGCPComputeInstance: unknown boot disk type %q", diskType) + } + boot := gbMo * float64(attrs.BootDiskSizeGB) + breakdown = append(breakdown, LineItem{Component: "BootDisk", MonthlyUSD: boot}) + total += boot + } else { + notes = append(notes, "Boot disk size not in plan, compute-only estimate") + } + + return Estimate{ + MonthlyUSD: total, + Currency: "USD", + Breakdown: breakdown, + Confidence: confidence, + Notes: notes, + }, nil +} diff --git a/internal/pricing/gcp_compute_test.go b/internal/pricing/gcp_compute_test.go new file mode 100644 index 0000000..fbdf548 --- /dev/null +++ b/internal/pricing/gcp_compute_test.go @@ -0,0 +1,100 @@ +package pricing + +import ( + "errors" + "math" + "testing" + + "CloudOracle/internal/iac/gcp" +) + +func approxEq(a, b float64) bool { return math.Abs(a-b) < 0.01 } + +func TestEstimateGCPComputeInstance_ComputePlusBootDisk(t *testing.T) { + // e2-standard-4 @ us-central1 = 0.134012/hr * 730 = 97.83; boot 100GB + // pd-balanced @ 0.10 = 10.00. + est, err := EstimateGCPComputeInstance(&gcp.ComputeInstanceAttributes{ + MachineType: "e2-standard-4", + Region: "us-central1", + BootDiskSizeGB: 100, + BootDiskType: "pd-balanced", + }, "us-east1") + if err != nil { + t.Fatalf("estimate: %v", err) + } + if !approxEq(est.MonthlyUSD, 97.83+10.00) { + t.Errorf("MonthlyUSD = %.2f, want ~107.83", est.MonthlyUSD) + } + if est.Confidence != ConfidenceMedium { + t.Errorf("Confidence = %q, want medium", est.Confidence) + } + if len(est.Breakdown) != 2 || est.Breakdown[1].Component != "BootDisk" { + t.Errorf("breakdown = %+v, want Compute+BootDisk", est.Breakdown) + } +} + +func TestEstimateGCPComputeInstance_RegionMultiplierApplied(t *testing.T) { + // europe-west2 multiplier is 1.16 → compute should be 16% above base. + base, err := EstimateGCPComputeInstance(&gcp.ComputeInstanceAttributes{ + MachineType: "n1-standard-1", Region: "us-central1", + }, "us-central1") + if err != nil { + t.Fatal(err) + } + euro, err := EstimateGCPComputeInstance(&gcp.ComputeInstanceAttributes{ + MachineType: "n1-standard-1", Region: "europe-west2", + }, "us-central1") + if err != nil { + t.Fatal(err) + } + if !approxEq(euro.MonthlyUSD, base.MonthlyUSD*1.16) { + t.Errorf("europe-west2 = %.2f, want ~%.2f (1.16x)", euro.MonthlyUSD, base.MonthlyUSD*1.16) + } +} + +func TestEstimateGCPComputeInstance_UnknownRegionDropsConfidence(t *testing.T) { + est, err := EstimateGCPComputeInstance(&gcp.ComputeInstanceAttributes{ + MachineType: "e2-medium", Region: "mars-central1", + }, "mars-central1") + if err != nil { + t.Fatal(err) + } + if est.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low for unknown region", est.Confidence) + } +} + +func TestEstimateGCPComputeInstance_PreemptibleIsLowConfidence(t *testing.T) { + est, err := EstimateGCPComputeInstance(&gcp.ComputeInstanceAttributes{ + MachineType: "e2-standard-4", Region: "us-central1", Preemptible: true, + }, "us-central1") + if err != nil { + t.Fatal(err) + } + if est.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low for preemptible", est.Confidence) + } +} + +func TestEstimateGCPComputeInstance_ZoneRegionBeatsPlanRegion(t *testing.T) { + // attrs.Region set → planRegion ignored. + est, err := EstimateGCPComputeInstance(&gcp.ComputeInstanceAttributes{ + MachineType: "e2-medium", Region: "europe-west2", + }, "us-central1") + if err != nil { + t.Fatal(err) + } + base, _, _ := gcpComputeHourly("e2-medium", "us-central1") + if approxEq(est.MonthlyUSD, base*HoursPerMonth) { + t.Error("used plan region us-central1; should have used instance region europe-west2") + } +} + +func TestEstimateGCPComputeInstance_UnknownMachineTypeSentinel(t *testing.T) { + _, err := EstimateGCPComputeInstance(&gcp.ComputeInstanceAttributes{ + MachineType: "z9-mega-9999", Region: "us-central1", + }, "us-central1") + if !errors.Is(err, errUnpricedGCPMachineType) { + t.Fatalf("err = %v, want errUnpricedGCPMachineType", err) + } +} diff --git a/internal/pricing/gcp_disk.go b/internal/pricing/gcp_disk.go new file mode 100644 index 0000000..48b6eb4 --- /dev/null +++ b/internal/pricing/gcp_disk.go @@ -0,0 +1,46 @@ +package pricing + +import ( + "errors" + "fmt" + + "CloudOracle/internal/iac/gcp" +) + +// defaultDiskType is google_compute_disk's default type when `type` is omitted. +const defaultDiskType = "pd-standard" + +// errUnpricedGCPDisk marks a disk we can't price (unknown type or a size the +// plan doesn't carry). The dispatcher turns it into a Skipped change; the +// "unsupported" prefix buckets it with unsupported types in the plan-wide notes. +var errUnpricedGCPDisk = errors.New("unsupported persistent disk not priced") + +// EstimateGCPComputeDisk calculates the monthly cost of a standalone +// persistent disk from the embedded static PD price table. Priced at the base +// $/GB-month rate (region variation is not modeled for storage, unlike +// compute); confidence is Medium with the static-table caveat. +func EstimateGCPComputeDisk(attrs *gcp.ComputeDiskAttributes) (Estimate, error) { + if attrs == nil { + return Estimate{}, fmt.Errorf("EstimateGCPComputeDisk: nil attrs") + } + diskType := attrs.Type + if diskType == "" { + diskType = defaultDiskType + } + if attrs.SizeGB <= 0 { + return Estimate{}, fmt.Errorf("%w: %s size not in plan", errUnpricedGCPDisk, diskType) + } + gbMo, ok := gcpPDGBMonth(diskType) + if !ok { + return Estimate{}, fmt.Errorf("%w: unknown disk type %q", errUnpricedGCPDisk, diskType) + } + + monthly := gbMo * float64(attrs.SizeGB) + return Estimate{ + MonthlyUSD: monthly, + Currency: "USD", + Breakdown: []LineItem{{Component: "Disk", MonthlyUSD: monthly}}, + Confidence: ConfidenceMedium, + Notes: []string{"Priced from a static GCP price table (may drift from current list price)"}, + }, nil +} diff --git a/internal/pricing/gcp_disk_test.go b/internal/pricing/gcp_disk_test.go new file mode 100644 index 0000000..077540c --- /dev/null +++ b/internal/pricing/gcp_disk_test.go @@ -0,0 +1,83 @@ +package pricing + +import ( + "context" + "errors" + "testing" + + "CloudOracle/internal/iac" + "CloudOracle/internal/iac/gcp" +) + +func TestEstimateGCPComputeDisk_SizeTimesRate(t *testing.T) { + // 500GB pd-ssd @ 0.17 = 85.00. + est, err := EstimateGCPComputeDisk(&gcp.ComputeDiskAttributes{Type: "pd-ssd", SizeGB: 500}) + if err != nil { + t.Fatalf("estimate: %v", err) + } + if !approxEq(est.MonthlyUSD, 85.00) { + t.Errorf("MonthlyUSD = %.2f, want 85.00", est.MonthlyUSD) + } + if est.Confidence != ConfidenceMedium { + t.Errorf("Confidence = %q, want medium", est.Confidence) + } +} + +func TestEstimateGCPComputeDisk_DefaultsToPDStandard(t *testing.T) { + // no type → pd-standard @ 0.04; 100GB = 4.00. + est, err := EstimateGCPComputeDisk(&gcp.ComputeDiskAttributes{SizeGB: 100}) + if err != nil { + t.Fatalf("estimate: %v", err) + } + if !approxEq(est.MonthlyUSD, 4.00) { + t.Errorf("MonthlyUSD = %.2f, want 4.00 (pd-standard default)", est.MonthlyUSD) + } +} + +func TestEstimateGCPComputeDisk_MissingSizeSentinel(t *testing.T) { + _, err := EstimateGCPComputeDisk(&gcp.ComputeDiskAttributes{Type: "pd-ssd"}) + if !errors.Is(err, errUnpricedGCPDisk) { + t.Fatalf("err = %v, want errUnpricedGCPDisk", err) + } +} + +func TestEstimateChange_CreateGCPComputeDisk(t *testing.T) { + rc := iac.ResourceChange{ + Address: "google_compute_disk.data", + Mode: "managed", + Type: "google_compute_disk", + Change: iac.Change{ + Actions: []string{"create"}, + After: map[string]interface{}{"type": "pd-balanced", "size": float64(200)}, + }, + } + ce, err := EstimateChange(context.Background(), nil, rc, "us-central1") + if err != nil { + t.Fatalf("EstimateChange: %v", err) + } + if ce.Skipped { + t.Fatalf("Skipped = true, reason=%q", ce.SkipReason) + } + if !approxEq(ce.MonthlyDelta, 20.00) { // 200 * 0.10 + t.Errorf("MonthlyDelta = %.2f, want 20.00", ce.MonthlyDelta) + } +} + +func TestEstimateChange_GCPDiskMissingSizeSkipped(t *testing.T) { + rc := iac.ResourceChange{ + Address: "google_compute_disk.fromimage", + Mode: "managed", + Type: "google_compute_disk", + Change: iac.Change{ + Actions: []string{"create"}, + After: map[string]interface{}{"type": "pd-ssd"}, + }, + } + ce, err := EstimateChange(context.Background(), nil, rc, "us-central1") + if err != nil { + t.Fatalf("EstimateChange returned error, want Skipped: %v", err) + } + if !ce.Skipped { + t.Fatal("Skipped = false, want true for disk with no size") + } +} diff --git a/internal/pricing/gcp_prices.go b/internal/pricing/gcp_prices.go new file mode 100644 index 0000000..cc48174 --- /dev/null +++ b/internal/pricing/gcp_prices.go @@ -0,0 +1,68 @@ +package pricing + +import ( + _ "embed" + "encoding/json" + "fmt" +) + +// GCP has no attribute-queryable pricing API comparable to AWS's Pricing API — +// the Cloud Billing Catalog exposes SKUs whose machine-type mapping lives in +// free-text descriptions, which is brittle to match and needs a live API call +// in CI. Since v2 is an approximate pre-merge estimate with explicit confidence +// levels, we price GCP from a curated static table embedded at build time. It +// drifts from the real list price over time; estimators surface that as a +// "static price table" note and cap confidence at Medium. +// +//go:embed gcp_prices.json +var gcpPricesJSON []byte + +type gcpPriceTable struct { + ComputeHourlyUSD map[string]float64 `json:"compute_hourly_usd"` + RegionMultiplier map[string]float64 `json:"region_multiplier"` + PDGBMonthUSD map[string]float64 `json:"pd_gb_month_usd"` + CloudSQL gcpCloudSQLPrices `json:"cloudsql"` +} + +type gcpCloudSQLPrices struct { + VCPUHourlyUSD float64 `json:"vcpu_hourly_usd"` + RAMGBHourlyUSD float64 `json:"ram_gb_hourly_usd"` + SharedCoreHourlyUSD map[string]float64 `json:"shared_core_hourly_usd"` + StorageGBMonthUSD map[string]float64 `json:"storage_gb_month_usd"` +} + +// gcpPrices is parsed once at package init. A malformed embedded table is a +// build/programming error, so we panic rather than thread an error through +// every estimator constructor. +var gcpPrices = mustLoadGCPPrices() + +func mustLoadGCPPrices() gcpPriceTable { + var t gcpPriceTable + if err := json.Unmarshal(gcpPricesJSON, &t); err != nil { + panic(fmt.Sprintf("pricing: parsing embedded gcp_prices.json: %v", err)) + } + return t +} + +// gcpComputeHourly returns the on-demand hourly price for machineType in region +// and whether it was found. region defaults to a 1.0 multiplier when it isn't in +// the table; regionKnown reports whether the multiplier was an exact match so +// the caller can lower confidence for a guessed region. +func gcpComputeHourly(machineType, region string) (price float64, machineKnown bool, regionKnown bool) { + base, ok := gcpPrices.ComputeHourlyUSD[machineType] + if !ok { + return 0, false, false + } + mult, regionKnown := gcpPrices.RegionMultiplier[region] + if !regionKnown { + mult = 1.0 + } + return base * mult, true, regionKnown +} + +// gcpPDGBMonth returns the per-GB-month price for a persistent-disk type and +// whether it was found. +func gcpPDGBMonth(diskType string) (float64, bool) { + p, ok := gcpPrices.PDGBMonthUSD[diskType] + return p, ok +} diff --git a/internal/pricing/gcp_prices.json b/internal/pricing/gcp_prices.json new file mode 100644 index 0000000..9622a6a --- /dev/null +++ b/internal/pricing/gcp_prices.json @@ -0,0 +1,109 @@ +{ + "_comment": "Approximate GCP on-demand list prices, us-central1 base, USD. compute_hourly is per machine type per hour; a region_multiplier scales it. Figures are a curated snapshot and drift over time — the estimator reports Medium confidence and a 'static price table' caveat. Update from https://cloud.google.com/compute/all-pricing and https://cloud.google.com/compute/disks-image-pricing.", + "compute_hourly_usd": { + "f1-micro": 0.0076, + "g1-small": 0.0257, + "e2-micro": 0.008376, + "e2-small": 0.016751, + "e2-medium": 0.033503, + "e2-standard-2": 0.067006, + "e2-standard-4": 0.134012, + "e2-standard-8": 0.268024, + "e2-standard-16": 0.536049, + "e2-standard-32": 1.072098, + "e2-highmem-2": 0.090440, + "e2-highmem-4": 0.180880, + "e2-highmem-8": 0.361760, + "e2-highmem-16": 0.723521, + "e2-highcpu-2": 0.049424, + "e2-highcpu-4": 0.098848, + "e2-highcpu-8": 0.197696, + "e2-highcpu-16": 0.395392, + "e2-highcpu-32": 0.790784, + "n1-standard-1": 0.0475, + "n1-standard-2": 0.0950, + "n1-standard-4": 0.1900, + "n1-standard-8": 0.3800, + "n1-standard-16": 0.7600, + "n1-standard-32": 1.5200, + "n1-standard-64": 3.0400, + "n1-standard-96": 4.5600, + "n1-highmem-2": 0.1184, + "n1-highmem-4": 0.2368, + "n1-highmem-8": 0.4736, + "n1-highmem-16": 0.9472, + "n1-highcpu-2": 0.0709, + "n1-highcpu-4": 0.1418, + "n1-highcpu-8": 0.2836, + "n1-highcpu-16": 0.5672, + "n1-highcpu-32": 1.1344, + "n1-highcpu-64": 2.2688, + "n2-standard-2": 0.097118, + "n2-standard-4": 0.194236, + "n2-standard-8": 0.388472, + "n2-standard-16": 0.776944, + "n2-standard-32": 1.553888, + "n2-standard-48": 2.330832, + "n2-standard-64": 3.107776, + "n2-standard-80": 3.884720, + "n2-highmem-2": 0.131014, + "n2-highmem-4": 0.262028, + "n2-highmem-8": 0.524056, + "n2-highmem-16": 1.048112, + "n2-highcpu-2": 0.071736, + "n2-highcpu-4": 0.143472, + "n2-highcpu-8": 0.286944, + "n2-highcpu-16": 0.573888, + "n2-highcpu-32": 1.147776, + "n2d-standard-2": 0.084448, + "n2d-standard-4": 0.168896, + "n2d-standard-8": 0.337792, + "n2d-standard-16": 0.675584, + "n2d-standard-32": 1.351168, + "n2d-standard-48": 2.026752, + "c2-standard-4": 0.208772, + "c2-standard-8": 0.417544, + "c2-standard-16": 0.835088, + "c2-standard-30": 1.565790, + "c2-standard-60": 3.131580 + }, + "region_multiplier": { + "us-central1": 1.00, + "us-east1": 1.00, + "us-east4": 1.12, + "us-west1": 1.00, + "us-west2": 1.20, + "us-west3": 1.20, + "us-west4": 1.12, + "europe-west1": 1.04, + "europe-west2": 1.16, + "europe-west3": 1.16, + "europe-west4": 1.04, + "europe-north1": 1.04, + "asia-east1": 1.04, + "asia-northeast1": 1.19, + "asia-southeast1": 1.10, + "asia-south1": 1.10, + "australia-southeast1": 1.25, + "southamerica-east1": 1.25 + }, + "pd_gb_month_usd": { + "pd-standard": 0.040, + "pd-balanced": 0.100, + "pd-ssd": 0.170, + "pd-extreme": 0.125 + }, + "cloudsql": { + "_comment": "Approximate US Cloud SQL list rates for PostgreSQL/MySQL. Custom tiers priced per vCPU-hour + per GB-RAM-hour; shared-core tiers are flat hourly. REGIONAL (HA) availability doubles compute and storage. SQL Server (licensing) is not modeled.", + "vcpu_hourly_usd": 0.0413, + "ram_gb_hourly_usd": 0.0070, + "shared_core_hourly_usd": { + "db-f1-micro": 0.0150, + "db-g1-small": 0.0500 + }, + "storage_gb_month_usd": { + "PD_SSD": 0.170, + "PD_HDD": 0.090 + } + } +} diff --git a/internal/pricing/gcp_sql.go b/internal/pricing/gcp_sql.go new file mode 100644 index 0000000..eaccbbd --- /dev/null +++ b/internal/pricing/gcp_sql.go @@ -0,0 +1,144 @@ +package pricing + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "CloudOracle/internal/iac/gcp" +) + +const ( + // defaultSQLDiskSizeGB is Cloud SQL's default storage when settings.disk_size + // is omitted. + defaultSQLDiskSizeGB = 10 + defaultSQLDiskType = "PD_SSD" +) + +// errUnpricedGCPSQLTier marks a Cloud SQL instance we can't price (unknown/absent +// tier, unknown disk type). errSQLServerNotModeled marks SQL Server, whose +// per-vCPU rate bundles licensing we don't model. Both route to a Skipped change; +// the "unsupported" prefix buckets them with unsupported types in plan notes. +var ( + errUnpricedGCPSQLTier = errors.New("unsupported Cloud SQL tier not priced") + errSQLServerNotModeled = errors.New("unsupported SQL Server pricing (licensing) not modeled") +) + +// EstimateGCPSQLInstance calculates the monthly cost of a Cloud SQL instance +// (compute + storage) from the embedded static rates. Custom tiers are priced +// per vCPU-hour + per GB-RAM-hour; shared-core tiers are flat; REGIONAL (HA) +// availability doubles both compute and storage. Priced at US rates (region +// variation not modeled), Medium confidence with the static-table caveat. +func EstimateGCPSQLInstance(attrs *gcp.SQLInstanceAttributes) (Estimate, error) { + if attrs == nil { + return Estimate{}, fmt.Errorf("EstimateGCPSQLInstance: nil attrs") + } + if strings.HasPrefix(attrs.DatabaseVersion, "SQLSERVER") { + return Estimate{}, errSQLServerNotModeled + } + if attrs.Tier == "" { + return Estimate{}, fmt.Errorf("%w: no tier in plan", errUnpricedGCPSQLTier) + } + + hourly, ok := gcpSQLComputeHourly(attrs.Tier) + if !ok { + return Estimate{}, fmt.Errorf("%w: %q", errUnpricedGCPSQLTier, attrs.Tier) + } + compute := hourly * HoursPerMonth + + notes := []string{ + "Priced from a static GCP price table at US rates (may drift; other regions differ)", + } + + diskSize := attrs.DiskSizeGB + if diskSize <= 0 { + diskSize = defaultSQLDiskSizeGB + notes = append(notes, fmt.Sprintf("Disk size not in plan; defaulted to %d GB", defaultSQLDiskSizeGB)) + } + storageRate, ok := gcpSQLStorageGBMonth(attrs.DiskType) + if !ok { + return Estimate{}, fmt.Errorf("%w: unknown disk type %q", errUnpricedGCPSQLTier, attrs.DiskType) + } + storage := storageRate * float64(diskSize) + + if attrs.Regional { + compute *= 2 + storage *= 2 + notes = append(notes, "REGIONAL (HA) availability doubles compute and storage") + } + + return Estimate{ + MonthlyUSD: compute + storage, + Currency: "USD", + Breakdown: []LineItem{ + {Component: "Compute", MonthlyUSD: compute}, + {Component: "Storage", MonthlyUSD: storage}, + }, + Confidence: ConfidenceMedium, + Notes: notes, + }, nil +} + +// gcpSQLComputeHourly returns the compute hourly rate for a Cloud SQL tier. +// Shared-core tiers (db-f1-micro, db-g1-small) are a flat lookup; everything +// else is parsed into vCPU + RAM and priced per-unit. +func gcpSQLComputeHourly(tier string) (float64, bool) { + cs := gcpPrices.CloudSQL + if rate, ok := cs.SharedCoreHourlyUSD[tier]; ok { + return rate, true + } + vcpu, memGB, ok := parseSQLTier(tier) + if !ok { + return 0, false + } + return float64(vcpu)*cs.VCPUHourlyUSD + memGB*cs.RAMGBHourlyUSD, true +} + +// parseSQLTier extracts vCPU count and RAM (GB) from a non-shared Cloud SQL tier: +// +// - db-custom-- → vcpu, mem_mb/1024 +// - db-n1-standard- → n vCPU, 3.75 GB each +// - db-n1-highmem- → n vCPU, 6.5 GB each +// - db-n1-highcpu- → n vCPU, 0.9 GB each +// +// Returns ok=false for any other shape so the caller can Skip it. +func parseSQLTier(tier string) (vcpu int, memGB float64, ok bool) { + parts := strings.Split(tier, "-") + if len(parts) != 4 || parts[0] != "db" { + return 0, 0, false + } + switch parts[1] { + case "custom": + v, err1 := strconv.Atoi(parts[2]) + m, err2 := strconv.Atoi(parts[3]) + if err1 != nil || err2 != nil || v <= 0 || m <= 0 { + return 0, 0, false + } + return v, float64(m) / 1024.0, true + case "n1": + n, err := strconv.Atoi(parts[3]) + if err != nil || n <= 0 { + return 0, 0, false + } + switch parts[2] { + case "standard": + return n, 3.75 * float64(n), true + case "highmem": + return n, 6.5 * float64(n), true + case "highcpu": + return n, 0.9 * float64(n), true + } + } + return 0, 0, false +} + +// gcpSQLStorageGBMonth returns the Cloud SQL storage $/GB-month for a disk type, +// defaulting an empty type to PD_SSD. +func gcpSQLStorageGBMonth(diskType string) (float64, bool) { + if diskType == "" { + diskType = defaultSQLDiskType + } + p, ok := gcpPrices.CloudSQL.StorageGBMonthUSD[diskType] + return p, ok +} diff --git a/internal/pricing/gcp_sql_test.go b/internal/pricing/gcp_sql_test.go new file mode 100644 index 0000000..e9ab6c9 --- /dev/null +++ b/internal/pricing/gcp_sql_test.go @@ -0,0 +1,146 @@ +package pricing + +import ( + "context" + "errors" + "testing" + + "CloudOracle/internal/iac" + "CloudOracle/internal/iac/gcp" +) + +func TestParseSQLTier(t *testing.T) { + cases := []struct { + tier string + vcpu int + memGB float64 + ok bool + }{ + {"db-custom-2-8192", 2, 8.0, true}, // 8192 MB = 8 GB + {"db-custom-4-16384", 4, 16.0, true}, + {"db-n1-standard-2", 2, 7.5, true}, // 3.75 * 2 + {"db-n1-highmem-4", 4, 26.0, true}, // 6.5 * 4 + {"db-n1-highcpu-8", 8, 7.2, true}, // 0.9 * 8 + {"db-f1-micro", 0, 0, false}, // shared-core, not a custom tier + {"garbage", 0, 0, false}, + {"db-custom-0-1024", 0, 0, false}, // zero vcpu rejected + } + for _, c := range cases { + v, m, ok := parseSQLTier(c.tier) + if ok != c.ok || (ok && (v != c.vcpu || !approxEq(m, c.memGB))) { + t.Errorf("parseSQLTier(%q) = (%d, %.2f, %v), want (%d, %.2f, %v)", + c.tier, v, m, ok, c.vcpu, c.memGB, c.ok) + } + } +} + +func TestEstimateGCPSQLInstance_CustomTierComputePlusStorage(t *testing.T) { + // db-custom-2-8192: 2 vCPU * 0.0413 + 8 GB * 0.0070 = 0.0826 + 0.056 = 0.1386/hr + // * 730 = 101.18. Storage 50GB PD_SSD * 0.17 = 8.50. Total 109.68. + est, err := EstimateGCPSQLInstance(&gcp.SQLInstanceAttributes{ + DatabaseVersion: "POSTGRES_15", + Tier: "db-custom-2-8192", + DiskSizeGB: 50, + DiskType: "PD_SSD", + }) + if err != nil { + t.Fatalf("estimate: %v", err) + } + if !approxEq(est.MonthlyUSD, 101.18+8.50) { + t.Errorf("MonthlyUSD = %.2f, want ~109.68", est.MonthlyUSD) + } + if len(est.Breakdown) != 2 { + t.Errorf("breakdown = %+v, want Compute+Storage", est.Breakdown) + } +} + +func TestEstimateGCPSQLInstance_RegionalDoublesCost(t *testing.T) { + zonal, _ := EstimateGCPSQLInstance(&gcp.SQLInstanceAttributes{ + Tier: "db-custom-2-8192", DiskSizeGB: 50, DiskType: "PD_SSD", + }) + regional, _ := EstimateGCPSQLInstance(&gcp.SQLInstanceAttributes{ + Tier: "db-custom-2-8192", DiskSizeGB: 50, DiskType: "PD_SSD", Regional: true, + }) + if !approxEq(regional.MonthlyUSD, zonal.MonthlyUSD*2) { + t.Errorf("regional = %.2f, want 2x zonal %.2f", regional.MonthlyUSD, zonal.MonthlyUSD) + } +} + +func TestEstimateGCPSQLInstance_SharedCoreFlatRate(t *testing.T) { + // db-f1-micro flat 0.0150/hr * 730 = 10.95; storage defaults to 10GB PD_SSD = 1.70. + est, err := EstimateGCPSQLInstance(&gcp.SQLInstanceAttributes{ + Tier: "db-f1-micro", + }) + if err != nil { + t.Fatalf("estimate: %v", err) + } + if !approxEq(est.MonthlyUSD, 10.95+1.70) { + t.Errorf("MonthlyUSD = %.2f, want ~12.65", est.MonthlyUSD) + } +} + +func TestEstimateGCPSQLInstance_SQLServerSkipped(t *testing.T) { + _, err := EstimateGCPSQLInstance(&gcp.SQLInstanceAttributes{ + DatabaseVersion: "SQLSERVER_2019_STANDARD", Tier: "db-custom-2-8192", + }) + if !errors.Is(err, errSQLServerNotModeled) { + t.Fatalf("err = %v, want errSQLServerNotModeled", err) + } +} + +func TestEstimateGCPSQLInstance_UnknownTierSentinel(t *testing.T) { + _, err := EstimateGCPSQLInstance(&gcp.SQLInstanceAttributes{Tier: "db-mystery-9"}) + if !errors.Is(err, errUnpricedGCPSQLTier) { + t.Fatalf("err = %v, want errUnpricedGCPSQLTier", err) + } +} + +func TestEstimateChange_CreateGCPSQLInstance(t *testing.T) { + rc := iac.ResourceChange{ + Address: "google_sql_database_instance.main", + Mode: "managed", + Type: "google_sql_database_instance", + Change: iac.Change{ + Actions: []string{"create"}, + After: map[string]interface{}{ + "database_version": "POSTGRES_15", + "settings": []interface{}{map[string]interface{}{ + "tier": "db-custom-2-8192", + "disk_size": float64(50), + }}, + }, + }, + } + ce, err := EstimateChange(context.Background(), nil, rc, "us-central1") + if err != nil { + t.Fatalf("EstimateChange: %v", err) + } + if ce.Skipped { + t.Fatalf("Skipped = true, reason=%q", ce.SkipReason) + } + if ce.MonthlyDelta <= 0 { + t.Errorf("MonthlyDelta = %.2f, want > 0", ce.MonthlyDelta) + } +} + +func TestEstimateChange_GCPSQLServerSkipped(t *testing.T) { + rc := iac.ResourceChange{ + Address: "google_sql_database_instance.mssql", + Mode: "managed", + Type: "google_sql_database_instance", + Change: iac.Change{ + Actions: []string{"create"}, + After: map[string]interface{}{ + "database_version": "SQLSERVER_2019_STANDARD", + "settings": []interface{}{map[string]interface{}{"tier": "db-custom-4-16384"}}, + }, + }, + } + ce, err := EstimateChange(context.Background(), nil, rc, "us-central1") + if err != nil { + t.Fatalf("EstimateChange returned error, want Skipped: %v", err) + } + if !ce.Skipped { + t.Fatal("Skipped = false, want true for SQL Server") + } +}